mirror of https://github.com/openclaw/openclaw.git
197 lines
7.3 KiB
Swift
197 lines
7.3 KiB
Swift
import SwiftUI
|
|
|
|
private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.overlay {
|
|
if let prompt = self.appModel.pendingExecApprovalPrompt {
|
|
ZStack {
|
|
Color.black.opacity(0.38)
|
|
.ignoresSafeArea()
|
|
|
|
ExecApprovalPromptCard(
|
|
prompt: prompt,
|
|
isResolving: self.appModel.pendingExecApprovalPromptResolving,
|
|
errorText: self.appModel.pendingExecApprovalPromptErrorText,
|
|
brighten: self.colorScheme == .light,
|
|
onAllowOnce: {
|
|
Task {
|
|
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
|
|
}
|
|
},
|
|
onAllowAlways: {
|
|
Task {
|
|
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
|
}
|
|
},
|
|
onDeny: {
|
|
Task {
|
|
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
|
|
}
|
|
},
|
|
onCancel: {
|
|
self.appModel.dismissPendingExecApprovalPrompt()
|
|
})
|
|
.padding(.horizontal, 20)
|
|
.frame(maxWidth: 460)
|
|
.transition(.scale(scale: 0.98).combined(with: .opacity))
|
|
}
|
|
.zIndex(1)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
|
|
}
|
|
}
|
|
|
|
private struct ExecApprovalPromptCard: View {
|
|
let prompt: NodeAppModel.ExecApprovalPrompt
|
|
let isResolving: Bool
|
|
let errorText: String?
|
|
let brighten: Bool
|
|
let onAllowOnce: () -> Void
|
|
let onAllowAlways: () -> Void
|
|
let onDeny: () -> Void
|
|
let onCancel: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Exec approval required")
|
|
.font(.headline)
|
|
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Text(self.prompt.commandText)
|
|
.font(.system(size: 15, weight: .regular, design: .monospaced))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(10)
|
|
.background(.black.opacity(0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let host = self.normalized(self.prompt.host) {
|
|
ExecApprovalPromptMetadataRow(label: "Host", value: host)
|
|
}
|
|
if let nodeId = self.normalized(self.prompt.nodeId) {
|
|
ExecApprovalPromptMetadataRow(label: "Node", value: nodeId)
|
|
}
|
|
if let agentId = self.normalized(self.prompt.agentId) {
|
|
ExecApprovalPromptMetadataRow(label: "Agent", value: agentId)
|
|
}
|
|
if let expiresText = self.expiresText(self.prompt.expiresAtMs) {
|
|
ExecApprovalPromptMetadataRow(label: "Expires", value: expiresText)
|
|
}
|
|
}
|
|
|
|
if let errorText = self.normalized(self.errorText) {
|
|
Text(errorText)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
if self.isResolving {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
Text("Resolving…")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 10) {
|
|
Button {
|
|
self.onAllowOnce()
|
|
} label: {
|
|
Text("Allow Once")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(self.isResolving)
|
|
|
|
if self.prompt.allowsAllowAlways {
|
|
Button {
|
|
self.onAllowAlways()
|
|
} label: {
|
|
Text("Allow Always")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(self.isResolving)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Button(role: .destructive) {
|
|
self.onDeny()
|
|
} label: {
|
|
Text("Deny")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(self.isResolving)
|
|
|
|
Button(role: .cancel) {
|
|
self.onCancel()
|
|
} label: {
|
|
Text("Cancel")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(self.isResolving)
|
|
}
|
|
}
|
|
.controlSize(.large)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
|
|
}
|
|
|
|
private func normalized(_ value: String?) -> String? {
|
|
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private func expiresText(_ expiresAtMs: Int?) -> String? {
|
|
guard let expiresAtMs else { return nil }
|
|
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
|
|
if remainingSeconds <= 0 {
|
|
return "expired"
|
|
}
|
|
if remainingSeconds < 60 {
|
|
return "under a minute"
|
|
}
|
|
if remainingSeconds < 3600 {
|
|
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
|
|
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
|
|
}
|
|
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
|
|
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
|
|
}
|
|
}
|
|
|
|
private struct ExecApprovalPromptMetadataRow: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(self.label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(self.value)
|
|
.font(.footnote)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func execApprovalPromptDialog() -> some View {
|
|
self.modifier(ExecApprovalPromptDialogModifier())
|
|
}
|
|
}
|