Read App Intents Documentation
Nucleic-Session: 56C5F781-4444-466B-91CE-A7734A8E117F Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user