feat: automatically record Steam game sessions
Detect games with native process exit watchers, own OBS on a background worker, and persist settings and recoverable session timelines. Place segments by split frame counts, recover capture with honest gaps, and finalise on game exit or explicit Quit. Keep the current recording alive after GPUI closes its last window, then exit when the game ends. Add an end-to-end rig covering recording, recovery, exact packet-count offsets, disk library reload and clean Quit.
This commit is contained in:
@@ -8,5 +8,18 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[dependencies]
|
||||
dirs = "5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rustix = { version = "1", features = ["process", "event"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
|
||||
|
||||
@@ -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 settings::{Settings, VideoCodec};
|
||||
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.
|
||||
//!
|
||||
//! Persistence comes with the recorder integration; until then the library is
|
||||
//! empty, or filled with `Library::sample()` for interface work.
|
||||
//! Loaded from the session store, or explicitly filled with `Library::sample()`
|
||||
//! for interface work.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Library {
|
||||
pub sessions: Vec<Session>,
|
||||
pub clips: Vec<Clip>,
|
||||
pub storage: StorageUsage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: u64,
|
||||
pub game: String,
|
||||
@@ -25,14 +26,14 @@ pub struct Session {
|
||||
pub bookmarks: Vec<Bookmark>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Span {
|
||||
pub start: Duration,
|
||||
pub end: Duration,
|
||||
pub kind: SpanKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SpanKind {
|
||||
Footage,
|
||||
/// Capture was unavailable; nothing was recorded (SPEC: recovery must not
|
||||
@@ -42,7 +43,7 @@ pub enum SpanKind {
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Bookmark {
|
||||
pub at: Duration,
|
||||
pub before: Duration,
|
||||
@@ -60,7 +61,7 @@ impl Bookmark {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Clip {
|
||||
pub id: u64,
|
||||
pub game: String,
|
||||
@@ -71,7 +72,7 @@ pub struct Clip {
|
||||
|
||||
/// SPEC: the budget counts only unprotected session footage; protected
|
||||
/// bookmarks and exported clips are shown separately.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorageUsage {
|
||||
pub budget_bytes: u64,
|
||||
pub unprotected_bytes: u64,
|
||||
|
||||
@@ -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};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VideoCodec {
|
||||
#[default]
|
||||
H264,
|
||||
@@ -18,11 +19,13 @@ impl fmt::Display for VideoCodec {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Persisted recorder preferences from SPEC.md.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// A fixed recording size. `None` records at the captured window's or
|
||||
/// monitor's own size, following its orientation.
|
||||
pub portal_restore_token: Option<String>,
|
||||
pub resolution: Option<(u32, u32)>,
|
||||
pub frames_per_second: u32,
|
||||
pub codec: VideoCodec,
|
||||
@@ -39,6 +42,7 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
portal_restore_token: None,
|
||||
resolution: None,
|
||||
frames_per_second: 60,
|
||||
codec: VideoCodec::H264,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user