Files
nucleic-remote-ios/NucleicRemote/Shared/ApproveFromActivityIntent.swift
T

72 lines
3.4 KiB
Swift
Raw Normal View History

import AppIntents
#if NUCLEIC_APP
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:)`. 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 {
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 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
// 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
}
}