Skip to content

feat(macos): find the AWTRIX clock from the app and configure the server (#57) - #60

Open
tarakanof wants to merge 11 commits into
mainfrom
feat/app-clock-discovery
Open

feat(macos): find the AWTRIX clock from the app and configure the server (#57)#60
tarakanof wants to merge 11 commits into
mainfrom
feat/app-clock-discovery

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Summary

The macOS app gains a client-side mDNS browse for the AWTRIX clock, so discovery no longer depends on the server's Docker networking. Complements the server-side self-heal shipped in #56 (Fix A of #55) — this is Fix B.

The app browses _http._tcp and fingerprints each candidate with the same /api/stats uid check the server uses, then configures the server via PUT /v1/device/config.

How it surfaces

  • Device tab — the old server-only picker is replaced by a shared FindClockView.
  • Connection tab — a new "Clock" section, for first-run/server setup.
  • One-click fix — when the server reports it can't reach the clock (HTTP 502 from /v1/device/settings), the Clock section auto-expands with "The server can't reach the clock. Find it from this Mac."

Automatic fallback, not two buttons: the server's GET /v1/device/discover runs first and is unchanged, so the existing path can't regress; the local browse only runs if the server's scan comes up empty. The caption always discloses which one answered — "Found by the Ember server" vs "Found by this Mac — the server's own scan came up empty".

Changes

EmberKit (logic, testable, no UI coupling)

  • ClockDiscovery.swift (new)@MainActor @Observable NWBrowser over _http._tcp, resolving each service (IPv4-forced, mirroring the server's baseURLFor v4 preference) and probing <base>/api/stats. Pure nonisolated static helpers mirror internal/discovery: baseURL(host:port:) (port always explicit, 0→80, so candidates are byte-identical to the server's), candidate(host:baseURL:status:body:) (rejects non-200 / undecodable / empty-uid), probe(...) (session injectable), merged(_:adding:) (de-dup by uid, sorted by host). Structure follows ServerDiscovery.swift.
  • EndpointFormat.swift (new)NWEndpoint.Host rendering + RFC 6874 IPv6 authority formatting, extracted from ServerDiscovery so both browsers share it. Moved verbatim; the two pre-existing IPv6 tests still pass.
  • DeviceService.swift — new DeviceFailure classification (unauthorized / serverUnreachable / clockUnreachable / other), so callers can tell "the clock is unreachable" from "the server is unreachable".
  • DeviceModels.swiftDiscoveredClock gains a public init. No new endpoint: DeviceService.setConfig already shapes PUT /v1/device/config.

App (view layer, thin)

  • FindClockView.swift (new) — the shared Find-clock rows, fallback logic, Local Network grant path.
  • DeviceTab.swift — uses FindClockView; failure handling switches on DeviceFailure.
  • ConnectionTab.swift — new Clock section; its private openLocalNetworkSettings moved to Support/SystemSettings.swift (two callers now).
  • Info.plist_http._tcp added to NSBonjourServices (required for the browse).

Docsdocs/ARCHITECTURE.md discovery section, macos/README.md.

Review fixes folded in

Four findings from review were fixed before this PR opened:

  • 4dd8723load()'s generic catch reported "clock unreachable" for server failures too. Fresh install with no server URL → .notConfigured → user was told to run discovery, the local browse genuinely found the clock, and the pick then failed with "Server not configured". Now only HTTP 502 drives that path.
  • 3ee0ecbresolve() had dropped ServerDiscovery's dedup guard. NWBrowser returns the full result set on every change, so this opened ~N connections per callback; _http._tcp is crowded on a home LAN (printers, NAS, Sonos, ESPHome, HomeKit bridges). Now claims each service(name,type,domain) before constructing any NWConnection.
  • c0591e7 — in-flight probe tasks survived stop() and could repopulate a dismissed list up to 3 s later. Now held and cancelled.
  • 2df88b8pick() wiped the candidate list (picking the wrong one of two clocks forced a full re-scan) and never cleared a stale error on success.

Test plan

  • swift test --package-path macos172 tests pass (151 on main; +21). New coverage: fingerprint accept/reject, base-URL shaping incl. port-0 and IPv6 bracketing, uid de-dup + ordering, probe request shaping over a stubbed transport, PUT /v1/device/config body/auth, the DeviceFailure split, and the resolve-claim/clear-on-stop behaviour. No test touches real mDNS.
  • xcodegen generate → OK; xcodebuild -scheme Ember -destination 'platform=macOS' clean buildBUILD SUCCEEDED, 0 errors.
  • No Go files touched (git diff --name-only origin/main..HEAD -- '*.go' go.mod → empty).

Not verified

No LAN, clock, or running server was available, so these are unverified at runtime: the real _http._tcp browse, the probe against an actual AWTRIX device, the Local Network permission prompt (the NSBonjourServices entry only takes effect in an installed build), and the live 502 path. The 502 split is verified at the classification level only.

Note: xcodebuild needs CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=- on a machine without the Developer ID identity — project.yml pins it and the post-compile producer signing script needs it. Pre-existing, unrelated to this change.

Known follow-ups (deliberately deferred)

  • Browse lifecycle — the browse isn't stopped when the 4 s search window ends; teardown relies on a List-row .onDisappear. Switching tabs mid-scan can leak a browse; row recycling could in principle kill one early. Wants a structured .task-scoped rework.
  • host field parity — the server reports the mDNS hostname (awtrix_116ae8.local.), the app reports the resolved IP, so a locally-found row renders the IP twice and merged sorts by IP string. The browse result does carry the instance name.
  • IPv6 parityresolve() pins v4, so EndpointFormat's bracketing path is unreachable from ClockDiscovery and a v6-only segment would find nothing where the server would. Safe direction (app rejects what the server accepts), but baseURL has no link-local/zone rejection, unlike Go's baseURLFor.
  • .needsAccess conflates "Local Network denied" with "Wi-Fi off" (inherited from ServerDiscovery); the grant button shows unconditionally; and the selected-clock checkmark won't match a portless configured URL like http://192.168.0.14 (pre-existing — the old picker compared the same way).

Closes #57.

The IPv6 authority rules (bracketing, RFC 6874 zone encoding) and the
NWEndpoint.Host rendering were private to ServerDiscovery. A second
browser (client-side clock discovery) needs exactly the same rules, and
getting them wrong twice is the failure mode worth designing out.
The server's own browse only works when its container can see multicast
(host or macvlan networking); on a default Docker bridge it finds nothing
and the clock has to be typed in by hand. The app has full-stack mDNS, so
it can find the clock regardless of how the server is deployed.

ClockDiscovery mirrors internal/discovery's matching rules exactly —
browse _http._tcp, keep only hosts whose /api/stats answers 200 with a
non-empty uid, and shape the base URL with an explicit port so a
candidate found here is byte-identical to the same clock reported by
GET /v1/device/discover. What differs is the environment it runs in, not
the logic.

Refs #57.
Discovery no longer depends on the server's environment. "Find clock"
runs the server's own browse first — that path keeps working exactly as
before — and falls back to this Mac's browse when the server returns
nothing, which is what a bridge-networked container always does. The
caption names which scan produced the list, so one button never hides
which half of the system answered.

The same rows appear in Connection (during first-run, before the server
has ever seen the clock) and in Device, where an unreachable clock now
auto-expands the Clock section and prompts the app-side scan as the fix
instead of telling the user to "check discovery".

Picking a clock still PUTs /v1/device/config: the server remains the only
writer to the device.

Refs #57.
The architecture doc's discovery section said mDNS "requires host/macvlan
networking" in both directions. That is still true of the server, but the
clock is now findable from the app regardless, and the reason the
duplicate browse exists (environment, not logic) belongs next to the
server's own implementation.

Refs #57.
load()'s generic catch folded APIError.notConfigured and .transport into
the clock-unreachable branch, so a fresh install with no server URL was
told to run clock discovery — which finds the clock, then fails to save
it because the PUT has nowhere to go. Same dead end with the server down
or the VPN off.

The server already draws the line: its /v1/device/* proxies answer 502
when the clock, not the server, is the thing that didn't respond.
DeviceFailure.classify keys off that, so discovery is only offered when
it can actually help; server-side failures point back at Connection, as
the pre-branch message did.

Refs #57.
NWBrowser replays the entire result set on every change and mDNS answers
trickle in over the first seconds, so the missing de-dupe meant ~N TCP
connections per callback on a LAN with N _http._tcp instances — and
_http._tcp is the busiest type on a home network. The base-URL guard in
fingerprint() can't prevent it: that key only exists after the connection
has already succeeded, which is the cost being paid.

Claim the Bonjour instance before creating the NWConnection, mirroring
ServerDiscovery's guard, and drop the claims on stop() so a re-scan still
retries a service that failed to resolve.

Refs #57.
A probe outlives the browse by up to its 3s resource timeout. Uncancelled
it lands after stop() has cleared `clocks` and repopulates a list the user
already dismissed. Hold the tasks and cancel them with the scan.

Refs #57.
Picking wiped the whole list, so choosing the other of two discovered
clocks meant a full re-scan; the tick moving as currentBaseURL updates
already shows which one is live. The success path also left a previous
"Couldn't switch clock" error on screen.

Refs #57.
@tarakanof

Copy link
Copy Markdown
Owner Author

Code review (independent pass)

Reviewed against origin/main, with a focus on fingerprint parity with the server, Swift 6 concurrency, browse lifecycle, and the refactor.

Verdict: ship with follow-ups. Two findings were regressions and are fixed in this PR (4dd8723, 3ee0ecb); two more cheap ones were folded in (c0591e7, 2df88b8). The rest are tracked in the PR description.

Verification actually run

Command Result
swift test --package-path macos exit 0, 172 tests passed
xcodebuild -project macos/Ember.xcodeproj -scheme Ember -configuration Debug CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=- clean build BUILD SUCCEEDED; only pre-existing CLGeocoder deprecation warnings
plutil -lint macos/Ember/Info.plist + -extract NSBonjourServices json OK → ["_ember._tcp","_http._tcp"]
git diff --name-only origin/main..HEAD -- '*.go' go.mod go.sum 0 files

A SourceKit diagnostic claiming "Main actor-isolated static property probeSession cannot be referenced from a nonisolated context; this is an error in the Swift 6 language mode" is stale/false. Both targets compile in Swift 6 mode (swift-tools-version: 6.0, SWIFT_VERSION: "6.0") with zero concurrency diagnostics on a clean build; URLSession is Sendable, so the nonisolated private static let is legal.

Fixed in this PR

  1. DeviceTab.load() blamed the clock for server failures.notConfigured / .transport fell into the same catch as a genuinely unreachable clock, so a fresh install with no server URL showed "Clock unreachable" + the orange prompt, ran a browse that genuinely found the clock, and then failed the pick with "Server not configured". A regression against the pre-branch string, which at least said "check Connection". The discriminator already existed — cmd/ember/device_settings.go returns 502 for exactly this case. Fixed in 4dd8723 via a DeviceFailure classification.
  2. resolve() re-connected to every endpoint on every browse callbackServerDiscovery.resolve guards with !servers.contains(where: { $0.id == name }); ClockDiscovery dropped it. NWBrowser returns the full result set on every change and mDNS results trickle in, so this was ~N connects per callback, and the probed set can't help because it is keyed on the base URL, which only exists after the connect succeeds. _http._tcp is far busier than _ember._tcp — printers, NAS, Sonos, ESPHome, HomeKit bridges. Fixed in 3ee0ecb by claiming the service(name,type,domain) tuple before constructing any NWConnection.
  3. Probe tasks survived stop() — they ran to the 3 s resource timeout and then wrote to clocks after it was cleared, so stale results could repopulate a dismissed list. Fixed in c0591e7.
  4. pick() wiped the candidate list and kept stale errors — picking the wrong one of two clocks forced a full re-scan, and error was never reset on the success path. Fixed in 2df88b8.

Deferred — see the PR description

Browse lifecycle (nothing stops the browse when the 4 s window ends; teardown rides on a List-row .onDisappear, which can leak on a mid-scan tab switch), the host-field parity gap (server reports the mDNS hostname, app the resolved IP — so merged sorts by IP string and a local row renders the IP twice, which makes the "both produce the same shape" comment in DeviceModels.swift and ARCHITECTURE.md slightly overstated), IPv6/link-local parity, .needsAccess conflating denied-permission with no-network, the unconditional grant button, and the portless-URL checkmark mismatch.

Confirmed sound

  • Fingerprint rules match the server on status (200 only), uid (present + non-empty), and undecodable-body rejection. StatsProbe's optional uid/version vs Go's plain strings give the same accept/reject across every JSON shape traced (null, wrong type, non-object, unknown fields). The 1.5 s probe timeout matches discovery.go's client.
  • The EndpointFormat refactor is behaviour-preservingurlHost/host moved verbatim, ServerDiscovery.Found.urlString output is identical, and both pre-existing IPv6 tests still pass. No _ember._tcp regression.
  • Concurrency is clean — consistent @MainActor hops, pure or Sendable-only nonisolated static helpers, and no suspension point between the read and write in the clocks read-modify-write.
  • Info.plist_http._tcp is the correct _service._transport form, NSLocalNetworkUsageDescription was already present.
  • Tests are meaningful, not tautological. Worthwhile additions for later, all doable without a LAN: EndpointFormat.host()'s IPv4 %-suffix stripping, merged's baseURL tie-break, and a shared (ips, port) → url fixture table mirroring Go's TestBaseURLForPrefersIPv4 to turn the "byte-identical to the server" claim into a test.

The 502-vs-transport split was unobservable in the case it was written
for. The server gives its request to the clock 8s before answering 502;
the app's shared session aborts every request at 5s. So a blackholed
clock IP — issue #56, the reason app-side discovery exists — surfaced as
APIError.transport, classified as "server unreachable", and the Device
tab hid the discovery prompt it should have been showing. The previous
message was wrong for a different reason; this one was wrong for the
right one.

Route /v1/device/* through a session with a 12s budget so the server's
502 wins the race, leaving the 5s fail-fast on every other route. A
URLSession timeout now maps to APIError.timeout rather than .transport,
and DeviceFailure.timedOut blames neither end: it out-waits the server's
own budget, so at that point either could be at fault.

Refs #57.
Removing the message that steered fresh installs into the scan left the
trap itself: with no server URL the browse still ran, listed the clock,
and failed on pick with "Couldn't switch clock: Server not configured".
Gate the button and the rows on DeviceService.isConfigured and say why,
so the dead end is closed at the entrance rather than at the exit.

Refs #57.
A claim taken before the NWConnection was never given back, so a host
that answered EHOSTUNREACH or refused once — ESP32 mid-reboot, no ARP
entry yet, Wi-Fi power save — was written off for the whole scan and the
user got "No clock found" for a clock that was there. Release the claim
when a connection ends without reaching .ready; in-flight and successful
resolves keep theirs, which is where the per-callback fan-out lives.

finish() now removes from `pending` before acting, so the .cancelled that
follows our own cancel of a ready connection can't be mistaken for a
failed resolve and hand the claim back.

The claim/connect ordering is what the fan-out fix rests on, so the seam
is now claimNew() — the batch selection resolve() actually loops over —
rather than a Set-semantics helper that would stay green if the claim
moved after NWConnection(to:). The probe transport is injectable so the
cancel-before-write ordering is testable too; both new tests were checked
against mutants.

Refs #57.
@tarakanof

Copy link
Copy Markdown
Owner Author

Verification pass on the four fix commits — and a third round

An adversarial pass over 4dd8723 / 3ee0ecb / c0591e7 / 2df88b8 found that the first fix traded a false positive for a false negative, and the second was stricter than it needed to be. Three more commits address that: e9bd96d, 365374c, cf6a509.

P1 — the 502 split was unobservable in the case it was written for

APIClient's shared session aborts every request at 5s (timeoutIntervalForRequest = 5); the server gives its request to the clock 8s (cmd/ember/device_settings.go, deviceBaseClient). Both numbers confirmed by reading the source.

So for a blackholed clock IP or a hanging ESP32 — issue #56's case, the whole reason app-side discovery exists — URLSession aborted first, producing APIError.transport, which 4dd8723 classified as .serverUnreachable, which suppressed the orange prompt and the auto-expand. Pre-4dd8723 this case correctly said "Clock unreachable — use Find clock". The fix removed a false positive and introduced a false negative.

e9bd96d: /v1/device/* now routes through APIClient.forDeviceProxy() on a 12s-budget session, so the server's 502 wins the race; every other route keeps the 5s fail-fast (Test Connection, tray polling, previews unchanged). A URLSession timeout maps to a new APIError.timeout rather than .transport, and DeviceFailure.timedOut blames neither end — it out-waits the server's own budget, so at that point either could be at fault. It still expands the Clock section (puts the fix on screen) without making the confident "the server can't reach the clock" claim.

12s was chosen, not measured: it clears the 8s budget by 4s. Cost is that pollStats can stall up to 12s before its 5s sleep, so a hung clock refreshes the stats rows at ~17s worst case. The server answers 502 at ~8s in practice.

P2 — the fresh-install trap was only half-closed

4dd8723 removed the message that steered users into the dead end, not the dead end. FindClockView still rendered with no server URL configured, so Find clock ran the full browse, listed the clock, and died on pick with "Server not configured".

365374c: DeviceService.isConfigured gates the button and every result row, with a caption explaining that a clock found here is saved on the server.

P3 — a transient resolve failure hid a clock for the whole scan

The claim in 3ee0ecb was correctly taken before any NWConnection (verified: eager filter, single construction site, @MainActor throughout — no race). But .waiting is treated as terminal and the key stayed claimed, so a clock that momentarily answers ECONNREFUSED/EHOSTUNREACH — ESP32 mid-reboot, no ARP entry, Wi-Fi power-save — was dropped once and never retried inside the 4s window.

cf6a509: finish(_:) releases the claim when the connection ended without .ready, removing from pending first so our own cancel of a ready connection can't be misread as a failed resolve. De-dupe still covers in-flight and successful resolves, which is where the fan-out cost lives. Cost: a service that consistently fails to resolve is retried once per browse callback; no retry cap.

Test quality — the gap was real and is closed

The two original claim() tests restated the implementation: they called claim directly and asserted Set semantics, so moving the claim after NWConnection(to:) would have left both green. Fixes c0591e7 and 2df88b8 shipped with no tests at all.

cf6a509 restructures for the seam (claimNew is what resolve() loops over; connection construction moved into connect(to:)) and replaces them with tests that break if the ordering moves. Both new guards were mutation-checked: deleting the probe-cancel loop fails clockDiscoveryDropsProbesThatFinishAfterStop; emptying releaseClaim fails clockDiscoveryRetriesAServiceThatFailedToResolve.

Verified sound, left alone

  • c0591e7 (probe cancellation)Task.isCancelled sits after await Self.probe(...), the only suspension point, and the write is synchronous after it, so a cancelled task cannot write. probes.insert completes on MainActor before the body starts, so no handle is lost. Growth is bounded: insertion is gated by probed.insert(base).inserted, so probes.count ≤ distinct host:port.
  • 2df88b8 (keep candidates listed) — the checkmark still works: applyDeviceBaseURL stores base_url verbatim with no normalization and handleDeviceConfigGet echoes it, so the comparison holds. No new leak class.

Cross-language contract

DeviceFailure's doc comment now names TestDeviceProxyMapsDeviceErrorTo502 as the contract's home and points at forDeviceProxy for the budget that makes it observable. Comment-level only — nothing in the repo can enforce it across the language boundary.

Commands (actual output)

swift test --package-path macos   → 180 tests passed   (172 before this round; 151 on main)
xcodebuild … CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=- clean build → BUILD SUCCEEDED, 0 errors
git diff --name-only origin/main..HEAD -- '*.go' go.mod go.sum → empty

Still unverified at runtime

No LAN, clock, or running server was available. The real _http._tcp browse, a live AWTRIX probe, the Local Network prompt, and the actual 502 and timeout paths against a running server are all verified through stubs only. The 12s-vs-8s race in particular is reasoned from source, not observed.

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.

App-side clock discovery: macOS app finds the clock and configures the server (#55 Fix B)

1 participant