diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec2b0b907..212edd7b6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,8 @@ jobs: run: | cargo test --locked -p cap-utils -p cap-cli-install --lib cargo test --locked -p cap --bin cap record::tests + cargo test --locked -p cap --bin cap recordings::tests + cargo test --locked -p cap --test cli recordings_list cargo test --locked -p cap-recording --lib -- --test-threads=1 cargo test --locked -p cap-recording --test x11_shm_capture -- --test-threads=1 cargo test --locked -p cap-enc-ffmpeg diff --git a/apps/cli/README.md b/apps/cli/README.md index e33a97c0e8..817a528b39 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -82,7 +82,7 @@ step. - `cap screenshot` — capture a still of a screen/window (`--json` → `{path,width,height}`). - `cap targets` (`screens`/`windows`/`cameras`/`mics`) — enumerate capture inputs. - `cap project inspect` / `validate` / `config get|set` — inspect and edit `.cap` projects. -- `cap recordings list` — list `.cap` recordings in the desktop library. +- `cap recordings list` — list `.cap` recordings across Desktop's current, default, and previously used storage folders. Use `--dir ` to list only that directory. - `cap upload` — upload a `.cap` project or video file and get a shareable link. - `cap update` — download and install the latest Cap Desktop bundle, then repair the `cap` shim. - `cap doctor` / `version` / `guide` — diagnostics, version info, and the agent capability manifest. diff --git a/apps/cli/src/recordings.rs b/apps/cli/src/recordings.rs index c633bb4a03..dc517bb81a 100644 --- a/apps/cli/src/recordings.rs +++ b/apps/cli/src/recordings.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, path::{Path, PathBuf}, time::SystemTime, }; @@ -12,10 +13,41 @@ use crate::{OutputFormat, write_json}; // which the user can reach with an explicit `--dir`. const DESKTOP_BUNDLE_IDENTIFIER: &str = "so.cap.desktop"; -fn default_library_dir() -> Result { - dirs::data_dir() - .ok_or_else(|| "Could not determine the OS application-data directory".to_string()) - .map(|dir| dir.join(DESKTOP_BUNDLE_IDENTIFIER).join("recordings")) +fn library_dirs(app_data: &Path) -> Vec { + let store = std::fs::read(app_data.join("store")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let settings = store + .as_ref() + .and_then(|store| store.get("general_settings")); + let mut dirs = Vec::new(); + + if let Some(path) = settings + .and_then(|settings| settings.get("recordingsPath")) + .and_then(serde_json::Value::as_str) + { + dirs.push(PathBuf::from(path)); + } + dirs.push(app_data.join("recordings")); + if let Some(paths) = settings + .and_then(|settings| settings.get("previousRecordingsPaths")) + .and_then(serde_json::Value::as_array) + { + dirs.extend( + paths + .iter() + .filter_map(serde_json::Value::as_str) + .map(PathBuf::from), + ); + } + + let mut seen = HashSet::new(); + dirs.retain(|path| { + path.is_absolute() + && path.is_dir() + && seen.insert(path.canonicalize().unwrap_or_else(|_| path.clone())) + }); + dirs } #[derive(Serialize)] @@ -37,28 +69,38 @@ fn recording_type(meta: &RecordingMeta) -> &'static str { } } -fn collect_rows(dir: &Path) -> Result, String> { - if !dir.exists() { - return Ok(Vec::new()); - } - - let mut entries: Vec<(PathBuf, SystemTime, RecordingMeta)> = std::fs::read_dir(dir) - .map_err(|e| format!("Failed to read recordings directory: {e}"))? - .filter_map(|entry| { +fn collect_rows( + dirs: &[PathBuf], + fail_on_unreadable: bool, + created_at: impl Fn(&Path) -> std::io::Result, +) -> Result, String> { + let mut entries: Vec<(PathBuf, SystemTime, RecordingMeta)> = Vec::new(); + for dir in dirs { + if !dir.exists() { + continue; + } + let contents = match std::fs::read_dir(dir) { + Ok(contents) => contents, + Err(_) if !fail_on_unreadable => continue, + Err(error) => { + return Err(format!( + "Failed to read recordings directory {}: {error}", + dir.display() + )); + } + }; + entries.extend(contents.filter_map(|entry| { let path = entry.ok()?.path(); if !path.is_dir() || !path.file_name()?.to_str()?.ends_with(".cap") { return None; } let meta = RecordingMeta::load_for_project(&path).ok()?; - let created = path - .metadata() - .and_then(|m| m.created()) - .unwrap_or(SystemTime::UNIX_EPOCH); + let created = created_at(&path).unwrap_or(SystemTime::UNIX_EPOCH); Some((path, created, meta)) - }) - .collect(); + })); + } - entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.sort_by_key(|entry| std::cmp::Reverse(entry.1)); Ok(entries .into_iter() @@ -77,18 +119,26 @@ fn collect_rows(dir: &Path) -> Result, String> { } pub fn list(dir: Option, format: OutputFormat) -> Result<(), String> { - let dir = match dir { - Some(dir) => dir, - None => default_library_dir()?, + let dirs = match &dir { + Some(dir) => vec![dir.clone()], + None => { + let app_data = dirs::data_dir() + .ok_or_else(|| "Could not determine the OS application-data directory".to_string())? + .join(DESKTOP_BUNDLE_IDENTIFIER); + library_dirs(&app_data) + } }; - let rows = collect_rows(&dir)?; + let rows = collect_rows(&dirs, dir.is_some(), |path| path.metadata()?.created())?; match format { OutputFormat::Json => write_json(&rows), OutputFormat::Text => { if rows.is_empty() { - println!("No recordings found in {}", dir.display()); + match dir { + Some(dir) => println!("No recordings found in {}", dir.display()), + None => println!("No recordings found in the desktop library"), + } return Ok(()); } for row in &rows { @@ -106,3 +156,129 @@ pub fn list(dir: Option, format: OutputFormat) -> Result<(), String> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_recording(path: &Path, name: &str) { + std::fs::create_dir_all(path).unwrap(); + std::fs::write( + path.join("recording-meta.json"), + serde_json::to_vec(&serde_json::json!({ + "pretty_name": name, "fps": 30, "sample_rate": null + })) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn discovers_current_default_and_previous_storage_once() { + let root = tempfile::tempdir().unwrap(); + let app_data = root.path().join("desktop"); + let default = app_data.join("recordings"); + let current = root.path().join("current"); + let previous = root.path().join("previous"); + for dir in [&default, ¤t, &previous] { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write( + app_data.join("store"), + serde_json::to_vec(&serde_json::json!({ + "general_settings": { + "recordingsPath": current, + "previousRecordingsPaths": [previous, current, "relative", root.path().join("missing"), 42] + } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!(library_dirs(&app_data), vec![current, default, previous]); + } + + #[test] + fn falls_back_to_default_storage_without_valid_settings() { + let root = tempfile::tempdir().unwrap(); + let default = root.path().join("recordings"); + std::fs::create_dir(&default).unwrap(); + assert_eq!(library_dirs(root.path()), vec![default.clone()]); + + for store in [ + "invalid JSON", + r#"{"general_settings":{"recordingsPath":false,"previousRecordingsPaths":"invalid"}}"#, + ] { + std::fs::write(root.path().join("store"), store).unwrap(); + assert_eq!(library_dirs(root.path()), vec![default.clone()]); + } + } + + #[cfg(unix)] + #[test] + fn deduplicates_storage_reached_through_symlinks() { + let root = tempfile::tempdir().unwrap(); + let default = root.path().join("recordings"); + let alias = root.path().join("alias"); + std::fs::create_dir(&default).unwrap(); + std::os::unix::fs::symlink(&default, &alias).unwrap(); + std::fs::write( + root.path().join("store"), + serde_json::to_vec(&serde_json::json!({ + "general_settings": { "recordingsPath": alias } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!(library_dirs(root.path()), vec![alias]); + } + + #[test] + fn merges_recordings_from_multiple_libraries_newest_first() { + let root = tempfile::tempdir().unwrap(); + let recordings = [ + (root.path().join("default/oldest.cap"), "Oldest", 10), + (root.path().join("custom/older.cap"), "Older", 20), + (root.path().join("default/newer.cap"), "Newer", 30), + (root.path().join("custom/newest.cap"), "Newest", 40), + ]; + for (path, name, _) in &recordings { + write_recording(path, name); + } + + let rows = collect_rows( + &[root.path().join("default"), root.path().join("custom")], + true, + |path| { + let (_, _, seconds) = recordings + .iter() + .find(|(recording_path, _, _)| recording_path == path) + .unwrap(); + Ok(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(*seconds)) + }, + ) + .unwrap(); + assert_eq!( + rows.iter().map(|row| row.name.as_str()).collect::>(), + ["Newest", "Newer", "Older", "Oldest"] + ); + } + + #[test] + fn skips_unavailable_roots_only_when_discovering_the_library() { + let root = tempfile::tempdir().unwrap(); + let available = root.path().join("available"); + let unavailable = root.path().join("unavailable"); + write_recording(&available.join("example.cap"), "Available"); + std::fs::write(&unavailable, "no longer a directory").unwrap(); + + let rows = collect_rows(&[unavailable.clone(), available], false, |path| { + path.metadata()?.created() + }) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "Available"); + assert!(collect_rows(&[unavailable], true, |path| path.metadata()?.created()).is_err()); + } +} diff --git a/apps/cli/tests/cli.rs b/apps/cli/tests/cli.rs index 190c6fdfde..d3372bb3eb 100644 --- a/apps/cli/tests/cli.rs +++ b/apps/cli/tests/cli.rs @@ -609,6 +609,51 @@ fn recordings_list_empty_dir_is_empty_json() { assert_eq!(json.as_array().map(|a| a.len()), Some(0)); } +#[cfg(target_os = "linux")] +#[test] +fn recordings_list_follows_desktop_storage_unless_dir_is_explicit() { + let root = tempfile::tempdir().unwrap(); + let app_data = root.path().join("so.cap.desktop"); + let default = app_data.join("recordings"); + let current = root.path().join("custom"); + let previous = root.path().join("previous"); + for dir in [&default, ¤t, &previous] { + write_single_segment_meta(&dir.join("example.cap")); + } + std::fs::write( + app_data.join("store"), + serde_json::to_vec(&serde_json::json!({ + "general_settings": { + "recordingsPath": current, + "previousRecordingsPaths": [previous, current] + } + })) + .unwrap(), + ) + .unwrap(); + + let list = |dir: Option<&Path>| { + let mut command = cap(); + command + .env("XDG_DATA_HOME", root.path()) + .args(["recordings", "list", "--json"]); + if let Some(dir) = dir { + command.arg("--dir").arg(dir); + } + let output = command.output().unwrap(); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + parse_json(&output) + }; + + assert_eq!(list(None).as_array().unwrap().len(), 3); + let explicit = list(Some(¤t)); + assert_eq!(explicit.as_array().unwrap().len(), 1); + assert_eq!( + explicit[0]["path"], + current.join("example.cap").to_str().unwrap() + ); +} + #[test] fn project_config_set_then_get_roundtrips() { let dir = tempfile::tempdir().unwrap();