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
+4 -1
View File
@@ -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"] }
+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 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
/// 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();
}
}
}
+1
View File
@@ -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(),
+165 -29
View File
@@ -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 (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 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 interface_service = service.clone();
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 {
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;
}
}
}
}
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(|cx: &mut App| {
.run(move |cx: &mut App| {
if let Err(error) = assets::load_fonts(cx) {
eprintln!("Could not load the interface font: {error}");
}
let quitter = interface_service.clone();
cx.on_action(move |_: &Quit, cx| {
let quitter = service.clone();
let quit_flag = quitting.clone();
cx.on_action(move |_: &Quit, _| {
quit_flag.set(true);
quitter.quit();
let service = quitter.clone();
cx.spawn(async move |cx| {
loop {
gpui::Timer::after(std::time::Duration::from_millis(100)).await;
if service.snapshot().exited {
let _ = cx.update(|cx| cx.quit());
break;
}
}
})
.detach();
});
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
}
});
});
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();
});
// 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);
}
+27 -18
View File
@@ -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!(
snapshot.lock().unwrap().status,
RecordingStatus::Failed { .. }
)
{
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 { .. }) {
+77 -37
View File
@@ -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,43 +120,48 @@ 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!(
"{} · {}",
format_ago(clip.created, now),
format_bytes(clip.size_bytes)
.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,
)
.rounded_full()
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.open_clip(Some(id));
cx.notify();
},
)),
)
.child(
button(
("delete-clip", id),
ButtonStyle::Ghost,
None,
Some("Delete"),
)
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.delete_clip(id);
cx.notify();
},
)),
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| {
app.service.open_clip(Some(id));
cx.notify();
})),
)
.child(
button(
("delete-clip", id),
ButtonStyle::Ghost,
None,
Some("Delete"),
)
.flex_shrink_0()
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.delete_clip(id);
cx.notify();
},
)),
),
),
)
});
@@ -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()
}
+45
View File
@@ -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 {
+45 -10
View File
@@ -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 =>
{
RecordingStatus::Recording
if health.audio_lost {
RecordingStatus::Degraded {
reason: "audio capture lost".into(),
}
} else {
RecordingStatus::Recording
}
}
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
RecordingStatus::Failed {
reason: "the capture source stopped providing a picture".into(),
}
}
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, true, false) => {
RecordingStatus::Failed {
reason: format!(
"no footage was written for {} seconds",
self.stall_limit.as_secs()
),
}
}
(RecordingStatus::Recording, false, _) => RecordingStatus::Failed {
reason: "the capture source stopped providing a picture".into(),
},
(RecordingStatus::Recording, 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();
+7 -1
View File
@@ -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}")
}