logo
vision scalability social networking revelation source

Orchestrating the wallet GUI on a remote test machine

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.

1 Orchestrating the wallet GUI on a remote test machine

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:

1.1 The split: build here, run there

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.

1.2 Why plain ssh cannot show a GUI

ssh testenv1 some-command starts a non-login, non-interactive shell. sshd gives it none of:

Three more ssh facts shape the design:

  1. Debian’s bash does not source ~/.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.
  2. The server has 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.
  3. 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.

1.3 The toolkit on the test box

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).

1.3.1 How environment detection works

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=.

1.3.2 Command reference (the daily driver)

# 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 proof

Because 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.

1.4 Driving the GUI: AT-SPI in practice

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:

1.5 Screenshots

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.

1.6 Launching, lifecycle, logs

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.

1.7 Deploy loop

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.

1.8 The selftest: proof the plumbing works

gui selftest (or te1-gui selftest) is the canonical “did I break the environment?” check and doubles as a template for driving any app:

  1. doctor checks (X cookie, session bus, a11y bus, PyGObject, Gdk);
  2. launches demo_window.py detached;
  3. waits for it on AT-SPI and for its Press me button;
  4. types into its entry, clicks twice, reads the status label back (clicks: 2, entry: 'hello gui');
  5. screenshots the screen to a PNG;
  6. closes the window with its Quit button and waits for it to disappear.

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.

1.9 Troubleshooting

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

1.10 Recreating from scratch

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/11

That 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.

Creative Commons License reaction.la gpg key 154588427F2709CD9D7146B01C99BB982002C39F
This work is licensed under the Creative Commons Attribution 4.0 International License.