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
Generated
+109 -1
View File
@@ -462,7 +462,7 @@ dependencies = [
"log", "log",
"num-rational", "num-rational",
"num-traits", "num-traits",
"pastey", "pastey 0.1.1",
"rayon", "rayon",
"thiserror 2.0.21", "thiserror 2.0.21",
"v_frame", "v_frame",
@@ -890,13 +890,16 @@ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"futures-lite 2.6.1", "futures-lite 2.6.1",
"futures-util", "futures-util",
"ksni",
"rustix 1.1.5", "rustix 1.1.5",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"tray-icon",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winreg 0.55.0", "winreg 0.55.0",
"x11rb", "x11rb",
"zbus",
] ]
[[package]] [[package]]
@@ -1333,6 +1336,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.8" version = "0.8.8"
@@ -1545,6 +1557,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dtor" name = "dtor"
version = "0.0.6" version = "0.0.6"
@@ -3103,6 +3121,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@@ -3113,6 +3141,24 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ksni"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b"
dependencies = [
"async-executor",
"async-io",
"async-lock",
"futures-channel",
"futures-lite 2.6.1",
"futures-util",
"pastey 0.2.3",
"serde",
"task-local",
"zbus",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.11.3" version = "0.11.3"
@@ -3468,6 +3514,24 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "multiversion_no_op" name = "multiversion_no_op"
version = "1.0.0" version = "1.0.0"
@@ -3849,6 +3913,16 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@@ -4047,6 +4121,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pathfinder_geometry" name = "pathfinder_geometry"
version = "0.5.1" version = "0.5.1"
@@ -5722,6 +5802,15 @@ dependencies = [
"objc", "objc",
] ]
[[package]]
name = "task-local"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626"
dependencies = [
"pin-project-lite",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -6060,6 +6149,25 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tray-icon"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
dependencies = [
"crossbeam-channel",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
+5 -5
View File
@@ -6,11 +6,11 @@ An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust a
## Current state ## Current state
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; Windows runtime validation is still to do.
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 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 leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable.
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 and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. Autostart and a tray are not implemented yet; after closing the only window, reopening it without a tray is a follow-up. 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 and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Audio capture delivery loss produces an “audio capture lost” warning while video continues.
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.
@@ -78,7 +78,7 @@ cargo run --locked -p clipbin-desktop
The desktop app needs a working Wayland or X11 session on Linux, or a Windows desktop. It does not start a web server. On this remote development machine, bind any future development server to its tailnet IP. The desktop app needs a working Wayland or X11 session on Linux, or a Windows desktop. It does not start a web server. On this remote development machine, bind any future development server to its tailnet IP.
Use the Quit button or Ctrl+Q to quit. Use tray Quit or Ctrl+Q to quit. Closing the window keeps Clipbin in the background when a tray is available. Start on login is enabled by default and can be disabled in Settings. On Linux a session D-Bus and an SNI host (for example Waybar or KDE) provide the tray; bare Sway falls back to close=Quit.
Checks: Checks:
@@ -92,7 +92,7 @@ cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin
Clips/bookmarks/cleanup validation: `scripts/validate/linux-clips.sh`. Native X11 hotkey validation: `scripts/validate/linux-hotkeys.sh`. Both run in the headless rig. Real Hyprland portal hotkeys and Windows runtime behaviour still need validation. Clips/bookmarks/cleanup validation: `scripts/validate/linux-clips.sh`. Native X11 hotkey validation: `scripts/validate/linux-hotkeys.sh`. Both run in the headless rig. Real Hyprland portal hotkeys and Windows runtime behaviour still need validation.
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). Playback fixtures: `scripts/validate/playback-fixture.sh`. Playback/audio e2e: `scripts/validate/linux-playback.sh [output-dir]` (moving preview, active recording, gaps, thumbnails, audio monitor, timing and CPU report). Fake Steam rigs must run serially because discovery reads the machine’s `/proc`. 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. Background/tray/autostart validation: `scripts/validate/linux-background.sh` uses a minimal SNI host and activates the real D-Bus menu. 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). Playback fixtures: `scripts/validate/playback-fixture.sh`. Playback/audio e2e: `scripts/validate/linux-playback.sh [output-dir]` (moving preview, active recording, gaps, thumbnails, audio monitor, timing and CPU report). Fake Steam rigs must run serially because discovery reads the machine’s `/proc`. 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.
+4 -1
View File
@@ -18,6 +18,8 @@ serde_json = "1"
tempfile = "3" tempfile = "3"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
ksni = { version = "0.3.6", default-features = false, features = ["async-io", "blocking"] }
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
ashpd = { version = "0.12.3", default-features = false, features = ["async-std"] } ashpd = { version = "0.12.3", default-features = false, features = ["async-std"] }
async-io = "2" async-io = "2"
futures-lite = "2" futures-lite = "2"
@@ -26,5 +28,6 @@ x11rb = { version = "0.13", features = ["xkb"] }
rustix = { version = "1", features = ["process", "event"] } rustix = { version = "1", features = ["process", "event"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
tray-icon = { version = "0.26", default-features = false }
winreg = "0.55" winreg = "0.55"
windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] } windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_Security", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
+37
View File
@@ -0,0 +1,37 @@
//! Minimal SNI watcher/host for the headless validation rig (no desktop bar).
#[cfg(target_os = "linux")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::sync::Mutex;
struct Watcher(Mutex<Vec<String>>);
#[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
impl Watcher {
fn register_status_notifier_item(&self, service: String) {
eprintln!("registered={service}");
self.0.lock().unwrap().push(service);
}
fn register_status_notifier_host(&self, service: String) {
let _ = service;
}
#[zbus(property)]
fn is_status_notifier_host_registered(&self) -> bool {
true
}
#[zbus(property)]
fn protocol_version(&self) -> i32 {
0
}
#[zbus(property)]
fn registered_status_notifier_items(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
}
let _connection = zbus::blocking::connection::Builder::session()?
.name("org.kde.StatusNotifierWatcher")?
.serve_at("/StatusNotifierWatcher", Watcher(Mutex::new(Vec::new())))?
.build()?;
loop {
std::thread::park();
}
}
#[cfg(not(target_os = "linux"))]
fn main() {}
+60
View File
@@ -0,0 +1,60 @@
//! Reconcile the OS login entry with the saved setting.
use std::{io, path::Path};
pub fn reconcile(enabled: bool) -> io::Result<()> {
apply(enabled, &std::env::current_exe()?)
}
#[cfg(target_os = "linux")]
fn apply(enabled: bool, executable: &Path) -> io::Result<()> {
let path = dirs::config_dir()
.ok_or_else(|| io::Error::other("No config directory"))?
.join("autostart/clipbin.desktop");
if enabled {
std::fs::create_dir_all(path.parent().unwrap())?;
let executable = executable
.to_str()
.ok_or_else(|| io::Error::other("Executable path is not UTF-8"))?;
// Desktop Entry Exec quoting includes reserved characters and doubles %.
let quoted = executable
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$")
.replace('%', "%%");
let escaped = quoted
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
std::fs::write(
path,
format!(
"[Desktop Entry]\nType=Application\nName=Clipbin\nExec=\"{escaped}\" --background\nTerminal=false\n"
),
)
} else {
match std::fs::remove_file(path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
}
#[cfg(windows)]
fn apply(enabled: bool, executable: &Path) -> io::Result<()> {
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
let (key, _) = RegKey::predef(HKEY_CURRENT_USER)
.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")?;
if enabled {
key.set_value(
"Clipbin",
&format!("\"{}\" --background", executable.display()),
)
} else {
match key.delete_value("Clipbin") {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Background actions shared by the native tray and single-instance endpoint.
use crate::RecordingStatus;
#[derive(Clone, Copy, Debug)]
pub enum Action {
Open,
Clip,
Bookmark,
Quit,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct State {
pub status: RecordingStatus,
pub game: Option<String>,
}
impl State {
pub fn tooltip(&self) -> String {
let mut text = format!("Clipbin · {}", self.status.label());
if let Some(game) = &self.game {
text.push_str(&format!(" · {game}"));
}
match &self.status {
RecordingStatus::Failed { reason } | RecordingStatus::Degraded { reason } => {
text.push_str(&format!(" · {reason}"));
}
_ => {}
}
text
}
pub fn colour(&self) -> [u8; 3] {
match self.status {
RecordingStatus::Recording => [239, 68, 68],
RecordingStatus::Failed { .. } | RecordingStatus::Degraded { .. } => [245, 158, 11],
_ => [168, 85, 247],
}
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use ksni::blocking::TrayMethods;
use std::sync::mpsc::Sender;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
struct Endpoint(Sender<Action>);
#[zbus::interface(name = "dev.clipbin.Clipbin")]
impl Endpoint {
fn open(&self) {
let _ = self.0.send(Action::Open);
}
}
pub struct Instance {
_connection: zbus::blocking::Connection,
}
impl Instance {
pub fn acquire(actions: Sender<Action>) -> Result<Option<Self>, String> {
let connection = zbus::blocking::connection::Builder::session()
.map_err(|e| e.to_string())?
.serve_at("/dev/clipbin/Clipbin", Endpoint(actions))
.map_err(|e| e.to_string())?
.build()
.map_err(|e| e.to_string())?;
match connection.request_name_with_flags(
"dev.clipbin.Clipbin",
zbus::fdo::RequestNameFlags::DoNotQueue.into(),
) {
Ok(zbus::fdo::RequestNameReply::PrimaryOwner) => Ok(Some(Self {
_connection: connection,
})),
Err(zbus::Error::NameTaken) => {
let proxy = zbus::blocking::Proxy::new(
&connection,
"dev.clipbin.Clipbin",
"/dev/clipbin/Clipbin",
"dev.clipbin.Clipbin",
)
.map_err(|e| e.to_string())?;
proxy
.call::<_, _, ()>("Open", &())
.map_err(|e| e.to_string())?;
Ok(None)
}
Err(error) => Err(error.to_string()),
Ok(reply) => Err(format!("Unexpected D-Bus name response: {reply:?}")),
}
}
}
struct Item {
state: State,
actions: Sender<Action>,
available: Arc<AtomicBool>,
}
impl ksni::Tray for Item {
fn id(&self) -> String {
"clipbin".into()
}
fn title(&self) -> String {
self.state.tooltip()
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
let [r, g, b] = self.state.colour();
let mut data = Vec::with_capacity(32 * 32 * 4);
for y in 0_i32..32 {
for x in 0_i32..32 {
data.extend_from_slice(&[
if (x - 16).pow(2) + (y - 16).pow(2) < 196 {
255
} else {
0
},
r,
g,
b,
]);
}
}
vec![ksni::Icon {
width: 32,
height: 32,
data,
}]
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
title: self.state.tooltip(),
..Default::default()
}
}
fn activate(&mut self, _: i32, _: i32) {
let _ = self.actions.send(Action::Open);
}
fn watcher_offline(&self, _: ksni::OfflineReason) -> bool {
self.available.store(false, Ordering::Relaxed);
let _ = self.actions.send(Action::Open);
true
}
fn watcher_online(&self) {
self.available.store(true, Ordering::Relaxed);
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
[
("Open Clipbin", Action::Open),
("Save clip", Action::Clip),
("Add bookmark", Action::Bookmark),
("Quit", Action::Quit),
]
.into_iter()
.map(|(label, action)| {
ksni::menu::StandardItem {
label: label.into(),
activate: Box::new(move |item: &mut Self| {
let _ = item.actions.send(action);
}),
..Default::default()
}
.into()
})
.collect()
}
}
pub struct Tray {
handle: ksni::blocking::Handle<Item>,
available: Arc<AtomicBool>,
}
impl Tray {
pub fn start(actions: Sender<Action>) -> Result<Self, String> {
let available = Arc::new(AtomicBool::new(true));
let handle = Item {
actions,
available: available.clone(),
state: State {
status: RecordingStatus::Idle,
game: None,
},
}
.spawn()
.map_err(|e| e.to_string())?;
Ok(Self { handle, available })
}
pub fn available(&self) -> bool {
self.available.load(Ordering::Relaxed) && !self.handle.is_closed()
}
pub fn update(&self, state: State) {
self.handle.update(move |item| item.state = state);
}
}
impl Drop for Tray {
fn drop(&mut self) {
self.handle.shutdown().wait();
}
}
}
#[cfg(windows)]
pub use crate::windows_background::{Instance, Tray};
#[cfg(target_os = "linux")]
pub use linux::{Instance, Tray};
+5
View File
@@ -20,3 +20,8 @@ pub mod cleanup;
pub mod clips; pub mod clips;
pub mod hotkeys; pub mod hotkeys;
pub mod autostart;
pub mod background;
#[cfg(windows)]
mod windows_background;
+6 -1
View File
@@ -1,4 +1,4 @@
/// The recorder will own these transitions once capture is implemented. /// The recorder's externally visible health.
/// ///
/// A game being detected or permission being granted is insufficient to enter /// A game being detected or permission being granted is insufficient to enter
/// Recording: a usable capture source and successful output must be confirmed. /// Recording: a usable capture source and successful output must be confirmed.
@@ -9,6 +9,10 @@ pub enum RecordingStatus {
WaitingForCapture, WaitingForCapture,
Starting, Starting,
Recording, Recording,
/// Video continues while a configured audio source is no longer delivering.
Degraded {
reason: String,
},
Failed { Failed {
reason: String, reason: String,
}, },
@@ -21,6 +25,7 @@ impl RecordingStatus {
Self::WaitingForCapture => "Waiting for capture", Self::WaitingForCapture => "Waiting for capture",
Self::Starting => "Starting", Self::Starting => "Starting",
Self::Recording => "Recording", Self::Recording => "Recording",
Self::Degraded { .. } => "Recording degraded",
Self::Failed { .. } => "Recording failed", Self::Failed { .. } => "Recording failed",
} }
} }
@@ -0,0 +1,295 @@
//! Win32 message pumps stay off GPUI's main thread.
#![allow(unsafe_code)]
use crate::background::{Action, State};
use std::{
ptr,
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
mpsc::{self, Sender},
},
thread,
time::Duration,
};
use windows_sys::Win32::{
Foundation::{
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND, LPARAM, LRESULT, WPARAM,
},
System::{LibraryLoader::GetModuleHandleW, Threading::CreateMutexW},
UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, FindWindowW, GetMessageW,
PM_REMOVE, PeekMessageW, PostMessageW, PostQuitMessage, RegisterClassW, TranslateMessage,
UnregisterClassW, WM_APP, WM_CLOSE, WM_DESTROY, WNDCLASSW,
},
};
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(Some(0)).collect()
}
static ACTIONS: OnceLock<Sender<Action>> = OnceLock::new();
const OPEN: u32 = WM_APP + 1;
unsafe extern "system" fn procedure(
window: HWND,
message: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
if message == OPEN {
if let Some(actions) = ACTIONS.get() {
let _ = actions.send(Action::Open);
}
return 0;
}
// SAFETY: this is a registered window procedure with Win32-provided arguments.
unsafe {
if message == WM_CLOSE {
DestroyWindow(window);
return 0;
}
if message == WM_DESTROY {
PostQuitMessage(0);
return 0;
}
DefWindowProcW(window, message, wparam, lparam)
}
}
pub struct Instance {
mutex: HANDLE,
window: usize,
worker: Option<thread::JoinHandle<()>>,
}
impl Instance {
pub fn acquire(actions: Sender<Action>) -> Result<Option<Self>, String> {
let name = wide(&format!(
"Local\\Clipbin-{}",
std::env::var("USERNAME").unwrap_or_default()
));
let class = wide("ClipbinSingleInstance");
// SAFETY: names are NUL-terminated, no security attributes or initial ownership.
let mutex = unsafe { CreateMutexW(ptr::null(), 0, name.as_ptr()) };
if mutex.is_null() {
return Err(std::io::Error::last_os_error().to_string());
}
// SAFETY: GetLastError is read immediately after CreateMutexW.
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
// Startup can race the first instance's window registration.
for _ in 0..100 {
// SAFETY: class string is live; FindWindow and PostMessage accept borrowed handles.
let window = unsafe { FindWindowW(class.as_ptr(), ptr::null()) };
if !window.is_null() {
// SAFETY: send a private message to the live endpoint window.
let sent = unsafe { PostMessageW(window, OPEN, 0, 0) };
let error = std::io::Error::last_os_error();
// SAFETY: release this launch's mutex reference exactly once.
unsafe {
CloseHandle(mutex);
}
return if sent != 0 {
Ok(None)
} else {
Err(error.to_string())
};
}
thread::sleep(Duration::from_millis(50));
}
// SAFETY: release our reference, leaving the first instance's mutex intact.
unsafe {
CloseHandle(mutex);
}
return Err("Running Clipbin did not expose its reopen endpoint".into());
}
let _ = ACTIONS.set(actions);
let (ready, result) = mpsc::sync_channel(1);
let worker = thread::spawn(move || {
// SAFETY: window and message loop are owned by this thread; the class
// and instance handle remain valid through DestroyWindow.
unsafe {
let instance = GetModuleHandleW(ptr::null());
let definition = WNDCLASSW {
lpfnWndProc: Some(procedure),
hInstance: instance,
lpszClassName: class.as_ptr(),
..std::mem::zeroed()
};
RegisterClassW(&definition);
let window = CreateWindowExW(
0,
class.as_ptr(),
class.as_ptr(),
0,
0,
0,
0,
0,
ptr::null_mut(),
ptr::null_mut(),
instance,
ptr::null(),
);
let _ = ready.send(if window.is_null() {
Err(std::io::Error::last_os_error().to_string())
} else {
Ok(window as usize)
});
if window.is_null() {
return;
}
let mut message = std::mem::zeroed();
while GetMessageW(&mut message, ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
UnregisterClassW(class.as_ptr(), instance);
}
});
match result.recv().map_err(|e| e.to_string())? {
Ok(window) => Ok(Some(Self {
mutex,
window,
worker: Some(worker),
})),
Err(error) => {
// SAFETY: endpoint creation failed; release the acquired mutex.
unsafe {
CloseHandle(mutex);
}
let _ = worker.join();
Err(error)
}
}
}
}
impl Drop for Instance {
fn drop(&mut self) {
// SAFETY: the endpoint thread owns this HWND; posting close lets it clean up.
unsafe {
PostMessageW(self.window as HWND, WM_CLOSE, 0, 0);
}
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
// SAFETY: release the mutex reference acquired above.
unsafe {
CloseHandle(self.mutex);
}
}
}
enum Command {
Update(State),
Stop,
}
pub struct Tray {
commands: Sender<Command>,
available: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl Tray {
pub fn start(actions: Sender<Action>) -> Result<Self, String> {
let (commands, receiver) = mpsc::channel();
let (ready, result) = mpsc::sync_channel(1);
let available = Arc::new(AtomicBool::new(true));
let alive = available.clone();
let worker = thread::spawn(move || {
use tray_icon::{
Icon, TrayIconBuilder, TrayIconEvent,
menu::{Menu, MenuEvent, MenuItem},
};
let run = || -> Result<(), String> {
let menu = Menu::new();
let entries: Vec<_> = [
("Open Clipbin", Action::Open),
("Save clip", Action::Clip),
("Add bookmark", Action::Bookmark),
("Quit", Action::Quit),
]
.into_iter()
.map(|(label, action)| (MenuItem::new(label, true, None), action))
.collect();
for (item, _) in &entries {
menu.append(item).map_err(|e| e.to_string())?;
}
let icon = |state: &State| {
let [r, g, b] = state.colour();
Icon::from_rgba([r, g, b, 255].repeat(32 * 32), 32, 32)
.map_err(|e| e.to_string())
};
let state = State {
status: crate::RecordingStatus::Idle,
game: None,
};
let tray = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip(state.tooltip())
.with_icon(icon(&state)?)
.build()
.map_err(|e| e.to_string())?;
let _ = ready.send(Ok(()));
loop {
// SAFETY: the tray is created and dispatched on this thread.
unsafe {
let mut message = std::mem::zeroed();
while PeekMessageW(&mut message, ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
while let Ok(event) = MenuEvent::receiver().try_recv() {
if let Some((_, action)) =
entries.iter().find(|(item, _)| item.id() == &event.id)
{
let _ = actions.send(*action);
}
}
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if matches!(event, TrayIconEvent::DoubleClick { .. }) {
let _ = actions.send(Action::Open);
}
}
match receiver.recv_timeout(Duration::from_millis(20)) {
Ok(Command::Update(state)) => {
tray.set_tooltip(Some(state.tooltip()))
.map_err(|e| e.to_string())?;
tray.set_icon(Some(icon(&state)?))
.map_err(|e| e.to_string())?;
}
Ok(Command::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
}
Ok(())
};
if let Err(error) = run() {
let _ = ready.send(Err(error.clone()));
eprintln!("Windows tray unavailable: {error}");
let _ = actions.send(Action::Open);
}
alive.store(false, Ordering::Relaxed);
});
result.recv().map_err(|e| e.to_string())??;
Ok(Self {
commands,
available,
worker: Some(worker),
})
}
pub fn available(&self) -> bool {
self.available.load(Ordering::Relaxed)
&& self
.worker
.as_ref()
.is_some_and(|worker| !worker.is_finished())
}
pub fn update(&self, state: State) {
let _ = self.commands.send(Command::Update(state));
}
}
impl Drop for Tray {
fn drop(&mut self) {
let _ = self.commands.send(Command::Stop);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
+1
View File
@@ -517,6 +517,7 @@ impl ClipbinApp {
theme::destructive().into(), theme::destructive().into(),
self.recorder.game.clone().unwrap_or_default(), self.recorder.game.clone().unwrap_or_default(),
), ),
RecordingStatus::Degraded { reason } => (theme::warning().into(), reason.clone()),
RecordingStatus::Starting | RecordingStatus::WaitingForCapture => ( RecordingStatus::Starting | RecordingStatus::WaitingForCapture => (
theme::warning().into(), theme::warning().into(),
self.recorder.game.clone().unwrap_or_default(), self.recorder.game.clone().unwrap_or_default(),
+167 -31
View File
@@ -3,6 +3,17 @@ use gpui::{
actions, px, size, actions, px, size,
}; };
use clipbin_core::background::{Action, Instance, Tray};
use std::{
cell::Cell,
rc::Rc,
sync::{
Arc, Mutex,
mpsc::{self, Receiver},
},
time::Duration,
};
mod app; mod app;
mod assets; mod assets;
mod components; mod components;
@@ -25,34 +36,113 @@ fn main() {
Some(format!("Could not load settings: {error}")), Some(format!("Could not load settings: {error}")),
), ),
}; };
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error); let (actions, receiver) = mpsc::channel();
let interface_service = service.clone(); let _instance = match Instance::acquire(actions.clone()) {
Application::new() Ok(Some(instance)) => instance,
.with_assets(assets::Assets) Ok(None) => return,
.run(|cx: &mut App| { Err(error) => {
if let Err(error) = assets::load_fonts(cx) { eprintln!("Could not establish single instance: {error}");
eprintln!("Could not load the interface font: {error}"); return;
} }
let quitter = interface_service.clone(); };
cx.on_action(move |_: &Quit, cx| { let tray = match Tray::start(actions) {
quitter.quit(); Ok(tray) => Some(tray),
let service = quitter.clone(); Err(error) => {
cx.spawn(async move |cx| { eprintln!("Tray unavailable; closing the window will quit: {error}");
None
}
};
let initial_error = clipbin_core::autostart::reconcile(settings.start_on_login)
.err()
.map(|e| format!("Could not configure start on login: {e}"))
.or(initial_error);
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
let receiver = Arc::new(Mutex::new(receiver));
let tray = tray.map(Arc::new);
let route = Rc::new(Cell::new(app::Route::Sessions));
let quitting = Rc::new(Cell::new(false));
let mut show = !std::env::args().any(|arg| arg == "--background") || tray.is_none();
let mut last_state = None;
loop { loop {
gpui::Timer::after(std::time::Duration::from_millis(100)).await; if show {
if service.snapshot().exited { run_window(
let _ = cx.update(|cx| cx.quit()); service.clone(),
receiver.clone(),
tray.clone(),
route.clone(),
quitting.clone(),
);
if quitting.get() {
break;
}
if !tray.as_ref().is_some_and(|tray| tray.available()) {
service.quit();
break;
}
eprintln!("Window closed: Clipbin is running in the tray");
show = false;
}
let state = service.background_state();
if last_state.as_ref() != Some(&state) {
if let Some(tray) = &tray {
tray.update(state.clone());
}
last_state = Some(state);
}
if service.exited() {
break;
}
if tray.as_ref().is_some_and(|tray| !tray.available()) {
show = true;
}
if let Ok(action) = receiver
.lock()
.unwrap()
.recv_timeout(Duration::from_millis(200))
{
match action {
Action::Open => show = true,
Action::Clip => service.clip(),
Action::Bookmark => service.bookmark(),
Action::Quit => {
service.quit();
break; break;
} }
} }
}) }
.detach(); }
let _ = worker.join();
}
fn run_window(
service: recorder_service::Client,
receiver: Arc<Mutex<Receiver<Action>>>,
tray: Option<Arc<Tray>>,
route: Rc<Cell<app::Route>>,
quitting: Rc<Cell<bool>>,
) {
Application::new()
.with_assets(assets::Assets)
.run(move |cx: &mut App| {
if let Err(error) = assets::load_fonts(cx) {
eprintln!("Could not load the interface font: {error}");
}
let quitter = service.clone();
let quit_flag = quitting.clone();
cx.on_action(move |_: &Quit, _| {
quit_flag.set(true);
quitter.quit();
}); });
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]); cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
cx.set_menus(vec![gpui::Menu {
name: "Clipbin".into(),
items: vec![gpui::MenuItem::action("Quit Clipbin", Quit)],
}]);
text_field::bind_keys(cx); text_field::bind_keys(cx);
playback::bind_keys(cx); playback::bind_keys(cx);
let settings = clipbin_core::store::load_settings().unwrap_or_default();
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx); let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
let window_service = service.clone();
let result = cx.open_window( let result = cx.open_window(
WindowOptions { WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)), window_bounds: Some(WindowBounds::Windowed(bounds)),
@@ -64,26 +154,72 @@ fn main() {
app_id: Some("dev.clipbin.Clipbin".into()), app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default() ..Default::default()
}, },
|window, cx| { move |window, cx| {
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, interface_service)) cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service))
}, },
); );
let window = match result {
match result { Ok(window) => window,
Ok(window) => playback::bind_actions(window, cx),
Err(error) => { Err(error) => {
eprintln!("Could not open Clipbin: {error}"); eprintln!("Could not open Clipbin: {error}");
quitting.set(true);
service.quit();
cx.quit(); cx.quit();
return; return;
} }
};
let saved_route = route.clone();
let close_service = service.clone();
let close_tray = tray.clone();
let close_quitting = quitting.clone();
let _ = window.update(cx, |app, window, cx| {
if saved_route.get() != app::Route::Sessions {
app.navigate(saved_route.get(), cx);
}
let entity = cx.entity();
window.on_window_should_close(cx, move |_, cx| {
saved_route.set(entity.read(cx).route);
if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
close_quitting.set(true);
close_service.quit();
false
} else {
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 playback::bind_actions(window, cx);
// its game ends. Reopening the window needs the future tray integration. cx.activate(true);
service.window_closed(); cx.spawn(async move |cx| {
let _ = worker.join(); let mut last_state = None;
drop(service); loop {
gpui::Timer::after(Duration::from_millis(100)).await;
let state = service.background_state();
if last_state.as_ref() != Some(&state) {
if let Some(tray) = &tray {
tray.update(state.clone());
}
last_state = Some(state);
}
while let Ok(action) = receiver.lock().unwrap().try_recv() {
match action {
Action::Open => {
let _ = window.update(cx, |_, window, _| window.activate_window());
}
Action::Clip => service.clip(),
Action::Bookmark => service.bookmark(),
Action::Quit => {
quitting.set(true);
service.quit();
}
}
}
if service.exited() {
let _ = cx.update(|cx| cx.quit());
break;
}
}
})
.detach();
});
} }
+25 -16
View File
@@ -38,7 +38,6 @@ pub struct Client {
enum Command { enum Command {
Settings(Settings), Settings(Settings),
ChooseSource, ChooseSource,
WindowClosed,
Quit, Quit,
Clip(Instant), Clip(Instant),
Bookmark(Instant), Bookmark(Instant),
@@ -85,6 +84,16 @@ impl Client {
pub fn snapshot(&self) -> Snapshot { pub fn snapshot(&self) -> Snapshot {
self.snapshot.lock().unwrap().clone() self.snapshot.lock().unwrap().clone()
} }
pub fn background_state(&self) -> clipbin_core::background::State {
let snapshot = self.snapshot.lock().unwrap();
clipbin_core::background::State {
status: snapshot.status.clone(),
game: snapshot.game.clone(),
}
}
pub fn exited(&self) -> bool {
self.snapshot.lock().unwrap().exited
}
pub fn settings(&self, settings: Settings) { pub fn settings(&self, settings: Settings) {
let _ = self.commands.send(Command::Settings(settings)); let _ = self.commands.send(Command::Settings(settings));
} }
@@ -132,9 +141,6 @@ impl Client {
pub fn choose_source(&self) { pub fn choose_source(&self) {
let _ = self.commands.send(Command::ChooseSource); let _ = self.commands.send(Command::ChooseSource);
} }
pub fn window_closed(&self) {
let _ = self.commands.send(Command::WindowClosed);
}
pub fn quit(&self) { pub fn quit(&self) {
if self.commands.send(Command::Quit).is_err() { if self.commands.send(Command::Quit).is_err() {
self.snapshot.lock().unwrap().exited = true; self.snapshot.lock().unwrap().exited = true;
@@ -385,7 +391,6 @@ fn worker(
let mut hotkeys = Some(register(&settings)); let mut hotkeys = Some(register(&settings));
let mut active: Option<Active> = None; let mut active: Option<Active> = None;
let mut quitting = false; let mut quitting = false;
let mut finish_after_session = false;
let mut next_scan = Instant::now(); let mut next_scan = Instant::now();
let mut next_poll = Instant::now(); let mut next_poll = Instant::now();
let mut next_save = Instant::now(); let mut next_save = Instant::now();
@@ -395,10 +400,6 @@ fn worker(
let mut next_cleanup = Instant::now(); let mut next_cleanup = Instant::now();
loop { loop {
match commands.recv_timeout(Duration::from_millis(200)) { 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) => { Ok(Command::Quit) => {
quitting = true; quitting = true;
if let Some(a) = &mut active { if let Some(a) = &mut active {
@@ -424,6 +425,10 @@ fn worker(
if let Err(e) = store::save_settings(&settings) { if let Err(e) = store::save_settings(&settings) {
fail(snapshot, format!("Could not save settings: {e}")); fail(snapshot, format!("Could not save settings: {e}"));
} }
if let Err(e) = clipbin_core::autostart::reconcile(settings.start_on_login) {
snapshot.lock().unwrap().notice =
Some(format!("Could not configure start on login: {e}"));
}
snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone(); snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone();
} }
Ok(Command::Clip(pressed)) => { Ok(Command::Clip(pressed)) => {
@@ -548,11 +553,11 @@ fn worker(
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true, Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Timeout) => {}
} }
if (quitting || finish_after_session) && active.is_none() && !exports.busy() { if quitting && active.is_none() && !exports.busy() {
break; break;
} }
let now = Instant::now(); let now = Instant::now();
if active.is_none() && !quitting && !finish_after_session && now >= next_scan { if active.is_none() && !quitting && now >= next_scan {
next_scan = now + Duration::from_secs(1); next_scan = now + Duration::from_secs(1);
match games::discover() { match games::discover() {
Ok(games) => { Ok(games) => {
@@ -650,16 +655,20 @@ fn worker(
} }
if !a.ending && !a.stopping { if !a.ending && !a.stopping {
let status = a.tracker.sample(&health, now).clone(); let status = a.tracker.sample(&health, now).clone();
if status == RecordingStatus::Recording { if matches!(
status,
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
) {
a.retries = 0; a.retries = 0;
} }
// Keep a failure visible throughout recovery until footage is proven. // Keep a failure visible throughout recovery until footage is proven.
if status == RecordingStatus::Recording if matches!(
|| !matches!( status,
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
) || !matches!(
snapshot.lock().unwrap().status, snapshot.lock().unwrap().status,
RecordingStatus::Failed { .. } RecordingStatus::Failed { .. }
) ) {
{
snapshot.lock().unwrap().status = status.clone(); snapshot.lock().unwrap().status = status.clone();
} }
if matches!(status, RecordingStatus::Failed { .. }) { if matches!(status, RecordingStatus::Failed { .. }) {
+57 -17
View File
@@ -1,12 +1,14 @@
//! Exported clips: local MP4 files that never expire automatically. //! Exported clips: local MP4 files that never expire automatically.
use clipbin_core::library::{format_ago, format_bytes, format_length}; use clipbin_core::library::{format_ago, format_bytes, format_length};
use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelude::*}; use gpui::{
AnyElement, ClickEvent, Context, FontWeight, IntoElement, Pixels, canvas, div, prelude::*, px,
};
use crate::{ use crate::{
app::ClipbinApp, app::ClipbinApp,
components::{ButtonStyle, button, chip, count, empty_state, page_header}, components::{ButtonStyle, button, chip, count, empty_state, page_header},
screens::{corner, muted_line, thumbnail}, screens::{corner, thumbnail},
theme, theme,
}; };
@@ -90,6 +92,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
div() div()
.flex() .flex()
.flex_col() .flex_col()
.min_w_0()
.gap_3() .gap_3()
.child( .child(
thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child( thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child(
@@ -102,14 +105,14 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.child( .child(
div() div()
.flex() .flex()
.items_start() .flex_col()
.min_w_0()
.gap_2() .gap_2()
.px_1() .px_1()
.child( .child(
div() div()
.flex() .flex()
.flex_col() .flex_col()
.flex_1()
.min_w_0() .min_w_0()
.gap_0p5() .gap_0p5()
.child( .child(
@@ -117,29 +120,32 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.text_base() .text_base()
.font_weight(FontWeight::SEMIBOLD) .font_weight(FontWeight::SEMIBOLD)
.text_color(theme::foreground()) .text_color(theme::foreground())
.truncate() .child(label(clip.game.clone(), px(24.))),
.child(clip.game.clone()),
) )
.child(muted_line(format!( .child(div().text_xs().text_color(theme::subtle()).child(label(
format!(
"{} · {}", "{} · {}",
format_ago(clip.created, now), format_ago(clip.created, now),
format_bytes(clip.size_bytes) format_bytes(clip.size_bytes)
),
px(18.),
))), ))),
) )
.child( .child(
button( div()
("open-clip", clip.id), .flex()
ButtonStyle::Ghost, .items_center()
Some("play"), .justify_end()
None, .gap_2()
) .flex_shrink_0()
.child(
button(("open-clip", id), ButtonStyle::Ghost, Some("play"), None)
.flex_shrink_0()
.rounded_full() .rounded_full()
.on_click(cx.listener( .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
move |app, _: &ClickEvent, _, cx| {
app.service.open_clip(Some(id)); app.service.open_clip(Some(id));
cx.notify(); cx.notify();
}, })),
)),
) )
.child( .child(
button( button(
@@ -148,6 +154,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
None, None,
Some("Delete"), Some("Delete"),
) )
.flex_shrink_0()
.on_click(cx.listener( .on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| { move |app, _: &ClickEvent, _, cx| {
app.service.delete_clip(id); app.service.delete_clip(id);
@@ -155,6 +162,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
}, },
)), )),
), ),
),
) )
}); });
@@ -174,3 +182,35 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
) )
.into_any_element() .into_any_element()
} }
// GPUI 0.2.2 caches nowrap widths and mutates text runs during remeasurement.
// Shape once at the final card bounds to retain both ellipsis and font styling.
fn label(text: String, height: Pixels) -> impl IntoElement {
canvas(
move |bounds, window, cx| {
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let line_height = style
.line_height
.to_pixels(font_size.into(), window.rem_size());
let text: gpui::SharedString = text.replace(['\n', '\r'], " ").into();
let mut runs = vec![style.to_run(text.len())];
let text = cx
.text_system()
.line_wrapper(style.font(), font_size)
.truncate_line(text, bounds.size.width, "…", &mut runs);
(
window
.text_system()
.shape_line(text, font_size, &runs, None),
line_height,
)
},
|bounds, (line, line_height), window, cx| {
let _ = line.paint(bounds.origin, line_height, window, cx);
},
)
.w_full()
.h(height)
.overflow_hidden()
}
+45
View File
@@ -13,6 +13,7 @@ use std::{
atomic::{AtomicU8, AtomicU64, Ordering}, atomic::{AtomicU8, AtomicU64, Ordering},
mpsc::Sender, mpsc::Sender,
}, },
time::Instant,
}; };
use libobs as sys; use libobs as sys;
@@ -611,6 +612,26 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
stem.strip_prefix("segment-")?.parse().ok() stem.strip_prefix("segment-")?.parse().ok()
} }
struct AudioClock {
started: Instant,
last_millis: AtomicU64,
}
unsafe extern "C" fn audio_captured(
param: *mut c_void,
_: *mut sys::obs_source_t,
_: *const sys::audio_data,
_: bool,
) {
// SAFETY: each callback holds the stable AudioClock allocation until removal;
// OBS serialises removal against callbacks with source->audio_cb_mutex.
let clock = unsafe { &*param.cast::<AudioClock>() };
clock.last_millis.store(
clock.started.elapsed().as_millis() as u64,
Ordering::Relaxed,
);
}
/// The live OBS objects for one recording. Capture starts at once; encoders /// The live OBS objects for one recording. Capture starts at once; encoders
/// and the segment output are created only when the source has a picture, /// and the segment output are created only when the source has a picture,
/// so nothing is written while the user picks a source or capture is down. /// so nothing is written while the user picks a source or capture is down.
@@ -622,6 +643,7 @@ pub(crate) struct Pipeline {
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>, sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
video_source: *mut sys::obs_source_t, video_source: *mut sys::obs_source_t,
microphone: bool, microphone: bool,
audio_clocks: Vec<(*mut sys::obs_source_t, Box<AudioClock>)>,
pending: Mutex<Option<OutputSpec>>, pending: Mutex<Option<OutputSpec>>,
output: Mutex<Option<Output>>, output: Mutex<Option<Output>>,
} }
@@ -648,6 +670,7 @@ impl Pipeline {
sources: Vec::new(), sources: Vec::new(),
video_source: ptr::null_mut(), video_source: ptr::null_mut(),
microphone: capture.microphone.is_some(), microphone: capture.microphone.is_some(),
audio_clocks: Vec::new(),
pending: Mutex::new(None), pending: Mutex::new(None),
output: Mutex::new(None), output: Mutex::new(None),
name_prefix: name_prefix.clone(), name_prefix: name_prefix.clone(),
@@ -672,6 +695,16 @@ impl Pipeline {
if let Some(source_spec) = source_spec { if let Some(source_spec) = source_spec {
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?; let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
pipeline.sources.push((Some(channel), source)); pipeline.sources.push((Some(channel), source));
let mut clock = Box::new(AudioClock {
started: Instant::now(),
last_millis: AtomicU64::new(0),
});
sys::obs_source_add_audio_capture_callback(
source,
Some(audio_captured),
(&mut *clock as *mut AudioClock).cast(),
);
pipeline.audio_clocks.push((source, clock));
let mixers = tracks let mixers = tracks
.iter() .iter()
.filter(|t| **t == track || **t == COMBINED_TRACK) .filter(|t| **t == track || **t == COMBINED_TRACK)
@@ -769,6 +802,11 @@ impl Pipeline {
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64, lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
written_bytes: 0, written_bytes: 0,
disk_available_bytes: None, disk_available_bytes: None,
audio_lost: self.audio_clocks.iter().any(|(_, clock)| {
(clock.started.elapsed().as_millis() as u64)
.saturating_sub(clock.last_millis.load(Ordering::Relaxed))
>= 5_000
}),
} }
} }
@@ -797,6 +835,13 @@ impl Drop for Pipeline {
drop(self.output.get_mut().ok().and_then(Option::take)); drop(self.output.get_mut().ok().and_then(Option::take));
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there). // SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
unsafe { unsafe {
for (source, clock) in &mut self.audio_clocks {
sys::obs_source_remove_audio_capture_callback(
*source,
Some(audio_captured),
(&mut **clock as *mut AudioClock).cast(),
);
}
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut()); sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
for (channel, source) in &self.sources { for (channel, source) in &self.sources {
if let Some(channel) = channel { if let Some(channel) = channel {
+39 -4
View File
@@ -304,6 +304,8 @@ pub struct Health {
/// PipeWire capture's portal and stream state. `None` for other sources, /// PipeWire capture's portal and stream state. `None` for other sources,
/// which report loss through their size, and before the portal answers. /// which report loss through their size, and before the portal answers.
pub pipewire: Option<PipeWireState>, pub pipewire: Option<PipeWireState>,
/// A configured capture source has delivered no audio for five seconds.
pub audio_lost: bool,
pub output_active: bool, pub output_active: bool,
pub total_frames: u64, pub total_frames: u64,
pub dropped_frames: u64, pub dropped_frames: u64,
@@ -409,17 +411,27 @@ impl StatusTracker {
(_, true, true) (_, true, true)
if health.output_active && health.total_frames > 0 && self.last_written > 0 => if health.output_active && health.total_frames > 0 && self.last_written > 0 =>
{ {
if health.audio_lost {
RecordingStatus::Degraded {
reason: "audio capture lost".into(),
}
} else {
RecordingStatus::Recording RecordingStatus::Recording
} }
(RecordingStatus::Recording, false, _) => RecordingStatus::Failed { }
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
RecordingStatus::Failed {
reason: "the capture source stopped providing a picture".into(), reason: "the capture source stopped providing a picture".into(),
}, }
(RecordingStatus::Recording, true, false) => RecordingStatus::Failed { }
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, true, false) => {
RecordingStatus::Failed {
reason: format!( reason: format!(
"no footage was written for {} seconds", "no footage was written for {} seconds",
self.stall_limit.as_secs() self.stall_limit.as_secs()
), ),
}, }
}
(_, false, _) => RecordingStatus::WaitingForCapture, (_, false, _) => RecordingStatus::WaitingForCapture,
(status, _, _) => status.clone(), (status, _, _) => status.clone(),
}; };
@@ -511,10 +523,33 @@ mod tests {
total_bytes: bytes, total_bytes: bytes,
lagged_frames: 0, lagged_frames: 0,
written_bytes: bytes, written_bytes: bytes,
audio_lost: false,
disk_available_bytes: Some(u64::MAX), disk_available_bytes: Some(u64::MAX),
} }
} }
#[test]
fn audio_loss_degrades_and_recovers_without_masking_video_loss() {
let now = Instant::now();
let mut tracker = StatusTracker::new(now, 0, SEGMENT);
let mut health = healthy(10_000);
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
health.audio_lost = true;
assert!(matches!(
tracker.sample(&health, now),
RecordingStatus::Degraded { .. }
));
health.audio_lost = false;
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
health.audio_lost = true;
tracker.sample(&health, now);
health.source_width = 0;
assert!(matches!(
tracker.sample(&health, now),
RecordingStatus::Failed { .. }
));
}
#[test] #[test]
fn low_disk_space_fails_before_the_disk_is_full() { fn low_disk_space_fails_before_the_disk_is_full() {
let start = Instant::now(); let start = Instant::now();
+7 -1
View File
@@ -367,7 +367,10 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
health.written_bytes health.written_bytes
); );
report_status(&mut status, tracker.sample(&health, now), &elapsed()); report_status(&mut status, tracker.sample(&health, now), &elapsed());
recorded |= status == RecordingStatus::Recording; recorded |= matches!(
status,
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
);
} }
if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) { if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) {
stopping = true; stopping = true;
@@ -414,6 +417,9 @@ fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed
if shown != current { if shown != current {
*shown = current.clone(); *shown = current.clone();
match current { match current {
RecordingStatus::Degraded { reason } => {
println!("t={elapsed} status=degraded reason={reason}")
}
RecordingStatus::Failed { reason } => { RecordingStatus::Failed { reason } => {
println!("t={elapsed} status=failed reason={reason}") println!("t={elapsed} status=failed reason={reason}")
} }
+166 -14
View File
@@ -8,9 +8,9 @@ pass/fail results.
| Where | Script | Status | | Where | Script | Status |
| --- | --- | --- | | --- | --- | --- |
| 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 10 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 |
| 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 | | Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen 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
@@ -174,8 +174,11 @@ Windows status:
monitor's own size, set when capture first has a picture (portrait monitors monitor's own size, set when capture first has a picture (portrait monitors
record portrait). If the window is resized mid-session the size stays and record portrait). If the window is resized mid-session the size stays and
the picture is scaled to fit. the picture is scaled to fit.
- **Audio capture failures are not detected yet.** When the PulseAudio server - **Audio delivery health.** The configured system/microphone capture sources
was unreachable, recording continued with a silent track. are monitored directly through OBS audio capture callbacks. After five seconds
without delivery, recording is degraded with “audio capture lost”; video
continues. The silent muxer track cannot mask absent source callbacks. See
Background operation below for validation and limits.
- **Linux runtime dependencies.** The Linux OBS build links the system's - **Linux runtime dependencies.** The Linux OBS build links the system's
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open
@@ -251,17 +254,19 @@ Real Steam validated 2026-10-08 on CachyOS/Hyprland (native Steam): Escape
from Duckov (AppId 3167020, Proton Experimental under SteamLinuxRuntime_4) from Duckov (AppId 3167020, Proton Experimental under SteamLinuxRuntime_4)
runs under `reaper SteamLaunch AppId=3167020 -- …/_v2-entry-point … proton runs under `reaper SteamLaunch AppId=3167020 -- …/_v2-entry-point … proton
waitforexitandrun …/Duckov.exe`. Clipbin detected it, named it from its waitforexitandrun …/Duckov.exe`. Clipbin detected it, named it from its
manifest ("Escape from Duckov") and recorded the session. Audio-source manifest ("Escape from Duckov") and recorded the session. Audio delivery loss
failure detection remains limited as described above. is now detected as described below; the new warning still needs real hardware
and Windows validation.
GPUI 0.2.2 ends its Linux event loop when the last window is removed, even GPUI 0.2.2 ends its Linux and Windows event loops when the last window is
without an application close callback. Main keeps the recorder worker alive removed. Clipbin keeps the recorder, tray and single-instance endpoint alive
after that return. Closing the window continues the current game session; outside each GPUI run, and constructs a fresh Application on the main thread
when that game exits, it finalises and the process exits. If no session is when reopened. The previous route is restored and the library/settings are
active, it exits immediately. It stops discovering new games once closed, reloaded; unavailable sessions fall back to the library. This also releases
so there is no indefinitely running invisible app. Reopening the window and playback resources while the interface is closed. No fake or hidden GPUI window
persistent background discovery need the future tray integration. Explicit is kept alive: GPUI's Linux backends ignore WindowOptions::show. Window bounds,
Quit/Ctrl+Q stops and finalises the current recording immediately. scroll offsets and unsaved editor selection are not restored. Explicit Quit
waits for the existing worker's clean session finish before joining/exiting.
## Clips, bookmarks and storage cleanup ## Clips, bookmarks and storage cleanup
@@ -497,3 +502,150 @@ Logs are `/tmp/clipbin-clips-quit-fixed.log` and
output directories. `bash -n`, `git diff --check` and `cargo fmt --check` also output directories. `bash -n`, `git diff --check` and `cargo fmt --check` also
passed. This follow-up changes only the Linux rig scripts and documentation; passed. This follow-up changes only the Linux rig scripts and documentation;
Windows runtime and real Hyprland validation limits above remain unchanged. Windows runtime and real Hyprland validation limits above remain unchanged.
## Background operation (2026-10-13)
Linux uses [ksni 0.3.6](https://github.com/iovxw/ksni) with the blocking,
async-io API. It implements SNI and com.canonical.dbusmenu on its own executor,
works on Wayland and X11, and requires no GTK main loop. Using tray-icon on
Linux would require GTK alongside GPUI's main-thread loop, so it is only a
Windows dependency. Windows uses [tray-icon 0.26.1](https://github.com/tauri-apps/tray-icon)
with its menu and notification-area icon on a dedicated Win32 message thread;
its documented Windows event loop does not need the main thread. GPUI owns the
main thread on both platforms. Platform services live in clipbin-core and are
included in Windows cross-clippy. The new raw Win32 calls are confined to
windows_background.rs, with a module unsafe allowance, SAFETY comments and
crate-level unsafe_code=deny; desktop retains the workspace forbid policy.
The tray has purple idle, red recording and amber failed/degraded variants,
with status, game and error detail in its tooltip. Open Clipbin, Save clip,
Add bookmark and Quit use the recorder's existing actions. Linux requires an
SNI watcher with a registered host: startup failure opens the interface even
with --background, logs the reason and makes close finish/quit. A watcher that
disappears later requests Open and disables close-to-tray until it returns.
This avoids leaving an unreachable process after a bar/watcher disappears.
Linux owns dev.clipbin.Clipbin on the session bus and exposes Open at
/dev/clipbin/Clipbin. A second process requests the same name without queueing,
calls Open on the owner and exits before starting OBS or reconciling autostart.
A session bus is required; endpoint acquisition errors are logged and startup
exits rather than starting a duplicate recorder. Windows holds a per-user
Local named mutex and posts a private message to the first instance's hidden
endpoint window, with a bounded five-second startup race wait. The hidden
endpoint owns its message thread independently of the GPUI windows.
Start on login reconciles on startup and each settings save. Linux writes or
removes $XDG_CONFIG_HOME/autostart/clipbin.desktop (default ~/.config/autostart),
with a quoted executable path and --background; Windows writes/removes the
Clipbin value in HKCU\Software\Microsoft\Windows\CurrentVersion\Run. Changing
login settings does not interrupt capture. The executable must stay at its
installed path; development builds point the entry at the build executable.
Actual desktop login dispatch remains unvalidated.
Audio health uses [OBS 32.0.4's source capture callback API](https://github.com/obsproject/obs-studio/blob/32.0.4/libobs/obs-source.c)
on every configured system/microphone source, independently of the encoder,
mixed audio counters and silent output track. A boxed callback context stores
an atomic monotonic delivery timestamp; it performs no allocation or locking.
The context is removed before source release: OBS's audio_cb_mutex serialises
callback removal with callbacks. Five seconds without delivery (including a
source that never starts) is a warning-level RecordingStatus::Degraded,
with “audio capture lost” in the sidebar and tray. A new callback clears the
warning; video/source/output/disk failures still take precedence and stop
recording. Intentionally disabled audio has no callback and no warning. This
checks delivery, not amplitude: valid silent samples are healthy. It cannot
detect a driver delivering endless synthetic silence, and WASAPI silence or
idle-device callback behaviour needs a Windows runtime check. No automatic
source restart is added; video continues and audio can recover if OBS resumes
its source. Source identity is not displayed separately for system vs mic loss.
The rig's audio failure injection kills its private pipewire-pulse daemon,
leaving PipeWire video and the portal alive. Unloading the null sink and
requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8;
killing that private audio server deterministically stops source callbacks.
The original nine scenarios are retained alongside this tenth warning case.
The rig now uses an explicit private SWAYSOCK and waits for it; discovery by
the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint.
CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs.
Windows limitations for this step: core/OBS/probe cross-clippy covers the native
tray, message pump, named mutex/hidden-window endpoint, HKCU autostart and OBS
callback registration/removal. Nothing new has been linked or run on Windows.
GPUI desktop and clipbin-media remain excluded from that lint set because the
MSVC C toolchain/FFmpeg development import libraries are unavailable here.
Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton
startup races and multi-user/desktop behaviour, foreground activation, repeated
GPUI Application creation after close, clean Quit/file closure, --background,
registry writes and real login launch, WASAPI delivery-loss/recovery/idle
semantics, and the Clips layout on Windows. Real Linux desktop trays (Hyprland
Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration
and native X11 tray lifecycle still need runtime validation.
Clips cards give title/subtitle their own row and keep fixed-size Play/Delete
controls below. The pinned GPUI text element caches nowrap widths independently
of truncation width and mutates its text runs during repeated measurements;
ordinary truncate produced hard clipping, ellipsis-only text, or fallback fonts
in the grid. A small private canvas label uses GPUI's line-wrapper and shaping
APIs once at the final bounds, retaining Geist Mono, colour, weight and proper
ellipsis without patching GPUI. Newlines in game names become spaces. Sample
screenshots at 1100x760 and 1600x1000 were inspected: all sample metadata is
readable and controls cannot steal its width. The amber audio warning was also
inspected in lifecycle-final/audio-lost.png. Screenshot validation exercises
the actual rendering path; no unit test can reproduce the GPU/grid text
measurement chain without the rig.
Validation commands, from the background-tray worktree (all passed):
```sh
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
```
57 workspace tests passed (generated-media tests keep their existing opt-in
fixture behaviour). The added status test covers audio degradation, recovery
and video-loss precedence. The dependency's existing proc-macro-error2 future
incompatibility notice is still printed; strict current clippy passes.
/tmp is a user-quota tmpfs here: an earlier recording probe failed with
Disk quota exceeded despite df reporting free bytes. Successful serial rig
runs used repository output storage, with a short absolute TMPDIR so private
Unix socket paths fit their limits:
```sh
export PATH=/usr/bin:/bin:$HOME/.cargo/bin
export TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp
out=/home/harry/Dev/clipbin-desktop/target/background-validation
mkdir -p "$TMPDIR"
scripts/validate/linux-rig.sh "$out/capture"
scripts/validate/linux-background.sh "$out/lifecycle-final"
scripts/validate/linux-session.sh "$out/sessions"
scripts/validate/linux-clips.sh "$out/clips"
scripts/validate/linux-playback.sh "$out/playback"
CLIPBIN_RIG_WIDTH=1100 CLIPBIN_RIG_HEIGHT=760 scripts/rig/screenshot-ui.sh "$out/screens-narrow-labels" clips
CLIPBIN_RIG_WIDTH=1600 CLIPBIN_RIG_HEIGHT=1000 scripts/rig/screenshot-ui.sh "$out/screens-wide-labels" clips
```
Capture: all original nine scenarios plus audio loss passed. Lifecycle: actual
watcher registration and D-Bus menu Open/Quit, recording tooltip, no-window
background start, autostart toggle removal/write and startup reconciliation,
segments growing after close, second launch reopening the same process,
preserved Settings route, degraded UI/tooltip, clean active-session Quit,
watcher-loss reveal and no-host close=Quit fallback passed. Sessions: game exit,
exact cumulative segment placement, continued background discovery/library
reload, second-launch Open and active Ctrl+Q passed. Clips: H.264 MP4 retained,
360 CFR frames, continuous audio, zero decode errors, original press range,
bookmarks, tiny-budget cleanup and bookmark re-export passed. Playback: moving
preview (6.1% changed pixels), device audio RMS 0.08027, thumbnails, continued
recording, gaps, cross-boundary seek, volume/mute and held-key controls passed.
Decode median/p95 was 6.335/10.472 ms; GPUI upload median/p95 was 0.208/0.404 ms.
Both old UI suites still use explicit Quit and completed successfully.
The simplify quick self-review covered reuse, clarity and efficiency before
commit: removed the obsolete window-close shutdown mode, reused existing clip,
bookmark and clean-finish paths, avoided copying the full library for tray
polling, limited icon updates to state changes, kept callbacks allocation/lock
free, and made native tray/thread cleanup explicit. The text helper is limited
to the demonstrated GPUI 0.2.2 rendering bug; no dependency fork or general UI
framework was added. Native Windows runtime and real desktop limits above
remain unchanged.
+8 -4
View File
@@ -42,6 +42,10 @@ cleanup() {
wait 2>/dev/null || true wait 2>/dev/null || true
# The D-Bus-activated document portal mounts a FUSE filesystem here. # The D-Bus-activated document portal mounts a FUSE filesystem here.
fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}"
cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/" 2>/dev/null || true
fi
rm -rf "${rig_dir}" rm -rf "${rig_dir}"
} }
trap cleanup EXIT trap cleanup EXIT
@@ -70,6 +74,7 @@ pipewire > "${rig_dir}/pipewire.log" 2>&1 & pids+=($!)
wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]' wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]'
wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!) wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!)
pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!) pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!)
echo "${pids[-1]}" > "${rig_dir}/pulse.pid"
wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]' wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]'
# No sound card here: route playback to a virtual sink whose monitor OBS # No sound card here: route playback to a virtual sink whose monitor OBS
# records as desktop audio. # records as desktop audio.
@@ -89,11 +94,12 @@ printf '%s\n' \
"max_fps=60" \ "max_fps=60" \
> "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config" > "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config"
export SWAYSOCK="${XDG_RUNTIME_DIR}/clipbin-sway.sock"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \ WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \
sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!) sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!)
wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null' wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null'
WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")" WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")"
SWAYSOCK="$(compgen -G "${XDG_RUNTIME_DIR}/sway-ipc.*" | head -1)" wait_for "sway IPC" '[[ -S "${SWAYSOCK}" ]]'
export WAYLAND_DISPLAY SWAYSOCK export WAYLAND_DISPLAY SWAYSOCK
dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK
/usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!) /usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!)
@@ -126,7 +132,5 @@ export CLIPBIN_RIG_DIR="${rig_dir}"
echo "rig: ready (${mode}, logs in ${rig_dir})" >&2 echo "rig: ready (${mode}, logs in ${rig_dir})" >&2
status=0 status=0
"$@" || status=$? "$@" || status=$?
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}" && cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/"
fi
exit "${status}" exit "${status}"
+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 result "${name}" 1
fi 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 echo
if [[ "${failures}" == 0 ]]; then if [[ "${failures}" == 0 ]]; then
echo "All scenarios passed. Logs in ${out}" echo "All scenarios passed. Logs in ${out}"
+12 -12
View File
@@ -20,6 +20,7 @@ done
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}" out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}"
mkdir -p "${out}" mkdir -p "${out}"
cargo build --locked -p clipbin-core --example sni-host
cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml" cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}" export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER' "${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 echo "FAIL timed out waiting for ${minimum} '${pattern}' entries; log: ${log}" >&2
exit 1 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}" out="${CLIPBIN_SESSION_OUT}"
portal_config="${XDG_CONFIG_HOME}" portal_config="${XDG_CONFIG_HOME}"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & "${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 &
app=$! app=$!
trap 'kill "${app}" "${game:-}" "${portal:-}" 2>/dev/null || true' EXIT trap 'kill "${app}" "${game:-}" "${portal:-}" "${host}" 2>/dev/null || true' EXIT
sleep 3 sleep 3
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true 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 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')) 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 files and all(not json.loads(p.read_text())['session']['active'] for p in files)
PY PY
for _ in $(seq 10); do kill -0 "${app}"
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")" kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
# Reopen to prove the library loads from disk, then Quit cleanly with Ctrl+Q. # A second launch signals the original instance and exits; background discovery survives.
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/reopened.log" 2>&1 & "${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1
app=$! kill -0 "${app}"
sleep 4 sleep 4
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
sleep 0.5 sleep 0.5
grim "${out}/library.png" grim "${out}/library.png"
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' &
game=$! 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 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 for _ in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then break; fi if ! kill -0 "${app}" 2>/dev/null; then break; fi
sleep 1 sleep 1