diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac7dd9..1808695 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,5 @@ jobs: sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libdbus-1-dev patchelf - name: cargo test run: cargo test --lib --manifest-path src-tauri/Cargo.toml + - name: Managed Deno install smoke test + run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml deno::tests::installs_official_deno_and_reuses_it -- --ignored --exact diff --git a/README.md b/README.md index 895ed82..6468f37 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,10 @@ Download the latest installer from the [Releases](../../releases) page and run i Playback requires WebKitGTK 4.1, GTK 3 and the GStreamer base, good and libav plugins. Signing in also requires a desktop Secret Service provider such as GNOME Keyring or a compatible KWallet setup. -- On first launch the app downloads its own copy of yt-dlp (~12 MB) into its - data folder and keeps it updated automatically. +- On first launch the app downloads its own copies of yt-dlp (~12 MB) and + Deno (~40 MB, which yt-dlp needs for YouTube's player checks) into its data + folder. yt-dlp updates itself automatically; Deno is verified against the + official release checksum and updated with app releases. - Signing in is optional: browse and playback work anonymously; sign in to get your library, likes, and playlists. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03fdcf8..e18b4be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1357,6 +1357,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -7201,6 +7202,7 @@ dependencies = [ "resvg", "serde", "serde_json", + "sha2", "souvlaki", "tauri", "tauri-build", @@ -7216,6 +7218,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "time", "tokio", "tower", @@ -7225,6 +7228,7 @@ dependencies = [ "webview2-com", "windows 0.61.3", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -7387,10 +7391,17 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cfcfb95..3baf6d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,10 @@ discord-rich-presence = "0.2" # MD5 for Last.fm request signing (api_sig = md5 of sorted params + secret). # Tiny, single-purpose crate. See src/lastfm.rs. md5 = "0.7" +# Verify, stage and unpack the managed Deno runtime. +sha2 = "0.10" +tempfile = "3" +zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ diff --git a/src-tauri/src/deno.rs b/src-tauri/src/deno.rs new file mode 100644 index 0000000..f1be3e8 --- /dev/null +++ b/src-tauri/src/deno.rs @@ -0,0 +1,209 @@ +//! App-owned Deno runtime for yt-dlp's YouTube player challenges. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; + +// Keep the version and hashes together. Deno is updated with the app; yt-dlp +// continues to update independently when YouTube changes its extractors. +// 2.8.3 is the last release before Deno raised its macOS minimum to 12. +// Its official binaries target 10.12 on Intel and 11.0 on Apple Silicon. +const VERSION: &str = "2.8.3"; +const BINARY_NAME: &str = if cfg!(windows) { "deno.exe" } else { "deno" }; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +pub fn managed_path(bin_dir: &Path) -> PathBuf { + bin_dir.join(BINARY_NAME) +} + +fn release_asset(os: &str, arch: &str) -> Result<(String, &'static str), String> { + let (target, digest) = match (os, arch) { + ("windows", "x86_64") => ( + "x86_64-pc-windows-msvc", + "7fdd1f42e6b0855421ecf27bb406e2492ade1087c85e30ebf0deab6280ea743c", + ), + ("windows", "aarch64") => ( + "aarch64-pc-windows-msvc", + "243f478ac577ade1bbd980ecf510607a10ed8cc977b462083ada48e5f6580de1", + ), + ("linux", "x86_64") => ( + "x86_64-unknown-linux-gnu", + "30455b845ffa6082209c3590269c910ad3b7efdf28c9879afd4006c47ae54197", + ), + ("linux", "aarch64") => ( + "aarch64-unknown-linux-gnu", + "d4589cc1ffcbf1995c92a0127d932aaf832ac70cfdcc6d5b7bf38043cf303575", + ), + ("macos", "x86_64") => ( + "x86_64-apple-darwin", + "4254ec12123cfcf88b87703d7acf092a1ea024bdf9be8dd3cd9d4474761cb74e", + ), + ("macos", "aarch64") => ( + "aarch64-apple-darwin", + "88b350be928fdba0e5d8142ff7c101a17133426371e3cf5ed0e0f74e62476f6c", + ), + _ => { + return Err(format!( + "Deno playback runtime is unavailable for {os}/{arch}" + )) + } + }; + Ok((format!("deno-{target}.zip"), digest)) +} + +pub async fn installed(path: &Path) -> bool { + if !path.is_file() { + return false; + } + let mut cmd = tokio::process::Command::new(path); + cmd.arg("--version").stdin(Stdio::null()).kill_on_drop(true); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); + match tokio::time::timeout(Duration::from_secs(10), cmd.output()).await { + Ok(Ok(output)) => { + output.status.success() + && String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .take(2) + .eq(["deno", VERSION]) + } + _ => false, + } +} + +/// Set once the managed runtime has been seen working, so playback does not +/// spawn `deno --version` before every track. +static READY: AtomicBool = AtomicBool::new(false); + +/// The runtime playback should hand to yt-dlp, or `None` while it is missing. +/// Never downloads: that is setup's job, and a 40 MB fetch must not sit in +/// front of a song. The install is an atomic rename, so a runtime that is +/// still downloading is simply absent here. +pub async fn available(path: &Path) -> Option<&Path> { + if READY.load(Ordering::Acquire) || installed(path).await { + READY.store(true, Ordering::Release); + Some(path) + } else { + None + } +} + +/// Serialize setup callers. Staging lives on the install filesystem. +pub async fn ensure(path: &Path) -> Result<(), String> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _guard = LOCK.lock().await; + if installed(path).await { + READY.store(true, Ordering::Release); + return Ok(()); + } + let root = path.parent().ok_or("Deno install path has no parent")?; + tokio::fs::create_dir_all(root) + .await + .map_err(|e| format!("create Deno directory: {e}"))?; + let staging = tempfile::Builder::new() + .prefix("deno-install-") + .tempdir_in(root) + .map_err(|e| format!("create Deno staging directory: {e}"))?; + let install = download(staging.path(), path); + tokio::time::timeout(DOWNLOAD_TIMEOUT, install) + .await + .map_err(|_| "Deno download timed out".to_string())? +} + +async fn download(staging: &Path, destination: &Path) -> Result<(), String> { + let (asset, expected_hash) = release_asset(std::env::consts::OS, std::env::consts::ARCH)?; + let payload = staging.join("download"); + let mut response = reqwest::get(format!( + "https://github.com/denoland/deno/releases/download/v{VERSION}/{asset}" + )) + .await + .map_err(|e| format!("request Deno: {e}"))? + .error_for_status() + .map_err(|e| format!("download Deno: {e}"))?; + let mut file = tokio::fs::File::create(&payload) + .await + .map_err(|e| format!("create Deno download: {e}"))?; + let mut hash = Sha256::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("read Deno download: {e}"))? + { + hash.update(&chunk); + file.write_all(&chunk) + .await + .map_err(|e| format!("write Deno download: {e}"))?; + } + file.flush() + .await + .map_err(|e| format!("flush Deno download: {e}"))?; + drop(file); + if format!("{:x}", hash.finalize()) != expected_hash { + return Err("Deno download failed SHA-256 verification".to_string()); + } + + let executable = managed_path(staging); + let executable = tokio::task::spawn_blocking(move || extract(payload, executable)) + .await + .map_err(|e| format!("extract Deno: {e}"))? + .map_err(|e| format!("extract Deno: {e}"))?; + if !installed(&executable).await { + return Err("Downloaded Deno cannot run on this system".to_string()); + } + // Verify before replacing the existing runtime. A failed download or + // extraction leaves the previously installed executable untouched. + tokio::fs::rename(&executable, destination) + .await + .map_err(|e| format!("install Deno: {e}"))?; + READY.store(true, Ordering::Release); + Ok(()) +} + +fn extract(payload: PathBuf, executable: PathBuf) -> zip::result::ZipResult { + let mut archive = zip::ZipArchive::new(std::fs::File::open(payload)?)?; + // Copy only the expected executable; archive paths never select destinations. + let mut entry = archive.by_name(BINARY_NAME)?; + let mut file = std::fs::File::create(&executable)?; + std::io::copy(&mut entry, &mut file)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o755))?; + } + Ok(executable) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_platform_has_an_explicit_error() { + assert!(release_asset("linux", "riscv64") + .unwrap_err() + .contains("linux/riscv64")); + } + + #[test] + #[ignore = "downloads the official Deno runtime; requires network access"] + fn installs_official_deno_and_reuses_it() { + let root = tempfile::tempdir().unwrap(); + let path = managed_path(root.path()); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + ensure(&path).await.unwrap(); + assert!(installed(&path).await); + let before = std::fs::metadata(&path).unwrap().modified().unwrap(); + ensure(&path).await.unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().modified().unwrap(), + before + ); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); + }); + } +} diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs new file mode 100644 index 0000000..99d6140 --- /dev/null +++ b/src-tauri/src/downloader.rs @@ -0,0 +1,207 @@ +//! yt-dlp playback commands. Deno runs the player challenges when the +//! managed runtime is installed; playback stays anonymous either way. + +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; + +const FORMAT: &str = "bestaudio[ext=webm][protocol^=http]/bestaudio[protocol^=http]/bestaudio"; +const READ_TIMEOUT: Duration = Duration::from_secs(60); + +/// `deno` is `None` while the managed runtime is missing (first launch still +/// downloading it, or the download failed). yt-dlp then falls back to its own +/// runtime discovery instead of the whole track failing. +pub fn command(yt_dlp: &Path, deno: Option<&Path>) -> Command { + let mut cmd = Command::new(yt_dlp); + cmd.arg("--ignore-config"); + if let Some(deno) = deno { + // App-owned configuration: explicitly select Deno, including paths + // with spaces, so playback always uses the managed runtime. + cmd.args(["--no-js-runtimes", "--js-runtimes"]); + let mut runtime = std::ffi::OsString::from("deno:"); + runtime.push(deno); + cmd.arg(runtime); + } + cmd.args(["--no-playlist", "--no-warnings", "--socket-timeout", "15"]); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + cmd.kill_on_drop(true); + cmd +} + +async fn read_stderr(mut stderr: tokio::process::ChildStderr) -> String { + // Drain the pipe even after the limit so a verbose failure cannot + // deadlock the child. The final error is at the end of yt-dlp's output. + const LIMIT: usize = 16 * 1024; + let mut tail = Vec::new(); + let mut buffer = [0; 4096]; + while let Ok(n) = stderr.read(&mut buffer).await { + if n == 0 { + break; + } + tail.extend_from_slice(&buffer[..n]); + if tail.len() > LIMIT { + tail.drain(..tail.len() - LIMIT); + } + } + String::from_utf8_lossy(&tail).trim().to_string() +} + +pub async fn download( + yt_dlp: &Path, + deno: Option<&Path>, + video_id: &str, + destination: &Path, +) -> Result<(), String> { + let mut cmd = command(yt_dlp, deno); + cmd.args([ + "-f", + FORMAT, + "--no-part", + "-q", + "--retries", + "5", + "--extractor-retries", + "3", + "-o", + "-", + ]); + cmd.arg(format!("https://www.youtube.com/watch?v={video_id}")); + let mut file = tokio::fs::File::create(destination) + .await + .map_err(|e| format!("create audio file: {e}"))?; + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("spawn yt-dlp: {e}"))?; + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut errors = tokio::spawn(read_stderr(child.stderr.take().expect("piped stderr"))); + let copy = async { + let mut buffer = [0; 64 * 1024]; + loop { + let n = tokio::time::timeout(READ_TIMEOUT, stdout.read(&mut buffer)) + .await + .map_err(|_| "audio download timed out".to_string())? + .map_err(|e| format!("read audio: {e}"))?; + if n == 0 { + break; + } + file.write_all(&buffer[..n]) + .await + .map_err(|e| format!("write audio: {e}"))?; + } + file.flush().await.map_err(|e| format!("flush audio: {e}")) + } + .await; + if copy.is_err() { + let _ = child.kill().await; + } + let status = tokio::time::timeout(READ_TIMEOUT, child.wait()).await; + if status.is_err() { + let _ = child.kill().await; + } + let stderr = match tokio::time::timeout(Duration::from_secs(5), &mut errors).await { + Ok(result) => result.map_err(|e| format!("read yt-dlp error: {e}"))?, + Err(_) => { + errors.abort(); + return Err("yt-dlp error output timed out".to_string()); + } + }; + copy?; + let status = status + .map_err(|_| "yt-dlp exit timed out".to_string())? + .map_err(|e| format!("wait for yt-dlp: {e}"))?; + if !status.success() { + return Err(format!("yt-dlp {status}: {stderr}")); + } + Ok(()) +} + +pub async fn resolve(yt_dlp: &Path, deno: Option<&Path>, video_id: &str) -> Result { + let output = tokio::time::timeout( + READ_TIMEOUT, + command(yt_dlp, deno) + .args(["-j", "-f", FORMAT]) + .arg(format!("https://www.youtube.com/watch?v={video_id}")) + .output(), + ) + .await + .map_err(|_| "audio lookup timed out".to_string())? + .map_err(|e| format!("spawn yt-dlp: {e}"))?; + if !output.status.success() { + return Err(format!( + "yt-dlp {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + String::from_utf8(output.stdout).map_err(|e| format!("audio metadata is not UTF-8: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(cmd: &Command) -> Vec { + cmd.as_std() + .get_args() + .map(|s| s.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn runtime_path_with_spaces_is_a_single_argument() { + let args = args(&command( + Path::new("yt-dlp"), + Some(Path::new("path with spaces/deno")), + )); + assert!(args + .windows(2) + .any(|a| a == ["--js-runtimes", "deno:path with spaces/deno"])); + assert!(args.contains(&"--no-js-runtimes".to_string())); + } + + #[test] + fn missing_runtime_leaves_yt_dlp_defaults_alone() { + let args = args(&command(Path::new("yt-dlp"), None)); + assert!(!args.iter().any(|a| a.contains("js-runtimes"))); + } + + #[test] + fn playback_never_sends_account_cookies() { + let args = args(&command(Path::new("yt-dlp"), Some(Path::new("deno")))); + assert!(!args.iter().any(|a| a.starts_with("--cookies"))); + } + + #[test] + #[ignore = "requires YTUBIC_TEST_YTDLP, YTUBIC_TEST_DENO, YTUBIC_TEST_VIDEO_ID and YouTube access"] + fn real_ytdlp_playback() { + let yt_dlp = + std::path::PathBuf::from(std::env::var_os("YTUBIC_TEST_YTDLP").expect("yt-dlp path")); + let deno = + std::path::PathBuf::from(std::env::var_os("YTUBIC_TEST_DENO").expect("Deno path")); + let video_id = std::env::var("YTUBIC_TEST_VIDEO_ID").expect("video id"); + let root = tempfile::tempdir().unwrap(); + let audio = root.path().join("audio.webm"); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + crate::deno::ensure(&deno).await.unwrap(); + download(&yt_dlp, Some(&deno), &video_id, &audio) + .await + .unwrap(); + let bytes = std::fs::read(audio).unwrap(); + assert!(bytes.len() > 32 * 1024); + assert_eq!(&bytes[..4], &[0x1a, 0x45, 0xdf, 0xa3]); + use sha2::Digest; + println!( + "Downloaded {} bytes; SHA-256 {:x}", + bytes.len(), + sha2::Sha256::digest(&bytes) + ); + }); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9866725..751ca63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,11 @@ use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; -use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::process::Command as TokioCommand; use tokio::sync::{Mutex, Notify}; use axum::{ @@ -25,7 +23,9 @@ use tower_http::cors::CorsLayer; use tower_http::services::ServeFile; mod appid; +mod deno; mod discord; +mod downloader; mod lastfm; mod media; mod power; @@ -3142,8 +3142,8 @@ async fn set_cache_meta( Ok(()) } -/// Make the managed yt-dlp binary available (download on first run, -/// throttled self-update after). Invoked by the frontend on mount so +/// Make the managed yt-dlp and Deno binaries available (download on first +/// run, throttled yt-dlp self-update after). Invoked by the frontend on mount so /// the `ytdlp-state` event listener is guaranteed to exist before any /// state event fires; also serves as the retry path after a failed /// download. Idempotent — see `ytdlp::ensure`. @@ -3154,41 +3154,13 @@ async fn ensure_ytdlp(app: tauri::AppHandle) { /// Run yt-dlp to resolve a videoId into metadata JSON. #[tauri::command] -fn resolve_stream_ytdlp(app: tauri::AppHandle, video_id: String) -> Result { +async fn resolve_stream_ytdlp(app: tauri::AppHandle, video_id: String) -> Result { if !sanitize_video_id(&video_id) { return Err(format!("invalid videoId: {video_id}")); } - let url = format!("https://www.youtube.com/watch?v={video_id}"); - let mut command = std::process::Command::new(ytdlp::program(&ytdlp::managed_path(&app))); - command.args([ - "-j", - // Progressive only: the resolved URL is handed straight to an - //