Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b81e3a2
fix(io): report a failed local save as a file error, not a daemon one
napalm255 Sep 27, 2026
fcb3943
fix(model): keep the daemon reachable when a change is refused
napalm255 Sep 27, 2026
3639d6d
fix(i18n): give translators the strings the preferences window shows
napalm255 Sep 27, 2026
04bcdb4
refactor(io): move the decisions left in io.js where tests reach them
napalm255 Sep 27, 2026
5c5e35d
fix(model): revert a change that fails while the daemon is unreachable
napalm255 Sep 27, 2026
9a17e8b
fix(io): let reads through while the bus and a long request hold conn…
napalm255 Sep 27, 2026
61eb5c8
fix(devices): copy the DNS name the daemon gives, not one rebuilt loc…
napalm255 Sep 27, 2026
968110c
fix(panel): disarm a requested login once none is needed
napalm255 Sep 27, 2026
71e0338
fix(i18n): fill translated templates without reading $ patterns in va…
napalm255 Sep 27, 2026
57ff2d2
perf(state): skip the node sort when no node was re-marked
napalm255 Sep 27, 2026
dcabc8f
fix(shortcuts): allow Shift-only bindings when the key types nothing
napalm255 Sep 27, 2026
a0e0050
fix(prefs): inhibit system shortcuts when the capture dialog maps
napalm255 Sep 27, 2026
9632c79
fix(panel): release the tile menu from a destroy signal, not an override
napalm255 Sep 27, 2026
019d197
docs: say the daemon refuses changes without the operator, not the so…
napalm255 Sep 27, 2026
0287607
docs: list text.js and its importers in the module table
napalm255 Sep 27, 2026
7a14fd0
fix(state): drop a stale refusal when the daemon is lost
napalm255 Sep 27, 2026
7632d79
docs: align the text.js and AGENTS.md notes with the code
napalm255 Sep 27, 2026
f9d2bb7
docs(shortcuts): call the Shift rule close to GNOME Settings', not it…
napalm255 Sep 27, 2026
98137d7
fix(i18n): tell translators the order of the Sent %d file to %s place…
napalm255 Sep 27, 2026
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
36 changes: 19 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ Run `just ci` before claiming anything done.
already there, including a symlink — so nothing planted in the download
directory is ever followed or overwritten.
- **Every translatable string is a literal.** `tests/i18n.test.js`
(`tests/support/i18n.js`'s `nonLiteralGettextCalls`) fails if any `_()`
call's message, or either of `_n()`'s two arguments, is a variable,
property access or template rather than a string literal — invisible to
`xgettext -k_ -k_n:1,2` exactly the same way a translator would never
see it.
(`tests/support/i18n.js`'s `nonLiteralGettextCalls`) reads the source of
every file under `modules/`, `prefs.js` and `extension.js`, and fails if
any `_()` call's message, or either of `_n()`'s two arguments, is a
variable, property access or template rather than a string literal —
invisible to `xgettext -k_ -k_n:1,2` exactly the same way a translator
would never see it.
- **No JavaScript on the docs pages.** `docs/index.html` ships no
`<script>`; `just test-docs` fails the build if one appears.
- **The shared template files are byte-locked.** Everything in
Expand All @@ -98,11 +99,12 @@ Run `just ci` before claiming anything done.

Write the failing test first (RED → GREEN). Layers:

- **Vitest** (`just test`) — every module under `modules/`, `extension.js`
and `prefs.js` run exactly as shipped. `prefs.js` (Adw/Gtk widget
- **Vitest** (`just test`) — every module under `modules/` except
`io.js`, and `extension.js`, run exactly as shipped; `prefs.js` is
excluded and imported by no test. `prefs.js` (Adw/Gtk widget
construction) and `modules/io.js` (Soup/Gio plumbing, checked instead
against the real daemon) are excluded from coverage — identically in
`vitest.config.js` and `sonar-project.properties`. Stubs for `gi://` and
against the real daemon) are excluded from coverage — identically in `vitest.config.js`
and `sonar-project.properties`. Stubs for `gi://` and
`resource:///` imports live in `tests/stubs/`; a fake daemon and a small
fake Shell world are in `tests/support/`.
- **`just localapi-check`** (`scripts/localapi-check.js`, under plain
Expand Down Expand Up @@ -181,22 +183,22 @@ and `tests/support/world.js` (`tests/support/actors.js` is template-locked).
## Settings keys

`modules/settings.js` is the single source of truth: `KEYS` (the schema
key names), `SHORTCUT_KEYS` (the one accelerator key), and `SETTINGS` (each
key's gschema type plus the untranslated label and detail text `prefs.js`
builds every row from). It imports nothing, so `tests/settings.test.js`
checks it on plain Node against the gschema.
key names), `SHORTCUT_KEYS` (the one accelerator key), `SETTINGS` (each
key's gschema type), and `settingText` (the label and detail `prefs.js`
builds every row from, each a literal `_()` call). It imports nothing, so
`tests/settings.test.js` checks it on plain Node against the gschema.

Adding, renaming or removing a setting means updating all three together,
in this order, or the cross-check test fails:

1. `schemas/org.gnome.shell.extensions.quickts.gschema.xml` — the type,
default, summary and description (and `<range>` for `max-menu-height`,
currently 0–2000).
2. `modules/settings.js` — the key constant, its `SETTINGS` entry, and any
place in `modules/panel.js` or `prefs.js` that reads it through `KEYS`
or `SHORTCUT_KEYS`.
2. `modules/settings.js` — the key constant, its `SETTINGS` entry, its
`settingText` case, and any place in `modules/panel.js` or `prefs.js`
that reads it through `KEYS` or `SHORTCUT_KEYS`.
3. `prefs.js` — only if the widget it needs is not already covered by
`describe()`'s label/detail lookup (`Adw.SpinRow` needs its own
`settingText`'s label/detail lookup (`Adw.SpinRow` needs its own
`Gtk.Adjustment` bounds, kept equal to the gschema's `<range>`).

`AdvertiseRoutes` (the advertised-subnets row in preferences) is
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ testing, packaging and releasing.
sudo tailscale set --operator=$USER
```

Without that, the daemon refuses the socket and QuickTS says so in the menu
rather than showing a tailnet that is silently disconnected.
Without it the daemon refuses every change with a `403`. QuickTS says so in
the menu and offers the command, rather than leaving a switch that silently
flips back.

## Install

Expand Down
35 changes: 26 additions & 9 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1179,9 +1179,13 @@ <h2 id="keyboard-title">Keyboard</h2>
</tbody>
</table>
<p>
A bare key, or one with only <kbd class="kbd">Shift</kbd>, is
not accepted: it would be taken from every application. The
shortcut does nothing on the lock screen or the login screen.
A bare key is not accepted: it would be taken from every
application. <kbd class="kbd">Shift</kbd> alone is accepted only
with a key that types nothing, such as
<kbd class="kbd">Shift</kbd>+<kbd class="kbd">F5</kbd>;
<kbd class="kbd">Shift</kbd>+<kbd class="kbd">A</kbd> is how a
capital A is typed. The shortcut does nothing on the lock screen
or the login screen.
</p>
<p>
It is stored under <code>quickts-open-menu</code>. The prefix
Expand Down Expand Up @@ -1420,7 +1424,7 @@ <h3>Every module</h3>
<code>menu-items.js</code>,
<code>navigable-section.js</code>,
<code>ping.js</code>, <code>settings.js</code>,
<code>taildrop.js</code>
<code>taildrop.js</code>, <code>text.js</code>
</td>
</tr>
<tr>
Expand All @@ -1429,7 +1433,9 @@ <h3>Every module</h3>
Why a request to tailscaled failed, as a
<code>REASON</code> value rather than a string.
</td>
<td data-label="Imports" class="muted">—</td>
<td data-label="Imports" class="muted">
<code>text.js</code>
</td>
</tr>
<tr>
<td data-label="Module">
Expand All @@ -1443,7 +1449,7 @@ <h3>Every module</h3>
<td data-label="Imports" class="muted">
<code>menu-items.js</code>, <code>mullvad.js</code>,
<code>navigable-section.js</code>,
<code>settings.js</code>
<code>settings.js</code>, <code>text.js</code>
</td>
</tr>
<tr>
Expand Down Expand Up @@ -1499,7 +1505,8 @@ <h3>Every module</h3>
menu shares.
</td>
<td data-label="Imports" class="muted">
<code>errors.js</code>, <code>warnings.js</code>
<code>errors.js</code>, <code>text.js</code>,
<code>warnings.js</code>
</td>
</tr>
<tr>
Expand Down Expand Up @@ -1552,7 +1559,8 @@ <h3>Every module</h3>
<code>health.js</code>, <code>layout.js</code>,
<code>menu-items.js</code>, <code>routes.js</code>,
<code>settings.js</code>,
<code>taildrop-section.js</code>
<code>taildrop-section.js</code>,
<code>text.js</code>
</td>
</tr>
<tr>
Expand Down Expand Up @@ -1626,7 +1634,7 @@ <h3>Every module</h3>
</td>
<td data-label="Imports" class="muted">
<code>inbox.js</code>, <code>menu-items.js</code>,
<code>taildrop.js</code>
<code>taildrop.js</code>, <code>text.js</code>
</td>
</tr>
<tr>
Expand All @@ -1638,6 +1646,15 @@ <h3>Every module</h3>
<code>collate.js</code>
</td>
</tr>
<tr>
<td data-label="Module"><code>text.js</code></td>
<td data-label="Job">
Fills a translated sentence's <code>%s</code> and
<code>%d</code> in order, without reading
<code>$</code> patterns in the values.
</td>
<td data-label="Imports" class="muted">—</td>
</tr>
<tr>
<td data-label="Module"><code>timing.js</code></td>
<td data-label="Job">
Expand Down
25 changes: 9 additions & 16 deletions modules/device-section.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
problemMessage,
} from './menu-items.js';
import { NavigableSection } from './navigable-section.js';
import { fill } from './text.js';

/** The device submenu. */
export class DeviceSection {
Expand Down Expand Up @@ -52,8 +53,7 @@ export class DeviceSection {
this._visibleNodes(state).find(node => node.id === id) ?? null,
detailTitle: node => node.name,
renderList: (menu, state, open) => this._renderDevices(menu, state, open),
renderDetail: (menu, node, state) =>
this._renderDeviceActions(menu, node, state),
renderDetail: (menu, node) => this._renderDeviceActions(menu, node),
});
}

Expand Down Expand Up @@ -113,16 +113,11 @@ export class DeviceSection {
*
* @param {object} menu The submenu to fill.
* @param {object} node A normalized node.
* @param {object} state A snapshot.
*/
_renderDeviceActions(menu, node, state) {
_renderDeviceActions(menu, node) {
const { _ } = this._i18n;

const address = node.ips.at(0) ?? '';
const fqdn =
node.name && state.magicDNSSuffix
? `${node.name}.${state.magicDNSSuffix}`
: node.name;

if (address === '') {
addDisabledRow(menu, _('No address'));
Expand All @@ -147,12 +142,12 @@ export class DeviceSection {
this,
);

if (fqdn && fqdn !== node.name) {
if (node.dnsName) {
addRow(
menu,
_('Copy DNS name'),
'edit-copy-symbolic',
() => copyText(fqdn, this._gicon, this._i18n),
() => copyText(node.dnsName, this._gicon, this._i18n),
this,
);
}
Expand Down Expand Up @@ -228,12 +223,10 @@ export class DeviceSection {
function formatPing(result, { _ }) {
const latency = String(result.latencyMs);

if (result.route === ROUTE.DIRECT) return _('%s ms, direct').replace('%s', latency);
if (result.route === ROUTE.DIRECT) return fill(_('%s ms, direct'), latency);
if (result.route === ROUTE.RELAY && result.relay)
return _('%s ms, relayed via %s')
.replace('%s', latency)
.replace('%s', result.relay);
if (result.route === ROUTE.RELAY) return _('%s ms, relayed').replace('%s', latency);
return fill(_('%s ms, relayed via %s'), latency, result.relay);
if (result.route === ROUTE.RELAY) return fill(_('%s ms, relayed'), latency);

return _('%s ms').replace('%s', latency);
return fill(_('%s ms'), latency);
}
Loading
Loading