Skip to content

Pages for keys, routes and proxies, and a listen mode you can pick - #13

Merged
fylorn merged 7 commits into
mainfrom
dev
Sep 14, 2026
Merged

fylorn merged 7 commits into
mainfrom
dev

Conversation

@fylorn

@fylorn fylorn commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

The model the UI presented did not match the one core implements, and three of its four nouns had no page at all. This builds them, plus the two fixes underneath that made every write path dead.

Also carries the collapsible icon rail and the draggable window from earlier in the branch.

The model

A key walks exactly one route. Unbound keys walk the default route — the one you land on when nothing else applies, not one everybody passes through. Two keys share a rule set by naming the same route, not by copying rules between them.

Keys

The only access control there is: the port decides who can knock, the key decides who gets in, and without a key even 127.0.0.1 is refused. It used to be one read-only line inside "listen and access" — you could not create, rotate, revoke or bind one.

Everything else depended on it. With every client sharing one key, RequestFacts.client is always the same value, so traffic and sessions cannot tell clients apart, per-client concurrency limits do nothing, and binding a route to a key is not expressible.

The model-allow control is three-state, and the third state is "none at all" — an empty list. That is not a broken state, it is how you park a client, so it is reachable on purpose rather than by deleting the last entry. Every transition between the three works in both directions.

Routes

Create a route, name it, give it rules, bind keys to it. Deleting one that keys are bound to says which keys, and unbinds them in the same write — the other order leaves a moment where a key points at a route that is gone.

Proxies

Previously a reference with no way to create the referent: the upstream table had a proxy column whose options came from a list of names, and nothing in the app could add to it. Anyone who had not hand-edited config.yaml saw a dropdown they could only read.

The password is write-only. This view goes into logs, diagnostic bundles and the screenshots users post — same discipline as upstream keys.

Listen mode

Which interface the gateway binds to. That is not an allow-list: loopback, one LAN interface, or all of them. Interfaces are enumerated live and never cached — plugging in a cable or bringing up a VPN changes the list, and a cached one only offers an address that no longer exists. An address in the config that is not currently enumerable is still listed, since dropping it would silently show a different one and look like the setting changed by itself. Switching rebinds the way nginx reloads, so there is no restart button.

The two underneath

The config version was never read. It is the token every write carries, and only the config_reloaded event set it. That event reports a change, so on a fresh launch it never arrives — all six config-writing entry points were dead, and the error said "try again in a moment", which would never come true.

Adoption shared one key. It never passed key_name, so core fell through to cfg.clients.first() and every adopted client got the same key. One missing argument at the end of the chain defeated the whole per-client capability above.

Checks

cargo fmt, cargo clippy -D warnings, cargo test (63), pnpm typecheck, pnpm test (38), pnpm build — all clean.

Every write path was walked end to end against a running gateway starting from a config with nothing but clients and providers. 19 of them round-trip, and the resulting file differs from the original only in the lines the run meant to change: comments, blank lines and ordering all survive. The six patch-layer and engine bugs that run surfaced are fixed in ThinkWatchProject/ThinkWatch-Core#16.

🤖 Generated with Claude Code

fylorn and others added 7 commits September 14, 2026 22:09
**The window could not be moved at all.** The title bar is `Overlay`, so
there is no real title bar to grab — the whole window relies on
`data-tauri-drag-region`, and that attribute works by calling a command
gated on `core:window:allow-start-dragging`. That permission is not in
the `core:window` default set, so it has to be listed, and it was not.
Nothing failed and nothing logged; the drag regions were simply inert.

The `description` on this capability file already warned that a missing
Tauri v2 permission fails silently. It now also names this instance,
because the general warning did not stop the specific case.

## The sidebar

It read as a web admin panel, and the reason was not the palette. A
macOS source list has six identifying features and this had none of
them: no icons, a flat opaque background, a neutral-grey selection
instead of the accent colour, heavy group headings, 172px of width, and
rows tighter than the system's while the gaps between groups were much
looser — the opposite rhythm to the platform's.

**Icons, and they carry the collapsed state.** Nine 16px stroke glyphs,
hand-drawn as inline SVG rather than pulling in an icon library: this
codebase already hand-writes its charts and its PNG encoder for the same
reason. Routing, Gateway and Clients get three different skeletons — a
forking arrow, a two-tier rack, a laptop — because those three are the
concepts most easily confused, and with labels hidden a mistaken glyph
costs a wrong click.

**The rail collapses to 60px**, which hands 136px back to the content.
For an app left open watching charts, that is the only change here that
improves the main surface rather than the chrome. Labels move into
tooltips, so collapsing defers information rather than discarding it.
The choice is remembered, read in the state initialiser rather than an
effect so the first frame is already the right width. ⌘⌥S toggles it —
the same key Finder and Mail use, matched on `e.code` because ⌥ turns
`s` into `ß` on macOS.

**Group headings are gone, replaced by hairlines.** They were eating
roughly a third of the list's height to say something a rule also says.
The cost is real and worth stating: a line can say "these two are not
those two", it cannot say "this group is called Security" — and that
group was deliberately split out of Monitoring.

**A palette for the window chrome**, separate from Tailwind's neutrals.
A pure grey sidebar is exactly what a web sidebar looks like; the
system's own is always slightly cool because it is translucent over the
desktop. These neutrals carry a blue bias — chosen, not inherited.

**The selection follows the system accent colour** via CSS `AccentColor`
with a hard-coded blue behind it. Someone who sets their accent to pink
should see a pink selection; writing the blue in is the moment it turns
back into a web page.

## Also

`vitest` was collecting the test files inside `.claude/worktrees/`, a
second checkout of this same repository. The suite reported 8 files and
68 tests where the repository has 4 and 38. Running them twice is not
the problem — **running a stale copy is**: those files stop at the day
the worktree was made, so they can hide a failure in the real ones or
report one that was fixed months ago. Excluded explicitly.

`*.tsbuildinfo` is now ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things from looking at it running.

**The status dot was shouting.** Expanded, it sits next to the words
"运行中" and reads as punctuation. Collapsed, it becomes the only colour
in an otherwise empty 60px column — a fully saturated green dot making a
loud announcement that nothing is wrong. Healthy now renders at 45%
opacity and 6px instead of 8px: still visible, but you have to look for
it. Amber and red stay at full strength, because those are the ones that
have earned a glance.

**Rows go 26px to 28px**, which is the system metric. 26 came from the
compact variant of the sidebar sketches, but that variant had no icons —
with icons the row has nothing in the spare vertical space and the whole
list reads as undersized in a tall window.

The empty area below the last item is left alone. Ten items in a tall
window leaves a gap, and every macOS source list has one; the only way
to close it would be to invent something to put there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`configVersion` is the optimistic-concurrency token every write carries,
and it was only ever set by the `config_reloaded` event. That event reports
a *change*; on a fresh launch where nobody has touched the config it never
arrives, so the token stayed null and all six config-writing entry points
were dead — the guard page's three-state controls, the listen mode, the
upstream fields, the policy groups. The app looked read-only, and the error
said "haven't read the config version yet, try again in a moment", which
would never come true.

Read the current version once on mount; the event stream keeps it current
from there, and wins if it gets there first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two control-plane endpoints the desktop side had no way to reach.

`new_key` generates a gateway key in core rather than in the UI: the
alphabet and the length are security decisions, and keeping a second copy
here guarantees that one day only one of them gets changed.

`interfaces` is asked fresh every time, never cached. Plugging in a cable,
joining another network or bringing up a VPN changes the list, and a cached
one only puts an address in the picker that no longer exists — the
consequence of choosing it is a gateway that will not start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model the UI presented did not match the one core implements, and three
of its four nouns had no page at all.

**Keys.** The only access control there is — port decides who can knock, key
decides who gets in, and without a key even 127.0.0.1 is refused. It used to
be one read-only line inside "listen and access": you could not create,
rotate, revoke or bind one. Everything else depends on it. With every client
sharing one key, `RequestFacts.client` is always the same value, so traffic
and sessions cannot tell them apart, per-client concurrency limits do
nothing, and binding a route to a key is not expressible.

The model-allow control is three-state, and the third state is "none at
all" — an empty list. That is not a broken state, it is how you park a
client, so it has to be reachable on purpose rather than by deleting the
last entry. Every transition between the three works in both directions.

**Routes.** A key walks exactly one route. Unbound keys walk the default
route — the one you land on, not one everybody passes through. So two keys
share a rule set by naming the same route, not by copying rules.

**Proxies.** Previously a reference with no way to create the thing being
referenced: the upstream table had a proxy column whose options came from a
list of names, and nothing in the app could add to it. The password is
write-only here — the view goes into logs, diagnostic bundles and the
screenshots users post, so it can be set and cleared but never read back.

**Listen mode.** Which interface the gateway binds to, which is not an
allow-list: loopback, one LAN interface, or all of them. Interfaces are
enumerated live, and an address in the config that no longer exists is still
listed — dropping it would silently show a different address and look like
the setting changed by itself. Switching rebinds the way nginx reloads, so
there is no restart button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t one

Adoption never passed `key_name`, so core fell through to
`cfg.clients.first()` and five adopted clients all received the same key.
The consequences chain: `RequestFacts.client` is then always the same
value, so a route bound to a key never matches, per-client concurrency
limits do nothing, and traffic and sessions cannot say who sent what. The
whole per-client capability was defeated by one argument missing at the
very end of the chain.

Create the key first when there is no key of that name, then adopt. Two
writes, and the moment in between is only "one more key that nobody uses
yet" — harmless, unlike the reverse order, where the client is configured
with a key that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`clear` reaches Rust as a `tw_api::PatchOp`, and that variant only exists
in core as of #16 — with the lock behind it, TypeScript compiled, Rust
compiled (nothing names the new variant), and the button failed the moment
a user pressed it.

Deserialising the UI's own JSON in a test makes a stale lock a compile-time
fact rather than a runtime one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 1394617 into main Sep 14, 2026
5 checks passed
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