Exit nodes
Pick one, clear it, or take the daemon’s suggestion. Mullvad’s several thousand nodes are grouped by country instead of listed as rows.
GNOME Shell extension
Tailscale in Quick Settings: toggle the tailnet, pick an exit node, switch profiles, ping nodes and send or receive Taildrop files.
Toggle the tailnet, pick an exit node, switch profiles, ping a device, copy its address and send or receive files over Taildrop — without leaving the panel, and without the menu ever claiming a state the daemon does not have.
01
QuickTS adds one tile to Quick Settings. Clicking it brings the
tailnet up or down — or starts a login, if the daemon is waiting
for one — and its arrow opens everything else. It talks to
tailscaled the way the tailscale
command does, so there is nothing else to install.
Pick one, clear it, or take the daemon’s suggestion. Mullvad’s several thousand nodes are grouped by country instead of listed as rows.
The daemon pings for you, so the answer says whether the path is direct or relayed — usually the real question.
Send files to any peer the daemon lists as able to receive, and save the ones that arrive. The file chooser is the desktop portal, drawn outside the Shell.
The daemon’s warnings, collapsed out of the way. One that carries a link opens it; a permission failure offers the command that fixes it.
The tile follows the daemon, never the click: it cannot read “on” against a backend waiting for a login, or against a change the daemon refused.
Offer this machine as an exit node from the menu, and advertise subnet routes from the preferences window.
Switch between Tailscale profiles. The active one is read from the daemon, and a switch made in a terminal shows up here too.
Sized from the monitor’s work area, so a large tailnet scrolls inside the menu instead of running off the bottom of the screen.
02
/status says which peers can receive a
file. Developed and checked against 1.102.
Be the Tailscale operator. Without it the
daemon refuses every change with a 403, and
QuickTS can only tell you so — the menu offers the command,
and a click copies it.
$ sudo tailscale set --operator=$USER
$ curl -LO https://github.com/Ghost-Assembly/quickts/releases/latest/download/[email protected] $ gnome-extensions install --force [email protected] $ gnome-extensions enable [email protected]
$ just setup $ just install $ just enable
Log out and back in. A newly installed extension is only picked up when the Shell next starts. On Wayland that means a new session.
04
Open them from the Extensions app, or with
just prefs from a clone.
| Setting | Default | Notes |
|---|---|---|
| Show offline devices | On | Lists peers that are not currently reachable. |
| Show Mullvad exit nodes | On | Grouped by country. A tailnet with Mullvad has thousands. |
| Maximum height | 0 | Logical pixels, up to 2000. Zero uses whatever room the screen leaves below the menu. |
| Advertised subnets | None |
Comma-separated prefixes, such as
192.168.1.0/24. Applied on Enter;
anything that is not a prefix is refused and said
so.
|
| Open the menu | Unbound | See Keyboard. |
Advertised subnets is not a stored setting.
It reads and writes AdvertiseRoutes on the
daemon. A settings key mirroring it would be a second source
of truth, drifting the first time anyone ran
tailscale set.
05
One shortcut opens Quick Settings with QuickTS expanded. It is unbound by default, so it cannot collide with a GNOME shortcut; choose one in the preferences window, which records the next key combination you press.
| While recording | Does |
|---|---|
| Super+T | Binds it. Any key held with Ctrl, Alt or Super will do. |
| Backspace | Unbinds the shortcut |
| Esc | Cancels, changing nothing |
A bare key is not accepted: it would be taken from every application. Shift alone is accepted only with a key that types nothing and that editing text does not need, such as Shift+F5. Shift+A is how a capital A is typed, and Shift with an arrow key, Home, End, Page Up, Page Down, Tab, Enter or a dead key selects text, moves focus, ends a line or types an accented letter: the keys GNOME Settings refuses, plus dead keys. The shortcut does nothing on the lock screen or the login screen.
It is stored under quickts-open-menu. The prefix
matters: Mutter keeps one table of keybinding names for the
whole Shell and refuses a name that is already registered, so a
plain open-menu could be lost to any extension that
chose the same word.
06
QuickTS talks to tailscaled over its local socket,
with the same LocalAPI the tailscale command uses.
Anything the menu can do, you could already do from a terminal
as the operator; it grants no privilege you did not have.
/file-targets at
the moment of sending, whichever row the send started
from.
/ or a NUL byte, or starting with
., is refused and left with the daemon. The
file is created exclusively in your download folder — an
existing file or a planted symlink is never overwritten
or followed; the save moves on to
name (1) — and streamed to disk, never held
in the Shell’s memory.
Report a vulnerability privately through GitHub’s advisory form; the security policy has the details.
07
Every decision lives in a pure module that imports nothing from
GNOME, so the suite tests them on plain Node. The files that
touch
gi:// or resource:/// only carry
decisions out.
The diagram above shows the main ones. Every file under
modules/, what it decides, and which other modules
it imports — everything not listed here imports nothing of its
own:
| Module | Job | Imports |
|---|---|---|
bus.js |
Reads the IPN bus strictly as a change signal — that something changed, never what; offers no way to read a peer's value out of it. | — |
cancel.js |
One cancellation token per enable: canceling it aborts every request and settles every pending wait. | — |
collate.js |
How names are ordered wherever QuickTS sorts them. | — |
device-section.js
|
The Devices submenu: ping a peer, copy its address or DNS name, send it files. |
menu-items.js,
navigable-section.js,
ping.js, settings.js,
taildrop.js, text.js
|
errors.js |
Why a request to tailscaled failed, as a
REASON value rather than a string.
|
text.js
|
exit-node-section.js
|
The Exit node submenu: none, the daemon's suggestion, the tailnet's own candidates, Mullvad's grouped by country. |
menu-items.js, mullvad.js,
navigable-section.js,
settings.js, text.js
|
health.js |
What the menu should say about the daemon's warnings and its backend state. |
errors.js, state.js
|
inbox.js |
Taildrop's receiving half: which waiting file names are safe to use, and the candidate names a save falls back to. | — |
io.js |
The only module that touches Soup, Gio and the portal; carries out a decision, never makes one. |
cancel.js, errors.js,
inbox.js, localapi.js
|
layout.js |
How tall the menu is allowed to be. | — |
localapi.js |
Every request QuickTS makes to tailscaled's LocalAPI: the path, the body, what gets percent-encoded. | — |
menu-items.js |
Menu rows and small helpers every section of the menu shares. |
errors.js, text.js,
warnings.js
|
model.js |
The store: everything QuickTS knows, and everything it can be asked to do. |
bus.js, cancel.js,
errors.js, health.js,
inbox.js, localapi.js,
peers.js, ping.js,
reconnect.js, routes.js,
state.js, taildrop.js,
timing.js
|
mullvad.js |
Grouping Mullvad's several thousand exit nodes by country. |
collate.js
|
navigable-section.js
|
A submenu that swaps between a list and one entry's detail. |
menu-items.js
|
panel.js |
The actor tree: the tile, the keybinding, and the menu sections it builds. |
device-section.js,
exit-node-section.js,
health.js, layout.js,
menu-items.js, routes.js,
settings.js,
taildrop-section.js,
text.js
|
peers.js |
The one shape a node has in QuickTS — the only file that reads a raw peer field. |
collate.js
|
ping.js |
Reading a disco ping result: the latency, and whether it went direct or through a relay. | — |
reconnect.js |
Keep a stream open for as long as the token is live. |
cancel.js
|
routes.js |
Advertising routes: acting as an exit node and offering subnets, as one preference. | — |
settings.js |
The settings QuickTS has, named once: the gschema
keys and the label/detail text prefs.js
shows for each.
|
— |
shortcuts.js |
The rules for capturing a keyboard accelerator. | — |
state.js |
The whole of what QuickTS knows, as one immutable snapshot. |
errors.js, peers.js
|
taildrop-section.js
|
Taildrop's two halves: the Send files submenu, and received files waiting to be saved. |
inbox.js, menu-items.js,
taildrop.js, text.js
|
taildrop.js |
Who can receive a file, and why the rest cannot. |
collate.js
|
text.js |
Fills a translated sentence's %s and
%d in order, without reading
$ patterns in the values.
|
— |
timing.js |
When to retry, and when to stop coalescing. | — |
warnings.js |
Turning one of the daemon's health messages into something the menu can show, pulling out a URL when it carries one. | — |
The IPN bus says that something changed;
/status and /prefs say what.
No value is ever read out of a notification: a peer on the bus
and a peer in /status are different shapes, and two
translations of one dataset cannot be kept in agreement.
modules/bus.js offers no way to get a peer out, and
a test pins its exports to keep it that way. While the menu is
closed the re-read leaves out the peer map, most of the payload
on a large tailnet.
After an outage the bus reports only what changes from then on, so the first message on a new connection triggers one full re-read of preferences, profiles and peers.
One token per enable. Canceling it aborts every request, settles
every pending wait and drops every GLib source — the last two in
the same callback, deliberately. A timer removed without its
awaiter settled leaves an await that never returns,
keeping its whole call stack alive for the rest of the session.
It is a plain observable: subscribe() returns its
own disposer, and subscribers receive a whole snapshot plus a
list of what moved. There are no properties to bind and no
notify:: ordering to get wrong.
modules/peers.js is the only file that reads a raw
peer field. Whether a node is the exit node comes from the
preferences, so the tick follows the click rather than lagging
the network, and a node shared in from another tailnet keeps
enough of its name to stay distinguishable. Pure modules decide;
modules/panel.js and its section modules choose the
wording, so translators see whole sentences.
08
QuickTS tries /run/tailscale/tailscaled.sock, then
/var/run/…, and looks again on each connection, so
installing or starting Tailscale after you log in is picked up.
| Endpoint | Used for |
|---|---|
GET /status |
Peers, backend state, health and the login URL; with
?peers=false while the menu is closed.
|
GET, PATCH /prefs
|
Every switch, the exit node and the routes. A PATCH answers with the result, so a change never waits on the bus. |
GET /watch-ipn-bus
|
The change signal, with the initial-state and peer-changes bits. Since Tailscale 1.100 a Linux daemon announces peers no other way; the rate-limit bit is refused alongside them, so QuickTS coalesces bursts itself. |
GET /profiles/,
/profiles/current,
POST /profiles/{id}
|
The accounts, which one is in use, and switching. |
GET /file-targets,
PUT /file-put/…
|
Sending, streamed from disk. Asked only while the tailnet is up; the daemon refuses it otherwise. |
GET /files/, GET,
DELETE /files/{name}
|
Receiving, streamed to disk. The file is written
before the daemon is told to forget it. If the
daemon will not, the file still counts as saved and
is not offered again while QuickTS runs. It stays in
Tailscale’s inbox, where
tailscale file get can clear it, and
after a reload it can be offered again.
|
POST /ping |
A disco ping, which reports the route as well as the latency. |
GET /suggest-exit-node
|
The exit node the daemon would pick. |
POST /login-interactive
|
Signing in. The URL opens in a browser once, and only when asked for. |
Signing out is deliberately absent. A login
unblocks the menu; a logout invalidates the node key and
needs another browser round trip to undo. That does not
belong two clicks from the volume slider, and Quick Settings
has nowhere sensible to confirm it. Use
tailscale logout.
09
just test runs the unit
suite on plain Node. GNOME imports resolve to recording
stubs through Vitest’s aliases, and the fake daemon
behaves like a Linux tailscaled: it honors
the subscription mask and never sends a runtime
netmap.
just localapi-check runs
modules/io.js under plain
gjs against the real daemon. A stub of
libsoup can only prove the stub behaves; this is what
notices Tailscale changing its JSON.
just test-live boots a
headless gnome-shell and asserts that
enable, disable and enable again leave no JavaScript
error, orphaned source or lifetime warning, then checks
the bundle and runs the LocalAPI check. It needs a real
Shell and a running tailscaled.
just test-docs checks
this site in Chromium and Firefox: accessibility in both
color schemes, a 360px phone, no scripts and no request
to another origin.
Fixtures are written from the observed schema rather than captured. A real response carries node keys, addresses, the tailnet name and an account email, and this repository is public.
10
just build makes the zip with plain
zip, because gnome-extensions ships
inside the gnome-shell package and CI has no GNOME.
scripts/pack-check.sh keeps the official tool as
the authority, diffs the two listings and confirms every shipped
icon decodes — an icon that fails to load is silent.
The zip holds metadata.json,
extension.js, prefs.js,
modules/, schemas/ and
icons/. There is no stylesheet: nothing QuickTS
draws needs styling beyond the Shell’s own. The uuid is written
down once, in metadata.json, and everything else
reads it from there.
11
Set version-name in metadata.json and
version in package.json, commit, then
tag and push. The release workflow checks the tag against both
files before building, so it cannot publish a tag whose version
disagrees with either.
$ git tag -a v0.1.1 -m 'release v0.1.1' $ git push origin v0.1.1
12
just # list every recipe just test # unit suite just test-docs # this site, in Chromium and Firefox just lint # eslint, prettier, gschema, shellcheck just ci # what CI runs: lint, tests, docs, security, build just test-live # headless Shell, bundle and LocalAPI checks just localapi-check # modules/io.js against this machine's daemon just run # a Shell in a window (needs mutter-devkit) just docs # serve this site on localhost:8000
Toolchain versions live in mise.toml and commands
in the justfile; just setup installs
both and checks for gjs,
glib-compile-schemas and gnome-shell,
which have to match the running Shell.