//! Custom `tutdock` URI scheme protocol for serving video, subtitles, and fonts. //! //! On Windows the actual URL seen by the handler is: //! `http://tutdock.localhost/` (Tauri v2 Windows behaviour). //! On macOS / Linux it would be `tutdock://localhost/`. //! //! Routes: //! /video/{index} — video file with Range support //! /sub/{libid}/{fid} — stored VTT subtitle //! /fonts.css — cached Google Fonts CSS //! /fonts/{filename} — cached font file //! /fa.css — cached Font Awesome CSS //! /fa/webfonts/{filename} — cached FA webfont use std::fs::{self, File}; use std::io::{Read, Seek, SeekFrom}; use std::path::PathBuf; use std::sync::Mutex; use tauri::http::{self, header, StatusCode}; use tauri::Manager; use crate::library::Library; use crate::AppPaths; /// Maximum chunk size for full-file responses (4 MB). const MAX_CHUNK: u64 = 4 * 1024 * 1024; /// CORS header name. const CORS: &str = "Access-Control-Allow-Origin"; /// Register the `tutdock` custom protocol on the builder. pub fn register_protocol(builder: tauri::Builder) -> tauri::Builder { builder.register_uri_scheme_protocol("tutdock", move |ctx, request| { let app = ctx.app_handle(); handle_request(app, &request) }) } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- fn handle_request( app: &tauri::AppHandle, request: &http::Request>, ) -> http::Response> { let uri = request.uri(); let path = uri.path(); // Strip leading slash. let path = path.strip_prefix('/').unwrap_or(path); // Route. if let Some(rest) = path.strip_prefix("video/") { return handle_video(app, request, rest); } if let Some(rest) = path.strip_prefix("sub/") { return handle_subtitle(app, rest); } if path == "fonts.css" { return handle_fonts_css(app); } if let Some(rest) = path.strip_prefix("fonts/") { return handle_font_file(app, rest); } if path == "fa.css" { return handle_fa_css(app); } if let Some(rest) = path.strip_prefix("fa/webfonts/") { return handle_fa_webfont(app, rest); } not_found() } // --------------------------------------------------------------------------- // Video — with Range request support // --------------------------------------------------------------------------- fn handle_video( app: &tauri::AppHandle, request: &http::Request>, index_str: &str, ) -> http::Response> { let index: usize = match index_str.parse() { Ok(i) => i, Err(_) => return bad_request("invalid index"), }; let state = app.state::>(); let file_path = { let lib = match state.lock() { Ok(l) => l, Err(_) => return internal_error("lock error"), }; match lib.get_video_path(index) { Ok(p) => p, Err(_) => return not_found(), } }; let metadata = match fs::metadata(&file_path) { Ok(m) => m, Err(_) => return not_found(), }; let file_size = metadata.len(); let ext = file_path .extension() .and_then(|e| e.to_str()) .unwrap_or(""); let mime = mime_for_video(ext); // Check for Range header. let range_header = request .headers() .get(header::RANGE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); match range_header { Some(range_str) => serve_range(&file_path, file_size, &range_str, mime), None => serve_full(&file_path, file_size, mime), } } /// Serve the full file (or first MAX_CHUNK bytes). fn serve_full(path: &PathBuf, file_size: u64, mime: &str) -> http::Response> { let read_len = file_size.min(MAX_CHUNK) as usize; let mut buf = vec![0u8; read_len]; let mut file = match File::open(path) { Ok(f) => f, Err(_) => return internal_error("cannot open file"), }; if file.read_exact(&mut buf).is_err() { return internal_error("read error"); } http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, mime) .header(header::CONTENT_LENGTH, file_size.to_string()) .header(header::ACCEPT_RANGES, "bytes") .header(header::CACHE_CONTROL, "no-store") .header(CORS, "*") .body(buf) .unwrap_or_else(|_| internal_error("response build error")) } /// Serve a byte range (206 Partial Content). fn serve_range( path: &PathBuf, file_size: u64, range_str: &str, mime: &str, ) -> http::Response> { let (start, end) = match parse_range(range_str, file_size) { Some(r) => r, None => return range_not_satisfiable(file_size), }; let length = end - start + 1; let mut buf = vec![0u8; length as usize]; let mut file = match File::open(path) { Ok(f) => f, Err(_) => return internal_error("cannot open file"), }; if file.seek(SeekFrom::Start(start)).is_err() { return internal_error("seek error"); } if file.read_exact(&mut buf).is_err() { return internal_error("read error"); } http::Response::builder() .status(StatusCode::PARTIAL_CONTENT) .header(header::CONTENT_TYPE, mime) .header( header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_size), ) .header(header::CONTENT_LENGTH, length.to_string()) .header(header::ACCEPT_RANGES, "bytes") .header(header::CACHE_CONTROL, "no-store") .header(CORS, "*") .body(buf) .unwrap_or_else(|_| internal_error("response build error")) } /// Parse `Range: bytes=START-END` header. fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> { let s = header.strip_prefix("bytes=")?; let (start_s, end_s) = s.split_once('-')?; let start = if start_s.is_empty() { let suffix: u64 = end_s.parse().ok()?; file_size.checked_sub(suffix)? } else { start_s.parse().ok()? }; let end = if end_s.is_empty() { file_size - 1 } else { let e: u64 = end_s.parse().ok()?; e.min(file_size - 1) }; if start > end || start >= file_size { return None; } Some((start, end)) } // --------------------------------------------------------------------------- // Subtitles // --------------------------------------------------------------------------- fn handle_subtitle(app: &tauri::AppHandle, rest: &str) -> http::Response> { // rest is a VTT filename like "{fid}_{name}.vtt" let filename = rest; // Validate: no path traversal if filename.contains("..") || filename.contains('/') || filename.contains('\\') || filename.is_empty() { return not_found(); } let paths = app.state::(); let vtt_path = paths.subs_dir.join(filename); // Ensure resolved path is still inside subs_dir if let (Ok(base), Ok(resolved)) = (paths.subs_dir.canonicalize(), vtt_path.canonicalize()) { if !resolved.starts_with(&base) { return not_found(); } } else { return not_found(); } let data = match fs::read(&vtt_path) { Ok(d) => d, Err(_) => return not_found(), }; http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/vtt; charset=utf-8") .header(header::CACHE_CONTROL, "no-store") .header(CORS, "*") .body(data) .unwrap_or_else(|_| internal_error("response build error")) } // --------------------------------------------------------------------------- // Fonts // --------------------------------------------------------------------------- fn handle_fonts_css(app: &tauri::AppHandle) -> http::Response> { let paths = app.state::(); let css_path = paths.fonts_dir.join("fonts.css"); let data = if css_path.exists() { fs::read(&css_path).unwrap_or_default() } else { Vec::new() }; http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/css; charset=utf-8") .header(header::CACHE_CONTROL, "no-store") .body(data) .unwrap_or_else(|_| internal_error("response build error")) } fn handle_font_file(app: &tauri::AppHandle, filename: &str) -> http::Response> { let paths = app.state::(); serve_static_file(&paths.fonts_dir, filename) } fn handle_fa_css(app: &tauri::AppHandle) -> http::Response> { let paths = app.state::(); let css_path = paths.fa_dir.join("fa.css"); let data = if css_path.exists() { fs::read(&css_path).unwrap_or_default() } else { Vec::new() }; http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/css; charset=utf-8") .header(header::CACHE_CONTROL, "no-store") .body(data) .unwrap_or_else(|_| internal_error("response build error")) } fn handle_fa_webfont(app: &tauri::AppHandle, filename: &str) -> http::Response> { let paths = app.state::(); let webfonts_dir = paths.fa_dir.join("webfonts"); serve_static_file(&webfonts_dir, filename) } // --------------------------------------------------------------------------- // Static file helper // --------------------------------------------------------------------------- fn serve_static_file(base_dir: &PathBuf, filename: &str) -> http::Response> { if filename.contains("..") || filename.contains('\\') || filename.contains('/') { return not_found(); } let file_path = base_dir.join(filename); let canonical_base = match base_dir.canonicalize() { Ok(p) => p, Err(_) => return not_found(), }; let canonical_file = match file_path.canonicalize() { Ok(p) => p, Err(_) => return not_found(), }; if !canonical_file.starts_with(&canonical_base) { return not_found(); } let data = match fs::read(&file_path) { Ok(d) => d, Err(_) => return not_found(), }; let mime = guess_mime(filename); http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, mime) .header(header::CACHE_CONTROL, "no-store") .header(CORS, "*") .body(data) .unwrap_or_else(|_| internal_error("response build error")) } // --------------------------------------------------------------------------- // MIME helpers // --------------------------------------------------------------------------- fn mime_for_video(ext: &str) -> &'static str { match ext.to_ascii_lowercase().as_str() { "mp4" | "m4v" => "video/mp4", "webm" => "video/webm", "ogv" => "video/ogg", "mov" => "video/quicktime", "mkv" => "video/x-matroska", "avi" => "video/x-msvideo", "mpeg" | "mpg" => "video/mpeg", "m2ts" | "mts" => "video/mp2t", _ => "application/octet-stream", } } fn guess_mime(filename: &str) -> &'static str { let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); match ext.as_str() { "css" => "text/css; charset=utf-8", "woff" => "font/woff", "woff2" => "font/woff2", "ttf" => "font/ttf", "otf" => "font/otf", "eot" => "application/vnd.ms-fontobject", "svg" => "image/svg+xml", _ => "application/octet-stream", } } // --------------------------------------------------------------------------- // Error responses // --------------------------------------------------------------------------- fn not_found() -> http::Response> { http::Response::builder() .status(StatusCode::NOT_FOUND) .header(header::CONTENT_TYPE, "text/plain") .body(b"Not Found".to_vec()) .unwrap() } fn bad_request(msg: &str) -> http::Response> { http::Response::builder() .status(StatusCode::BAD_REQUEST) .header(header::CONTENT_TYPE, "text/plain") .body(msg.as_bytes().to_vec()) .unwrap() } fn internal_error(msg: &str) -> http::Response> { http::Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .header(header::CONTENT_TYPE, "text/plain") .body(msg.as_bytes().to_vec()) .unwrap() } fn range_not_satisfiable(file_size: u64) -> http::Response> { http::Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{}", file_size)) .body(Vec::new()) .unwrap() }