Merge branch 'dev' into canary
This commit is contained in:
@@ -22,6 +22,27 @@
|
||||
<string></string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<!-- Let the Macs' APNs pushes refresh the glance promptly while the phone is locked — without
|
||||
this, iOS budget-throttles frequent Live Activity updates and the lock screen lags
|
||||
(UX_IOS §5.3). -->
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<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 /
|
||||
ChaCha20-Poly1305 / SHA-256 via CryptoKit). App Store Connect's export-compliance
|
||||
questionnaire classified this as EXEMPT (standard algorithms / mass-market, no
|
||||
|
||||
@@ -11,8 +11,31 @@ final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
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>?
|
||||
|
||||
/// 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>?
|
||||
|
||||
/// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell
|
||||
/// them it ended). The Macs use the token to keep this glance fresh over APNs while the phone
|
||||
/// is backgrounded and its sync socket is suspended (UX_IOS §5.3).
|
||||
var onPushToken: ((_ token: String, _ activityID: String) -> Void)?
|
||||
var onActivityEnded: ((_ activityID: String) -> Void)?
|
||||
/// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end.
|
||||
private var tokenObservation: Task<Void, Never>?
|
||||
|
||||
/// Reconcile the Activity with the current session set.
|
||||
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
@@ -25,38 +48,159 @@ final class LiveActivityManager {
|
||||
return
|
||||
}
|
||||
|
||||
// The most urgent session headlines: the top waiter, else the freshest runner.
|
||||
let top = needsYou.max { $0.updatedAt < $1.updatedAt }
|
||||
?? running.max { $0.updatedAt < $1.updatedAt }
|
||||
// Everything in flight or waiting on the user, attention-first (approvals, then waiting
|
||||
// input, then running), freshest within a rank. This is both the detail-row source and
|
||||
// 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(
|
||||
runningCount: running.count,
|
||||
needsYouCount: needsYou.count,
|
||||
topTitle: top?.title,
|
||||
topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false)
|
||||
approvalCount: approvals,
|
||||
filesChanged: files,
|
||||
linesAdded: added,
|
||||
linesRemoved: removed,
|
||||
lines: Array(lines))
|
||||
|
||||
if let activity {
|
||||
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
} else {
|
||||
// Recover an Activity that survived an app relaunch before starting a new one.
|
||||
push(state, hostName: hostName)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
activity = existing
|
||||
Task { await existing.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
observePushToken(existing)
|
||||
enqueue(state)
|
||||
} else {
|
||||
// `pushType: .token` opts the activity into APNs updates — the Macs push new
|
||||
// content-state to the token so the glance stays fresh while the phone is locked.
|
||||
let started = try? Activity.request(
|
||||
attributes: NucleicSessionAttributes(hostName: hostName),
|
||||
content: ActivityContent(state: state, staleDate: nil),
|
||||
pushType: .token)
|
||||
activity = started
|
||||
if let started { observePushToken(started) }
|
||||
}
|
||||
return
|
||||
}
|
||||
activity = try? Activity.request(
|
||||
attributes: NucleicSessionAttributes(hostName: hostName),
|
||||
content: ActivityContent(state: state, staleDate: nil))
|
||||
enqueue(state)
|
||||
}
|
||||
|
||||
/// Forward the activity's APNS update token (and its rotations) to `RemoteStore`.
|
||||
private func observePushToken(_ activity: Activity<NucleicSessionAttributes>) {
|
||||
tokenObservation?.cancel()
|
||||
tokenObservation = Task { [weak self] in
|
||||
for await tokenData in activity.pushTokenUpdates {
|
||||
let hex = tokenData.map { String(format: "%02x", $0) }.joined()
|
||||
self?.onPushToken?(hex, activity.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
func end() {
|
||||
updateTask?.cancel()
|
||||
updateTask = nil
|
||||
tokenObservation?.cancel()
|
||||
tokenObservation = nil
|
||||
pendingState = nil
|
||||
lastState = nil
|
||||
guard let activity else { return }
|
||||
self.activity = nil
|
||||
onActivityEnded?(activity.id) // let the Macs stop pushing to this token
|
||||
Task {
|
||||
await activity.end(
|
||||
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||
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 }
|
||||
/// A pairing handshake succeeded — persist the pinned host record.
|
||||
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).
|
||||
var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
|
||||
}
|
||||
@@ -69,6 +72,7 @@ final class HostConnection {
|
||||
|
||||
private let identity: DeviceIdentity
|
||||
private let discovery: LANDiscovery
|
||||
private let pathMonitor: NetworkPathMonitor
|
||||
|
||||
// MARK: Connection machine (moved from RemoteStore, one per host)
|
||||
|
||||
@@ -99,12 +103,14 @@ final class HostConnection {
|
||||
|
||||
init(
|
||||
hostID: String, hostName: String,
|
||||
identity: DeviceIdentity, discovery: LANDiscovery, callbacks: Callbacks
|
||||
identity: DeviceIdentity, discovery: LANDiscovery, pathMonitor: NetworkPathMonitor,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
self.hostID = hostID
|
||||
self.hostName = hostName
|
||||
self.identity = identity
|
||||
self.discovery = discovery
|
||||
self.pathMonitor = pathMonitor
|
||||
self.callbacks = callbacks
|
||||
}
|
||||
|
||||
@@ -171,13 +177,50 @@ final class HostConnection {
|
||||
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(
|
||||
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||
tailnet: (host: String?, port: UInt16?)?,
|
||||
relay: (membershipToken: String?, url: String?)? = nil
|
||||
) -> [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))
|
||||
}
|
||||
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,
|
||||
mode: mode, deviceID: deviceID,
|
||||
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
|
||||
consume(client, pairingPayload: pairingPayload)
|
||||
}
|
||||
@@ -421,9 +467,37 @@ final class HostConnection {
|
||||
case .peerList(let peers):
|
||||
meshPeers = peers
|
||||
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:
|
||||
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
|
||||
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):
|
||||
// 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
|
||||
@@ -476,6 +550,27 @@ final class HostConnection {
|
||||
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?) {
|
||||
guard let host else { return }
|
||||
reconnectAttempts += 1
|
||||
|
||||
@@ -112,6 +112,26 @@ enum IdentityStore {
|
||||
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
|
||||
/// `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).
|
||||
|
||||
@@ -86,6 +86,11 @@ final class RemoteStore: ObservableObject {
|
||||
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
|
||||
@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
|
||||
/// decide which transitions deserve a notification.
|
||||
private(set) var isActive = true
|
||||
@@ -95,6 +100,19 @@ final class RemoteStore: ObservableObject {
|
||||
/// Route to a session from a notification tap (deep link).
|
||||
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
|
||||
/// 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
|
||||
@@ -159,6 +177,9 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||
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
|
||||
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
|
||||
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
|
||||
@@ -261,11 +282,31 @@ final class RemoteStore: ObservableObject {
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func onAppear() {
|
||||
setupLiveActivityBridge()
|
||||
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()
|
||||
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() {
|
||||
connectivity = .connected(.lan)
|
||||
hostName = "Andrew's Mac"
|
||||
@@ -436,7 +477,7 @@ final class RemoteStore: ObservableObject {
|
||||
private func makeConnection(hostID: String, hostName: String) -> HostConnection {
|
||||
HostConnection(
|
||||
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
|
||||
@@ -449,6 +490,9 @@ final class RemoteStore: ObservableObject {
|
||||
if hostID == self.activeHostID { self.mirrorActive() }
|
||||
self.refreshAggregate()
|
||||
self.flushPendingNotificationDecision()
|
||||
// A host that just connected (or reconnected) needs the current Live Activity token
|
||||
// so it can push while the phone is away.
|
||||
self.syncLiveActivityRegistration()
|
||||
}
|
||||
cb.openSnapshot = { [weak self] snap in
|
||||
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
||||
@@ -490,6 +534,11 @@ final class RemoteStore: ObservableObject {
|
||||
IdentityStore.savePairedHost(host)
|
||||
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
|
||||
self?.tailnetStatus = status
|
||||
self?.tailnetLoginURL = loginURL
|
||||
@@ -789,6 +838,49 @@ final class RemoteStore: ObservableObject {
|
||||
activeConnection?.send(msg)
|
||||
}
|
||||
|
||||
// MARK: - Live Activity push registration (UX_IOS §5.3)
|
||||
|
||||
/// The current Live Activity's APNS update token + id, shipped to every capable Mac so it can
|
||||
/// refresh the lock-screen glance over APNs while the phone is backgrounded and its socket is
|
||||
/// suspended. Sent to *all* connected hosts (not just the active one) — while the phone is
|
||||
/// away, whichever Mac has work to report should be able to push.
|
||||
private var liveActivityReg: (token: String, activityID: String)?
|
||||
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
|
||||
/// re-sent to everyone when the token rotates).
|
||||
private var liveActivitySentTo: Set<String> = []
|
||||
|
||||
private func setupLiveActivityBridge() {
|
||||
LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
|
||||
guard let self, self.liveActivityReg?.token != token else { return }
|
||||
self.liveActivityReg = (token, activityID)
|
||||
self.liveActivitySentTo.removeAll() // a fresh token must reach every host again
|
||||
self.syncLiveActivityRegistration()
|
||||
}
|
||||
LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
|
||||
guard let self else { return }
|
||||
self.liveActivityReg = nil
|
||||
self.liveActivitySentTo.removeAll()
|
||||
for conn in self.connections.values where conn.capabilities.canPushLiveActivity {
|
||||
conn.send(.endLiveActivity(activityID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the current Live Activity token to every live, capable host that hasn't got it yet.
|
||||
/// Called when the token changes and on every connection update, so a host that just connected
|
||||
/// — or reconnected while the phone was away — learns the token it needs to push.
|
||||
private func syncLiveActivityRegistration() {
|
||||
guard let reg = liveActivityReg else { return }
|
||||
// A host that dropped should re-register when it returns.
|
||||
liveActivitySentTo = liveActivitySentTo.filter { connections[$0]?.connectivity.isLive == true }
|
||||
for (id, conn) in connections {
|
||||
guard conn.connectivity.isLive, conn.capabilities.canPushLiveActivity,
|
||||
!liveActivitySentTo.contains(id) else { continue }
|
||||
conn.send(.registerLiveActivity(reg.token, reg.activityID))
|
||||
liveActivitySentTo.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Demo simulator (offline, interactive)
|
||||
//
|
||||
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
|
||||
@@ -842,7 +934,11 @@ final class RemoteStore: ObservableObject {
|
||||
case .setSessionShipBranch(let id, let branch):
|
||||
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
||||
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
||||
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate,
|
||||
.ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
|
||||
.addressUpdate, .meshRoster,
|
||||
// Live Activity push registration is a real-connection concern (there's no host to
|
||||
// push in demo), so it's inert here.
|
||||
.registerLiveActivity, .endLiveActivity,
|
||||
// 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.
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
||||
|
||||
@@ -28,7 +28,15 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
||||
}
|
||||
|
||||
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
|
||||
connection = NWConnection(to: endpoint, using: params)
|
||||
peerDescription = "\(endpoint)"
|
||||
@@ -39,6 +47,13 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
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 .cancelled: self?.continuation.finish()
|
||||
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);
|
||||
// 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(
|
||||
identifier: Self.allowAction, title: "Allow",
|
||||
options: [.authenticationRequired])
|
||||
|
||||
@@ -10,6 +10,9 @@ struct NucleicRemoteApp: App {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.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 {
|
||||
store.onAppear()
|
||||
// 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.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
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
|
||||
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)
|
||||
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 {
|
||||
// Offline UI previews (NUCLEIC_DEMO): open straight into one session.
|
||||
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
import NucleicProtocol
|
||||
|
||||
/// 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.
|
||||
/// First-responder-wins: if the Mac (or another phone) answers first, the host's
|
||||
/// `approvalResolved` removes this card.
|
||||
/// still routes through the app (never the notification banner) so approval is a deliberate,
|
||||
/// in-context tap. First-responder-wins: if the Mac (or another phone) answers first, the
|
||||
/// host's `approvalResolved` removes this card.
|
||||
struct ApprovalCardView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let approval: ApprovalRequest
|
||||
|
||||
@State private var biometricPassed = false
|
||||
@State private var authError: String?
|
||||
@State private var showAlwaysMenu = false
|
||||
|
||||
private var requiresBiometric: Bool { approval.risk.isHigh }
|
||||
private var allowEnabled: Bool {
|
||||
store.connectivity.isLive && (!requiresBiometric || biometricPassed)
|
||||
}
|
||||
private var allowEnabled: Bool { store.connectivity.isLive }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -51,18 +45,6 @@ struct ApprovalCardView: View {
|
||||
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) {
|
||||
Button(role: .destructive) {
|
||||
store.respond(approval, .deny(reason: nil))
|
||||
@@ -100,8 +82,7 @@ struct ApprovalCardView: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.onChange(of: approval.id) { _, _ in biometricPassed = false; authError = nil }
|
||||
.glassSurface(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
||||
@@ -111,23 +92,6 @@ struct ApprovalCardView: View {
|
||||
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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One session: a segmented Transcript/Diff read view over a status-driven action area
|
||||
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
|
||||
/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with
|
||||
/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on
|
||||
/// appear, unsubscribes on disappear.
|
||||
struct SessionDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let sessionID: SessionID
|
||||
@@ -20,19 +21,7 @@ struct SessionDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $tab) {
|
||||
Text("Transcript").tag(0)
|
||||
Text("Diff").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if store.canControl, let summary { controlBar(summary) }
|
||||
|
||||
Divider()
|
||||
|
||||
Group {
|
||||
if tab == 0 {
|
||||
TranscriptList(events: store.openEvents)
|
||||
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
|
||||
@@ -42,12 +31,25 @@ struct SessionDetailView: View {
|
||||
// Older host with no on-demand diff — the stat summary is all it projects.
|
||||
DiffSummaryView(diffStat: summary?.diffStat)
|
||||
}
|
||||
|
||||
actionArea
|
||||
}
|
||||
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting in
|
||||
// a boxed strip below it, so the transcript runs the full height of the screen.
|
||||
.safeAreaInset(edge: .bottom) { actionArea }
|
||||
.navigationTitle(summary?.title ?? "Session")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
// Transcript/Diff lives in the navigation bar (icon segments) rather than a
|
||||
// dedicated row — it's a mode flip, not content, and shouldn't cost chat height.
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Picker("View", selection: $tab) {
|
||||
Image(systemName: "text.bubble").tag(0)
|
||||
.accessibilityLabel("Transcript")
|
||||
Image(systemName: "plus.forwardslash.minus").tag(1)
|
||||
.accessibilityLabel("Diff")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
}
|
||||
if let summary, store.canControl {
|
||||
ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) }
|
||||
}
|
||||
@@ -124,9 +126,10 @@ struct SessionDetailView: View {
|
||||
}
|
||||
|
||||
/// The model / effort / auto controls for the open session — the mobile echo of the Mac's
|
||||
/// session header. Reads current state from the summary; each change is a control intent.
|
||||
/// session header, folded into the glass chat bar so they don't cost a strip of transcript
|
||||
/// height. Reads current state from the summary; each change is a control intent.
|
||||
@ViewBuilder
|
||||
private func controlBar(_ summary: WireSessionSummary) -> some View {
|
||||
private func controlRow(_ summary: WireSessionSummary) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||
@@ -149,8 +152,6 @@ struct SessionDetailView: View {
|
||||
}
|
||||
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -192,21 +193,25 @@ struct SessionDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating action layer: one Liquid Glass bar carrying the queued pills, the working
|
||||
/// indicator, the session controls, and the composer — or the approval card when a gate is
|
||||
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
|
||||
@ViewBuilder
|
||||
private var actionArea: some View {
|
||||
Divider()
|
||||
Group {
|
||||
if let approval = store.openApprovals.first {
|
||||
ApprovalCardView(approval: approval)
|
||||
.padding()
|
||||
} else if let status = summary?.status {
|
||||
let running = status == .running
|
||||
let canCompose = running || status == .awaitingInput || status.isTerminal
|
||||
VStack(spacing: 8) {
|
||||
// Follow-ups submitted mid-turn, held by the host to send when the current turn
|
||||
// finishes. Each is shown as its own pill (with its attachments) and — at control
|
||||
// scope — can be cancelled individually, matching the Mac composer. (They still
|
||||
// send as one combined turn.)
|
||||
if let queued = summary?.queuedMessages, !queued.isEmpty {
|
||||
let queued = summary?.queuedMessages ?? []
|
||||
if canCompose || store.canControl || !queued.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
// Follow-ups submitted mid-turn, held by the host to send when the current
|
||||
// turn finishes. Each is shown as its own pill (with its attachments) and —
|
||||
// at control scope — can be cancelled individually, matching the Mac
|
||||
// composer. (They still send as one combined turn.)
|
||||
if !queued.isEmpty {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(queued) { message in
|
||||
queuedMessageRow(message)
|
||||
@@ -221,13 +226,15 @@ struct SessionDetailView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if store.canControl, let summary { controlRow(summary) }
|
||||
if canCompose {
|
||||
HStack(spacing: 8) {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
|
||||
text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1...4)
|
||||
.keyboardDismissable()
|
||||
.padding(.vertical, 3)
|
||||
Button {
|
||||
store.sendInput(draft, to: sessionID)
|
||||
draft = ""
|
||||
@@ -236,15 +243,21 @@ struct SessionDetailView: View {
|
||||
.font(.title2)
|
||||
}
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
||||
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac —
|
||||
// plain Return stays newline in the multiline field.
|
||||
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
|
||||
// Mac — plain Return stays newline in the multiline field.
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.glassSurface(cornerRadius: 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
/// A queued follow-up shown above the composer: its text (or attachment names) with the
|
||||
/// attachments as chips, and — at control scope — a ✕ that cancels just this message.
|
||||
@@ -309,7 +322,7 @@ struct TranscriptList: View {
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
LazyVStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(items) { item in
|
||||
TranscriptRow(item: item).id(item.id)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ struct SettingsView: View {
|
||||
// the active Mac unpairs the live connection; removing another just forgets it.
|
||||
let pairedHosts = IdentityStore.pairedHosts()
|
||||
if !pairedHosts.isEmpty {
|
||||
Section("Paired Macs") {
|
||||
Section("Mesh") {
|
||||
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||||
let isActive = host.fingerprint == pairedHosts.last?.fingerprint
|
||||
HStack(spacing: 10) {
|
||||
@@ -162,7 +162,7 @@ struct SettingsView: View {
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
||||
Label("Join a Mac's group", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
Button {
|
||||
showManualPair = true
|
||||
@@ -213,8 +213,8 @@ struct PairingIntroView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
||||
Text("Pair with your Mac").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("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 join code beneath it manually. Scanning any Mac in the group connects you to all of them.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 32)
|
||||
@@ -331,9 +331,9 @@ struct ManualPairingView: View {
|
||||
.focused($fieldFocused)
|
||||
.onChange(of: code) { error = nil }
|
||||
} header: {
|
||||
Text("Pairing code")
|
||||
Text("Join code")
|
||||
} 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 {
|
||||
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||
@@ -342,7 +342,7 @@ struct ManualPairingView: View {
|
||||
Button {
|
||||
submit()
|
||||
} label: {
|
||||
Label("Pair", systemImage: "link").frame(maxWidth: .infinity)
|
||||
Label("Join", systemImage: "link").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
@@ -313,6 +313,25 @@ struct KeyboardDismissable: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// A floating Liquid Glass surface for bars that hover over scrolling content (the session
|
||||
/// chat bar, approval card). Real `glassEffect` on iOS 26; an ultra-thin material with a
|
||||
/// hairline edge on earlier systems so the bar still reads as a translucent layer.
|
||||
struct GlassSurface: ViewModifier {
|
||||
var cornerRadius: CGFloat = 24
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
|
||||
} else {
|
||||
content
|
||||
.background(.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
|
||||
struct CardBackground: ViewModifier {
|
||||
var padding: CGFloat = 14
|
||||
@@ -328,6 +347,10 @@ struct CardBackground: ViewModifier {
|
||||
|
||||
extension View {
|
||||
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
||||
/// Float content on Liquid Glass (material fallback pre-iOS 26). See `GlassSurface`.
|
||||
func glassSurface(cornerRadius: CGFloat = 24) -> some View {
|
||||
modifier(GlassSurface(cornerRadius: cornerRadius))
|
||||
}
|
||||
/// Apply the teal accent app-wide.
|
||||
func nucleicTint() -> some View { tint(Palette.accent) }
|
||||
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
|
||||
|
||||
@@ -63,20 +63,27 @@ struct TranscriptRow: View {
|
||||
/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses
|
||||
/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving
|
||||
/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
|
||||
/// The user's messages sit right-aligned in a soft accent bubble; the assistant's prose runs
|
||||
/// open on the background — no box — so the transcript reads as a page, not a stack of cards.
|
||||
struct MessageBubble: View {
|
||||
let role: TranscriptItem.Role
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
if role == .user {
|
||||
HStack {
|
||||
if role == .user { Spacer(minLength: 40) }
|
||||
Spacer(minLength: 48)
|
||||
MarkdownText(markdown: text)
|
||||
.textSelection(.enabled)
|
||||
.padding(10)
|
||||
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
|
||||
if role == .assistant { Spacer(minLength: 40) }
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.background(Palette.accent.opacity(0.15),
|
||||
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
} else {
|
||||
MarkdownText(markdown: text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,125 +2,252 @@ import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// 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
|
||||
/// (straight to the approval, via the notification-style route). Self-contained colors — the
|
||||
/// extension doesn't link the app's Theme, so the two palette constants it needs live here
|
||||
/// (the same teal/amber/blue as `Palette`).
|
||||
/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3).
|
||||
/// The glance answers "what is Nucleic doing right now?": headline counts (running / waiting /
|
||||
/// approvals), the total churn in flight, and the most urgent sessions with their state and diff.
|
||||
/// Tapping opens the app (straight to the approval, via the notification-style route).
|
||||
///
|
||||
/// 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 {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
|
||||
// Lock screen banner.
|
||||
LockScreenView(state: context.state, hostName: context.attributes.hostName)
|
||||
.widgetURL(context.state.tapURL)
|
||||
.activityBackgroundTint(Color.black.opacity(0.55))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
let state = context.state
|
||||
return DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled")
|
||||
.font(.headline)
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
CountBadge(
|
||||
systemImage: "circle.lefthalf.filled",
|
||||
count: state.runningCount, tint: ActivityPalette.active)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if context.state.needsYouCount > 0 {
|
||||
Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(ActivityPalette.attention)
|
||||
if state.approvalCount > 0 {
|
||||
CountBadge(
|
||||
systemImage: "exclamationmark.shield.fill",
|
||||
count: state.approvalCount, tint: ActivityPalette.attention)
|
||||
} else if state.needsYouCount > 0 {
|
||||
CountBadge(
|
||||
systemImage: "bubble.left.fill",
|
||||
count: state.needsYouCount, tint: ActivityPalette.accent)
|
||||
} else {
|
||||
Label("0", systemImage: "checkmark.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.headline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
if let title = context.state.topTitle {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: context.state.topNeedsYou
|
||||
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||
.foregroundStyle(context.state.topNeedsYou
|
||||
? ActivityPalette.attention : ActivityPalette.active)
|
||||
Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
|
||||
Spacer()
|
||||
Text(statusLine(context.state))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(state.lines.prefix(2)) { line in
|
||||
SessionRow(line: line)
|
||||
}
|
||||
if state.hasChurn {
|
||||
ChurnLine(state: state).padding(.top, 1)
|
||||
}
|
||||
} else {
|
||||
Text(statusLine(context.state))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "circle.lefthalf.filled")
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
Image(systemName: state.needsAttention
|
||||
? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running))
|
||||
.foregroundStyle(state.needsAttention
|
||||
? ActivityPalette.attention : ActivityPalette.active)
|
||||
} compactTrailing: {
|
||||
if context.state.needsYouCount > 0 {
|
||||
Text("\(context.state.needsYouCount)")
|
||||
Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(ActivityPalette.attention)
|
||||
} else {
|
||||
Text("\(context.state.runningCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
}
|
||||
.foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active)
|
||||
} minimal: {
|
||||
Image(systemName: context.state.needsYouCount > 0
|
||||
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||
.foregroundStyle(context.state.needsYouCount > 0
|
||||
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 {
|
||||
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: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock screen
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let state: NucleicSessionAttributes.ContentState
|
||||
let hostName: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: state.needsYouCount > 0
|
||||
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||
.font(.title2)
|
||||
.foregroundStyle(state.needsYouCount > 0
|
||||
? ActivityPalette.attention : 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()
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header: brand + host, and the headline counts.
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "hexagon.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
Text(hostName)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.caption.weight(.semibold))
|
||||
.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)
|
||||
.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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
projection of the host. Identity + pinned host live in `IdentityStore` (Keychain + UserDefaults).
|
||||
- **UI** — SwiftUI: `SessionsView` (attention-first list), `SessionDetailView`
|
||||
(transcript/diff + status-driven action area), `ApprovalCardView` (Face ID gate on
|
||||
high-risk), `PairingScannerView` (QR), `SettingsView`.
|
||||
(transcript/diff + status-driven action area), `ApprovalCardView` (approve/deny,
|
||||
high-risk answered in-app), `PairingScannerView` (QR), `SettingsView`.
|
||||
|
||||
## Build & run
|
||||
|
||||
|
||||
@@ -2,21 +2,126 @@ import ActivityKit
|
||||
import Foundation
|
||||
|
||||
/// 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
|
||||
/// urgent session's title. Shared between the app (which starts/updates it) and the widget
|
||||
/// extension (which renders it).
|
||||
/// respects iOS's budget better than one per session). It answers "what is Nucleic doing right
|
||||
/// now?" at a glance: the headline counts (running / waiting / approvals), the total churn in
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
/// Sessions actively working a turn.
|
||||
var runningCount: Int
|
||||
/// Sessions blocked on the user (approval or next prompt) — the loud number.
|
||||
var needsYouCount: Int
|
||||
/// The most urgent session's title: the top waiting one, else the busiest running.
|
||||
var topTitle: String?
|
||||
/// Whether `topTitle` is waiting on the user (amber) vs just running (blue).
|
||||
var topNeedsYou: Bool
|
||||
/// Total individual approval decisions waiting across all sessions — the "act now" count.
|
||||
var approvalCount: Int
|
||||
/// Aggregate worktree churn across the active sessions.
|
||||
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).
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user