diff --git a/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift b/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift index 9a73fd3..d35504b 100644 --- a/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift +++ b/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift @@ -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." } } } diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 82d6daf..0a597ec 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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. diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 4b6410e..7c93e33 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -348,6 +348,15 @@ final class RemoteStore: ObservableObject { private var transcriptPersistTask: Task? @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) } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 6dd638a..26fd75c 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -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) diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift index 747baad..02668b0 100644 --- a/NucleicRemote/Shared/ApproveFromActivityIntent.swift +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -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),