--- name: verify-trayscale description: Drive the Trayscale GTK 4 / Libadwaita desktop UI the way a user does. Use when proving a Trayscale UI change, checking the main window, About, Preferences, peer search, the this-machine page, or a peer page against a live tailscaled. --- # Verify Trayscale Trayscale is an unofficial Tailscale GUI. The user-facing surface is a Libadwaita window titled `Trayscale` plus an optional tray icon. There is no web UI and no first-party CLI besides `tailscale` itself. This skill drives **this checkout's binary** in an isolated D-Bus session. It does not drive a Trayscale the user already started. Set `CTRL` to the helper and run every command from the repo root: ```bash CTRL=.cursor/skills/verify-trayscale/scripts/control-trayscale ``` Read `features/README.md` before a proof. Drive every entry point the matching feature file lists, not one convenient path. ## Isolation GApplication owns the session-bus name `dev.deedles.Trayscale`. A second process on the user bus only activates the first. `launch` starts `dbus-run-session` so the user instance can stay running. Shared with the user and **not** isolated: - `tailscaled` and the live tailnet - the Wayland display (the verify window appears on the current session) Do not activate `use_exit_node`, `login`, `change_control_server`, `admin_dashboard`, or `quit` unless a feature file says to and passes `--i-mean-it`. Do not click the unnamed header `switch` (connect/disconnect). Do not toggle `Advertise exit node`, `Allow LAN access`, `Accept routes`, `Accept DNS`, or `Use as exit node`. Two verify launches at once are refused. Two user-plus-verify instances are allowed. ## Launch The helper needs `go`, `glib-compile-schemas`, `dbus-run-session`, `gdbus`, at-spi2-core, and PyGObject with the `Atspi` 2.0 typelib (`python3 -c 'import gi; gi.require_version("Atspi", "2.0")'` must succeed). ```bash $CTRL launch ``` This compiles `dev.deedles.Trayscale.gschema.xml` into the run dir, builds `./cmd/trayscale` into that run dir, then starts the binary under `dbus-run-session` with: - `GSETTINGS_BACKEND=memory` and `GSETTINGS_SCHEMA_DIR` pointing at the run schema - `TRAYSCALE_PRIVATE=1` (profile names render as `profile@example.com`) - `GTK_A11Y=atspi` and a private AT-SPI registry - isolated `XDG_*` directories under the run dir - `LANGUAGE=en` and `LANG`/`LC_ALL` set to `C.UTF-8`, so labels match this map whatever the user's locale Ready when `doctor` prints `"window": "frame 'Trayscale'"` and `"bus_name_owned": true`. First build can take more than a minute. `--timeout` default is 180 seconds. A `dbind-WARNING` about `/org/a11y/atspi/cache` during launch is ignorable if doctor then succeeds. Tray registration on the private bus is expected to fail (`StatusNotifierWatcher` is absent). That is isolation working, not a product bug. If the build fails, `launch` removes its run directory and exits nonzero. If the D-Bus name never appears, it stops the session and keeps `runs//` for its logs; delete that directory once you have read them. Teardown is `cleanup`. There is no long-lived server besides the process `launch` started. ## Doctor ```bash $CTRL doctor ``` Require all of: - `pgid_alive` true - `binary_exists` true - `bus_name_owned` true - `window` equal to `frame 'Trayscale'` - `gsettings_backend` equal to `memory` Run doctor before the first drive, after any failed drive, and before treating a surprising tree as product behavior. If doctor fails, read `runs//inner.log` and `runs//session.log`, then `cleanup` before another `launch`. ## Drive GApplication actions are the same handlers the menus and shortcuts call. Object path `/dev/deedles/Trayscale` on the isolated bus. ```bash $CTRL actions $CTRL action about $CTRL action preferences $CTRL action search-peers ``` AT-SPI handles that exist in this app: | Handle | Role | Notes | | --- | --- | --- | | `Trayscale` | `frame` | Main window | | `Search peers` | `toggle button` | Sidebar search control | | `profile@example.com` | `combo box` | Profile dropdown under `TRAYSCALE_PRIVATE=1` | | (empty) | `entry` | Peer search entry. Present only while search is open | | `No matching peers` | `grouping` | Sidebar placeholder when a search matches nothing | | `This machine`, `Exit Nodes`, `Online`, `Offline` | `label` | Sidebar section titles when connected | | `Tailscale IPs`, `Options`, `Files`, `Advertised Routes`, `Network Check` | `grouping` | This-machine page | | `Advertise exit node` and the other option rows | `switch` | Read only | | `Tailscale IPs`, `Misc.`, `Advertised Routes` | `grouping` | Peer page | | `Use as exit node` | `switch` | Peer page of an exit node. Read only | | `About` | `dialog` | After `action about` | | `Trayscale`, `DeedleFake` | `label` | Inside the About dialog | | `Preferences` | `dialog` | After `action preferences` | | `General`, `Taildrop` | `grouping` | Inside Preferences | ```bash $CTRL snapshot --path artifacts/example.tree.txt $CTRL find --role dialog --name About --exact $CTRL wait --role dialog --name About --exact --timeout 10 $CTRL click --role toggle --name "Search peers" --exact $CTRL select --role label --name "" --exact $CTRL fill --role entry --value "query" ``` `click` uses a `click`/`press`/`activate`/`toggle` action. It refuses to default to `clipboard.copy` (GTK exposes that on selectable labels). Sidebar rows have empty `list item` names and no AT-SPI actions; `select --name` selects the list item that contains that label. That marks the row `states=selected` but does not change the content page. To show a peer page, search for the peer. When the visible page is not among the matches, the first match becomes the visible page (see `features/peer-page.md`). The peer search entry has no accessible name. Address it as `--role entry` without `--name`; it is the only entry while no dialog is open. `fill` replaces its text and the sidebar filters as if the user typed. `press` synthesizes AT-SPI key events. On GNOME Wayland those events often do nothing. Prefer `action search-peers` over `press --key Control+f`. ## Evidence Store proof under `.cursor/skills/verify-trayscale/artifacts//`. Cleanup deletes `runs/` and does not delete `artifacts/`. Do not commit artifacts; they can contain peer names and Tailscale IPs. Required for a UI proof: 1. Drive the user path (window, menu action, or listed control), not an internal setter. 2. Capture the tree **before** the action and **after** it when the action changes state. 3. The after-tree must identify Trayscale (`frame 'Trayscale'` or `application 'trayscale'`) and the feature's end state. ```bash $CTRL snapshot --path .cursor/skills/verify-trayscale/artifacts//after.tree.txt $CTRL screenshot --path .cursor/skills/verify-trayscale/artifacts//after.png --allow-fail ``` GNOME Shell on this kind of session returns `AccessDenied` for `org.gnome.Shell.Screenshot`. `--allow-fail` writes `after.png.skipped.txt` and continues. An AT-SPI snapshot is enough when the screenshot is denied. Mocks: none. The window talks to the real `tailscaled`. If Tailscale is down, the offline page (`Not Connected`) is the correct UI. Do not fake IPN status. ## Cleanup ```bash $CTRL cleanup ``` This signals only the process group recorded at launch (the `dbus-run-session` pgid). It never `pkill trayscale`. Confirm the user's instance, if any, is still alive after cleanup. `--keep-run` leaves the run directory for log inspection. Artifacts stay either way. ## Helpers `scripts/control-trayscale` is executable. Commands: `launch`, `doctor`, `cleanup`, `actions`, `action`, `snapshot`, `screenshot`, `find`, `click`, `select`, `fill`, `press`, `wait`.