Compare commits

..
4 Commits
Author SHA1 Message Date
harry ec4a6ad74d fix: pluralise session and clip counts
Rust checks / windows-cross-check (push) Successful in 1m0s
Rust checks / linux (push) Failing after 5m34s
2026-10-08 17:40:26 +02:00
harry c1cac1ba46 fix: fail session validation promptly without missing tools 2026-10-08 17:38:56 +02:00
harry b51c64eee3 docs: record automatic session validation and platform gaps 2026-10-08 17:32:00 +02:00
harry 435ddf2b17 feat: automatically record Steam game sessions
Detect games with native process exit watchers, own OBS on a background worker, and persist settings and recoverable session timelines. Place segments by split frame counts, recover capture with honest gaps, and finalise on game exit or explicit Quit.

Keep the current recording alive after GPUI closes its last window, then exit when the game ends. Add an end-to-end rig covering recording, recovery, exact packet-count offsets, disk library reload and clean Quit.
2026-10-08 17:30:40 +02:00
26 changed files with 1991 additions and 48 deletions
Generated
+21 -1
View File
@@ -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"
+6 -4
View File
@@ -8,7 +8,9 @@ An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust a
The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig and the hardware sign-off on CachyOS/Hyprland/RX 9070 XT; all Windows validation is still to do.
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It is not yet connected to the recorder, so it shows empty states; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Steam detection, the recorder in the app, playback, export, persistence, global hotkeys, autostart, and background recording are not implemented yet.
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window keeps the current session recording until the game exits, then exits the app. Ctrl+Q or Quit stops the output cleanly.
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback, export, global hotkeys, storage cleanup, autostart and a tray are not implemented yet. After closing the only window, reopening it without a tray is a follow-up.
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
@@ -16,7 +18,7 @@ Windows is a first-class target. Every change must keep the Windows code paths b
```text
crates/
clipbin-core/ Settings and recorder state; independent of GPUI and native OBS.
clipbin-core/ Settings, Steam detection and session storage; independent of GPUI and OBS.
clipbin-obs/ Recording engine over the pinned libobs runtime; all unsafe code is in src/ffi.rs.
clipbin-probe/ Validation probe for capture, encoders, segments and status.
clipbin-desktop/ GPUI application and initial session-library view.
@@ -28,7 +30,7 @@ docs/validation.md Validation status and findings.
SPEC.md Product requirements and acceptance checks.
```
The core has no external dependencies. Keep capture and session operations out of the UI thread.
Keep capture and session operations out of the UI thread. Core uses serde/JSON, dirs, tempfile, rustix on Linux and winreg/windows-sys on Windows. Raw OBS calls live in `clipbin-obs/src/ffi.rs`; raw Windows process calls live in the small `clipbin-core/src/windows_process.rs` bridge.
## Development
@@ -85,7 +87,7 @@ cargo test --locked --workspace
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
```
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times.
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Automatic desktop sessions: `scripts/validate/linux-session.sh` (fake Steam reaper, recording and library screenshots, close-window survival, game exit and explicit Quit). Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times.
Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms.
+15 -2
View File
@@ -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"] }
+362
View File
@@ -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<u32> {
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<u32> {
value.trim().parse::<u32>().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<VdfValue> {
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<VdfValue> {
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<PathBuf> {
#[cfg(windows)]
{
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey("Software\\Valve\\Steam")
.ok()?
.get_value::<String, _>("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<PathBuf> {
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::<u32>().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<Vec<Game>> {
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<Self> {
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<bool> {
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<Vec<Game>> {
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::<u32, _>("RunningAppID")
.ok()
.filter(|id| *id > 0)
.or_else(|| {
steam
.get_value::<String, _>("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::<u32, _>("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<Self> {
Ok(Self {
watch: crate::windows_process::ExitWatch::open(game.pid, &game.executable)?,
})
}
pub fn exited(&mut self) -> io::Result<bool> {
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());
}
}
+8
View File
@@ -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;
+10 -9
View File
@@ -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<Session>,
pub clips: Vec<Clip>,
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<Bookmark>,
}
#[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,
+30
View File
@@ -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<bool> {
cluster_in(File::open(path)?)
}
fn cluster_in(reader: impl Read) -> io::Result<bool> {
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());
}
}
+7 -3
View File
@@ -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<String>,
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,
+347
View File
@@ -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<Segment>,
}
#[derive(Clone)]
pub struct Store {
pub root: PathBuf,
}
pub fn config_path() -> io::Result<PathBuf> {
Ok(dirs::config_dir()
.ok_or_else(|| io::Error::other("No configuration directory"))?
.join("clipbin/settings.json"))
}
pub fn load_settings() -> io::Result<Settings> {
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<Self> {
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<Metadata> {
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<Vec<Metadata>> {
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<Library> {
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<Duration>,
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::<Settings>(&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();
}
}
+164
View File
@@ -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<Self> {
// 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<String> {
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<Vec<(u32, String)>> {
// 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::<PROCESSENTRY32W>() 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<io::Result<()>>,
cancel: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl ExitWatch {
pub fn open(pid: u32, executable: &str) -> io::Result<Self> {
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<bool> {
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();
}
}
}
+1
View File
@@ -10,6 +10,7 @@ publish.workspace = true
[dependencies]
clipbin-core.workspace = true
clipbin-obs.workspace = true
gpui.workspace = true
[lints]
+56
View File
@@ -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: <root>/bin/<exe> loads <root>/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 <profile>/build/<package>-<hash>/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();
}
}
+47 -4
View File
@@ -68,6 +68,7 @@ pub struct ClipbinApp {
/// Where the timeline was last laid out, to turn pointer positions into times.
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
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>) -> Self {
let settings = Settings::default();
pub fn new(
window: &mut Window,
cx: &mut Context<Self>,
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>) {
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();
})),
)
+5
View File
@@ -222,3 +222,8 @@ pub fn meter(parts: &[(f32, Hsla)]) -> Div {
.bg(*colour)
}))
}
/// `1 session`, `3 sessions`.
pub fn count(n: usize, noun: &str) -> String {
format!("{n} {noun}{}", if n == 1 { "" } else { "s" })
}
+34 -10
View File
@@ -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);
}
@@ -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<String>,
pub library: Library,
pub restore_token: Option<String>,
pub exited: bool,
}
#[derive(Clone)]
pub struct Client {
commands: Sender<Command>,
snapshot: Arc<Mutex<Snapshot>>,
}
enum Command {
Settings(Settings),
ChooseSource,
WindowClosed,
Quit,
}
impl Client {
pub fn start(
settings: Settings,
initial_error: Option<String>,
) -> (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<Recording>,
tracker: StatusTracker,
run: u32,
run_base: Option<Duration>,
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<Mutex<Snapshot>>, reason: impl ToString) {
snapshot.lock().unwrap().status = RecordingStatus::Failed {
reason: reason.to_string(),
};
}
fn worker(
mut settings: Settings,
commands: Receiver<Command>,
snapshot: &Arc<Mutex<Snapshot>>,
) -> 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<Active> = 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(())
}
+3 -3
View File
@@ -5,7 +5,7 @@ use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelud
use crate::{
app::ClipbinApp,
components::{ButtonStyle, button, chip, empty_state, page_header},
components::{ButtonStyle, button, chip, count, empty_state, page_header},
screens::{corner, muted_line, thumbnail},
theme,
};
@@ -19,8 +19,8 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
"Clips you save are kept until you delete them.".to_owned()
} else {
format!(
"{} clips · {} · kept until you delete them",
clips.len(),
"{} · {} · kept until you delete them",
count(clips.len(), "clip"),
format_bytes(total)
)
},
@@ -5,7 +5,7 @@ use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelud
use crate::{
app::{ClipbinApp, Route},
components::{chip, empty_state, icon, page_header},
components::{chip, count, empty_state, icon, page_header},
screens::{corner, muted_line, thumbnail},
theme,
};
@@ -17,8 +17,8 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
"Sessions start automatically when you launch a Steam game.".to_owned()
} else {
format!(
"{} sessions · {} of footage within your {} budget",
library.sessions.len(),
"{} · {} of footage within your {} budget",
count(library.sessions.len(), "session"),
format_bytes(library.storage.unprotected_bytes),
format_bytes(library.storage.budget_bytes)
)
@@ -77,7 +77,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> 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();
}))
}
@@ -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);
+18 -4
View File
@@ -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<u64> {
+8 -2
View File
@@ -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<u32>,
pub video_source: Option<SourceSpec>,
pub system_audio: Option<SourceSpec>,
pub microphone: Option<SourceSpec>,
@@ -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<String>,
+3 -1
View File
@@ -221,6 +221,7 @@ fn encoders(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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 }) => {
+72
View File
@@ -10,6 +10,7 @@ pass/fail results.
| --- | --- | --- |
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) |
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) |
| Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-08: Recording sidebar, game exit, library reload, window close and active Quit |
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
## Probe commands
@@ -119,6 +120,8 @@ Windows status:
- The assembled runtime keeps the six Game Capture hook binaries byte-identical
to the release, with their Authenticode signatures present; signature
validity has not been checked on Windows.
- The native Steam process bridge (`clipbin-core/src/windows_process.rs`) uses Toolhelp enumeration, limited process queries and a dedicated thread waiting on the process handle. Registry discovery uses `RunningAppID` and `Apps\<id>\Running`; executable paths are matched beneath the app manifest’s installation directory. This code is cross-checked but unvalidated on Windows, including launcher/child-process identity, handle access permissions and exit ordering.
- Unvalidated: atomic metadata replacement and recovery on Windows, settings/data directory paths, desktop close-window survival and clean Quit. GPUI desktop integration cannot be cross-checked from Linux.
- Unvalidated: encoder ids and settings for NVENC, AMF and QSV; Game Capture
hooking and its loss detection (the engine expects the source size to drop
to 0); WASAPI audio; `obs-ffmpeg-mux.exe` beside the executable; linking
@@ -184,3 +187,72 @@ Windows status:
- **Plugin configuration.** libobs is started without a module config path;
set one under the app's config directory before plugins that save settings
are added.
## Automatic desktop sessions
`scripts/validate/linux-session.sh [output-dir]` runs the desktop and a fake
Steam reaper (`argv[0]=reaper`, `SteamLaunch`, `AppId=480`) inside the headless
Wayland rig. It verifies Recording status, non-empty segment files and metadata,
closes the only window while recording continues, kills the game process,
checks finalisation, reopens the app to load its library from disk, captures
screenshots and uses Ctrl+Q during a second recording for clean shutdown.
The script checks required tools before starting and distinguishes pending
status from failed checks. The recovery run also passed with `PATH=/usr/bin:/bin`
without ripgrep installed (2026-10-08); scripts use grep instead.
The recording and library screenshots were inspected; storage totals came from
real Matroska file sizes. The existing Linux rig also passed all nine scenarios
after integration (2026-10-08). Its unwritable-output assertion accepts both
OBS Error and EncodeError: the muxer can report either for a permission error.
Session metadata is `session.json` inside each data-directory session folder;
recovery attempts use separate `run-NNN` folders. Each segment stores its exact
start using `(total_frames_at_file_changed - 1) / fps`. The keyframe triggering
the split is already included in OBS’s count but belongs to the new segment:
the rig observed 121, 241, 361 at the boundaries of 120-frame segments. The
initial file starts at zero. Raw callback counts are preserved in events; the
e2e script checks placements exactly against cumulative earlier ffprobe video
packet counts within each run. Dropped output frames could make this count
drift: each segment records the run’s cumulative dropped-frame count at its
completion, and a nonzero count logs an explicit warning. Placement with
actual dropped frames still needs validation.
Stopping exactly as a split fires can leave a header-only final Matroska file
(the rig observed 747 bytes, no Cluster and no video packets), despite a counted
split keyframe and zero reported drops. Finalisation checks the small header
prefix for the Cluster ID and deletes an empty tail after output stop. If deletion
fails (including a still-open file on Windows), it is marked unavailable in
metadata. Footage ends at the last real segment’s end and the remainder is
Missing. The offset check verifies every retained segment and explicitly
checks any unavailable marker, without treating an empty file as playable. Wall-clock time places each run inside the session and
marks capture waiting/recovery as Missing spans. Containers are never summed
to derive segment offsets. Metadata is replaced atomically from a synced
same-directory temporary file; startup finalises previously active metadata.
Run `CLIPBIN_VALIDATE_RECOVERY=1 scripts/validate/linux-session.sh` to also
kill the portal capture stream and check that bounded automatic recovery
resumes into the same session, with a Missing span and a second run directory.
This passed in the headless Wayland rig. A deliberately missing runtime path
also opened the interface with a persistent failure banner and honoured Quit.
Settings are saved under the config directory, including the portal restore
token. Capture selection clears that token and restarts the current session’s
capture; automatic recovery retries at five-second intervals, at most three
consecutive attempts. A failure stays visible until footage is proven again.
One game is recorded at a time. Startup discovery also records a game already
running; simultaneous games are selected by PID order and the next still-live
game is detected after the current session ends. Linux Steam names come from
library manifests, with `App <id>` as the fallback. Windows requires a manifest
installation path to identify candidate processes and fails honestly if none
can be found. Real Steam/Proton lifecycle validation, X11 desktop session
integration and Windows runtime validation remain follow-ups. Audio-source
failure detection remains limited as described above.
GPUI 0.2.2 ends its Linux event loop when the last window is removed, even
without an application close callback. Main keeps the recorder worker alive
after that return. Closing the window continues the current game session;
when that game exits, it finalises and the process exits. If no session is
active, it exits immediately. It stops discovering new games once closed,
so there is no indefinitely running invisible app. Reopening the window and
persistent background discovery need the future tray integration. Explicit
Quit/Ctrl+Q stops and finalises the current recording immediately.
+1 -1
View File
@@ -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
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# End-to-end desktop/Steam-process/session test in the private Wayland rig.
set -euo pipefail
# Check before building or starting the rig; missing tools must not look like
# a status that has simply not appeared yet.
for tool in bash cargo grep python3 ffprobe grim wtype swaymsg dirname mkdir sleep cat seq \
env dbus-run-session mktemp chmod rm basename head dbus-update-activation-environment \
pipewire wireplumber pipewire-pulse pactl sway Xwayland ffplay fusermount3; do
if ! command -v "${tool}" > /dev/null; then
echo "FAIL required tool '${tool}' is missing from PATH" >&2
exit 127
fi
done
for tool in /usr/lib/xdg-desktop-portal /usr/lib/xdg-desktop-portal-wlr; do
if [[ ! -x "${tool}" ]]; then
echo "FAIL required tool '${tool}' is missing or not executable" >&2
exit 127
fi
done
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
# grep returns 1 for no matches; every other error must fail immediately.
wait_log() {
local pattern="$1" log="$2" minimum="$3" attempts="$4" count status attempt
for ((attempt = 0; attempt < attempts; attempt++)); do
if count=$(grep -c "${pattern}" "${log}"); then
if ((count >= minimum)); then return 0; fi
else
status=$?
if [[ ${status} != 1 ]]; then
echo "FAIL could not check '${pattern}' in ${log} (grep exited ${status})" >&2
exit 1
fi
fi
if ! kill -0 "${app}" 2>/dev/null; then
echo "FAIL desktop exited while waiting for '${pattern}'; log: ${log}" >&2
exit 1
fi
sleep 1
done
echo "FAIL timed out waiting for ${minimum} '${pattern}' entries; log: ${log}" >&2
exit 1
}
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
wait_log 'recorder_status=Recording' "${out}/desktop.log" 1 30
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")"
wait_log 'recorder_status=Failed' "${out}/desktop.log" 1 15
grim "${out}/capture-failed.png"
XDG_CONFIG_HOME="${portal_config}" /usr/lib/xdg-desktop-portal-wlr > "${out}/recovered-portal.log" 2>&1 &
portal=$!
wait_log 'recorder_status=Recording' "${out}/desktop.log" 2 30
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
status=0
python3 - "${XDG_DATA_HOME}" <<'PY' || status=$?
import pathlib, json, sys
files = list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
if not files: sys.exit(2)
path = max(files, key=lambda p: int(p.parent.name))
m = json.loads(path.read_text())
if m['session']['active']: sys.exit(2)
assert m['app_id'] == 480
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
case "${status}" in
0) finalised=1; break ;;
2) sleep 1 ;;
*) echo "FAIL session finalisation check exited ${status}" >&2; exit 1 ;;
esac
done
if [[ ${finalised} != 1 ]]; then
echo "FAIL timed out waiting for session finalisation; log: ${out}/desktop.log" >&2
exit 1
fi
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=$!
wait_log 'recorder_status=Recording' "${out}/reopened.log" 1 30
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