Files
harry f1c31d6c90 feat: keep Clipbin recording in the tray
Add native Linux SNI and Windows notification-area trays, single-instance reopening, background startup and login entries. Preserve recording and discovery after close, with clean Quit and reachable no-tray fallbacks.

Track capture-source audio delivery independently of silent output tracks and warn without stopping video. Give clip metadata its own row and shape labels at final bounds to avoid GPUI 0.2.2 truncation cache and text-run bugs.

Validated with strict workspace checks, 57 tests, Windows core/OBS/probe cross-clippy, all ten capture scenarios, tray/session/clips/playback rigs and narrow/wide screenshots. Document runtime validation gaps.
2026-10-13 18:29:37 +02:00

183 lines
7.8 KiB
Bash
Executable File

#!/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-core --example sni-host
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
}
"${CLIPBIN_SESSION_REPO}/target/debug/examples/sni-host" > "${CLIPBIN_SESSION_OUT}/tray-host.log" 2>&1 &
host=$!
sleep 1
source "${CLIPBIN_SESSION_REPO}/scripts/rig/keys.sh"
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:-}" "${host}" 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
kill -0 "${app}"
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
# A second launch signals the original instance and exits; background discovery survives.
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1
kill -0 "${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}/desktop.log" 2 30
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
rig_key q 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