197 lines
9.5 KiB
Markdown
197 lines
9.5 KiB
Markdown
# 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.
|