Merge nucleic/humble-feather-yak-edwe into dev

This commit is contained in:
2026-07-13 17:51:17 -07:00
parent 6269a4f5a5
commit 2f7bdab5ce
5 changed files with 126 additions and 17 deletions
@@ -13,6 +13,9 @@ enum IntentError: Error, CustomLocalizedStringResourceConvertible {
/// A high-risk approval (destructive/network/host-exec) can't be allowed inline — it must be
/// confirmed on the app's guarded card (docs/APP_INTENTS_OPPORTUNITIES §3.3).
case needsAppConfirmation
/// Cloud lock-screen approvals are off (SyncedSettings.resolveApprovalsViaCloud) — the decision
/// won't be sent over Nucleic's relay, so open the app to resolve it locally instead.
case resolveInApp
var localizedStringResource: LocalizedStringResource {
switch self {
@@ -24,6 +27,8 @@ enum IntentError: Error, CustomLocalizedStringResourceConvertible {
"This device can view and approve, but isn't allowed to control sessions."
case .needsAppConfirmation:
"This one's high-risk — open Nucleic to confirm it on the approval card."
case .resolveInApp:
"Open Nucleic to approve. Turn on relay approvals in Settings to answer from the lock screen."
}
}
}
@@ -127,6 +127,10 @@ final class HostConnection {
/// A mesh-dispatch ack from this host (mesh dispatch) — RemoteStore correlates it by
/// `requestID` to resume the waiting `dispatchChatToMesh`.
var chatStarted: (WireChatStarted) -> Void = { _ in }
/// The account-level synced settings this host advertised — from `Welcome.settings` on
/// connect and every `HostMsg.settings` broadcast after. RemoteStore adopts it as the
/// account truth (every paired Mac reports the same value).
var settingsChanged: (SyncedSettings) -> Void = { _ in }
}
private let callbacks: Callbacks
@@ -222,7 +226,9 @@ final class HostConnection {
}
/// Reconnect to the pinned host using IK: LAN when reachable, else the pairing's tailnet hint.
func reconnect(to host: PairedHost) {
/// `preferRelay` reorders the candidates to try the Nucleic Edge first (a backgrounded/locked
/// App Intent resolving an approval — see `buildCandidates`); the direct paths stay as fallbacks.
func reconnect(to host: PairedHost, preferRelay: Bool = false) {
teardown()
pinnedHost = host
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
@@ -232,7 +238,8 @@ final class HostConnection {
fingerprint: host.fingerprint,
lanHost: host.lanHost, lanPort: host.lanPort,
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil,
relay: (host.relayMembershipToken, host.relayURL))
relay: (host.relayMembershipToken, host.relayURL),
preferRelay: preferRelay)
guard !candidates.isEmpty else {
connectivity = .hostOffline
callbacks.didUpdate()
@@ -302,9 +309,19 @@ final class HostConnection {
private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)?,
relay: (membershipToken: String?, url: String?)? = nil
relay: (membershipToken: String?, url: String?)? = nil,
preferRelay: Bool = false
) -> [TransportAttempt] {
let relayCandidate: TransportAttempt? = {
guard let relay, let token = relay.membershipToken, !token.isEmpty else { return nil }
return .relay(base: RelayAPI.baseURL(relay.url), membershipToken: token)
}()
var candidates: [TransportAttempt] = []
// Relay-first: a backgrounded/locked App Intent (a Live Activity approve/deny) can't rely on
// the local network — iOS restricts LAN/Bonjour for a process launched into the background,
// so the LAN attempt just burns its connect timeout. Dial the Nucleic Edge first and keep the
// direct paths as fallbacks. Only when the account opted into cloud approvals (SyncedSettings).
if preferRelay, let relayCandidate { candidates.append(relayCandidate) }
// 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.
@@ -315,9 +332,9 @@ final class HostConnection {
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
candidates.append(.tailnet(host: host, port: port))
}
if let relay, let token = relay.membershipToken, !token.isEmpty {
candidates.append(.relay(base: RelayAPI.baseURL(relay.url), membershipToken: token))
}
// Default order: relay is the last resort — a direct path beats a brokered one when both
// exist. `preferRelay` already placed it first, so don't add it twice.
if !preferRelay, let relayCandidate { candidates.append(relayCandidate) }
return candidates
}
@@ -537,6 +554,9 @@ final class HostConnection {
capabilities = welcome.capabilities
grantedScope = welcome.grantedScope
modelCatalog = welcome.modelCatalog
// Adopt the host's account-level synced settings (relay-approvals opt-in, …). A host
// that predates the field omits it, and we keep whatever we already had.
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
callbacks.didPair(PairedHost(
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
@@ -736,6 +756,9 @@ final class HostConnection {
case .castCatchUp(let catchUp):
// One catch-up batch for one (origin, channel) — `reset` handling lives in the ledger.
callbacks.castCatchUp(catchUp)
case .settings(let settings):
// The account-level synced settings changed on the host — adopt the new truth.
callbacks.settingsChanged(settings)
case .credentialNeeded, .credentialUpdate,
// The owner's runner-pool credential (item 4) — inert until the phone grows a
// pool-management surface; Macs are the managers today.
@@ -348,6 +348,15 @@ final class RemoteStore: ObservableObject {
private var transcriptPersistTask: Task<Void, Never>?
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
/// The account-level synced settings (`SyncedSettings`) — the host is the source of truth; this
/// mirrors it. Seeded from a local `UserDefaults` cache so it's correct even before any host
/// connects (a cold background App Intent reads the same key directly via
/// `SyncedSettings.resolveApprovalsViaCloud(from:)`), then replaced by `Welcome.settings` /
/// `HostMsg.settings`. Drives the Settings toggle and the Live-Activity relay-first routing.
@Published private(set) var syncedSettings: SyncedSettings =
SyncedSettings(resolveApprovalsViaCloud:
UserDefaults.standard.bool(forKey: SyncedSettings.resolveApprovalsViaCloudKey))
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
@Published private(set) var modelCatalog: WireModelCatalog = .empty
@@ -1326,6 +1335,11 @@ final class RemoteStore: ObservableObject {
self.addProject = .created(outcome.name ?? "Project")
}
}
cb.settingsChanged = { [weak self] settings in
// Account-level truth from a host (`Welcome.settings` or a `HostMsg.settings` broadcast).
// Every paired Mac reports the same value, so last-writer-wins is correct here.
self?.adoptSyncedSettings(settings)
}
return cb
}
@@ -1633,14 +1647,17 @@ final class RemoteStore: ObservableObject {
/// switcher flips between them instantly. Idempotent — an already-live connection is left alone;
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
/// "Reconnect" path.
func reconnect() {
/// `preferRelay` reorders each dialing host's candidates to try the Nucleic Edge first — set
/// only from a backgrounded/locked App Intent (a Live Activity approve/deny) where the local
/// network is unreliable. The normal launch/foreground path leaves it false (direct-first).
func reconnect(preferRelay: Bool = false) {
let hosts = IdentityStore.pairedHosts()
guard !hosts.isEmpty else { connectivity = .unpaired; return }
let paired = Set(hosts.map(\.fingerprint))
for (id, conn) in connections where !paired.contains(id) { conn.teardown(); connections[id] = nil }
for host in hosts {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
if !conn.connectivity.isLive { conn.reconnect(to: host, preferRelay: preferRelay) }
}
rebuildAggregate()
refreshAggregate()
@@ -2100,14 +2117,14 @@ final class RemoteStore: ObservableObject {
/// background process (Siri / Shortcuts / a widget or Live Activity button), where the SwiftUI
/// scene never mounts and `onAppear` never fires. Mirrors the push handler's silent-launch
/// bootstrap (`startNetworkingIfNeeded` + `reconnect`). Idempotent; a no-op in demo mode.
func bootstrapForIntent() {
func bootstrapForIntent(preferRelay: Bool = false) {
guard !demoMode else { return }
startNetworkingIfNeeded()
if isPaired {
// Show the persisted session list immediately so a cold intent query (Siri/Spotlight)
// has data to answer with before any host connects — same seed as `onAppear`.
if sessions.isEmpty { sessions = cachedSummaries }
reconnect()
reconnect(preferRelay: preferRelay)
}
}
@@ -2117,9 +2134,14 @@ final class RemoteStore: ObservableObject {
/// Await a live connection to any paired Mac, up to `timeout` seconds — an intent must act over a
/// *live* channel or fail clean (UX_IOS §6, §3.1). Brings networking up first if it's cold, then
/// polls until a link comes up or the deadline passes. Returns whether a link is live.
func awaitLiveConnection(timeout: TimeInterval = 6) async -> Bool {
///
/// `preferRelay` dials the Nucleic Edge first (a Live Activity approve/deny from a
/// backgrounded/locked device, where LAN is unreliable). The relay handshake — mint a
/// connection token, upgrade the WebSocket, run Noise — costs more than a LAN dial, so the
/// caller gives it a longer budget.
func awaitLiveConnection(timeout: TimeInterval = 6, preferRelay: Bool = false) async -> Bool {
if demoMode || hasLiveConnection { return true }
bootstrapForIntent()
bootstrapForIntent(preferRelay: preferRelay)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try? await Task.sleep(for: .milliseconds(200))
@@ -2152,6 +2174,34 @@ final class RemoteStore: ObservableObject {
return false
}
// MARK: - Account-level synced settings (SyncedSettings)
/// Adopt an account-level settings value the host advertised (`Welcome.settings` /
/// `HostMsg.settings`). Mirrors it to the local `UserDefaults` cache so a cold background App
/// Intent — which never mounts this store — reads the same truth directly. Idempotent.
private func adoptSyncedSettings(_ settings: SyncedSettings) {
UserDefaults.standard.set(
settings.resolveApprovalsViaCloud, forKey: SyncedSettings.resolveApprovalsViaCloudKey)
if syncedSettings != settings { syncedSettings = settings }
}
/// Request an account-level settings change from the phone (the Settings toggle). The host is
/// the source of truth, so this optimistically adopts the value (and caches it for the intent),
/// then sends `ClientMsg.updateSettings` to every live Mac that can sync settings; the host
/// applies, enforces, and echoes the authoritative value back as `HostMsg.settings`. A no-op
/// with no live control-scope host that syncs settings — returns whether it went out.
@discardableResult
func updateSyncedSettings(_ settings: SyncedSettings) -> Bool {
adoptSyncedSettings(settings)
if demoMode { return true }
let targets = connections.values.filter {
$0.connectivity.isLive && $0.grantedScope >= .approve && $0.capabilities.canSyncSettings
}
guard !targets.isEmpty else { return false }
for conn in targets { conn.send(.updateSettings(settings)) }
return true
}
// MARK: - Plumbing
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
@@ -2221,7 +2271,10 @@ final class RemoteStore: ObservableObject {
// Composer typing goes straight to the owning connection from
// `composerDraftChanged`/`flushComposerDraft` — ephemeral by design, it must
// never be queued for optimistic replay, so it skips this router entirely.
.composerTyping:
.composerTyping,
// Account-level settings go straight to every eligible host from
// `updateSyncedSettings`, not through this owner-routing switch.
.updateSettings:
break
}
}
@@ -2417,7 +2470,10 @@ final class RemoteStore: ObservableObject {
.castSubscribe,
// Live composer streaming — demo has no other devices to stream to, and
// `composerDraftChanged` already no-ops in demo mode before routing.
.composerTyping:
.composerTyping,
// Account-level settings sync — demo has no host to persist/enforce them; the local
// `syncedSettings` mirror is already updated optimistically by `updateSyncedSettings`.
.updateSettings:
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
}
}
@@ -114,6 +114,20 @@ struct SettingsView: View {
.font(.footnote.monospaced())
}
Section {
Toggle("Resolve over Nucleic Edge", isOn: Binding(
get: { store.syncedSettings.resolveApprovalsViaCloud },
set: { store.updateSyncedSettings(
SyncedSettings(resolveApprovalsViaCloud: $0)) }))
} header: {
Text("Lock-screen approvals")
} footer: {
Text("Send Approve/Deny from the Live Activity to your Mac through the Nucleic "
+ "relay, so a decision lands even when this iPhone is locked or off your "
+ "Wi-Fi. When off, the lock-screen buttons open Nucleic to approve "
+ "locally instead. Synced with your Mac.")
}
Section {
Toggle("Show lock events", isOn: $showLockEvents)
Toggle("Show advanced detail", isOn: $showRaw)
@@ -54,9 +54,20 @@ struct ApproveFromActivityIntent: AppIntent {
store.route(to: SessionID(rawValue: sessionID))
throw IntentError.needsAppConfirmation
}
// Best-effort bring-up; `respondToApproval` queues briefly on a dropped link (§3.1). A lost
// first-responder race is de-duped host-side (§3.4), so we never surface an error for it.
_ = await store.awaitLiveConnection()
// Cloud lock-screen approvals are opt-in (SyncedSettings.resolveApprovalsViaCloud). When
// off, don't push the decision over Nucleic's relay — hand off to the app to resolve it
// locally (the foreground app can use the local network). Read the flag from the local
// cache the sync layer keeps current, so a cold background launch decides without a host.
guard SyncedSettings.resolveApprovalsViaCloud() else {
store.route(to: SessionID(rawValue: sessionID))
throw IntentError.resolveInApp
}
// On: resolve inline over the Nucleic Edge. Dial relay-first with a longer budget — a
// backgrounded/locked intent can't rely on LAN, and the relay handshake (mint token +
// WebSocket upgrade + Noise) needs more time than a direct dial. `respondToApproval`
// queues briefly on a dropped link (§3.1); a lost first-responder race is de-duped
// host-side (§3.4), so we never surface an error for it.
_ = await store.awaitLiveConnection(timeout: 12, preferRelay: true)
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
store.respondToApproval(
id: ApprovalID(rawValue: approvalID),