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
+2 -2
View File
@@ -10,7 +10,7 @@ The recording foundation is being validated (see [docs/validation.md](docs/valid
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable. The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable.
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Audio capture delivery loss produces an “audio capture lost” warning while video continues. The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Linux capture delivery loss or Windows WASAPI device errors produce an “audio capture lost” warning while video continues.
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows. Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
@@ -35,7 +35,7 @@ Keep capture and session operations out of the UI thread. Core uses serde/JSON,
## Development ## Development
Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Cargo.lock pins transitive dependencies. Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Its vendored [background-loop patch](vendor/gpui/CLIPBIN.md) keeps one application alive while its windows close and reopen. Cargo.lock pins transitive dependencies.
Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux. Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux.
+79 -125
View File
@@ -4,15 +4,7 @@ use gpui::{
}; };
use clipbin_core::background::{Action, Instance, Tray}; use clipbin_core::background::{Action, Instance, Tray};
use std::{ use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
cell::Cell,
rc::Rc,
sync::{
Arc, Mutex,
mpsc::{self, Receiver},
},
time::Duration,
};
mod app; mod app;
mod assets; mod assets;
@@ -36,7 +28,7 @@ fn main() {
Some(format!("Could not load settings: {error}")), 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()) { let _instance = match Instance::acquire(actions.clone()) {
Ok(Some(instance)) => instance, Ok(Some(instance)) => instance,
Ok(None) => return, Ok(None) => return,
@@ -57,82 +49,18 @@ fn main() {
.map(|e| format!("Could not configure start on login: {e}")) .map(|e| format!("Could not configure start on login: {e}"))
.or(initial_error); .or(initial_error);
let (service, worker) = recorder_service::Client::start(settings.clone(), 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 tray = tray.map(Arc::new);
let route = Rc::new(Cell::new(app::Route::Sessions)); let background = std::env::args().any(|arg| arg == "--background");
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() Application::new()
.with_assets(assets::Assets) .with_assets(assets::Assets)
.run(move |cx: &mut App| { .run(move |cx: &mut App| {
cx.set_quit_on_last_window_close(false);
if let Err(error) = assets::load_fonts(cx) { if let Err(error) = assets::load_fonts(cx) {
eprintln!("Could not load the interface font: {error}"); eprintln!("Could not load the interface font: {error}");
} }
let quitter = service.clone(); let quitter = interface_service.clone();
let quit_flag = quitting.clone(); cx.on_action(move |_: &Quit, _| quitter.quit());
cx.on_action(move |_: &Quit, _| {
quit_flag.set(true);
quitter.quit();
});
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]); cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
cx.set_menus(vec![gpui::Menu { cx.set_menus(vec![gpui::Menu {
name: "Clipbin".into(), name: "Clipbin".into(),
@@ -140,6 +68,66 @@ fn run_window(
}]); }]);
text_field::bind_keys(cx); text_field::bind_keys(cx);
playback::bind_keys(cx); playback::bind_keys(cx);
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 = 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.try_recv() {
match action {
Action::Open => {
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 interface_service.exited() {
let _ = cx.update(|cx| cx.quit());
break;
}
}
})
.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 settings = clipbin_core::store::load_settings().unwrap_or_default();
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx); let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
let window_service = service.clone(); let window_service = service.clone();
@@ -154,72 +142,38 @@ fn run_window(
app_id: Some("dev.clipbin.Clipbin".into()), app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default() ..Default::default()
}, },
move |window, cx| { move |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service)),
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service))
},
); );
let window = match result { let window = match result {
Ok(window) => window, Ok(window) => window,
Err(error) => { Err(error) => {
eprintln!("Could not open Clipbin: {error}"); eprintln!("Could not open Clipbin: {error}");
quitting.set(true);
service.quit(); service.quit();
cx.quit();
return; return;
} }
}; };
let saved_route = route.clone(); let route = ui.borrow().route;
ui.borrow_mut().window = Some(window);
let close_service = service.clone(); let close_service = service.clone();
let close_tray = tray.clone(); let close_tray = tray.clone();
let close_quitting = quitting.clone(); let close_ui = ui.clone();
let _ = window.update(cx, |app, window, cx| { let _ = window.update(cx, |app, window, cx| {
if saved_route.get() != app::Route::Sessions { if route != app::Route::Sessions {
app.navigate(saved_route.get(), cx); app.navigate(route, cx);
} }
let entity = cx.entity(); let entity = cx.entity();
window.on_window_should_close(cx, move |_, cx| { 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()) { if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
close_quitting.set(true);
close_service.quit(); close_service.quit();
false return false;
} else {
true
} }
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); playback::bind_actions(window, cx);
cx.activate(true); 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();
});
} }
+112 -10
View File
@@ -153,6 +153,8 @@ unsafe extern "C" fn log_to_stderr(
if let Some(state) = pipewire_state(&text) { if let Some(state) = pipewire_state(&text) {
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed); PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
} }
#[cfg(windows)]
record_wasapi_event(&text);
eprintln!("obs {label}: {text}"); eprintln!("obs {label}: {text}");
} }
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed). // 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() 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 { struct AudioClock {
started: Instant, started: Instant,
last_millis: AtomicU64, 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( 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; // SAFETY: each callback holds the stable AudioClock allocation until removal;
// OBS serialises removal against callbacks with source->audio_cb_mutex. // OBS serialises removal against callbacks with source->audio_cb_mutex.
let clock = unsafe { &*param.cast::<AudioClock>() }; let clock = unsafe { &*param.cast::<AudioClock>() };
#[cfg(windows)]
clock.failed.store(false, Ordering::Relaxed);
clock.last_millis.store( clock.last_millis.store(
clock.started.elapsed().as_millis() as u64, clock.started.elapsed().as_millis() as u64,
Ordering::Relaxed, Ordering::Relaxed,
@@ -693,12 +777,10 @@ impl Pipeline {
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK), (&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
] { ] {
if let Some(source_spec) = source_spec { 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)); 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( sys::obs_source_add_audio_capture_callback(
source, source,
Some(audio_captured), Some(audio_captured),
@@ -802,11 +884,7 @@ impl Pipeline {
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64, lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
written_bytes: 0, written_bytes: 0,
disk_available_bytes: None, disk_available_bytes: None,
audio_lost: self.audio_clocks.iter().any(|(_, clock)| { audio_lost: self.audio_clocks.iter().any(|(_, clock)| clock.lost()),
(clock.started.elapsed().as_millis() as u64)
.saturating_sub(clock.last_millis.load(Ordering::Relaxed))
>= 5_000
}),
} }
} }
@@ -1033,6 +1111,30 @@ unsafe fn create_source(spec: &SourceSpec, name: &CStr) -> Result<*mut sys::obs_
mod tests { mod tests {
use super::*; 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] #[test]
fn pipewire_state_is_read_from_the_plugin_log() { fn pipewire_state_is_read_from_the_plugin_log() {
let state = |line| pipewire_state(line); 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, /// PipeWire capture's portal and stream state. `None` for other sources,
/// which report loss through their size, and before the portal answers. /// which report loss through their size, and before the portal answers.
pub pipewire: Option<PipeWireState>, 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 audio_lost: bool,
pub output_active: bool, pub output_active: bool,
pub total_frames: u64, pub total_frames: u64,
+116 -29
View File
@@ -8,7 +8,7 @@ pass/fail results.
| Where | Script | Status | | Where | Script | Status |
| --- | --- | --- | | --- | --- | --- |
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 10 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 | | Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 12 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 |
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) | | Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) |
| Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen and active Quit | | Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen and active Quit |
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested | | Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
@@ -175,9 +175,10 @@ Windows status:
record portrait). If the window is resized mid-session the size stays and record portrait). If the window is resized mid-session the size stays and
the picture is scaled to fit. the picture is scaled to fit.
- **Audio delivery health.** The configured system/microphone capture sources - **Audio delivery health.** The configured system/microphone capture sources
are monitored directly through OBS audio capture callbacks. After five seconds are monitored through OBS audio capture callbacks on Linux (five seconds
without delivery, recording is degraded with “audio capture lost”; video without delivery) and source-scoped WASAPI device errors on Windows. Either
continues. The silent muxer track cannot mask absent source callbacks. See degrades recording with “audio capture lost”; video continues. The silent
muxer track cannot mask capture failure. See
Background operation below for validation and limits. Background operation below for validation and limits.
- **Linux runtime dependencies.** The Linux OBS build links the system's - **Linux runtime dependencies.** The Linux OBS build links the system's
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
@@ -258,15 +259,16 @@ manifest ("Escape from Duckov") and recorded the session. Audio delivery loss
is now detected as described below; the new warning still needs real hardware is now detected as described below; the new warning still needs real hardware
and Windows validation. and Windows validation.
GPUI 0.2.2 ends its Linux and Windows event loops when the last window is GPUI 0.2.2 normally ends its Linux and Windows event loops when the last
removed. Clipbin keeps the recorder, tray and single-instance endpoint alive window is removed. Clipbin now keeps one Application and native loop alive
outside each GPUI run, and constructs a fresh Application on the main thread until explicit Quit, with a small vendored backend policy patch (see
when reopened. The previous route is restored and the library/settings are [vendor/gpui/CLIPBIN.md](../vendor/gpui/CLIPBIN.md)). The visible window is
reloaded; unavailable sessions fall back to the library. This also releases removed on close and recreated on Open, restoring its previous route and
playback resources while the interface is closed. No fake or hidden GPUI window reloading the library/settings; unavailable sessions fall back to the library.
is kept alive: GPUI's Linux backends ignore WindowOptions::show. Window bounds, Playback resources are released while closed. No invisible GPUI window is
scroll offsets and unsaved editor selection are not restored. Explicit Quit needed. Bounds, scroll offsets and unsaved editor selection are not restored.
waits for the existing worker's clean session finish before joining/exiting. Quit waits for the existing worker's clean session finish before joining/exiting.
## Clips, bookmarks and storage cleanup ## Clips, bookmarks and storage cleanup
@@ -547,22 +549,42 @@ on every configured system/microphone source, independently of the encoder,
mixed audio counters and silent output track. A boxed callback context stores mixed audio counters and silent output track. A boxed callback context stores
an atomic monotonic delivery timestamp; it performs no allocation or locking. an atomic monotonic delivery timestamp; it performs no allocation or locking.
The context is removed before source release: OBS's audio_cb_mutex serialises The context is removed before source release: OBS's audio_cb_mutex serialises
callback removal with callbacks. Five seconds without delivery (including a callback removal with callbacks. On Linux, five seconds without delivery
source that never starts) is a warning-level RecordingStatus::Degraded, (including a source that never starts) is a warning-level RecordingStatus::Degraded,
with “audio capture lost” in the sidebar and tray. A new callback clears the with “audio capture lost” in the sidebar and tray. A new callback clears the
warning; video/source/output/disk failures still take precedence and stop warning; video/source/output/disk failures still take precedence and stop
recording. Intentionally disabled audio has no callback and no warning. This recording. Disabled audio has no callback and no warning. Valid silent samples
checks delivery, not amplitude: valid silent samples are healthy. It cannot are healthy; an endless synthetic silence stream cannot reveal a driver failure.
detect a driver delivering endless synthetic silence, and WASAPI silence or
idle-device callback behaviour needs a Windows runtime check. No automatic Windows does not infer loss from absent buffers. In the pinned
source restart is added; video continues and audio can recover if OBS resumes [OBS 32.0.4 win-wasapi source](https://github.com/obsproject/obs-studio/blob/32.0.4/plugins/win-wasapi/win-wasapi.cpp),
its source. Source identity is not displayed separately for system vs mic loss. WASAPISource::Initialize calls ClearBuffer before InitCapture for DeviceOutput. ClearBuffer
creates a temporary shared renderer and fills its buffer with silence, but
neither starts nor retains that renderer. ProcessCaptureData accepts silent
packets and exits normally when GetNextPacketSize reports no packets. This is
not evidence of uninterrupted callbacks during idle loopback capture. Instead,
Clipbin tracks the plugin's source-scoped “failed to start” and device
“invalidated. Retrying” logs; successful initialization or a delivered capture
buffer clears the warning. The callback remains allocation/lock free; the log
handler separately updates weak registered fault flags. Flags are registered
before source creation and unregistered on cleanup, so unrelated/stale sources
cannot affect a recording. The parser has a unit test. Windows failures that
neither deliver buffers nor produce these pinned log messages remain undetected;
this is preferable to reporting ordinary silence as lost capture. Actual WASAPI
silence, mute, unplug/recovery and changed-device behavior require Windows runtime
validation. No automatic source restart is added; OBS's recovery can clear the
warning. Source identity is not displayed separately for system vs mic loss.
The rig's audio failure injection kills its private pipewire-pulse daemon, The rig's audio failure injection kills its private pipewire-pulse daemon,
leaving PipeWire video and the portal alive. Unloading the null sink and leaving PipeWire video and the portal alive. Unloading the null sink and
requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8; requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8;
killing that private audio server deterministically stops source callbacks. killing that private audio server deterministically stops source callbacks.
The original nine scenarios are retained alongside this tenth warning case. The original nine scenarios are retained alongside audio loss, idle silence
and muted-microphone cases (twelve total). Silence cases omit the ffplay tone,
assert there are no playback streams, wait twelve seconds and require the null
sink to be SUSPENDED before capture. They then record for sixteen seconds with
no degraded/failed status. The muted-mic case selects the null sink monitor as
the default input and mutes it; physical microphone hardware remains untested.
The rig now uses an explicit private SWAYSOCK and waits for it; discovery by The rig now uses an explicit private SWAYSOCK and waits for it; discovery by
the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint. the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint.
CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs. CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs.
@@ -573,10 +595,11 @@ callback registration/removal. Nothing new has been linked or run on Windows.
GPUI desktop and clipbin-media remain excluded from that lint set because the GPUI desktop and clipbin-media remain excluded from that lint set because the
MSVC C toolchain/FFmpeg development import libraries are unavailable here. MSVC C toolchain/FFmpeg development import libraries are unavailable here.
Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton
startup races and multi-user/desktop behaviour, foreground activation, repeated startup races and multi-user/desktop behaviour, foreground activation,
GPUI Application creation after close, clean Quit/file closure, --background, GPUI persistent-loop policy (including the vendored Windows backend, which is
registry writes and real login launch, WASAPI delivery-loss/recovery/idle not cross-linted), window recreation, clean Quit/file closure, --background,
semantics, and the Clips layout on Windows. Real Linux desktop trays (Hyprland registry writes and real login launch, WASAPI error-log/recovery/idle semantics, and the Clips layout on Windows.
Real Linux desktop trays (Hyprland
Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration
and native X11 tray lifecycle still need runtime validation. and native X11 tray lifecycle still need runtime validation.
@@ -646,6 +669,70 @@ commit: removed the obsolete window-close shutdown mode, reused existing clip,
bookmark and clean-finish paths, avoided copying the full library for tray bookmark and clean-finish paths, avoided copying the full library for tray
polling, limited icon updates to state changes, kept callbacks allocation/lock polling, limited icon updates to state changes, kept callbacks allocation/lock
free, and made native tray/thread cleanup explicit. The text helper is limited free, and made native tray/thread cleanup explicit. The text helper is limited
to the demonstrated GPUI 0.2.2 rendering bug; no dependency fork or general UI to the demonstrated GPUI 0.2.2 rendering bug. The subsequent lifecycle review
framework was added. Native Windows runtime and real desktop limits above required the small vendored GPUI policy patch described below. Native Windows
remain unchanged. runtime and real desktop limits above remain unchanged.
## Background review follow-up (2026-10-13)
The first five-cycle close/Open test exposed a leak in repeated Application
creation: process thread counts grew 99, 114, 129, 144, 159, 174. GPUI's
LinuxDispatcher creates a worker pool and Timer per platform; the Timer ignores
channel closure and keeps running. This invalidates repeated-run ownership even
though each reopened window worked. Keeping one Application removes the repeated
platform construction. The final five-cycle test measured 71 threads initially
and 69 after every reopen; RSS was 243228, 242580, 247344, 243032, 243136,
243364 KiB. The rig saves reopen-metrics.tsv and asserts bounded thread/RSS growth.
The pinned Windows source was also examined: WindowsPlatform::new calls
OleInitialize; Drop destroys the message-only platform window and calls
OleUninitialize. The platform RegisterClassW result is ignored; the visible
window class uses Once, so repeated class registration itself is not a panic.
The VSync thread uses weak platform state and can exit on drop, and Windows
uses the OS thread pool rather than Linux's owned worker pool. Both dispatchers,
however, forget scheduled runnables when the main channel has closed, assuming
process shutdown. Repeated Application lifetimes are therefore not established
as safe on Windows. Clipbin avoids them entirely: fonts, dispatcher, OLE,
classes, DirectX state and native loop initialize once. The Windows last-window
policy is patched in the same backend decision as Linux; its runtime/build
remains unvalidated because the complete GPUI desktop cannot be cross-checked
here. The unmodified published dependency is retained with its Apache license;
clipbin-background.patch records the complete source delta. The only additional
compatibility change gives two taffy float literals explicit f32 types for
Rust 1.98's strict lint.
Follow-up artifacts live under target/background-review; reopen-before and
reopen-after contain lifecycle metrics. The silent-monitor experiment also
passed with the sink initially suspended. An initial muted-mic experiment used
an absent default input and correctly degraded; the committed fixture explicitly
selects a real monitor source before muting it.
Follow-up validation (all exit 0, 2026-10-13):
```sh
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
env RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-background.sh /home/harry/Dev/clipbin-desktop/target/background-review/reopen-after
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-rig.sh /home/harry/Dev/clipbin-desktop/target/background-review/capture
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp bash -e -c 'for suite in linux-session linux-clips linux-playback; do scripts/validate/${suite}.sh /home/harry/Dev/clipbin-desktop/target/background-review/${suite}; done'
```
All 58 workspace tests passed (fixture-dependent media tests retain their
existing opt-in behavior). All twelve capture scenarios passed; silence and
muted-mic logs show the initially suspended sink and respectively 650 and
650/645 captured packets, with no degradation. Audio-server loss still degrades
while video continues. Lifecycle passed SNI registration/menu actions,
--background, autostart write/remove, close survival, second launch, all five
reopen cycles, audio warning, clean Quit, watcher loss and no-host fallback.
Session finalization/library reload, clips/bookmarks/protected cleanup, and
playback all passed after the persistent-loop change. Playback changed 6.4% of
preview pixels, audio RMS was 0.08044; decode median/p95 was 4.951/11.467 ms and
GPUI upload median/p95 was 0.186/0.334 ms. The simplify quick self-review checked
reuse, clarity and efficiency; it retained existing actions/clean finish, one
application/dispatcher, weak fault registration with explicit cleanup, and no
new callback locks or allocations. The Windows policy flag uses a separate
Cell to avoid adding native state borrows during close handling.
+6 -2
View File
@@ -116,9 +116,13 @@ if [[ "${mode}" == x11 ]]; then
video_driver=x11 video_driver=x11
fi fi
# A moving 60 FPS picture with a 440 Hz tone, standing in for a game. # A moving 60 FPS picture, optionally without any audio stream for idle tests.
pattern="testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0]"
if [[ "${CLIPBIN_RIG_NO_AUDIO:-0}" != 1 ]]; then
pattern+=';sine=frequency=440:sample_rate=48000[out1]'
fi
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \ SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
-f lavfi "testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0];sine=frequency=440:sample_rate=48000[out1]" \ -f lavfi "${pattern}" \
> "${rig_dir}/game.log" 2>&1 & pids+=($!) > "${rig_dir}/game.log" 2>&1 & pids+=($!)
echo "${pids[-1]}" > "${rig_dir}/game.pid" echo "${pids[-1]}" > "${rig_dir}/game.pid"
if [[ "${mode}" == x11 ]]; then if [[ "${mode}" == x11 ]]; then
+28
View File
@@ -81,6 +81,34 @@ swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0 wait_windows 0
menu 1 menu 1
wait_windows 1 wait_windows 1
# Repeated reopen must release each UI's threads and allocation state.
measure() {
python3 - "${app}" "$1" <<'PYMETRICS'
import pathlib,sys
status=dict(line.split(':',1) for line in pathlib.Path(f'/proc/{sys.argv[1]}/status').read_text().splitlines() if ':' in line)
print(f"{sys.argv[2]}\t{status['Threads'].strip()}\t{status['VmRSS'].split()[0]}")
PYMETRICS
}
measure 0 > "${out}/reopen-metrics.tsv"
for cycle in $(seq 5); do
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0
sleep 1
menu 1
wait_windows 1
sleep 1
measure "${cycle}" >> "${out}/reopen-metrics.tsv"
done
cat "${out}/reopen-metrics.tsv"
python3 - "${out}/reopen-metrics.tsv" <<'PYBOUNDS'
import pathlib,sys
rows=[tuple(map(int,line.split())) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()]
assert max(r[1] for r in rows[1:]) <= rows[0][1]+4, rows
# Allow the first reopen to warm caches; later windows must stay bounded.
rss=[r[2] for r in rows[1:]]
assert max(rss)-min(rss) <= 32*1024 and rows[-1][2] <= rows[0][2]+64*1024, rows
print('PASS five close/reopen cycles have bounded thread count and RSS')
PYBOUNDS
# Audio delivery loss must be visible in the application and real tray tooltip. # Audio delivery loss must be visible in the application and real tray tooltip.
kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")" kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")"
for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done
+20
View File
@@ -128,6 +128,26 @@ else
result "${name}" 1 result "${name}" 1
fi fi
# 10. Silence is healthy: no playback streams, idle >10s before monitor capture.
for mode in silence muted-mic; do
name="audio-${mode}"
mic=""
setup=""
if [[ "${mode}" == muted-mic ]]; then
mic="--mic"
setup="pactl set-default-source clipbin-rig-sink.monitor; pactl set-source-mute clipbin-rig-sink.monitor 1;"
fi
CLIPBIN_RIG_NO_AUDIO=1 "${rig}" bash -c "sleep 12; test -z \"\$(pactl list short sink-inputs)\" || exit 1; pactl list short sinks | grep SUSPENDED || exit 1; ${setup} '${probe}' record ${mic} --seconds 16 --out '${out}/${name}'; status=\$?; pactl list short sinks; exit \$status" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
if [[ "${probe_exit}" == 0 ]] && has "${out}/${name}.log" "status=Recording" \
&& ! has "${out}/${name}.log" "status=(degraded|failed)"; then
result "${name}" 0 "no playback for >10s, capture remains healthy"
else
result "${name}" 1
fi
done
echo echo
if [[ "${failures}" == 0 ]]; then if [[ "${failures}" == 0 ]]; then
echo "All scenarios passed. Logs in ${out}" echo "All scenarios passed. Logs in ${out}"
+14
View File
@@ -0,0 +1,14 @@
# Clipbin GPUI 0.2.2 patch
Baseline: crates.io gpui 0.2.2, checksum
`979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707`.
Original Apache-2.0 license retained. This dependency is excluded from Clipbin's
workspace; its existing third-party unsafe code is unchanged.
`clipbin-background.patch` is the complete source delta from the published
crate (zero-context unified diff; apply with `patch -p1` or
`git apply --unidiff-zero`). It adds App::set_quit_on_last_window_close and an initially true backend
flag on Wayland, X11 and Windows. Clipbin sets it false so one Application/loop
lives until explicit Quit, while visible windows can be destroyed/recreated.
No timers, dispatchers or FFI calls are changed. Two taffy float literals
are explicitly typed f32 to satisfy Rust 1.98 strict clippy.
+55
View File
@@ -0,0 +1,55 @@
--- a/src/app.rs
+++ b/src/app.rs
@@ -982,0 +983,5 @@
+ /// Keep the native loop alive when the last window closes (for tray apps).
+ pub fn set_quit_on_last_window_close(&self, quit: bool) {
+ self.platform.set_quit_on_last_window_close(quit);
+ }
+
--- a/src/platform/linux/platform.rs
+++ b/src/platform/linux/platform.rs
@@ -96,0 +97 @@
+ pub quit_on_last_window_close: bool,
@@ -122,0 +124 @@
+ quit_on_last_window_close: true,
@@ -232,0 +235,4 @@
+ }
+
+ fn set_quit_on_last_window_close(&self, quit: bool) {
+ self.with_common(|common| common.quit_on_last_window_close = quit);
--- a/src/platform/linux/wayland/client.rs
+++ b/src/platform/linux/wayland/client.rs
@@ -387 +387 @@
- if state.windows.is_empty() {
+ if state.windows.is_empty() && state.common.quit_on_last_window_close {
--- a/src/platform/linux/x11/client.rs
+++ b/src/platform/linux/x11/client.rs
@@ -250 +250 @@
- if state.windows.is_empty() {
+ if state.windows.is_empty() && state.common.quit_on_last_window_close {
--- a/src/platform/windows/platform.rs
+++ b/src/platform/windows/platform.rs
@@ -2 +2 @@
- cell::RefCell,
+ cell::{Cell, RefCell},
@@ -45,0 +46 @@
+ quit_on_last_window_close: Cell<bool>,
@@ -369,0 +371,4 @@
+ fn set_quit_on_last_window_close(&self, quit: bool) {
+ self.inner.quit_on_last_window_close.set(quit);
+ }
+
@@ -682,0 +688 @@
+ quit_on_last_window_close: Cell::new(true),
@@ -744 +750 @@
- lock.is_empty()
+ lock.is_empty() && self.quit_on_last_window_close.get()
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -171,0 +172 @@
+ fn set_quit_on_last_window_close(&self, _quit: bool) {}
--- a/src/taffy.rs
+++ b/src/taffy.rs
@@ -312 +312 @@
- unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])])
+ unit.map(|count| vec![repeat(count, vec![minmax(length(0.0_f32), fr(1.0_f32))])])
+5
View File
@@ -980,6 +980,11 @@ impl App {
self.platform.activate(ignoring_other_apps); self.platform.activate(ignoring_other_apps);
} }
/// Keep the native loop alive when the last window closes (for tray apps).
pub fn set_quit_on_last_window_close(&self, quit: bool) {
self.platform.set_quit_on_last_window_close(quit);
}
/// Hide the application at the platform level. /// Hide the application at the platform level.
pub fn hide(&self) { pub fn hide(&self) {
self.platform.hide(); self.platform.hide();
+1
View File
@@ -169,6 +169,7 @@ pub(crate) trait Platform: 'static {
fn quit(&self); fn quit(&self);
fn restart(&self, binary_path: Option<PathBuf>); fn restart(&self, binary_path: Option<PathBuf>);
fn activate(&self, ignoring_other_apps: bool); fn activate(&self, ignoring_other_apps: bool);
fn set_quit_on_last_window_close(&self, _quit: bool) {}
fn hide(&self); fn hide(&self);
fn hide_other_apps(&self); fn hide_other_apps(&self);
fn unhide_other_apps(&self); fn unhide_other_apps(&self);
+6
View File
@@ -94,6 +94,7 @@ pub(crate) struct PlatformHandlers {
} }
pub(crate) struct LinuxCommon { pub(crate) struct LinuxCommon {
pub quit_on_last_window_close: bool,
pub(crate) background_executor: BackgroundExecutor, pub(crate) background_executor: BackgroundExecutor,
pub(crate) foreground_executor: ForegroundExecutor, pub(crate) foreground_executor: ForegroundExecutor,
pub(crate) text_system: Arc<dyn PlatformTextSystem>, pub(crate) text_system: Arc<dyn PlatformTextSystem>,
@@ -120,6 +121,7 @@ impl LinuxCommon {
let background_executor = BackgroundExecutor::new(dispatcher.clone()); let background_executor = BackgroundExecutor::new(dispatcher.clone());
let common = LinuxCommon { let common = LinuxCommon {
quit_on_last_window_close: true,
background_executor, background_executor,
foreground_executor: ForegroundExecutor::new(dispatcher), foreground_executor: ForegroundExecutor::new(dispatcher),
text_system, text_system,
@@ -232,6 +234,10 @@ impl<P: LinuxClient + 'static> Platform for P {
log::info!("activate is not implemented on Linux, ignoring the call") log::info!("activate is not implemented on Linux, ignoring the call")
} }
fn set_quit_on_last_window_close(&self, quit: bool) {
self.with_common(|common| common.quit_on_last_window_close = quit);
}
fn hide(&self) { fn hide(&self) {
log::info!("hide is not implemented on Linux, ignoring the call") log::info!("hide is not implemented on Linux, ignoring the call")
} }
+1 -1
View File
@@ -384,7 +384,7 @@ impl WaylandClientStatePtr {
{ {
state.keyboard_focused_window = Some(window); state.keyboard_focused_window = Some(window);
} }
if state.windows.is_empty() { if state.windows.is_empty() && state.common.quit_on_last_window_close {
state.common.signal.stop(); state.common.signal.stop();
} }
} }
+1 -1
View File
@@ -247,7 +247,7 @@ impl X11ClientStatePtr {
} }
state.cursor_styles.remove(&x_window); state.cursor_styles.remove(&x_window);
if state.windows.is_empty() { if state.windows.is_empty() && state.common.quit_on_last_window_close {
state.common.signal.stop(); state.common.signal.stop();
} }
} }
+8 -2
View File
@@ -1,5 +1,5 @@
use std::{ use std::{
cell::RefCell, cell::{Cell, RefCell},
ffi::OsStr, ffi::OsStr,
mem::ManuallyDrop, mem::ManuallyDrop,
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -43,6 +43,7 @@ pub(crate) struct WindowsPlatform {
} }
struct WindowsPlatformInner { struct WindowsPlatformInner {
quit_on_last_window_close: Cell<bool>,
state: RefCell<WindowsPlatformState>, state: RefCell<WindowsPlatformState>,
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>, raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
// The below members will never change throughout the entire lifecycle of the app. // The below members will never change throughout the entire lifecycle of the app.
@@ -367,6 +368,10 @@ impl Platform for WindowsPlatform {
fn activate(&self, _ignoring_other_apps: bool) {} fn activate(&self, _ignoring_other_apps: bool) {}
fn set_quit_on_last_window_close(&self, quit: bool) {
self.inner.quit_on_last_window_close.set(quit);
}
fn hide(&self) {} fn hide(&self) {}
// todo(windows) // todo(windows)
@@ -680,6 +685,7 @@ impl WindowsPlatformInner {
context.directx_devices.take().unwrap(), context.directx_devices.take().unwrap(),
)); ));
Ok(Rc::new(Self { Ok(Rc::new(Self {
quit_on_last_window_close: Cell::new(true),
state, state,
raw_window_handles: context.raw_window_handles.clone(), raw_window_handles: context.raw_window_handles.clone(),
validation_number: context.validation_number, validation_number: context.validation_number,
@@ -741,7 +747,7 @@ impl WindowsPlatformInner {
.unwrap(); .unwrap();
lock.remove(index); lock.remove(index);
lock.is_empty() lock.is_empty() && self.quit_on_last_window_close.get()
} }
#[inline] #[inline]
+1 -1
View File
@@ -309,7 +309,7 @@ impl ToTaffy<taffy::style::Style> for Style {
unit: &Option<u16>, unit: &Option<u16>,
) -> Vec<taffy::GridTemplateComponent<T>> { ) -> Vec<taffy::GridTemplateComponent<T>> {
// grid-template-columns: repeat(<number>, minmax(0, 1fr)); // grid-template-columns: repeat(<number>, minmax(0, 1fr));
unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])]) unit.map(|count| vec![repeat(count, vec![minmax(length(0.0_f32), fr(1.0_f32))])])
.unwrap_or_default() .unwrap_or_default()
} }