A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.
Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own
copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors
the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left
in each shell is the part that genuinely differs: its dialog or screen, its advert drain,
its re-key, and its route back into the trust gate.
Behavioural review, since a sleeping host to test against wasn't available:
- GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second
like every other implementation. Up to half a second slower to notice a host coming
back, and exactly the reference cadence in exchange.
- GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully
and takes the last, i.e. the freshest address. Same connect, fresher re-key.
- Windows sent one packet before the loop AND one on its first pass; the machine owns the
packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the
budget still expires at 90 s.
- Both keep their own cancel path, their own re-key, and their own error surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host's pairing-gate rejections (not armed / bound to another device /
rate-limited / identity required / denied / approval timeout / superseded /
wire-version mismatch) used to drop the connection with a bare code-0 close,
and every client collapsed that — plus plain unreachability — into one
"wrong PIN / not accepted" message. A dead network path, a disarmed host,
and an operator denial were indistinguishable, which is exactly the
misdiagnosis behind the recent Android pairing support thread.
- core: new ungated `reject` module — shared close-code block 0x60–0x67
(+ 0x42 busy promoted from the host), `RejectReason`, and
`PunktfunkError::Rejected`; `pair()`/`connect()` decode the host's
ApplicationClosed code into `Rejected` instead of a generic Io error.
C ABI v7: status block −20…−28 and `punktfunk_connect_ex8` (`status_out`
reports the failure cause; NULL-return alone can't). Wire unchanged —
old peers see exactly the old bare close.
- host: every gate rejection `conn.close()`s with its typed code (and the
human reason as close bytes) before erroring out of the session task.
- pf-client-core: shared `pair_error_message`/`connect_reject_message`
wording consumed by the Windows + Linux + console-UI + CLI surfaces; a
connect failure now renders the host's stated reason.
- android: `nativeTakeLastError()` JNI token + `ConnectErrors.kt` — a
network timeout is no longer reported as "wrong PIN, or the host isn't
armed", and a typed rejection skips the wake-and-wait fallback (the host
is demonstrably awake).
- apple: `HostRejection` + `.rejected`; the pair sheet and session alerts
show the stated reason; connect moves to `ex8`.
Completes the cross-client half of the hunks that rode along in 12148243
(client.rs / trust.rs / punktfunk1.rs) — main did not build without this.
Validated: workspace clippy -D warnings + full test suite green on .21
(EXIT=0, 309 host / 148 core suites); macOS core 147+c_abi green; swift
build green; Android Kotlin + native crate green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A saved host that stopped advertising on mDNS but has a learned Wake-on-LAN
MAC was routed into a wake-and-WAIT-for-mDNS flow instead of being dialed. For
a host reached over a routed network (Tailscale/VPN/another subnet) mDNS never
sees it, so the wait could never succeed and reconnects were impossible — the
host log showed no connection attempt at all. The tile's Offline pip and this
connect gate shared the same LAN-only `advertises`/`liveAdvert` predicate, so a
perfectly reachable host read as unreachable. The first connect worked only
because the MAC hadn't been learned yet (a direct dial); once learned, every
reconnect wedged in the waker.
All four full clients carried this pattern (deliberate mirrors of the Apple
`HostWaker`). Each now DIALS FIRST: fire the magic packet up front
(fire-and-forget — harmless if the host is awake, and a genuinely-asleep box is
booting while the dial times out) and attempt the connection unconditionally.
Only a FAILED dial falls into the visible "Waking…" wait, whose redial carries
no fallback so it can't loop. A fingerprint-mismatch/trust-rejection failure
means the host ANSWERED, so it never takes the wake path.
- apple: SessionModel.connect gains `onUnreachable`; startSession dials first.
- linux (relm4): AppMsg::WakeConnect wakes+dials; a per-request wake_fallback
is armed and consumed in SessionExited, matched by fingerprint/address.
- windows: initiate_waking + ConnectOpts.wake_on_fail; the worker's Failed arm
escalates to wake_and_connect only on a non-trust failure.
- android: doConnectDirect gains onFailure; doConnect wakes+dials first.
Decky was already correct (always launches `--connect`; WoL fire-and-forget).
Explicit "Wake host" menu actions are unchanged — waiting on mDNS is right when
the user explicitly asked to wake a box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GTK client becomes a relm4 component tree: AppModel owns the window,
trust gate (rules 1-3), and the spawned session child's lifecycle as
typed messages; the hosts page is a child component with a
FactoryVecDeque of host cards — the HostsCallbacks Rc<dyn Fn> bag, the
busy Cell, and the Rc<RefCell<HostsUi>> cross-page pokes are gone.
Trust/settings/library dialogs stay plain GTK, invoked from update with
a ComponentSender.
Deleted with the in-process presenter: ui_stream.rs, video_gl.rs,
ui_gamepad_library.rs, launch.rs, the khronos-egl dep, and the
PUNKTFUNK_LEGACY_PRESENTER hatch. Every stream (and the console library)
now runs in punktfunk-session; the shell spawns it for card connects and
exec's it for --connect/--browse so the Decky wrapper keeps working. The
request-access flow gains --connect-timeout + a CancelHandle that kills
the child. Screenshot scenes re-pointed onto the components (verified:
hosts + library self-capture; the dialog scenes present correctly and
capture under a GL renderer); the gamepad-library scene is gone with the
page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux client already had WOL send + MAC storage + a Wake action + auto-wake-
on-connect, but the auto-wake just fired a packet and did one dial to the stored
address — so a host that woke on a new DHCP lease failed, and there was no
"waiting" feedback. Add the polished flow (mirrors Apple/Android HostWaker):
- ui_trust::wake_and_connect — send the magic packet, poll mDNS until the host
reappears (re-sending every 6 s, 90 s budget) behind a cancelable "Waking…"
dialog, then connect; if it woke on a new IP, re-key the saved host first.
- trust::rekey_addr — no-churn addr/port update keyed by fingerprint.
- the hosts page routes an offline saved-host-with-MAC tap to on_wake_connect
(the new flow) instead of fire-and-forget wake + immediate dial.
Builds + clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- library.rs + ui_library.rs: the host's unified game library over the
management API (the Apple LibraryClient/LibraryView ported) — mTLS with the
paired identity, host verified by its pinned cert fingerprint (ureq + rustls,
unified with the workspace rustls 0.23); posters load async with monogram
placeholders, and picking a title starts a session that asks the host to
launch it (the library id rides the Hello).
- app.rs (~800 lines lighter) splits into cli.rs (argv/headless
pairing/--connect/screenshot scenes), launch.rs (mode resolve + session
worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN /
delegated-approval dialogs); ui_hosts/ui_stream reworked around the split.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>