feat: implement video_protocol.rs, commands.rs, wire up main.rs, and index.html
- video_protocol.rs: tutdock:// custom protocol with HTTP Range support for video streaming, subtitle/font serving with path traversal protection - commands.rs: all 26 Tauri command handlers as thin wrappers - main.rs: full Tauri bootstrap with state management, window restore, async font caching, and ffmpeg discovery - index.html: complete HTML markup extracted from Python app - lib.rs: updated with all module declarations and AppPaths struct
This commit is contained in:
404
src-tauri/src/video_protocol.rs
Normal file
404
src-tauri/src/video_protocol.rs
Normal file
@@ -0,0 +1,404 @@
|
||||
//! Custom `tutdock` URI scheme protocol for serving video, subtitles, and fonts.
|
||||
//!
|
||||
//! On Windows the actual URL seen by the handler is:
|
||||
//! `http://tutdock.localhost/<path>` (Tauri v2 Windows behaviour).
|
||||
//! On macOS / Linux it would be `tutdock://localhost/<path>`.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// Register the `tutdock` custom protocol on the builder.
|
||||
pub fn register_protocol(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
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<Vec<u8>>,
|
||||
) -> http::Response<Vec<u8>> {
|
||||
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<Vec<u8>>,
|
||||
index_str: &str,
|
||||
) -> http::Response<Vec<u8>> {
|
||||
let index: usize = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
Err(_) => return bad_request("invalid index"),
|
||||
};
|
||||
|
||||
let state = app.state::<Mutex<Library>>();
|
||||
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<Vec<u8>> {
|
||||
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")
|
||||
.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<Vec<u8>> {
|
||||
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")
|
||||
.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<Vec<u8>> {
|
||||
// Path: sub/{libid}/{fid}
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() != 2 {
|
||||
return not_found();
|
||||
}
|
||||
let fid = parts[1];
|
||||
|
||||
if !fid.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return not_found();
|
||||
}
|
||||
|
||||
let paths = app.state::<AppPaths>();
|
||||
let vtt_path = paths.subs_dir.join(format!("{}.vtt", fid));
|
||||
|
||||
if !vtt_path.exists() {
|
||||
return not_found();
|
||||
}
|
||||
|
||||
let data = match fs::read(&vtt_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return internal_error("read error"),
|
||||
};
|
||||
|
||||
http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/vtt; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.body(data)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_fonts_css(app: &tauri::AppHandle) -> http::Response<Vec<u8>> {
|
||||
let paths = app.state::<AppPaths>();
|
||||
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<Vec<u8>> {
|
||||
let paths = app.state::<AppPaths>();
|
||||
serve_static_file(&paths.fonts_dir, filename)
|
||||
}
|
||||
|
||||
fn handle_fa_css(app: &tauri::AppHandle) -> http::Response<Vec<u8>> {
|
||||
let paths = app.state::<AppPaths>();
|
||||
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<Vec<u8>> {
|
||||
let paths = app.state::<AppPaths>();
|
||||
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<Vec<u8>> {
|
||||
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")
|
||||
.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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
http::Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file_size))
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
Reference in New Issue
Block a user