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();
}
}
}