From 844549b44352797efe246f93c8ee0d81378b756b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 15:10:07 -0700 Subject: [PATCH 1/2] =?UTF-8?q?nvrsion:=20Add:=20Align=20streak=20card=20l?= =?UTF-8?q?ightning=20bolt=20to=20left=20side=20of=20card,=20remove=20stre?= =?UTF-8?q?ak=20freeze=20counter,=20fix=20push=20notification=20wording=20?= =?UTF-8?q?to=20reflect=20correct=20session=20status,=20adjust=20push=20no?= =?UTF-8?q?tification=20logic=20to=20recall=20only=20the=20approval=20requ?= =?UTF-8?q?est=E2=80=99s=20notification=20without=20affecting=20live=20act?= =?UTF-8?q?ivities,=20and=20investigate=20Live=20Activity=20Session=20Load?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../AppIntents/ApprovalIntents.swift | 138 ++++++++++++++++++ .../AppIntents/IntentError.swift | 29 ++++ .../AppIntents/NucleicShortcuts.swift | 45 ++++++ .../AppIntents/SessionEntity.swift | 80 ++++++++++ .../AppIntents/SessionIntents.swift | 101 +++++++++++++ .../NucleicRemote/Localizable.strings | 5 + .../NucleicRemote/Models/RemoteStore.swift | 58 ++++++++ .../NucleicRemote/Notifications.swift | 16 +- .../SessionLiveActivity.swift | 64 +++++++- .../Shared/ApproveFromActivityIntent.swift | 65 +++++++++ .../Shared/SessionActivityAttributes.swift | 24 +++ 11 files changed, 612 insertions(+), 13 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift create mode 100644 NucleicRemote/NucleicRemote/AppIntents/IntentError.swift create mode 100644 NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift create mode 100644 NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift create mode 100644 NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift create mode 100644 NucleicRemote/Shared/ApproveFromActivityIntent.swift diff --git a/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift b/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift new file mode 100644 index 0000000..e1beebf --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift @@ -0,0 +1,138 @@ +import AppIntents +import NucleicProtocol + +// MARK: - Decision option (§6 enums) + +/// The decision an approval intent can carry, mapped from the wire `Decision` (`Approval.swift`). +/// The `allowAlways` cases are the safe subset (session / this-tool); pattern scopes and any allow +/// on a destructive request are handled by the risk gate, not offered here. +enum ApprovalDecisionOption: String, AppEnum { + case allow + case deny + case allowAlwaysSession + case allowAlwaysTool + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Decision") + static let caseDisplayRepresentations: [ApprovalDecisionOption: DisplayRepresentation] = [ + .allow: "Allow", + .deny: "Deny", + .allowAlwaysSession: "Always Allow (this session)", + .allowAlwaysTool: "Always Allow (this tool)", + ] + + /// Whether this decision grants the request (everything but `deny`) — the half the risk gate + /// forbids inline on a high-risk approval. + var isAllow: Bool { self != .deny } + + /// The wire `Decision` this option resolves to. + func decision() -> Decision { + switch self { + case .allow: .allow(updatedInput: nil) + case .deny: .deny(reason: nil) + case .allowAlwaysSession: .allowAlways(.session) + case .allowAlwaysTool: .allowAlways(.toolName) + } + } +} + +// MARK: - Approval entity (§4.6) + +/// A pending approval exposed to Shortcuts/Siri. Backed by `RemoteStore.openApprovals`, which the +/// phone holds in full (id, risk, title) only for the *subscribed* session — so today this surfaces +/// the approvals of the session you're looking at. (A global pending-approvals feed would need the +/// host to carry the top approval's id/risk on the wire summary; see §4.1 / the Live Activity note.) +struct ApprovalEntity: AppEntity, Identifiable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Approval") + static let defaultQuery = ApprovalEntityQuery() + + /// `ApprovalID.rawValue`. + var id: String + /// `SessionID.rawValue` of the owning session — routes the decision to the right Mac. + var sessionID: String + var toolName: String + var requestTitle: String + /// `Risk.rawValue`, kept for display; the gate uses `isHighRisk`. + var riskLabel: String + /// Destructive / network / host-exec — never allowed inline (§3.3). + var isHighRisk: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(toolName): \(requestTitle)", + subtitle: "\(riskLabel)") + } +} + +extension ApprovalEntity { + init(_ r: ApprovalRequest) { + self.init( + id: r.id.rawValue, + sessionID: r.sessionID.rawValue, + toolName: r.toolName, + requestTitle: r.title, + riskLabel: r.risk.label, + isHighRisk: r.risk.isHigh) + } +} + +struct ApprovalEntityQuery: EntityQuery { + @MainActor + func entities(for identifiers: [ApprovalEntity.ID]) async throws -> [ApprovalEntity] { + let wanted = Set(identifiers) + return RemoteStore.shared.openApprovals + .filter { wanted.contains($0.id.rawValue) } + .map(ApprovalEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [ApprovalEntity] { + RemoteStore.shared.openApprovals.map(ApprovalEntity.init) + } +} + +// MARK: - Answer an approval (§4.1, the flagship) + +/// Allow or deny what an agent is asking to do. The risk gate (§3.3) is enforced here so an intent +/// can never be a softer approval path than the UI: a high-risk request (destructive / network / +/// host-exec) is never granted inline — it routes to the app's guarded card. Deny always goes +/// straight through. "Already resolved elsewhere" is a friendly no-op (§3.4), not an error, because +/// the host de-dupes a lost first-responder race. +struct AnswerApprovalIntent: AppIntent { + static let title: LocalizedStringResource = "Answer Approval" + static let description = IntentDescription( + "Allow or deny what a Nucleic agent is asking to do.") + + @Parameter(title: "Approval") + var approval: ApprovalEntity + + @Parameter(title: "Decision", default: .allow) + var decision: ApprovalDecisionOption + + init() {} + init(approval: ApprovalEntity, decision: ApprovalDecisionOption) { + self.approval = approval + self.decision = decision + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + let store = RemoteStore.shared + guard store.isPaired else { throw IntentError.notPaired } + let approvalID = ApprovalID(rawValue: approval.id) + let sessionID = SessionID(rawValue: approval.sessionID) + + // §3.3 — high-risk can't be granted inline; route to the app's biometric-gated card. + if approval.isHighRisk, decision.isAllow { + store.route(to: sessionID) + throw IntentError.needsAppConfirmation + } + + guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable } + store.respondToApproval(id: approvalID, sessionID: sessionID, decision: decision.decision()) + return .result(dialog: decision.isAllow ? "Allowed." : "Denied.") + } + + static var parameterSummary: some ParameterSummary { + Summary("\(\.$decision) \(\.$approval)") + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift b/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift new file mode 100644 index 0000000..9a73fd3 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift @@ -0,0 +1,29 @@ +import AppIntents + +/// Friendly, spoken-safe failures for Nucleic's App Intents. The phone is a thin client with no +/// local authority (docs/APP_INTENTS_OPPORTUNITIES §3.1): an intent that can't reach the paired +/// Mac must fail *clean* with a dialog, never silently or with a raw error. +enum IntentError: Error, CustomLocalizedStringResourceConvertible { + /// No live channel to any paired Mac (and none came up within the wait window). + case macUnreachable + /// Not paired with a Mac yet — nothing to act on. + case notPaired + /// The action needs `control` scope, which this device hasn't been granted. + case controlScopeRequired + /// 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 + + var localizedStringResource: LocalizedStringResource { + switch self { + case .macUnreachable: + "Your Mac isn't reachable right now. Try again when it's online." + case .notPaired: + "Pair this iPhone with your Mac in Nucleic first." + case .controlScopeRequired: + "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." + } + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift b/NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift new file mode 100644 index 0000000..6e16131 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift @@ -0,0 +1,45 @@ +import AppIntents + +/// Curates the handful of genuinely voice-worthy actions for Siri / Spotlight (§8: keep the spoken +/// set to ~4 — approve, unblock, open, "what needs me" — and expose the long tail through Shortcuts +/// only). Every phrase must name the app; `\(.applicationName)` resolves to "Nucleic". +struct NucleicShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: SessionsNeedingMeIntent(), + phrases: [ + "What needs me in \(.applicationName)", + "What's waiting in \(.applicationName)", + "Which agents need me in \(.applicationName)", + ], + shortTitle: "What Needs Me", + systemImageName: "bell.badge") + + AppShortcut( + intent: AnswerApprovalIntent(), + phrases: [ + "Approve in \(.applicationName)", + "Answer an approval in \(.applicationName)", + ], + shortTitle: "Answer Approval", + systemImageName: "checkmark.shield") + + AppShortcut( + intent: SendFollowUpIntent(), + phrases: [ + "Send a follow-up in \(.applicationName)", + "Tell an agent in \(.applicationName)", + ], + shortTitle: "Send Follow-Up", + systemImageName: "arrowshape.turn.up.right") + + AppShortcut( + intent: OpenSessionIntent(), + phrases: [ + "Open a session in \(.applicationName)", + "Open \(.applicationName)", + ], + shortTitle: "Open Session", + systemImageName: "bubble.left.and.text.bubble.right") + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift b/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift new file mode 100644 index 0000000..fff0b8e --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift @@ -0,0 +1,80 @@ +import AppIntents +import NucleicProtocol + +/// A Nucleic agent session exposed to Siri / Shortcuts / Spotlight (docs/APP_INTENTS_OPPORTUNITIES +/// §4.6). Backed by the host's `SessionSummary` projection the phone already holds; the entity id +/// is the wire `SessionID`, so it maps straight onto the deep-link and control APIs. +/// +/// A thin, `Sendable` value snapshot — never the live store row. The `EntityQuery` re-reads +/// `RemoteStore` each time so a stale Siri suggestion never acts on outdated state. +struct SessionEntity: AppEntity, Identifiable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Session") + static let defaultQuery = SessionEntityQuery() + + /// `SessionID.rawValue`. + var id: String + var title: String + var projectName: String + var statusLabel: String + /// Whether this session is waiting on the user (approval, or a non-completed input turn). + var needsAttention: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: "\(projectName) · \(statusLabel)") + } +} + +extension SessionEntity { + /// Project a wire summary into the entity (falling back to the project name for an untitled + /// session, exactly as the session list does). + init(_ s: WireSessionSummary) { + self.init( + id: s.sessionID.rawValue, + title: s.title.isEmpty ? s.projectName : s.title, + projectName: s.projectName, + statusLabel: StatusStyle.label(s.status, disposition: s.disposition), + needsAttention: s.status.needsYou(s.disposition)) + } +} + +/// Resolves `SessionEntity` values for Shortcuts/Siri from the phone's current (or cached) session +/// list. Discovery paths (`suggestedEntities`, string matching) bootstrap the channel first so a +/// cold intent process still has data; id resolution stays fast and offline-tolerant. +struct SessionEntityQuery: EntityQuery { + @MainActor + func entities(for identifiers: [SessionEntity.ID]) async throws -> [SessionEntity] { + let wanted = Set(identifiers) + return RemoteStore.shared.liveSessions + .filter { wanted.contains($0.sessionID.rawValue) } + .map(SessionEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [SessionEntity] { + let store = RemoteStore.shared + store.bootstrapForIntent() + return store.liveSessions + .sorted(by: StatusStyle.attentionThenRecency) + .prefix(12) + .map(SessionEntity.init) + } +} + +/// Lets Siri match a session by spoken name ("open payment-flow") against title or project. +extension SessionEntityQuery: EntityStringQuery { + @MainActor + func entities(matching string: String) async throws -> [SessionEntity] { + let store = RemoteStore.shared + store.bootstrapForIntent() + let needle = string.lowercased() + return store.liveSessions + .filter { + let title = ($0.title.isEmpty ? $0.projectName : $0.title).lowercased() + return title.contains(needle) || $0.projectName.lowercased().contains(needle) + } + .sorted(by: StatusStyle.attentionThenRecency) + .map(SessionEntity.init) + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift b/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift new file mode 100644 index 0000000..8e0f0d9 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift @@ -0,0 +1,101 @@ +import AppIntents +import NucleicProtocol + +// MARK: - Open a session (deep-link intent, §4.4) + +/// Open a specific session in the app. Reuses the existing `nucleic://session/` route via +/// `RemoteStore.route(to:)` — so a Spotlight result, a Siri phrase, or a Shortcut jumps straight +/// into the waiting session. `openAppWhenRun` foregrounds the app (this is a navigation action). +struct OpenSessionIntent: AppIntent { + static let title: LocalizedStringResource = "Open Session" + static let description = IntentDescription("Open a Nucleic agent session.") + static let openAppWhenRun = true + + @Parameter(title: "Session") + var session: SessionEntity + + init() {} + init(session: SessionEntity) { self.session = session } + + @MainActor + func perform() async throws -> some IntentResult { + RemoteStore.shared.route(to: SessionID(rawValue: session.id)) + return .result() + } + + static var parameterSummary: some ParameterSummary { + Summary("Open \(\.$session)") + } +} + +// MARK: - Unblock with a follow-up (§4.2, approve-scope) + +/// Send a follow-up prompt to a paused agent — the "type the next step to unblock it" flow that +/// stays at `approve` scope (docs/UX_IOS §2). Runs in the background over the app's live channel +/// (no foregrounding); if the Mac isn't reachable it fails clean with a spoken error (§3.1). +struct SendFollowUpIntent: AppIntent { + static let title: LocalizedStringResource = "Send Follow-Up" + static let description = IntentDescription( + "Send a follow-up prompt to a Nucleic agent to unblock or steer it.") + + @Parameter(title: "Session") + var session: SessionEntity + + @Parameter(title: "Message", requestValueDialog: "What should the agent do next?") + var text: String + + init() {} + init(session: SessionEntity, text: String) { + self.session = session + self.text = text + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + let store = RemoteStore.shared + guard store.isPaired else { throw IntentError.notPaired } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .result(dialog: "That message was empty.") } + guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable } + store.sendInput(trimmed, to: SessionID(rawValue: session.id)) + return .result(dialog: "Sent to \(session.title).") + } + + static var parameterSummary: some ParameterSummary { + Summary("Tell \(\.$session) to \(\.$text)") + } +} + +// MARK: - "What needs me?" (§4.3) + +/// List the agents waiting on the user — for approval, or a next prompt — using the same attention +/// rule as the badge and NEEDS YOU section (`SessionStatus.needsYou`). Answers Siri's "what needs me +/// in Nucleic?" and feeds Shortcuts automations. Runs in the background; returns the list plus a +/// spoken summary. +struct SessionsNeedingMeIntent: AppIntent { + static let title: LocalizedStringResource = "Sessions Needing Me" + static let description = IntentDescription( + "List the Nucleic agents waiting on you — for approval or your next prompt.") + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue<[SessionEntity]> & ProvidesDialog { + let store = RemoteStore.shared + guard store.isPaired else { throw IntentError.notPaired } + store.bootstrapForIntent() + // Give a cold process a brief moment to bring a channel up so the answer is current; fall + // back to the persisted list if the Mac stays unreachable. + _ = await store.awaitLiveConnection(timeout: 4) + let needy = store.liveSessions + .filter { $0.status.needsYou($0.disposition) } + .sorted(by: StatusStyle.attentionThenRecency) + .map(SessionEntity.init) + + let dialog: IntentDialog + switch needy.count { + case 0: dialog = "Nothing needs you right now." + case 1: dialog = "One agent needs you: \(needy[0].title)." + default: dialog = "\(needy.count) agents need you." + } + return .result(value: needy, dialog: dialog) + } +} diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings index a690f5a..b9b26a0 100644 --- a/NucleicRemote/NucleicRemote/Localizable.strings +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -3,3 +3,8 @@ this localized alert is all the lock screen shows. The app pulls the real approval over the encrypted channel on open. */ "approval.pending" = "A session is waiting for your approval"; + +/* The same content-free wake, but the block is an agent question (`AskUserQuestion`) rather than + a tool approval — the host tags the wake `kind:"question"` so the lock screen reads accurately. + Still content-free: the real question is pulled over the encrypted channel on open. */ +"question.pending" = "A session is waiting for your answer"; diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f7fe423..e02ecd0 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1225,6 +1225,64 @@ final class RemoteStore: ObservableObject { func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) } func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) } + // MARK: - App Intents support + + /// Bring networking online and dial the paired host(s) for an App Intent that runs in a *cold* + /// 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() { + 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() + } + } + + /// Whether any paired Mac currently has a live channel. + var hasLiveConnection: Bool { connections.values.contains { $0.connectivity.isLive } } + + /// 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 { + if demoMode || hasLiveConnection { return true } + bootstrapForIntent() + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + try? await Task.sleep(for: .milliseconds(200)) + if hasLiveConnection { return true } + } + return hasLiveConnection + } + + /// Resolve an approval by id from an App Intent with a full `Decision`. Like + /// `respondFromNotification`, it prefers the owning Mac, else broadcasts to every live Mac (the + /// owner resolves; the rest see an unknown / `alreadyResolved` id and no-op), and queues briefly + /// on a dropped link so a decision made just as the socket blips still lands. `sessionID` (when + /// the surface knows it) routes directly. Returns whether it went out over a live channel now. + @discardableResult + func respondToApproval(id: ApprovalID, sessionID: SessionID?, decision: Decision) -> Bool { + if demoMode { demoHandle(.approvalRespond(id, decision)); return true } + if let sessionID, let conn = connection(owningSession: sessionID), conn.connectivity.isLive { + conn.send(.approvalRespond(id, decision)) + openApprovals.removeAll { $0.id == id } + return true + } + let live = connections.values.filter { $0.connectivity.isLive } + if !live.isEmpty { + for conn in live { conn.send(.approvalRespond(id, decision)) } + openApprovals.removeAll { $0.id == id } + return true + } + pendingNotificationDecision = (id, decision, Date()) + reconnect() + return false + } + // MARK: - Plumbing /// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown diff --git a/NucleicRemote/NucleicRemote/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift index fb2d0dc..b2c601f 100644 --- a/NucleicRemote/NucleicRemote/Notifications.swift +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -108,21 +108,15 @@ final class NotificationRouter: NSObject { center.removePendingNotificationRequests(withIdentifiers: [identifier]) } - /// Recall the shared "waiting for your approval" attention notification once nothing is pending - /// anywhere. This is the remote wake tickle (delivered under `approvalTickleIdentifier`) that a - /// prior push left on the lock screen — the counterpart to the per-approval `withdrawApproval`. - /// Arrives two ways: the relay's silent `clear` push (phone was away), or the store's own - /// reconcile when a live socket sees the last approval resolve. Also sweeps any per-approval - /// locals a live socket posted but hasn't individually withdrawn, so the lock screen ends clean. + /// Recall the generic remote wake tickle ("A session is waiting for your approval") once no + /// approval is pending anywhere. This targets *only* the shared tickle (delivered under + /// `approvalTickleIdentifier`, its APNs collapse-id) — the content-free push a phone gets while + /// away. Per-approval notifications are each recalled by `withdrawApproval(_:)` as they resolve, + /// so a still-pending approval's own banner is never swept away by this. func withdrawApprovalAttention() { let center = UNUserNotificationCenter.current() center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier]) center.removePendingNotificationRequests(withIdentifiers: [Self.approvalTickleIdentifier]) - center.getDeliveredNotifications { delivered in - let stale = delivered.map(\.request.identifier).filter { $0.hasPrefix("approval-") } - guard !stale.isEmpty else { return } - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: stale) - } } /// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8). diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 5e2f336..38632c7 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -131,15 +131,42 @@ private struct LockScreenView: View { private struct SessionRow: View { let line: NucleicSessionAttributes.SessionLine + /// An approval this row can resolve inline: it's blocked on an approval, carries that approval's + /// id, and isn't high-risk (§3.3 — high-risk keeps the plain tap-to-open row and is answered on + /// the app's guarded card). `nil` until the producer populates `approvalID` — an older host, or + /// today's summary that doesn't yet carry it, so the row stays a deep-link exactly as before. + private var inlineApprovalID: String? { + guard line.kind == .approval, (line.approvalIsHighRisk ?? false) == false else { return nil } + return line.approvalID + } + var body: some View { - if let url = NucleicDeepLink.session(line.id) { + if let approvalID = inlineApprovalID { + actionableRow(approvalID: approvalID) + } else if let url = NucleicDeepLink.session(line.id) { Link(destination: url) { rowContent } } else { rowContent } } - private var rowContent: some View { + /// An approval row with inline Allow/Deny. The identity still deep-links to the session; the + /// buttons fire `ApproveFromActivityIntent`, which the system performs in the app's background + /// process — allow/deny without unlocking (§4.1). + private func actionableRow(approvalID: String) -> some View { + HStack(spacing: 8) { + if let url = NucleicDeepLink.session(line.id) { + Link(destination: url) { rowIdentity } + } else { + rowIdentity + } + Spacer(minLength: 6) + ApprovalButtons(sessionID: line.id, approvalID: approvalID) + } + } + + /// The glyph + title + "project · Backend" — the row's identity, minus the trailing status. + private var rowIdentity: some View { HStack(spacing: 8) { Image(systemName: ActivityPalette.glyph(line.kind)) .font(.footnote) @@ -159,6 +186,12 @@ private struct SessionRow: View { .foregroundStyle(.secondary) .lineLimit(1) } + } + } + + private var rowContent: some View { + HStack(spacing: 8) { + rowIdentity Spacer(minLength: 6) Text(line.detail) .font(.caption2.weight(.medium)) @@ -169,6 +202,33 @@ private struct SessionRow: View { } } +/// The compact inline Allow/Deny pair for an actionable approval row. Each button fires +/// `ApproveFromActivityIntent` (a low/medium-risk decision — high-risk never reaches here). +private struct ApprovalButtons: View { + let sessionID: String + let approvalID: String + + var body: some View { + HStack(spacing: 6) { + Button(intent: intent(allow: false)) { + Image(systemName: "xmark").font(.caption2.bold()) + } + .tint(ActivityPalette.danger) + Button(intent: intent(allow: true)) { + Image(systemName: "checkmark").font(.caption2.bold()) + } + .tint(ActivityPalette.success) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + private func intent(allow: Bool) -> ApproveFromActivityIntent { + ApproveFromActivityIntent( + approvalID: approvalID, sessionID: sessionID, isHighRisk: false, allow: allow) + } +} + /// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show. private struct CountChips: View { let state: NucleicSessionAttributes.ContentState diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift new file mode 100644 index 0000000..51cad25 --- /dev/null +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -0,0 +1,65 @@ +import AppIntents +#if canImport(NucleicProtocol) +import NucleicProtocol +#endif + +/// The interactive approve / deny an aggregate Live Activity (or widget) button fires — the flagship +/// "resolve from the lock screen without unlocking" path (docs/APP_INTENTS_OPPORTUNITIES §4.1). +/// +/// It lives in the **Shared** group so both the app and the widget extension can reference it in +/// `Button(intent:)`. The widget extension links no `NucleicProtocol`, so this intent carries plain +/// `String` ids and compiles its real work only into the app (`#if canImport(NucleicProtocol)`). +/// That's sound because iOS runs a widget/Live-Activity button's intent in the **app's background +/// process** — where `RemoteStore` owns the live E2EE channel — never in the extension. The +/// extension-side copy exists solely to satisfy the `Button(intent:)` type reference. +/// +/// Not discoverable in Shortcuts/Spotlight: it's button-only, driven by ids embedded at render time +/// (a human uses `AnswerApprovalIntent` for the spoken/Shortcuts path). +struct ApproveFromActivityIntent: AppIntent { + static let title: LocalizedStringResource = "Approve from Live Activity" + static let isDiscoverable = false + + @Parameter(title: "Approval ID") + var approvalID: String + @Parameter(title: "Session ID") + var sessionID: String + @Parameter(title: "High Risk") + var isHighRisk: Bool + /// `true` = allow, `false` = deny. + @Parameter(title: "Allow") + var allow: Bool + + init() {} + init(approvalID: String, sessionID: String, isHighRisk: Bool, allow: Bool) { + self.approvalID = approvalID + self.sessionID = sessionID + self.isHighRisk = isHighRisk + self.allow = allow + } + + @MainActor + func perform() async throws -> some IntentResult { + #if canImport(NucleicProtocol) + let store = RemoteStore.shared + // §3.3 — a high-risk allow is never resolved inline; route to the app's biometric-gated card. + // (Surfaces shouldn't render an inline Allow for high-risk in the first place; this is the + // backstop so the intent can never be a softer path than the UI.) + if isHighRisk, allow { + 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() + let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil) + store.respondToApproval( + id: ApprovalID(rawValue: approvalID), + sessionID: SessionID(rawValue: sessionID), + decision: decision) + return .result() + #else + // Widget-extension build: never executed (the system performs this in the app process). + return .result() + #endif + } +} diff --git a/NucleicRemote/Shared/SessionActivityAttributes.swift b/NucleicRemote/Shared/SessionActivityAttributes.swift index 9809710..b352ef9 100644 --- a/NucleicRemote/Shared/SessionActivityAttributes.swift +++ b/NucleicRemote/Shared/SessionActivityAttributes.swift @@ -48,6 +48,30 @@ struct NucleicSessionAttributes: ActivityAttributes { /// A compact right-aligned status: its diff ("3 files +42 −7"), "2 to approve", /// "Waiting on you", "Working…", etc. var detail: String + /// The single most-urgent pending approval on this session, when it has one — the id the + /// inline Allow/Deny buttons resolve (`ApproveFromActivityIntent`). `nil` when the session + /// isn't blocked on an approval, or when the producer doesn't carry it (an older host, or a + /// summary that predates the field) — in which case the row falls back to a plain deep-link + /// tap, exactly as before. Optional so a host that omits it still decodes. + var approvalID: String? + /// Whether that approval is high-risk (destructive / network / host-exec). The surface hides + /// inline Allow on a high-risk request (§3.3) — only Deny / open-the-app is offered. `nil` + /// (treated as unknown → no inline Allow) when the producer doesn't carry it. + var approvalIsHighRisk: Bool? + + init( + id: String, title: String, project: String, backend: Backend, kind: Kind, + detail: String, approvalID: String? = nil, approvalIsHighRisk: Bool? = nil + ) { + self.id = id + self.title = title + self.project = project + self.backend = backend + self.kind = kind + self.detail = detail + self.approvalID = approvalID + self.approvalIsHighRisk = approvalIsHighRisk + } } struct ContentState: Codable, Hashable { From 15ecf479fcbd950cbb8e15d131be39c7c7fea68c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:20:09 -0700 Subject: [PATCH 2/2] =?UTF-8?q?nvrsion:=20Add=20instant=20away-recall=20lo?= =?UTF-8?q?gic=20to=20SyncHost.swift;=20fix=20live=20activity=20crash;=20a?= =?UTF-8?q?djust=20host=5Fexec=20tool=20to=20allow=20read-only=20tools=20e?= =?UTF-8?q?xcept=20=E2=80=9C.env=E2=80=9D=20files;=20fix=20iOS=20build=20e?= =?UTF-8?q?rrors=20by=20adding=20RemoteStore=20and=20IntentError=20imports?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote.xcodeproj/project.pbxproj | 2 ++ .../Shared/ApproveFromActivityIntent.swift | 16 +++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index db9ea3e..50da6ad 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -357,6 +357,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) NUCLEIC_APP"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -400,6 +401,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) NUCLEIC_APP"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift index 51cad25..747baad 100644 --- a/NucleicRemote/Shared/ApproveFromActivityIntent.swift +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -1,5 +1,5 @@ import AppIntents -#if canImport(NucleicProtocol) +#if NUCLEIC_APP import NucleicProtocol #endif @@ -7,12 +7,18 @@ import NucleicProtocol /// "resolve from the lock screen without unlocking" path (docs/APP_INTENTS_OPPORTUNITIES §4.1). /// /// It lives in the **Shared** group so both the app and the widget extension can reference it in -/// `Button(intent:)`. The widget extension links no `NucleicProtocol`, so this intent carries plain -/// `String` ids and compiles its real work only into the app (`#if canImport(NucleicProtocol)`). -/// That's sound because iOS runs a widget/Live-Activity button's intent in the **app's background +/// `Button(intent:)`. It carries plain `String` ids and compiles its real work only into the app, +/// gated on `#if NUCLEIC_APP` (a custom compilation condition set on the app target only). That's +/// sound because iOS runs a widget/Live-Activity button's intent in the **app's background /// process** — where `RemoteStore` owns the live E2EE channel — never in the extension. The /// extension-side copy exists solely to satisfy the `Button(intent:)` type reference. /// +/// NB: the guard is `#if NUCLEIC_APP`, *not* `#if canImport(NucleicProtocol)`. `canImport` tests +/// module findability, not linkage — and because the app builds `NucleicProtocol` into the shared +/// DerivedData products dir, it's findable from the widget extension too. So `canImport` is `true` +/// in the extension, which would compile this branch there and fail on the app-only `RemoteStore` +/// / `IntentError` types. `NUCLEIC_APP` tracks target membership, which is what we actually mean. +/// /// Not discoverable in Shortcuts/Spotlight: it's button-only, driven by ids embedded at render time /// (a human uses `AnswerApprovalIntent` for the spoken/Shortcuts path). struct ApproveFromActivityIntent: AppIntent { @@ -39,7 +45,7 @@ struct ApproveFromActivityIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { - #if canImport(NucleicProtocol) + #if NUCLEIC_APP let store = RemoteStore.shared // §3.3 — a high-risk allow is never resolved inline; route to the app's biometric-gated card. // (Surfaces shouldn't render an inline Allow for high-risk in the first place; this is the