Files
signer/plans/pure_x11_gui_backend_plan.md

197 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()`](../src/gui.rs:1810) fails with
`Failed to load one of xlib's shared libraries`, even when the libraries are
installed.
## Design
```mermaid
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](../src/gui.rs:1803)
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](../src/gui.rs:1749) 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](../src/gui.rs:990). The backend must check viewport commands
in the output. Then destroy the window and return 0.
### Changes to existing code
- [src/gui.rs](../src/gui.rs):
- Replace `use eframe::egui` with `use egui`.
- Change [`SignerApp::new(cc)`](../src/gui.rs:357) to take `&egui::Context`.
- Change [`impl eframe::App`](../src/gui.rs:1765) to implement the
backend's own `App` trait. Keep the `ui(&mut self, ui)` signature and
drop the `_frame` argument.
- Rewrite [`run_gui()`](../src/gui.rs:1810) 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](../src/gui/seed_wheel.rs): replace `use eframe::egui`
with `use egui`.
- [src/main.rs](../src/main.rs:182):
- 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](../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](../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](../src/gui/seed_wheel.rs:230)).
- 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.