From 38914cf67f2516b8196daa8093d7f7497ccb9d4b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 04:29:04 +0300 Subject: [PATCH] fix: keep manual USB selection available during keyboard grabs --- dev/smoke.py | 106 ++++++++++++++++++++++++ patches/virt-viewer-hydra-session.patch | 43 +++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 dev/smoke.py diff --git a/dev/smoke.py b/dev/smoke.py new file mode 100644 index 0000000..b3b3087 --- /dev/null +++ b/dev/smoke.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Disposable headless GUI checks. Never connects to an inherited display.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from hydra.profiles import Profile +from hydra.runtime import Runtime +from hydra.session import build_labwc_config, build_viewer_argv, _viewer_environment + + +def wait_for(predicate, description, timeout=8): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(.05) + raise AssertionError(description) + + +def main(): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer')) + cli.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc')) + cli.add_argument('--keyboard', default='wtype') + args = cli.parse_args() + for name in ('viewer', 'labwc', 'keyboard'): + value = shutil.which(getattr(args, name)) + if not value: + cli.error(f'missing {name}: {getattr(args, name)}') + setattr(args, name, value) + root = Path(tempfile.mkdtemp(prefix='hydra-smoke-')) + print(f'Evidence: {root}', flush=True) + xdg = root / 'xdg' + xdg.mkdir(mode=0o700) + config = root / 'labwc' + config.mkdir() + (config / 'rc.xml').write_text(build_labwc_config(['probe'], ['/bin/true'])) + (config / 'autostart').write_text('#!/bin/sh\n') + (config / 'environment').touch() + env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless', + WLR_RENDERER='pixman', WLR_XWAYLAND='/bin/false', XDG_DATA_DIRS='/nonexistent', + GSETTINGS_BACKEND='memory') + for key in ('DISPLAY', 'WAYLAND_DISPLAY'): + env.pop(key, None) + manager = Runtime(root / 'config', root / 'state', root / 'run') + manager.add(Profile('probe', memory=128, cpus=1, disk_size='8M', graphics='software')) + processes = [] + try: + with (root / 'labwc.log').open('w') as cl, (root / 'viewer.log').open('w') as vl: + compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl) + processes.append(compositor) + wait_for(lambda: list(xdg.glob('wayland-*')), 'private display did not start') + env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock')) + keyboard = subprocess.Popen([args.keyboard, '-k', 'Escape', '-s', '30000'], env=env) + processes.append(keyboard) + time.sleep(.2) + manager.start('probe') + previous = dict(os.environ) + try: + os.environ.update(env) + viewer_env = _viewer_environment(root / 'viewer-state') + finally: + os.environ.clear() + os.environ.update(previous) + viewer_env['WAYLAND_DEBUG'] = '1' + viewer = subprocess.Popen(build_viewer_argv(manager.paths('probe'), args.viewer), + env=viewer_env, stdout=vl, stderr=vl) + processes.append(viewer) + log = root / 'viewer.log' + wait_for(lambda: 'set_title("HydraOS: probe")' in log.read_text(), 'viewer did not connect') + time.sleep(.5) + title = 'set_title("Select USB devices for redirection")' + assert title not in log.read_text(), 'USB selector opened automatically' + subprocess.run([args.keyboard, '-M', 'ctrl', '-M', 'alt', '-M', 'shift', '-k', 'u', + '-m', 'shift', '-m', 'alt', '-m', 'ctrl'], env=env, check=True) + wait_for(lambda: title in log.read_text(), 'manual USB selector did not open', timeout=3) + viewer.terminate() + viewer.wait(timeout=5) + assert manager.status('probe')['status'] == 'running' + compositor.terminate() + compositor.wait(timeout=5) + assert manager.status('probe')['status'] == 'running' + result = {'manual_usb_dialog': True, 'automatic_usb_dialog': False, + 'qemu_survives_viewer_and_compositor': True, + 'viewer_version': subprocess.check_output([args.viewer, '--version'], text=True).strip()} + (root / 'result.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + finally: + for proc in reversed(processes): + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=5) + if manager.status('probe')['status'] != 'stopped': + manager.stop('probe', force=True) + + +if __name__ == '__main__': + main() diff --git a/patches/virt-viewer-hydra-session.patch b/patches/virt-viewer-hydra-session.patch index f32225e..d0626a4 100644 --- a/patches/virt-viewer-hydra-session.patch +++ b/patches/virt-viewer-hydra-session.patch @@ -8,20 +8,20 @@ gboolean attach; gboolean shared; gboolean quitting; -@@ -951,6 +952,13 @@ - { +@@ -952,6 +953,13 @@ gchar *subtitle = NULL; const gchar *title = virt_viewer_app_get_title(app); -+ + + /* Hidden fullscreen windows use the requested title as stable identity. */ + if (title != NULL && virt_viewer_app_get_hide_toolbar(app) && + virt_viewer_app_get_fullscreen(app)) { + g_object_set(window, "subtitle", title, NULL); + return; + } - ++ if (title != NULL) { VirtViewerDisplay *display = virt_viewer_window_get_display(window); + gchar *d = strstr(title, "%d"); @@ -2134,6 +2142,7 @@ static gboolean opt_verbose = FALSE; static gboolean opt_debug = FALSE; @@ -110,3 +110,38 @@ gtk_window_set_title(GTK_WINDOW(self->window), title); if (self->subtitle) { gtk_header_bar_set_title(GTK_HEADER_BAR(header), self->subtitle); +@@ -1467,6 +1475,34 @@ + display = self->display; + event = (GdkEventKey *)ev; + ++ /* Keyboard grabs remove GTK's application accelerator group so normal ++ * shortcuts reach the guest. Keep only the explicitly configured manual ++ * USB selector available before forwarding this event to the display. */ ++ if (!self->kiosk) { ++ gchar **accels = gtk_application_get_accels_for_action( ++ GTK_APPLICATION(self->app), "win.usb-device-select"); ++ gboolean matched = FALSE; ++ for (guint i = 0; accels[i]; i++) { ++ guint key; ++ GdkModifierType mods; ++ gtk_accelerator_parse(accels[i], &key, &mods); ++ if (key && gdk_keyval_to_lower(event->keyval) == key && ++ (event->state & gtk_accelerator_get_default_mod_mask()) == mods) { ++ matched = TRUE; ++ break; ++ } ++ } ++ g_strfreev(accels); ++ if (matched) { ++ GAction *action = g_action_map_lookup_action( ++ G_ACTION_MAP(self->window), "usb-device-select"); ++ if (action && g_action_get_enabled(action)) { ++ g_action_activate(action, NULL); ++ return TRUE; ++ } ++ } ++ } ++ + gtk_widget_grab_focus(GTK_WIDGET(display)); + + // Look through keymaps - if set for mappings and intercept