This commit is contained in:
Julian Freeman
2026-04-19 09:56:09 -04:00
parent 4d5cac7a46
commit e86bc86793
18 changed files with 685 additions and 500 deletions

View File

@@ -1,6 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion

View File

@@ -10,6 +10,7 @@ use std::os::windows::process::CommandExt;
use zip::ZipArchive;
use std::io::Cursor;
use crate::process_utils::first_non_empty_line;
use crate::storage::{self};
const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
@@ -285,6 +286,11 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
pub async fn update_qjs(app: &AppHandle) -> Result<String> {
// QuickJS doesn't have self-update, so we just re-download
download_qjs(app).await?;
let mut settings = storage::load_settings(app)?;
settings.last_updated = Some(chrono::Utc::now());
storage::save_settings(app, &settings)?;
Ok("QuickJS 已更新/安装".to_string())
}
@@ -441,18 +447,8 @@ pub fn get_ffmpeg_version(app: &AppHandle) -> Result<String> {
let output = cmd.output()?;
if output.status.success() {
// Prefer stdout, fallback to stderr if stdout empty
let out = if !output.stdout.is_empty() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
String::from_utf8_lossy(&output.stderr).to_string()
};
if let Some(first_line) = out.lines().next() {
let v = first_line.trim().to_string();
if !v.is_empty() {
return Ok(v);
}
if let Some(line) = first_non_empty_line(&output) {
return Ok(line);
}
}
@@ -465,6 +461,31 @@ pub fn get_qjs_version(app: &AppHandle) -> Result<String> {
if !path.exists() {
return Ok("未安装".to_string());
}
let mut version_cmd = std::process::Command::new(&path);
version_cmd.arg("--version");
#[cfg(target_os = "windows")]
version_cmd.creation_flags(0x08000000);
if let Ok(output) = version_cmd.output() {
if output.status.success() {
if let Some(line) = first_non_empty_line(&output) {
return Ok(line);
}
}
}
let mut help_cmd = std::process::Command::new(&path);
help_cmd.arg("-h");
#[cfg(target_os = "windows")]
help_cmd.creation_flags(0x08000000);
if let Ok(output) = help_cmd.output() {
if let Some(line) = first_non_empty_line(&output) {
return Ok(line);
}
}
Ok("已安装".to_string())
}

View File

@@ -5,6 +5,10 @@ use crate::downloader::DownloadOptions;
use crate::storage::{Settings, HistoryItem};
use uuid::Uuid;
use std::path::Path;
use std::sync::LazyLock;
use tokio::sync::Semaphore;
static DOWNLOAD_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(3));
#[tauri::command]
pub async fn init_ytdlp(app: AppHandle) -> Result<bool, String> {
@@ -60,14 +64,29 @@ pub async fn fetch_image(url: String) -> Result<String, String> {
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("image fetch failed with status {}", res.status()));
}
let mime = res
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or("image/jpeg").to_string())
.unwrap_or_else(|| {
if url.to_lowercase().ends_with(".png") {
"image/png".to_string()
} else if url.to_lowercase().ends_with(".webp") {
"image/webp".to_string()
} else {
"image/jpeg".to_string()
}
});
let bytes = res.bytes().await.map_err(|e| e.to_string())?;
// Convert to base64
let b64 = general_purpose::STANDARD.encode(&bytes);
// Simple heuristic for mime type
let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else { "image/jpeg" };
Ok(format!("data:{};base64,{}", mime, b64))
}
@@ -84,9 +103,11 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
// Spawn the download task
tauri::async_runtime::spawn(async move {
let _permit = DOWNLOAD_SEMAPHORE.acquire().await.ok();
let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await;
let status = if res.is_ok() { "success" } else { "failed" };
let file_path = res.ok().flatten();
// Add to history
let output_dir = options.output_path.clone(); // Store the directory user selected
@@ -97,6 +118,7 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
thumbnail: metadata.thumbnail,
url: url,
output_path: output_dir,
file_path,
timestamp: chrono::Utc::now(),
status: status.to_string(),
format: options.output_format,
@@ -136,16 +158,16 @@ pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> {
#[tauri::command]
pub async fn close_splash(app: AppHandle) {
if let Some(splash) = app.get_webview_window("splashscreen") {
splash.close().unwrap();
let _ = splash.close();
}
if let Some(main) = app.get_webview_window("main") {
main.show().unwrap();
let _ = main.show();
}
}
#[tauri::command]
pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
let path_to_open = if Path::new(&path).exists() {
let resolved_path = if Path::new(&path).exists() {
path
} else {
app.path().download_dir()
@@ -155,17 +177,29 @@ pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(path_to_open)
.spawn()
.map_err(|e| e.to_string())?;
let resolved = Path::new(&resolved_path);
let mut command = std::process::Command::new("explorer");
if resolved.is_file() {
command.arg("/select,").arg(resolved);
} else {
command.arg(resolved);
}
command.spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(path_to_open)
.spawn()
.map_err(|e| e.to_string())?;
let resolved = Path::new(&resolved_path);
let mut command = std::process::Command::new("open");
if resolved.is_file() {
command.arg("-R").arg(resolved);
} else {
command.arg(resolved);
}
command.spawn().map_err(|e| e.to_string())?;
}
Ok(())
}
}

View File

@@ -57,6 +57,7 @@ pub struct LogEvent {
pub level: String, // "info", "error"
}
const FINAL_PATH_MARKER: &str = "__STREAM_CAPTURE_FINAL_PATH__";
pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result<MetadataResult> {
@@ -218,7 +219,7 @@ pub async fn download_video(
id: String, // Unique ID for this download task (provided by frontend)
url: String,
options: DownloadOptions,
) -> Result<String> {
) -> Result<Option<String>> {
let ytdlp_path = binary_manager::get_ytdlp_path(&app)?;
let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs
let ffmpeg_path = binary_manager::get_ffmpeg_path(&app)?; // Get absolute path to ffmpeg
@@ -246,6 +247,8 @@ pub async fn download_video(
let output_template = format!("{}/%(title)s.%(ext)s", options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR));
args.push("-o".to_string());
args.push(output_template);
args.push("--print".to_string());
args.push(format!("after_move:{FINAL_PATH_MARKER}%(filepath)s"));
// Formats
if options.is_audio_only {
@@ -295,56 +298,84 @@ pub async fn download_video(
let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?;
let stderr = child.stderr.take().ok_or(anyhow!("Failed to open stderr"))?;
let mut stdout_reader = BufReader::new(stdout);
let mut stderr_reader = BufReader::new(stderr);
let progress_regex = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%.*?(?:\s+at\s+([^\s]+))?").unwrap();
let re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap();
let stdout_task = {
let app = app.clone();
let id = id.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
let mut final_path: Option<String> = None;
// Loop to read both streams
loop {
let mut out_line = String::new();
let mut err_line = String::new();
tokio::select! {
res = stdout_reader.read_line(&mut out_line) => {
if res.unwrap_or(0) == 0 {
break; // EOF
while let Some(line) = reader.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Parse progress
if let Some(caps) = re.captures(&out_line) {
if let Some(path) = trimmed.strip_prefix(FINAL_PATH_MARKER) {
final_path = Some(path.to_string());
continue;
}
if let Some(caps) = progress_regex.captures(trimmed) {
if let Some(pct_match) = caps.get(1) {
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
let speed = caps
.get(2)
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "待定".to_string());
app.emit("download-progress", ProgressEvent {
id: id.clone(),
progress: pct,
speed: "待定".to_string(),
speed,
status: "downloading".to_string(),
}).ok();
continue;
}
}
} else { // Only emit download-log if it's NOT a progress line
app.emit("download-log", LogEvent {
id: id.clone(),
message: out_line.trim().to_string(),
level: "info".to_string(),
}).ok();
}
app.emit("download-log", LogEvent {
id: id.clone(),
message: trimmed.to_string(),
level: "info".to_string(),
}).ok();
}
res = stderr_reader.read_line(&mut err_line) => {
if res.unwrap_or(0) > 0 {
// Log error
app.emit("download-log", LogEvent {
id: id.clone(),
message: err_line.trim().to_string(),
level: "error".to_string(),
}).ok();
}
Ok::<Option<String>, anyhow::Error>(final_path)
})
};
let stderr_task = {
let app = app.clone();
let id = id.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
let mut last_error: Option<String> = None;
while let Some(line) = reader.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
last_error = Some(trimmed.to_string());
app.emit("download-log", LogEvent {
id: id.clone(),
message: trimmed.to_string(),
level: "error".to_string(),
}).ok();
}
}
}
Ok::<Option<String>, anyhow::Error>(last_error)
})
};
let status = child.wait().await?;
let final_path = stdout_task.await.map_err(|e| anyhow!(e.to_string()))??;
let last_error = stderr_task.await.map_err(|e| anyhow!(e.to_string()))??;
if status.success() {
app.emit("download-progress", ProgressEvent {
@@ -353,7 +384,7 @@ pub async fn download_video(
speed: "-".to_string(),
status: "finished".to_string(),
}).ok();
Ok("下载完成".to_string())
Ok(final_path)
} else {
app.emit("download-progress", ProgressEvent {
id: id.clone(),
@@ -361,6 +392,11 @@ pub async fn download_video(
speed: "-".to_string(),
status: "error".to_string(),
}).ok();
Err(anyhow!("下载进程失败"))
Err(anyhow!(
"下载进程失败{}",
last_error
.map(|message| format!(": {message}"))
.unwrap_or_default()
))
}
}

View File

@@ -3,6 +3,7 @@ mod binary_manager;
mod downloader;
mod storage;
mod commands;
mod process_utils;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {

View File

@@ -0,0 +1,10 @@
use std::process::Output;
pub fn first_non_empty_line(output: &Output) -> Option<String> {
String::from_utf8_lossy(&output.stdout)
.lines()
.chain(String::from_utf8_lossy(&output.stderr).lines())
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
}

View File

@@ -34,11 +34,35 @@ pub struct HistoryItem {
pub thumbnail: String,
pub url: String,
pub output_path: String,
#[serde(default)]
pub file_path: Option<String>,
pub timestamp: DateTime<Utc>,
pub status: String, // "success", "failed"
pub format: String,
}
fn write_json_atomically(path: &PathBuf, content: &str) -> Result<()> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("data.json");
let tmp_path = path.with_file_name(format!("{file_name}.tmp"));
fs::write(&tmp_path, content)?;
if path.exists() {
match fs::rename(&tmp_path, path) {
Ok(()) => return Ok(()),
Err(_) => {
fs::remove_file(path)?;
}
}
}
fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn get_app_data_dir(app: &AppHandle) -> Result<PathBuf> {
// In Tauri v2, we use app.path().app_data_dir()
let path = app.path().app_data_dir()?;
@@ -76,7 +100,7 @@ pub fn load_settings(app: &AppHandle) -> Result<Settings> {
pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> {
let path = get_settings_path(app)?;
let content = serde_json::to_string_pretty(settings)?;
fs::write(path, content)?;
write_json_atomically(&path, &content)?;
Ok(())
}
@@ -94,7 +118,7 @@ pub fn load_history(app: &AppHandle) -> Result<Vec<HistoryItem>> {
pub fn save_history(app: &AppHandle, history: &[HistoryItem]) -> Result<()> {
let path = get_history_path(app)?;
let content = serde_json::to_string_pretty(history)?;
fs::write(path, content)?;
write_json_atomically(&path, &content)?;
Ok(())
}

View File

@@ -31,7 +31,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self' asset: http://asset.localhost https://asset.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost ws://localhost:1420 http://localhost:1420 https:; font-src 'self' asset: http://asset.localhost https://asset.localhost data:;"
}
},
"bundle": {