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:
Generated
+109
-1
@@ -462,7 +462,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"pastey 0.1.1",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"v_frame",
|
||||
@@ -890,13 +890,16 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"futures-lite 2.6.1",
|
||||
"futures-util",
|
||||
"ksni",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tray-icon",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.55.0",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1333,6 +1336,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -1545,6 +1557,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dtor"
|
||||
version = "0.0.6"
|
||||
@@ -3103,6 +3121,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -3113,6 +3141,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "kurbo"
|
||||
version = "0.11.3"
|
||||
@@ -3468,6 +3514,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3849,6 +3913,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4047,6 +4121,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pathfinder_geometry"
|
||||
version = "0.5.1"
|
||||
@@ -5722,6 +5802,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -6060,6 +6149,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -6,11 +6,11 @@ An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust a
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[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"] }
|
||||
async-io = "2"
|
||||
futures-lite = "2"
|
||||
@@ -26,5 +28,6 @@ x11rb = { version = "0.13", features = ["xkb"] }
|
||||
rustix = { version = "1", features = ["process", "event"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tray-icon = { version = "0.26", default-features = false }
|
||||
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"] }
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -20,3 +20,8 @@ pub mod cleanup;
|
||||
pub mod clips;
|
||||
|
||||
pub mod hotkeys;
|
||||
|
||||
pub mod autostart;
|
||||
pub mod background;
|
||||
#[cfg(windows)]
|
||||
mod windows_background;
|
||||
|
||||
@@ -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
|
||||
/// Recording: a usable capture source and successful output must be confirmed.
|
||||
@@ -9,6 +9,10 @@ pub enum RecordingStatus {
|
||||
WaitingForCapture,
|
||||
Starting,
|
||||
Recording,
|
||||
/// Video continues while a configured audio source is no longer delivering.
|
||||
Degraded {
|
||||
reason: String,
|
||||
},
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
@@ -21,6 +25,7 @@ impl RecordingStatus {
|
||||
Self::WaitingForCapture => "Waiting for capture",
|
||||
Self::Starting => "Starting",
|
||||
Self::Recording => "Recording",
|
||||
Self::Degraded { .. } => "Recording degraded",
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,6 +517,7 @@ impl ClipbinApp {
|
||||
theme::destructive().into(),
|
||||
self.recorder.game.clone().unwrap_or_default(),
|
||||
),
|
||||
RecordingStatus::Degraded { reason } => (theme::warning().into(), reason.clone()),
|
||||
RecordingStatus::Starting | RecordingStatus::WaitingForCapture => (
|
||||
theme::warning().into(),
|
||||
self.recorder.game.clone().unwrap_or_default(),
|
||||
|
||||
@@ -3,6 +3,17 @@ use gpui::{
|
||||
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 assets;
|
||||
mod components;
|
||||
@@ -25,34 +36,113 @@ fn main() {
|
||||
Some(format!("Could not load settings: {error}")),
|
||||
),
|
||||
};
|
||||
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
|
||||
let interface_service = service.clone();
|
||||
Application::new()
|
||||
.with_assets(assets::Assets)
|
||||
.run(|cx: &mut App| {
|
||||
if let Err(error) = assets::load_fonts(cx) {
|
||||
eprintln!("Could not load the interface font: {error}");
|
||||
let (actions, receiver) = mpsc::channel();
|
||||
let _instance = match Instance::acquire(actions.clone()) {
|
||||
Ok(Some(instance)) => instance,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
eprintln!("Could not establish single instance: {error}");
|
||||
return;
|
||||
}
|
||||
let quitter = interface_service.clone();
|
||||
cx.on_action(move |_: &Quit, cx| {
|
||||
quitter.quit();
|
||||
let service = quitter.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
};
|
||||
let tray = match Tray::start(actions) {
|
||||
Ok(tray) => Some(tray),
|
||||
Err(error) => {
|
||||
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 {
|
||||
gpui::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||
if service.snapshot().exited {
|
||||
let _ = cx.update(|cx| cx.quit());
|
||||
if show {
|
||||
run_window(
|
||||
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;
|
||||
}
|
||||
}
|
||||
})
|
||||
.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.set_menus(vec![gpui::Menu {
|
||||
name: "Clipbin".into(),
|
||||
items: vec![gpui::MenuItem::action("Quit Clipbin", Quit)],
|
||||
}]);
|
||||
text_field::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 window_service = service.clone();
|
||||
let result = cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
@@ -64,26 +154,72 @@ fn main() {
|
||||
app_id: Some("dev.clipbin.Clipbin".into()),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, interface_service))
|
||||
move |window, cx| {
|
||||
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(window) => playback::bind_actions(window, cx),
|
||||
let window = match result {
|
||||
Ok(window) => window,
|
||||
Err(error) => {
|
||||
eprintln!("Could not open Clipbin: {error}");
|
||||
quitting.set(true);
|
||||
service.quit();
|
||||
cx.quit();
|
||||
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
|
||||
// its game ends. Reopening the window needs the future tray integration.
|
||||
service.window_closed();
|
||||
let _ = worker.join();
|
||||
drop(service);
|
||||
});
|
||||
playback::bind_actions(window, cx);
|
||||
cx.activate(true);
|
||||
cx.spawn(async move |cx| {
|
||||
let mut last_state = None;
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ pub struct Client {
|
||||
enum Command {
|
||||
Settings(Settings),
|
||||
ChooseSource,
|
||||
WindowClosed,
|
||||
Quit,
|
||||
Clip(Instant),
|
||||
Bookmark(Instant),
|
||||
@@ -85,6 +84,16 @@ impl Client {
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
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) {
|
||||
let _ = self.commands.send(Command::Settings(settings));
|
||||
}
|
||||
@@ -132,9 +141,6 @@ impl Client {
|
||||
pub fn choose_source(&self) {
|
||||
let _ = self.commands.send(Command::ChooseSource);
|
||||
}
|
||||
pub fn window_closed(&self) {
|
||||
let _ = self.commands.send(Command::WindowClosed);
|
||||
}
|
||||
pub fn quit(&self) {
|
||||
if self.commands.send(Command::Quit).is_err() {
|
||||
self.snapshot.lock().unwrap().exited = true;
|
||||
@@ -385,7 +391,6 @@ fn worker(
|
||||
let mut hotkeys = Some(register(&settings));
|
||||
let mut active: Option<Active> = None;
|
||||
let mut quitting = false;
|
||||
let mut finish_after_session = false;
|
||||
let mut next_scan = Instant::now();
|
||||
let mut next_poll = Instant::now();
|
||||
let mut next_save = Instant::now();
|
||||
@@ -395,10 +400,6 @@ fn worker(
|
||||
let mut next_cleanup = Instant::now();
|
||||
loop {
|
||||
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) => {
|
||||
quitting = true;
|
||||
if let Some(a) = &mut active {
|
||||
@@ -424,6 +425,10 @@ fn worker(
|
||||
if let Err(e) = store::save_settings(&settings) {
|
||||
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();
|
||||
}
|
||||
Ok(Command::Clip(pressed)) => {
|
||||
@@ -548,11 +553,11 @@ fn worker(
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if (quitting || finish_after_session) && active.is_none() && !exports.busy() {
|
||||
if quitting && active.is_none() && !exports.busy() {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
match games::discover() {
|
||||
Ok(games) => {
|
||||
@@ -650,16 +655,20 @@ fn worker(
|
||||
}
|
||||
if !a.ending && !a.stopping {
|
||||
let status = a.tracker.sample(&health, now).clone();
|
||||
if status == RecordingStatus::Recording {
|
||||
if matches!(
|
||||
status,
|
||||
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
|
||||
) {
|
||||
a.retries = 0;
|
||||
}
|
||||
// Keep a failure visible throughout recovery until footage is proven.
|
||||
if status == RecordingStatus::Recording
|
||||
|| !matches!(
|
||||
if matches!(
|
||||
status,
|
||||
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
|
||||
) || !matches!(
|
||||
snapshot.lock().unwrap().status,
|
||||
RecordingStatus::Failed { .. }
|
||||
)
|
||||
{
|
||||
) {
|
||||
snapshot.lock().unwrap().status = status.clone();
|
||||
}
|
||||
if matches!(status, RecordingStatus::Failed { .. }) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Exported clips: local MP4 files that never expire automatically.
|
||||
|
||||
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::{
|
||||
app::ClipbinApp,
|
||||
components::{ButtonStyle, button, chip, count, empty_state, page_header},
|
||||
screens::{corner, muted_line, thumbnail},
|
||||
screens::{corner, thumbnail},
|
||||
theme,
|
||||
};
|
||||
|
||||
@@ -90,6 +92,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.gap_3()
|
||||
.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(
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
@@ -117,29 +120,32 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
.text_base()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::foreground())
|
||||
.truncate()
|
||||
.child(clip.game.clone()),
|
||||
.child(label(clip.game.clone(), px(24.))),
|
||||
)
|
||||
.child(muted_line(format!(
|
||||
.child(div().text_xs().text_color(theme::subtle()).child(label(
|
||||
format!(
|
||||
"{} · {}",
|
||||
format_ago(clip.created, now),
|
||||
format_bytes(clip.size_bytes)
|
||||
),
|
||||
px(18.),
|
||||
))),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
("open-clip", clip.id),
|
||||
ButtonStyle::Ghost,
|
||||
Some("play"),
|
||||
None,
|
||||
)
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
button(("open-clip", id), ButtonStyle::Ghost, Some("play"), None)
|
||||
.flex_shrink_0()
|
||||
.rounded_full()
|
||||
.on_click(cx.listener(
|
||||
move |app, _: &ClickEvent, _, cx| {
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
app.service.open_clip(Some(id));
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
@@ -148,6 +154,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
None,
|
||||
Some("Delete"),
|
||||
)
|
||||
.flex_shrink_0()
|
||||
.on_click(cx.listener(
|
||||
move |app, _: &ClickEvent, _, cx| {
|
||||
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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::{
|
||||
atomic::{AtomicU8, AtomicU64, Ordering},
|
||||
mpsc::Sender,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use libobs as sys;
|
||||
@@ -611,6 +612,26 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
||||
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
|
||||
/// 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.
|
||||
@@ -622,6 +643,7 @@ pub(crate) struct Pipeline {
|
||||
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
|
||||
video_source: *mut sys::obs_source_t,
|
||||
microphone: bool,
|
||||
audio_clocks: Vec<(*mut sys::obs_source_t, Box<AudioClock>)>,
|
||||
pending: Mutex<Option<OutputSpec>>,
|
||||
output: Mutex<Option<Output>>,
|
||||
}
|
||||
@@ -648,6 +670,7 @@ impl Pipeline {
|
||||
sources: Vec::new(),
|
||||
video_source: ptr::null_mut(),
|
||||
microphone: capture.microphone.is_some(),
|
||||
audio_clocks: Vec::new(),
|
||||
pending: Mutex::new(None),
|
||||
output: Mutex::new(None),
|
||||
name_prefix: name_prefix.clone(),
|
||||
@@ -672,6 +695,16 @@ impl Pipeline {
|
||||
if let Some(source_spec) = source_spec {
|
||||
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
|
||||
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
|
||||
.iter()
|
||||
.filter(|t| **t == track || **t == COMBINED_TRACK)
|
||||
@@ -769,6 +802,11 @@ impl Pipeline {
|
||||
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
|
||||
written_bytes: 0,
|
||||
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));
|
||||
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
|
||||
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());
|
||||
for (channel, source) in &self.sources {
|
||||
if let Some(channel) = channel {
|
||||
|
||||
@@ -304,6 +304,8 @@ pub struct Health {
|
||||
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||
/// which report loss through their size, and before the portal answers.
|
||||
pub pipewire: Option<PipeWireState>,
|
||||
/// A configured capture source has delivered no audio for five seconds.
|
||||
pub audio_lost: bool,
|
||||
pub output_active: bool,
|
||||
pub total_frames: u64,
|
||||
pub dropped_frames: u64,
|
||||
@@ -409,17 +411,27 @@ impl StatusTracker {
|
||||
(_, true, true)
|
||||
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, false, _) => RecordingStatus::Failed {
|
||||
}
|
||||
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
|
||||
RecordingStatus::Failed {
|
||||
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!(
|
||||
"no footage was written for {} seconds",
|
||||
self.stall_limit.as_secs()
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
(_, false, _) => RecordingStatus::WaitingForCapture,
|
||||
(status, _, _) => status.clone(),
|
||||
};
|
||||
@@ -511,10 +523,33 @@ mod tests {
|
||||
total_bytes: bytes,
|
||||
lagged_frames: 0,
|
||||
written_bytes: bytes,
|
||||
audio_lost: false,
|
||||
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]
|
||||
fn low_disk_space_fails_before_the_disk_is_full() {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -367,7 +367,10 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
health.written_bytes
|
||||
);
|
||||
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 { .. })) {
|
||||
stopping = true;
|
||||
@@ -414,6 +417,9 @@ fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed
|
||||
if shown != current {
|
||||
*shown = current.clone();
|
||||
match current {
|
||||
RecordingStatus::Degraded { reason } => {
|
||||
println!("t={elapsed} status=degraded reason={reason}")
|
||||
}
|
||||
RecordingStatus::Failed { reason } => {
|
||||
println!("t={elapsed} status=failed reason={reason}")
|
||||
}
|
||||
|
||||
+166
-14
@@ -8,9 +8,9 @@ pass/fail results.
|
||||
|
||||
| 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 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 |
|
||||
|
||||
## Probe commands
|
||||
@@ -174,8 +174,11 @@ Windows status:
|
||||
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
|
||||
the picture is scaled to fit.
|
||||
- **Audio capture failures are not detected yet.** When the PulseAudio server
|
||||
was unreachable, recording continued with a silent track.
|
||||
- **Audio delivery health.** The configured system/microphone capture sources
|
||||
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
|
||||
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
|
||||
@@ -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)
|
||||
runs under `reaper SteamLaunch AppId=3167020 -- …/_v2-entry-point … proton
|
||||
waitforexitandrun …/Duckov.exe`. Clipbin detected it, named it from its
|
||||
manifest ("Escape from Duckov") and recorded the session. Audio-source
|
||||
failure detection remains limited as described above.
|
||||
manifest ("Escape from Duckov") and recorded the session. Audio delivery loss
|
||||
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
|
||||
without an application close callback. Main keeps the recorder worker alive
|
||||
after that return. Closing the window continues the current game session;
|
||||
when that game exits, it finalises and the process exits. If no session is
|
||||
active, it exits immediately. It stops discovering new games once closed,
|
||||
so there is no indefinitely running invisible app. Reopening the window and
|
||||
persistent background discovery need the future tray integration. Explicit
|
||||
Quit/Ctrl+Q stops and finalises the current recording immediately.
|
||||
GPUI 0.2.2 ends its Linux and Windows event loops when the last window is
|
||||
removed. Clipbin keeps the recorder, tray and single-instance endpoint alive
|
||||
outside each GPUI run, and constructs a fresh Application on the main thread
|
||||
when reopened. The previous route is restored and the library/settings are
|
||||
reloaded; unavailable sessions fall back to the library. This also releases
|
||||
playback resources while the interface is closed. No fake or hidden GPUI window
|
||||
is kept alive: GPUI's Linux backends ignore WindowOptions::show. Window bounds,
|
||||
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
|
||||
@@ -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
|
||||
passed. This follow-up changes only the Linux rig scripts and documentation;
|
||||
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.
|
||||
|
||||
@@ -42,6 +42,10 @@ cleanup() {
|
||||
wait 2>/dev/null || true
|
||||
# The D-Bus-activated document portal mounts a FUSE filesystem here.
|
||||
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}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -70,6 +74,7 @@ pipewire > "${rig_dir}/pipewire.log" 2>&1 & pids+=($!)
|
||||
wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]'
|
||||
wireplumber > "${rig_dir}/wireplumber.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" ]]'
|
||||
# No sound card here: route playback to a virtual sink whose monitor OBS
|
||||
# records as desktop audio.
|
||||
@@ -89,11 +94,12 @@ printf '%s\n' \
|
||||
"max_fps=60" \
|
||||
> "${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 \
|
||||
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'
|
||||
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
|
||||
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+=($!)
|
||||
@@ -126,7 +132,5 @@ export CLIPBIN_RIG_DIR="${rig_dir}"
|
||||
echo "rig: ready (${mode}, logs in ${rig_dir})" >&2
|
||||
status=0
|
||||
"$@" || 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}"
|
||||
|
||||
Executable
+125
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user