Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4a6ad74d | ||
|
|
c1cac1ba46 | ||
|
|
b51c64eee3 | ||
|
|
435ddf2b17 |
Generated
+21
-1
@@ -862,12 +862,22 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "clipbin-core"
|
name = "clipbin-core"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "clipbin-desktop"
|
name = "clipbin-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clipbin-core",
|
"clipbin-core",
|
||||||
|
"clipbin-obs",
|
||||||
"gpui",
|
"gpui",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1487,7 +1497,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.7+spec-1.1.0",
|
"toml 1.1.7+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.56.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6723,6 +6733,16 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.56.0"
|
version = "0.56.0"
|
||||||
|
|||||||
@@ -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 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.
|
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
|
```text
|
||||||
crates/
|
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-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-probe/ Validation probe for capture, encoders, segments and status.
|
||||||
clipbin-desktop/ GPUI application and initial session-library view.
|
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.
|
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
|
## 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
|
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.
|
Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms.
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,18 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints.rust]
|
||||||
workspace = true
|
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"] }
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,11 @@ mod status;
|
|||||||
pub use library::Library;
|
pub use library::Library;
|
||||||
pub use settings::{Settings, VideoCodec};
|
pub use settings::{Settings, VideoCodec};
|
||||||
pub use status::RecordingStatus;
|
pub use status::RecordingStatus;
|
||||||
|
|
||||||
|
pub mod games;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows_process;
|
||||||
|
|
||||||
|
pub mod segments;
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
//! Recorded sessions, bookmarks and exported clips as the interface shows them.
|
//! Recorded sessions, bookmarks and exported clips as the interface shows them.
|
||||||
//!
|
//!
|
||||||
//! Persistence comes with the recorder integration; until then the library is
|
//! Loaded from the session store, or explicitly filled with `Library::sample()`
|
||||||
//! empty, or filled with `Library::sample()` for interface work.
|
//! for interface work.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
pub sessions: Vec<Session>,
|
pub sessions: Vec<Session>,
|
||||||
pub clips: Vec<Clip>,
|
pub clips: Vec<Clip>,
|
||||||
pub storage: StorageUsage,
|
pub storage: StorageUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub game: String,
|
pub game: String,
|
||||||
@@ -25,14 +26,14 @@ pub struct Session {
|
|||||||
pub bookmarks: Vec<Bookmark>,
|
pub bookmarks: Vec<Bookmark>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub start: Duration,
|
pub start: Duration,
|
||||||
pub end: Duration,
|
pub end: Duration,
|
||||||
pub kind: SpanKind,
|
pub kind: SpanKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum SpanKind {
|
pub enum SpanKind {
|
||||||
Footage,
|
Footage,
|
||||||
/// Capture was unavailable; nothing was recorded (SPEC: recovery must not
|
/// Capture was unavailable; nothing was recorded (SPEC: recovery must not
|
||||||
@@ -42,7 +43,7 @@ pub enum SpanKind {
|
|||||||
Removed,
|
Removed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Bookmark {
|
pub struct Bookmark {
|
||||||
pub at: Duration,
|
pub at: Duration,
|
||||||
pub before: Duration,
|
pub before: Duration,
|
||||||
@@ -60,7 +61,7 @@ impl Bookmark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Clip {
|
pub struct Clip {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub game: String,
|
pub game: String,
|
||||||
@@ -71,7 +72,7 @@ pub struct Clip {
|
|||||||
|
|
||||||
/// SPEC: the budget counts only unprotected session footage; protected
|
/// SPEC: the budget counts only unprotected session footage; protected
|
||||||
/// bookmarks and exported clips are shown separately.
|
/// 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 struct StorageUsage {
|
||||||
pub budget_bytes: u64,
|
pub budget_bytes: u64,
|
||||||
pub unprotected_bytes: u64,
|
pub unprotected_bytes: u64,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{fmt, time::Duration};
|
use std::{fmt, time::Duration};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum VideoCodec {
|
pub enum VideoCodec {
|
||||||
#[default]
|
#[default]
|
||||||
H264,
|
H264,
|
||||||
@@ -18,11 +19,13 @@ impl fmt::Display for VideoCodec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
|
/// Persisted recorder preferences from SPEC.md.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// A fixed recording size. `None` records at the captured window's or
|
/// A fixed recording size. `None` records at the captured window's or
|
||||||
/// monitor's own size, following its orientation.
|
/// monitor's own size, following its orientation.
|
||||||
|
pub portal_restore_token: Option<String>,
|
||||||
pub resolution: Option<(u32, u32)>,
|
pub resolution: Option<(u32, u32)>,
|
||||||
pub frames_per_second: u32,
|
pub frames_per_second: u32,
|
||||||
pub codec: VideoCodec,
|
pub codec: VideoCodec,
|
||||||
@@ -39,6 +42,7 @@ pub struct Settings {
|
|||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
portal_restore_token: None,
|
||||||
resolution: None,
|
resolution: None,
|
||||||
frames_per_second: 60,
|
frames_per_second: 60,
|
||||||
codec: VideoCodec::H264,
|
codec: VideoCodec::H264,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clipbin-core.workspace = true
|
clipbin-core.workspace = true
|
||||||
|
clipbin-obs.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ pub struct ClipbinApp {
|
|||||||
/// Where the timeline was last laid out, to turn pointer positions into times.
|
/// Where the timeline was last laid out, to turn pointer positions into times.
|
||||||
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||||
pub fields: SettingsFields,
|
pub fields: SettingsFields,
|
||||||
|
pub service: crate::recorder_service::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The typed settings and the fields that edit them.
|
/// The typed settings and the fields that edit them.
|
||||||
@@ -106,6 +107,7 @@ impl SettingsFields {
|
|||||||
window,
|
window,
|
||||||
move |app, _, Committed(text), window, cx| {
|
move |app, _, Committed(text), window, cx| {
|
||||||
if apply(&mut app.settings, text) {
|
if apply(&mut app.settings, text) {
|
||||||
|
app.save_settings();
|
||||||
app.fields.refresh(&app.settings, window, cx);
|
app.fields.refresh(&app.settings, window, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -185,8 +187,12 @@ impl SettingsFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ClipbinApp {
|
impl ClipbinApp {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(
|
||||||
let settings = Settings::default();
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
settings: Settings,
|
||||||
|
service: crate::recorder_service::Client,
|
||||||
|
) -> Self {
|
||||||
let fields = SettingsFields::new(&settings, window, cx);
|
let fields = SettingsFields::new(&settings, window, cx);
|
||||||
// Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with
|
// Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with
|
||||||
// example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and
|
// example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and
|
||||||
@@ -232,6 +238,7 @@ impl ClipbinApp {
|
|||||||
drag: None,
|
drag: None,
|
||||||
timeline_bounds: Rc::default(),
|
timeline_bounds: Rc::default(),
|
||||||
fields,
|
fields,
|
||||||
|
service,
|
||||||
};
|
};
|
||||||
match std::env::var("CLIPBIN_ROUTE").as_deref() {
|
match std::env::var("CLIPBIN_ROUTE").as_deref() {
|
||||||
Ok("clips") => app.route = Route::Clips,
|
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
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn save_settings(&self) {
|
||||||
|
self.service.settings(self.settings.clone());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn navigate(&mut self, route: Route, cx: &mut Context<Self>) {
|
pub fn navigate(&mut self, route: Route, cx: &mut Context<Self>) {
|
||||||
self.notice = None;
|
self.notice = None;
|
||||||
match route {
|
match route {
|
||||||
@@ -530,8 +573,8 @@ impl ClipbinApp {
|
|||||||
Some("Choose capture source"),
|
Some("Choose capture source"),
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||||
this.notice =
|
this.settings.portal_restore_token = None;
|
||||||
Some("Choosing a source arrives with the recorder integration.".into());
|
this.service.choose_source();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -222,3 +222,8 @@ pub fn meter(parts: &[(f32, Hsla)]) -> Div {
|
|||||||
.bg(*colour)
|
.bg(*colour)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `1 session`, `3 sessions`.
|
||||||
|
pub fn count(n: usize, noun: &str) -> String {
|
||||||
|
format!("{n} {noun}{}", if n == 1 { "" } else { "s" })
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use gpui::{
|
|||||||
mod app;
|
mod app;
|
||||||
mod assets;
|
mod assets;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod recorder_service;
|
||||||
mod screens;
|
mod screens;
|
||||||
mod text_field;
|
mod text_field;
|
||||||
mod theme;
|
mod theme;
|
||||||
@@ -14,23 +15,38 @@ mod timeline_view;
|
|||||||
actions!(clipbin, [Quit]);
|
actions!(clipbin, [Quit]);
|
||||||
|
|
||||||
fn main() {
|
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()
|
Application::new()
|
||||||
.with_assets(assets::Assets)
|
.with_assets(assets::Assets)
|
||||||
.run(|cx: &mut App| {
|
.run(|cx: &mut App| {
|
||||||
if let Err(error) = assets::load_fonts(cx) {
|
if let Err(error) = assets::load_fonts(cx) {
|
||||||
eprintln!("Could not load the interface font: {error}");
|
eprintln!("Could not load the interface font: {error}");
|
||||||
}
|
}
|
||||||
cx.on_action(|_: &Quit, cx| cx.quit());
|
let quitter = interface_service.clone();
|
||||||
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
cx.on_action(move |_: &Quit, cx| {
|
||||||
text_field::bind_keys(cx);
|
quitter.quit();
|
||||||
|
let service = quitter.clone();
|
||||||
// This shell has no background recorder yet.
|
cx.spawn(async move |cx| {
|
||||||
cx.on_window_closed(|cx| {
|
loop {
|
||||||
if cx.windows().is_empty() {
|
gpui::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||||
cx.quit();
|
if service.snapshot().exited {
|
||||||
|
let _ = cx.update(|cx| cx.quit());
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
});
|
||||||
|
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||||
|
text_field::bind_keys(cx);
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
||||||
let result = cx.open_window(
|
let result = cx.open_window(
|
||||||
@@ -44,7 +60,9 @@ fn main() {
|
|||||||
app_id: Some("dev.clipbin.Clipbin".into()),
|
app_id: Some("dev.clipbin.Clipbin".into()),
|
||||||
..Default::default()
|
..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 {
|
if let Err(error) = result {
|
||||||
@@ -55,4 +73,10 @@ fn main() {
|
|||||||
|
|
||||||
cx.activate(true);
|
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(())
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelud
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::ClipbinApp,
|
app::ClipbinApp,
|
||||||
components::{ButtonStyle, button, chip, empty_state, page_header},
|
components::{ButtonStyle, button, chip, count, empty_state, page_header},
|
||||||
screens::{corner, muted_line, thumbnail},
|
screens::{corner, muted_line, thumbnail},
|
||||||
theme,
|
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()
|
"Clips you save are kept until you delete them.".to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{} clips · {} · kept until you delete them",
|
"{} · {} · kept until you delete them",
|
||||||
clips.len(),
|
count(clips.len(), "clip"),
|
||||||
format_bytes(total)
|
format_bytes(total)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelud
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{ClipbinApp, Route},
|
app::{ClipbinApp, Route},
|
||||||
components::{chip, empty_state, icon, page_header},
|
components::{chip, count, empty_state, icon, page_header},
|
||||||
screens::{corner, muted_line, thumbnail},
|
screens::{corner, muted_line, thumbnail},
|
||||||
theme,
|
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()
|
"Sessions start automatically when you launch a Steam game.".to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{} sessions · {} of footage within your {} budget",
|
"{} · {} of footage within your {} budget",
|
||||||
library.sessions.len(),
|
count(library.sessions.len(), "session"),
|
||||||
format_bytes(library.storage.unprotected_bytes),
|
format_bytes(library.storage.unprotected_bytes),
|
||||||
format_bytes(library.storage.budget_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.))
|
.max_w(px(820.))
|
||||||
.child(page_header(
|
.child(page_header(
|
||||||
"Settings",
|
"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(),
|
Vec::new(),
|
||||||
))
|
))
|
||||||
.child(
|
.child(
|
||||||
@@ -259,6 +259,7 @@ fn segmented(
|
|||||||
.child(*label)
|
.child(*label)
|
||||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
update(&mut app.settings);
|
update(&mut app.settings);
|
||||||
|
app.save_settings();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
@@ -289,6 +290,7 @@ fn toggle(
|
|||||||
.child(div().size_4().rounded_full().bg(theme::foreground()))
|
.child(div().size_4().rounded_full().bg(theme::foreground()))
|
||||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
update(&mut app.settings);
|
update(&mut app.settings);
|
||||||
|
app.save_settings();
|
||||||
cx.notify();
|
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 {
|
pub fn start(&self) -> Duration {
|
||||||
self.start
|
self.start
|
||||||
}
|
}
|
||||||
@@ -79,6 +88,17 @@ mod tests {
|
|||||||
|
|
||||||
const HOUR: Duration = Duration::from_secs(3600);
|
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]
|
#[test]
|
||||||
fn zooming_keeps_the_time_under_the_pointer() {
|
fn zooming_keeps_the_time_under_the_pointer() {
|
||||||
let mut view = TimelineView::full(HOUR);
|
let mut view = TimelineView::full(HOUR);
|
||||||
|
|||||||
@@ -521,11 +521,15 @@ struct SignalContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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() {
|
if let Ok(mut current) = self.current_segment.lock() {
|
||||||
*current = Some(path.clone());
|
*current = Some(path.clone());
|
||||||
}
|
}
|
||||||
self.send(RecorderEvent::SegmentStarted { path });
|
self.send(RecorderEvent::SegmentStarted {
|
||||||
|
path,
|
||||||
|
total_frames,
|
||||||
|
dropped_frames,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send(&self, event: RecorderEvent) {
|
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.
|
// outlives the connection.
|
||||||
let context = unsafe { &*(data as *const SignalContext) };
|
let context = unsafe { &*(data as *const SignalContext) };
|
||||||
context.send(RecorderEvent::OutputStarted);
|
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) {
|
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) });
|
let settings = Data(unsafe { sys::obs_output_get_settings(context.output) });
|
||||||
settings.set("format", &SettingValue::String(segment_stem(index + 1)));
|
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> {
|
pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl Engine {
|
|||||||
microphone: config.microphone,
|
microphone: config.microphone,
|
||||||
};
|
};
|
||||||
let output = OutputSpec {
|
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,
|
resolution: config.resolution,
|
||||||
video_encoder: config.video_encoder,
|
video_encoder: config.video_encoder,
|
||||||
video_encoder_settings: vec![
|
video_encoder_settings: vec![
|
||||||
@@ -161,6 +161,8 @@ fn forward_slashes(path: &Path) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct RecordingConfig {
|
pub struct RecordingConfig {
|
||||||
|
/// Override the engine startup rate for this recording.
|
||||||
|
pub frames_per_second: Option<u32>,
|
||||||
pub video_source: Option<SourceSpec>,
|
pub video_source: Option<SourceSpec>,
|
||||||
pub system_audio: Option<SourceSpec>,
|
pub system_audio: Option<SourceSpec>,
|
||||||
pub microphone: 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.
|
/// OBS reports the output as started. Not yet proof that footage is written.
|
||||||
OutputStarted,
|
OutputStarted,
|
||||||
/// The muxer opened a new segment file; the previous one is complete.
|
/// 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 {
|
OutputStopped {
|
||||||
code: i64,
|
code: i64,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
|
|||||||
@@ -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 dir = options.out.join(format!("verify-{key}-{encoder}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
let config = RecordingConfig {
|
let config = RecordingConfig {
|
||||||
|
frames_per_second: None,
|
||||||
video_source: None,
|
video_source: None,
|
||||||
system_audio: None,
|
system_audio: None,
|
||||||
microphone: 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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
println!("segment_dir={}", dir.display());
|
println!("segment_dir={}", dir.display());
|
||||||
let config = RecordingConfig {
|
let config = RecordingConfig {
|
||||||
|
frames_per_second: None,
|
||||||
video_source: Some(video_source),
|
video_source: Some(video_source),
|
||||||
system_audio: options.audio.then(capture::system_audio_source),
|
system_audio: options.audio.then(capture::system_audio_source),
|
||||||
microphone: options.mic.then(capture::microphone_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())
|
println!("t={} recording_size={width}x{height}", elapsed())
|
||||||
}
|
}
|
||||||
Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()),
|
Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()),
|
||||||
Ok(RecorderEvent::SegmentStarted { path }) => {
|
Ok(RecorderEvent::SegmentStarted { path, .. }) => {
|
||||||
println!("t={} segment_started={path}", elapsed())
|
println!("t={} segment_started={path}", elapsed())
|
||||||
}
|
}
|
||||||
Ok(RecorderEvent::OutputStopped { code, last_error }) => {
|
Ok(RecorderEvent::OutputStopped { code, last_error }) => {
|
||||||
|
|||||||
@@ -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, 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, 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 |
|
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
||||||
|
|
||||||
## Probe commands
|
## Probe commands
|
||||||
@@ -119,6 +120,8 @@ Windows status:
|
|||||||
- The assembled runtime keeps the six Game Capture hook binaries byte-identical
|
- The assembled runtime keeps the six Game Capture hook binaries byte-identical
|
||||||
to the release, with their Authenticode signatures present; signature
|
to the release, with their Authenticode signatures present; signature
|
||||||
validity has not been checked on Windows.
|
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
|
- 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
|
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
|
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;
|
- **Plugin configuration.** libobs is started without a module config path;
|
||||||
set one under the app's config directory before plugins that save settings
|
set one under the app's config directory before plugins that save settings
|
||||||
are added.
|
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.
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ name=write-failure
|
|||||||
> "${out}/${name}.log" 2>&1
|
> "${out}/${name}.log" 2>&1
|
||||||
probe_exit=$?
|
probe_exit=$?
|
||||||
chmod 700 "${out}/${name}/session" 2>/dev/null
|
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.
|
# 7. Free space falls below the safety margin.
|
||||||
name=low-disk-space
|
name=low-disk-space
|
||||||
|
|||||||
Executable
+182
@@ -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
|
||||||
Reference in New Issue
Block a user