From 3678e0f390973377a5af13750379a3d3f7a8ce80 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Fri, 14 Aug 2026 18:55:24 +0300 Subject: [PATCH 01/13] fix(viewer): don't call set_titlebar() on a realized window GTK4 forbids gtk_window_set_titlebar() after the window has been realized; toggling fullscreen swapped the titlebar at runtime, which emitted Gtk-WARNINGs and crashed the viewer with SIGSEGV (both via the F11 hotkey and --fullscreen at startup). Set the titlebar once at construction and only toggle the header bar's visibility when entering/leaving fullscreen - GTK hides titlebars in fullscreen anyway, so the visible behavior is unchanged. Co-Authored-By: Claude Fable 5 --- src/viewer/chrome.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/viewer/chrome.rs b/src/viewer/chrome.rs index 1a590f9..a9aa6a6 100644 --- a/src/viewer/chrome.rs +++ b/src/viewer/chrome.rs @@ -341,8 +341,11 @@ pub(super) fn sync_fullscreen_chrome( update_fullscreen_button(button, is_fullscreen); } + // GTK4 forbids set_titlebar() on a realized window (warning + segfault). + // Keep the titlebar set once at construction and only toggle its visibility; + // GTK hides titlebars in fullscreen anyway. if is_fullscreen { - window.set_titlebar(None::<>k::Widget>); + header_bar.set_visible(false); fullscreen_hotspot.set_visible(true); reveal_fullscreen_bar(fullscreen_revealer, fullscreen_state); schedule_hide_fullscreen_bar(window, fullscreen_revealer, fullscreen_state); @@ -351,10 +354,6 @@ pub(super) fn sync_fullscreen_chrome( fullscreen_revealer.set_reveal_child(false); fullscreen_revealer.set_visible(false); fullscreen_hotspot.set_visible(false); - if decorated_window { - window.set_titlebar(Some(header_bar)); - } else { - window.set_titlebar(None::<>k::Widget>); - } + header_bar.set_visible(decorated_window); } } From 4ae441c04ace569e98a92a5c1439d4d6f04c4af2 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Fri, 14 Aug 2026 18:55:47 +0300 Subject: [PATCH 02/13] feat(viewer): auto-resize the guest display to match the viewer Forward the viewer's size to the guest via the console's SetUIInfo method - the same mechanism the SPICE vdagent uses - so the guest display follows window resizes, maximization and fullscreen instead of staying at its EDID-preferred mode and getting letterboxed. Requests are debounced (350 ms) so interactive resizes send one final size, are scale-factor aware for HiDPI hosts, and are also sent on the first map so windows born fullscreen (--fullscreen) size the guest correctly. Co-Authored-By: Claude Fable 5 --- src/viewer/listener/remote.rs | 13 +++++-- src/viewer/listener/session.rs | 9 +++++ src/viewer/mod.rs | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/viewer/listener/remote.rs b/src/viewer/listener/remote.rs index 5a52505..332f419 100644 --- a/src/viewer/listener/remote.rs +++ b/src/viewer/listener/remote.rs @@ -71,6 +71,13 @@ impl RemoteConsole { .context("failed to query the mouse mode") } + pub(super) async fn set_ui_info(&self, width: u32, height: u32) -> Result<()> { + self.proxy + .set_ui_info(0, 0, 0, 0, width, height) + .await + .with_context(|| format!("failed to request a guest resize to {width}x{height}")) + } + pub(super) async fn check_alive(&self) -> Result<()> { self.proxy .label() @@ -91,9 +98,9 @@ impl RemoteConsole { .release(keycode) .await .with_context(|| format!("failed to send key release for qnum {keycode}")), - InputEvent::ClipboardViewerFocused(_) | InputEvent::ClipboardHostChanged(_, _) => { - Ok(()) - } + InputEvent::ClipboardViewerFocused(_) + | InputEvent::ClipboardHostChanged(_, _) + | InputEvent::UiInfo { .. } => Ok(()), InputEvent::MousePress(button) => self .mouse .press(button) diff --git a/src/viewer/listener/session.rs b/src/viewer/listener/session.rs index 9ad45cd..bd55e4c 100644 --- a/src/viewer/listener/session.rs +++ b/src/viewer/listener/session.rs @@ -137,6 +137,15 @@ pub(super) async fn listener_session( continue; } + if let InputEvent::UiInfo { width, height } = &input { + if let Err(error) = console.set_ui_info(*width, *height).await { + let _ = event_tx.send(ViewerEvent::Status(format!( + "Guest resize request failed: {error:#}" + ))); + } + continue; + } + let needs_mouse_mode = mouse::input_needs_mouse_mode(&input); if let Err(error) = console.handle_input(input).await { let recovered = if needs_mouse_mode { diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 104779a..e4f1067 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -341,6 +341,69 @@ fn run_window( } }); + // Auto-resize: forward viewer size changes to the guest via SetUIInfo, + // the same mechanism the SPICE vdagent uses. Debounced so an interactive + // resize sends one final size instead of a flood. + { + let input_tx = input_tx.clone(); + let picture_for_send = picture.clone(); + let last_sent: Rc> = Rc::new(RefCell::new((0, 0))); + let pending: Rc>> = Rc::new(RefCell::new(None)); + let send_ui_info: Rc = Rc::new(move || { + let scale = picture_for_send.scale_factor(); + let width = picture_for_send.width() * scale; + let height = picture_for_send.height() * scale; + if width > 0 && height > 0 && *last_sent.borrow() != (width, height) { + *last_sent.borrow_mut() = (width, height); + let _ = input_tx.send(InputEvent::UiInfo { + width: width as u32, + height: height as u32, + }); + } + }); + let schedule: Rc = Rc::new({ + let pending = pending.clone(); + move || { + if let Some(source) = pending.borrow_mut().take() { + source.remove(); + } + let send_ui_info = send_ui_info.clone(); + let pending_inner = pending.clone(); + let source = glib::timeout_add_local_once( + std::time::Duration::from_millis(350), + move || { + *pending_inner.borrow_mut() = None; + send_ui_info(); + }, + ); + *pending.borrow_mut() = Some(source); + } + }); + window.connect_default_width_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + window.connect_default_height_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + window.connect_fullscreened_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + window.connect_maximized_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + // First map: the window may already be fullscreen (--fullscreen) before + // the picture gets an allocation, so no notify above will fire — request + // once the widgets are actually laid out. + window.connect_map({ + let schedule = schedule.clone(); + move |_| schedule() + }); + } + let hotspot_motion = gtk::EventControllerMotion::new(); hotspot_motion.connect_enter({ let window = window.clone(); @@ -999,6 +1062,7 @@ enum InputEvent { MouseAbs { x: u32, y: u32 }, MouseRel { dx: i32, dy: i32 }, MouseWheel(MouseButton), + UiInfo { width: u32, height: u32 }, } #[cfg(test)] From 1f8d6828cc8f99bad664d2fbd4296d95ab1a0ddf Mon Sep 17 00:00:00 2001 From: Phaengris Date: Fri, 14 Aug 2026 18:56:03 +0300 Subject: [PATCH 03/13] fix(viewer): clear "guest display was disabled" once the display returns The status label was shown but never hidden, so the transient disable notice emitted during guest mode switches (or listener handover) stayed on screen for the rest of the session. Track the pending notice and clear it on the next scanout or dmabuf update from the guest; an empty status message now hides the label. Co-Authored-By: Claude Fable 5 --- src/viewer/framebuffer.rs | 12 ++++++++++++ src/viewer/mod.rs | 8 ++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/viewer/framebuffer.rs b/src/viewer/framebuffer.rs index 6bd33ca..2d63cd5 100644 --- a/src/viewer/framebuffer.rs +++ b/src/viewer/framebuffer.rs @@ -703,6 +703,7 @@ fn premultiply(channel: u8, alpha: u8) -> u8 { pub(super) struct FrameStreamHandler { event_tx: EventSender, framebuffer: Option, + disable_notice_pending: bool, } impl FrameStreamHandler { @@ -710,6 +711,14 @@ impl FrameStreamHandler { Self { event_tx, framebuffer: None, + disable_notice_pending: false, + } + } + + fn clear_disable_notice(&mut self) { + if self.disable_notice_pending { + self.disable_notice_pending = false; + self.send_status(""); } } @@ -794,16 +803,19 @@ impl FrameStreamHandler { #[cfg(unix)] pub(super) fn emit_dmabuf_scanout(&mut self, scanout: DmabufFrame) { self.framebuffer = None; + self.clear_disable_notice(); let _ = self.event_tx.send(ViewerEvent::Dmabuf(scanout)); } #[cfg(unix)] pub(super) fn update_dmabuf(&mut self, update: UpdateDMABUF) { + self.clear_disable_notice(); let _ = self.event_tx.send(ViewerEvent::DmabufUpdate(update)); } pub(super) fn disable(&mut self) { self.framebuffer = None; + self.disable_notice_pending = true; self.send_status("The guest display was disabled."); } diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index e4f1067..1a96809 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -905,8 +905,12 @@ fn run_window( } if let Some(message) = latest_status { - status_label.set_label(&message); - status_label.set_visible(true); + if message.is_empty() { + status_label.set_visible(false); + } else { + status_label.set_label(&message); + status_label.set_visible(true); + } } glib::ControlFlow::Continue From 6e11ab153dbe49e9d4984d3db4436084bde9cc0e Mon Sep 17 00:00:00 2001 From: Phaengris Date: Fri, 14 Aug 2026 18:56:03 +0300 Subject: [PATCH 04/13] feat(cli): add --no-fullscreen-bar Leave the floating fullscreen toolbar and its top-edge hover hotspot unparented when requested. Useful when the guest desktop has its own panels at the screen edges: the hover hotspot otherwise fights with edge-activated guest UI (auto-hide panels, top-edge menus). Fullscreen can still be toggled via the hotkey (F11 by default) and the titlebar button in windowed mode. Co-Authored-By: Claude Fable 5 --- src/cli.rs | 5 +++++ src/main.rs | 1 + src/viewer/mod.rs | 21 +++++++++++++++------ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ed095d5..60c7fef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -94,6 +94,11 @@ pub struct ConnectArgs { #[arg(long)] pub undecorated: bool, + /// Do not show the floating toolbar (or its top-edge hover hotspot) in + /// fullscreen. Useful when the guest has panels at the screen edges. + #[arg(long)] + pub no_fullscreen_bar: bool, + /// Use QEMU-provided DMABUF damage rectangles instead of full-surface /// refreshes. This can be faster, but some guest/driver combinations may /// flicker. diff --git a/src/main.rs b/src/main.rs index fd06864..8732b7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ async fn run_connect_command(args: ConnectArgs) -> Result<()> { args.hotkeys.as_deref(), args.fullscreen, args.undecorated, + args.no_fullscreen_bar, args.dmabuf_partial_updates, ) } diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 1a96809..1971e4d 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -125,6 +125,7 @@ pub fn connect( hotkeys_spec: Option<&str>, start_fullscreen: bool, undecorated: bool, + no_fullscreen_bar: bool, dmabuf_partial_updates: bool, ) -> Result<()> { let hotkeys = hotkeys::ViewerHotkeys::parse(hotkeys_spec) @@ -156,6 +157,7 @@ pub fn connect( hotkeys, start_fullscreen, undecorated, + no_fullscreen_bar, dmabuf_partial_updates, ); @@ -176,6 +178,7 @@ fn run_window( hotkeys: hotkeys::ViewerHotkeys, start_fullscreen: bool, undecorated: bool, + no_fullscreen_bar: bool, dmabuf_partial_updates: bool, ) -> Result<()> { gtk::init().context("failed to initialize GTK4")?; @@ -290,9 +293,13 @@ fn run_window( .build(); fullscreen_revealer.set_child(Some(&floating_controls.container)); fullscreen_revealer.set_visible(false); - overlay.add_overlay(&fullscreen_revealer); - overlay.set_measure_overlay(&fullscreen_revealer, false); - overlay.set_clip_overlay(&fullscreen_revealer, false); + // --no-fullscreen-bar: leave the floating bar and its hotspot unparented so + // fullscreen has no screen-edge chrome at all. + if !no_fullscreen_bar { + overlay.add_overlay(&fullscreen_revealer); + overlay.set_measure_overlay(&fullscreen_revealer, false); + overlay.set_clip_overlay(&fullscreen_revealer, false); + } let fullscreen_hotspot = gtk::Box::builder() .halign(gtk::Align::Center) @@ -302,9 +309,11 @@ fn run_window( .build(); fullscreen_hotspot.set_opacity(0.0); fullscreen_hotspot.set_visible(false); - overlay.add_overlay(&fullscreen_hotspot); - overlay.set_measure_overlay(&fullscreen_hotspot, false); - overlay.set_clip_overlay(&fullscreen_hotspot, false); + if !no_fullscreen_bar { + overlay.add_overlay(&fullscreen_hotspot); + overlay.set_measure_overlay(&fullscreen_hotspot, false); + overlay.set_clip_overlay(&fullscreen_hotspot, false); + } let fullscreen_state = Rc::new(RefCell::new(chrome::FullscreenChromeState::default())); let titlebar_widget = header_bar.clone().upcast::(); From 1b4d97a1a2d314a60d7f3651e93ccf7a8446286a Mon Sep 17 00:00:00 2001 From: Phaengris Date: Wed, 2 Sep 2026 21:02:01 +0300 Subject: [PATCH 05/13] fix(viewer): follow compositor-driven surface resizes for guest auto-resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-resize triggers listened only to window property notifications (default size, fullscreened, maximized) — but when the compositor resizes the surface itself, none of those change. The common case: the monitor's resolution changes while the viewer is fullscreen; the window keeps covering the screen but the guest is never asked to adopt the new size until the viewer is reopened. Hook the GDK surface's layout signal (the ground truth for actual size changes, connected on every realize) and the window's scale-factor notify (a monitor with a different scale changes the physical pixel count without a logical resize), both feeding the existing debounce. --- src/viewer/mod.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 1971e4d..7c23ff5 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -411,6 +411,28 @@ fn run_window( let schedule = schedule.clone(); move |_| schedule() }); + // The compositor can resize the surface without any of the window + // properties above changing — e.g. the monitor's resolution changes + // while the viewer is fullscreen. The surface `layout` signal is the + // ground truth for actual size changes, so hook it on every realize + // (each realize creates a fresh surface). + window.connect_realize({ + let schedule = schedule.clone(); + move |window| { + if let Some(surface) = window.surface() { + surface.connect_layout({ + let schedule = schedule.clone(); + move |_, _, _| schedule() + }); + } + } + }); + // Moving to a monitor with a different scale changes the physical + // pixel count without a logical resize. + window.connect_scale_factor_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); } let hotspot_motion = gtk::EventControllerMotion::new(); From ed98219b30bf650001348f478a5efd910e384a73 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Sat, 29 Aug 2026 00:15:57 +0300 Subject: [PATCH 06/13] feat(viewer): render the guest cursor in-scene for sharp HiDPI display GTK rasterizes gdk::Cursor at logical scale (the cursor callback is invoked with scale=1 even on 200% outputs), so any cursor set through the GTK cursor API is blurry on scaled displays. Draw the guest cursor texture inside the scene instead, scaled by the same factor as the framebuffer content: wherever the display is pixel-perfect, the cursor now is too. The new CursorScene overlay widget tracks the local pointer (zero lag, same semantics as the replaced GTK cursor) and snapshots the cursor texture at hotspot-corrected coordinates. The widget cursor path is reduced to hiding the host cursor while a guest shape is defined. This mirrors what rdw does for relative mode, applied to absolute mode. In relative mode nothing is drawn, preserving existing behavior. --- src/viewer/cursor.rs | 16 ++-- src/viewer/cursor_scene.rs | 176 +++++++++++++++++++++++++++++++++++++ src/viewer/mod.rs | 10 ++- 3 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 src/viewer/cursor_scene.rs diff --git a/src/viewer/cursor.rs b/src/viewer/cursor.rs index 013ae9e..93175ff 100644 --- a/src/viewer/cursor.rs +++ b/src/viewer/cursor.rs @@ -55,7 +55,8 @@ impl GuestCursor { })) } - fn to_gdk_cursor(&self) -> gdk::Cursor { + /// The cursor texture plus hotspot, for rendering inside the scene. + pub(super) fn to_texture(&self) -> (gdk::Texture, i32, i32) { let bytes = glib::Bytes::from_owned(self.rgba.clone()); let texture = gdk::MemoryTexture::new( self.width, @@ -64,8 +65,7 @@ impl GuestCursor { &bytes, self.stride(), ); - let fallback = gdk::Cursor::from_name("default", None); - gdk::Cursor::from_texture(&texture, self.hotspot_x, self.hotspot_y, fallback.as_ref()) + (texture.upcast(), self.hotspot_x, self.hotspot_y) } fn stride(&self) -> usize { @@ -106,8 +106,14 @@ impl Default for CursorState { } impl CursorState { - pub(super) fn set_shape(&mut self, shape: Option) { - self.active_cursor = shape.as_ref().map(GuestCursor::to_gdk_cursor); + /// While a guest shape is defined the scene draws it (see `cursor_scene`), + /// so the widget cursor only needs to keep the host cursor out of the way. + pub(super) fn set_shape(&mut self, has_guest_shape: bool) { + self.active_cursor = if has_guest_shape { + Some(self.hidden_cursor()) + } else { + None + }; } pub(super) fn set_visible(&mut self, visible: bool) { diff --git a/src/viewer/cursor_scene.rs b/src/viewer/cursor_scene.rs new file mode 100644 index 0000000..fe08869 --- /dev/null +++ b/src/viewer/cursor_scene.rs @@ -0,0 +1,176 @@ +//! Renders the guest cursor inside the viewer scene instead of relying on +//! `gdk::Cursor`. GTK rasterizes pointer cursors at logical scale (scale=1 +//! even on HiDPI outputs), so a cursor set through the GTK cursor API is +//! always blurry on scaled displays. Drawing the cursor texture as part of +//! the scene maps guest pixels 1:1 to physical pixels whenever the display +//! itself does, which is what makes it sharp. + +use std::{cell::RefCell, rc::Rc}; + +use gtk::{gdk, glib, graphene, prelude::*, subclass::prelude::*}; +use gtk4 as gtk; + +use super::{UiState, mouse::MouseMode}; + +pub(super) struct SceneState { + texture: Option, + hotspot: (i32, i32), + pointer: Option<(f64, f64)>, + cursor_visible: bool, + picture: Option, + ui_state: Option>>, + mouse_mode: Option>>, +} + +impl Default for SceneState { + fn default() -> Self { + Self { + texture: None, + hotspot: (0, 0), + pointer: None, + cursor_visible: true, + picture: None, + ui_state: None, + mouse_mode: None, + } + } +} + +mod cursor_scene_imp { + use super::*; + + #[derive(Default)] + pub struct CursorScene { + pub(super) state: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CursorScene { + const NAME: &'static str = "Qd2CursorScene"; + type Type = super::CursorScene; + type ParentType = gtk::Widget; + } + + impl ObjectImpl for CursorScene {} + + impl WidgetImpl for CursorScene { + fn snapshot(&self, snapshot: >k::Snapshot) { + let state = self.state.borrow(); + + if !state.cursor_visible { + return; + } + let Some(texture) = &state.texture else { + return; + }; + let Some((pointer_x, pointer_y)) = state.pointer else { + return; + }; + let (Some(picture), Some(ui_state), Some(mouse_mode)) = + (&state.picture, &state.ui_state, &state.mouse_mode) + else { + return; + }; + // In relative mode the local pointer position says nothing about + // where the guest keeps its cursor, so don't pretend otherwise. + if *mouse_mode.borrow() != MouseMode::Absolute { + return; + } + let Some((frame_width, frame_height)) = ui_state.borrow().frame_size else { + return; + }; + let Some(bounds) = picture.compute_bounds(self.obj().upcast_ref::()) + else { + return; + }; + + // The same guest-pixel -> logical-pixel factor ContentFit::Contain + // applies to the framebuffer, so the cursor scales with the scene. + let scale = (f64::from(bounds.width()) / f64::from(frame_width)) + .min(f64::from(bounds.height()) / f64::from(frame_height)); + if !scale.is_finite() || scale <= 0.0 { + return; + } + + let x = f64::from(bounds.x()) + pointer_x - f64::from(state.hotspot.0) * scale; + let y = f64::from(bounds.y()) + pointer_y - f64::from(state.hotspot.1) * scale; + snapshot.append_texture( + texture, + &graphene::Rect::new( + x as f32, + y as f32, + (f64::from(texture.width()) * scale) as f32, + (f64::from(texture.height()) * scale) as f32, + ), + ); + } + } +} + +glib::wrapper! { + pub struct CursorScene(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl CursorScene { + pub(super) fn new( + picture: >k::Picture, + ui_state: Rc>, + mouse_mode: Rc>, + ) -> Self { + let scene: Self = glib::Object::builder().build(); + { + let mut state = scene.imp().state.borrow_mut(); + state.picture = Some(picture.clone()); + state.ui_state = Some(ui_state); + state.mouse_mode = Some(mouse_mode); + } + scene.set_can_target(false); + scene + } + + pub(super) fn set_shape(&self, shape: Option<(gdk::Texture, i32, i32)>) { + { + let mut state = self.imp().state.borrow_mut(); + match shape { + Some((texture, hotspot_x, hotspot_y)) => { + state.texture = Some(texture); + state.hotspot = (hotspot_x, hotspot_y); + } + None => state.texture = None, + } + } + self.queue_draw(); + } + + pub(super) fn set_cursor_visible(&self, visible: bool) { + self.imp().state.borrow_mut().cursor_visible = visible; + self.queue_draw(); + } + + pub(super) fn set_pointer(&self, pointer: Option<(f64, f64)>) { + self.imp().state.borrow_mut().pointer = pointer; + self.queue_draw(); + } +} + +/// Follow the local pointer over the picture. Unlike the input controllers in +/// `mouse.rs` this is not gated on the input grab: the drawn cursor replaces +/// the host cursor, which also follows the pointer unconditionally. +pub(super) fn track_pointer(picture: >k::Picture, scene: &CursorScene) { + let motion = gtk::EventControllerMotion::new(); + motion.connect_enter({ + let scene = scene.clone(); + move |_, x, y| scene.set_pointer(Some((x, y))) + }); + motion.connect_motion({ + let scene = scene.clone(); + move |_, x, y| scene.set_pointer(Some((x, y))) + }); + motion.connect_leave({ + let scene = scene.clone(); + move |_| scene.set_pointer(None) + }); + picture.add_controller(motion); +} diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 7c23ff5..0818c87 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -3,6 +3,7 @@ mod chooser; mod chrome; mod clipboard; mod cursor; +mod cursor_scene; mod dmabuf; mod events; mod framebuffer; @@ -527,6 +528,10 @@ fn run_window( let clipboard_state = Rc::new(RefCell::new(clipboard::ClipboardUiState::default())); let cursor_state = Rc::new(RefCell::new(cursor::CursorState::default())); let mouse_mode = Rc::new(RefCell::new(ready.mouse_mode)); + let cursor_scene = + cursor_scene::CursorScene::new(&picture, ui_state.clone(), mouse_mode.clone()); + overlay.add_overlay(&cursor_scene); + cursor_scene::track_pointer(&picture, &cursor_scene); let input_grab = grab::new_state(); let keyboard_controller = ready.keyboard_available.then(|| { keyboard::install_keyboard_controller( @@ -663,6 +668,7 @@ fn run_window( let picture = picture.clone(); let status_label = status_label.clone(); let cursor_state = cursor_state.clone(); + let cursor_scene = cursor_scene.clone(); let clipboard_state = clipboard_state.clone(); let input_grab = input_grab.clone(); let ui_state = ui_state.clone(); @@ -750,10 +756,12 @@ fn run_window( if cursor_dirty { let mut current_cursor = cursor_state.borrow_mut(); if let Some(shape) = latest_cursor_shape { - current_cursor.set_shape(shape); + cursor_scene.set_shape(shape.as_ref().map(cursor::GuestCursor::to_texture)); + current_cursor.set_shape(shape.is_some()); } if let Some(visible) = latest_cursor_visible { current_cursor.set_visible(visible); + cursor_scene.set_cursor_visible(visible); } drop(current_cursor); grab::sync_cursor_capture(&picture, &cursor_state, &input_grab, &mouse_mode); From 0e3ce704eeeadf69e771679c578421fdc1e5cf0e Mon Sep 17 00:00:00 2001 From: Phaengris Date: Sat, 29 Aug 2026 14:53:02 +0300 Subject: [PATCH 07/13] perf(viewer): offload dmabuf frames to a compositor subsurface Wrap the picture in GtkGraphicsOffload (black-background mode) so dmabuf scanouts bypass GTK's GL compositing and are attached directly to a Wayland subsurface. At large guest resolutions (e.g. 5120x1440) that compositing pass dominated the frame budget and made fullscreen viewing sluggish regardless of how fast the guest rendered. Black-background mode keeps the subsurface below the main surface, so overlays (in-scene cursor, fullscreen bar) still draw above the video. Requires GTK 4.16 for black-background; gtk4 feature bumped v4_14->v4_16. --- Cargo.toml | 2 +- src/viewer/mod.rs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c7e9419..df43f09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ homepage = "https://github.com/thelicato/qd2" anyhow = "1.0.102" async-trait = "0.1.89" clap = { version = "4.6.0", features = ["derive"] } -gtk4 = { version = "0.10.0", features = ["v4_14"] } +gtk4 = { version = "0.10.0", features = ["v4_16"] } pixman-sys = "0.1.0" qemu-display = "0.2.1" serde_bytes = "0.11.19" diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 0818c87..dda9f15 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -204,9 +204,19 @@ fn run_window( status_label.set_margin_start(12); status_label.set_margin_end(12); + // Hand dmabuf frames straight to the compositor as a subsurface instead of + // compositing them through GTK's GL renderer — at large guest resolutions + // that compositing pass dominates the frame budget. Black-background mode + // keeps the subsurface below the UI so overlays (in-scene cursor, + // fullscreen bar) can still draw on top. + let offload = gtk::GraphicsOffload::new(Some(&picture)); + offload.set_hexpand(true); + offload.set_vexpand(true); + offload.set_black_background(true); + let container = gtk::Box::new(gtk::Orientation::Vertical, 0); container.append(&status_label); - container.append(&picture); + container.append(&offload); let overlay = gtk::Overlay::new(); overlay.set_child(Some(&container)); From 4e58e7fae002fd7c307388b0d3fdb9d1cad44ee6 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Sat, 29 Aug 2026 15:00:41 +0300 Subject: [PATCH 08/13] feat(viewer): activate the input grab on window focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard forwarding was gated on the input grab, which only activated on an explicit click — so after switching to the viewer window the first click was swallowed by grab activation and keys went nowhere until then. Activate the grab when the window becomes active instead, matching how spice viewers behave: focus means input. Focus-out still releases. --- src/viewer/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index dda9f15..7188b55 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -611,6 +611,13 @@ fn run_window( let keyboard_controller = keyboard_controller.clone(); move |window| { if window.is_active() { + // Focus means input: start forwarding keys immediately, like + // other viewers do, instead of demanding a first sacrificial + // click to activate the grab. + if grab::activate(window, &picture, &input_grab, None) { + ui_state.borrow_mut().last_pointer_guest_position = None; + grab::sync_cursor_capture(&picture, &cursor_state, &input_grab, &mouse_mode); + } return; } From 70e411046c8d4e123b1e98bfaafa030ce5713f6f Mon Sep 17 00:00:00 2001 From: Phaengris Date: Sat, 29 Aug 2026 15:23:01 +0300 Subject: [PATCH 09/13] feat(cli): add --name to set the viewer's app identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets the program name before GTK initializes, which becomes the Wayland app_id / X11 WM class of the viewer window — the same job as spicy's --name. Lets taskbars group the viewer under a pinned launcher and lets window rules target a specific connection. --- src/cli.rs | 6 ++++++ src/main.rs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 60c7fef..6d9ed9e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -104,6 +104,12 @@ pub struct ConnectArgs { /// flicker. #[arg(long = "dpu")] pub dmabuf_partial_updates: bool, + + /// Set the viewer's application/window identity (Wayland app_id, X11 WM + /// class), like spicy's --name. Lets taskbars and window rules match the + /// viewer to a specific launcher. + #[arg(long, value_name = "APP_ID")] + pub name: Option, } impl ConnectArgs { diff --git a/src/main.rs b/src/main.rs index 8732b7f..ff11c0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,12 @@ async fn main() -> Result<()> { } async fn run_connect_command(args: ConnectArgs) -> Result<()> { + // Must happen before GTK initializes a display connection: the Wayland + // app_id / X11 WM class are derived from the program name. + if let Some(name) = args.name.as_deref() { + gtk4::glib::set_prgname(Some(name)); + } + let target = if let Some(selector) = args.vm.as_deref() { qemu::resolve_connect_target(args.address(), Some(selector), args.console).await? } else { From c6941a8b222dcce514dba4250d04b606d3bee5f7 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Mon, 31 Aug 2026 21:14:29 +0300 Subject: [PATCH 10/13] fix(viewer): only grab input on focus once a display is presented Inhibiting host shortcuts for a window that shows no guest display yet (still connecting, or an error dialog) held the user's alt-tab and screenshot hotkeys hostage. Gate the focus-time grab on the picture being visible; the click-time grab already requires pointer interaction. --- src/viewer/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 7188b55..28e5f14 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -613,8 +613,10 @@ fn run_window( if window.is_active() { // Focus means input: start forwarding keys immediately, like // other viewers do, instead of demanding a first sacrificial - // click to activate the grab. - if grab::activate(window, &picture, &input_grab, None) { + // click to activate the grab. Only once a display is actually + // presented — inhibiting host shortcuts while showing a blank + // window or an error dialog holds alt-tab hostage. + if picture.is_visible() && grab::activate(window, &picture, &input_grab, None) { ui_state.borrow_mut().last_pointer_guest_position = None; grab::sync_cursor_capture(&picture, &cursor_state, &input_grab, &mouse_mode); } From c4df7f4e1fd900685328d4571ef19717a349f736 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Mon, 31 Aug 2026 21:30:14 +0300 Subject: [PATCH 11/13] docs: document --no-fullscreen-bar, --name, and viewer behavior changes Covers the auto-resize, in-scene HiDPI cursor, GraphicsOffload rendering, and focus-time keyboard grab in the viewer highlights, adds the two new flags to the options table, and records the GTK 4.16 requirement. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b49f659..f5874dc 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ QD2 is built for people who want the flexibility of QEMU's D-Bus display stack w | `--undecorated` | Open the GTK4 viewer without normal window decorations for `connect`. | `qd2 connect --undecorated` | | `--dpu` | Use QEMU-provided DMABUF damage rectangles instead of the default full-surface DMABUF refresh path. | `qd2 connect --dpu` | | `--hotkeys ...` | Override viewer shortcuts in a virt-viewer-style format for `connect`. | `qd2 connect --hotkeys "toggle-fullscreen=ctrl+enter,release-cursor=ctrl+alt"` | +| `--no-fullscreen-bar` | Hide the floating fullscreen toolbar and its top-edge hover hotspot for `connect`. Useful when the guest has panels at the screen edges. | `qd2 connect --fullscreen --no-fullscreen-bar` | +| `--name ` | Set the viewer's application identity (Wayland app_id / X11 WM class) so taskbars and window rules can match it, like spicy's `--name`. | `qd2 connect --name my-vm-launcher` | ## 🖥️ Viewer Highlights @@ -55,6 +57,10 @@ QD2 is built for people who want the flexibility of QEMU's D-Bus display stack w - Guest audio playback through the QEMU D-Bus audio interface. - Floating fullscreen controls inspired by virt-viewer. - Direct fullscreen launch with `qd2 connect --fullscreen`. +- Guest display resolution follows the viewer window size (including fullscreen). +- Sharp guest cursor on HiDPI displays: the cursor is rendered inside the scene at content scale instead of through the GTK cursor API. +- DMABUF frames are offloaded to a compositor subsurface (`GtkGraphicsOffload`), keeping large guest resolutions smooth in fullscreen. +- Keyboard forwarding starts when the viewer window gains focus (once a display is presented), with host shortcuts restored on focus loss. - Optional undecorated launch mode for tiling compositor workflows. - Top-bar actions for taking screenshots and sending guest shortcuts like `Ctrl+Alt+Delete`. - Configurable hotkeys for fullscreen, grab release, and DMABUF transforms. @@ -69,7 +75,7 @@ Linux releases also ship native `.deb` and `.rpm` packages. Those packages insta Building from source currently requires: - Rust stable with Cargo -- GTK4 development files +- GTK4 development files (GTK 4.16 or newer) - pixman development files - usbredir 0.13+ libraries visible to `pkg-config` (`libusbredirhost.pc` and `libusbredirparser-0.5.pc`) - `pkg-config` or `pkgconf` From da4a0da43939b975ff4c389611c377234e0d4f1a Mon Sep 17 00:00:00 2001 From: Phaengris Date: Mon, 31 Aug 2026 21:44:38 +0300 Subject: [PATCH 12/13] chore: cargo update (2026-08-31) --- Cargo.lock | 540 +++++++++++++++++------------------------------------ 1 file changed, 170 insertions(+), 370 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93c18cd..d10a198 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,9 +54,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-broadcast" @@ -151,7 +151,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -180,13 +180,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -197,21 +197,21 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -222,9 +222,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cairo-rs" @@ -251,9 +251,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex", @@ -261,9 +261,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" dependencies = [ "smallvec", "target-lexicon", @@ -277,9 +277,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -287,9 +287,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -299,14 +299,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -332,9 +332,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "derive_more" @@ -354,7 +354,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -382,7 +382,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -403,11 +403,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -424,9 +423,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "field-offset" @@ -440,21 +439,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -467,9 +460,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -477,15 +470,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -494,9 +487,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -513,32 +506,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -610,15 +603,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", ] [[package]] @@ -682,7 +673,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -790,7 +781,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -814,18 +805,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -835,9 +817,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -845,22 +827,14 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -869,33 +843,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libusb1-sys" @@ -915,23 +878,17 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -1022,9 +979,9 @@ checksum = "a1a0483e89e81d7915defe83c51f23f6800594d64f6f4a21253ce87fd8444ada" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "polling" @@ -1040,16 +997,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1061,9 +1008,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1112,9 +1059,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1159,9 +1106,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "semver" @@ -1171,9 +1118,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1191,46 +1138,33 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "syn 3.0.4", ] [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -1244,9 +1178,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1266,9 +1200,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "strsim" @@ -1278,9 +1212,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -1302,9 +1247,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -1321,9 +1266,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "pin-project-lite", "tokio-macros", @@ -1331,20 +1276,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -1352,7 +1297,7 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow", ] [[package]] @@ -1366,30 +1311,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow 1.0.1", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -1410,7 +1355,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1495,9 +1440,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom", "js-sys", @@ -1517,29 +1462,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1550,9 +1477,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1560,60 +1487,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "windows" version = "0.61.3" @@ -1668,7 +1561,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1679,7 +1572,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1742,115 +1635,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zbus" -version = "5.14.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -1875,7 +1671,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys", - "winnow 0.7.15", + "winnow", "zbus_macros", "zbus_names", "zvariant", @@ -1883,14 +1679,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -1898,58 +1694,62 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 0.7.15", + "winnow", "zvariant", ] [[package]] -name = "zmij" -version = "1.0.21" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] [[package]] name = "zvariant" -version = "5.10.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", - "winnow 0.7.15", + "winnow", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 3.0.4", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn", - "winnow 0.7.15", + "syn 3.0.4", + "winnow", ] From cc3e54b3cc06257d5e6e44456083c49debf98b57 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Mon, 31 Aug 2026 21:51:54 +0300 Subject: [PATCH 13/13] fix(viewer): don't fire the modifier-chord release hotkey mid-shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default release-cursor binding (Ctrl+Alt) fired the moment the chord was completed on key PRESS — making it the prefix of every Ctrl+Alt+ guest shortcut: the grab dropped mid-combo, the remaining key went nowhere, and keyboard input appeared frozen until a click re-armed the grab. Modifier-only chords now arm on press and fire only when a chord key is RELEASED with no other key pressed in between (virt-viewer semantics); key+modifier bindings keep firing on press. Also add a modifier reconciliation net: when GTK's modifier state shows a modifier is up but we still track it as pressed (its release event was lost to grab churn or compositor focus flicker), release it in the guest instead of leaving it stuck repeating the last chord. --- src/viewer/hotkeys.rs | 19 ++++++++ src/viewer/keyboard.rs | 99 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/viewer/hotkeys.rs b/src/viewer/hotkeys.rs index ddbef7b..4abc85b 100644 --- a/src/viewer/hotkeys.rs +++ b/src/viewer/hotkeys.rs @@ -208,6 +208,25 @@ impl Hotkey { } } + /// Whether the binding is a modifier-only chord (like the default + /// `Ctrl+Alt`). Those must not fire on press: half of every + /// `Ctrl+Alt+` guest shortcut would trigger them. The keyboard + /// controller arms them on press and fires on a clean release instead. + pub(super) fn is_modifier_chord(&self) -> bool { + matches!(self, Self::Modifiers { .. }) + } + + /// Whether this key is one of the modifiers making up a modifier-only + /// binding (used for release-time chord detection). + pub(super) fn chord_member(&self, keyval: gdk::Key) -> bool { + match self { + Self::Modifiers { modifiers, .. } => { + modifier_mask_for_key(keyval).is_some_and(|mask| modifiers.contains(mask)) + } + _ => false, + } + } + pub(super) fn accelerator(&self) -> Option<&str> { match self { Self::Disabled => None, diff --git a/src/viewer/keyboard.rs b/src/viewer/keyboard.rs index 6b54efe..30433e4 100644 --- a/src/viewer/keyboard.rs +++ b/src/viewer/keyboard.rs @@ -1,6 +1,6 @@ use std::{cell::RefCell, collections::HashSet, rc::Rc}; -use gtk::{glib, prelude::*}; +use gtk::{gdk, glib, prelude::*}; use gtk4 as gtk; use tokio::sync::mpsc as tokio_mpsc; @@ -91,6 +91,7 @@ pub(super) fn install_keyboard_controller( release_grab: impl Fn() + 'static, ) -> KeyboardControllerHandle { let state = Rc::new(RefCell::new(PressedKeyState::default())); + let release_grab = Rc::new(release_grab); let key_controller = gtk::EventControllerKey::new(); key_controller.set_propagation_phase(gtk::PropagationPhase::Capture); @@ -98,6 +99,7 @@ pub(super) fn install_keyboard_controller( let input_tx = input_tx.clone(); let input_grab = input_grab.clone(); let release_hotkey = release_hotkey.clone(); + let release_grab = release_grab.clone(); let state = state.clone(); move |_, keyval, keycode, modifiers| { let Some(qnum) = gdk_keycode_to_qnum(keycode) else { @@ -109,6 +111,18 @@ pub(super) fn install_keyboard_controller( } if release_hotkey.matches(keyval, modifiers) { + if release_hotkey.is_modifier_chord() { + // Don't fire on press: the chord is the prefix of every + // `+` guest shortcut. Arm it, keep forwarding, + // and fire only if it is released without another key. + let mut state = state.borrow_mut(); + state.release_chord_armed = true; + if state.press(qnum) { + let _ = input_tx.send(InputEvent::KeyPress(qnum)); + } + return glib::Propagation::Stop; + } + let mut state = state.borrow_mut(); state.release_all(&input_tx); state.suppress_next_release(qnum); @@ -117,6 +131,7 @@ pub(super) fn install_keyboard_controller( } let mut state = state.borrow_mut(); + state.release_chord_armed = false; if state.press(qnum) { let _ = input_tx.send(InputEvent::KeyPress(qnum)); } @@ -125,13 +140,24 @@ pub(super) fn install_keyboard_controller( }); key_controller.connect_key_released({ let input_tx = input_tx.clone(); + let release_hotkey = release_hotkey.clone(); + let release_grab = release_grab.clone(); let state = state.clone(); - move |_, _, keycode, _| { + move |_, keyval, keycode, _| { let Some(qnum) = gdk_keycode_to_qnum(keycode) else { return; }; let mut state = state.borrow_mut(); + if state.release_chord_armed && release_hotkey.chord_member(keyval) { + // Clean chord release: no other key was pressed in between. + state.release_chord_armed = false; + state.release_all(&input_tx); + state.take_suppressed_release(qnum); + release_grab(); + return; + } + if state.take_suppressed_release(qnum) { return; } @@ -141,6 +167,18 @@ pub(super) fn install_keyboard_controller( } } }); + // Safety net for release events GTK never delivers (grab churn, focus + // flicker, compositor shortcuts): when the modifier state says a modifier + // is up but we still track it as pressed, release it in the guest so it + // can never get stuck repeating a chord. + key_controller.connect_modifiers({ + let input_tx = input_tx.clone(); + let state = state.clone(); + move |_, modifiers| { + state.borrow_mut().reconcile_modifiers(modifiers, &input_tx); + glib::Propagation::Proceed + } + }); picture.add_controller(key_controller); KeyboardControllerHandle { state, input_tx } @@ -159,10 +197,24 @@ pub(super) fn send_guest_shortcut( } } +/// Modifier keys by qnum, paired with the GDK modifier bit they raise. +/// Used to reconcile our pressed-key tracking against GTK's modifier state. +const MODIFIER_QNUMS: &[(u32, gdk::ModifierType)] = &[ + (29, gdk::ModifierType::CONTROL_MASK), // Ctrl_L + (157, gdk::ModifierType::CONTROL_MASK), // Ctrl_R + (42, gdk::ModifierType::SHIFT_MASK), // Shift_L + (54, gdk::ModifierType::SHIFT_MASK), // Shift_R + (56, gdk::ModifierType::ALT_MASK), // Alt_L + (184, gdk::ModifierType::ALT_MASK), // Alt_R / AltGr + (219, gdk::ModifierType::SUPER_MASK), // Super_L + (220, gdk::ModifierType::SUPER_MASK), // Super_R +]; + #[derive(Default)] struct PressedKeyState { pressed: HashSet, suppressed_releases: HashSet, + release_chord_armed: bool, } impl PressedKeyState { @@ -170,6 +222,18 @@ impl PressedKeyState { self.pressed.insert(qnum) } + fn reconcile_modifiers( + &mut self, + current: gdk::ModifierType, + input_tx: &tokio_mpsc::UnboundedSender, + ) { + for &(qnum, mask) in MODIFIER_QNUMS { + if !current.contains(mask) && self.pressed.remove(&qnum) { + let _ = input_tx.send(InputEvent::KeyRelease(qnum)); + } + } + } + fn release(&mut self, qnum: u32) -> bool { self.pressed.remove(&qnum) } @@ -410,6 +474,37 @@ mod tests { assert!(!state.take_suppressed_release(29)); } + #[test] + fn reconcile_modifiers_releases_stuck_modifiers_only() { + let (input_tx, mut input_rx) = tokio_mpsc::unbounded_channel(); + let mut state = PressedKeyState::default(); + + assert!(state.press(29)); // Ctrl_L, release will be "lost" + assert!(state.press(34)); // G, still physically held + + state.reconcile_modifiers(gdk::ModifierType::empty(), &input_tx); + + assert_eq!( + input_rx.try_recv().ok(), + Some(super::InputEvent::KeyRelease(29)) + ); + assert!(input_rx.try_recv().is_err()); + assert!(state.pressed.contains(&34)); + assert!(!state.pressed.contains(&29)); + } + + #[test] + fn reconcile_modifiers_keeps_held_modifiers() { + let (input_tx, mut input_rx) = tokio_mpsc::unbounded_channel(); + let mut state = PressedKeyState::default(); + + assert!(state.press(29)); // Ctrl_L + state.reconcile_modifiers(gdk::ModifierType::CONTROL_MASK, &input_tx); + + assert!(input_rx.try_recv().is_err()); + assert!(state.pressed.contains(&29)); + } + #[test] fn guest_shortcut_sends_press_and_reverse_release_sequence() { let (input_tx, mut input_rx) = tokio_mpsc::unbounded_channel();