The relay Worker (nucleic-edge at relay.nucleic.blakeslee.xyz) is deployed, so land the
formerly deploy-gated client side of the data path:
- NucleicProtocol/Sync/RelayTransport.swift: RelayAPI (one base URL for REST + WS,
membership -> connection token trade), RelayWebSocket (ordered sends, ping keepalive,
ping-confirmed connect), RelayFrameChannel (client leg, WireFraming inside WS binary,
presence fail-fast when the room has no host), RelayPresence.
- NucleicCore/Sync/RelayAccess.swift: X25519 PoP enrollment (RelayEnrollment), room
credential in the login Keychain (separate from the push credential), membership minting
with re-enroll-on-401.
- NucleicCore/Sync/RelayListener.swift: host SyncListener demuxing the room socket into
per-tag virtual FrameChannels via RelayEnvelope; presence-driven reaping; backoff redial;
injectable RelayRoomSocket seam for tests.
- Wire: additive HostMsg.relayMembership(WireRelayMembership) pushed after every hello
(SyncHost.register -> AppStore mint) so devices paired before the relay adopt it and the
~90-day token refreshes on each connect; the pairing QR also carries a bootstrap
membership so first contact can ride the relay. Old clients ignore the unknown tag.
- AppStore: .relay joins the listener composite behind the Connection-methods checkbox
(failure degrades to a status row), advertises relayRoomID in PeerAddresses, mints the
QR bootstrap in beginPairing.
- Desktop UI: the Nucleic Private Relay toggle is enabled (was "coming soon"); the
LAN-only banner offers it alongside Tailnet.
- iOS: relay is the last dial candidate in HostConnection pair + reconnect (10s handshake
watchdog); PairedHost persists relayRoomID/relayMembershipToken/relayURL; the
relayMembership push updates the registry in place; Settings shows Relay in Transports.
Tests: RelayTransportTests, RelayListenerTests, SyncHostTests relay push + QR bootstrap.
Full suite green (783 core + 113 protocol + 2 new); iOS simulator build succeeds. Live
smoke test against the deployed Worker passed end-to-end (PoP enroll, both token tiers,
two-socket frame round-trip through the Room DO with correct envelope tags).
Known limits: host revoke-on-unpair not wired (endpoint is admin-only); PeerClient
(Mac<->Mac) doesn't dial the relay yet.
Co-Authored-By: Claude Fable 5 <[email protected]>
First step of Phase 3 (iOS multi-host), per docs/MESH_TRANSFER.md. Converts IdentityStore from a
single `nucleic.pairedHost` slot to a `nucleic.pairedHosts` registry (ordered [PairedHost], keyed by
fingerprint = hostID), with a one-time migration of the legacy single value on first read (then the
old key is removed) so an upgrade keeps its Mac.
New registry API — pairedHosts(), pairedHost(id:), upsertPairedHost(_:), removePairedHost(id:) — for
the coming RemoteStore multiplexer. A single-host bridge keeps every current caller unchanged and
behavior identical: loadPairedHost() returns the active (most-recently-paired) host, savePairedHost
upserts + makes active, clearPairedHost removes the active. RemoteStore is untouched and still holds
one connection.
Settings gains a "Paired Macs" list (the visible artifact): each registered Mac with its fingerprint,
an "Active" marker, and a per-host remove (removing the active one unpairs the live connection;
removing another just forgets it) — this also gives a removal path now that pairing a new Mac keeps
the old one in the registry instead of overwriting it.
Verified with an iOS Simulator build (BUILD SUCCEEDED). Remaining Phase 3: the RemoteStore
[HostID: HostConnection] multiplexer (simultaneous connections, (hostID, sessionID) keying),
host-qualified notifications/Live Activity, host switcher UI, demo N-hosts, and the two-Mac
tailnet spike.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
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]>
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]>
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM
library as a local package. Builds for the iOS 27 simulator and launches to the
pairing screen.
- Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp).
- Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect,
hello/welcome, HostMsg→Event stream).
- State: RemoteStore (ObservableObject) — the single on-device projection of host
state; IdentityStore persists the device identity (Keychain) + pinned host.
- UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff +
status-driven action area / composer), ApprovalCardView with Face ID gate on
high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR),
SettingsView, connection chip. Same status glyphs/semantics as the Mac.
Add-iPhone QR display + server start live on the macOS side (follow-up); push /
Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite
the blanket *.xcodeproj rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>