Completes Phase 4 of the multi-device mesh program (docs/MESH_TRANSFER.md):
a Mac now dials paired sibling Macs with the same platform-neutral SyncClient
the iPhone uses, so it is both a host to its phones and a .control-scope client
of its peers — the dependency session transfer (P5) was waiting on.
Wire (NucleicProtocol, version stays 1, additive + capability-gated):
- PeerAddresses {lanHint, tailnet, relayRoomID, updatedAt}; optional
Hello.addresses / Welcome.addresses; ClientMsg.addressUpdate gated on new
WireCapabilities.canUpdateAddresses; PairedDevice.addresses — all
decode-defaulted so shipped iPhones and pre-mesh stores load unchanged.
Core (NucleicCore):
- PeerClient: serial LAN→tailnet dial over an injectable PeerDialer, capped
backoff, live presence stream, listPeers only when advertised. MacPeerDialer
+ LANDialChannel add the outbound dial-side FrameChannel that didn't exist.
- Symmetric pairing into one PairedDeviceStore (one pasted link makes both Macs
dialable); accepting-Mac confirm (locked decision #4); SyncHost per-deviceID
connection dedup (keep-newest, 2s grace); AppStore lifecycle + meshPeers.
UI: "Paired Macs" section (presence/transport/revoke), paste-link pairing sheet,
QR sheet doubles as copy-link + confirm dialog.
Hardening (adversarial review, 11 defects fixed incl. two security holes):
- Mac-pairing confirm enforced on the reconnect promotion path (a phone can't
reconnect claiming deviceKind=mac to skip the confirm).
- Existing-device hello branch requires the authenticated static key to match
the pin (a pairing party can't claim another device's deviceID).
- startPeerClient guarded against a racing stopSyncServer; confirm timer stored
and cancelled on resolve; pair() treats pre-welcome wireError as terminal and
classifies decline vs unreachable; handshake deadlines; setPresence won't
resurrect an unpaired peer; meshPeersChanged no-ops once the server is down;
pairing UI cancels in-flight pair() on dismiss; LANDialChannel cancels on
.failed + TCP keepalive.
Tests: 871 green (742 core + 98 protocol + 31 new) — wire codec/backcompat,
dedup, address exchange, addressUpdate, mac-confirm decline/phone-skip,
key-mismatch + promotion rejects, PeerClient pair-via-link/reconnect/listPeers
gating/unpair loopback.
Co-Authored-By: Claude Fable 5 <[email protected]>
The admin bearer must not ship in a distributed build, so hosts no
longer use it at all. A Mac self-enrolls with the relay on first use
(POST /v1/host/enroll) and receives its own scoped credential (only
the SHA-256 is stored server-side; the credential lives in the login
Keychain). Register and notify are now authorized per host: a host
can wake only devices it registered itself (admin remains an
operator-only override); room-registered records are owner-tagged
with the DO id and refused out-of-band. Each device record's APNS
environment now wins over the global APNS_ENV secret, so mixed
sandbox/production fleets work.
Settings ▸ Remote's section is now just "Relay" with a single
"Push notifications" toggle that takes effect immediately; the relay
URL and APNS-environment overrides appear only in local dev builds.
PushRelayConfig reduces to enabled + baseURL (built-in production
default) + apnsEnv (production default). PUSH_SETUP.md §3/§4 updated;
stale dotted-bundle-id comments in env.ts/wrangler.jsonc fixed.
Co-Authored-By: Claude Fable 5 <[email protected]>
An app can't hold a secret the machine's owner can't extract (root +
debugger defeats bundling, Keychain, and obfuscation; the Secure
Enclave can't import external keys), and a leaked .p8 can push to
every user of the topic. So the push path is relay-only again: the
key lives exclusively in the relay's Worker secrets and the Mac only
asks the relay to send. Settings and PUSH_SETUP.md revert to the
relay-only form, with the rationale recorded in §4.
Co-Authored-By: Claude Fable 5 <[email protected]>
Answering "can push be local when on LAN": a backgrounded iOS app can
only be woken through Apple's push service, so a LAN-only wake path
doesn't exist — but the *sender* can be this Mac. DirectAPNSSender
holds the APNS .p8 (path + Key ID + Team ID, runtime-configured like
the relay: env vars first, then the Settings-written defaults keys)
and posts the same content-free approval.pending tickle straight to
api.push.apple.com (ES256 provider JWT via CryptoKit, 40-min cache,
per-device throttle) — no Cloudflare dependency for push.
ApprovalPushing unifies the two senders; SyncHost wakes non-connected
paired devices through whichever is configured (direct wins). The
Settings ▸ Remote push section gains a sender picker with the direct
fields (.p8 chooser, Key ID, Team ID). PUSH_SETUP.md §4 rewritten as
Option A (this Mac) / Option B (relay). JWT signing verified against
the public key in tests; payload asserted byte-compatible with the
worker's apns.ts tickle.
Co-Authored-By: Claude Fable 5 <[email protected]>
The relay URL / admin secret / APNS environment are runtime host
settings (PushRelayConfig.resolve reads env then UserDefaults when
the sync server starts), not build settings — and a Finder-launched
app never sees shell env. Add an "Approval push (relay)" section to
Settings ▸ Remote writing the same defaults keys, with an Apply
button that restarts remote access so changes take effect. Update
PUSH_SETUP.md §4 to point at the UI and keep the env-var form for
scripted runs.
Co-Authored-By: Claude Fable 5 <[email protected]>
docs/PUSH_SETUP.md lists the manual steps left to light up push /
Live Activities end-to-end: APNS key, the one-click Xcode Push
capability, Cloudflare provisioning + secrets (with the bundle-id
topic gotcha: xyz.blakeslee.nucleic-remote, not .remote), host relay
config, and the on-device verification checklist. Also adds
CODE_SIGN_ENTITLEMENTS so the existing entitlements file actually
signs into the app.
Co-Authored-By: Claude Fable 5 <[email protected]>
Four Mac surfaces the iOS remote couldn't render now ride the wire,
forward-compatibly (decodeIfPresent defaults; unknown HostMsg tags
already decode to .unknown):
- DashboardSnapshot gains `usage` (WireSubscriptionUsage — the Mac's
5-hour/weekly quota gauges) and `statusFeeds` (WireStatusFeed —
active provider incidents); the host re-pushes the dashboard when a
poll changes either.
- WireTodo gains `triage` (raw TriageLevel name); the phone renders
the Mac's TriageBadge honoring the encouraging/classic label
setting.
- ClientMsg.fetchDiff / HostMsg.sessionDiff deliver the full worktree
patch on demand (capped at 512 KB with a truncated flag), gated on
the new WireCapabilities.canFetchDiff so a new phone never sends it
to an old host. iOS renders a file list + colored unified patch,
falling back to the diffstat summary against older hosts.
Round-trip and legacy-decode tests cover the new fields; the full
suite passes apart from the pre-existing fake-backend fixture gaps
and the flaky nvrsion lock-domain test (same failure rate on the
base commit).
Co-Authored-By: Claude Fable 5 <[email protected]>
Plan for a paid cloud runtime: headless Linux nucleicd (ported NucleicCore)
in per-user Cloudflare Containers (not Workers for Platforms), reusing the
nucleic-edge relay; relay+push stay free, $19/mo tier, premium conveniences
that paywall no current functionality.
Co-Authored-By: Claude Fable 5 <[email protected]>