fix: preserve GPUI lifetime and avoid WASAPI silence warnings
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user