abstract: How the wxWidgets wallet GUI is built on one machine and run, observed and driven on another: the ssh environment gap and how it is closed, the toolkit that lives on the test box, AT-SPI driving recipes and their pitfalls, screenshots, deploy loops, and the end-to-end selftest that proves the plumbing. Everything needed so the next session does not start from zero.
The wallet is a wxWidgets/GTK GUI. It is built on the development machine
(redmi, which has the compilers) and exercised on a separate box
(testenv1, Debian 13, lightdm + XFCE on X11 :0) that has a real desktop
session but no build toolchain. This document records how the two are wired
together so that an agent or a human sitting on the development machine can
launch, watch, click and screenshot the wallet over plain ssh.
Companion material:
.agents/driving_wallet_gui.md in this repository: the AT-SPI recipes
specific to the wallet’s own dialogs (file chooser, seed-phrase dialog,
Zooko-name dialog). Read that before driving the wallet itself.docs/setup/gui_session/: the actual toolkit source (this is the
authoritative copy; install.sh there deploys it).~/.config/gui-session/README.md on the test box: the on-box manual
page for the gui command.| machine | role | relevant facts |
|---|---|---|
development box (redmi) |
compile, unit tests (build/wallet -ct), orchestration |
has cmake/ninja/g++, X server of its own, ~/.local/bin/te1-gui |
testenv1 |
the desktop under test | Debian 13 trixie, Xorg :0 via lightdm, XFCE (Xfwm4), 1280x800, user tool (uid 1001), no sudo without password, no cmake/make/g++/git/scrot/xdotool/imagemagick |
The test box deliberately has no compilers: binaries are pushed over, not
built there. What it does have, and what everything else depends on:
python3 with PyGObject (python3-gi, gir1.2-atspi-2.0, Gtk/Gdk/GdkPixbuf
3.0 typelibs), at-spi2-core with a running at-spi-bus-launcher, and the
X11 introspection tools xdpyinfo, xwininfo, xprop, xauth, xwd.
ssh testenv1 some-command starts a non-login, non-interactive shell. sshd
gives it none of:
DISPLAY (so GTK dies with Unable to initialize GTK+, is DISPLAY set properly?),XAUTHORITY (the MIT-MAGIC-COOKIE for :0 lives in ~/.Xauthority),DBUS_SESSION_BUS_ADDRESS (unix:path=/run/user/1001/bus; the AT-SPI
accessibility bus hangs off this session bus, so without it no automation
can attach either),XDG_* variables (XDG_RUNTIME_DIR, XDG_CURRENT_DESKTOP=XFCE,
XDG_DATA_DIRS, …).Three more ssh facts shape the design:
~/.bashrc for ssh host cmd (upstream
SSH_SOURCE_BASHRC is not enabled in the Debian build). A .bashrc fix
therefore only helps interactive (ssh -t, bash -ic) and login shells.
Non-interactive automation must go through a wrapper that sources the
environment itself.X11Forwarding yes, so ssh -X testenv1 xeyes displays
back on the development box. That path must keep working: any environment
machinery must never stomp a DISPLAY that already connects.AcceptEnv is restricted to LANG LC_* ... and PermitUserEnvironment is
off, so pushing environment through ssh config is not an option; and there
is no passwordless sudo to touch /etc/ssh/sshd_config or install
packages. Everything has to live in the user’s home.Installed by docs/setup/gui_session/install.sh (idempotent), byte-identical
to the copies in that directory:
path on testenv1 |
what it is |
|---|---|
~/.config/gui-session/env.sh |
detection library; sourcing it exports the session environment |
~/.local/bin/gui |
the CLI wrapper, the remote entry point for everything |
~/.local/lib/gui-session/atspi_tool.py |
AT-SPI inspect/drive: tree with node paths, act, settext, text, find, wait, kill |
~/.local/lib/gui-session/xshot.py |
PNG screenshot straight off the X root window via GdkPixbuf |
~/.local/lib/gui-session/demo_window.py |
a GTK3 window that exists purely to be driven by the selftest |
~/.local/state/gui-session/ |
logs of detached launches |
/run/user/1001/gui-session/env |
cache of the detected environment |
managed block at the end of ~/.bashrc |
sources env.sh for interactive/login shells, adds ~/.local/bin to PATH |
On the development box the same installer puts te1-gui in ~/.local/bin: a
frontend with the same subcommands as gui, plus deploy (scp a fresh build
to ~/wallet, keeping ~/wallet.prev) and shot (the PNG lands on this
machine so it can actually be looked at).
env.sh finds the process that owns the graphical session — xfce4-session
here; gnome/kde/mate/lxqt/sway/… are recognised too — and copies
DISPLAY, XAUTHORITY, DBUS_SESSION_BUS_ADDRESS and the whole XDG_* set
out of its /proc/<pid>/environ (/proc/<pid>/environ is owner-readable, so
this needs no privileges). Fallbacks when no session manager is found: first
/tmp/.X11-unix/X* socket for DISPLAY, ~/.Xauthority for the cookie,
/run/user/$UID/bus for the session bus. The result is cached against the
session manager’s PID, so a re-login invalidates it automatically and every
later command pays essentially nothing. An already-working DISPLAY
(ssh -X) always wins over the detected one. Knobs: GUI_ENV_DISABLE=1,
GUI_ENV_NOCACHE=1, GUI_ENV_FORCE_DISPLAY=, GUI_ENV_FORCE_AUTH=.
# from the development box (te1-gui) or on the box (~/.local/bin/gui):
gui doctor # env + X + dbus + at-spi + Gdk checks
gui env # eval-able export lines
gui run PROG ARGS... # foreground, in the session
gui launch ~/wallet -T # detached (setsid); prints pid + log
gui shot [-o OUT.png|--monitor N|--geometry WxH+X+Y|--window-id ID]
gui apps # AT-SPI applications (index, pid, name)
gui tree --app wallet --depth 6 --states # paths + roles + names
gui find --app wallet --role button --name '^No$'
gui act --app wallet --path 0.3.1 # do_action(0) by path
gui act --app wallet --name '^No$' --role button
gui settext --app wallet --role text --text 'alice'
gui text --app wallet --path 0.0.0
gui wait --app wallet --name 'strong master secret created' # poll for node
gui wait --app wallet --gone
gui kill --app wallet # SIGTERM by AT-SPI pid
gui demo --exit-after 60 # the self-test window
gui selftest # full end-to-end proofBecause of ssh fact (1) above, from non-interactive ssh always use the
absolute path ~/.local/bin/gui ... (or te1-gui ... locally, which sshes
for you). ssh -t testenv1 gui ... also works, since interactive shells pick
the environment up from .bashrc.
The wallet and any GTK program register on the accessibility bus; that is the only reliable way to click and type from another machine. Hard-won details:
--app root, exactly as tree prints them
(0.0.0.1). The absolute form desktop[N].0.0.0.1 is also accepted.
Paths go stale the moment the widget tree changes (dialog opens/closes):
re-run tree/find, never reuse a path across a transition. A stale
reference raises Atspi.NoOpErrorObject-style errors rather than crashing
the app.button — not push button as older documents say — while
wxWidgets keeps the classic names (menu bar, menu, menu item,
panel, label, text, alert, …). --role is a case-insensitive
substring and --name a regex (or --exact), so --role button matches
both worlds. Ambiguous matches exit 4 and list the candidates; disambiguate
with --index or switch to --path.do_action(0) is “click/activate” for menus, buttons, toolbar items.
Menus need no pointer event; GTK prints a benign
Gtk-WARNING **: no trigger event for menu popup — ignore it.accessible.get_text() is the getter for the Text interface, not a text
reader. Read with iface.get_text(0, -1) on the object returned by
get_text(), or Atspi.Text.get_text(node, 0, -1). For labels the
accessible name is the text. Writing goes through
get_editable_text_iface().set_text_contents(s). The toolkit hides all of
this behind gui text / gui settext.GLib.set_prgname(...); a wx
app under its binary name, so the wallet shows up as app wallet. The
wallet’s main frame title comes from its config (rho on a configured
box), and with an empty ~/.config/rho the first thing on screen is the
“Welcome to Rhocoin” three-option screen.ShowModal();
gui wait --app X --name ... is the right primitive, never a blind sleep.gui kill --app NAME (it reads the pid off the bus).
pkill -f demo_window-style patterns match your own ssh command line and
kill the session you are working from — a failure mode that cost real time.DBUS_SESSION_BUS_ADDRESS must be the
desktop session’s; the a11y bus (/run/user/1001/at-spi/bus_0) is spawned
from it by at-spi-bus-launcher, which XFCE starts by default. gui doctor
verifies all of this in one go.There is no scrot/ImageMagick/xdotool on the test box. xshot.py grabs the
root window with Gdk.pixbuf_get_from_window (union of monitor geometries)
and writes a PNG; gui shot -o - streams the PNG over stdout, which is how
te1-gui shot drops it onto the development box for eyeballing. If Gdk ever
fails, gui shot falls back to xwd -root, which can be converted wherever
ImageMagick exists.
gui launch runs the program under setsid with stdio redirected to
~/.local/state/gui-session/<prog>-<stamp>.log, so it survives the ssh
connection closing; it prints pid=... log=... and, if the process dies
within a second, dumps the log tail (this catches “no DISPLAY”-class failures
immediately). Foreground work uses gui run. The wallet’s own debug output
comes from its -T flag: gui launch ~/wallet -T.
cd build && cmake -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .. && cmake --build .
te1-gui deploy # scp build/wallet -> testenv1:~/wallet (keeps .prev)
te1-gui launch ~/wallet -T
te1-gui tree --app wallet --depth 5
te1-gui shot /tmp/w.png # look at it on this machine
te1-gui act --app wallet --name '^Quit$' --role 'menu item'Unit tests stay local: build/wallet -ct. The test box cannot run them (no
toolchain) and should not need to.
gui selftest (or te1-gui selftest) is the canonical “did I break the
environment?” check and doubles as a template for driving any app:
doctor checks (X cookie, session bus, a11y bus, PyGObject, Gdk);demo_window.py detached;Press me button;clicks: 2, entry: 'hello gui');Eleven checks, exit 0 when the whole chain works. Run it first whenever GUI misbehaviour is suspected: if it passes, the environment is fine and the bug is in the program under test.
| symptom | cause / cure |
|---|---|
Unable to initialize GTK+, is DISPLAY set properly? |
command ran without the wrapper; use ~/.local/bin/gui run ... or te1-gui run ... |
gui: no DISPLAY: is a graphical session running... |
nobody is logged into the desktop; log in at lightdm (or check loginctl list-sessions) |
| X connection fails but DISPLAY is set | wrong/missing cookie: XAUTHORITY should be ~/.Xauthority; xauth list should show testenv1/unix:0 |
AT-SPI desktop empty / wait times out although window is up |
missing session DBUS_SESSION_BUS_ADDRESS, or app started before the bus; gui doctor tells which |
no node matching name=... right after opening a dialog |
bus registration lag; use gui wait, not a fixed sleep |
| multiple matches listed, exit 4 | pass --index or a --path from tree |
| clicks do nothing on a wx dialog | wx dialogs re-parent; re-walk with tree after every transition |
| stale paths raising errors | tree changed; re-run tree/find |
my own ssh session died after a pkill |
pattern matched the ssh command line; use gui kill --app ... |
| environment looks stale after re-login | cache is keyed on the session PID and should refresh itself; force with GUI_ENV_NOCACHE=1 gui doctor |
| want the GUI on the dev box instead | ssh -X testenv1 ... still works and is never overridden |
On a fresh test box with a logged-in desktop session:
docs/setup/gui_session/install.sh <host> # deploys toolkit + bashrc block
ssh <host> '~/.local/bin/gui doctor' # all checks ok
te1-gui selftest # 11/11That is the entire procedure; no root, no packages, nothing outside the
user’s home directory. The scripts in docs/setup/gui_session/ are the
single source of truth — edit them there, re-run install.sh, and keep the
copies on the box byte-identical.
reaction.la gpg key 154588427F2709CD9D7146B01C99BB982002C39F
This work is licensed under the Creative Commons Attribution 4.0 International License.