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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user