mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 12:31:36 +00:00
* feat(mac): add bulk Approve All / Reject All to the pairing approval panel * fix(mac): resolve bulk pairing decisions against the rendered snapshot
608 lines
23 KiB
Swift
608 lines
23 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Floating approval UI listing every pending pairing request as a card.
|
|
/// Liquid Glass surface on macOS 26+, material fallback on macOS 15.
|
|
struct PairingApprovalPanelView: View {
|
|
let center: PairingApprovalCenter
|
|
|
|
/// Transparent margin around the glass so the drawn shadow has room;
|
|
/// the NSPanel shadow is disabled (it would trace a square window edge).
|
|
static let shadowMargin: CGFloat = 32
|
|
|
|
var body: some View {
|
|
self.surface
|
|
.frame(width: PairingApprovalPanelController.panelWidth)
|
|
.compositingGroup()
|
|
.shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 10)
|
|
.padding(Self.shadowMargin)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var surface: some View {
|
|
if #available(macOS 26.0, *) {
|
|
self.content
|
|
.glassEffect(.regular, in: .rect(cornerRadius: 24))
|
|
} else {
|
|
self.content
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
let cards = self.center.cards
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
self.header(cards: cards)
|
|
// Every request renders and stays actionable; the scroll plus the
|
|
// controller's screen-height clamp bound hostile queue sizes.
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ForEach(cards) { card in
|
|
PairingRequestCardView(
|
|
card: card,
|
|
isBusy: self.center.decisionsInFlight.contains(card.requestId),
|
|
isOnlyRequest: cards.count == 1,
|
|
onDecision: { self.center.decide(card, $0) })
|
|
}
|
|
}
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
self.footer(cards: cards)
|
|
}
|
|
.padding(18)
|
|
}
|
|
|
|
/// Single request keeps the minimal "Not Now" footer; multiple requests
|
|
/// add one-click bulk actions so a queue never needs card-by-card clicks.
|
|
@ViewBuilder
|
|
private func footer(cards: [PairingApprovalCenter.Card]) -> some View {
|
|
let notNow = Button("Not Now") { self.center.snooze() }
|
|
.keyboardShortcut(.cancelAction)
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
if cards.count > 1 {
|
|
let allBusy = cards.allSatisfy { self.center.decisionsInFlight.contains($0.id) }
|
|
HStack(spacing: 8) {
|
|
notNow
|
|
Spacer()
|
|
// No keyboard shortcuts here: bulk approval is a security
|
|
// decision and must never be a stray Return press. Both
|
|
// actions resolve the rendered `cards` snapshot, never the
|
|
// live queue, so a request that arrives mid-click cannot be
|
|
// approved before it was ever displayed.
|
|
Button(role: .destructive) {
|
|
self.center.decideAll(cards, .reject)
|
|
} label: {
|
|
Text("Reject All")
|
|
.padding(.horizontal, 6)
|
|
}
|
|
.pairingActionStyle(prominent: false)
|
|
Button {
|
|
self.center.decideAll(cards, .approve)
|
|
} label: {
|
|
Text("Approve All")
|
|
.padding(.horizontal, 6)
|
|
}
|
|
.pairingActionStyle(prominent: true)
|
|
}
|
|
.disabled(allBusy)
|
|
} else {
|
|
HStack {
|
|
Spacer()
|
|
notNow
|
|
}
|
|
}
|
|
}
|
|
|
|
private func header(cards: [PairingApprovalCenter.Card]) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "checkmark.shield")
|
|
.font(.system(size: 22, weight: .medium))
|
|
.foregroundStyle(.tint)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(cards.count == 1 ? "Pairing Request" : "Pairing Requests")
|
|
.font(.title3.weight(.semibold))
|
|
Text(PairingCardPresentation.headerSummary(for: cards))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PairingRequestCardView: View {
|
|
let card: PairingApprovalCenter.Card
|
|
let isBusy: Bool
|
|
let isOnlyRequest: Bool
|
|
let onDecision: (PairingApprovalCenter.Decision) -> Void
|
|
|
|
@State private var isHoveringDetail = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
self.icon
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(PairingCardPresentation.title(for: self.card))
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
if let subtitle = PairingCardPresentation.subtitle(for: self.card) {
|
|
Text(subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
self.trustRow
|
|
self.accessRows
|
|
self.detailRow
|
|
|
|
HStack(spacing: 8) {
|
|
Spacer()
|
|
self.rejectButton
|
|
self.approveButton
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(RoundedRectangle(cornerRadius: 16).fill(.quinary))
|
|
.disabled(self.isBusy)
|
|
.opacity(self.isBusy ? 0.6 : 1)
|
|
}
|
|
|
|
private var icon: some View {
|
|
Image(systemName: PairingCardPresentation.deviceSymbol(for: self.card))
|
|
.font(.system(size: 20, weight: .medium))
|
|
.frame(width: 42, height: 42)
|
|
.background(Circle().fill(.quaternary))
|
|
}
|
|
|
|
private var trustRow: some View {
|
|
let trust = PairingCardPresentation.trustLine(for: self.card)
|
|
return Label(trust.text, systemImage: trust.symbol)
|
|
.font(.caption)
|
|
.foregroundStyle(self.trustStyle(trust.tone))
|
|
}
|
|
|
|
private func trustStyle(_ tone: PairingCardPresentation.TrustTone) -> AnyShapeStyle {
|
|
switch tone {
|
|
case .caution: AnyShapeStyle(.orange)
|
|
case .neutral: AnyShapeStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var accessRows: some View {
|
|
// Every requested scope/capability renders: hiding one behind a cap
|
|
// could conceal what approval grants. The panel scrolls when long.
|
|
let rows = PairingCardPresentation.accessRows(for: self.card)
|
|
if !rows.isEmpty {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(rows) { row in
|
|
Label(row.text, systemImage: row.symbol)
|
|
.font(.caption)
|
|
.foregroundStyle(
|
|
row.isElevated ? AnyShapeStyle(.orange) : AnyShapeStyle(.primary))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var detailRow: some View {
|
|
HStack(spacing: 4) {
|
|
Text(PairingCardPresentation.identityLine(for: self.card))
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
// Hover-only copy affordance; stays out of the keyboard focus loop.
|
|
Button {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(self.card.subjectId, forType: .string)
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusable(false)
|
|
.help("Copy full ID")
|
|
.opacity(self.isHoveringDetail ? 1 : 0)
|
|
Spacer()
|
|
Text(PairingCardPresentation.metaLine(for: self.card))
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.help(PairingCardPresentation.versionTooltip(for: self.card))
|
|
}
|
|
.onHover { self.isHoveringDetail = $0 }
|
|
}
|
|
|
|
private var approveButton: some View {
|
|
Button {
|
|
self.onDecision(.approve)
|
|
} label: {
|
|
Text(self.card.kind == .node ? "Approve Node" : "Approve Device")
|
|
.padding(.horizontal, 6)
|
|
}
|
|
.pairingActionStyle(prominent: true)
|
|
.keyboardShortcut(self.isOnlyRequest ? .defaultAction : nil)
|
|
}
|
|
|
|
private var rejectButton: some View {
|
|
Button(role: .destructive) {
|
|
self.onDecision(.reject)
|
|
} label: {
|
|
Text("Reject")
|
|
.padding(.horizontal, 6)
|
|
}
|
|
.pairingActionStyle(prominent: false)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Shared capsule styling for pairing decision buttons: Liquid Glass on
|
|
/// macOS 26+, bordered fallback on macOS 15.
|
|
@ViewBuilder
|
|
func pairingActionStyle(prominent: Bool) -> some View {
|
|
if #available(macOS 26.0, *) {
|
|
if prominent {
|
|
self.buttonStyle(.glassProminent).buttonBorderShape(.capsule)
|
|
} else {
|
|
self.buttonStyle(.glass).buttonBorderShape(.capsule)
|
|
}
|
|
} else {
|
|
if prominent {
|
|
self.buttonStyle(.borderedProminent).buttonBorderShape(.capsule)
|
|
} else {
|
|
self.buttonStyle(.bordered).buttonBorderShape(.capsule)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pure display mapping for pairing cards; kept UI-framework-free so tests
|
|
/// can assert copy and symbols without instantiating views.
|
|
enum PairingCardPresentation {
|
|
struct AccessRow: Identifiable, Equatable {
|
|
/// Derived from the raw grant, not display text: distinct grants can
|
|
/// render identically (e.g. caps `voice`/`audio`) and colliding ids
|
|
/// would let SwiftUI drop rows from the approval surface.
|
|
let id: String
|
|
let symbol: String
|
|
let text: String
|
|
let isElevated: Bool
|
|
}
|
|
|
|
enum TrustTone: Equatable {
|
|
case caution
|
|
case neutral
|
|
}
|
|
|
|
struct TrustLine: Equatable {
|
|
let symbol: String
|
|
let text: String
|
|
let tone: TrustTone
|
|
}
|
|
|
|
static func headerSummary(for cards: [PairingApprovalCenter.Card]) -> String {
|
|
guard cards.count == 1, let card = cards.first else {
|
|
return "\(cards.count) devices want to connect to OpenClaw."
|
|
}
|
|
return card.kind == .node
|
|
? "A node wants to connect to OpenClaw."
|
|
: "A device wants to connect to OpenClaw."
|
|
}
|
|
|
|
static func title(for card: PairingApprovalCenter.Card) -> String {
|
|
let name = card.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let name, !name.isEmpty, name != card.subjectId {
|
|
return name
|
|
}
|
|
switch card.kind {
|
|
case .node: return "Unnamed node"
|
|
case .device: return self.isMac(card.platform) ? "OpenClaw Mac app" : "New device"
|
|
}
|
|
}
|
|
|
|
static func subtitle(for card: PairingApprovalCenter.Card) -> String? {
|
|
var parts: [String] = []
|
|
if let platform = self.prettyPlatform(card.platform) {
|
|
parts.append(platform)
|
|
}
|
|
if let model = self.prettyModel(card.modelIdentifier, deviceFamily: card.deviceFamily),
|
|
parts.allSatisfy({ $0 != model })
|
|
{
|
|
parts.append(model)
|
|
}
|
|
if card.kind == .device, let role = card.role?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!role.isEmpty
|
|
{
|
|
parts.append(role == "operator" ? "Operator" : role)
|
|
}
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}
|
|
|
|
static func trustLine(for card: PairingApprovalCenter.Card) -> TrustLine {
|
|
if card.isRepair {
|
|
return TrustLine(
|
|
symbol: "arrow.triangle.2.circlepath",
|
|
text: "Repair request — its access token will rotate",
|
|
tone: .caution)
|
|
}
|
|
switch card.previouslyPaired {
|
|
case true:
|
|
// The requester merely CLAIMS this id (it is not authenticated),
|
|
// so an already-paired id is a caution — approval replaces the
|
|
// existing peer's token — never a positive trust signal.
|
|
return TrustLine(
|
|
symbol: "exclamationmark.triangle",
|
|
text: "This ID is already paired — approving replaces its access token",
|
|
tone: .caution)
|
|
case false:
|
|
return TrustLine(
|
|
symbol: "sparkles",
|
|
text: "First connection from this \(card.kind == .node ? "node" : "device")",
|
|
tone: .neutral)
|
|
case nil:
|
|
return TrustLine(
|
|
symbol: "clock",
|
|
text: "Checking pairing history…",
|
|
tone: .neutral)
|
|
}
|
|
}
|
|
|
|
static func accessRows(for card: PairingApprovalCenter.Card) -> [AccessRow] {
|
|
switch card.kind {
|
|
case .device:
|
|
// Admin access is what approval actually grants; surface it first
|
|
// and highlighted so it can never hide among ordinary scopes.
|
|
let rows = self.friendlyScopes(card.scopes).map { scope in
|
|
AccessRow(
|
|
id: "scope:\(scope.raw)",
|
|
symbol: scope.raw == "operator.admin" ? "exclamationmark.shield" : "key",
|
|
text: scope.text,
|
|
isElevated: scope.raw == "operator.admin")
|
|
}
|
|
return rows.filter(\.isElevated) + rows.filter { !$0.isElevated }
|
|
case .node:
|
|
var rows: [AccessRow] = []
|
|
// Commands in NODE_SYSTEM_RUN_COMMANDS (src/infra/node-commands.ts)
|
|
// mean approving grants arbitrary command execution on the node.
|
|
let isSystemRun = { (command: String) in
|
|
command == "system.run" || command == "system.which" || command.hasPrefix("system.run.")
|
|
}
|
|
if card.commands.contains(where: isSystemRun) {
|
|
rows.append(AccessRow(
|
|
id: "system-run",
|
|
symbol: "exclamationmark.shield",
|
|
text: "Can run system commands",
|
|
isElevated: true))
|
|
}
|
|
rows.append(contentsOf: self.friendlyCapNames(card.caps).map {
|
|
AccessRow(id: "cap:\($0.raw)", symbol: $0.symbol, text: $0.text, isElevated: false)
|
|
})
|
|
// Approval persists the whole declared command surface; list the
|
|
// remaining commands so none of it is granted invisibly.
|
|
var seen = Set<String>()
|
|
let otherCommands = card.commands.filter {
|
|
!$0.isEmpty && !isSystemRun($0) && seen.insert($0).inserted
|
|
}
|
|
if !otherCommands.isEmpty {
|
|
rows.append(AccessRow(
|
|
id: "commands",
|
|
symbol: "terminal",
|
|
text: "Commands: \(otherCommands.joined(separator: ", "))",
|
|
isElevated: false))
|
|
}
|
|
return rows
|
|
}
|
|
}
|
|
|
|
/// Left, monospaced: stable identity facts (short id, source address).
|
|
static func identityLine(for card: PairingApprovalCenter.Card) -> String {
|
|
var parts = ["ID \(self.shortIdentifier(card.subjectId))"]
|
|
if let ip = self.prettyIP(card.remoteIp) {
|
|
parts.append(ip)
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
/// Right, trailing: app version and request age.
|
|
static func metaLine(for card: PairingApprovalCenter.Card, now: Date = Date()) -> String {
|
|
var parts: [String] = []
|
|
if let version = card.version?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!version.isEmpty
|
|
{
|
|
parts.append("v\(version)")
|
|
}
|
|
parts.append(self.relativeRequestTime(for: card, now: now))
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
static func versionTooltip(for card: PairingApprovalCenter.Card) -> String {
|
|
var parts: [String] = []
|
|
if let version = card.version?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!version.isEmpty
|
|
{
|
|
parts.append("App \(version)")
|
|
}
|
|
if let core = card.coreVersion?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!core.isEmpty
|
|
{
|
|
parts.append("Core \(core)")
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
static func relativeRequestTime(for card: PairingApprovalCenter.Card, now: Date = Date()) -> String {
|
|
let elapsed = now.timeIntervalSince(card.requestedAt)
|
|
if elapsed < 60 {
|
|
return "just now"
|
|
}
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .short
|
|
return formatter.localizedString(for: card.requestedAt, relativeTo: now)
|
|
}
|
|
|
|
static func deviceSymbol(for card: PairingApprovalCenter.Card) -> String {
|
|
let model = card.modelIdentifier?.lowercased() ?? ""
|
|
if model.hasPrefix("macbook") {
|
|
return "macbook"
|
|
}
|
|
if model.hasPrefix("macmini") {
|
|
return "macmini"
|
|
}
|
|
if model.hasPrefix("macstudio") {
|
|
return "macstudio"
|
|
}
|
|
if model.hasPrefix("macpro") {
|
|
return "macpro.gen3"
|
|
}
|
|
if model.hasPrefix("imac") {
|
|
return "desktopcomputer"
|
|
}
|
|
|
|
let family = (card.deviceFamily ?? "").lowercased()
|
|
let platform = (card.platform ?? "").lowercased()
|
|
let hints = "\(family) \(platform)"
|
|
if hints.contains("iphone") || hints.contains("ios") {
|
|
return "iphone"
|
|
}
|
|
if hints.contains("ipad") {
|
|
return "ipad"
|
|
}
|
|
if hints.contains("android") {
|
|
return "smartphone"
|
|
}
|
|
if hints.contains("mac") || hints.contains("darwin") {
|
|
return "laptopcomputer"
|
|
}
|
|
if hints.contains("linux") || hints.contains("windows") {
|
|
return "server.rack"
|
|
}
|
|
return "network"
|
|
}
|
|
|
|
static func shortIdentifier(_ id: String) -> String {
|
|
let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.count > 20 else {
|
|
return trimmed
|
|
}
|
|
return "\(trimmed.prefix(8))...\(trimmed.suffix(7))"
|
|
}
|
|
|
|
static func prettyIP(_ ip: String?) -> String? {
|
|
let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let trimmed, !trimmed.isEmpty else {
|
|
return nil
|
|
}
|
|
return trimmed.replacingOccurrences(of: "::ffff:", with: "")
|
|
}
|
|
|
|
static func prettyPlatform(_ raw: String?) -> String? {
|
|
let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let platform, !platform.isEmpty else {
|
|
return nil
|
|
}
|
|
// Device pairing sends browser-style tokens (MacIntel); map those
|
|
// before the generic "os version" formatter capitalizes them.
|
|
switch platform.lowercased() {
|
|
case "macintel", "x86_64-apple-darwin":
|
|
return "Mac (Intel)"
|
|
case "macarm", "macarm64", "arm64-apple-darwin", "aarch64-apple-darwin":
|
|
return "Mac (Apple silicon)"
|
|
case "darwin":
|
|
return "Mac"
|
|
default:
|
|
if let pretty = PlatformLabelFormatter.pretty(platform) {
|
|
return pretty
|
|
}
|
|
return platform.lowercased().contains("mac") ? "Mac" : platform
|
|
}
|
|
}
|
|
|
|
static func friendlyScopes(_ scopes: [String]) -> [(raw: String, text: String)] {
|
|
var seen = Set<String>()
|
|
return scopes.compactMap { scope in
|
|
let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !normalized.isEmpty, seen.insert(normalized).inserted else {
|
|
return nil
|
|
}
|
|
switch normalized {
|
|
case "operator.admin":
|
|
return (normalized, "Admin access")
|
|
case "operator.read":
|
|
return (normalized, "Read OpenClaw data")
|
|
case "operator.write":
|
|
return (normalized, "Send messages and make changes")
|
|
case "operator.approvals":
|
|
return (normalized, "Manage approvals")
|
|
case "operator.pairing":
|
|
return (normalized, "Pair and repair devices")
|
|
case "operator.talk.secrets":
|
|
return (normalized, "Use Talk credentials")
|
|
default:
|
|
return (normalized, normalized)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func friendlyCapNames(_ caps: [String]) -> [(raw: String, symbol: String, text: String)] {
|
|
var seen = Set<String>()
|
|
return caps.compactMap { cap in
|
|
let normalized = cap.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !normalized.isEmpty, seen.insert(normalized).inserted else {
|
|
return nil
|
|
}
|
|
switch normalized {
|
|
case "screen":
|
|
return (normalized, "rectangle.inset.filled.badge.record", "Screen capture")
|
|
case "camera":
|
|
return (normalized, "camera", "Camera")
|
|
case "file":
|
|
return (normalized, "folder", "File transfer")
|
|
case "location":
|
|
return (normalized, "location", "Location")
|
|
case "voice", "audio":
|
|
return (normalized, "mic", "Microphone and voice")
|
|
case "canvas":
|
|
return (normalized, "paintbrush", "Canvas display")
|
|
default:
|
|
return (normalized, "puzzlepiece.extension", self.prettifyRawName(normalized))
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func prettifyRawName(_ raw: String) -> String {
|
|
let words = raw.split(whereSeparator: { $0 == "-" || $0 == "_" || $0 == "." }).map(String.init)
|
|
guard let first = words.first else {
|
|
return raw
|
|
}
|
|
let capitalized = first.prefix(1).uppercased() + first.dropFirst()
|
|
return ([capitalized] + words.dropFirst()).joined(separator: " ")
|
|
}
|
|
|
|
private static func prettyModel(_ modelIdentifier: String?, deviceFamily: String?) -> String? {
|
|
let model = modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let model, !model.isEmpty {
|
|
return model
|
|
}
|
|
let family = deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let family, !family.isEmpty else {
|
|
return nil
|
|
}
|
|
return family
|
|
}
|
|
|
|
private static func isMac(_ platform: String?) -> Bool {
|
|
guard let platform else {
|
|
return false
|
|
}
|
|
let lower = platform.lowercased()
|
|
return lower.contains("mac") || lower.contains("darwin")
|
|
}
|
|
}
|