Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions src-tauri/src/asset_server.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>> {
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<PathBuf> {
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"));
}
}
30 changes: 23 additions & 7 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod asset_server;
mod config;
mod endpoint;
mod state;
Expand Down Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions src-tauri/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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::<Vec<_>>(),
original.components().collect::<Vec<_>>()
);
}
}

pub(crate) fn get_version_name(version: &Version) -> String {
if let Some(name) = version.get_name() {
name.to_string()
Expand Down