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.
This commit is contained in:
Your Name
2026-02-19 12:44:57 +02:00
parent 52e334ebfe
commit b9815d0e45
25 changed files with 11665 additions and 7364 deletions

5622
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
[package]
name = "tutorialdock"
name = "tutorialvault"
version = "0.1.0"
description = "TutorialDock - Video Tutorial Library Manager"
description = "TutorialVault - Video Tutorial Library Manager"
authors = []
edition = "2021"
[lib]
name = "tutorialdock_lib"
name = "tutorialvault_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]

View File

@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for TutorialDock",
"description": "Default capabilities for TutorialVault",
"windows": ["main"],
"permissions": [
"core:default",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for TutorialVault","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -519,7 +519,11 @@ impl Library {
let folder = self
.root
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.map(|p| {
let s = p.to_string_lossy().to_string();
// Strip Windows extended-length path prefix.
s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string()
})
.unwrap_or_default();
let count = self.fids.len();
@@ -623,6 +627,14 @@ impl Library {
/// Find the next unfinished video index after the current position.
fn _compute_next_up(&self, current_index: Option<usize>) -> Value {
let make_result = |i: usize| -> Value {
let fid = &self.fids[i];
let title = self.fid_to_rel.get(fid)
.map(|r| pretty_title_from_filename(r))
.unwrap_or_default();
json!({"index": i, "title": title})
};
let start = current_index.map(|i| i + 1).unwrap_or(0);
for i in start..self.fids.len() {
let fid = &self.fids[i];
@@ -633,7 +645,7 @@ impl Library {
.map(|m| m.finished)
.unwrap_or(false);
if !finished {
return json!(i);
return make_result(i);
}
}
// Wrap around from beginning.
@@ -647,7 +659,7 @@ impl Library {
.map(|m| m.finished)
.unwrap_or(false);
if !finished {
return json!(i);
return make_result(i);
}
}
Value::Null
@@ -914,7 +926,7 @@ impl Library {
None => return json!({"ok": false, "error": "current fid not in library"}),
};
let mut result = self._basic_file_meta(&fid);
let basic = self._basic_file_meta(&fid);
// Probe if not cached.
if !self.meta_cache.contains_key(&fid) {
@@ -929,6 +941,12 @@ impl Library {
}
}
let mut result = json!({
"ok": true,
"basic": basic,
"ffprobe_found": self.ffprobe.is_some(),
});
if let Some(cached) = self.meta_cache.get(&fid) {
if let Ok(meta_val) = serde_json::to_value(cached) {
result
@@ -938,10 +956,6 @@ impl Library {
}
}
result
.as_object_mut()
.unwrap()
.insert("ok".to_string(), json!(true));
result
}
@@ -949,6 +963,23 @@ impl Library {
// Subtitle methods
// -----------------------------------------------------------------------
/// Build a protocol URL for a stored subtitle VTT path.
/// The `vtt` field is like `"subtitles/{fid}_{name}.vtt"` — extract the filename.
fn _sub_url(vtt: &str) -> String {
let filename = vtt.rsplit('/').next().unwrap_or(vtt);
format!("http://tutdock.localhost/sub/{}", filename)
}
/// Build a successful subtitle JSON response with `has`, `url`, and `label`.
fn _sub_response(vtt: &str, label: &str) -> Value {
json!({
"ok": true,
"has": true,
"url": Self::_sub_url(vtt),
"label": label,
})
}
/// Get subtitle for the current video.
///
/// Priority: stored subtitle -> sidecar -> embedded.
@@ -968,12 +999,7 @@ impl Library {
if let Some(ref sub_ref) = meta.subtitle {
let vtt_path = state_dir.join(&sub_ref.vtt);
if vtt_path.exists() {
return json!({
"ok": true,
"source": "stored",
"vtt": sub_ref.vtt,
"label": sub_ref.label,
});
return Self::_sub_response(&sub_ref.vtt, &sub_ref.label);
}
}
}
@@ -993,12 +1019,7 @@ impl Library {
});
}
self.save_state();
return json!({
"ok": true,
"source": "sidecar",
"vtt": stored.vtt,
"label": stored.label,
});
return Self::_sub_response(&stored.vtt, &stored.label);
}
}
}
@@ -1026,12 +1047,7 @@ impl Library {
});
}
self.save_state();
return json!({
"ok": true,
"source": "embedded",
"vtt": stored.vtt,
"label": stored.label,
});
return Self::_sub_response(&stored.vtt, &stored.label);
}
}
}
@@ -1039,7 +1055,7 @@ impl Library {
}
}
json!({"ok": true, "source": "none"})
json!({"ok": true, "has": false})
}
/// Store a user-selected subtitle file for the current video.
@@ -1064,11 +1080,7 @@ impl Library {
});
}
self.save_state();
json!({
"ok": true,
"vtt": stored.vtt,
"label": stored.label,
})
Self::_sub_response(&stored.vtt, &stored.label)
}
None => {
json!({"ok": false, "error": "unsupported subtitle format"})
@@ -1154,11 +1166,7 @@ impl Library {
});
}
self.save_state();
json!({
"ok": true,
"vtt": stored.vtt,
"label": stored.label,
})
Self::_sub_response(&stored.vtt, &stored.label)
}
Err(e) => {
json!({"ok": false, "error": e})
@@ -1288,10 +1296,15 @@ impl Library {
});
for (label, path, _) in &found {
let ext = std::path::Path::new(path)
.extension()
.map(|e| e.to_string_lossy().to_uppercase())
.unwrap_or_default();
sidecar_subs.push(json!({
"type": "sidecar",
"label": label,
"path": path,
"format": ext,
}));
}
}
@@ -1358,11 +1371,7 @@ impl Library {
});
}
self.save_state();
json!({
"ok": true,
"vtt": stored.vtt,
"label": stored.label,
})
Self::_sub_response(&stored.vtt, &stored.label)
}
None => {
json!({"ok": false, "error": "unsupported subtitle format or read error"})
@@ -1572,7 +1581,10 @@ impl Library {
let folder = path
.parent()
.map(|p| p.to_string_lossy().to_string())
.map(|p| {
let s = p.to_string_lossy().to_string();
s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string()
})
.unwrap_or_default();
return json!({

View File

@@ -2,7 +2,7 @@
use std::sync::Mutex;
use tauri::Manager;
use tutorialdock_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
use tutorialvault_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
fn main() {
// 1. Resolve exe directory for portability.
@@ -41,6 +41,7 @@ fn main() {
// Discover ffmpeg/ffprobe.
let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir);
let needs_ffmpeg_download = ff_paths.ffprobe.is_none() || ff_paths.ffmpeg.is_none();
lib.ffprobe = ff_paths.ffprobe;
lib.ffmpeg = ff_paths.ffmpeg;
@@ -91,22 +92,22 @@ fn main() {
// Configure window from saved prefs and launch.
builder
.setup(|app| {
.setup(move |app| {
let prefs_state = app.state::<Mutex<prefs::Prefs>>();
let p = prefs_state.lock().unwrap();
let win = app.get_webview_window("main").unwrap();
if let Some(x) = p.window.x {
if let Some(y) = p.window.y {
// Only restore position/size if values are sane (not minimized/offscreen).
let w = p.window.width.max(640) as u32;
let h = p.window.height.max(480) as u32;
if let (Some(x), Some(y)) = (p.window.x, p.window.y) {
if x > -10000 && y > -10000 && x < 10000 && y < 10000 {
let _ = win.set_position(tauri::Position::Physical(
tauri::PhysicalPosition::new(x, y),
));
}
}
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(
p.window.width as u32,
p.window.height as u32,
)));
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
let _ = win.set_always_on_top(p.always_on_top);
drop(p);
@@ -119,6 +120,32 @@ fn main() {
let _ = fonts::ensure_fontawesome_local(&fa_dir).await;
});
// Auto-download ffmpeg/ffprobe if not found locally.
if needs_ffmpeg_download {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let sd = handle.state::<AppPaths>().state_dir.clone();
let (tx, _rx) = tokio::sync::mpsc::channel(16);
match ffmpeg::download_ffmpeg(&sd, tx).await {
Ok(paths) => {
let lib_state = handle.state::<Mutex<library::Library>>();
if let Ok(mut lib) = lib_state.lock() {
if lib.ffprobe.is_none() {
lib.ffprobe = paths.ffprobe;
}
if lib.ffmpeg.is_none() {
lib.ffmpeg = paths.ffmpeg;
}
}
eprintln!("[ffmpeg] Auto-download complete");
}
Err(e) => {
eprintln!("[ffmpeg] Auto-download failed: {}", e);
}
}
});
}
Ok(())
})
.run(tauri::generate_context!())

View File

@@ -26,6 +26,9 @@ 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| {
@@ -140,6 +143,7 @@ fn serve_full(path: &PathBuf, file_size: u64, mime: &str) -> http::Response<Vec<
.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"))
}
@@ -180,6 +184,7 @@ fn serve_range(
.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"))
}
@@ -215,33 +220,36 @@ fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
// ---------------------------------------------------------------------------
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];
// rest is a VTT filename like "{fid}_{name}.vtt"
let filename = rest;
if !fid.chars().all(|c| c.is_ascii_hexdigit()) {
// 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(format!("{}.vtt", fid));
let vtt_path = paths.subs_dir.join(filename);
if !vtt_path.exists() {
// 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 internal_error("read error"),
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"))
}
@@ -331,6 +339,7 @@ fn serve_static_file(base_dir: &PathBuf, filename: &str) -> http::Response<Vec<u
.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"))
}

View File

@@ -1,8 +1,8 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"productName": "TutorialDock",
"productName": "TutorialVault",
"version": "0.1.0",
"identifier": "com.tutorialdock.app",
"identifier": "com.tutorialvault.app",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "TutorialDock",
"title": "TutorialVault",
"width": 1320,
"height": 860,
"minWidth": 640,
@@ -20,7 +20,7 @@
}
],
"security": {
"csp": "default-src 'self'; media-src 'self' tutdock: asset: https://asset.localhost; font-src 'self' tutdock: asset: https://asset.localhost data:; style-src 'self' tutdock: asset: https://asset.localhost 'unsafe-inline'; img-src 'self' tutdock: asset: https://asset.localhost data:; script-src 'self'"
"csp": "default-src 'self'; media-src 'self' http://tutdock.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self'"
}
},
"bundle": {
@@ -34,7 +34,5 @@
"icons/icon.ico"
]
},
"plugins": {
"dialog": {}
}
"plugins": {}
}