9.5 KiB
9.5 KiB
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
- Connection:
x11rb::connect(None)readsDISPLAYandXAUTHORITYitself. If it fails,run_guireturns an error andmainfalls back to the TUI. - 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.
- Window setup:
WM_NAME/_NET_WM_NAME=Signer <version>,WM_CLASS=signer,WM_PROTOCOLSwithWM_DELETE_WINDOW,WM_NORMAL_HINTSminimum size 640×480 (logical, multiplied by the scale), and_NET_WM_PID. Initial size 860×720, as today. - HiDPI: native scale =
Xft.dpi / 96, read from the root window'sRESOURCE_MANAGERproperty; default 1.0. An optionalSIGNER_GUI_SCALEenvironment variable overrides it.pixels_per_point= native scale; the existingset_zoom_factor(INITIAL_ZOOM)still applies on top. - Rasterizer:
- Draw egui
Meshtriangles with half-space (edge function) tests over the triangle's bounding box, clipped toclip_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>. Applytextures_delta.set, including partial updates withpos, before painting, andfreeafter. - Handle every
ImageDatavariant egui 0.35 provides. - Optional later step: split rows across
std::thread::scopeworkers.
- Draw egui
- 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.
- Present:
- Preferred: MIT-SHM
ShmPutImagefrom a SysV segment. Mark itIPC_RMIDright 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.
- Preferred: MIT-SHM
- 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 (fromrepaint_delay). - Run a frame when input arrives, on
Expose, on resize, or when the deadline passes. - Cap the frame rate at about 60 fps.
- Drain
- 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→ newscreen_rect.- Keys: keycode → keysym using the core
GetKeyboardMappingtable and the shift/lock/Mode_switch levels. Refresh the table onMappingNotify. - keysym →
egui::Key. EmitEvent::Textfor 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.
- Mouse buttons 1–3 →
- Clipboard:
- Copy: for each
platform_output.commandscopy-text command, callSetSelectionOwner(CLIPBOARD)and answerSelectionRequestforTARGETS,UTF8_STRING,STRINGandTEXT. - Paste: call
ConvertSelection(CLIPBOARD, UTF8_STRING)on our window, and onSelectionNotifyread the property and pushEvent::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
SelectionClearand 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.
- Copy: for each
- Cursor: map egui
CursorIconto corecursorfont glyphs (left_ptr, xterm, hand2, sb_h_double_arrow, …) withCreateGlyphCursor. Nice-to-have. - Close: handle both
WM_DELETE_WINDOWand egui'sViewportCommand::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::eguiwithuse egui. - Change
SignerApp::new(cc)to take&egui::Context. - Change
impl eframe::Appto implement the backend's ownApptrait. Keep theui(&mut self, ui)signature and drop the_frameargument. - Rewrite
run_gui()to callbackend::run(...)and return aResultsomaincan fall back. - Check how egui 0.35 builds the root
Ui(eframe's equivalent ofrun_ui) and mirror it in the backend.
- Replace
- src/gui/seed_wheel.rs: replace
use eframe::eguiwithuse 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-featuresbuilds, fall back to the TUI as well.
- Cargo.toml: replace
eframewithx11rbandxkeysym, keepegui, and update theguifeature and the comment above it.
Verification
cargo tree -e normal --target x86_64-unknown-linux-musllists no*-syscrates,x11-dl,wayland-*,glutinorwinit.- build_musl.sh: add a check that the binary contains no
libX11.so,libxcb.so,libGL,libEGLorlibwaylandstrings. - 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 underxvfb-runinside 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.