Browser login — the auth key is now optional on both platforms. When the
embedded node starts with no key, TailnetNode asks the backend (LocalAPI
backendStatus — IPN state, deliberately not filesystem heuristics: tsnet
writes logs and a machine key on every start, registered or not) whether a
login is needed, triggers login-interactive, surfaces the auth URL through a
new statusStream()/.needsLogin, and waits for Running (4-minute deadline,
generation-fenced against stop/restart). The Mac auto-opens the login page
from Settings ▸ Remote and shows a re-open button; the iPhone auto-opens only
during user-initiated pairing (a background reconnect that suddenly needs a
login must not eject the user to Safari — Settings ▸ Tailscale carries the
link). The remote-access toggle now reflects the in-flight start instead of
snapping off for the whole login window, and toggling off mid-start is
honored at both commit points (before and after host.start).
LAN↔tailnet fallback — picking Tailnet now keeps LAN on too: the host runs
both listeners under a new CompositeSyncListener (merged accept stream; one
child ending doesn't end the rest) and the pairing QR carries both hints.
The phone builds an ordered candidate chain — LAN first (QR hint or Bonjour),
tailnet second — and walks it on pair and reconnect, so a phone that leaves
the Mac's Wi‑Fi rolls over to the tailnet and rolls back when it returns.
A per-channel 4s connect guard (readiness-checking, bound to exactly its
channel) keeps a stale LAN hint from hanging the chain; a stale-client guard
in consume() keeps a replaced client's tail events from advancing it; chain
exhaustion during pairing lands in a terminal failure instead of spinning on
"Connecting…"; routine pre-fallback handshake errors no longer flash the red
error bubble. "Connected · LAN / Tailnet" shows whichever transport won.
Multi-agent review: 11 confirmed findings (incl. the login gate being dead
code via tsnet's eager state-dir writes, and two connect-timeout races), all
fixed and re-verified. Suite green (24 sync-related tests incl. 3 new
CompositeSyncListener tests); macOS + iOS builds clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
Settings ▸ Remote gains a "Connect via" picker — LAN (default), Tailscale
(tailnet), or Relay (disabled, coming soon). On Tailnet, both devices run an
embedded tsnet node via TailscaleKit (tailscale/libtailscale) and sync frames
flow over the user's tailnet, so the phone can connect from anywhere the
tailnet reaches; Noise E2EE runs above the transport unchanged.
- NucleicTailnet (new target, macOS + iOS): TailnetNode wraps TailscaleKit's
node lifecycle (auth-key login, generation-fenced start/stop since up() is
un-cancellable) and drops to the framework's public C API for the data path
— tailscale_dial/listen/accept hand back full-duplex socketpair fds, wrapped
by FDFrameChannel (DispatchIO) into the shared FrameChannel seam. The Swift
wrapper's one-way connection actors can't carry a bidirectional stream.
- Host: TailnetListener adopts SyncListener; startSyncServer is single-flight
and honors toggle-off/picker changes at the commit point; pairing QRs carry
transport + tailnet IP/port hints (PairingPayload additive optional fields,
forward/backward compatible over CBOR).
- iPhone: pair/reconnect dial over whichever transport the pairing recorded;
Settings gains a Tailscale auth-key field (Keychain, committed on editing
end); connectivity chip shows "Connected · Tailnet".
- TailscaleKit has no SwiftPM distribution: scripts/build-tailscalekit.sh
builds a pinned libtailscale commit into an untracked local xcframework;
Package.swift links it only when present (everything builds without it, the
picker then reports Tailscale support as not built in), and the script
clears SwiftPM's content-keyed manifest cache so the toggle is picked up.
- iOS floor 17.0 → 18.1 (TailscaleKit requires the iOS 18 Swift runtime);
package-app.sh embeds the framework in the .app like Sparkle.
703-test suite: no new failures (the 7 fake-claude/fake-grok staging issues
reproduce identically on an untouched checkout — pre-existing, tracked
separately). New coverage: FDFrameChannel over socketpairs, pairing-payload
version-skew both directions, transport-setting resolution.
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]>
Brings the phone's ambient surfaces (UX_IOS §5/§8) to maturity:
- Notification pipeline (NotificationRouter): local notifications for
approvals and needs-input transitions while backgrounded; low-risk
approvals are actionable from the banner (Allow requires device
auth, high-risk must open the app's Face ID gate); taps deep-link
to the session; app-icon badge = NEEDS YOU count; resolutions
withdraw the notification (first-responder-wins). The relay's
content-free approval.pending tickle localizes via
Localizable.strings.
- Live Activity: new NucleicRemoteWidgets extension target (lock
screen + Dynamic Island) rendering one aggregate Activity —
N running / M waiting + the most urgent session — started/updated/
ended by LiveActivityManager as session state changes.
- Out-of-band push path: nucleic-edge gains POST /v1/push/register
(admin) so the host can upload tokens for LAN-only pairings; the
host's new PushRelayClient (config-gated on NUCLEIC_RELAY_URL +
NUCLEIC_RELAY_ADMIN_SECRET) mirrors Hello.pushToken to the relay
and wakes non-connected phones when an approval arrives, throttled
per device. Everything stays off until the relay is provisioned.
Worker tests (23) and Swift suites pass apart from the pre-existing
fixture gaps and nvrsion flake. Simulated APNs delivery is blocked in
this environment (notification auth can't be granted headlessly).
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]>
Port the Mac's MarkdownText renderer, AppPalette (color-vision modes +
night-softening), appearance settings (theme/text size/color vision),
and BuildBanner channel strip to the iPhone remote. Add session-row
attention/unseen-completion washes and marker icons, Discard action,
branch/worktree options in the new-chat composer, and a transient
error bubble for host wire errors. Stamp the build channel via
NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL,
default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the
notifications prompt so UI previews are scriptable.
Co-Authored-By: Claude Fable 5 <[email protected]>
Concludes an in-progress merge the auto-lander could not finish with
partial commits. Lands the iOS host_exec command breakdown and the
RemoteStore preview-data missing-return fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Squash-promote the nucleic/trunk work (Sessions Auto-Switching, Play
System Sounds Configuration) into dev. Resolved the two divergent
conflicts where dev and trunk had independently implemented the chat
status-sound feature, keeping trunk's canonical version:
- SettingsView: the Sounds settings live under the General tab (trunk's
placement); removed dev's duplicate Sounds section from the Chat tab.
- AppStore: adopt trunk's centralized ChatCueState/announceChatCue
edge-detection (fired from upsertSummary/upsertSummaries), and remove
dev's superseded chatStatusSound(...) helper and its two hand-placed
call sites. Also removed a silent merge-duplicated upsertSummaries.
Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Squash-merge nucleic/trunk into dev (234 commits of accumulated nvrsion work).
Resolved two auto-generated-file conflicts:
- cloud/nucleic-edge/wrangler.jsonc: kept dev's real KV namespace ids and the
xyz.blakeslee.nucleic.remote APNS topic, with binding names corrected to
RELAY_TOKENS/PUSH_TOKENS to match the worker code on both branches.
- Package.resolved: took trunk's newer dependency pins.
Nucleic-Promote: 1
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Desktop channels → xyz.blakeslee.nucleic.desktop.{dev,beta,rc,release} (the stable channel keyword is unchanged; only its bundle-id suffix is "release"). iOS remote → xyz.blakeslee.nucleic.remote, including its Keychain account namespaces, the scanner log subsystem, and the coupled APNS topic.
Correct the Apple Developer Team ID to L7UDTQ6F5W across the Xcode project, ExportOptions, the APNS config + tests, and the signing/cloud docs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>