Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/drive-trayscale/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,15 @@ AT-SPI handles:
| `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 |
| `Exit node` | `list item` | First row in the self page `Options`. Its second `label` is the subtitle: `None`, the peer name, the Mullvad location (for example `🇸🇪 Stockholm, Sweden`), an IP, `Waiting for an exit node` while automatic selection has no node yet, or `Unavailable` when a manually chosen node is no longer in the netmap. When the node has a page the row also has an `image ''` child (the arrow). It exposes no AT-SPI action in either state, so `click` cannot activate it; use a pointer click |
| `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 |
| `Exit Node` | `combo box` | Inside `General`. The current choice is a `label` (`Previous exit node` or `Recommended exit node`) inside its `list item ''`. It exposes no AT-SPI action, so `click` cannot open it |
| (empty) | `toggle button` | Page menu button in the content header: `click --role "toggle button" --name "" --exact --index 1` (index 0 is the main menu). Insensitive when no peer page is visible |
| (empty) | `menu item` | Page menu items, in order: index 0 `Copy FQDN`, 1 `Send file...`, 2 `Send directory...`. Need `snapshot --max-depth 60` to see |
| `Select dir(s) to send to <host>` / `Select file(s) ...` | `dialog` | File chooser from the send items. `press --key /` opens its location field (`text ''`); `fill --role text --name "" --exact --value <path>`; then `click --role button --name Open` (file) or `Select` (directory) |
Expand Down Expand Up @@ -147,7 +149,7 @@ Without a GNOME session (for example Xvfb plus a private `dbus-daemon --session`
.agents/skills/drive-trayscale/scripts/tsharness/run.sh # prints READY, runs until interrupted
```

Flags: `-extra N` adds `peer-NN` peers (every third offline); `-mullvad N` adds Mullvad exit nodes in a few cities and grants this machine the `mullvad` capability, so the `Mullvad Exit Nodes` page appears; `-churn 20s` flips one peer's online state (and toggles an extra route on routers) per tick; `-receiver` also starts a second real `tailscaled`, hostname `receiver`, and grants both nodes Taildrop, so sends from Trayscale to `receiver` complete and land in `/tmp/tsharness/receiver/state/files/*/` (a directory arrives as `<name>.tar.zst`). Clear that directory before a run that checks it. Use `/tmp/tsharness/tailscale` for CLI changes against the fake node.
Flags: `-extra N` adds `peer-NN` peers (every third offline); `-mullvad N` adds Mullvad exit nodes in a few cities and grants this machine the `mullvad` capability, so the `Mullvad Exit Nodes` page appears; `-suggest-exit-nodes` makes exit nodes eligible for recommendation, so the `Recommended exit node` preference can resolve a node; `-remove-exit-after 60s` removes `bravo-exit` from this machine's netmap that long after `READY`, as control does when a node is deleted, and testcontrol sends this machine no further netmaps; `-churn 20s` flips one peer's online state (and toggles an extra route on routers) per tick; `-receiver` also starts a second real `tailscaled`, hostname `receiver`, and grants both nodes Taildrop, so sends from Trayscale to `receiver` complete and land in `/tmp/tsharness/receiver/state/files/*/` (a directory arrives as `<name>.tar.zst`). Clear that directory before a run that checks it. Use `/tmp/tsharness/tailscale` for CLI changes against the fake node.

It serves the LocalAPI on `/run/tailscale/tailscaled.sock`, Trayscale's default socket, so use it only where no system `tailscaled` runs (create `/run/tailscale` writable by you first). Its `go.mod` pins `tailscale.com` separately from the app's; `run.sh` builds `tailscale`/`tailscaled` at the app's version into `$DIR` (default `/tmp/tsharness`). Go skips dot-directories, so CI does not build it.

Expand Down
48 changes: 36 additions & 12 deletions .agents/skills/drive-trayscale/scripts/tsharness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,17 @@ type Peer struct {

// Harness is the reusable fixture.
type Harness struct {
Dir string // state, logs, binaries
Socket string // tailscaled LocalAPI socket
Tailscaled string // path to tailscaled binary
Tailscale string // path to tailscale CLI binary
Hostname string // this machine's hostname
Peers []Peer
Churn time.Duration // if nonzero, flip peer state this often
Mullvad bool // grant this machine the mullvad capability
Receiver bool // run a second real node, "receiver", that accepts Taildrop
Dir string // state, logs, binaries
Socket string // tailscaled LocalAPI socket
Tailscaled string // path to tailscaled binary
Tailscale string // path to tailscale CLI binary
Hostname string // this machine's hostname
Peers []Peer
Churn time.Duration // if nonzero, flip peer state this often
Mullvad bool // grant this machine the mullvad capability
Receiver bool // run a second real node, "receiver", that accepts Taildrop
SuggestExitNodes bool // allow fake exit nodes to be recommended automatically
RemoveExitAfter time.Duration // if nonzero, remove bravo-exit from this machine's netmap after this long
}

const capFileSharing = tailcfg.NodeCapability(tailcfg.CapabilityFileSharing)
Expand Down Expand Up @@ -128,6 +130,7 @@ func (h *Harness) Run(ctx context.Context) error {
ctrl.SetNodeCapMap(selfKey, selfCaps)
}
var peerKeys []key.NodePublic
var removeID tailcfg.NodeID
for i, p := range h.Peers {
before := map[tailcfg.NodeID]bool{}
for _, n := range ctrl.AllNodes() {
Expand Down Expand Up @@ -158,11 +161,20 @@ func (h *Harness) Run(ctx context.Context) error {
n.Created = time.Now().Add(-30 * 24 * time.Hour)
if p.ExitNode {
n.AllowedIPs = append(n.AllowedIPs, netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"))
if h.SuggestExitNodes && p.Location == nil {
n.HomeDERP = 1 // The harness's local DERP region.
}
}
n.AllowedIPs = append(n.AllowedIPs, p.Routes...)
n.PrimaryRoutes = append(n.PrimaryRoutes, p.Routes...)
ctrl.UpdateNode(n)
if p.ExitNode && h.SuggestExitNodes {
ctrl.SetNodeCapMap(n.Key, tailcfg.NodeCapMap{tailcfg.NodeAttrSuggestExitNode: nil})
}
peerKeys = append(peerKeys, n.Key)
if p.Host == "bravo-exit" {
removeID = n.ID
}
}
}
if h.Receiver {
Expand All @@ -185,6 +197,14 @@ func (h *Harness) Run(ctx context.Context) error {
if h.Churn > 0 {
go churn(ctx, ctrl, peerKeys, h.Churn)
}
if h.RemoveExitAfter > 0 {
time.AfterFunc(h.RemoveExitAfter, func() {
// Real control sends a delta like this when a node is deleted.
// testcontrol then stops sending this node full netmaps.
ok := ctrl.AddRawMapResponse(selfKey, &tailcfg.MapResponse{PeersRemoved: []tailcfg.NodeID{removeID}})
log.Printf("removed bravo-exit from netmap: %v", ok)
})
}
<-ctx.Done()
_ = cli("down")
return nil
Expand Down Expand Up @@ -274,6 +294,8 @@ func main() {
churnEvery := flag.Duration("churn", 0, "flip one peer's state this often (0 disables)")
receiver := flag.Bool("receiver", false, "run a second real node, receiver, that accepts Taildrop from this machine")
mullvad := flag.Int("mullvad", 0, "Mullvad exit nodes to register (also grants the mullvad capability)")
suggestExitNodes := flag.Bool("suggest-exit-nodes", false, "allow fake exit nodes to be recommended automatically")
removeExitAfter := flag.Duration("remove-exit-after", 0, "remove bravo-exit from this machine's netmap this long after READY (0 disables)")
flag.Parse()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
Expand All @@ -288,9 +310,11 @@ func main() {
{Host: "echo-down", Online: false, ExitNode: true},
{Host: "delta-router", Online: true, Routes: []netip.Prefix{netip.MustParsePrefix("192.168.50.0/24")}},
},
Churn: *churnEvery,
Mullvad: *mullvad > 0,
Receiver: *receiver,
Churn: *churnEvery,
Mullvad: *mullvad > 0,
Receiver: *receiver,
SuggestExitNodes: *suggestExitNodes,
RemoveExitAfter: *removeExitAfter,
}
for i := range *extra {
h.Peers = append(h.Peers, Peer{Host: fmt.Sprintf("peer-%02d", i), Online: i%3 != 0})
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/verify-trayscale/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Keep implementation details out of the map. Name only user paths, stable handles

- [Main window](./main-window.md) covers the window title, sidebar sections, the Show Offline Peers toggle, and connected vs not-connected chrome.
- [This machine](./this-machine.md) covers the self page: hostname, Tailscale IPs, options, files, routes, and network check.
- [Exit node](./exit-node.md) covers the Exit Node preference, turning exit node use on from the menu, and the self page row that opens the node in use.
- [About](./about.md) covers the About dialog from the app menu action.
- [Preferences](./preferences.md) covers the Preferences dialog and its General and Taildrop groups.
- [Peer search](./peer-search.md) covers opening search from the toolbar button and from Ctrl+F, filtering, the empty result, and closing search.
Expand Down
49 changes: 49 additions & 0 deletions .agents/skills/verify-trayscale/features/exit-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Exit node

The `Exit Node` preference picks how Trayscale chooses an exit node when the user turns exit node use on from Trayscale's menu: `Previous exit node` (the default) or `Recommended exit node`. The self page shows the exit node in use, and its row opens that node's page.

## Sub-features

- `exit-node-preference` shows the `Exit Node` combo row in the Preferences `General` group with `Previous exit node` selected by default.
- `exit-node-previous` turns exit node use on from the menu and gets the last node back, with automatic selection off.
- `exit-node-recommended` turns exit node use on from the menu and lets Tailscale pick a node automatically.
- `exit-node-row` shows the self page `Exit node` row: `None`, the peer name, the Mullvad location, a bare IP, `Waiting for an exit node` while Recommended has not picked a node yet, or `Unavailable` when a manually chosen node has left the netmap.
- `exit-node-open-peer` opens the exit node's peer page from that row.
- `exit-node-open-mullvad` opens the Mullvad page with the node's country expanded and its row in view.

## How to get to it (user POV)

- Open Preferences from the main menu and use `Exit Node` in `General`.
- Turn exit node use on or off from the main menu or the tray menu.
- Open the self page under `This machine`. `Exit node` is the first row in `Options`.

## Driving it with control-trayscale

Preconditions:

- `doctor` reports `frame 'Trayscale'` and `gsettings_backend` equal to `memory`.
- Before every step that changes Tailscale state, `check-fake-tailnet` must report `FAKE`. This recipe runs `use_exit_node` and `Use as exit node` only against tsharness. Never run these steps against the user's real tailnet.
- The test node is connected and does not advertise itself as an exit node.
- Run tsharness with `-suggest-exit-nodes` so Recommended can resolve a node. Add `-mullvad N` for the Mullvad checks.
- The `Exit Node` combo box and the `Exit node` row expose no AT-SPI action. Changing the preference and activating the row need a pointer click (for example `xdotool` on X11). Without one, run only the steps that use actions and the CLI.
- `tailscale debug prefs` omits `AutoExitNode` when it is empty. Treat a missing key as empty.

- **Preference.** Run `action preferences`, then snapshot `prefs.tree.txt`. `grouping 'General'` contains `combo box 'Exit Node'`, and its `list item ''` holds `label 'Previous exit node'`.
- **Row, none in use.** On the self page, snapshot `none.tree.txt`. `list item 'Exit node'` has `label 'None'` and no `image ''` child.
- **Previous.** Use a peer page's `Use as exit node` switch to select `bravo-exit`, then run `action use_exit_node --i-mean-it` twice (off, then on). Check `tailscale debug prefs` against the harness socket: `ExitNodeID` names `bravo-exit` and `AutoExitNode` is empty.
- **Recommended.** Click `combo box 'Exit Node'` and choose `Recommended exit node` from the popover, turn exit node use off and on with `use_exit_node`, and check that `AutoExitNode` is `any`. The `Exit node` row names whatever node Tailscale picked. Without `-suggest-exit-nodes`, `ExitNodeID` stays `auto:any` and the row says `Waiting for an exit node`.
- **Previous after Recommended.** Switch the preference back to `Previous exit node`, then toggle `use_exit_node` off and on. `AutoExitNode` is empty and `ExitNodeID` is the node automatic mode had chosen.
- **Node leaves the netmap.** Run tsharness with `-remove-exit-after 60s` and select `bravo-exit` with `$TS set --exit-node=bravo-exit` before the timer fires. After the harness logs `removed bravo-exit from netmap`, `ExitNodeID` still names it and `use_exit_node` is still on. The row says `Unavailable` and has no `image ''` child. With `-suggest-exit-nodes` and `$TS set --exit-node=auto:any` instead, the row says `Waiting for an exit node`. Restart tsharness afterwards, because testcontrol stops updating the netmap.
- **Open peer.** With `bravo-exit` in use, the row has `label 'bravo-exit'` and an `image ''` child. Click the row and snapshot `peer.tree.txt`. The visible page is `bravo-exit`'s peer page, with `switch 'Use as exit node' states=checked`.
- **Open Mullvad.** Select a Mullvad node with `$TS set --exit-node=<ip>`, activate the `Exit node` row, wait at least 300 ms, and snapshot `mullvad.tree.txt`. The Mullvad page is visible (its sidebar label becomes the location, for example `🇸🇪 Stockholm, Sweden`), the search `entry ''` is empty, the country is `list item '<flag> <country>' states=expanded`, and the node is `switch '<city>' states=checked,focused`.
- **Proof.** Keep every snapshot above with the `tailscale debug prefs` output next to each state change.

## Gotchas

- The preference applies only when Trayscale turns exit node use on. `tailscale set` and other clients ignore it.
- If exit node use is turned off outside Trayscale while automatic mode is on, Tailscale remembers automatic mode instead of a node, and Previous can restore only what Tailscale remembered. Turn it off from Trayscale when testing Previous.
- To get back to the self page, click `This machine`'s sidebar row. `select` marks it selected but does not change the page.
- Recommended can pick a different node on each run. Assert `AutoExitNode` and that the row names a node, not a specific server.
- Sharee nodes and nodes known only by IP have no page, so their row is not activatable.
- The Mullvad page expands the country first and focuses the node row after the expand animation. A snapshot taken right away may show only the country expanded.
- A private D-Bus session isolates the app, not the Tailscale socket. Use a separate test machine or a mount namespace exposing only the harness socket at Trayscale's default path.
4 changes: 2 additions & 2 deletions .agents/skills/verify-trayscale/features/preferences.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Preferences

Preferences is a dialog with a General group (tray icon and polling interval) and a Taildrop group (auto-save received files).
Preferences is a dialog with a General group (tray icon, polling interval, and exit node selection) and a Taildrop group (auto-save received files).

## Sub-features

- `prefs-open` opens the Preferences dialog from the app menu action.
- `prefs-general` shows a `General` group.
- `prefs-general` shows a `General` group, including the `Exit Node` combo row. See [Exit node](./exit-node.md) for what it does.
- `prefs-taildrop` shows a `Taildrop` group.

## How to get to it (user POV)
Expand Down
2 changes: 2 additions & 0 deletions .agents/skills/verify-trayscale/features/this-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This machine is the self peer page. It shows the hostname, Tailscale IPs with co
- `self-identity` shows the hostname and MagicDNS name on the page.
- `self-ips` lists each Tailscale address under `Tailscale IPs`.
- `self-options` shows `Advertise exit node`, `Allow LAN access`, `Accept routes`, and `Accept DNS`.
- `self-exit-node` shows the `Exit node` row first in `Options`, with `None` or the node in use. See [Exit node](./exit-node.md) for activating it.
- `self-files` shows `No incoming files.` when none are waiting.
- `self-routes` shows `No advertised routes.` when none are advertised.
- `self-netcheck` shows `Last run` with `Never` until a check runs.
Expand All @@ -27,6 +28,7 @@ Preconditions:
- **Identity.** Run `control-trayscale snapshot --path .agents/skills/verify-trayscale/artifacts/this-machine/page.tree.txt`. The `panel` named after this machine's sidebar label has a hostname label and a MagicDNS label. On Tailscale's coordination server the MagicDNS label ends in `.ts.net.`; other control servers can use another name.
- **IPs.** That snapshot contains `grouping 'Tailscale IPs'` and at least one `list item` that is not `No addresses.`. Each address has a `button` with the same name as the address.
- **Options.** The snapshot contains `switch 'Advertise exit node'`, `switch 'Allow LAN access'`, `switch 'Accept routes'`, and `switch 'Accept DNS'`. Do not toggle them.
- **Exit node.** The snapshot contains `list item 'Exit node'` whose second label is `None` or the node in use. Do not activate it here; [Exit node](./exit-node.md) covers that.
- **Empty files and routes.** The snapshot contains `No incoming files.` and `No advertised routes.` unless the live node actually has files or routes.
- **Netcheck.** The snapshot contains `grouping 'Network Check'`, `label 'Last run'`, and `label 'Never'` if no check has run in this session.
- **Proof.** Keep `page.tree.txt`. It must include `frame 'Trayscale'`, `grouping 'Tailscale IPs'`, and the four option switches.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Tailscale Config

Trayscale interfaces with the Tailscale daemon, `tailscaled`, to perform many of its operations. In order for this to work, the daemon must have been configured with the current user as the "operator". To do this, run `sudo tailscale set --operator=$USER` from the command-line at least once manually.

In Preferences, **Exit node selection** chooses what happens when **Use Exit Node** turns on: the previous exit node, or Tailscale's [recommended exit node](https://tailscale.com/docs/features/exit-nodes/auto-exit-nodes). The self page shows which exit node is in use and can open that node's page.

Installation
------------

Expand Down
13 changes: 13 additions & 0 deletions dev.deedles.Trayscale.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@
destination is configured.
</description>
</key>
<key name="exit-node-selection" type="s">
<choices>
<choice value="previous"/>
<choice value="recommended"/>
</choices>
<default>'previous'</default>
<summary>Exit node to use when turning on exit node use</summary>
<description>
Either "previous" to reuse the most recently used exit node or
"recommended" to let Tailscale pick the recommended exit node
and keep tracking it.
</description>
</key>
</schema>
</schemalist>

Expand Down
8 changes: 8 additions & 0 deletions internal/gutil/data.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package gutil

import (
"time"

"sync"

"codeberg.org/puregotk/puregotk/v4/gio"
Expand Down Expand Up @@ -61,6 +63,12 @@ func IdleAdd(f func()) {
glib.IdleAdd(&idle, Hold(f))
}

// TimeoutAdd runs f once on the main loop after the given delay. It is
// safe to call from any goroutine.
func TimeoutAdd(delay time.Duration, f func()) {
glib.TimeoutAdd(uint32(delay.Milliseconds()), &idle, Hold(f))
}

// AsyncCallback is the GAsyncReadyCallback for functions passed to
// Async. Pass it along with Async's ID as the user data.
var AsyncCallback gio.AsyncReadyCallback = func(_, res, id uintptr) {
Expand Down
Loading
Loading