Files
tutorialvault/src-tauri/src/video_protocol.rs
Your Name 9c8d7d94cd TutorialVault: complete Tauri v2 port with runtime fixes
Rename from TutorialDock to TutorialVault. Remove legacy Python app
and scripts. Fix video playback, subtitles, metadata display, window
state persistence, and auto-download of ffmpeg/ffprobe on first run.
Bundle fonts via npm instead of runtime download.
2026-02-19 12:44:57 +02:00

414 lines
13 KiB
Rust

//! 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;
/// 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::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")
.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<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")
.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<Vec<u8>> {
// 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::<AppPaths>();
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<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")
.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<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()
}