Files
signer/plans/pure_x11_gui_backend_plan.md

9.5 KiB
Raw Permalink Blame History

Pure-Rust X11 + CPU-rendered GUI backend

Goal

The static musl signer binary should run its egui GUI on any x86_64 Linux that has an X server or XWayland, including minimal Qubes templates. It should load no shared libraries at runtime: no libX11, libxcb, libxkbcommon, libwayland, libGL or libEGL.

Root cause being fixed

eframe → winit + glow load X11, Wayland and OpenGL with dlopen at runtime. A static musl binary can't load glibc-linked libraries, so on Debian/Fedora qubes run_gui() fails with Failed to load one of xlib's shared libraries, even when the libraries are installed.

Design

flowchart TD
    A[SignerApp - existing UI code] --> B[egui Context run]
    B --> C[FullOutput: shapes, textures_delta, platform_output, repaint_delay]
    C --> D[tessellate into ClippedPrimitive meshes]
    D --> E{frame hash changed?}
    E -->|no| L[skip raster and present]
    E -->|yes| F[CPU rasterizer into BGRX buffer]
    F --> G[present: MIT-SHM PutImage or chunked core PutImage]
    G --> H[X server via x11rb RustConnection]
    H --> I[X events]
    I --> J[input translation to egui RawInput]
    J --> B
    C --> K[platform_output: copied text, cursor icon, close command]
    K --> M[clipboard owner and cursor]

Crates

Crate Purpose Notes
egui 0.35 UI, unchanged default-features = false, features default_fonts
x11rb X11 protocol Pure-Rust RustConnection (default). Enable the shm extension feature. Do not enable allow-unsafe-code + dl-libxcb.
xkeysym keysym → char/name Pure Rust, no system libraries
libc already a dependency SysV shmget/shmat for MIT-SHM, poll on the X socket

eframe is removed. hamburger-morphing only depends on egui, so it keeps working.

Module layout

src/gui.rs                 existing app (SignerApp), small edits only
src/gui/seed_wheel.rs      unchanged
src/gui/backend/mod.rs     run(app) entry point, event loop, App trait
src/gui/backend/x11.rs     connection, window, atoms, WM hints, present
src/gui/backend/shm.rs     MIT-SHM segment lifecycle and fallback
src/gui/backend/raster.rs  CPU rasterizer and texture store
src/gui/backend/input.rs   X events to egui::Event, modifiers, scroll
src/gui/backend/keymap.rs  keycode to keysym table, keysym to egui::Key
src/gui/backend/clipboard.rs  CLIPBOARD owner and requester
src/gui/backend/cursor.rs  egui CursorIcon to X core cursor font glyphs

Key technical decisions

  1. Connection: x11rb::connect(None) reads DISPLAY and XAUTHORITY itself. If it fails, run_gui returns an error and main falls back to the TUI.
  2. Visual: require a 24- or 32-bit TrueColor visual. Check that the pixmap format is 32 bpp and that the image byte order is LSB. Convert in the present step if it isn't.
  3. Window setup: WM_NAME/_NET_WM_NAME = Signer <version>, WM_CLASS = signer, WM_PROTOCOLS with WM_DELETE_WINDOW, WM_NORMAL_HINTS minimum size 640×480 (logical, multiplied by the scale), and _NET_WM_PID. Initial size 860×720, as today.
  4. HiDPI: native scale = Xft.dpi / 96, read from the root window's RESOURCE_MANAGER property; default 1.0. An optional SIGNER_GUI_SCALE environment variable overrides it. pixels_per_point = native scale; the existing set_zoom_factor(INITIAL_ZOOM) still applies on top.
  5. Rasterizer:
    • Draw egui Mesh triangles with half-space (edge function) tests over the triangle's bounding box, clipped to clip_rect.
    • Interpolate vertex colour and UV across each triangle, sample the texture bilinearly, and blend with premultiplied alpha in gamma space, matching egui_glow on a non-sRGB framebuffer.
    • Anti-aliasing comes from egui's feathering, so no MSAA is needed.
    • Fast path: axis-aligned quads that sample the white texel become solid rect fills. Most of egui's output is rects and glyph quads.
    • Texture store: HashMap<TextureId, Image>. Apply textures_delta.set, including partial updates with pos, before painting, and free after.
    • Handle every ImageData variant egui 0.35 provides.
    • Optional later step: split rows across std::thread::scope workers.
  6. Avoid redrawing when nothing changed: src/gui.rs requests a repaint every 50 ms to poll the server. Keep running the egui logic at that rate, but hash the tessellated primitives and texture deltas each frame. If the hash matches the last frame, skip both rasterizing and presenting.
  7. Present:
    • Preferred: MIT-SHM ShmPutImage from a SysV segment. Mark it IPC_RMID right after attaching so it can't leak. Recreate it on resize.
    • Fallback: core PutImage, split into row bands that fit the server's maximum request length (use BIG-REQUESTS where available).
    • Optional: send only the row bands that differ from the previous frame.
  8. Event loop:
    • Drain poll_for_event() first, because x11rb may already have events buffered.
    • Then poll(2) the connection fd, with a timeout equal to the time until the next egui repaint deadline (from repaint_delay).
    • Run a frame when input arrives, on Expose, on resize, or when the deadline passes.
    • Cap the frame rate at about 60 fps.
  9. Input translation:
    • Mouse buttons 1–3 → PointerButton. Buttons 4/5 and 6/7 → MouseWheel (vertical/horizontal), in line units.
    • MotionNotify → PointerMoved; LeaveNotify → PointerGone.
    • FocusIn/FocusOut → WindowFocused.
    • ConfigureNotify → new screen_rect.
    • Keys: keycode → keysym using the core GetKeyboardMapping table and the shift/lock/Mode_switch levels. Refresh the table on MappingNotify.
    • keysym → egui::Key. Emit Event::Text for printable characters, but not while Ctrl or Alt is held.
    • Modifiers come from the event state mask.
    • Ctrl+C/X/V → Event::Copy/Event::Cut/paste request.
    • Key auto-repeat: X sends a Release+Press pair with the same timestamp. Detect it and set repeat: true.
  10. Clipboard:
    • Copy: for each platform_output.commands copy-text command, call SetSelectionOwner(CLIPBOARD) and answer SelectionRequest for TARGETS, UTF8_STRING, STRING and TEXT.
    • Paste: call ConvertSelection(CLIPBOARD, UTF8_STRING) on our window, and on SelectionNotify read the property and push Event::Paste(text). This is asynchronous and doesn't block the loop.
    • Ignore INCR transfers and limit size to 64 KiB. Seed phrases and URIs are small.
    • Zeroize the held clipboard buffer on SelectionClear and at exit, because the seed phrase copy at src/gui.rs can put it there.
    • This is what the Qubes inter-qube clipboard (Ctrl+Shift+C) reads.
  11. Cursor: map egui CursorIcon to core cursor font glyphs (left_ptr, xterm, hand2, sb_h_double_arrow, …) with CreateGlyphCursor. Nice-to-have.
  12. Close: handle both WM_DELETE_WINDOW and egui's ViewportCommand::Close, sent by the Exit menu at src/gui.rs. The backend must check viewport commands in the output. Then destroy the window and return 0.

Changes to existing code

  • src/gui.rs:
    • Replace use eframe::egui with use egui.
    • Change SignerApp::new(cc) to take &egui::Context.
    • Change impl eframe::App to implement the backend's own App trait. Keep the ui(&mut self, ui) signature and drop the _frame argument.
    • Rewrite run_gui() to call backend::run(...) and return a Result so main can fall back.
    • Check how egui 0.35 builds the root Ui (eframe's equivalent of run_ui) and mirror it in the backend.
  • src/gui/seed_wheel.rs: replace use eframe::egui with use egui.
  • src/main.rs:
    • If the GUI fails to start, print one warning line and continue into the TUI instead of exiting.
    • In --no-default-features builds, fall back to the TUI as well.
  • Cargo.toml: replace eframe with x11rb and xkeysym, keep egui, and update the gui feature and the comment above it.

Verification

  • cargo tree -e normal --target x86_64-unknown-linux-musl lists no *-sys crates, x11-dl, wayland-*, glutin or winit.
  • build_musl.sh: add a check that the binary contains no libX11.so, libxcb.so, libGL, libEGL or libwayland strings.
  • Rasterizer unit tests: solid rect, clipped rect, textured quad sampling, alpha blend, partial texture update, freeing a texture.
  • Keymap unit tests: Latin letters with and without shift, digits, arrows, PageUp/PageDown, Enter/Tab/Backspace/Escape (all used by seed_wheel.rs).
  • Smoke test: with SIGNER_GUI_SMOKE_FRAMES=N, render N frames and exit 0. Run it under xvfb-run inside the Alpine Docker build.
  • Manual test matrix:
    • Debian minimal Qubes AppVM (the failing case).
    • Fedora AppVM.
    • GNOME on Wayland via XWayland.
    • Plain Xorg desktop.
    • Check copy/paste, including Qubes Ctrl+Shift+C/V between qubes.
    • Check zoom, resize, the seed wheel keyboard and scroll, the hamburger animation, the QR modal, and Exit.

Out of scope

  • Native Wayland without XWayland: falls back to the TUI.
  • IME / compose sequences and dead keys.
  • GPU rendering.
  • Removing nostr_mine_event, which stays in the signer for now.