diff --git a/Cargo.lock b/Cargo.lock index 638f492..68d0b55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,12 +862,22 @@ dependencies = [ [[package]] name = "clipbin-core" version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "rustix 1.1.5", + "serde", + "serde_json", + "tempfile", + "windows-sys 0.61.2", + "winreg 0.55.0", +] [[package]] name = "clipbin-desktop" version = "0.1.0" dependencies = [ "clipbin-core", + "clipbin-obs", "gpui", ] @@ -1487,7 +1497,7 @@ dependencies = [ "rustc_version", "toml 1.1.7+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.56.0", ] [[package]] @@ -6723,6 +6733,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winreg" version = "0.56.0" diff --git a/crates/clipbin-core/Cargo.toml b/crates/clipbin-core/Cargo.toml index 93b484e..d67adc0 100644 --- a/crates/clipbin-core/Cargo.toml +++ b/crates/clipbin-core/Cargo.toml @@ -8,5 +8,18 @@ license.workspace = true repository.workspace = true publish.workspace = true -[lints] -workspace = true +[lints.rust] +unsafe_code = "deny" + +[dependencies] +dirs = "5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" + +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { version = "1", features = ["process", "event"] } + +[target.'cfg(windows)'.dependencies] +winreg = "0.55" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] } diff --git a/crates/clipbin-core/src/games.rs b/crates/clipbin-core/src/games.rs new file mode 100644 index 0000000..6a99c5a --- /dev/null +++ b/crates/clipbin-core/src/games.rs @@ -0,0 +1,362 @@ +//! Steam discovery is independent of both the interface and the recorder. +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Game { + pub app_id: u32, + pub pid: u32, + pub name: String, + pub executable: String, +} + +/// `/proc` cmdlines are NUL-separated argv, not shell command strings. +pub fn reaper_app_id(bytes: &[u8]) -> Option { + let argv: Vec<_> = bytes + .split(|b| *b == 0) + .filter_map(|s| std::str::from_utf8(s).ok()) + .collect(); + if Path::new(argv.first()?).file_name()?.to_str()? != "reaper" || !argv.contains(&"SteamLaunch") + { + return None; + } + argv.iter() + .find_map(|s| s.strip_prefix("AppId=").and_then(parse_running_app_id)) +} +pub fn parse_running_app_id(value: &str) -> Option { + value.trim().parse::().ok().filter(|id| *id > 0) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VdfValue { + Text(String), + Object(Vec<(String, VdfValue)>), +} +impl VdfValue { + pub fn get(&self, key: &str) -> Option<&Self> { + match self { + Self::Object(items) => items + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v), + _ => None, + } + } + pub fn text(&self) -> Option<&str> { + match self { + Self::Text(s) => Some(s), + _ => None, + } + } +} +/// Steam's quoted strings, comments, and unquoted keys; unknown keys are kept. +pub fn parse_vdf(input: &str) -> Option { + let mut tokens = vec![]; + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + continue; + } + if c == '/' && chars.peek() == Some(&'/') { + for c in chars.by_ref() { + if c == '\n' { + break; + } + } + continue; + } + if c == '{' || c == '}' { + tokens.push(c.to_string()); + continue; + } + let mut token = String::new(); + if c == '"' { + let mut closed = false; + while let Some(c) = chars.next() { + if c == '"' { + closed = true; + break; + } + if c == '\\' { + let next = chars.next()?; + if next != '"' && next != '\\' { + token.push('\\'); + } + token.push(next); + } else { + token.push(c); + } + } + if !closed { + return None; + } + } else { + token.push(c); + while chars + .peek() + .is_some_and(|c| !c.is_whitespace() && *c != '{' && *c != '}') + { + token.push(chars.next()?); + } + } + tokens.push(token); + } + fn object(tokens: &[String], at: &mut usize, nested: bool, depth: usize) -> Option { + if depth > 32 { + return None; + } + let mut entries = vec![]; + while let Some(key) = tokens.get(*at) { + if key == "}" { + if !nested { + return None; + } + *at += 1; + return Some(VdfValue::Object(entries)); + } + if key == "{" { + return None; + } + *at += 1; + let value = tokens.get(*at)?; + *at += 1; + let value = if value == "{" { + object(tokens, at, true, depth + 1)? + } else if value == "}" { + return None; + } else { + VdfValue::Text(value.clone()) + }; + entries.push((key.clone(), value)); + } + if nested { + None + } else { + Some(VdfValue::Object(entries)) + } + } + object(&tokens, &mut 0, false, 0) +} + +fn steam_root() -> Option { + #[cfg(windows)] + { + use winreg::{RegKey, enums::HKEY_CURRENT_USER}; + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey("Software\\Valve\\Steam") + .ok()? + .get_value::("SteamPath") + .ok() + .map(PathBuf::from) + } + #[cfg(not(windows))] + { + let home = dirs::home_dir()?; + [ + home.join(".steam/steam"), + home.join(".local/share/Steam"), + home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), + ] + .into_iter() + .find(|p| p.exists()) + } +} +fn libraries(root: &Path) -> Vec { + let mut result = vec![root.to_path_buf()]; + if let Some(vdf) = fs::read_to_string(root.join("steamapps/libraryfolders.vdf")) + .ok() + .and_then(|s| parse_vdf(&s)) + && let Some(VdfValue::Object(entries)) = vdf.get("libraryfolders") + { + for (key, value) in entries { + if key.parse::().is_ok() + && let Some(path) = value + .get("path") + .and_then(VdfValue::text) + .or_else(|| value.text()) + { + result.push(PathBuf::from(path)); + } + } + } + result.sort(); + result.dedup(); + result +} +fn manifest(app_id: u32) -> Option<(String, PathBuf)> { + for root in libraries(&steam_root()?) { + let Some(vdf) = + fs::read_to_string(root.join(format!("steamapps/appmanifest_{app_id}.acf"))) + .ok() + .and_then(|s| parse_vdf(&s)) + else { + continue; + }; + let app = vdf.get("AppState")?; + let name = app.get("name")?.text()?.to_string(); + let install = root + .join("steamapps/common") + .join(app.get("installdir")?.text()?); + return Some((name, install)); + } + None +} + +#[cfg(target_os = "linux")] +pub fn discover() -> io::Result> { + let mut games = vec![]; + for entry in fs::read_dir("/proc")?.flatten() { + let Some(pid) = entry.file_name().to_str().and_then(|s| s.parse().ok()) else { + continue; + }; + if let Ok(bytes) = fs::read(entry.path().join("cmdline")) + && let Some(app_id) = reaper_app_id(&bytes) + { + games.push(Game { + app_id, + pid, + name: manifest(app_id).map_or_else(|| format!("App {app_id}"), |m| m.0), + executable: String::new(), + }); + } + } + games.sort_by_key(|g| g.pid); + Ok(games) +} + +#[cfg(target_os = "linux")] +pub struct ExitWatch { + fd: rustix::fd::OwnedFd, +} +#[cfg(target_os = "linux")] +impl ExitWatch { + pub fn open(game: &Game) -> io::Result { + let pid = rustix::process::Pid::from_raw(game.pid as i32) + .ok_or_else(|| io::Error::other("Invalid process id"))?; + let fd = rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty())?; + // Check identity after opening: a recycled PID must not attach to another game. + if reaper_app_id(&fs::read(format!("/proc/{}/cmdline", game.pid))?) != Some(game.app_id) { + return Err(io::Error::other("Game exited during discovery")); + } + Ok(Self { fd }) + } + pub fn exited(&mut self) -> io::Result { + use rustix::event::{PollFd, PollFlags, Timespec, poll}; + let mut fds = [PollFd::new(&self.fd, PollFlags::IN)]; + Ok(poll( + &mut fds, + Some(&Timespec { + tv_sec: 0, + tv_nsec: 0, + }), + )? > 0) + } +} + +#[cfg(windows)] +pub fn discover() -> io::Result> { + use winreg::{RegKey, enums::HKEY_CURRENT_USER}; + let steam = match RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Valve\\Steam") { + Ok(key) => key, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]), + Err(error) => return Err(error), + }; + let app_id = steam + .get_value::("RunningAppID") + .ok() + .filter(|id| *id > 0) + .or_else(|| { + steam + .get_value::("RunningAppID") + .ok() + .and_then(|s| parse_running_app_id(&s)) + }); + let Some(app_id) = app_id else { + return Ok(vec![]); + }; + if let Ok(app) = steam.open_subkey(format!("Apps\\{app_id}")) + && app.get_value::("Running").ok() == Some(0) + { + return Ok(vec![]); + } + let (name, directory) = manifest(app_id) + .ok_or_else(|| io::Error::other(format!("Cannot locate installation for App {app_id}")))?; + Ok(crate::windows_process::processes_in(&directory)? + .into_iter() + .map(|(pid, executable)| Game { + app_id, + pid, + name: name.clone(), + executable, + }) + .collect()) +} +#[cfg(windows)] +pub struct ExitWatch { + watch: crate::windows_process::ExitWatch, +} +#[cfg(windows)] +impl ExitWatch { + pub fn open(game: &Game) -> io::Result { + Ok(Self { + watch: crate::windows_process::ExitWatch::open(game.pid, &game.executable)?, + }) + } + pub fn exited(&mut self) -> io::Result { + self.watch.exited() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cmdline_matches_only_the_steam_reaper() { + assert_eq!( + reaper_app_id(b"/usr/bin/reaper\0SteamLaunch\0AppId=480\0--\0sleep\0"), + Some(480) + ); + for bytes in [ + b"sleep\0SteamLaunch\0AppId=480\0".as_slice(), + b"reaper\0AppId=480\0", + b"reaper\0SteamLaunch\0AppId=bad\0", + b"reaper\0SteamLaunch\0AppId=0\0", + ] { + assert_eq!(reaper_app_id(bytes), None); + } + } + #[test] + fn registry_values_are_positive_app_ids() { + assert_eq!(parse_running_app_id(" 480 "), Some(480)); + for value in ["0", "-1", "4294967296", "abc", ""] { + assert_eq!(parse_running_app_id(value), None); + } + } + #[test] + fn manifests_and_library_folders_are_tolerant() { + let app = parse_vdf("// comment\n\"AppState\" { appid 480 \"name\" \"Space \\\"War\\\"\" installdir Spacewar }").unwrap(); + assert_eq!( + app.get("appstate").unwrap().get("name").unwrap().text(), + Some("Space \"War\"") + ); + let folders = parse_vdf( + r#""libraryfolders" { "0" { "path" "C:\\Steam" "apps" { "480" "1" } } "1" "/games" }"#, + ) + .unwrap(); + assert_eq!( + folders + .get("libraryfolders") + .unwrap() + .get("0") + .unwrap() + .get("path") + .unwrap() + .text(), + Some("C:\\Steam") + ); + assert!(parse_vdf("root { name \"unterminated").is_none()); + assert!(parse_vdf("root { name value").is_none()); + } +} diff --git a/crates/clipbin-core/src/lib.rs b/crates/clipbin-core/src/lib.rs index c4c2d97..5c246c3 100644 --- a/crates/clipbin-core/src/lib.rs +++ b/crates/clipbin-core/src/lib.rs @@ -7,3 +7,11 @@ mod status; pub use library::Library; pub use settings::{Settings, VideoCodec}; pub use status::RecordingStatus; + +pub mod games; +pub mod store; + +#[cfg(windows)] +mod windows_process; + +pub mod segments; diff --git a/crates/clipbin-core/src/library.rs b/crates/clipbin-core/src/library.rs index 3780d96..a90986e 100644 --- a/crates/clipbin-core/src/library.rs +++ b/crates/clipbin-core/src/library.rs @@ -1,18 +1,19 @@ //! Recorded sessions, bookmarks and exported clips as the interface shows them. //! -//! Persistence comes with the recorder integration; until then the library is -//! empty, or filled with `Library::sample()` for interface work. +//! Loaded from the session store, or explicitly filled with `Library::sample()` +//! for interface work. +use serde::{Deserialize, Serialize}; use std::time::{Duration, SystemTime}; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Library { pub sessions: Vec, pub clips: Vec, pub storage: StorageUsage, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Session { pub id: u64, pub game: String, @@ -25,14 +26,14 @@ pub struct Session { pub bookmarks: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Span { pub start: Duration, pub end: Duration, pub kind: SpanKind, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SpanKind { Footage, /// Capture was unavailable; nothing was recorded (SPEC: recovery must not @@ -42,7 +43,7 @@ pub enum SpanKind { Removed, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Bookmark { pub at: Duration, pub before: Duration, @@ -60,7 +61,7 @@ impl Bookmark { } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Clip { pub id: u64, pub game: String, @@ -71,7 +72,7 @@ pub struct Clip { /// SPEC: the budget counts only unprotected session footage; protected /// bookmarks and exported clips are shown separately. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct StorageUsage { pub budget_bytes: u64, pub unprotected_bytes: u64, diff --git a/crates/clipbin-core/src/segments.rs b/crates/clipbin-core/src/segments.rs new file mode 100644 index 0000000..0e56a60 --- /dev/null +++ b/crates/clipbin-core/src/segments.rs @@ -0,0 +1,30 @@ +//! A split racing with stop can leave only a Matroska header, with no footage. +use std::{ + fs::File, + io::{self, Read}, + path::Path, +}; + +/// The pinned muxer writes its first Cluster immediately after a small header. +/// Inspect only that prefix, so checking a completed segment never reads its footage. +pub fn has_cluster(path: &Path) -> io::Result { + cluster_in(File::open(path)?) +} +fn cluster_in(reader: impl Read) -> io::Result { + let mut prefix = Vec::new(); + reader.take(64 * 1024).read_to_end(&mut prefix)?; + Ok(prefix.windows(4).any(|id| id == [0x1f, 0x43, 0xb6, 0x75])) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn distinguishes_a_header_only_tail_from_media() { + let header = [0x1a, 0x45, 0xdf, 0xa3, 0x80, 0x18, 0x53, 0x80, 0x67, 0xff]; + assert!(!cluster_in(header.as_slice()).unwrap()); + let mut media = header.to_vec(); + media.extend([0x1f, 0x43, 0xb6, 0x75, 0x84, 0xe7, 0x82, 0, 0]); + assert!(cluster_in(media.as_slice()).unwrap()); + } +} diff --git a/crates/clipbin-core/src/settings.rs b/crates/clipbin-core/src/settings.rs index b2068b9..dd07204 100644 --- a/crates/clipbin-core/src/settings.rs +++ b/crates/clipbin-core/src/settings.rs @@ -1,6 +1,7 @@ +use serde::{Deserialize, Serialize}; use std::{fmt, time::Duration}; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum VideoCodec { #[default] H264, @@ -18,11 +19,13 @@ impl fmt::Display for VideoCodec { } } -/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Persisted recorder preferences from SPEC.md. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] pub struct Settings { /// A fixed recording size. `None` records at the captured window's or /// monitor's own size, following its orientation. + pub portal_restore_token: Option, pub resolution: Option<(u32, u32)>, pub frames_per_second: u32, pub codec: VideoCodec, @@ -39,6 +42,7 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { Self { + portal_restore_token: None, resolution: None, frames_per_second: 60, codec: VideoCodec::H264, diff --git a/crates/clipbin-core/src/store.rs b/crates/clipbin-core/src/store.rs new file mode 100644 index 0000000..5efffda --- /dev/null +++ b/crates/clipbin-core/src/store.rs @@ -0,0 +1,347 @@ +//! Atomic session metadata; segment paths are relative to their session directory. +use crate::{ + Library, Settings, + library::{Session, Span, SpanKind}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, io, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Segment { + pub path: PathBuf, + pub start: Duration, + pub end: Duration, + pub complete: bool, + /// False for a header-only file left by a split racing with stop. + #[serde(default = "available_default")] + pub available: bool, + #[serde(default)] + pub frame_start: u64, + #[serde(default = "default_fps")] + pub frames_per_second: u32, + /// Cumulative dropped frames in this run when the segment completed. + #[serde(default)] + pub dropped_frames: u64, +} + +fn available_default() -> bool { + true +} +fn default_fps() -> u32 { + 60 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metadata { + pub session: Session, + pub app_id: u32, + pub segments: Vec, +} + +#[derive(Clone)] +pub struct Store { + pub root: PathBuf, +} + +pub fn config_path() -> io::Result { + Ok(dirs::config_dir() + .ok_or_else(|| io::Error::other("No configuration directory"))? + .join("clipbin/settings.json")) +} + +pub fn load_settings() -> io::Result { + match fs::read(config_path()?) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Settings::default()), + Err(e) => Err(e), + } +} +pub fn save_settings(settings: &Settings) -> io::Result<()> { + atomic_json(&config_path()?, settings) +} + +/// Same-directory replacement keeps readers and crash recovery away from partial JSON. +pub fn atomic_json(path: &Path, value: &impl Serialize) -> io::Result<()> { + fs::create_dir_all( + path.parent() + .ok_or_else(|| io::Error::other("No parent directory"))?, + )?; + let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + serde_json::to_writer_pretty(temporary.as_file_mut(), value).map_err(io::Error::other)?; + temporary.as_file().sync_all()?; + temporary.persist(path).map_err(|e| e.error)?; + #[cfg(unix)] + fs::File::open(path.parent().unwrap())?.sync_all()?; + Ok(()) +} + +/// Frame offsets never depend on container timestamps (each segment starts at zero). +pub fn frame_offset(frames: u64, fps: u32) -> Duration { + let fps = u64::from(fps.max(1)); + Duration::new(frames / fps, ((frames % fps) * 1_000_000_000 / fps) as u32) +} + +/// The keyframe triggering `file_changed` is counted already, but is the new +/// segment's first frame. Zero identifies the initial `OutputStarted` segment. +pub fn segment_offset(total_frames: u64, fps: u32) -> Duration { + frame_offset(total_frames.saturating_sub(1), fps) +} + +impl Store { + pub fn discover() -> io::Result { + let root = dirs::data_local_dir() + .ok_or_else(|| io::Error::other("No data directory"))? + .join(if cfg!(windows) { "Clipbin" } else { "clipbin" }); + Ok(Self { + root: root.join("sessions"), + }) + } + pub fn directory(&self, id: u64) -> PathBuf { + self.root.join(id.to_string()) + } + pub fn save(&self, metadata: &Metadata) -> io::Result<()> { + atomic_json( + &self.directory(metadata.session.id).join("session.json"), + metadata, + ) + } + pub fn create(&self, app_id: u32, game: String) -> io::Result { + let started = SystemTime::now(); + let mut id = started + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + while self.directory(id).exists() { + id += 1; + } + let metadata = Metadata { + session: Session { + id, + game, + started, + length: Duration::ZERO, + active: true, + spans: vec![], + bookmarks: vec![], + }, + app_id, + segments: vec![], + }; + self.save(&metadata)?; + Ok(metadata) + } + pub fn read(&self) -> io::Result> { + let entries = match fs::read_dir(&self.root) { + Ok(e) => e, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e), + }; + let mut sessions = vec![]; + for entry in entries { + let path = entry?.path().join("session.json"); + if path.exists() { + let bytes = fs::read(&path)?; + sessions.push( + serde_json::from_slice(&bytes) + .map_err(|e| io::Error::other(format!("{}: {e}", path.display())))?, + ); + } + } + Ok(sessions) + } + /// Active metadata cannot belong to a live recorder during initial startup. + pub fn recover(&self) -> io::Result<()> { + for mut metadata in self.read()? { + if metadata.session.active { + metadata.session.active = false; + for segment in &mut metadata.segments { + segment.complete = true; + } + self.save(&metadata)?; + } + } + Ok(()) + } + pub fn library(&self, budget: u64) -> io::Result { + let mut library = Library::empty(budget); + for metadata in self.read()? { + for segment in &metadata.segments { + let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path)) + .map_or(0, |f| f.len()); + if metadata.session.bookmarks.iter().any(|b| { + let (start, end) = b.range(metadata.session.length); + b.protected && start < segment.end && end > segment.start + }) { + library.storage.protected_bytes += bytes; + } else { + library.storage.unprotected_bytes += bytes; + } + } + library.sessions.push(metadata.session); + } + library + .sessions + .sort_by_key(|s| std::cmp::Reverse(s.started)); + Ok(library) + } +} + +impl Metadata { + pub fn update_run( + &mut self, + span_base: usize, + footage_start: Option, + footage_end: Duration, + elapsed: Duration, + ) { + self.session.spans.truncate(span_base); + if let Some(start) = footage_start { + let previous = self.session.spans.last().map_or(Duration::ZERO, |s| s.end); + if start > previous { + self.session.spans.push(Span { + start: previous, + end: start, + kind: SpanKind::Missing, + }); + } + if footage_end > start { + self.session.spans.push(Span { + start, + end: footage_end, + kind: SpanKind::Footage, + }); + } + } else { + let start = self.session.spans.last().map_or(Duration::ZERO, |s| s.end); + if elapsed > start { + self.session.spans.push(Span { + start, + end: elapsed, + kind: SpanKind::Missing, + }); + } + } + self.session.length = elapsed.max(footage_end); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn offsets_use_frames_without_accumulating_container_rounding() { + assert_eq!(segment_offset(0, 60), Duration::ZERO); + assert_eq!(segment_offset(121, 60), Duration::from_secs(2)); + assert_eq!(segment_offset(241, 60), Duration::from_secs(4)); + assert_eq!(segment_offset(361, 60), Duration::from_secs(6)); + assert_eq!(frame_offset(120, 60), Duration::from_secs(2)); + assert_eq!(frame_offset(121, 60), Duration::from_nanos(2_016_666_666)); + assert_eq!(frame_offset(216_000, 60), Duration::from_secs(3600)); + } + #[test] + fn recovery_preserves_footage_and_a_wall_clock_gap() { + let directory = tempfile::tempdir().unwrap(); + let store = Store { + root: directory.path().into(), + }; + let mut metadata = store.create(480, "Game".into()).unwrap(); + metadata.update_run( + 0, + Some(Duration::from_secs(1)), + Duration::from_secs(5), + Duration::from_secs(5), + ); + let base = metadata.session.spans.len(); + metadata.update_run(base, None, Duration::ZERO, Duration::from_secs(8)); + assert_eq!(metadata.session.missing_time(), Duration::from_secs(4)); + metadata.update_run( + base, + Some(Duration::from_secs(10)), + Duration::from_secs(12), + Duration::from_secs(12), + ); + assert_eq!(metadata.session.missing_time(), Duration::from_secs(6)); + assert_eq!( + metadata.session.spans.last().unwrap().kind, + SpanKind::Footage + ); + } + #[test] + fn atomic_writes_leave_readers_a_complete_document() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + atomic_json(&path, &Settings::default()).unwrap(); + std::thread::scope(|scope| { + let reader = scope.spawn(|| { + for _ in 0..100 { + let bytes = fs::read(&path).unwrap(); + serde_json::from_slice::(&bytes).unwrap(); + } + }); + for rate in [30, 60, 120] { + atomic_json( + &path, + &Settings { + frames_per_second: rate, + ..Settings::default() + }, + ) + .unwrap(); + } + reader.join().unwrap(); + }); + } + #[test] + fn round_trip_atomic_replace_and_crash_recovery() { + let root = std::env::temp_dir().join(format!( + "clipbin-store-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let store = Store { root }; + let mut metadata = store.create(480, "Test game".into()).unwrap(); + metadata.session.length = Duration::from_secs(7); + metadata.segments.push(Segment { + path: PathBuf::from("segment.mkv"), + start: Duration::ZERO, + end: Duration::from_secs(7), + complete: false, + available: true, + frame_start: 0, + frames_per_second: 60, + dropped_frames: 0, + }); + fs::write( + store.directory(metadata.session.id).join("segment.mkv"), + [0u8; 13], + ) + .unwrap(); + store.save(&metadata).unwrap(); + assert!( + !store + .directory(metadata.session.id) + .join("session.json.tmp") + .exists() + ); + assert_eq!(store.read().unwrap()[0].session, metadata.session); + fs::write( + store.directory(metadata.session.id).join("interrupted.tmp"), + b"{partial", + ) + .unwrap(); + store.recover().unwrap(); + let library = store.library(100).unwrap(); + assert!(!library.sessions[0].active); + assert_eq!(library.sessions[0].length, Duration::from_secs(7)); + assert_eq!(library.storage.unprotected_bytes, 13); + assert!(store.read().unwrap()[0].segments[0].complete); + fs::remove_dir_all(store.root).unwrap(); + } +} diff --git a/crates/clipbin-core/src/windows_process.rs b/crates/clipbin-core/src/windows_process.rs new file mode 100644 index 0000000..f066493 --- /dev/null +++ b/crates/clipbin-core/src/windows_process.rs @@ -0,0 +1,164 @@ +//! Small Windows process bridge. The rest of the detection code stays safe Rust. +#![allow(unsafe_code)] +use std::{ + io, + path::Path, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver}, + }, + thread::{self, JoinHandle}, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }, + Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + QueryFullProcessImageNameW, WaitForSingleObject, + }, + }, +}; + +// The numeric handle can be transferred to the waiting thread; Windows process +// handles are valid across threads. Ownership still has exactly one Drop. +struct Handle(usize); +impl Handle { + fn raw(&self) -> HANDLE { + self.0 as HANDLE + } + fn process(pid: u32) -> io::Result { + // SAFETY: valid access flags and PID; ownership of the result is taken here. + let raw = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + 0, + pid, + ) + }; + if raw.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(Self(raw as usize)) + } + } + fn path(&self) -> io::Result { + let mut buffer = vec![0u16; 32768]; + let mut length = buffer.len() as u32; + // SAFETY: the handle is owned, and the writable buffer has the stated capacity. + if unsafe { QueryFullProcessImageNameW(self.raw(), 0, buffer.as_mut_ptr(), &mut length) } + == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(String::from_utf16_lossy(&buffer[..length as usize])) + } +} +impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: Handle owns a successful API result and closes it exactly once. + unsafe { + CloseHandle(self.raw()); + } + } +} + +pub fn processes_in(directory: &Path) -> io::Result> { + // SAFETY: flags request process enumeration; no caller pointers are involved. + let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if raw == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let snapshot = Handle(raw as usize); + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let prefix = format!( + "{}\\", + directory.to_string_lossy().trim_end_matches(['\\', '/']) + ) + .to_lowercase(); + let mut result = vec![]; + // SAFETY: snapshot remains live and entry is correctly sized and writable. + let mut found = unsafe { Process32FirstW(snapshot.raw(), &mut entry) } != 0; + while found { + // Access-denied system processes and processes exiting during enumeration + // are expected and cannot belong to the selected installation. + if let Ok(process) = Handle::process(entry.th32ProcessID) + && let Ok(path) = process.path() + && path.to_lowercase().starts_with(&prefix) + && let Some(executable) = Path::new(&path).file_name().and_then(|s| s.to_str()) + { + result.push((entry.th32ProcessID, executable.into())); + } + // SAFETY: as for Process32FirstW; snapshot and entry are still live. + found = unsafe { Process32NextW(snapshot.raw(), &mut entry) } != 0; + } + result.sort_by_key(|(pid, _)| *pid); + Ok(result) +} + +pub struct ExitWatch { + events: Receiver>, + cancel: Arc, + thread: Option>, +} +impl ExitWatch { + pub fn open(pid: u32, executable: &str) -> io::Result { + let handle = Handle::process(pid)?; + if !Path::new(&handle.path()?) + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(executable)) + { + return Err(io::Error::other("Game exited during discovery")); + } + let (sender, events) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let cancelled = cancel.clone(); + let thread = thread::Builder::new() + .name("clipbin-game-exit".into()) + .spawn(move || { + while !cancelled.load(Ordering::Relaxed) { + // SAFETY: the thread owns a live process handle with SYNCHRONIZE + // access. A finite wait lets Drop cancel without leaking a waiter. + let result = unsafe { WaitForSingleObject(handle.raw(), 250) }; + match result { + WAIT_OBJECT_0 => { + let _ = sender.send(Ok(())); + break; + } + WAIT_TIMEOUT => {} + _ => { + let _ = sender.send(Err(io::Error::last_os_error())); + break; + } + } + } + })?; + Ok(Self { + events, + cancel, + thread: Some(thread), + }) + } + pub fn exited(&self) -> io::Result { + match self.events.try_recv() { + Ok(result) => result.map(|()| true), + Err(mpsc::TryRecvError::Empty) => Ok(false), + Err(mpsc::TryRecvError::Disconnected) => Ok(true), + } + } +} +impl Drop for ExitWatch { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} diff --git a/crates/clipbin-desktop/Cargo.toml b/crates/clipbin-desktop/Cargo.toml index e314e59..44aabd0 100644 --- a/crates/clipbin-desktop/Cargo.toml +++ b/crates/clipbin-desktop/Cargo.toml @@ -10,6 +10,7 @@ publish.workspace = true [dependencies] clipbin-core.workspace = true +clipbin-obs.workspace = true gpui.workspace = true [lints] diff --git a/crates/clipbin-desktop/build.rs b/crates/clipbin-desktop/build.rs new file mode 100644 index 0000000..65b4420 --- /dev/null +++ b/crates/clipbin-desktop/build.rs @@ -0,0 +1,56 @@ +//! Makes development builds run against the OBS runtime in target/obs-runtime. +//! +//! OBS starts `obs-ffmpeg-mux` from the running executable's directory, and on +//! Windows the OBS DLLs must sit beside the executable too, so they are linked +//! (Unix) or copied (Windows) into the Cargo profile directory. A symlink keeps +//! the helper's `$ORIGIN/../lib` runpath pointing into the runtime. Packaged +//! builds place the executable in the runtime's own bin directory instead (see +//! clipbin_obs::RuntimeLayout). +use std::{env, fs, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=CLIPBIN_OBS_RUNTIME_DIR"); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + + if target_os == "linux" { + // Packaged layout: /bin/ loads /lib/libobs.so. + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN/../lib"); + } + + let Some(runtime_dir) = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") else { + return; + }; + let runtime = PathBuf::from(runtime_dir).join(format!("{target_os}-{arch}")); + // OUT_DIR is /build/-/out. + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let profile_dir = out_dir.ancestors().nth(3).unwrap().to_path_buf(); + + let (bin_dir, lib_dir) = match target_os.as_str() { + "windows" => (runtime.join("bin").join("64bit"), None), + _ => (runtime.join("bin"), Some(runtime.join("lib"))), + }; + if let Some(lib_dir) = lib_dir { + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{}", lib_dir.display()); + } + let Ok(entries) = fs::read_dir(&bin_dir) else { + println!( + "cargo:warning=OBS runtime not found at {}; run scripts/obs/ to assemble it", + runtime.display() + ); + return; + }; + println!("cargo:rerun-if-changed={}", bin_dir.display()); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let destination = profile_dir.join(entry.file_name()); + let _ = fs::remove_file(&destination); + #[cfg(unix)] + std::os::unix::fs::symlink(&path, &destination).unwrap(); + #[cfg(not(unix))] + fs::copy(&path, &destination).unwrap(); + } +} diff --git a/crates/clipbin-desktop/src/app.rs b/crates/clipbin-desktop/src/app.rs index 417b405..7c977ca 100644 --- a/crates/clipbin-desktop/src/app.rs +++ b/crates/clipbin-desktop/src/app.rs @@ -68,6 +68,7 @@ pub struct ClipbinApp { /// Where the timeline was last laid out, to turn pointer positions into times. pub timeline_bounds: Rc>>>, pub fields: SettingsFields, + pub service: crate::recorder_service::Client, } /// The typed settings and the fields that edit them. @@ -106,6 +107,7 @@ impl SettingsFields { window, move |app, _, Committed(text), window, cx| { if apply(&mut app.settings, text) { + app.save_settings(); app.fields.refresh(&app.settings, window, cx); cx.notify(); } @@ -185,8 +187,12 @@ impl SettingsFields { } impl ClipbinApp { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let settings = Settings::default(); + pub fn new( + window: &mut Window, + cx: &mut Context, + settings: Settings, + service: crate::recorder_service::Client, + ) -> Self { let fields = SettingsFields::new(&settings, window, cx); // Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with // example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and @@ -232,6 +238,7 @@ impl ClipbinApp { drag: None, timeline_bounds: Rc::default(), fields, + service, }; match std::env::var("CLIPBIN_ROUTE").as_deref() { Ok("clips") => app.route = Route::Clips, @@ -248,9 +255,45 @@ impl ClipbinApp { } _ => {} } + if !sample { + cx.spawn(async move |entity, cx| { + loop { + gpui::Timer::after(Duration::from_millis(250)).await; + if entity + .update(cx, |app, cx| { + let snapshot = app.service.snapshot(); + if app.recorder.status != snapshot.status + || app.recorder.game != snapshot.game + || app.library != snapshot.library + || app.settings.portal_restore_token != snapshot.restore_token + { + app.recorder.status = snapshot.status; + app.recorder.game = snapshot.game; + if let Route::Session(id) = app.route + && let Some(session) = snapshot.library.session(id) + { + app.editor.view.set_length(session.length); + } + app.library = snapshot.library; + app.settings.portal_restore_token = snapshot.restore_token; + cx.notify(); + } + }) + .is_err() + { + break; + } + } + }) + .detach(); + } app } + pub fn save_settings(&self) { + self.service.settings(self.settings.clone()); + } + pub fn navigate(&mut self, route: Route, cx: &mut Context) { self.notice = None; match route { @@ -530,8 +573,8 @@ impl ClipbinApp { Some("Choose capture source"), ) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.notice = - Some("Choosing a source arrives with the recorder integration.".into()); + this.settings.portal_restore_token = None; + this.service.choose_source(); cx.notify(); })), ) diff --git a/crates/clipbin-desktop/src/main.rs b/crates/clipbin-desktop/src/main.rs index d59234f..a769075 100644 --- a/crates/clipbin-desktop/src/main.rs +++ b/crates/clipbin-desktop/src/main.rs @@ -6,6 +6,7 @@ use gpui::{ mod app; mod assets; mod components; +mod recorder_service; mod screens; mod text_field; mod theme; @@ -14,24 +15,39 @@ mod timeline_view; actions!(clipbin, [Quit]); fn main() { + let (settings, initial_error) = match clipbin_core::store::load_settings() { + Ok(settings) => (settings, None), + Err(error) => ( + clipbin_core::Settings::default(), + Some(format!("Could not load settings: {error}")), + ), + }; + let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error); + let interface_service = service.clone(); Application::new() .with_assets(assets::Assets) .run(|cx: &mut App| { if let Err(error) = assets::load_fonts(cx) { eprintln!("Could not load the interface font: {error}"); } - cx.on_action(|_: &Quit, cx| cx.quit()); + let quitter = interface_service.clone(); + cx.on_action(move |_: &Quit, cx| { + quitter.quit(); + let service = quitter.clone(); + cx.spawn(async move |cx| { + loop { + gpui::Timer::after(std::time::Duration::from_millis(100)).await; + if service.snapshot().exited { + let _ = cx.update(|cx| cx.quit()); + break; + } + } + }) + .detach(); + }); cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]); text_field::bind_keys(cx); - // This shell has no background recorder yet. - cx.on_window_closed(|cx| { - if cx.windows().is_empty() { - cx.quit(); - } - }) - .detach(); - let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx); let result = cx.open_window( WindowOptions { @@ -44,7 +60,9 @@ fn main() { app_id: Some("dev.clipbin.Clipbin".into()), ..Default::default() }, - |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx)), + |window, cx| { + cx.new(|cx| app::ClipbinApp::new(window, cx, settings, interface_service)) + }, ); if let Err(error) = result { @@ -55,4 +73,10 @@ fn main() { cx.activate(true); }); + // GPUI 0.2.2 ends its Linux event loop after the last window closes. + // Finish the current session after closing; stop discovery and exit when + // its game ends. Reopening the window needs the future tray integration. + service.window_closed(); + let _ = worker.join(); + drop(service); } diff --git a/crates/clipbin-desktop/src/recorder_service.rs b/crates/clipbin-desktop/src/recorder_service.rs new file mode 100644 index 0000000..32e5d49 --- /dev/null +++ b/crates/clipbin-desktop/src/recorder_service.rs @@ -0,0 +1,565 @@ +//! One worker owns OBS; the interface sends commands and reads cheap snapshots. +use clipbin_core::{ + Library, RecordingStatus, Settings, + games::{self, ExitWatch, Game}, + library::{Span, SpanKind}, + store::{self, Metadata, Segment, Store, frame_offset, segment_offset}, +}; +use clipbin_obs::{ + Engine, RecorderEvent, Recording, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, + capture, +}; +use std::{ + sync::{ + Arc, Mutex, + mpsc::{self, Receiver, Sender}, + }, + thread, + time::{Duration, Instant}, +}; + +#[derive(Clone)] +pub struct Snapshot { + pub status: RecordingStatus, + pub game: Option, + pub library: Library, + pub restore_token: Option, + pub exited: bool, +} +#[derive(Clone)] +pub struct Client { + commands: Sender, + snapshot: Arc>, +} +enum Command { + Settings(Settings), + ChooseSource, + WindowClosed, + Quit, +} +impl Client { + pub fn start( + settings: Settings, + initial_error: Option, + ) -> (Self, thread::JoinHandle<()>) { + let (commands, receiver) = mpsc::channel(); + let snapshot = Arc::new(Mutex::new(Snapshot { + status: initial_error.map_or(RecordingStatus::Idle, |reason| RecordingStatus::Failed { + reason, + }), + game: None, + library: Library::empty(settings.unprotected_session_limit_bytes), + restore_token: settings.portal_restore_token.clone(), + exited: false, + })); + let state = snapshot.clone(); + let worker = thread::Builder::new() + .name("clipbin-recorder".into()) + .spawn(move || { + if let Err(error) = worker(settings, receiver, &state) { + state.lock().unwrap().status = RecordingStatus::Failed { reason: error }; + } + }) + .expect("start recorder worker"); + let client = Self { commands, snapshot }; + (client, worker) + } + pub fn snapshot(&self) -> Snapshot { + self.snapshot.lock().unwrap().clone() + } + pub fn settings(&self, settings: Settings) { + let _ = self.commands.send(Command::Settings(settings)); + } + pub fn choose_source(&self) { + let _ = self.commands.send(Command::ChooseSource); + } + pub fn window_closed(&self) { + let _ = self.commands.send(Command::WindowClosed); + } + pub fn quit(&self) { + if self.commands.send(Command::Quit).is_err() { + self.snapshot.lock().unwrap().exited = true; + } + } +} + +struct Active { + game: Game, + watch: ExitWatch, + metadata: Metadata, + started: Instant, + recording: Option, + tracker: StatusTracker, + run: u32, + run_base: Option, + span_base: usize, + last_frames: u64, + last_dropped: u64, + fps: u32, + stopping: bool, + ending: bool, + retry_at: Instant, + retries: u32, +} +impl Active { + fn start_run( + &mut self, + engine: &Engine, + settings: &Settings, + store: &Store, + ) -> Result<(), String> { + let encoder = engine + .hardware_encoders(settings.codec) + .into_iter() + .next() + .ok_or_else(|| format!("No hardware {} encoder", settings.codec))?; + #[cfg(windows)] + let target = capture::CaptureTarget::Game { + window: capture::WindowMatch { + obs_window: format!("::{}", self.game.executable), + restore_token: None, + }, + }; + #[cfg(not(windows))] + let target = capture::CaptureTarget::Monitor { + monitor: capture::MonitorMatch { + obs_monitor: String::new(), + restore_token: settings.portal_restore_token.clone(), + }, + }; + #[cfg(windows)] + let video_source = target.video_source(); + #[cfg(not(windows))] + let video_source = target.video_source( + capture::LinuxSession::detect().ok_or("No Wayland or X11 display for capture")?, + ); + self.fps = settings.frames_per_second; + self.run += 1; + self.run_base = None; + self.span_base = self.metadata.session.spans.len(); + self.last_frames = 0; + self.last_dropped = 0; + self.tracker = StatusTracker::new( + Instant::now(), + StatusTracker::DEFAULT_MIN_FREE_BYTES, + Duration::from_secs(2), + ); + self.recording = Some( + engine + .start_recording(RecordingConfig { + frames_per_second: Some(settings.frames_per_second), + video_source: Some(video_source), + system_audio: settings + .system_audio_enabled + .then(capture::system_audio_source), + microphone: settings.microphone_enabled.then(capture::microphone_source), + resolution: settings.resolution, + video_encoder: encoder, + video_bitrate_kbps: 12_000, + keyframe_interval_seconds: 1, + segment_seconds: 2, + segment_dir: store + .directory(self.metadata.session.id) + .join(format!("run-{:03}", self.run)), + }) + .map_err(|e| e.to_string())?, + ); + self.stopping = false; + Ok(()) + } + fn update_timeline(&mut self) { + let end = self.run_base.map_or(Duration::ZERO, |base| { + base + frame_offset(self.last_frames, self.fps) + }); + self.metadata + .update_run(self.span_base, self.run_base, end, self.started.elapsed()); + if let Some(segment) = self.metadata.segments.last_mut() + && !segment.complete + { + segment.end = end.max(segment.start); + } + } + fn finish_run(&mut self, store: &Store) -> Result<(), String> { + let mut empty_tail = false; + if let Some(segment) = self.metadata.segments.last_mut() + && !segment.complete + { + let path = store + .directory(self.metadata.session.id) + .join(&segment.path); + if !clipbin_core::segments::has_cluster(&path).map_err(|e| e.to_string())? { + eprintln!( + "Warning: {} has no media cluster; its tail is Missing", + path.display() + ); + segment.available = false; + segment.end = segment.start; + self.last_frames = segment.frame_start; + match std::fs::remove_file(&path) { + Ok(()) => empty_tail = true, + Err(error) => { + eprintln!("Could not remove empty segment: {error}; marked unavailable") + } + } + } + } + if empty_tail { + self.metadata.segments.pop(); + } + self.update_timeline(); + if let Some(segment) = self.metadata.segments.last_mut() + && !segment.complete + { + segment.complete = true; + segment.dropped_frames = self.last_dropped; + if self.last_dropped > 0 { + eprintln!( + "Warning: {} frames dropped in run {}; segment offsets may drift", + self.last_dropped, self.run + ); + } + } + self.span_base = self.metadata.session.spans.len(); + self.run_base = None; + self.recording = None; + self.stopping = false; + self.retry_at = Instant::now() + Duration::from_secs(5); + Ok(()) + } + fn finish_session(&mut self) { + self.metadata.session.length = self.started.elapsed().max(self.metadata.session.length); + let end = self + .metadata + .session + .spans + .last() + .map_or(Duration::ZERO, |s| s.end); + if self.metadata.session.length > end { + self.metadata.session.spans.push(Span { + start: end, + end: self.metadata.session.length, + kind: SpanKind::Missing, + }); + } + self.metadata.session.active = false; + } +} + +fn fail(snapshot: &Arc>, reason: impl ToString) { + snapshot.lock().unwrap().status = RecordingStatus::Failed { + reason: reason.to_string(), + }; +} +fn worker( + mut settings: Settings, + commands: Receiver, + snapshot: &Arc>, +) -> Result<(), String> { + let store = Store::discover().map_err(|e| e.to_string())?; + store.recover().map_err(|e| e.to_string())?; + snapshot.lock().unwrap().library = store + .library(settings.unprotected_session_limit_bytes) + .map_err(|e| e.to_string())?; + // An absent runtime is an interface-visible error; keep accepting commands. + let engine = RuntimeLayout::discover() + .map_err(|e| e.to_string()) + .and_then(|layout| { + Engine::start(&layout, settings.frames_per_second).map_err(|e| e.to_string()) + }); + let engine = match engine { + Ok(engine) => Some(engine), + Err(e) => { + fail(snapshot, e); + None + } + }; + let mut active: Option = None; + let mut quitting = false; + let mut finish_after_session = false; + let mut next_scan = Instant::now(); + let mut next_poll = Instant::now(); + let mut next_save = Instant::now(); + let mut previous_status = None; + loop { + match commands.recv_timeout(Duration::from_millis(200)) { + Ok(Command::WindowClosed) => { + finish_after_session = true; + eprintln!("Window closed: finishing the current game session, then exiting"); + } + Ok(Command::Quit) => { + quitting = true; + if let Some(a) = &mut active { + a.ending = true; + if !a.stopping + && let Some(r) = &a.recording + { + r.stop(); + a.stopping = true; + } + } + } + Ok(Command::Settings(new)) => { + settings = new; + snapshot.lock().unwrap().library.storage.budget_bytes = + settings.unprotected_session_limit_bytes; + if let Err(e) = store::save_settings(&settings) { + fail(snapshot, format!("Could not save settings: {e}")); + } + snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone(); + } + Ok(Command::ChooseSource) => { + settings.portal_restore_token = None; + if let Err(e) = store::save_settings(&settings) { + fail(snapshot, e); + } + snapshot.lock().unwrap().restore_token = None; + if let Some(a) = &mut active { + a.retries = 0; + a.retry_at = Instant::now(); + if !a.stopping + && let Some(r) = &a.recording + { + r.stop(); + a.stopping = true; + } + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if (quitting || finish_after_session) && active.is_none() { + break; + } + let now = Instant::now(); + if active.is_none() && !quitting && now >= next_scan { + next_scan = now + Duration::from_secs(1); + match games::discover() { + Ok(games) => { + if let Some(game) = games.into_iter().next() + && let Some(engine) = &engine + { + match ExitWatch::open(&game).and_then(|watch| { + store + .create(game.app_id, game.name.clone()) + .map(|metadata| (watch, metadata)) + }) { + Ok((watch, metadata)) => { + let mut a = Active { + game, + watch, + metadata, + started: now, + recording: None, + tracker: StatusTracker::new(now, 0, Duration::from_secs(2)), + run: 0, + run_base: None, + span_base: 0, + last_frames: 0, + last_dropped: 0, + fps: settings.frames_per_second, + stopping: false, + ending: false, + retry_at: now + Duration::from_secs(5), + retries: 0, + }; + snapshot.lock().unwrap().game = Some(a.game.name.clone()); + match a.start_run(engine, &settings, &store) { + Ok(()) => { + let mut state = snapshot.lock().unwrap(); + if !matches!(state.status, RecordingStatus::Failed { .. }) { + state.status = RecordingStatus::Starting; + } + } + Err(e) => fail(snapshot, e), + } + active = Some(a); + } + Err(e) => fail(snapshot, e), + } + } + } + Err(e) => fail(snapshot, e), + } + } + if let Some(a) = &mut active { + // Process exit has priority over health: closing a game is normal. + match a.watch.exited() { + Ok(true) => a.ending = true, + Err(e) => { + fail(snapshot, format!("Could not watch game exit: {e}")); + a.ending = true; + } + Ok(false) => {} + } + if a.ending + && !a.stopping + && let Some(r) = &a.recording + { + r.stop(); + a.stopping = true; + } + if !a.ending + && a.recording.is_none() + && a.retries < 3 + && now >= a.retry_at + && let Some(engine) = &engine + { + a.retries += 1; + a.retry_at = now + Duration::from_secs(5); + if let Err(e) = a.start_run(engine, &settings, &store) { + fail(snapshot, e); + } + } + let mut stopped = false; + if let Some(r) = &a.recording { + if now >= next_poll { + next_poll = now + Duration::from_secs(1); + match r.poll() { + Ok(health) => { + a.last_frames = health.total_frames; + a.last_dropped = health.dropped_frames; + if health.output_active && a.run_base.is_none() { + a.run_base = Some( + a.started + .elapsed() + .saturating_sub(frame_offset(health.total_frames, a.fps)), + ); + } + if !a.ending && !a.stopping { + let status = a.tracker.sample(&health, now).clone(); + if status == RecordingStatus::Recording { + a.retries = 0; + } + // Keep a failure visible throughout recovery until footage is proven. + if status == RecordingStatus::Recording + || !matches!( + snapshot.lock().unwrap().status, + RecordingStatus::Failed { .. } + ) + { + snapshot.lock().unwrap().status = status.clone(); + } + if matches!(status, RecordingStatus::Failed { .. }) { + r.stop(); + a.stopping = true; + } + } + if let Some(token) = r.restore_token() + && settings.portal_restore_token.as_ref() != Some(&token) + { + settings.portal_restore_token = Some(token.clone()); + snapshot.lock().unwrap().restore_token = Some(token); + if let Err(e) = store::save_settings(&settings) { + fail(snapshot, e); + } + } + } + Err(e) => { + fail(snapshot, e); + if !a.stopping { + r.stop(); + a.stopping = true; + } + } + } + } + for event in r.events().try_iter() { + match event { + RecorderEvent::SegmentStarted { + path, + total_frames, + dropped_frames, + } => { + let base = *a.run_base.get_or_insert_with(|| { + a.started + .elapsed() + .saturating_sub(frame_offset(a.last_frames, a.fps)) + }); + let offset = base + segment_offset(total_frames, a.fps); + if let Some(previous) = a.metadata.segments.last_mut() + && !previous.complete + { + previous.end = offset; + previous.complete = true; + previous.dropped_frames = dropped_frames; + if dropped_frames > 0 { + eprintln!( + "Warning: {dropped_frames} frames dropped in run {}; segment offsets may drift", + a.run + ); + } + } + let path = std::path::PathBuf::from(path); + let relative = path + .strip_prefix(store.directory(a.metadata.session.id)) + .map_err(|e| e.to_string())? + .to_path_buf(); + a.metadata.segments.push(Segment { + path: relative, + start: offset, + end: offset, + complete: false, + available: true, + frame_start: total_frames.saturating_sub(1), + frames_per_second: a.fps, + dropped_frames: 0, + }); + } + RecorderEvent::OutputStopped { code, last_error } => { + if !a.stopping && !a.ending { + fail(snapshot, "The recording output stopped unexpectedly"); + } + if code != 0 { + snapshot.lock().unwrap().status = a + .tracker + .stopped(StopReason::from_code(code), last_error.as_deref()) + .clone(); + } + // Read final cumulative frame count after output finalisation. + if let Ok(health) = r.poll() { + a.last_frames = health.total_frames; + a.last_dropped = health.dropped_frames; + } + stopped = true; + } + _ => {} + } + } + } + if stopped { + a.finish_run(&store)?; + } + a.update_timeline(); + if now >= next_save || stopped || a.ending { + next_save = now + Duration::from_secs(1); + if a.ending && a.recording.is_none() { + a.finish_session(); + store.save(&a.metadata).map_err(|e| e.to_string())?; + let mut state = snapshot.lock().unwrap(); + if !matches!(state.status, RecordingStatus::Failed { .. }) { + state.status = RecordingStatus::Idle; + } + drop(state); + snapshot.lock().unwrap().game = None; + active = None; + } else { + store.save(&a.metadata).map_err(|e| e.to_string())?; + } + match store.library(settings.unprotected_session_limit_bytes) { + Ok(library) => snapshot.lock().unwrap().library = library, + Err(e) => fail(snapshot, e), + } + } + } + let status = snapshot.lock().unwrap().status.clone(); + if previous_status.as_ref() != Some(&status) { + eprintln!("recorder_status={status:?}"); + previous_status = Some(status); + } + } + drop(active); + drop(engine); + eprintln!("Recorder stopped; exiting"); + snapshot.lock().unwrap().exited = true; + Ok(()) +} diff --git a/crates/clipbin-desktop/src/screens/settings.rs b/crates/clipbin-desktop/src/screens/settings.rs index f308231..985d606 100644 --- a/crates/clipbin-desktop/src/screens/settings.rs +++ b/crates/clipbin-desktop/src/screens/settings.rs @@ -77,7 +77,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context) -> AnyElement { .max_w(px(820.)) .child(page_header( "Settings", - "Changes apply now. Saving them between launches comes with settings storage.", + "Settings are saved automatically. Recording changes apply to the next capture run.", Vec::new(), )) .child( @@ -259,6 +259,7 @@ fn segmented( .child(*label) .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| { update(&mut app.settings); + app.save_settings(); cx.notify(); })) }), @@ -289,6 +290,7 @@ fn toggle( .child(div().size_4().rounded_full().bg(theme::foreground())) .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| { update(&mut app.settings); + app.save_settings(); cx.notify(); })) } diff --git a/crates/clipbin-desktop/src/timeline_view.rs b/crates/clipbin-desktop/src/timeline_view.rs index d3e9c77..9e5392f 100644 --- a/crates/clipbin-desktop/src/timeline_view.rs +++ b/crates/clipbin-desktop/src/timeline_view.rs @@ -21,6 +21,15 @@ impl TimelineView { } } + /// New footage extends a full view without moving a user's zoomed range. + pub fn set_length(&mut self, length: Duration) { + let full = !self.is_zoomed(); + self.length = length; + if full { + self.span = length.max(Duration::from_secs(1)); + } + } + pub fn start(&self) -> Duration { self.start } @@ -79,6 +88,17 @@ mod tests { const HOUR: Duration = Duration::from_secs(3600); + #[test] + fn new_footage_extends_the_full_view_and_preserves_a_zoomed_view() { + let mut view = TimelineView::full(Duration::from_secs(20)); + view.set_length(Duration::from_secs(40)); + assert_eq!(view.end(), Duration::from_secs(40)); + view.zoom_by(0.5, 0.5); + let range = (view.start(), view.end()); + view.set_length(Duration::from_secs(60)); + assert_eq!((view.start(), view.end()), range); + assert_eq!(view.time_at(1.0), range.1); + } #[test] fn zooming_keeps_the_time_under_the_pointer() { let mut view = TimelineView::full(HOUR); diff --git a/crates/clipbin-obs/src/ffi.rs b/crates/clipbin-obs/src/ffi.rs index 46b4bf4..832b957 100644 --- a/crates/clipbin-obs/src/ffi.rs +++ b/crates/clipbin-obs/src/ffi.rs @@ -521,11 +521,15 @@ struct SignalContext { } impl SignalContext { - fn segment_started(&self, path: String) { + fn segment_started(&self, path: String, total_frames: u64, dropped_frames: u64) { if let Ok(mut current) = self.current_segment.lock() { *current = Some(path.clone()); } - self.send(RecorderEvent::SegmentStarted { path }); + self.send(RecorderEvent::SegmentStarted { + path, + total_frames, + dropped_frames, + }); } fn send(&self, event: RecorderEvent) { @@ -549,7 +553,7 @@ unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) { // outlives the connection. let context = unsafe { &*(data as *const SignalContext) }; context.send(RecorderEvent::OutputStarted); - context.segment_started(context.first_segment.clone()); + context.segment_started(context.first_segment.clone(), 0, 0); } unsafe extern "C" fn on_stop(data: *mut c_void, calldata: *mut sys::calldata_t) { @@ -588,7 +592,17 @@ unsafe extern "C" fn on_file_changed(data: *mut c_void, calldata: *mut sys::call let settings = Data(unsafe { sys::obs_output_get_settings(context.output) }); settings.set("format", &SettingValue::String(segment_stem(index + 1))); } - context.segment_started(path); + // SAFETY: the output outlives its signal connections. + let total_frames = unsafe { sys::obs_output_get_total_frames(context.output) }; + // The split-triggering keyframe is already counted here, but belongs to + // the new file. Consumers subtract one for placement; retain the raw count. + // SAFETY: as above, the output outlives this callback. + let dropped_frames = unsafe { sys::obs_output_get_frames_dropped(context.output) }; + context.segment_started( + path, + total_frames.max(0) as u64, + dropped_frames.max(0) as u64, + ); } pub(crate) fn segment_index(path: &str) -> Option { diff --git a/crates/clipbin-obs/src/recorder.rs b/crates/clipbin-obs/src/recorder.rs index 6cff6aa..b6a15c2 100644 --- a/crates/clipbin-obs/src/recorder.rs +++ b/crates/clipbin-obs/src/recorder.rs @@ -112,7 +112,7 @@ impl Engine { microphone: config.microphone, }; let output = OutputSpec { - frames_per_second: self.frames_per_second, + frames_per_second: config.frames_per_second.unwrap_or(self.frames_per_second), resolution: config.resolution, video_encoder: config.video_encoder, video_encoder_settings: vec![ @@ -161,6 +161,8 @@ fn forward_slashes(path: &Path) -> String { } pub struct RecordingConfig { + /// Override the engine startup rate for this recording. + pub frames_per_second: Option, pub video_source: Option, pub system_audio: Option, pub microphone: Option, @@ -282,7 +284,11 @@ pub enum RecorderEvent { /// OBS reports the output as started. Not yet proof that footage is written. OutputStarted, /// The muxer opened a new segment file; the previous one is complete. - SegmentStarted { path: String }, + SegmentStarted { + path: String, + total_frames: u64, + dropped_frames: u64, + }, OutputStopped { code: i64, last_error: Option, diff --git a/crates/clipbin-probe/src/main.rs b/crates/clipbin-probe/src/main.rs index 4487ff9..9402e8b 100644 --- a/crates/clipbin-probe/src/main.rs +++ b/crates/clipbin-probe/src/main.rs @@ -221,6 +221,7 @@ fn encoders(options: &Options) -> Result> { let dir = options.out.join(format!("verify-{key}-{encoder}")); let _ = std::fs::remove_dir_all(&dir); let config = RecordingConfig { + frames_per_second: None, video_source: None, system_audio: None, microphone: None, @@ -319,6 +320,7 @@ fn record(options: &Options) -> Result> { let _ = std::fs::remove_dir_all(&dir); println!("segment_dir={}", dir.display()); let config = RecordingConfig { + frames_per_second: None, video_source: Some(video_source), system_audio: options.audio.then(capture::system_audio_source), microphone: options.mic.then(capture::microphone_source), @@ -377,7 +379,7 @@ fn record(options: &Options) -> Result> { println!("t={} recording_size={width}x{height}", elapsed()) } Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()), - Ok(RecorderEvent::SegmentStarted { path }) => { + Ok(RecorderEvent::SegmentStarted { path, .. }) => { println!("t={} segment_started={path}", elapsed()) } Ok(RecorderEvent::OutputStopped { code, last_error }) => { diff --git a/scripts/validate/linux-rig.sh b/scripts/validate/linux-rig.sh index d44fbe6..60d7520 100755 --- a/scripts/validate/linux-rig.sh +++ b/scripts/validate/linux-rig.sh @@ -87,7 +87,7 @@ name=write-failure > "${out}/${name}.log" 2>&1 probe_exit=$? chmod 700 "${out}/${name}/session" 2>/dev/null -expect_failure "${name}" "(no footage was written|Error)" +expect_failure "${name}" "(no footage was written|Error|EncodeError)" # 7. Free space falls below the safety margin. name=low-disk-space diff --git a/scripts/validate/linux-session.sh b/scripts/validate/linux-session.sh new file mode 100755 index 0000000..9c715cf --- /dev/null +++ b/scripts/validate/linux-session.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# End-to-end desktop/Steam-process/session test in the private Wayland rig. +set -euo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}" +mkdir -p "${out}" +cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml" +export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}" +"${repo}/scripts/rig/headless.sh" bash <<'INNER' +set -euo pipefail +out="${CLIPBIN_SESSION_OUT}" +portal_config="${XDG_CONFIG_HOME}" +export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" +mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" +"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & +app=$! +trap 'kill "${app}" "${game:-}" "${portal:-}" 2>/dev/null || true' EXIT +sleep 3 +swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true +swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' > /dev/null +bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & +game=$! +python3 - "${game}" <<'PY' +import sys,time +for _ in range(20): + argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0') + if argv[0] == b'reaper': break + time.sleep(.1) +argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0') +assert argv[0] == b'reaper' and b'SteamLaunch' in argv and b'AppId=480' in argv, argv +PY +for _ in $(seq 30); do + if rg -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi + sleep 1 +done +rg -q 'recorder_status=Recording' "${out}/desktop.log" +swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null +sleep 0.5 +grim "${out}/recording.png" +if [[ "${CLIPBIN_VALIDATE_RECOVERY:-0}" == 1 ]]; then + kill "$(cat "${CLIPBIN_RIG_DIR}/xdpw.pid")" + for _ in $(seq 15); do + if rg -q 'recorder_status=Failed' "${out}/desktop.log"; then break; fi + sleep 1 + done + rg -q 'recorder_status=Failed' "${out}/desktop.log" + grim "${out}/capture-failed.png" + XDG_CONFIG_HOME="${portal_config}" /usr/lib/xdg-desktop-portal-wlr > "${out}/recovered-portal.log" 2>&1 & + portal=$! + for _ in $(seq 30); do + if [[ $(rg -c 'recorder_status=Recording' "${out}/desktop.log") -ge 2 ]]; then break; fi + sleep 1 + done + [[ $(rg -c 'recorder_status=Recording' "${out}/desktop.log") -ge 2 ]] + python3 - "${XDG_DATA_HOME}" <<'PY' +import pathlib,json,sys +p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name)) +m=json.loads(p.read_text()) +assert any(s['path'].startswith('run-002/') for s in m['segments']) +assert len([s for s in m['session']['spans'] if s['kind']=='Missing']) >= 2 +print('PASS capture recovery resumed in the same session with a Missing gap') +PY +fi +# Closing the sole window must leave the process and footage running. +swaymsg '[app_id="dev.clipbin.Clipbin"] kill' > /dev/null +kill -0 "${app}" +sleep 3 +kill -0 "${app}" +kill "${game}" +wait "${game}" 2>/dev/null || true +finalised=0 +for _ in $(seq 20); do + if python3 - "${XDG_DATA_HOME}" 2>/dev/null <<'PY' +import pathlib, json, sys +files = list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) +assert files +path = max(files, key=lambda p: int(p.parent.name)) +m = json.loads(path.read_text()) +assert m['app_id'] == 480 and not m['session']['active'] +assert m['segments'] and all(s['complete'] for s in m['segments']) +assert all((path.parent / s['path']).stat().st_size > 0 for s in m['segments']) +assert any(s['kind'] == 'Footage' for s in m['session']['spans']) +print('PASS session finalised with footage') +PY + then finalised=1; break; fi + sleep 1 +done +[[ ${finalised} == 1 ]] +python3 - "${XDG_DATA_HOME}" <<'PY' +import pathlib,json,subprocess,sys +path=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name)) +m=json.loads(path.read_text()) +ns=lambda d:d['secs']*1_000_000_000+d['nanos'] +runs={} +for segment in m['segments']: + run=segment['path'].split('/')[0] + base,packets=runs.get(run,(ns(segment['start']),0)) + assert segment['dropped_frames']==0, 'Dropped frames make count-derived offsets uncertain' + expected=base+packets*1_000_000_000//segment['frames_per_second'] + assert ns(segment['start'])==expected, (segment['path'],ns(segment['start']),expected) + if not segment['available']: + assert ns(segment['end'])==ns(segment['start']) + runs[run]=(base,packets) + continue + result=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-count_packets','-show_entries','stream=nb_read_packets','-of','json',str(path.parent/segment['path'])])) + count=int(result['streams'][0]['nb_read_packets']) + assert count>0 + runs[run]=(base,packets+count) +print('PASS segment offsets exactly match cumulative earlier video packet counts') +PY +python3 - "${XDG_DATA_HOME}" <<'PY' +import pathlib,json,sys +files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) +assert files and all(not json.loads(p.read_text())['session']['active'] for p in files) +PY +for _ in $(seq 10); do + if ! kill -0 "${app}" 2>/dev/null; then break; fi + sleep 1 +done +if kill -0 "${app}" 2>/dev/null; then echo 'FAIL closed app kept discovering after game exit' >&2; exit 1; fi +wait "${app}" +kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")" +# Reopen to prove the library loads from disk, then Quit cleanly with Ctrl+Q. +"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/reopened.log" 2>&1 & +app=$! +sleep 4 +swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null +sleep 0.5 +grim "${out}/library.png" +bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & +game=$! +for _ in $(seq 30); do + if rg -q 'recorder_status=Recording' "${out}/reopened.log"; then break; fi + sleep 1 +done +rg -q 'recorder_status=Recording' "${out}/reopened.log" +swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null +wtype -M ctrl -k q -m ctrl +for _ in $(seq 20); do + if ! kill -0 "${app}" 2>/dev/null; then break; fi + sleep 1 +done +if kill -0 "${app}" 2>/dev/null; then echo 'FAIL explicit Quit did not exit' >&2; exit 1; fi +wait "${app}" +python3 - "${XDG_DATA_HOME}" <<'PY' +import pathlib,json,sys +files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) +assert len(files) >= 2 +assert all(not json.loads(p.read_text())['session']['active'] for p in files) +PY +echo "PASS automatic recording, window close, game exit, library reload and Quit" +INNER