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.
414 lines
13 KiB
Rust
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()
|
|
}
|