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