Skip to content

fix: keep the daemon reachable on a refused change, classify local save errors, and make every string translatable - #20

Merged
napalm255 merged 19 commits into
mainfrom
fix/review-findings
Sep 27, 2026
Merged

napalm255 merged 19 commits into
mainfrom
fix/review-findings

Conversation

@napalm255

Copy link
Copy Markdown
Collaborator

Fixes the QuickTS findings from a code-review sweep of the quick* extensions.

Bugs

  • A refused change no longer marks the daemon down (model.js, state.js, panel.js):
    • An HTTP or protocol refusal of a PATCH keeps reachable and records refusedReason, which the problems row shows. Only transport failures still count as lost contact.
    • Every failed change bumps refusedCount, so a switch always flips back, even on a repeated identical failure or while the daemon is already unreachable.
  • Local save errors are reported as file errors (errors.js, io.js): a read-only, missing or full Downloads folder now reads "Could not save the file." (REASON.LOCAL_FILE), not a Tailscale install or operator problem.
  • Requests no longer queue behind the bus (io.js): libsoup's default of 2 connections per host meant the watch-ipn-bus long poll left one connection for everything else, so a big Taildrop transfer blocked toggles. The session now allows 8. scripts/localapi-check.js checks parallel reads while long polls are open.
  • "Copy DNS name" copies the daemon's DNSName, rather than rebuilding it. The rebuilt name was wrong for shared-in and Mullvad nodes.
  • A requested login is disarmed once no login is needed, so a later re-auth URL can't open a browser nobody asked for.
  • Placeholders are filled without $ expansion (new modules/text.js fill, byte-identical to QuickClip's and QuickMusic's). A file named a$'b.txt now renders as itself.

i18n

  • problemMessage and settingText hold literal _() strings in import-free modules. prefs.js uses them instead of _(variable), and the literal-string scanner now also reads prefs.js and extension.js. The unused messageFor and problem.message are gone.
  • A translator comment fixes the placeholder order of "Sent %d file to %s".

Other

  • derive() skips re-sorting the nodes when no node was re-marked.
  • The io.js status and Content-Type decisions moved into tested pure functions (reasonForStatus, isJsonContentType).
  • Drift with QuickTiler:
    • the same Shift-only accelerator rule (Shift+F5 allowed, Shift+A refused);
    • the same map/unmap timing for inhibiting system shortcuts in the capture dialog;
    • the toggle is torn down from its 'destroy' signal.
  • Docs: README (a non-operator gets a 403 on changes, and the socket is not refused), AGENTS.md, and the docs site's module table.

Verification

  • just ci passes: template-check, lint, 851 Vitest tests, 50 Playwright docs tests, the security scans and the build.
  • just test-live passes in a headless gnome-shell.
  • just localapi-check passes against a live tailscaled.
  • To check by hand: in just prefs, capture a shortcut while pressing a GNOME-bound combo, close the dialog with Esc and with the close button, and confirm the system shortcuts still work.

Noted, not fixed (pre-existing): if the Taildrop DELETE fails after the file was written (model.js saveFile), the saved path is discarded, and a retry saves "name (1)".

A received file that could not be written here (a full disk, a
read-only or missing download directory, a name too long for the file
system) was reported as the Tailscale daemon being unreachable, because
io.js mapped every Gio code as if it came from the socket.

Add REASON.LOCAL_FILE ("Could not save the file.") and move the Gio
code to REASON mapping into modules/errors.js as reasonForIoError,
which is told what the failed operation touched. saveFile now blames
the file for every create_async failure, for a splice_async failure
with NO_SPACE, READ_ONLY, PERMISSION_DENIED or NOT_FOUND, and for
running out of candidate names.
One refused PATCH (an HTTP error or an unusable answer) went through
#fail and marked the whole daemon unreachable: the subtitle said the
daemon refused the request and the tile read off, over a menu that was
otherwise working. It was also the only thing that set a flipped
switch back, so a second refusal with the same reason changed nothing
in the state and left the switch showing what the daemon refused.

#patch now calls #fail only for SOCKET_MISSING, CONNECTION_REFUSED,
UNKNOWN and PERMISSION_DENIED, the last so the operator row still
shows. Any other reason is recorded with applyRefusal, which sets
refusedReason and bumps refusedCount so that every refusal, a repeat
included, is a change the panel hears about; _syncOptions then sets
the switch back from the preferences that still hold, and the problems
section says why. applyPrefs clears refusedReason. #patch resolves to
{ error } with the reason, or '' once applied.
prefs.js handed gettext variables: every settings row title and
subtitle from SETTINGS' own English, and every routes failure as
messageFor's composed sentence. xgettext only extracts literals, so
none of them could ever reach the translation template, and the i18n
check never saw it because it scanned modules/ only.

Move problemMessage into import-free modules/errors.js, which
prefs.js can reach, and re-export it from menu-items.js for the menu
sections. Give modules/settings.js a settingText(key, _) of literal
_() calls, leaving SETTINGS with the key and its type. The i18n check
now also reads prefs.js and extension.js.

messageFor and problemOf's message field had no reader left but their
own tests, so both are gone. AGENTS.md now says what the i18n check
scans, no longer claims Vitest runs prefs.js, and names settingText in
the settings-key procedure.
io.js is excluded from coverage on the grounds that it makes no
decisions, but it still decided which HTTP status means a missing
operator and whether a Content-Type is JSON, so neither was covered by
anything but the live daemon check.

Move them into pure functions with tests: reasonForStatus in
modules/errors.js (2xx is no error, 401 and 403 are PERMISSION_DENIED,
anything else HTTP) and isJsonContentType in modules/localapi.js,
which now also owns JSON_TYPE. io.js keeps the Soup and Gio calls, and
its header says what is left in it. The coverage comments in
vitest.config.js and sonar-project.properties no longer claim the two
exclusion lists are identical: vitest's also has tests/**.
A change that failed with the reason already recorded, such as
CONNECTION_REFUSED while tailscaled is stopped or a 403 for a user who
is not the operator, went through applyError into a state identical to
the one before. No subscriber was told, so the switch that was flipped
kept showing a value the daemon never applied, from the very first
flip.

Count every failed change in refusedCount, not only refusals. A
transport failure now commits applyChangeError, which is applyError
plus the count in one snapshot, so the panel always re-syncs and sets
the switch back. refusedReason is still set only for a refusal.
…ections

libsoup 3.6 allows two connections per host by default, and the IPN
bus holds one of them for as long as the extension is enabled. One
more long request beside it, such as a Taildrop send or a ping to a
peer that never answers, took the other, and every /status, /prefs
and change queued behind it until that finished.

Raise max-conns-per-host and max-conns to 8 on the session. There is
only one host, the Unix socket, so the two limits are the same.
localapi-check now opens two bus streams against the real daemon and
requires four parallel /status requests to answer within five seconds.
…ally

Copy DNS name joined the display name to this tailnet's MagicDNS
suffix. A node shared in from another tailnet, or one of Mullvad's,
does not live under that suffix, so it copied a name that resolves
nowhere, such as laptop.other-tailnet.example-tailnet.ts.net. A peer
with no DNSName got one built from its HostName.

normalizePeer now keeps dnsName, the peer's own DNSName without its
trailing dot, or '' when there is none. The device section copies it
and hides the row when it is empty, and sameNodes compares it so a
changed name redraws the menu.
_loginRequested was cleared only when the menu opened the auth URL
itself or the login request failed. A login that went through without
the URL reaching the menu, such as `tailscale up` in a terminal, left
it set, and the next AuthURL to appear for any reason, a reauth hours
later, opened a browser nobody asked for.

sync() now clears the flag whenever the backend no longer needs a
login.
…lues

Every translated sentence was filled with .replace('%s', value), and a
replacement string reads $&, $` and $' as patterns. A received file
named draft$'s.txt was reported as saved to .../drafts.txt, and a node
or file name with $& or $` came out spliced with the sentence around
it. The chained .replace calls also rescanned what they had already
inserted, so a first value containing %s took the second value.

Add modules/text.js with fill(template, ...values), which imports
nothing and makes one pass with a function replacer. Its body is the
one QuickClip's listing.js and QuickMusic's panel.js carry, so the
copies agree. Every call site in modules/ and prefs.js uses it now,
including problemMessage in errors.js, which still imports nothing but
text.js. The _() arguments stay literals, so the i18n check is
unchanged.
derive() sorted the node list on every apply, so each preferences,
profiles or error update, and every status read, allocated and sorted
a new array that could not have changed order, and changed() then
walked it field by field to find nothing had moved.

Map first, and keep state.nodes itself when no node was re-marked:
the list already arrives sorted, from normalizePeers or the last
derive. sameBy now treats the same array as equal before comparing
elements, so an unchanged list costs nothing to compare.
isValidBinding refused every Shift-only combination, so Shift+F5 was
as unbindable as Shift+A even though F5 types nothing on its own.
Adopt QuickClip's rule, which QuickTiler now shares: Shift alone is
fine when the key's code point is absent or a control character, and
refused otherwise. prefs.js passes Gdk.keyval_to_unicode(keyval)
alongside the other Gdk/Gtk values the rule needs, and the docs site
states the rule.
The capture dialog inhibited system shortcuts synchronously right after
present(), which does nothing when the window has no surface yet, so
a GNOME-bound combination could still fire instead of being captured.
It restored them only on close-request.

Inhibit on the dialog's map, through get_surface() and a Gdk.Toplevel
check, and restore on unmap, the timing QuickTiler's prefs.js uses.
Every way out of the dialog unmaps it. The comment no longer claims
QuickClip's approach, which presents an in-window dialog instead.
An actor Clutter destroys from C never calls back into an overridden
JS destroy() method, only the 'destroy' signal every actor emits
either way. QuickTSToggle's destroy() override left the menu the Shell
parents into the Quick Settings overlay, its rows' handlers and any
pending re-measure behind whenever that happened. Connect to 'destroy'
instead, QuickClip's pattern.
…cket

README.md said that without the operator set the daemon refuses the
socket, and comments in errors.js and health.js described an empty
menu. The socket is open to every local user and reads succeed; what
tailscaled refuses is every change, with a 403. Word it as AGENTS.md
and the docs site already do.
The docs site's table of every module and what it imports predates modules/text.js. Add its row, and add text.js to the imports of errors.js, device-section.js, exit-node-section.js, menu-items.js, panel.js and taildrop-section.js, which now fill their translated sentences through it.
applyError and applyChangeError left refusedReason as it was. After a
refused change, losing the daemon and then reading /status again
brought back the "refused the request" row for a change nobody had made
since. Both now clear refusedReason; applyPrefs still clears it on the
next preferences read.
text.js said it lets errors.js stay import-free, while errors.js now imports text.js; say instead that errors.js imports nothing else. AGENTS.md said Vitest runs every module under modules/ as shipped, but io.js is not among them.
…s own

The rule is shared with QuickClip and QuickTiler and resembles GNOME Settings' check without matching it exactly; the comments no longer claim it is the same rule.
…holders

fill() fills placeholders positionally, so a translation that put %s before %d would swap the count and the device name. A Translators comment directly above the _n() call, which xgettext extracts, says to keep the count first.
@sonarqubecloud

Copy link
Copy Markdown

@napalm255
napalm255 merged commit 8e1d0e2 into main Sep 27, 2026
6 checks passed
@napalm255
napalm255 deleted the fix/review-findings branch September 27, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant