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 + } +}