From 3678e0f390973377a5af13750379a3d3f7a8ce80 Mon Sep 17 00:00:00 2001 From: Phaengris Date: Fri, 14 Aug 2026 18:55:24 +0300 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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();