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.
This commit is contained in:
2026-10-13 18:29:37 +02:00
parent 178c400eeb
commit f1c31d6c90
21 changed files with 1413 additions and 133 deletions
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Serial tray/window/autostart validation in the private SNI-host rig.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-background-validation}"
mkdir -p "${out}"
cargo build --locked -p clipbin-desktop -p rig-pointer
cargo build --locked -p clipbin-core --example sni-host
export CLIPBIN_BACKGROUND_REPO="${repo}" CLIPBIN_BACKGROUND_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
repo="${CLIPBIN_BACKGROUND_REPO}" out="${CLIPBIN_BACKGROUND_OUT}"
export XDG_CONFIG_HOME="${out}/config" XDG_DATA_HOME="${out}/data"
mkdir -p "${XDG_CONFIG_HOME}" "${XDG_DATA_HOME}"
source "${repo}/scripts/rig/keys.sh"
"${repo}/target/debug/examples/sni-host" > "${out}/host.log" 2>&1 & host=$!
app= game=
trap 'kill "${app:-}" "${game:-}" "${host:-}" 2>/dev/null || true' EXIT
sleep 1
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] floating enable' >/dev/null
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] resize set width 1360 px height 860 px' >/dev/null
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] move position 280 110' >/dev/null
windows() {
swaymsg -t get_tree | python3 -c 'import json,sys
nodes=[json.load(sys.stdin)]; total=0
while nodes:
n=nodes.pop(); nodes.extend(n.get("nodes",[])+n.get("floating_nodes",[])); total+=n.get("app_id")=="dev.clipbin.Clipbin"
print(total)'
}
wait_windows() {
for _ in $(seq 50); do [[ "$(windows)" == "$1" ]] && return 0; sleep .2; done
echo "FAIL expected $1 Clipbin windows" >&2; exit 1
}
wait_exit() {
for _ in $(seq 100); do if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi; sleep .2; done
echo 'FAIL Quit did not exit' >&2; exit 1
}
CLIPBIN_ROUTE=settings "${repo}/target/debug/clipbin-desktop" --background > "${out}/desktop.log" 2>&1 & app=$!
sleep 3
kill -0 "${app}"
[[ "$(windows)" == 0 ]]
[[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
grep -q -- '--background' "${XDG_CONFIG_HOME}/autostart/clipbin.desktop"
item="$(sed -n 's/^registered=//p' "${out}/host.log" | tail -1)"
[[ -n "${item}" ]]
gdbus call --session --dest org.kde.StatusNotifierWatcher --object-path /StatusNotifierWatcher \
--method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems > "${out}/registered.txt"
grep -q "${item}" "${out}/registered.txt"
gdbus call --session --dest "${item}" --object-path /MenuBar \
--method com.canonical.dbusmenu.GetLayout -- 0 -1 '[]' > "${out}/menu.txt"
grep -q 'Open Clipbin' "${out}/menu.txt"
menu() { gdbus call --session --dest "${item}" --object-path /MenuBar --method com.canonical.dbusmenu.Event "$1" clicked '<0>' 0 > /dev/null; }
menu 1
wait_windows 1
swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position center, focus' >/dev/null
sleep 1
grim "${out}/settings.png"
# Coordinates within the centred 1360x860 settings window at 1920x1080.
printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080
sleep 1
[[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080
sleep 1
[[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
echo 'PASS SNI registration, menu Open, background startup and autostart setting'
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
for _ in $(seq 40); do grep -q 'recorder_status=Recording' "${out}/desktop.log" && break; sleep 1; done
grep -q 'recorder_status=Recording' "${out}/desktop.log"
gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/recording-tooltip.txt"
grep -q Recording "${out}/recording-tooltip.txt"
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0
before="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)"
sleep 5
after="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)"
(( after > before ))
"${repo}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1
wait_windows 1
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0
menu 1
wait_windows 1
# Audio delivery loss must be visible in the application and real tray tooltip.
kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")"
for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done
grep -q 'recorder_status=Degraded' "${out}/desktop.log"
sleep .3
gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/audio-lost-tooltip.txt"
grep -q 'audio capture lost' "${out}/audio-lost-tooltip.txt"
grim "${out}/audio-lost.png"
echo 'PASS audio loss is degraded in the UI and tray while video continues'
# Quit through D-Bus menu while a game is still alive.
menu 4
wait_exit
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)
assert all(s['complete'] for p in files for s in json.loads(p.read_text())['segments'])
PY
kill "${game}"; wait "${game}" 2>/dev/null || true; game=
echo 'PASS close keeps segments growing, second launch reopens, tray Open reopens, tray Quit finalises'
# Losing the watcher must reveal the UI; closing then quits cleanly.
"${repo}/target/debug/clipbin-desktop" --background > "${out}/host-loss.log" 2>&1 & app=$!
sleep 2
wait_windows 0
kill "${host}"; wait "${host}" 2>/dev/null || true; host=
wait_windows 1
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_exit
# Reconcile an externally disabled saved setting, removing the existing entry.
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
import json,pathlib,sys
p=pathlib.Path(sys.argv[1]); settings=json.loads(p.read_text()); settings['start_on_login']=False; p.write_text(json.dumps(settings))
PYSETTINGS
# With no watcher at startup --background must open a reachable window.
"${repo}/target/debug/clipbin-desktop" --background > "${out}/no-tray.log" 2>&1 & app=$!
wait_windows 1
grep -q 'Tray unavailable' "${out}/no-tray.log"
[[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_exit
echo 'PASS watcher loss reveals UI; no-tray background falls back to close=Quit'
INNER
+13
View File
@@ -115,6 +115,19 @@ else
result "${name}" 1
fi
# 9. Capture audio disappears while video/output remain healthy.
name=audio-source-lost
"${rig}" bash -c "'${probe}' record --seconds 15 --out '${out}/${name}' & p=\$!; sleep 6; kill \$(cat \"\${CLIPBIN_RIG_DIR}/pulse.pid\"); wait \$p" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
if [[ "${probe_exit}" == 0 ]] && has "${out}/${name}.log" "status=Recording" \
&& has "${out}/${name}.log" "audio capture lost" \
&& ! has "${out}/${name}.log" "status=failed"; then
result "${name}" 0 "video continued; audio loss surfaced as degraded"
else
result "${name}" 1
fi
echo
if [[ "${failures}" == 0 ]]; then
echo "All scenarios passed. Logs in ${out}"
+12 -12
View File
@@ -20,6 +20,7 @@ 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'
@@ -46,13 +47,17 @@ wait_log() {
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:-}" 2>/dev/null || true' EXIT
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
@@ -147,25 +152,20 @@ 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 -0 "${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=$!
# 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}/reopened.log" 1 30
wait_log 'recorder_status=Recording' "${out}/desktop.log" 2 30
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
wtype -M ctrl -k q -m ctrl
rig_key q ctrl
for _ in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then break; fi
sleep 1