mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 11:53:32 +02:00
* fix(ios): harden watch exec approval review * fix(ios): address watch approval review feedback * fix(ios): finalize watch approval background recovery * fix(ios): finalize watch approval background recovery (#61757) (thanks @ngutman)
311 lines
11 KiB
Swift
311 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct WatchInboxView: View {
|
|
var store: WatchInboxStore
|
|
var onAction: ((WatchPromptAction) -> Void)?
|
|
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
var onRefreshExecApprovalReview: (() -> Void)?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
if self.store.sortedExecApprovals.count == 1,
|
|
let record = self.store.activeExecApproval
|
|
{
|
|
WatchExecApprovalDetailView(
|
|
store: self.store,
|
|
record: record,
|
|
onDecision: self.onExecApprovalDecision)
|
|
} else if !self.store.sortedExecApprovals.isEmpty {
|
|
WatchExecApprovalListView(
|
|
store: self.store,
|
|
onDecision: self.onExecApprovalDecision)
|
|
} else if self.store.shouldShowExecApprovalReviewStatus {
|
|
WatchExecApprovalLoadingView(
|
|
store: self.store,
|
|
onRetry: self.onRefreshExecApprovalReview)
|
|
} else {
|
|
WatchGenericInboxView(store: self.store, onAction: self.onAction)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchExecApprovalLoadingView: View {
|
|
var store: WatchInboxStore
|
|
var onRetry: (() -> Void)?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Exec approval")
|
|
.font(.headline)
|
|
|
|
if self.store.isExecApprovalReviewLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let statusText = self.store.execApprovalReviewStatusText, !statusText.isEmpty {
|
|
Text(statusText)
|
|
.font(.body)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if !self.store.isExecApprovalReviewLoading {
|
|
Button("Retry") {
|
|
self.onRetry?()
|
|
}
|
|
}
|
|
|
|
Text("Keep your iPhone nearby and unlocked if review details take a moment to appear.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding()
|
|
}
|
|
.navigationTitle("Exec approval")
|
|
}
|
|
}
|
|
|
|
private struct WatchExecApprovalListView: View {
|
|
var store: WatchInboxStore
|
|
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
|
|
var body: some View {
|
|
List {
|
|
Section("Exec approvals") {
|
|
ForEach(self.store.sortedExecApprovals) { record in
|
|
NavigationLink {
|
|
WatchExecApprovalDetailView(
|
|
store: self.store,
|
|
record: record,
|
|
onDecision: self.onDecision)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(record.approval.commandPreview ?? record.approval.commandText)
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
Text(self.metadataLine(for: record))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
if let statusText = record.statusText, !statusText.isEmpty {
|
|
Text(statusText)
|
|
.font(.footnote)
|
|
.foregroundStyle(record.isResolving ? Color.secondary : Color.red)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
|
|
Section("Last result") {
|
|
Text(outcome)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Approvals")
|
|
}
|
|
|
|
private func metadataLine(for record: WatchExecApprovalRecord) -> String {
|
|
var parts: [String] = []
|
|
if let host = record.approval.host, !host.isEmpty {
|
|
parts.append(host)
|
|
}
|
|
if let nodeId = record.approval.nodeId, !nodeId.isEmpty {
|
|
parts.append(nodeId)
|
|
}
|
|
if let expiresText = Self.expiresText(record.approval.expiresAtMs) {
|
|
parts.append(expiresText)
|
|
}
|
|
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
|
|
}
|
|
|
|
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
|
guard let expiresAtMs else { return nil }
|
|
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
|
if deltaSeconds < 60 {
|
|
return "Expires in <1m"
|
|
}
|
|
return "Expires in \(deltaSeconds / 60)m"
|
|
}
|
|
}
|
|
|
|
private struct WatchExecApprovalDetailView: View {
|
|
var store: WatchInboxStore
|
|
let record: WatchExecApprovalRecord
|
|
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(self.record.approval.commandText)
|
|
.font(.headline)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if let host = self.record.approval.host, !host.isEmpty {
|
|
self.metadataRow(label: "Host", value: host)
|
|
}
|
|
if let nodeId = self.record.approval.nodeId, !nodeId.isEmpty {
|
|
self.metadataRow(label: "Node", value: nodeId)
|
|
}
|
|
if let agentId = self.record.approval.agentId, !agentId.isEmpty {
|
|
self.metadataRow(label: "Agent", value: agentId)
|
|
}
|
|
if let expiresText = Self.expiresText(self.record.approval.expiresAtMs) {
|
|
self.metadataRow(label: "Expires", value: expiresText)
|
|
}
|
|
if let riskText = self.riskText(self.record.approval.risk) {
|
|
self.metadataRow(label: "Risk", value: riskText)
|
|
}
|
|
|
|
if let statusText = self.currentRecord?.statusText, !statusText.isEmpty {
|
|
Text(statusText)
|
|
.font(.footnote)
|
|
.foregroundStyle((self.currentRecord?.isResolving ?? false) ? Color.secondary : Color.red)
|
|
}
|
|
|
|
if let currentRecord,
|
|
currentRecord.approval.allowedDecisions.contains(.allowOnce)
|
|
{
|
|
Button("Allow Once") {
|
|
self.onDecision?(currentRecord.id, .allowOnce)
|
|
}
|
|
.disabled(currentRecord.isResolving)
|
|
}
|
|
|
|
if let currentRecord,
|
|
currentRecord.approval.allowedDecisions.contains(.deny)
|
|
{
|
|
Button(role: .destructive) {
|
|
self.onDecision?(currentRecord.id, .deny)
|
|
} label: {
|
|
Text("Deny")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.disabled(currentRecord.isResolving)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding()
|
|
}
|
|
.navigationTitle("Exec approval")
|
|
.onAppear {
|
|
self.store.selectExecApproval(id: self.record.id)
|
|
}
|
|
}
|
|
|
|
private var currentRecord: WatchExecApprovalRecord? {
|
|
self.store.execApprovals.first(where: { $0.id == self.record.id })
|
|
}
|
|
|
|
private func metadataRow(label: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Text(value)
|
|
.font(.footnote)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
private func riskText(_ risk: WatchRiskLevel?) -> String? {
|
|
switch risk {
|
|
case .high:
|
|
return "High"
|
|
case .medium:
|
|
return "Medium"
|
|
case .low:
|
|
return "Low"
|
|
case nil:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
|
guard let expiresAtMs else { return nil }
|
|
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
|
if deltaSeconds < 60 {
|
|
return "<1 minute"
|
|
}
|
|
return "\(deltaSeconds / 60) minutes"
|
|
}
|
|
}
|
|
|
|
private struct WatchGenericInboxView: View {
|
|
var store: WatchInboxStore
|
|
var onAction: ((WatchPromptAction) -> Void)?
|
|
|
|
private func role(for action: WatchPromptAction) -> ButtonRole? {
|
|
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "destructive":
|
|
return .destructive
|
|
case "cancel":
|
|
return .cancel
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(self.store.title)
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
|
|
Text(self.store.body)
|
|
.font(.body)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if let details = self.store.details, !details.isEmpty {
|
|
Text(details)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
|
|
Text(outcome)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if !self.store.actions.isEmpty {
|
|
ForEach(self.store.actions) { action in
|
|
Button(role: self.role(for: action)) {
|
|
self.onAction?(action)
|
|
} label: {
|
|
Text(action.label)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.disabled(self.store.isReplySending)
|
|
}
|
|
}
|
|
|
|
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
|
|
Text(replyStatusText)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if let updatedAt = self.store.updatedAt {
|
|
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding()
|
|
}
|
|
.navigationTitle("OpenClaw")
|
|
}
|
|
}
|