Skip to content

fix(discovery): a blocked local network must not look like "no devices" - #8

Merged
Skeyelab merged 8 commits into
mainfrom
fix/discovery-permission-is-not-silent
Jul 14, 2026
Merged

Skeyelab merged 8 commits into
mainfrom
fix/discovery-permission-is-not-silent

Conversation

@Skeyelab

Copy link
Copy Markdown
Member

The user's Touch was powered, on the network, and audibly following the Link session — its LED blinking in time — and the app showed nothing at all.

It was never the network

The device was advertising _http._tcp correctly the whole time (dns-sd confirmed it: kstouch-dfd0, resolving to 192.168.0.101). The same build found it instantly in the Simulator, which shares the Mac's network and needs no Local Network permission.

What was different on the phone was iOS's Local Network permission, reset by reinstalling the app.

Denied, iOS does not fail the Bonjour browse. It returns nothing. Forever. And KitchenSyncDiscovery never set a stateUpdateHandler, so nobody was listening anyway — the browser was dead on arrival and stayed dead in silence, while the screen said:

Looking for KitchenSync units on this network…

Which was a lie. It was not looking. It never would be. So the user goes and debugs their WiFi, their router, their firmware — everything except the one switch that actually turns it back on.

The recurring bug of this codebase, one more time: a failure with no signal, sending someone to fix the wrong thing (T-009, T-010, T-016, T-018, and link_owns in link-devices#117). iOS does tell us — kDNSServiceErr_PolicyDenied. We just never listened.

The fix

  • DiscoveryStatus — a pure mapping from NWBrowser.State, so it can be asserted without standing up a browser. The trap is that a denial arrives as a .waiting: it looks like something that will resolve itself, and it never does. Only the DNS policy code tells a denial apart from a transient wait (WiFi coming up, interface bouncing) — and only a denial is the user's to fix. Putting a "change your privacy settings" banner on a transient wait would be the same crime in the other direction.
  • KitchenSyncDiscovery gains a stateUpdateHandler, and restarts a failed browser — NWBrowser does not come back from .failed on its own. A denial is deliberately not retried: iOS would refuse the new browser exactly as it refused the last one, and a retry loop would only bury the one fact the user needs.
  • The list names the cause and puts them one tap from Settings, with "Add by IP" still available as the escape hatch.

Verification

7 new tests, full suite green. Verified in the Simulator that the happy path still works — the bench Touch shows up as kstouch-dfd0.local with live BPM and 2 peers.

The permission-denied path itself is asserted at the DiscoveryStatus level (pure), not end-to-end — reproducing a real iOS permission denial in CI isn't something I can do honestly, so I haven't claimed it.

https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6

…off"

The firmware now tells the truth: a device does not report hardware it does not
have (link-devices ESP-030). Absent hardware is ABSENT from /config.json, never
reported false. This app has to decode an HONEST document — until now KsConfig
required every field, so it would have THROWN on one, meaning the firmware told
the truth and the app called it broken.

KsConfig.metronome / .led / .followBeatEnabled are now OPTIONAL, and nil is a
different statement from false:

  nil   = there is no speaker fitted
  false = there IS a speaker and it is switched off

Conflating them is how a UI ends up drawing a volume slider for a speaker that
does not exist. Same class of error as the other four this codebase has produced
today (T-009, T-010, T-016, T-018): two situations collapsed into one signal.

Sections render only for hardware the device reports. No speaker, no metronome
controls. No strip, no LED controls. saveFormFields only sends what exists, too —
posting metronome=0 to a board with no speaker would mean this app INVENTED a
value for absent hardware, which is exactly the fabricated-defaults clobbering
that /config.json exists to prevent. Absent stays absent, in both directions.

And capability belongs to the BUILD, not the product. Solder a strip onto the
Touch, flip ONE firmware flag, and the LED section appears in this app with NO
app change at all. That is the whole point of declaring capability instead of
hardcoding a product identity.

KsConfig.outputCount becomes maxOutputCount — a MAXIMUM, never an assumption. The
real count is clock.count, which the firmware sets to what is actually fitted: one
DIN jack on a Touch, four on a P4. Padding would draw three dead output cards.
(T-015.)

The test fixture is the REAL Touch's /config.json, captured verbatim from
kstouch-dfd0: no metronome keys, no led keys, no follow_beat, three WiFi slots
with the migrated credential in slot 0, and ONE clock output.

103 tests, 0 failures. Verified against the real device: it decodes, displays
120.0 BPM with 1 peer, and is controllable. No UNSUPPORTED FW badge.
The device screen is one scroll mixing transport with cable/rate/LED/wifi. On
stage you want transport and nothing you can fat-finger; at soundcheck you want
the knobs.

Split criterion is not advanced-vs-basic, it's WHAT YOU REACH FOR MID-SET.
Transport, obviously. Nudge — the firmware's own comment says it 'slides the
RC-505 into the pocket', live via /nudge, no reboot: a playing control, not a
setup one. Swing, same. Everything else is set once and left alone.

PERFORMANCE (the screen you land on): tempo, meter bridge, master transport, and
per output the big transport button + NUDGE + SWING. Nothing on it changes your
SOUND — only your timing and transport. You cannot knock a cable assignment loose
mid-set because it isn't there to knock.

SETTINGS (gear sheet): everything LIVE — cable, rate, follow-link, click, LED,
diagnostics. DEVICE SETUP (sheet from Settings): everything that REBOOTS — wifi,
metronome enable, follow-beat enable.

THE INVARIANT SURVIVES: no surface ever mixes live and reboot controls. One merged
Settings sheet would have re-created the exact adjacency this app exists to fix —
the device's own web page puts metronome-enable and metro-accent 40px apart as the
identical control, one of which reboots the box mid-set. Better labelling is not
the same as impossible.

Firmware update stays in the toolbar, not inside Settings: Settings needs
/config.json, which 404s on old firmware, and an update is how you ESCAPE that
state (T-009).
The device screen was one scroll mixing transport with cable, rate, LED, click and
diagnostics. On stage you want transport and nothing you can fat-finger; at
soundcheck you want the knobs.

The split criterion is not advanced-vs-basic. It's WHAT DO YOU REACH FOR MID-SET.

PERFORMANCE (the screen you land on): tempo glass, meter bridge, master PLAY/STOP,
and per output the big transport button + NUDGE + SWING. Nothing else. Nothing on
this screen changes your SOUND — only your timing and your transport. You cannot
knock a cable assignment or a clock division loose mid-song, because they are not
there to knock.

Nudge earns its place on the firmware's own account: nudge_mbeats is live via
/nudge, no reboot, and the comment says it "slides the RC-505 into the pocket".
That's a playing control, not a setup one. Swing is the same kind of thing.

SETTINGS (gear sheet): everything LIVE — per-output enable/cable/rate/follow-link,
click vol/voice/accent, LED, diagnostics. It opens with the reassuring counterpart
of the reboot warning: "Everything on this screen applies instantly. Nothing here
reboots the device."

DEVICE SETUP (a DOOR inside Settings): everything that REBOOTS — WiFi, metronome
enable, follow-beat enable. Consequence band, REBOOTS tags, WRITE & REBOOT key.

THE INVARIANT SURVIVES, AND IT'S THE WHOLE POINT: no surface ever mixes live and
reboot controls. Merging everything into one Settings sheet would have re-created
the exact adjacency this app exists to fix — the device's own web page puts the
metronome ENABLE toggle and the metro-accent toggle forty pixels apart, as the
identical component, in the same box, and one of them restarts the box mid-set.
Better labelling is not the same as impossible. LiveRebootPartitionTests still
enforces it in CI.

Capability still drives visibility: no speaker, no click section; no strip, no LED
section (link-devices ESP-030).

Firmware update stays in the toolbar, NOT inside Settings: Settings needs
/config.json, which 404s on old firmware, and an update is how you ESCAPE that
state. Gating the escape behind the broken thing would strand the user (T-009).

103 tests, 0 failures. Verified against the real Touch: Performance shows 120.0
BPM, CLOCK OUT 1 PLAYING, with NUDGE and SWING under it and nothing else.
THE BUG THE USER REPORTED: "nudge and swing buttons not working."

They weren't working because the Touch had no /live route and returned 404. But
that is not why it was UNDIAGNOSABLE. `apply()` was:

    try? await client.postLive(edit)

The 404 was caught and dropped. The stepper moved, no error appeared, and the
device never changed. The device said "I have never heard of that endpoint" as
loudly as HTTP can, and the app said nothing at all.

- apply() now reports failure, and keeps the three causes APART:
  unsupportedByFirmware (404 — no /live at all; the way out is an OTA update),
  rejectedByDevice, unreachable. Collapsing situations into one signal is the
  mistake this codebase keeps making (T-009, T-010, T-016, T-018) and it sends
  the user to fix the wrong thing.

- A failed edit leaves the displayed value at the DEVICE'S truth. Never leave a
  stepper showing a number the device does not have.

- A SUCCEEDED edit now updates the local config (KsConfig.apply, pure and
  tested). Without it even a WORKING /live looked broken: the POST succeeded,
  the device obeyed, and the number on screen never moved.

- The Performance screen draws the failure. A failure the view model records and
  the view never shows is still invisible.

An icon, drawn rather than pasted: Tools/make_app_icon.swift renders it from the
same palette as the device's own web UI. It's a MIDI clock pulse train in lime on
charcoal with a taller accented downbeat — literally what the box emits, and a
square wave still reads at 40px where a wordmark does not. Re-runnable, so the
art is source, not a binary nobody can regenerate.

7 new tests, full suite green.

Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
…th it

CLAUDE.md names this gap out loud: "Nothing has ever spoken to a real device. Every
network test runs against a stub." Every fixture in this suite was written by me from
a READING of the firmware, which makes them a test of my reading, not of the device.

These bytes are a verbatim capture from a Touch (a4:cb:8f:da:df:d0, fw 2.2.0) --
stopped and mid-song. Nothing prettified, reordered, or filled in.

They catch the reported bug immediately. `link_owns` was true while the device
cheerfully obeyed every play and stop; the app honours the ks_status.h contract
("when link_owns is true the manual buttons are ignored, so the UI greys them") and
so refused to even send the tap. Buttons dead, device fine.

test_the_app_can_start_a_stopped_touch FAILED against the device's real bytes. That
is the test that should have existed before I ever called the firmware "verified".

Also kept: the BROKEN bytes, as a fixture. A device that genuinely defers to Link (the
P4 does) SHOULD have its buttons greyed -- the app's contract-following is correct and
must not be "fixed" away. The fix belonged in whoever lied.

Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
`-derivedDataPath build/dd` (used to build for a physical device) writes derived
data into the repo, and .gitignore only covered DerivedData/. 2121 build artifacts
were committed on the previous commit. Ignored under both names now.

Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
A fixture is a public artifact — it goes in the repo, the CI log, and the PR diff.
The pre-push redaction hook caught the real SSID in two of them.

Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
The user's KitchenSync Touch was powered, on the network, and audibly following the
Link session -- its LED blinking in time -- and the app showed nothing at all.

The device was advertising _http._tcp correctly the whole time; the SAME build found
it instantly in the Simulator (which needs no Local Network permission). What was
different on the phone was iOS's Local Network permission, reset by reinstalling the
app.

Denied, iOS does not FAIL the Bonjour browse. It returns nothing. Forever. And
KitchenSyncDiscovery never set a stateUpdateHandler, so nobody was listening anyway:
the browser was dead on arrival and stayed dead in silence, while the screen said
"Looking for KitchenSync units on this network..." -- which was a lie. It was not
looking. It never would be. So the user goes and debugs their WiFi, their router,
their firmware: everything except the one switch that actually turns it back on.

The recurring bug of this codebase, one more time: a failure with NO signal, sending
someone to fix the wrong thing (T-009, T-010, T-016, T-018, and link_owns). iOS does
tell us -- kDNSServiceErr_PolicyDenied. We just never listened.

- DiscoveryStatus: a pure mapping from NWBrowser.State, so it can be asserted without
  standing up a browser. A denial ARRIVES AS A WAIT, which is the trap -- it looks
  like something that will resolve itself, and it never does. Only the DNS policy code
  tells a denial apart from a transient wait, and only a denial is the user's to fix.
- KitchenSyncDiscovery gains a stateUpdateHandler and RESTARTS a failed browser.
  NWBrowser does not come back from .failed on its own. A denial is deliberately NOT
  retried: iOS would refuse the new browser exactly as it refused the last one, and a
  retry loop would only bury the one fact the user needs.
- The list names the cause and puts them one tap from Settings.

7 new tests. Full suite green. Verified in the Simulator: the happy path still
discovers the bench Touch (kstouch-dfd0.local, live BPM, 2 peers).

Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
@Skeyelab
Skeyelab merged commit 82310b7 into main Jul 14, 2026
1 check 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.

2 participants