diff --git a/src-tauri/src/asset_server.rs b/src-tauri/src/asset_server.rs new file mode 100644 index 0000000..b0388ca --- /dev/null +++ b/src-tauri/src/asset_server.rs @@ -0,0 +1,199 @@ +use std::{ + io::Read, + path::{Component, Path, PathBuf}, +}; + +use log::*; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +const MAX_HEADER_SIZE: usize = 16 * 1024; +const FILE_READ_BUF_SIZE: usize = 64 * 1024; + +/// Serves files from `root` over HTTP so Proton/Wine can fetch local +/// `file://` assets through the same loopback URL used for proxied CDN downloads. +pub async fn run(listener: TcpListener, root: PathBuf) { + info!( + "Local asset server listening on {} ({})", + listener.local_addr().unwrap(), + root.display() + ); + + loop { + match listener.accept().await { + Ok((stream, addr)) => { + let root = root.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(stream, &root).await { + debug!("Local asset server error from {}: {}", addr, e); + } + }); + } + Err(e) => { + error!("Local asset server accept error: {}", e); + } + } + } +} + +async fn handle_client(mut stream: TcpStream, root: &Path) -> std::io::Result<()> { + let header_bytes = read_headers(&mut stream).await?; + let header_text = String::from_utf8_lossy(&header_bytes); + let request_line = header_text.lines().next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let raw_path = parts.next().unwrap_or("/"); + + if method != "GET" && method != "HEAD" { + write_empty_response(&mut stream, "405 Method Not Allowed").await?; + return Ok(()); + } + + let path_only = raw_path.split(['?', '#']).next().unwrap_or("/"); + let Some(file_path) = resolve_asset_path(root, path_only) else { + write_empty_response(&mut stream, "400 Bad Request").await?; + return Ok(()); + }; + + let mut file = match std::fs::File::open(&file_path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + debug!("Local asset not found: {}", file_path.display()); + write_empty_response(&mut stream, "404 Not Found").await?; + return Ok(()); + } + Err(e) => { + warn!("Failed to open {}: {}", file_path.display(), e); + write_empty_response(&mut stream, "500 Internal Server Error").await?; + return Ok(()); + } + }; + + let metadata = file.metadata()?; + if metadata.is_dir() { + write_empty_response(&mut stream, "404 Not Found").await?; + return Ok(()); + } + + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n", + metadata.len() + ); + stream.write_all(headers.as_bytes()).await?; + + if method == "GET" { + let mut buf = vec![0u8; FILE_READ_BUF_SIZE]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + stream.write_all(&buf[..n]).await?; + } + } + + stream.shutdown().await.ok(); + Ok(()) +} + +async fn read_headers(stream: &mut TcpStream) -> std::io::Result> { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + while buf.len() < MAX_HEADER_SIZE { + let n = stream.read(&mut byte).await?; + if n == 0 { + break; + } + buf.push(byte[0]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.windows(2).any(|w| w == b"\n\n") { + return Ok(buf); + } + } + if buf.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "No data received", + )) + } else { + Ok(buf) + } +} + +async fn write_empty_response(stream: &mut TcpStream, status: &str) -> std::io::Result<()> { + let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + stream.write_all(response.as_bytes()).await?; + stream.shutdown().await.ok(); + Ok(()) +} + +pub(crate) fn resolve_asset_path(root: &Path, request_path: &str) -> Option { + let trimmed = request_path.trim_start_matches('/'); + if trimmed.is_empty() { + return None; + } + + let decoded = percent_decode(trimmed); + let relative = Path::new(&decoded); + if relative.is_absolute() + || relative + .components() + .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) + { + return None; + } + + Some(root.join(relative)) +} + +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(value) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(value); + i += 3; + continue; + } + if bytes[i] == b'+' { + out.push(b' '); + i += 1; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_rejects_parent_dir() { + let root = Path::new("/tmp/assets"); + assert!(resolve_asset_path(root, "/../secret").is_none()); + assert!(resolve_asset_path(root, "/foo/../../secret").is_none()); + } + + #[test] + fn resolve_joins_relative_name() { + let root = PathBuf::from("assets"); + let path = resolve_asset_path(&root, "/Map_01_01.unity3d").unwrap(); + assert_eq!(path, root.join("Map_01_01.unity3d")); + } + + #[test] + fn resolve_decodes_percent_encoding() { + let root = PathBuf::from("assets"); + let path = resolve_asset_path(&root, "/foo%20bar.unity3d").unwrap(); + assert_eq!(path, root.join("foo bar.unity3d")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bdb5c32..a516ba0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod asset_server; mod config; mod endpoint; mod state; @@ -549,17 +550,32 @@ async fn prep_launch( main_url = offline_main_url; } } else if state.config.launcher.proxy_asset_downloads { - let mut proxy = TcpProxy::default(); - proxy.set_base_path(asset_url.clone()); let listener = TcpListener::bind("127.0.0.1:0").await?; let proxy_addr = listener.local_addr()?; let new_asset_url = format!("http://{}", proxy_addr); - asset_url = new_asset_url; - let handle = tokio::spawn(async move { - proxy.run(&listener).await; - }); - state.proxy = Some(handle); + if util::is_file_uri(&asset_url) { + let local_dir = util::file_uri_to_path(&asset_url)?; + if !local_dir.is_dir() { + return Err( + format!("Asset directory not found: {}", local_dir.display()).into(), + ); + } + debug!("Proxying local file assets from {}", local_dir.display()); + let handle = tokio::spawn(async move { + asset_server::run(listener, local_dir).await; + }); + state.proxy = Some(handle); + } else { + let mut proxy = TcpProxy::default(); + proxy.set_base_path(asset_url.clone()); + let handle = tokio::spawn(async move { + proxy.run(&listener).await; + }); + state.proxy = Some(handle); + } + + asset_url = new_asset_url; } // Upgrade the main URL to HTTPS, if it's available, since ffrunner supports it diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 70f2a87..1ba5e1c 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -295,6 +295,119 @@ pub(crate) fn get_path_as_file_uri(path: &Path) -> String { uri.replace("\\", "/") } +pub(crate) fn is_file_uri(uri: &str) -> bool { + uri.len() >= 5 && uri[..5].eq_ignore_ascii_case("file:") +} + +/// Converts a `file:` URI into a filesystem path. +pub(crate) fn file_uri_to_path(uri: &str) -> Result { + if !is_file_uri(uri) { + return Err(format!("Not a file URI: {}", uri).into()); + } + + let normalized = uri.replace('\\', "/"); + let after_scheme = normalized + .split_once(':') + .map(|(_, rest)| rest) + .unwrap_or(""); + + let path_part = if let Some(rest) = after_scheme.strip_prefix("//") { + if rest.starts_with('/') { + rest + } else { + // file://localhost/path or file://host/path - skip the host + rest.find('/').map(|i| &rest[i..]).unwrap_or("") + } + } else { + after_scheme + }; + + if path_part.is_empty() { + return Err(format!("Invalid file URI: {}", uri).into()); + } + + let decoded = percent_decode_uri_path(path_part); + + #[cfg(windows)] + { + // file:///C:/foo -> /C:/foo -> C:/foo + let windows_path = decoded + .strip_prefix('/') + .filter(|s| s.chars().nth(1) == Some(':')) + .unwrap_or(&decoded); + Ok(PathBuf::from(windows_path)) + } + + #[cfg(not(windows))] + { + Ok(PathBuf::from(decoded)) + } +} + +fn percent_decode_uri_path(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(value) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(value); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod file_uri_tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn detects_file_uri() { + assert!(is_file_uri("file:///tmp/assets")); + assert!(is_file_uri("FILE:///C:/builds")); + assert!(!is_file_uri("http://cdn.example.com/build")); + } + + #[test] + fn parses_unix_file_uri() { + let path = file_uri_to_path("file:///tmp/assets").unwrap(); + if cfg!(windows) { + assert!(path.ends_with("tmp\\assets") || path.ends_with("tmp/assets")); + } else { + assert_eq!(path, PathBuf::from("/tmp/assets")); + } + } + + #[test] + fn parses_windows_file_uri() { + let path = file_uri_to_path("file:///C:/builds/assets").unwrap(); + if cfg!(windows) { + assert_eq!(path, PathBuf::from("C:/builds/assets")); + } else { + assert_eq!(path, PathBuf::from("/C:/builds/assets")); + } + } + + #[test] + fn roundtrips_local_path() { + let original = std::env::temp_dir(); + let uri = get_path_as_file_uri(&original); + let parsed = file_uri_to_path(&uri).unwrap(); + assert_eq!( + parsed.components().collect::>(), + original.components().collect::>() + ); + } +} + pub(crate) fn get_version_name(version: &Version) -> String { if let Some(name) = version.get_name() { name.to_string()