nvrsion: Add: Global Icon For Remote Projects to display globe icon next to remote projects in sidebar, matching local projects in appearance, Fix: Chat: Remove Face ID Permission Text, Refactor: Chat: Live Activity Content Density to open app directly into waiting sessions when tapped, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Network Type Transition Optimization to reduce delay during network type switches, Feature Enhancement Planning: All devices in a mesh group should see and be aware of each other; “pair” reframed into “join”; scanning

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 00:28:21 -07:00
co-authored by Nucleic
parent 88329de96a
commit 368978bea8
15 changed files with 731 additions and 158 deletions
+16
View File
@@ -22,6 +22,22 @@
<string></string> <string></string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<!-- The `nucleic://` deep-link scheme the Live Activity taps through on: `nucleic://session/<id>`
opens a waiting session; `nucleic://sessions` surfaces the sessions list. Handled by
`.onOpenURL` → `RemoteStore.handleDeepLink`. -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.nucleic.remote</string>
<key>CFBundleURLSchemes</key>
<array>
<string>nucleic</string>
</array>
</dict>
</array>
<!-- Export compliance: the app ships a Noise-Protocol secure channel (X25519 / <!-- Export compliance: the app ships a Noise-Protocol secure channel (X25519 /
ChaCha20-Poly1305 / SHA-256 via CryptoKit). App Store Connect's export-compliance ChaCha20-Poly1305 / SHA-256 via CryptoKit). App Store Connect's export-compliance
questionnaire classified this as EXEMPT (standard algorithms / mass-market, no questionnaire classified this as EXEMPT (standard algorithms / mass-market, no
@@ -11,8 +11,23 @@ final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private init() {} private init() {}
/// How many sessions the detail rows show. The glance stays a glance — the counts and churn
/// still summarize everything, this just bounds the per-session list.
private static let maxLines = 3
private var activity: Activity<NucleicSessionAttributes>? private var activity: Activity<NucleicSessionAttributes>?
/// The last content we pushed. Updates that don't change it are skipped so we don't spend
/// ActivityKit's update budget on no-ops — `sync` fires on every host message (dashboard,
/// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the
/// budget on those is exactly what makes a *real* change land late and the glance read stale.
private var lastState: NucleicSessionAttributes.ContentState?
/// The newest state waiting to be applied, and the single task draining it. Coalescing to the
/// latest through one serial task means the newest data always wins — firing an unstructured
/// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance.
private var pendingState: NucleicSessionAttributes.ContentState?
private var updateTask: Task<Void, Never>?
/// Reconcile the Activity with the current session set. /// Reconcile the Activity with the current session set.
func sync(hostName: String, sessions: [WireSessionSummary]) { func sync(hostName: String, sessions: [WireSessionSummary]) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
@@ -25,32 +40,79 @@ final class LiveActivityManager {
return return
} }
// The most urgent session headlines: the top waiter, else the freshest runner. // Everything in flight or waiting on the user, attention-first (approvals, then waiting
let top = needsYou.max { $0.updatedAt < $1.updatedAt } // input, then running), freshest within a rank. This is both the detail-row source and
?? running.max { $0.updatedAt < $1.updatedAt } // the set the aggregate churn/approval totals sum over.
let active = live
.filter {
$0.status == .running || $0.status == .provisioning
|| $0.status.needsYou($0.disposition)
}
.sorted { lhs, rhs in
let l = StatusStyle.sortRank(lhs), r = StatusStyle.sortRank(rhs)
return l == r ? lhs.updatedAt > rhs.updatedAt : l < r
}
let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
let added = active.reduce(0) { $0 + ($1.diffStat?.added ?? 0) }
let removed = active.reduce(0) { $0 + ($1.diffStat?.removed ?? 0) }
let lines = active.prefix(Self.maxLines).map(Self.line(for:))
let state = NucleicSessionAttributes.ContentState( let state = NucleicSessionAttributes.ContentState(
runningCount: running.count, runningCount: running.count,
needsYouCount: needsYou.count, needsYouCount: needsYou.count,
topTitle: top?.title, approvalCount: approvals,
topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false) filesChanged: files,
linesAdded: added,
linesRemoved: removed,
lines: Array(lines))
if let activity { push(state, hostName: hostName)
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } }
} else {
// Recover an Activity that survived an app relaunch before starting a new one. /// Apply `state` to the Activity — deduped against the last push and coalesced through one
/// serial task so the newest state always wins.
private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
guard state != lastState else { return }
lastState = state
guard let activity else {
// Recover an Activity that survived an app relaunch before starting a new one; the
// recovered one still needs the fresh state, so fall through to the update pipeline.
if let existing = Activity<NucleicSessionAttributes>.activities.first { if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing activity = existing
Task { await existing.update(ActivityContent(state: state, staleDate: nil)) } enqueue(state)
return } else {
}
activity = try? Activity.request( activity = try? Activity.request(
attributes: NucleicSessionAttributes(hostName: hostName), attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil)) content: ActivityContent(state: state, staleDate: nil))
} }
return
}
enqueue(state)
}
/// Hand the newest state to the serial drainer (starting it if idle).
private func enqueue(_ state: NucleicSessionAttributes.ContentState) {
pendingState = state
guard updateTask == nil else { return } // the running drainer will pick this up
updateTask = Task { @MainActor [weak self] in
guard let self else { return }
while let next = self.pendingState {
self.pendingState = nil
await self.activity?.update(ActivityContent(state: next, staleDate: nil))
}
self.updateTask = nil
}
} }
/// End the Activity (all idle, or unpaired). /// End the Activity (all idle, or unpaired).
func end() { func end() {
updateTask?.cancel()
updateTask = nil
pendingState = nil
lastState = nil
guard let activity else { return } guard let activity else { return }
self.activity = nil self.activity = nil
Task { Task {
@@ -59,4 +121,58 @@ final class LiveActivityManager {
dismissalPolicy: .immediate) dismissalPolicy: .immediate)
} }
} }
// MARK: - Projection
/// Project a wire summary into the widget's self-contained row model.
private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine {
NucleicSessionAttributes.SessionLine(
id: s.sessionID.rawValue,
title: s.title.isEmpty ? s.projectName : s.title,
project: s.projectName,
backend: backend(s.backend),
kind: kind(for: s),
detail: detail(for: s))
}
private static func kind(for s: WireSessionSummary) -> NucleicSessionAttributes.Kind {
switch s.status {
case .awaitingApproval: .approval
case .running: .running
case .provisioning: .provisioning
case .awaitingInput: s.disposition == .completed ? .done : .needsInput
case .idle: .idle
case .finished: .done
case .interrupted, .error: .error
}
}
private static func backend(_ id: BackendID) -> NucleicSessionAttributes.Backend {
switch id {
case .claudeCode: .claude
case .codex, .codexExec: .codex
case .grok: .grok
}
}
/// The compact right-aligned status for a row: the actionable ask wins (approvals, then
/// "waiting on you"), else the worktree churn, else what the agent is up to.
private static func detail(for s: WireSessionSummary) -> String {
if s.status == .awaitingApproval || s.pendingApprovalCount > 0 {
let n = max(s.pendingApprovalCount, 1)
return "\(n) to approve"
}
if s.status == .awaitingInput, s.disposition != .completed {
return "Waiting on you"
}
if let d = s.diffStat, d.filesChanged > 0 {
return "\(d.filesChanged) file\(d.filesChanged == 1 ? "" : "s") +\(d.added) −\(d.removed)"
}
switch s.status {
case .provisioning: return "Starting…"
case .running: return "Working…"
case .awaitingInput: return "Done"
default: return StatusStyle.label(s.status, disposition: s.disposition)
}
}
} }
@@ -60,6 +60,9 @@ final class HostConnection {
var wireError: (WireError) -> Void = { _ in } var wireError: (WireError) -> Void = { _ in }
/// A pairing handshake succeeded — persist the pinned host record. /// A pairing handshake succeeded — persist the pinned host record.
var didPair: (PairedHost) -> Void = { _ in } var didPair: (PairedHost) -> Void = { _ in }
/// The mesh roster changed (mesh "join"): a Mac was learned or revoked via gossip — the
/// registry was updated, so (re)connect to every paired Mac and drop any that left.
var meshRosterChanged: () -> Void = {}
/// The embedded Tailscale node's status changed (for Settings). /// The embedded Tailscale node's status changed (for Settings).
var tailnetStatus: (String?, URL?) -> Void = { _, _ in } var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
} }
@@ -69,6 +72,7 @@ final class HostConnection {
private let identity: DeviceIdentity private let identity: DeviceIdentity
private let discovery: LANDiscovery private let discovery: LANDiscovery
private let pathMonitor: NetworkPathMonitor
// MARK: Connection machine (moved from RemoteStore, one per host) // MARK: Connection machine (moved from RemoteStore, one per host)
@@ -99,12 +103,14 @@ final class HostConnection {
init( init(
hostID: String, hostName: String, hostID: String, hostName: String,
identity: DeviceIdentity, discovery: LANDiscovery, callbacks: Callbacks identity: DeviceIdentity, discovery: LANDiscovery, pathMonitor: NetworkPathMonitor,
callbacks: Callbacks
) { ) {
self.hostID = hostID self.hostID = hostID
self.hostName = hostName self.hostName = hostName
self.identity = identity self.identity = identity
self.discovery = discovery self.discovery = discovery
self.pathMonitor = pathMonitor
self.callbacks = callbacks self.callbacks = callbacks
} }
@@ -171,13 +177,50 @@ final class HostConnection {
callbacks.didUpdate() callbacks.didUpdate()
} }
/// Translate a gossiped mesh member (mesh "join") into a pinnable/dialable `PairedHost`. The
/// member carries the host static key + last-known addresses — everything IK reconnect needs.
/// No relay token yet (the phone never directly paired this Mac); it's adopted from the host's
/// `relayMembership` push after the first LAN/tailnet contact, so first contact needs one of
/// those paths.
static func pairedHost(from member: MeshMember) -> PairedHost {
let (lanHost, lanPort) = splitHostPort(member.addresses?.lanHint)
let tailnetHost = member.addresses?.tailnet
let hasTailnet = !(tailnetHost?.isEmpty ?? true)
return PairedHost(
deviceID: IdentityStore.deviceID(),
hostName: member.label,
hostStaticKey: member.staticPublicKey,
fingerprint: member.staticPublicKey.fingerprintHex,
lanHost: lanHost, lanPort: lanPort,
transport: (hasTailnet ? SyncTransportHint.tailnet : .lan).rawValue,
tailnetHost: hasTailnet ? tailnetHost : nil,
// The sync port on a tailnet is fixed protocol-wide (SyncTransportSetting.tailnetPort);
// a gossiped address carries only the IP.
tailnetPort: hasTailnet ? 43_753 : nil,
relayRoomID: member.addresses?.relayRoomID,
relayMembershipToken: nil, relayURL: nil)
}
/// Split a "host:port" hint (last-colon split so a bracketed IPv6 host survives).
private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) {
guard let hint, let colon = hint.lastIndex(of: ":"),
let port = UInt16(hint[hint.index(after: colon)...]) else { return (nil, nil) }
var host = String(hint[..<colon])
if host.hasPrefix("["), host.hasSuffix("]") { host = String(host.dropFirst().dropLast()) }
return (host.isEmpty ? nil : host, port)
}
private func buildCandidates( private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?, fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)?, tailnet: (host: String?, port: UInt16?)?,
relay: (membershipToken: String?, url: String?)? = nil relay: (membershipToken: String?, url: String?)? = nil
) -> [TransportAttempt] { ) -> [TransportAttempt] {
var candidates: [TransportAttempt] = [] var candidates: [TransportAttempt] = []
if let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) { // Only offer LAN when the device actually has a LAN-capable path (Wi-Fi/wired). On cellular
// the pinned `lanHost:lanPort` is unreachable, so including it here would just burn the
// connect timeout before falling through — skip it and dial tailnet/relay immediately.
if pathMonitor.canUseLAN,
let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
candidates.append(.lan(endpoint)) candidates.append(.lan(endpoint))
} }
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn { if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
@@ -280,7 +323,10 @@ final class HostConnection {
channel: channel, identity: identity, hostStaticKey: hostStaticKey, channel: channel, identity: identity, hostStaticKey: hostStaticKey,
mode: mode, deviceID: deviceID, mode: mode, deviceID: deviceID,
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex, deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel) releaseChannel: BuildInfo.current.channel.releaseChannel,
// Mesh "join": advertise roster gossip so a host pushes its group view — the phone
// then auto-learns and connects to every Mac in the mesh, not just the one it scanned.
clientCaps: WireClientCapabilities(mesh: 1, canSyncRoster: true))
self.client = client self.client = client
consume(client, pairingPayload: pairingPayload) consume(client, pairingPayload: pairingPayload)
} }
@@ -421,9 +467,37 @@ final class HostConnection {
case .peerList(let peers): case .peerList(let peers):
meshPeers = peers meshPeers = peers
callbacks.didUpdate() callbacks.didUpdate()
case .meshRoster(let push):
// Mesh "join": the host gossiped its group view. Auto-learn every Mac member into the
// paired-hosts registry (so the multiplexer connects to all of them), and drop any the
// group revoked. Phone members are ignored — a phone doesn't dial other phones.
var changed = false
for member in push.members where member.kind == .mac || member.capabilities.canHost {
// A mac deviceID is `sha256(staticPublicKey)`; reject a forged (id, key) pair.
guard DeviceIdentity.hostID(ofStaticKey: member.staticPublicKey) == member.deviceID
else { continue }
let fingerprint = member.staticPublicKey.fingerprintHex
guard fingerprint != self.hostID else { continue } // that's the Mac we're on
if IdentityStore.pairedHost(id: fingerprint) == nil { changed = true }
IdentityStore.mergePairedHost(Self.pairedHost(from: member))
}
for tombstone in push.tombstones {
// fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key.
let fingerprint = String(tombstone.deviceID.prefix(16))
if IdentityStore.pairedHost(id: fingerprint) != nil {
IdentityStore.removePairedHost(id: fingerprint)
changed = true
}
}
if changed { callbacks.meshRosterChanged() }
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck: case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one. // Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
break break
case .transcriptChunk, .transcriptFetchComplete, .transcriptUnavailable:
// Full-transcript fetch replies (mesh sync) only arrive in response to a
// `fetchTranscript` this device sent. A phone pulls full history on demand via a
// dedicated fetch path (not this subscribe stream), so nothing to do here yet.
break
case .relayMembership(let membership): case .relayMembership(let membership):
// The host issued/refreshed this device's relay credential (mesh P2). Persist it // The host issued/refreshed this device's relay credential (mesh P2). Persist it
// in place (no reordering — this can arrive from a non-active Mac) so the relay // in place (no reordering — this can arrive from a non-active Mac) so the relay
@@ -476,6 +550,27 @@ final class HostConnection {
Task { await client.send(msg) } Task { await client.send(msg) }
} }
/// The device's network path changed (Wi-Fi ⇄ cellular, joined/left a network). React now rather
/// than waiting for a zombie LAN socket to time out or the reconnect backoff to elapse — this is
/// what makes the transport switch feel immediate. Only reconnect-managed hosts (a pinned host)
/// re-plan here; a host mid-pairing runs its own one-shot candidate chain.
func networkPathChanged() {
guard let host = pinnedHost else { return }
// Using (or dialing) LAN but LAN is no longer reachable → the socket is dead in all but name.
// Drop it and re-plan now; `buildCandidates` skips LAN and dials tailnet/relay.
if activeTransport == .lan, !pathMonitor.canUseLAN {
reconnectAttempts = 0
reconnect(to: host)
return
}
// Offline/reconnecting and the network just came back or changed → retry immediately instead
// of sitting out the remaining backoff.
if !connectivity.isLive, pathMonitor.isSatisfied {
reconnectAttempts = 0
reconnect(to: host)
}
}
private func scheduleRetry(host: PairedHost?) { private func scheduleRetry(host: PairedHost?) {
guard let host else { return } guard let host else { return }
reconnectAttempts += 1 reconnectAttempts += 1
@@ -112,6 +112,26 @@ enum IdentityStore {
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID }) savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
} }
/// Merge a Mac learned via mesh roster gossip (mesh "join"): append it if new, else refresh its
/// hints **in place** — unlike `upsertPairedHost`, this must NOT reorder (which would hijack the
/// active host) and must not clobber a relay token or our established device id with the
/// gossiped nils.
static func mergePairedHost(_ host: PairedHost) {
var hosts = pairedHosts()
if let index = hosts.firstIndex(where: { $0.fingerprint == host.fingerprint }) {
var merged = host
let existing = hosts[index]
merged.deviceID = existing.deviceID // keep our established id for this host
merged.relayMembershipToken = host.relayMembershipToken ?? existing.relayMembershipToken
merged.relayRoomID = host.relayRoomID ?? existing.relayRoomID
merged.relayURL = host.relayURL ?? existing.relayURL
hosts[index] = merged
} else {
hosts.append(host)
}
savePairedHosts(hosts)
}
/// Mutate one host's record **in place**, preserving registry order — unlike /// Mutate one host's record **in place**, preserving registry order — unlike
/// `upsertPairedHost`, this must not make the host active (it backs background /// `upsertPairedHost`, this must not make the host active (it backs background
/// refreshes like the relay-membership push, which can arrive from a non-active Mac). /// refreshes like the relay-membership push, which can arrive from a non-active Mac).
@@ -86,6 +86,11 @@ final class RemoteStore: ObservableObject {
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears. /// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
@Published var pendingRoute: SessionID? @Published var pendingRoute: SessionID?
/// A request to surface the sessions list itself, no specific session — the Live Activity's
/// "work running, nothing waiting" tap. Compact jumps to the Sessions tab; the iPad split
/// reveals its sidebar (which always lists sessions). One-shot: the shell clears it.
@Published var pendingSessionsList = false
/// Whether the app is foreground-active (scene phase), mirrored here so the store can /// Whether the app is foreground-active (scene phase), mirrored here so the store can
/// decide which transitions deserve a notification. /// decide which transitions deserve a notification.
private(set) var isActive = true private(set) var isActive = true
@@ -95,6 +100,19 @@ final class RemoteStore: ObservableObject {
/// Route to a session from a notification tap (deep link). /// Route to a session from a notification tap (deep link).
func route(to sessionID: SessionID) { pendingRoute = sessionID } func route(to sessionID: SessionID) { pendingRoute = sessionID }
/// Ask the shell to show the sessions list (no specific session opened).
func showSessionsList() { pendingSessionsList = true }
/// Follow a `nucleic://` deep link — the Live Activity tap. A session waiting on the user
/// opens straight into that session; the plain sessions link just surfaces the list.
func handleDeepLink(_ url: URL) {
switch NucleicDeepLink.route(for: url) {
case .session(let id): route(to: SessionID(rawValue: id))
case .sessionsList: showSessionsList()
case .none: break
}
}
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live /// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
/// channel; if the socket dropped while backgrounded, reconnect and send once ready — /// channel; if the socket dropped while backgrounded, reconnect and send once ready —
/// but only briefly (a stale queued approval must never fire minutes later; see /// but only briefly (a stale queued approval must never fire minutes later; see
@@ -159,6 +177,9 @@ final class RemoteStore: ObservableObject {
private let identity = IdentityStore.loadOrCreateIdentity() private let identity = IdentityStore.loadOrCreateIdentity()
let discovery = LANDiscovery() let discovery = LANDiscovery()
/// Proactive network-path awareness (shared across every host connection): flips transports the
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
let pathMonitor = NetworkPathMonitor()
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so /// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly). /// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
@@ -262,10 +283,29 @@ final class RemoteStore: ObservableObject {
func onAppear() { func onAppear() {
if demoMode { seedDemo(); return } if demoMode { seedDemo(); return }
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
pathMonitor.start()
discovery.start() discovery.start()
if isPaired { reconnect() } if isPaired { reconnect() }
} }
/// A network-path change: tell each host connection to re-plan its transport now.
private func handlePathChange() {
guard !demoMode else { return }
for conn in connections.values { conn.networkPathChanged() }
}
/// Returning to the foreground: the network may have changed while the app was suspended (and
/// path/keepalive callbacks were frozen). Re-read the path and re-dial anything not live so the
/// transport is correct by the time the UI is on screen, rather than after a backoff.
func onForeground() {
guard !demoMode, isPaired else { return }
pathMonitor.refresh()
reconnect()
}
private func seedDemo() { private func seedDemo() {
connectivity = .connected(.lan) connectivity = .connected(.lan)
hostName = "Andrew's Mac" hostName = "Andrew's Mac"
@@ -436,7 +476,7 @@ final class RemoteStore: ObservableObject {
private func makeConnection(hostID: String, hostName: String) -> HostConnection { private func makeConnection(hostID: String, hostName: String) -> HostConnection {
HostConnection( HostConnection(
hostID: hostID, hostName: hostName, identity: identity, discovery: discovery, hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
callbacks: callbacks(for: hostID)) pathMonitor: pathMonitor, callbacks: callbacks(for: hostID))
} }
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured /// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
@@ -490,6 +530,11 @@ final class RemoteStore: ObservableObject {
IdentityStore.savePairedHost(host) IdentityStore.savePairedHost(host)
self.activeHostID = host.fingerprint self.activeHostID = host.fingerprint
} }
cb.meshRosterChanged = { [weak self] in
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
// (connecting the newcomer) and drop any that left — without switching the active host.
self?.reconnect()
}
cb.tailnetStatus = { [weak self] status, loginURL in cb.tailnetStatus = { [weak self] status, loginURL in
self?.tailnetStatus = status self?.tailnetStatus = status
self?.tailnetLoginURL = loginURL self?.tailnetLoginURL = loginURL
@@ -842,7 +887,7 @@ final class RemoteStore: ObservableObject {
case .setSessionShipBranch(let id, let branch): case .setSessionShipBranch(let id, let branch):
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate, .ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate, .meshRoster,
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these, // Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
// and demo has no peer Macs, so they're inert here. // and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel: .transferOffer, .transferChunk, .transferCommit, .transferCancel:
@@ -28,7 +28,15 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
} }
init(endpoint: NWEndpoint) { init(endpoint: NWEndpoint) {
let params = NWParameters.tcp // Short TCP keepalive so a silently-dead LAN link (Wi-Fi dropped, Mac left the subnet) is
// detected in ~15s instead of the OS default of minutes — the belt to the path-monitor's
// braces for the case where a path still exists but the route to the Mac is gone.
let tcp = NWProtocolTCP.Options()
tcp.enableKeepalive = true
tcp.keepaliveIdle = 5
tcp.keepaliveInterval = 5
tcp.keepaliveCount = 2
let params = NWParameters(tls: nil, tcp: tcp)
params.includePeerToPeer = true params.includePeerToPeer = true
connection = NWConnection(to: endpoint, using: params) connection = NWConnection(to: endpoint, using: params)
peerDescription = "\(endpoint)" peerDescription = "\(endpoint)"
@@ -39,6 +47,13 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
connection.stateUpdateHandler = { [weak self] state in connection.stateUpdateHandler = { [weak self] state in
switch state { switch state {
case .ready: self?.markReady(); self?.onReady?() case .ready: self?.markReady(); self?.onReady?()
case .waiting(let error):
// No usable path right now (Wi-Fi dropped, host unreachable). NWConnection would sit
// in `.waiting` indefinitely, holding the transport hostage — the core of the "still
// on LAN after leaving Wi-Fi" stall. Fail fast so the store falls straight through to
// tailnet/relay. (Healthy connections go setup→preparing→ready and never hit this;
// the connect-timeout guard covers a wrong-network socket that stays `.preparing`.)
self?.onFailed?("waiting: \(error)"); self?.continuation.finish()
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish() case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
case .cancelled: self?.continuation.finish() case .cancelled: self?.continuation.finish()
default: break default: break
@@ -0,0 +1,51 @@
import Foundation
import Network
/// Watches the device's network path (Wi‑Fi ⇄ cellular ⇄ offline) so the app can switch transports
/// the instant the link changes, instead of waiting for a dead LAN socket to time out. This is the
/// missing proactive signal behind the "iOS sticks on LAN after leaving Wi‑Fi" stall: the phone's
/// pinned `lanHost:lanPort` is only reachable over Wi‑Fi/wired, so on cellular the LAN candidate is a
/// guaranteed stall. `HostConnection` skips LAN when `canUseLAN` is false and re‑plans on `onChange`.
@MainActor
final class NetworkPathMonitor: ObservableObject {
/// True when a LAN‑capable path (Wi‑Fi or wired Ethernet) is available — the only paths on which
/// the pinned `lanHost:lanPort` could resolve. Cellular‑only ⇒ false, so the LAN candidate is
/// dropped and we dial tailnet/relay straight away. Defaults optimistic so a not‑yet‑started
/// monitor never spuriously suppresses LAN.
@Published private(set) var canUseLAN = true
/// True when any network path is available at all (used to decide whether an immediate reconnect
/// is even worth attempting).
@Published private(set) var isSatisfied = true
/// Fired on the main actor whenever a *relevant* aspect of the path changed — LAN availability or
/// overall reachability. The cue for connections to re‑plan their transport immediately rather
/// than waiting out a reconnect backoff.
var onChange: (() -> Void)?
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "nucleic.remote.path")
private var started = false
func start() {
guard !started else { return }
started = true
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in self?.apply(path) }
}
monitor.start(queue: queue)
}
/// Re‑read the current path synchronously — used on foreground, where an update that landed while
/// the app was suspended may not have been redelivered yet.
func refresh() { apply(monitor.currentPath) }
private func apply(_ path: NWPath) {
let lan = path.status == .satisfied
&& (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet))
let satisfied = path.status == .satisfied
let changed = lan != canUseLAN || satisfied != isSatisfied
canUseLAN = lan
isSatisfied = satisfied
if changed { onChange?() }
}
}
@@ -36,7 +36,7 @@ final class NotificationRouter: NSObject {
// Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1); // Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1);
// Allow requires device auth so a pocket-tap can't grant. High-risk approvals use // Allow requires device auth so a pocket-tap can't grant. High-risk approvals use
// the action-less category — they must open the app (Face ID gate on the card). // the action-less category — they must open the app and be answered on the card.
let allow = UNNotificationAction( let allow = UNNotificationAction(
identifier: Self.allowAction, title: "Allow", identifier: Self.allowAction, title: "Allow",
options: [.authenticationRequired]) options: [.authenticationRequired])
@@ -10,6 +10,9 @@ struct NucleicRemoteApp: App {
WindowGroup { WindowGroup {
RootView() RootView()
.environmentObject(store) .environmentObject(store)
// A Live Activity tap arrives as a `nucleic://` deep link: open the waiting
// session, or surface the sessions list when work is only running.
.onOpenURL { store.handleDeepLink($0) }
.onAppear { .onAppear {
store.onAppear() store.onAppear()
// Notification categories + delegate must be in place before any // Notification categories + delegate must be in place before any
@@ -28,7 +31,12 @@ struct NucleicRemoteApp: App {
// activity flag current so it only notifies for background transitions. // activity flag current so it only notifies for background transitions.
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
store.setScenePhaseActive(phase == .active) store.setScenePhaseActive(phase == .active)
if phase == .active { Task { await HeartbeatReporter.reportIfDue() } } if phase == .active {
// Re-plan transports immediately on foreground — the network may have
// changed while suspended, so don't wait for a dead socket to notice.
store.onForeground()
Task { await HeartbeatReporter.reportIfDue() }
}
} }
} }
} }
@@ -49,6 +49,10 @@ struct CompactRootView: View {
.onChange(of: store.pendingRoute) { _, route in .onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 } if route != nil { tab = 1 }
} }
// A Live Activity tap with work running but nothing waiting: show the Sessions list.
.onChange(of: store.pendingSessionsList) { _, show in
if show { tab = 1; store.pendingSessionsList = false }
}
} }
} }
@@ -92,6 +96,13 @@ struct SplitRootView: View {
selection = .session(route) selection = .session(route)
store.pendingRoute = nil store.pendingRoute = nil
} }
// A Live Activity tap with work running but nothing waiting: reveal the sidebar, which
// always lists the live sessions (the regular-width analogue of the Sessions tab).
.onChange(of: store.pendingSessionsList) { _, show in
guard show else { return }
columnVisibility = .all
store.pendingSessionsList = false
}
.onAppear { .onAppear {
// Offline UI previews (NUCLEIC_DEMO): open straight into one session. // Offline UI previews (NUCLEIC_DEMO): open straight into one session.
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty { if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
@@ -1,23 +1,17 @@
import SwiftUI import SwiftUI
import LocalAuthentication
import NucleicProtocol import NucleicProtocol
/// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network) /// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network)
/// requires Face ID/Touch ID before Allow is enabled — deliberate friction mirroring the Mac. /// still routes through the app (never the notification banner) so approval is a deliberate,
/// First-responder-wins: if the Mac (or another phone) answers first, the host's /// in-context tap. First-responder-wins: if the Mac (or another phone) answers first, the
/// `approvalResolved` removes this card. /// host's `approvalResolved` removes this card.
struct ApprovalCardView: View { struct ApprovalCardView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
let approval: ApprovalRequest let approval: ApprovalRequest
@State private var biometricPassed = false
@State private var authError: String?
@State private var showAlwaysMenu = false @State private var showAlwaysMenu = false
private var requiresBiometric: Bool { approval.risk.isHigh } private var allowEnabled: Bool { store.connectivity.isLive }
private var allowEnabled: Bool {
store.connectivity.isLive && (!requiresBiometric || biometricPassed)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
@@ -51,18 +45,6 @@ struct ApprovalCardView: View {
ApprovalDetailBox(text: approval.input.approvalDetail) ApprovalDetailBox(text: approval.input.approvalDetail)
} }
if requiresBiometric && !biometricPassed {
Button {
authenticate()
} label: {
Label("Face ID required to allow", systemImage: "faceid").font(.caption)
}
.buttonStyle(.bordered)
}
if let authError {
Text(authError).font(.caption2).foregroundStyle(.red)
}
HStack(spacing: 10) { HStack(spacing: 10) {
Button(role: .destructive) { Button(role: .destructive) {
store.respond(approval, .deny(reason: nil)) store.respond(approval, .deny(reason: nil))
@@ -101,7 +83,6 @@ struct ApprovalCardView: View {
} }
.padding() .padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14)) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
.onChange(of: approval.id) { _, _ in biometricPassed = false; authError = nil }
} }
private func alwaysLabel(_ scope: AlwaysScope) -> String { private func alwaysLabel(_ scope: AlwaysScope) -> String {
@@ -111,23 +92,6 @@ struct ApprovalCardView: View {
case .toolNameWithPattern: "\(approval.toolName) matching this pattern" case .toolNameWithPattern: "\(approval.toolName) matching this pattern"
} }
} }
private func authenticate() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
authError = "Biometrics unavailable"; return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Approve a \(approval.risk.label) action"
) { success, evalError in
Task { @MainActor in
if success { biometricPassed = true; authError = nil }
else { authError = evalError?.localizedDescription ?? "Authentication failed" }
}
}
}
} }
/// The full content of an approval, wrapped and selectable. The box sizes to its /// The full content of an approval, wrapped and selectable. The box sizes to its
@@ -71,7 +71,7 @@ struct SettingsView: View {
// the active Mac unpairs the live connection; removing another just forgets it. // the active Mac unpairs the live connection; removing another just forgets it.
let pairedHosts = IdentityStore.pairedHosts() let pairedHosts = IdentityStore.pairedHosts()
if !pairedHosts.isEmpty { if !pairedHosts.isEmpty {
Section("Paired Macs") { Section("Mesh") {
ForEach(pairedHosts, id: \.fingerprint) { host in ForEach(pairedHosts, id: \.fingerprint) { host in
let isActive = host.fingerprint == pairedHosts.last?.fingerprint let isActive = host.fingerprint == pairedHosts.last?.fingerprint
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -162,7 +162,7 @@ struct SettingsView: View {
Button { Button {
showScanner = true showScanner = true
} label: { } label: {
Label("Pair with a Mac", systemImage: "qrcode.viewfinder") Label("Join a Mac's group", systemImage: "qrcode.viewfinder")
} }
Button { Button {
showManualPair = true showManualPair = true
@@ -213,8 +213,8 @@ struct PairingIntroView: View {
var body: some View { var body: some View {
VStack(spacing: 24) { VStack(spacing: 24) {
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint) Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
Text("Pair with your Mac").font(.title2.weight(.semibold)) Text("Join your Mac's group").font(.title2.weight(.semibold))
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.") Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the join code beneath it manually. Scanning any Mac in the group connects you to all of them.")
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 32) .padding(.horizontal, 32)
@@ -331,9 +331,9 @@ struct ManualPairingView: View {
.focused($fieldFocused) .focused($fieldFocused)
.onChange(of: code) { error = nil } .onChange(of: code) { error = nil }
} header: { } header: {
Text("Pairing code") Text("Join code")
} footer: { } footer: {
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.") Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the join link shown beneath the QR code, then paste it here.")
} }
if let error { if let error {
Label(error, systemImage: "exclamationmark.triangle.fill") Label(error, systemImage: "exclamationmark.triangle.fill")
@@ -342,7 +342,7 @@ struct ManualPairingView: View {
Button { Button {
submit() submit()
} label: { } label: {
Label("Pair", systemImage: "link").frame(maxWidth: .infinity) Label("Join", systemImage: "link").frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -2,125 +2,252 @@ import ActivityKit
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3): /// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3).
/// glance = running count + needs-you count + the most urgent session; tap opens the app /// The glance answers "what is Nucleic doing right now?": headline counts (running / waiting /
/// (straight to the approval, via the notification-style route). Self-contained colors — the /// approvals), the total churn in flight, and the most urgent sessions with their state and diff.
/// extension doesn't link the app's Theme, so the two palette constants it needs live here /// Tapping opens the app (straight to the approval, via the notification-style route).
/// (the same teal/amber/blue as `Palette`). ///
/// Self-contained colors/glyphs — the extension doesn't link the app's `Theme`, so the small
/// palette + glyph maps it needs live here (the same teal/amber/blue/green as `Palette`).
struct SessionLiveActivity: Widget { struct SessionLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
// Lock screen banner.
LockScreenView(state: context.state, hostName: context.attributes.hostName) LockScreenView(state: context.state, hostName: context.attributes.hostName)
.widgetURL(context.state.tapURL)
.activityBackgroundTint(Color.black.opacity(0.55)) .activityBackgroundTint(Color.black.opacity(0.55))
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in } dynamicIsland: { context in
DynamicIsland { let state = context.state
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled") CountBadge(
.font(.headline) systemImage: "circle.lefthalf.filled",
.foregroundStyle(ActivityPalette.active) count: state.runningCount, tint: ActivityPalette.active)
} }
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
if context.state.needsYouCount > 0 { if state.approvalCount > 0 {
Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill") CountBadge(
.font(.headline) systemImage: "exclamationmark.shield.fill",
.foregroundStyle(ActivityPalette.attention) count: state.approvalCount, tint: ActivityPalette.attention)
} else if state.needsYouCount > 0 {
CountBadge(
systemImage: "bubble.left.fill",
count: state.needsYouCount, tint: ActivityPalette.accent)
} else { } else {
Label("0", systemImage: "checkmark.circle") Label("0", systemImage: "checkmark.circle")
.font(.headline) .font(.headline).foregroundStyle(.secondary)
.foregroundStyle(.secondary)
} }
} }
DynamicIslandExpandedRegion(.bottom) { DynamicIslandExpandedRegion(.bottom) {
if let title = context.state.topTitle { VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) { ForEach(state.lines.prefix(2)) { line in
Image(systemName: context.state.topNeedsYou SessionRow(line: line)
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") }
.foregroundStyle(context.state.topNeedsYou if state.hasChurn {
? ActivityPalette.attention : ActivityPalette.active) ChurnLine(state: state).padding(.top, 1)
Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
Spacer()
Text(statusLine(context.state))
.font(.caption).foregroundStyle(.secondary)
} }
} else {
Text(statusLine(context.state))
.font(.caption).foregroundStyle(.secondary)
} }
} }
} compactLeading: { } compactLeading: {
Image(systemName: "circle.lefthalf.filled") Image(systemName: state.needsAttention
.foregroundStyle(ActivityPalette.active) ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running))
} compactTrailing: { .foregroundStyle(state.needsAttention
if context.state.needsYouCount > 0 {
Text("\(context.state.needsYouCount)")
.font(.caption2.bold())
.foregroundStyle(ActivityPalette.attention)
} else {
Text("\(context.state.runningCount)")
.font(.caption2.bold())
.foregroundStyle(ActivityPalette.active)
}
} minimal: {
Image(systemName: context.state.needsYouCount > 0
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
.foregroundStyle(context.state.needsYouCount > 0
? ActivityPalette.attention : ActivityPalette.active) ? ActivityPalette.attention : ActivityPalette.active)
} compactTrailing: {
Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)")
.font(.caption2.bold())
.foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active)
} minimal: {
Image(systemName: state.needsAttention
? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running))
.foregroundStyle(state.needsAttention
? ActivityPalette.attention : ActivityPalette.active)
}
// The compact/minimal presentations tap through to the same target as the lock
// screen: a waiting session opens itself; otherwise the sessions list.
.widgetURL(state.tapURL)
} }
} }
} }
private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String { // MARK: - Lock screen
var parts: [String] = []
if state.runningCount > 0 { parts.append("\(state.runningCount) running") }
if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") }
return parts.isEmpty ? "Idle" : parts.joined(separator: " · ")
}
}
private struct LockScreenView: View { private struct LockScreenView: View {
let state: NucleicSessionAttributes.ContentState let state: NucleicSessionAttributes.ContentState
let hostName: String let hostName: String
var body: some View { var body: some View {
HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) {
Image(systemName: state.needsYouCount > 0 // Header: brand + host, and the headline counts.
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled") HStack(spacing: 8) {
.font(.title2) Image(systemName: "hexagon.fill")
.foregroundStyle(state.needsYouCount > 0 .font(.footnote)
? ActivityPalette.attention : ActivityPalette.active) .foregroundStyle(ActivityPalette.active)
VStack(alignment: .leading, spacing: 2) {
if let title = state.topTitle {
Text(title).font(.headline).lineLimit(1)
}
HStack(spacing: 4) {
if state.runningCount > 0 {
Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active)
}
if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") }
if state.needsYouCount > 0 {
Text("\(state.needsYouCount) waiting for you")
.foregroundStyle(ActivityPalette.attention)
}
if state.runningCount == 0 && state.needsYouCount == 0 {
Text("Idle").foregroundStyle(.secondary)
}
}
.font(.subheadline)
}
Spacer()
Text(hostName) Text(hostName)
.font(.caption2).foregroundStyle(.secondary) .font(.caption.weight(.semibold))
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 8)
CountChips(state: state)
}
// The most urgent sessions — what's actually happening.
ForEach(state.lines.prefix(3)) { line in
SessionRow(line: line)
}
// Aggregate churn footer — how much work is in flight right now.
if state.hasChurn {
Divider().overlay(Color.white.opacity(0.12))
ChurnLine(state: state)
}
} }
.padding(14) .padding(14)
.foregroundStyle(.white) .foregroundStyle(.white)
} }
} }
/// The two categorical colors the Activity needs — same constants as the app's `Palette`. // MARK: - Shared pieces
/// One session: state glyph + title + "project · Backend" + a right-aligned status/diff.
private struct SessionRow: View {
let line: NucleicSessionAttributes.SessionLine
var body: some View {
HStack(spacing: 8) {
Image(systemName: ActivityPalette.glyph(line.kind))
.font(.footnote)
.foregroundStyle(ActivityPalette.color(line.kind))
.frame(width: 16)
VStack(alignment: .leading, spacing: 1) {
Text(line.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
HStack(spacing: 4) {
Text(line.project)
Text("·").foregroundStyle(.secondary)
Text(line.backend.label)
.foregroundStyle(ActivityPalette.backendColor(line.backend))
}
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 6)
Text(line.detail)
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(ActivityPalette.color(line.kind))
.lineLimit(1)
}
}
}
/// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show.
private struct CountChips: View {
let state: NucleicSessionAttributes.ContentState
var body: some View {
HStack(spacing: 10) {
if state.runningCount > 0 {
chip("circle.lefthalf.filled", state.runningCount, ActivityPalette.active)
}
if state.needsYouCount > 0 {
chip("bubble.left.fill", state.needsYouCount, ActivityPalette.accent)
}
if state.approvalCount > 0 {
chip("exclamationmark.shield.fill", state.approvalCount, ActivityPalette.attention)
}
}
}
private func chip(_ systemImage: String, _ count: Int, _ tint: Color) -> some View {
HStack(spacing: 3) {
Image(systemName: systemImage).font(.caption2)
Text("\(count)").font(.caption.weight(.semibold)).monospacedDigit()
}
.foregroundStyle(tint)
}
}
/// Aggregate worktree churn: "N files +A −R".
private struct ChurnLine: View {
let state: NucleicSessionAttributes.ContentState
var body: some View {
HStack(spacing: 6) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(state.filesChanged) file\(state.filesChanged == 1 ? "" : "s") changed")
.foregroundStyle(.secondary)
Text("+\(state.linesAdded)")
.foregroundStyle(ActivityPalette.added)
Text("−\(state.linesRemoved)")
.foregroundStyle(ActivityPalette.removed)
}
.font(.caption2.weight(.medium))
.monospacedDigit()
.lineLimit(1)
}
}
/// Dynamic Island expanded leading/trailing count badge.
private struct CountBadge: View {
let systemImage: String
let count: Int
let tint: Color
var body: some View {
Label("\(count)", systemImage: systemImage)
.font(.headline)
.monospacedDigit()
.foregroundStyle(tint)
}
}
/// The categorical colors + glyphs the Activity needs — the same semantics as the app's
/// `Palette.status` / `StatusStyle.symbol`, re-declared because the widget extension can't link
/// the app's `Theme`.
private enum ActivityPalette { private enum ActivityPalette {
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // approval amber
static let accent = Color(red: 0.16, green: 0.72, blue: 0.68) // needs-input teal
static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green
static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red
static let neutral = Color.secondary
static let added = Color(red: 0.30, green: 0.78, blue: 0.45)
static let removed = Color(red: 0.92, green: 0.34, blue: 0.34)
static func glyph(_ kind: NucleicSessionAttributes.Kind) -> String {
switch kind {
case .running: "circle.lefthalf.filled"
case .provisioning: "circle.dotted"
case .approval: "exclamationmark.triangle.fill"
case .needsInput: "bubble.left.fill"
case .done: "checkmark.circle.fill"
case .error: "xmark.octagon.fill"
case .idle: "circle"
}
}
static func color(_ kind: NucleicSessionAttributes.Kind) -> Color {
switch kind {
case .running: active
case .provisioning: neutral
case .approval: attention
case .needsInput: accent
case .done: success
case .error: danger
case .idle: neutral
}
}
static func backendColor(_ backend: NucleicSessionAttributes.Backend) -> Color {
switch backend {
case .claude: Color(red: 0.85, green: 0.52, blue: 0.30) // Anthropic clay
case .codex: Color(red: 0.45, green: 0.72, blue: 0.55) // OpenAI green
case .grok: Color(red: 0.55, green: 0.58, blue: 0.66) // xAI graphite
case .other: Color.secondary
}
}
} }
+2 -2
View File
@@ -16,8 +16,8 @@ All wire/crypto logic is shared with the Mac via the **`NucleicProtocol`** Swift
- **State** — `RemoteStore` (`ObservableObject`) is the single on-device UI state, a pure - **State** — `RemoteStore` (`ObservableObject`) is the single on-device UI state, a pure
projection of the host. Identity + pinned host live in `IdentityStore` (Keychain + UserDefaults). projection of the host. Identity + pinned host live in `IdentityStore` (Keychain + UserDefaults).
- **UI** — SwiftUI: `SessionsView` (attention-first list), `SessionDetailView` - **UI** — SwiftUI: `SessionsView` (attention-first list), `SessionDetailView`
(transcript/diff + status-driven action area), `ApprovalCardView` (Face ID gate on (transcript/diff + status-driven action area), `ApprovalCardView` (approve/deny,
high-risk), `PairingScannerView` (QR), `SettingsView`. high-risk answered in-app), `PairingScannerView` (QR), `SettingsView`.
## Build & run ## Build & run
@@ -2,21 +2,126 @@ import ActivityKit
import Foundation import Foundation
/// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity /// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity
/// respects iOS's budget better than one per session): "N running, M waiting" plus the most /// respects iOS's budget better than one per session). It answers "what is Nucleic doing right
/// urgent session's title. Shared between the app (which starts/updates it) and the widget /// now?" at a glance: the headline counts (running / waiting / approvals), the total churn in
/// extension (which renders it). /// flight, and a few of the most urgent sessions with their state and diff. Shared between the
/// app (which starts/updates it) and the widget extension (which renders it).
///
/// Deliberately free of `NucleicProtocol` types — the widget extension links only this file, so
/// the state carries plain value types and its own small `Backend`/`Kind` enums rather than the
/// wire `BackendID`/`SessionStatus`.
struct NucleicSessionAttributes: ActivityAttributes { struct NucleicSessionAttributes: ActivityAttributes {
/// The agent behind a session — drives a row's provider tag/tint.
enum Backend: String, Codable, Hashable {
case claude, codex, grok, other
var label: String {
switch self {
case .claude: "Claude"
case .codex: "Codex"
case .grok: "Grok"
case .other: "Agent"
}
}
}
/// A session's live state, collapsed to what the glance needs — drives each row's glyph/tint.
enum Kind: String, Codable, Hashable {
case running // agent actively working a turn
case provisioning // worktree/setup spinning up
case approval // blocked on a human approval decision (loudest)
case needsInput // turn ended asking the user something
case done // turn finished its work; nothing required
case error // errored / interrupted
case idle // created, not yet working
}
/// One agent session surfaced in the activity's detail rows.
struct SessionLine: Codable, Hashable, Identifiable {
var id: String
/// The session's title (falls back to the project name upstream).
var title: String
/// Project the session belongs to.
var project: String
var backend: Backend
var kind: Kind
/// A compact right-aligned status: its diff ("3 files +42 −7"), "2 to approve",
/// "Waiting on you", "Working…", etc.
var detail: String
}
struct ContentState: Codable, Hashable { struct ContentState: Codable, Hashable {
/// Sessions actively working a turn. /// Sessions actively working a turn.
var runningCount: Int var runningCount: Int
/// Sessions blocked on the user (approval or next prompt) — the loud number. /// Sessions blocked on the user (approval or next prompt) — the loud number.
var needsYouCount: Int var needsYouCount: Int
/// The most urgent session's title: the top waiting one, else the busiest running. /// Total individual approval decisions waiting across all sessions — the "act now" count.
var topTitle: String? var approvalCount: Int
/// Whether `topTitle` is waiting on the user (amber) vs just running (blue). /// Aggregate worktree churn across the active sessions.
var topNeedsYou: Bool var filesChanged: Int
var linesAdded: Int
var linesRemoved: Int
/// The most urgent sessions, attention-first (capped, typically ≤3): the detail rows.
var lines: [SessionLine]
/// The single most urgent session (drives the compact/minimal regions).
var top: SessionLine? { lines.first }
/// Whether anything needs the user (waiting session or a pending approval).
var needsAttention: Bool { needsYouCount > 0 || approvalCount > 0 }
/// Whether any worktree has uncommitted changes to summarize.
var hasChurn: Bool { filesChanged > 0 }
} }
/// The paired Mac's name (static for the Activity's lifetime). /// The paired Mac's name (static for the Activity's lifetime).
var hostName: String var hostName: String
} }
extension NucleicSessionAttributes.ContentState {
/// Where a tap on the whole activity should go (UX_IOS §5.3): a session waiting on the user
/// opens straight into that session; otherwise (work running, nothing waiting) the sessions
/// list. `lines` is attention-first, so any waiting session is `lines.first` — and always in
/// the capped list, since waiting outranks running in the sort.
var tapURL: URL? {
if let waiting = lines.first(where: { $0.kind == .approval || $0.kind == .needsInput }) {
return NucleicDeepLink.session(waiting.id)
}
return NucleicDeepLink.sessionsList
}
}
/// The `nucleic://` deep links the Live Activity emits and the app consumes. Kept in the shared
/// file so the widget that *builds* a URL and the app that *parses* one can't drift.
enum NucleicDeepLink {
static let scheme = "nucleic"
/// Open a specific session's detail: `nucleic://session/<id>`.
static func session(_ id: String) -> URL? {
var c = URLComponents()
c.scheme = scheme
c.host = "session"
c.path = "/\(id)"
return c.url
}
/// Surface the sessions list, no specific session: `nucleic://sessions`.
static var sessionsList: URL? { URL(string: "\(scheme)://sessions") }
/// What an incoming link asks the app to do.
enum Route: Equatable {
case session(String)
case sessionsList
}
static func route(for url: URL) -> Route? {
guard url.scheme == scheme else { return nil }
switch url.host {
case "session":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return id.isEmpty ? nil : .session(id)
case "sessions":
return .sessionsList
default:
return nil
}
}
}