Grow the universal NucleicRemote app into a width-adaptive shell so a
regular-width iPad renders the macOS sidebar+detail IA while the iPhone
keeps its TabView -- both over the same RemoteStore projection (one host
authority, N renderers).
Phase 1 (adaptive shell):
- AdaptiveRootView branches on horizontalSizeClass: CompactRootView (the
existing iPhone TabView, moved verbatim) vs SplitRootView
(NavigationSplitView) on regular width.
- SplitSidebar: Home/Projects/To-dos/Settings destinations + sessions
grouped under their projects (attention-sorted), connection chip footer.
- SplitDetail selects a destination or a session; a selected session reuses
SessionDetailView keyed .id(sessionID) so switching drives open/close.
- RemoteStore.closeOpen(_:) is now id-guarded so a split-view A->B switch
(onAppear(B) before onDisappear(A)) can't tear down B's fresh subscription.
- IdentityStore.deviceID idiom-tags the prefix (ipad-/iphone-) for new
installs so the host lists a paired iPad correctly.
Phase 2 (width tuning + diff):
- readableColumn() caps+centers Home and the transcript on wide layouts;
a no-op at phone/portrait width.
- SessionDiffView switches on available width (GeometryReader): a Mac-style
two-pane diff (file list + selected file's patch) on wide/landscape, the
phone stack otherwise. UnifiedPatch splits the combined patch per file.
Read-only, same wire, no protocol change.
- Demo diff fixture now carries both files' patches.
iPhone layout and behavior unchanged. Builds clean; verified in the iPad
simulator (demo mode).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A first tailnet pairing with no auth key detours through a Tailscale browser
login that can take minutes. The intro screen — the only UI an unpaired phone
has — showed just "Connecting to your Mac…" for that whole window, and if
the user closed Safari there was no way back to the login page (the Settings
tab with the login link only exists once paired). The intro's pairing status
now explains the approval step and links to the login page whenever the
embedded node is waiting on one.
Co-Authored-By: Claude Fable 5 <[email protected]>
Tailscale (Tailnet) sync transport: transport picker in Settings ▸ Remote,
interactive browser login, and LAN↔tailnet fallback.
Semantic merge fixes (both sides compiled alone but not together):
- handleTransportFailure (dev's friendly transport errors) now checks
connectivity.isLive — .connected gained a transport payload on the branch —
and stays silent while the branch's connect chain still has candidates to
try, so a LAN probe failing over to the tailnet doesn't flash a red error.
- Chain-exhaustion paths keep a friendlier transport-level failure message
when onFailed already surfaced one instead of clobbering it with the
generic handshake error.
Verified on the merge: swift build + 37 sync/transport/store tests green
(incl. dev's FilePairedDeviceStore + the now-fixed contract tests), iOS
simulator build green.
Co-Authored-By: Claude Fable 5 <[email protected]>
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]>