Merge nucleic/humble-feather-yak-edwe into dev
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user