Ghost Assembly QuickTS

GNOME Shell extension

QuickTS

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.

  • GNOME Shell 50
  • Tailscale 1.82 or later
  • GPL-3.0-or-later

Install Source on GitHub

On this page
  1. 01Overview
  2. 02Install
  3. 03The menu
  4. 04Preferences
  5. 05Keyboard
  6. 06Security
  7. 07Architecture
  8. 08The LocalAPI
  9. 09Testing
  10. 10Packaging
  11. 11Releasing
  12. 12Development

01

Overview

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.

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.

Ping without a terminal

The daemon pings for you, so the answer says whether the path is direct or relayed — usually the real question.

Taildrop, both ways

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.

Health you can act on

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.

Honest state

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.

Routing

Offer this machine as an exit node from the menu, and advertise subnet routes from the preferences window.

Multiple accounts

Switch between Tailscale profiles. The active one is read from the daemon, and a switch made in a terminal shows up here too.

A menu that fits

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

Install

Requires

  • GNOME Shell 50.
  • Tailscale 1.82 or later — the first release whose /status says which peers can receive a file. Developed and checked against 1.102.
  • Your user set as the Tailscale operator (below).

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.

Shell
$ sudo tailscale set --operator=$USER

From a release

Shell
$ curl -LO https://github.com/Ghost-Assembly/quickts/releases/latest/download/[email protected]
$ gnome-extensions install --force [email protected]
$ gnome-extensions enable [email protected]

From a clone

Shell
$ 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

Preferences

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

Keyboard

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

Security

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.

  • Request paths are built in one pure module, where node ids and file names are percent-encoded before they reach a URL.
  • Sending streams files from disk to a peer the daemon lists in /file-targets at the moment of sending, whichever row the send started from.
  • Receiving checks the sender’s file name before it becomes a path: one containing / 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.
  • Tailnet data is text. Peer names, tags and health messages come from the coordination server and are never used to build a command or markup.
  • Nothing identifying is logged. Peer names, addresses, keys and file names stay out of the journal; a failed send is reported in a notification, which GNOME does not log.

Report a vulnerability privately through GitHub’s advisory form; the security policy has the details.

07

Architecture

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.

exit-node-section.js · device-section.js · taildrop-section.jsOne plain class per menu section, each with its own rows and its own generation counter, built from menu-items.js and navigable-section.js. The toggle builds them, passes what they need and tells them when the state moves or the menu opens.
extension.js · prefs.jsThe cancellation token and the wiring; and the preferences window, which uses io.js to reach the daemon for the routes field.

Every module

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 bus is a signal, not a source

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.

Cancellation settles the awaiter

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.

The model is not a GObject

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.

One shape for a peer

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

The LocalAPI

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

Testing

  1. 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.
  2. 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.
  3. 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.
  4. 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

Packaging

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

Releasing

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.

Shell
$ git tag -a v0.1.1 -m 'release v0.1.1'
$ git push origin v0.1.1

12

Development

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