fix: preserve GPUI lifetime and avoid WASAPI silence warnings

This commit is contained in:
2026-10-13 18:58:37 +02:00
parent d065ad7775
commit 072a4ab7ca
17 changed files with 470 additions and 188 deletions
+93 -139
View File
@@ -4,15 +4,7 @@ use gpui::{
};
use clipbin_core::background::{Action, Instance, Tray};
use std::{
cell::Cell,
rc::Rc,
sync::{
Arc, Mutex,
mpsc::{self, Receiver},
},
time::Duration,
};
use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
mod app;
mod assets;
@@ -36,7 +28,7 @@ fn main() {
Some(format!("Could not load settings: {error}")),
),
};
let (actions, receiver) = mpsc::channel();
let (actions, receiver) = std::sync::mpsc::channel();
let _instance = match Instance::acquire(actions.clone()) {
Ok(Some(instance)) => instance,
Ok(None) => return,
@@ -57,82 +49,18 @@ fn main() {
.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 interface_service = service.clone();
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>>,
) {
let background = std::env::args().any(|arg| arg == "--background");
Application::new()
.with_assets(assets::Assets)
.run(move |cx: &mut App| {
cx.set_quit_on_last_window_close(false);
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();
});
let quitter = interface_service.clone();
cx.on_action(move |_: &Quit, _| quitter.quit());
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
cx.set_menus(vec![gpui::Menu {
name: "Clipbin".into(),
@@ -140,81 +68,36 @@ fn run_window(
}]);
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)),
window_min_size: Some(size(px(1040.), px(640.))),
titlebar: Some(TitlebarOptions {
title: Some("Clipbin".into()),
..Default::default()
}),
app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default()
},
move |window, cx| {
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service))
},
);
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);
let ui = Rc::new(RefCell::new(Interface {
window: None,
route: app::Route::Sessions,
}));
if !background || tray.is_none() {
open_window(cx, &interface_service, &ui, &tray);
}
cx.spawn(async move |cx| {
let mut last_state = None;
loop {
gpui::Timer::after(Duration::from_millis(100)).await;
let state = service.background_state();
let state = interface_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() {
while let Ok(action) = receiver.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();
let _ =
cx.update(|cx| open_window(cx, &interface_service, &ui, &tray));
}
Action::Clip => interface_service.clip(),
Action::Bookmark => interface_service.bookmark(),
Action::Quit => interface_service.quit(),
}
}
if service.exited() {
if interface_service.exited() {
let _ = cx.update(|cx| cx.quit());
break;
}
@@ -222,4 +105,75 @@ fn run_window(
})
.detach();
});
service.quit();
let _ = worker.join();
}
struct Interface {
window: Option<gpui::WindowHandle<app::ClipbinApp>>,
route: app::Route,
}
fn open_window(
cx: &mut App,
service: &recorder_service::Client,
ui: &Rc<RefCell<Interface>>,
tray: &Option<Arc<Tray>>,
) {
let current = ui.borrow().window;
if let Some(window) = current
&& window
.update(cx, |_, window, _| window.activate_window())
.is_ok()
{
return;
}
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)),
window_min_size: Some(size(px(1040.), px(640.))),
titlebar: Some(TitlebarOptions {
title: Some("Clipbin".into()),
..Default::default()
}),
app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default()
},
move |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service)),
);
let window = match result {
Ok(window) => window,
Err(error) => {
eprintln!("Could not open Clipbin: {error}");
service.quit();
return;
}
};
let route = ui.borrow().route;
ui.borrow_mut().window = Some(window);
let close_service = service.clone();
let close_tray = tray.clone();
let close_ui = ui.clone();
let _ = window.update(cx, |app, window, cx| {
if route != app::Route::Sessions {
app.navigate(route, cx);
}
let entity = cx.entity();
window.on_window_should_close(cx, move |_, cx| {
if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
close_service.quit();
return false;
}
let mut ui = close_ui.borrow_mut();
ui.route = entity.read(cx).route;
ui.window = None;
eprintln!("Window closed: Clipbin is running in the tray");
true
});
});
playback::bind_actions(window, cx);
cx.activate(true);
}
+112 -10
View File
@@ -153,6 +153,8 @@ unsafe extern "C" fn log_to_stderr(
if let Some(state) = pipewire_state(&text) {
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
}
#[cfg(windows)]
record_wasapi_event(&text);
eprintln!("obs {label}: {text}");
}
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
@@ -612,9 +614,89 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
stem.strip_prefix("segment-")?.parse().ok()
}
#[cfg(windows)]
static AUDIO_FAULTS: Mutex<Vec<(String, std::sync::Weak<std::sync::atomic::AtomicBool>)>> =
Mutex::new(Vec::new());
#[cfg(any(windows, test))]
fn wasapi_event(message: &str) -> Option<(&str, bool)> {
let (message, source) = message.rsplit_once(" (source: ")?;
let source = source.strip_suffix(')')?;
if !source.starts_with("clipbin-") {
return None;
}
if message.starts_with("WASAPI: Device '") && message.contains(" initialized") {
Some((source, false))
} else if (message.starts_with("WASAPI: Device '") && message.contains(" failed to start"))
|| (message.starts_with("Device '") && message.contains(" invalidated. Retrying"))
{
Some((source, true))
} else {
None
}
}
#[cfg(windows)]
fn record_wasapi_event(message: &str) {
if let Some((source, failed)) = wasapi_event(message)
&& let Ok(faults) = AUDIO_FAULTS.lock()
&& let Some(flag) = faults
.iter()
.find(|(name, _)| name == source)
.and_then(|(_, flag)| flag.upgrade())
{
flag.store(failed, Ordering::Relaxed);
}
}
struct AudioClock {
started: Instant,
last_millis: AtomicU64,
#[cfg(windows)]
source_name: String,
#[cfg(windows)]
failed: Arc<std::sync::atomic::AtomicBool>,
}
impl AudioClock {
fn new(source_name: &str) -> Self {
#[cfg(windows)]
let failed = Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(windows)]
AUDIO_FAULTS
.lock()
.unwrap()
.push((source_name.into(), Arc::downgrade(&failed)));
#[cfg(not(windows))]
let _ = source_name;
Self {
started: Instant::now(),
last_millis: AtomicU64::new(0),
#[cfg(windows)]
source_name: source_name.into(),
#[cfg(windows)]
failed,
}
}
fn lost(&self) -> bool {
#[cfg(windows)]
{
self.failed.load(Ordering::Relaxed)
}
#[cfg(not(windows))]
{
(self.started.elapsed().as_millis() as u64)
.saturating_sub(self.last_millis.load(Ordering::Relaxed))
>= 5_000
}
}
}
#[cfg(windows)]
impl Drop for AudioClock {
fn drop(&mut self) {
if let Ok(mut faults) = AUDIO_FAULTS.lock() {
faults.retain(|(name, _)| name != &self.source_name);
}
}
}
unsafe extern "C" fn audio_captured(
@@ -626,6 +708,8 @@ unsafe extern "C" fn audio_captured(
// 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>() };
#[cfg(windows)]
clock.failed.store(false, Ordering::Relaxed);
clock.last_millis.store(
clock.started.elapsed().as_millis() as u64,
Ordering::Relaxed,
@@ -693,12 +777,10 @@ impl Pipeline {
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
] {
if let Some(source_spec) = source_spec {
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
let source_name = format!("{name_prefix}-audio-{channel}");
let mut clock = Box::new(AudioClock::new(&source_name));
let source = create_source(source_spec, &cstring(&source_name))?;
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),
@@ -802,11 +884,7 @@ 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
}),
audio_lost: self.audio_clocks.iter().any(|(_, clock)| clock.lost()),
}
}
@@ -1033,6 +1111,30 @@ unsafe fn create_source(spec: &SourceSpec, name: &CStr) -> Result<*mut sys::obs_
mod tests {
use super::*;
#[test]
fn wasapi_events_are_scoped_and_distinguish_failure_from_silence() {
assert_eq!(
wasapi_event(
"WASAPI: Device 'Speakers' [48000 Hz] initialized (source: clipbin-7-audio-1)"
),
Some(("clipbin-7-audio-1", false))
);
assert_eq!(
wasapi_event("Device 'Speakers' invalidated. Retrying (source: clipbin-7-audio-1)"),
Some(("clipbin-7-audio-1", true))
);
assert_eq!(
wasapi_event("WASAPI: Device 'default' failed to start (source: clipbin-7-audio-3)"),
Some(("clipbin-7-audio-3", true))
);
assert_eq!(
wasapi_event("Device 'Speakers' invalidated. Retrying (source: another-app)"),
None
);
assert_eq!(wasapi_event("WASAPI: Device 'Speakers' Terminated"), None);
assert_eq!(wasapi_event("WASAPI: Default output device changed"), None);
}
#[test]
fn pipewire_state_is_read_from_the_plugin_log() {
let state = |line| pipewire_state(line);
+1 -1
View File
@@ -304,7 +304,7 @@ 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.
/// A configured capture source lost delivery (Linux) or reported a device error (Windows).
pub audio_lost: bool,
pub output_active: bool,
pub total_frames: u64,