Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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.
Expand Down
224 changes: 200 additions & 24 deletions apps/cli/src/recordings.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::{
collections::HashSet,
path::{Path, PathBuf},
time::SystemTime,
};
Expand All @@ -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<PathBuf, String> {
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<PathBuf> {
let store = std::fs::read(app_data.join("store"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&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)]
Expand All @@ -37,28 +69,38 @@ fn recording_type(meta: &RecordingMeta) -> &'static str {
}
}

fn collect_rows(dir: &Path) -> Result<Vec<RecordingRow>, 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<SystemTime>,
) -> Result<Vec<RecordingRow>, 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()
Expand All @@ -77,18 +119,26 @@ fn collect_rows(dir: &Path) -> Result<Vec<RecordingRow>, String> {
}

pub fn list(dir: Option<PathBuf>, 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 {
Expand All @@ -106,3 +156,129 @@ pub fn list(dir: Option<PathBuf>, 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, &current, &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::<Vec<_>>(),
["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());
}
}
45 changes: 45 additions & 0 deletions apps/cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current, &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(&current));
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();
Expand Down
Loading