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:
2026-10-08 17:30:40 +02:00
parent 98fe898efb
commit 435ddf2b17
21 changed files with 1872 additions and 38 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ name=write-failure
> "${out}/${name}.log" 2>&1
probe_exit=$?
chmod 700 "${out}/${name}/session" 2>/dev/null
expect_failure "${name}" "(no footage was written|Error)"
expect_failure "${name}" "(no footage was written|Error|EncodeError)"
# 7. Free space falls below the safety margin.
name=low-disk-space
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# End-to-end desktop/Steam-process/session test in the private Wayland rig.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}"
mkdir -p "${out}"
cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
out="${CLIPBIN_SESSION_OUT}"
portal_config="${XDG_CONFIG_HOME}"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 &
app=$!
trap 'kill "${app}" "${game:-}" "${portal:-}" 2>/dev/null || true' EXIT
sleep 3
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' > /dev/null
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' &
game=$!
python3 - "${game}" <<'PY'
import sys,time
for _ in range(20):
argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0')
if argv[0] == b'reaper': break
time.sleep(.1)
argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0')
assert argv[0] == b'reaper' and b'SteamLaunch' in argv and b'AppId=480' in argv, argv
PY
for _ in $(seq 30); do
if rg -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi
sleep 1
done
rg -q 'recorder_status=Recording' "${out}/desktop.log"
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
sleep 0.5
grim "${out}/recording.png"
if [[ "${CLIPBIN_VALIDATE_RECOVERY:-0}" == 1 ]]; then
kill "$(cat "${CLIPBIN_RIG_DIR}/xdpw.pid")"
for _ in $(seq 15); do
if rg -q 'recorder_status=Failed' "${out}/desktop.log"; then break; fi
sleep 1
done
rg -q 'recorder_status=Failed' "${out}/desktop.log"
grim "${out}/capture-failed.png"
XDG_CONFIG_HOME="${portal_config}" /usr/lib/xdg-desktop-portal-wlr > "${out}/recovered-portal.log" 2>&1 &
portal=$!
for _ in $(seq 30); do
if [[ $(rg -c 'recorder_status=Recording' "${out}/desktop.log") -ge 2 ]]; then break; fi
sleep 1
done
[[ $(rg -c 'recorder_status=Recording' "${out}/desktop.log") -ge 2 ]]
python3 - "${XDG_DATA_HOME}" <<'PY'
import pathlib,json,sys
p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name))
m=json.loads(p.read_text())
assert any(s['path'].startswith('run-002/') for s in m['segments'])
assert len([s for s in m['session']['spans'] if s['kind']=='Missing']) >= 2
print('PASS capture recovery resumed in the same session with a Missing gap')
PY
fi
# Closing the sole window must leave the process and footage running.
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' > /dev/null
kill -0 "${app}"
sleep 3
kill -0 "${app}"
kill "${game}"
wait "${game}" 2>/dev/null || true
finalised=0
for _ in $(seq 20); do
if python3 - "${XDG_DATA_HOME}" 2>/dev/null <<'PY'
import pathlib, json, sys
files = list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
assert files
path = max(files, key=lambda p: int(p.parent.name))
m = json.loads(path.read_text())
assert m['app_id'] == 480 and not m['session']['active']
assert m['segments'] and all(s['complete'] for s in m['segments'])
assert all((path.parent / s['path']).stat().st_size > 0 for s in m['segments'])
assert any(s['kind'] == 'Footage' for s in m['session']['spans'])
print('PASS session finalised with footage')
PY
then finalised=1; break; fi
sleep 1
done
[[ ${finalised} == 1 ]]
python3 - "${XDG_DATA_HOME}" <<'PY'
import pathlib,json,subprocess,sys
path=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name))
m=json.loads(path.read_text())
ns=lambda d:d['secs']*1_000_000_000+d['nanos']
runs={}
for segment in m['segments']:
run=segment['path'].split('/')[0]
base,packets=runs.get(run,(ns(segment['start']),0))
assert segment['dropped_frames']==0, 'Dropped frames make count-derived offsets uncertain'
expected=base+packets*1_000_000_000//segment['frames_per_second']
assert ns(segment['start'])==expected, (segment['path'],ns(segment['start']),expected)
if not segment['available']:
assert ns(segment['end'])==ns(segment['start'])
runs[run]=(base,packets)
continue
result=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-count_packets','-show_entries','stream=nb_read_packets','-of','json',str(path.parent/segment['path'])]))
count=int(result['streams'][0]['nb_read_packets'])
assert count>0
runs[run]=(base,packets+count)
print('PASS segment offsets exactly match cumulative earlier video packet counts')
PY
python3 - "${XDG_DATA_HOME}" <<'PY'
import pathlib,json,sys
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
assert files and all(not json.loads(p.read_text())['session']['active'] for p in files)
PY
for _ in $(seq 10); do
if ! kill -0 "${app}" 2>/dev/null; then break; fi
sleep 1
done
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL closed app kept discovering after game exit' >&2; exit 1; fi
wait "${app}"
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
# Reopen to prove the library loads from disk, then Quit cleanly with Ctrl+Q.
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/reopened.log" 2>&1 &
app=$!
sleep 4
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
sleep 0.5
grim "${out}/library.png"
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' &
game=$!
for _ in $(seq 30); do
if rg -q 'recorder_status=Recording' "${out}/reopened.log"; then break; fi
sleep 1
done
rg -q 'recorder_status=Recording' "${out}/reopened.log"
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
wtype -M ctrl -k q -m ctrl
for _ in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then break; fi
sleep 1
done
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL explicit Quit did not exit' >&2; exit 1; fi
wait "${app}"
python3 - "${XDG_DATA_HOME}" <<'PY'
import pathlib,json,sys
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
assert len(files) >= 2
assert all(not json.loads(p.read_text())['session']['active'] for p in files)
PY
echo "PASS automatic recording, window close, game exit, library reload and Quit"
INNER