Merge branch 'dev' into canary
This commit is contained in:
@@ -22,6 +22,27 @@
|
|||||||
<string></string>
|
<string></string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<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 /
|
<!-- 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,31 @@ 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>?
|
||||||
|
|
||||||
|
/// 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.
|
/// 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,38 +48,159 @@ 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)) }
|
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
|
return
|
||||||
}
|
}
|
||||||
activity = try? Activity.request(
|
enqueue(state)
|
||||||
attributes: NucleicSessionAttributes(hostName: hostName),
|
}
|
||||||
content: ActivityContent(state: state, staleDate: nil))
|
|
||||||
|
/// 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).
|
/// End the Activity (all idle, or unpaired).
|
||||||
func end() {
|
func end() {
|
||||||
|
updateTask?.cancel()
|
||||||
|
updateTask = nil
|
||||||
|
tokenObservation?.cancel()
|
||||||
|
tokenObservation = nil
|
||||||
|
pendingState = nil
|
||||||
|
lastState = nil
|
||||||
guard let activity else { return }
|
guard let activity else { return }
|
||||||
self.activity = nil
|
self.activity = nil
|
||||||
|
onActivityEnded?(activity.id) // let the Macs stop pushing to this token
|
||||||
Task {
|
Task {
|
||||||
await activity.end(
|
await activity.end(
|
||||||
ActivityContent(state: activity.content.state, staleDate: nil),
|
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||||
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).
|
||||||
@@ -261,11 +282,31 @@ final class RemoteStore: ObservableObject {
|
|||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
|
setupLiveActivityBridge()
|
||||||
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 +477,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
|
||||||
@@ -449,6 +490,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
if hostID == self.activeHostID { self.mirrorActive() }
|
if hostID == self.activeHostID { self.mirrorActive() }
|
||||||
self.refreshAggregate()
|
self.refreshAggregate()
|
||||||
self.flushPendingNotificationDecision()
|
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
|
cb.openSnapshot = { [weak self] snap in
|
||||||
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
||||||
@@ -490,6 +534,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
|
||||||
@@ -789,6 +838,49 @@ final class RemoteStore: ObservableObject {
|
|||||||
activeConnection?.send(msg)
|
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)
|
// MARK: - Demo simulator (offline, interactive)
|
||||||
//
|
//
|
||||||
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
|
// 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):
|
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, .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,
|
// 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))
|
||||||
@@ -100,8 +82,7 @@ struct ApprovalCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
.glassSurface(cornerRadius: 20)
|
||||||
.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
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// One session: a segmented Transcript/Diff read view over a status-driven action area
|
/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with
|
||||||
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
|
/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on
|
||||||
|
/// appear, unsubscribes on disappear.
|
||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
let sessionID: SessionID
|
let sessionID: SessionID
|
||||||
@@ -20,19 +21,7 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
Group {
|
||||||
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()
|
|
||||||
|
|
||||||
if tab == 0 {
|
if tab == 0 {
|
||||||
TranscriptList(events: store.openEvents)
|
TranscriptList(events: store.openEvents)
|
||||||
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
|
} 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.
|
// Older host with no on-demand diff — the stat summary is all it projects.
|
||||||
DiffSummaryView(diffStat: summary?.diffStat)
|
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")
|
.navigationTitle(summary?.title ?? "Session")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.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 {
|
if let summary, store.canControl {
|
||||||
ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) }
|
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
|
/// 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
|
@ViewBuilder
|
||||||
private func controlBar(_ summary: WireSessionSummary) -> some View {
|
private func controlRow(_ summary: WireSessionSummary) -> some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||||
@@ -149,8 +152,6 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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
|
@ViewBuilder
|
||||||
private var actionArea: some View {
|
private var actionArea: some View {
|
||||||
Divider()
|
Group {
|
||||||
if let approval = store.openApprovals.first {
|
if let approval = store.openApprovals.first {
|
||||||
ApprovalCardView(approval: approval)
|
ApprovalCardView(approval: approval)
|
||||||
.padding()
|
|
||||||
} else if let status = summary?.status {
|
} else if let status = summary?.status {
|
||||||
let running = status == .running
|
let running = status == .running
|
||||||
let canCompose = running || status == .awaitingInput || status.isTerminal
|
let canCompose = running || status == .awaitingInput || status.isTerminal
|
||||||
VStack(spacing: 8) {
|
let queued = summary?.queuedMessages ?? []
|
||||||
// Follow-ups submitted mid-turn, held by the host to send when the current turn
|
if canCompose || store.canControl || !queued.isEmpty {
|
||||||
// finishes. Each is shown as its own pill (with its attachments) and — at control
|
VStack(spacing: 10) {
|
||||||
// scope — can be cancelled individually, matching the Mac composer. (They still
|
// Follow-ups submitted mid-turn, held by the host to send when the current
|
||||||
// send as one combined turn.)
|
// turn finishes. Each is shown as its own pill (with its attachments) and —
|
||||||
if let queued = summary?.queuedMessages, !queued.isEmpty {
|
// at control scope — can be cancelled individually, matching the Mac
|
||||||
|
// composer. (They still send as one combined turn.)
|
||||||
|
if !queued.isEmpty {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
ForEach(queued) { message in
|
ForEach(queued) { message in
|
||||||
queuedMessageRow(message)
|
queuedMessageRow(message)
|
||||||
@@ -221,13 +226,15 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
if store.canControl, let summary { controlRow(summary) }
|
||||||
if canCompose {
|
if canCompose {
|
||||||
HStack(spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
|
TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
|
||||||
text: $draft, axis: .vertical)
|
text: $draft, axis: .vertical)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.plain)
|
||||||
.lineLimit(1...4)
|
.lineLimit(1...4)
|
||||||
.keyboardDismissable()
|
.keyboardDismissable()
|
||||||
|
.padding(.vertical, 3)
|
||||||
Button {
|
Button {
|
||||||
store.sendInput(draft, to: sessionID)
|
store.sendInput(draft, to: sessionID)
|
||||||
draft = ""
|
draft = ""
|
||||||
@@ -236,15 +243,21 @@ struct SessionDetailView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
}
|
}
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
||||||
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac —
|
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
|
||||||
// plain Return stays newline in the multiline field.
|
// Mac — plain Return stays newline in the multiline field.
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.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
|
/// 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.
|
/// attachments as chips, and — at control scope — a ✕ that cancels just this message.
|
||||||
@@ -309,7 +322,7 @@ struct TranscriptList: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 10) {
|
LazyVStack(alignment: .leading, spacing: 14) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
TranscriptRow(item: item).id(item.id)
|
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.
|
// 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)
|
||||||
|
|||||||
@@ -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`.
|
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
|
||||||
struct CardBackground: ViewModifier {
|
struct CardBackground: ViewModifier {
|
||||||
var padding: CGFloat = 14
|
var padding: CGFloat = 14
|
||||||
@@ -328,6 +347,10 @@ struct CardBackground: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
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.
|
/// Apply the teal accent app-wide.
|
||||||
func nucleicTint() -> some View { tint(Palette.accent) }
|
func nucleicTint() -> some View { tint(Palette.accent) }
|
||||||
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
|
/// 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
|
/// 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
|
/// (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 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 {
|
struct MessageBubble: View {
|
||||||
let role: TranscriptItem.Role
|
let role: TranscriptItem.Role
|
||||||
let text: String
|
let text: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if role == .user {
|
||||||
HStack {
|
HStack {
|
||||||
if role == .user { Spacer(minLength: 40) }
|
Spacer(minLength: 48)
|
||||||
MarkdownText(markdown: text)
|
MarkdownText(markdown: text)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.padding(10)
|
.padding(.horizontal, 14)
|
||||||
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
|
.padding(.vertical, 9)
|
||||||
in: RoundedRectangle(cornerRadius: 12))
|
.background(Palette.accent.opacity(0.15),
|
||||||
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
|
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
if role == .assistant { Spacer(minLength: 40) }
|
}
|
||||||
|
} else {
|
||||||
|
MarkdownText(markdown: text)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user