Permission requests only ever displayed a summary that was hard-capped: RiskClassifier.title() slices to 80 chars, and the iOS card used a lossy compactSummary clipped to 8 lines. The full tool input was present on the request but never shown, so a long Bash command, file path, or URL was cut off with "…" and the user couldn't see what they were granting. - Add RiskClassifier.detail(toolName:input:): the full, untruncated content (full command/path/url/query), falling back to the pretty-printed input so nothing about an unrecognized tool is hidden. - Add JSONValue.prettyString() for indented, multi-line display. - macOS ApprovalBar: render the detail in a bounded, scrollable, selectable monospaced block (shown only when it adds beyond the already-shown title). - iOS ApprovalCardView: replace lossy compactSummary/lineLimit(8) with the full content in a bounded scroll view via JSONValue.approvalDetail. - Tests: assert detail is full and untruncated, with pretty-input fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
121 lines
4.9 KiB
Swift
121 lines
4.9 KiB
Swift
import SwiftUI
|
|
import LocalAuthentication
|
|
import NucleicProtocol
|
|
|
|
/// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network)
|
|
/// requires Face ID/Touch ID before Allow is enabled — deliberate friction mirroring the Mac.
|
|
/// First-responder-wins: if the Mac (or another phone) answers first, the host's
|
|
/// `approvalResolved` removes this card.
|
|
struct ApprovalCardView: View {
|
|
@EnvironmentObject var store: RemoteStore
|
|
let approval: ApprovalRequest
|
|
|
|
@State private var biometricPassed = false
|
|
@State private var authError: String?
|
|
@State private var showAlwaysMenu = false
|
|
|
|
private var requiresBiometric: Bool { approval.risk.isHigh }
|
|
private var allowEnabled: Bool {
|
|
store.connectivity.isLive && (!requiresBiometric || biometricPassed)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
|
Text("Permission requested").font(.subheadline.weight(.semibold))
|
|
}
|
|
HStack(spacing: 6) {
|
|
Text(approval.toolName).font(.caption.weight(.bold))
|
|
Text(approval.risk.label)
|
|
.font(.caption2.weight(.semibold))
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
|
.foregroundStyle(approval.risk.color)
|
|
}
|
|
// Full, untruncated content in a bounded scroll view — the user must be
|
|
// able to read exactly what they are granting, so long commands/paths/inputs
|
|
// scroll instead of being clipped to a few lines.
|
|
if !approval.input.approvalDetail.isEmpty {
|
|
ScrollView([.horizontal, .vertical]) {
|
|
Text(approval.input.approvalDetail)
|
|
.font(.caption.monospaced())
|
|
.textSelection(.enabled)
|
|
.padding(8)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: 200, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
if requiresBiometric && !biometricPassed {
|
|
Button {
|
|
authenticate()
|
|
} label: {
|
|
Label("Face ID required to allow", systemImage: "faceid").font(.caption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
if let authError {
|
|
Text(authError).font(.caption2).foregroundStyle(.red)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Button(role: .destructive) {
|
|
store.respond(approval, .deny(reason: nil))
|
|
} label: {
|
|
Text("Deny").frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button {
|
|
store.respond(approval, .allow())
|
|
} label: {
|
|
Text("Allow").frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!allowEnabled)
|
|
}
|
|
|
|
if !store.capabilities.allowAlwaysScopes.isEmpty {
|
|
Menu("Allow always…") {
|
|
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in
|
|
Button(alwaysLabel(scope)) {
|
|
store.respond(approval, .allowAlways(scope))
|
|
}
|
|
.disabled(!allowEnabled)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.onChange(of: approval.id) { _, _ in biometricPassed = false; authError = nil }
|
|
}
|
|
|
|
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
|
switch scope {
|
|
case .session: "This command, this session"
|
|
case .toolName: "Any \(approval.toolName), this session"
|
|
case .toolNameWithPattern: "\(approval.toolName) matching this pattern"
|
|
}
|
|
}
|
|
|
|
private func authenticate() {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
|
authError = "Biometrics unavailable"; return
|
|
}
|
|
context.evaluatePolicy(
|
|
.deviceOwnerAuthenticationWithBiometrics,
|
|
localizedReason: "Approve a \(approval.risk.label) action"
|
|
) { success, evalError in
|
|
Task { @MainActor in
|
|
if success { biometricPassed = true; authError = nil }
|
|
else { authError = evalError?.localizedDescription ?? "Authentication failed" }
|
|
}
|
|
}
|
|
}
|
|
}
|