mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(mac): unified Liquid Glass pairing approval panel (#102601)
* feat(mac): unified Liquid Glass pairing approval panel Replace the serial node/device pairing NSAlerts with one floating approval panel. Both prompters feed request cards into a shared PairingApprovalCenter; each card shows a hardware icon, platform and model, app/core version, source IP, a copyable short id, request age, a trust line (first connection / already-paired-id caution / repair token rotation), and a friendly access summary with an elevated warning for system.run-class commands. Requests resolved elsewhere disappear live; Not Now snoozes without resolving (gateway TTL applies) and the menu-bar pending line reopens the panel. Liquid Glass surface on macOS 26+, material fallback on macOS 15. Deletes the NSAlert + invisible-host-window machinery, dead node isRepair handling, and text-only describe() body. Closes #102535 * fix(mac): satisfy swiftformat conditionalAssignment and resync native i18n inventory
This commit is contained in:
parent
6433b82723
commit
4cc92009ed
12 changed files with 1652 additions and 943 deletions
|
|
@ -16217,70 +16217,6 @@
|
|||
"surface": "apple",
|
||||
"id": "native.apple.4f3841d5de0135c4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 263,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "New Mac wants to connect",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.135336820846e999"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 263,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "New device wants to connect",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.18c2594347b2c96e"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 267,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "this Mac app",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.fbd59df61769188f"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 267,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "this device",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.172ef5ed341ad73a"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 272,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "Approve Device",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.809e76375508d27d"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 272,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "Approve Mac",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.b5182817237683bc"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 308,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "New device",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.40a3483823dbbd36"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 308,
|
||||
"path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift",
|
||||
"source": "OpenClaw Mac app",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.0702e353696c907f"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 19,
|
||||
|
|
@ -17403,15 +17339,15 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 55,
|
||||
"line": 53,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Pairing approval pending (\\(self.pairingPrompter.pendingCount))\\(repairSuffix)",
|
||||
"source": "Pairing approval pending (\\(self.pairingPrompter.pendingCount))",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.8c3b9bd94bc2f8ef"
|
||||
"id": "native.apple.66280f3e99ab2f36"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 60,
|
||||
"line": 57,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": " · \\(repairCount) repair",
|
||||
"surface": "apple",
|
||||
|
|
@ -17419,7 +17355,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 62,
|
||||
"line": 59,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Device pairing pending (\\(self.devicePairingPrompter.pendingCount))\\(repairSuffix)",
|
||||
"surface": "apple",
|
||||
|
|
@ -17427,7 +17363,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 72,
|
||||
"line": 68,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Send Heartbeats",
|
||||
"surface": "apple",
|
||||
|
|
@ -17435,7 +17371,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 84,
|
||||
"line": 80,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Browser Control",
|
||||
"surface": "apple",
|
||||
|
|
@ -17443,7 +17379,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 87,
|
||||
"line": 83,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Allow Camera",
|
||||
"surface": "apple",
|
||||
|
|
@ -17451,7 +17387,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 94,
|
||||
"line": 90,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Exec Approvals",
|
||||
"surface": "apple",
|
||||
|
|
@ -17459,7 +17395,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 97,
|
||||
"line": 93,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Allow Canvas",
|
||||
"surface": "apple",
|
||||
|
|
@ -17467,7 +17403,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 105,
|
||||
"line": 101,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Voice Wake",
|
||||
"surface": "apple",
|
||||
|
|
@ -17475,7 +17411,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 116,
|
||||
"line": 112,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Dashboard",
|
||||
"surface": "apple",
|
||||
|
|
@ -17483,7 +17419,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 121,
|
||||
"line": 117,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Chat",
|
||||
"surface": "apple",
|
||||
|
|
@ -17491,7 +17427,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 128,
|
||||
"line": 124,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Close Canvas",
|
||||
"surface": "apple",
|
||||
|
|
@ -17499,7 +17435,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 128,
|
||||
"line": 124,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Canvas",
|
||||
"surface": "apple",
|
||||
|
|
@ -17507,7 +17443,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 135,
|
||||
"line": 131,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Stop Talk Mode",
|
||||
"surface": "apple",
|
||||
|
|
@ -17515,7 +17451,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 135,
|
||||
"line": 131,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Talk Mode",
|
||||
"surface": "apple",
|
||||
|
|
@ -17523,7 +17459,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 140,
|
||||
"line": 136,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Settings…",
|
||||
"surface": "apple",
|
||||
|
|
@ -17531,7 +17467,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 143,
|
||||
"line": 139,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "About OpenClaw",
|
||||
"surface": "apple",
|
||||
|
|
@ -17539,7 +17475,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 145,
|
||||
"line": 141,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Update ready, restart now?",
|
||||
"surface": "apple",
|
||||
|
|
@ -17547,7 +17483,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 147,
|
||||
"line": 143,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Quit",
|
||||
"surface": "apple",
|
||||
|
|
@ -17555,7 +17491,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 179,
|
||||
"line": 175,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "OpenClaw Not Configured",
|
||||
"surface": "apple",
|
||||
|
|
@ -17563,7 +17499,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 181,
|
||||
"line": 177,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Remote OpenClaw Active",
|
||||
"surface": "apple",
|
||||
|
|
@ -17571,7 +17507,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 183,
|
||||
"line": 179,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "OpenClaw Active",
|
||||
"surface": "apple",
|
||||
|
|
@ -17579,7 +17515,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 220,
|
||||
"line": 216,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Debug",
|
||||
"surface": "apple",
|
||||
|
|
@ -17587,7 +17523,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 224,
|
||||
"line": 220,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Config Folder",
|
||||
"surface": "apple",
|
||||
|
|
@ -17595,7 +17531,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 229,
|
||||
"line": 225,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Run Health Check Now",
|
||||
"surface": "apple",
|
||||
|
|
@ -17603,15 +17539,23 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 234,
|
||||
"line": 230,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Send Test Heartbeat",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.406f1a5866bf0bd0"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 236,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Show Pairing Panel (Demo)",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.79a6d2d396cd5477"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 240,
|
||||
"line": 243,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Remote Tunnel",
|
||||
"surface": "apple",
|
||||
|
|
@ -17619,7 +17563,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 243,
|
||||
"line": 246,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Reset Remote Tunnel",
|
||||
"surface": "apple",
|
||||
|
|
@ -17627,7 +17571,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 251,
|
||||
"line": 254,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Verbose Logging (Main): Off",
|
||||
"surface": "apple",
|
||||
|
|
@ -17635,7 +17579,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 251,
|
||||
"line": 254,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Verbose Logging (Main): On",
|
||||
"surface": "apple",
|
||||
|
|
@ -17643,7 +17587,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 256,
|
||||
"line": 259,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Verbosity",
|
||||
"surface": "apple",
|
||||
|
|
@ -17651,7 +17595,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 264,
|
||||
"line": 267,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "File Logging: Off",
|
||||
"surface": "apple",
|
||||
|
|
@ -17659,7 +17603,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 264,
|
||||
"line": 267,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "File Logging: On",
|
||||
"surface": "apple",
|
||||
|
|
@ -17667,7 +17611,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 269,
|
||||
"line": 272,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "App Logging",
|
||||
"surface": "apple",
|
||||
|
|
@ -17675,7 +17619,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 274,
|
||||
"line": 277,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Session Store",
|
||||
"surface": "apple",
|
||||
|
|
@ -17683,7 +17627,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 280,
|
||||
"line": 283,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Agent Events…",
|
||||
"surface": "apple",
|
||||
|
|
@ -17691,7 +17635,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 285,
|
||||
"line": 288,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Open Log",
|
||||
"surface": "apple",
|
||||
|
|
@ -17699,7 +17643,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 290,
|
||||
"line": 293,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Send Debug Voice Text",
|
||||
"surface": "apple",
|
||||
|
|
@ -17707,7 +17651,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 295,
|
||||
"line": 298,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Send Test Notification",
|
||||
"surface": "apple",
|
||||
|
|
@ -17715,7 +17659,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 302,
|
||||
"line": 305,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Restart Gateway",
|
||||
"surface": "apple",
|
||||
|
|
@ -17723,7 +17667,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 308,
|
||||
"line": 311,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Restart Onboarding",
|
||||
"surface": "apple",
|
||||
|
|
@ -17731,7 +17675,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 313,
|
||||
"line": 316,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Restart App",
|
||||
"surface": "apple",
|
||||
|
|
@ -17739,7 +17683,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 352,
|
||||
"line": 355,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Main",
|
||||
"surface": "apple",
|
||||
|
|
@ -17747,15 +17691,23 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 352,
|
||||
"line": 355,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Other",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.5eca63e1a931e290"
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 429,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Show pairing requests",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.72d7e38d56c76ff8"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 440,
|
||||
"line": 454,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Refreshing microphones…",
|
||||
"surface": "apple",
|
||||
|
|
@ -17763,7 +17715,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 447,
|
||||
"line": 461,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Microphone",
|
||||
"surface": "apple",
|
||||
|
|
@ -17771,7 +17723,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 468,
|
||||
"line": 482,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Disconnected (using System default)",
|
||||
"surface": "apple",
|
||||
|
|
@ -17915,7 +17867,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 399,
|
||||
"line": 428,
|
||||
"path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift",
|
||||
"source": "Node pairing approved",
|
||||
"surface": "apple",
|
||||
|
|
@ -17923,7 +17875,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 399,
|
||||
"line": 428,
|
||||
"path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift",
|
||||
"source": "Node pairing rejected",
|
||||
"surface": "apple",
|
||||
|
|
@ -18985,6 +18937,94 @@
|
|||
"surface": "apple",
|
||||
"id": "native.apple.b82621bc28d4c6e3"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 52,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Not Now",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.048e9cc88fb7af55"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 67,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Pairing Request",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.57e27a9a3ef63ac4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 67,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Pairing Requests",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.61152c2ea359eaa0"
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 176,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Copy full ID",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.d3b539b5e822f581"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 192,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Approve Device",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.01bd6552a4adee7c"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 192,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Approve Node",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.b12ad4f00b1cd3d1"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 214,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "Reject",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.8878bb60553ad307"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 258,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "A device wants to connect to OpenClaw.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.2e9d9d1f5d4346d4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 258,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "A node wants to connect to OpenClaw.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.11b1b5e9dd510766"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 269,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "New device",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.2412e85f7d11395e"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 269,
|
||||
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
|
||||
"source": "OpenClaw Mac app",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.a09a61f4efe1e63e"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 15,
|
||||
|
|
|
|||
|
|
@ -251,6 +251,51 @@ enum DebugActions {
|
|||
proc.arguments = ["code", path]
|
||||
try? proc.run()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
static func showPairingPanelDemo() {
|
||||
let now = Date()
|
||||
PairingApprovalCenter.shared.injectDemoCards([
|
||||
PairingApprovalCenter.Card(
|
||||
kind: .node,
|
||||
requestId: "demo-node-1",
|
||||
subjectId: "19cec1c3301a7469d4fd71f5f81339508390dadda91b34aee15faf2849dccdc7",
|
||||
displayName: "Peter's MacBook Pro",
|
||||
platform: "macos 26.5",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,3",
|
||||
version: "2026.6.11",
|
||||
coreVersion: "2026.6.10",
|
||||
remoteIp: "192.168.1.42",
|
||||
role: nil,
|
||||
scopes: [],
|
||||
caps: ["screen", "camera", "file"],
|
||||
commands: ["system.run", "system.notify"],
|
||||
isRepair: false,
|
||||
previouslyPaired: false,
|
||||
requestedAt: now.addingTimeInterval(-45)),
|
||||
PairingApprovalCenter.Card(
|
||||
kind: .device,
|
||||
requestId: "demo-device-1",
|
||||
subjectId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd",
|
||||
displayName: "OpenClaw iPhone",
|
||||
platform: "ios 26.4",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
remoteIp: "192.168.1.87",
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.approvals"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
isRepair: true,
|
||||
previouslyPaired: true,
|
||||
requestedAt: now.addingTimeInterval(-190)),
|
||||
])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum DebugActionError: LocalizedError {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,17 @@ final class DevicePairingApprovalPrompter {
|
|||
private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing")
|
||||
private var task: Task<Void, Never>?
|
||||
private var isStopping = false
|
||||
private var isPresenting = false
|
||||
private var listFetchGeneration = 0
|
||||
private var queue: [PendingRequest] = []
|
||||
var pendingCount: Int = 0
|
||||
var pendingRepairCount: Int = 0
|
||||
private let alertState = PairingAlertState()
|
||||
private var resolvedByRequestId: Set<String> = []
|
||||
/// Device ids already paired on the gateway (from the last list fetch);
|
||||
/// drives the "previously paired" trust signal on cards.
|
||||
private var pairedDeviceIds: Set<String> = []
|
||||
/// Requests that arrived via push after the last list fetch; their trust
|
||||
/// state is unknown until fresh gateway truth applies (stale snapshots
|
||||
/// must not produce a positive "previously paired" claim).
|
||||
private var trustUnknownRequestIds: Set<String> = []
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
|
|
@ -53,14 +58,17 @@ final class DevicePairingApprovalPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||
private typealias PairingResolvedEvent = PairingPromptSupport.PairingResolvedEvent
|
||||
|
||||
func start() {
|
||||
PairingApprovalCenter.shared.register(kind: .device) { [weak self] card, decision in
|
||||
await self?.handleDecision(card: card, decision: decision)
|
||||
}
|
||||
self.startPushTask()
|
||||
}
|
||||
|
||||
private func startPushTask() {
|
||||
PairingAlertSupport.startPairingPushTask(
|
||||
PairingPromptSupport.startPairingPushTask(
|
||||
task: &self.task,
|
||||
isStopping: &self.isStopping,
|
||||
loadPending: self.loadPendingRequestsFromGateway,
|
||||
|
|
@ -68,33 +76,38 @@ final class DevicePairingApprovalPrompter {
|
|||
}
|
||||
|
||||
func stop() {
|
||||
self.stopPushTask()
|
||||
self.updatePendingCounts()
|
||||
self.resolvedByRequestId.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func stopPushTask() {
|
||||
PairingAlertSupport.stopPairingPrompter(
|
||||
PairingPromptSupport.stopPairingPrompter(
|
||||
isStopping: &self.isStopping,
|
||||
task: &self.task,
|
||||
queue: &self.queue,
|
||||
isPresenting: &self.isPresenting,
|
||||
state: self.alertState)
|
||||
queue: &self.queue)
|
||||
PairingApprovalCenter.shared.unregister(kind: .device)
|
||||
self.updatePendingCounts()
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
// Push-triggered refreshes can overlap; only the newest snapshot may
|
||||
// replace the queue or an older read would drop just-arrived requests.
|
||||
self.listFetchGeneration += 1
|
||||
let generation = self.listFetchGeneration
|
||||
do {
|
||||
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||
await self.apply(list: list)
|
||||
guard generation == self.listFetchGeneration else { return }
|
||||
self.apply(list: list)
|
||||
} catch {
|
||||
self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(list: PairingList) async {
|
||||
self.queue = list.pending.sorted(by: { $0.ts > $1.ts })
|
||||
private func apply(list: PairingList) {
|
||||
if self.isStopping {
|
||||
return
|
||||
}
|
||||
self.pairedDeviceIds = Set((list.paired ?? []).map(\.deviceId))
|
||||
self.queue = list.pending.sorted(by: { $0.ts < $1.ts })
|
||||
// This snapshot is authoritative for every pending request in it.
|
||||
self.trustUnknownRequestIds.removeAll()
|
||||
self.updatePendingCounts()
|
||||
self.presentNextIfNeeded()
|
||||
self.syncCards()
|
||||
}
|
||||
|
||||
private func updatePendingCounts() {
|
||||
|
|
@ -102,71 +115,66 @@ final class DevicePairingApprovalPrompter {
|
|||
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
||||
}
|
||||
|
||||
private func presentNextIfNeeded() {
|
||||
private func syncCards() {
|
||||
guard !self.isStopping else { return }
|
||||
guard !self.isPresenting else { return }
|
||||
guard let next = self.queue.first else { return }
|
||||
self.isPresenting = true
|
||||
self.presentAlert(for: next)
|
||||
let cards = self.queue.map { self.card(for: $0) }
|
||||
PairingApprovalCenter.shared.sync(kind: .device, cards: cards)
|
||||
}
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
private func card(for req: PendingRequest) -> PairingApprovalCenter.Card {
|
||||
PairingApprovalCenter.Card(
|
||||
kind: .device,
|
||||
requestId: req.requestId,
|
||||
messageText: Self.alertTitle(for: req),
|
||||
informativeText: Self.alertSummary(for: req),
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: Self.approveButtonTitle(for: req)),
|
||||
accessoryView: Self.buildAccessoryView(for: req),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
subjectId: req.deviceId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
remoteIp: req.remoteIp,
|
||||
role: req.role,
|
||||
scopes: req.scopes ?? [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
isRepair: req.isRepair == true,
|
||||
previouslyPaired: self.trustUnknownRequestIds.contains(req.requestId)
|
||||
? nil
|
||||
: self.pairedDeviceIds.contains(req.deviceId),
|
||||
requestedAt: Date(timeIntervalSince1970: req.ts / 1000))
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
var shouldRemove = response != .alertSecondButtonReturn
|
||||
defer {
|
||||
if shouldRemove {
|
||||
if self.queue.first == request {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
self.queue.removeAll { $0 == request }
|
||||
}
|
||||
}
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
private func handleDecision(card: PairingApprovalCenter.Card, decision: PairingApprovalCenter.Decision) async {
|
||||
guard !self.isStopping else { return }
|
||||
guard let request = self.queue.first(where: { $0.requestId == card.requestId }) else { return }
|
||||
|
||||
if self.resolvedByRequestId.remove(request.requestId) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
switch decision {
|
||||
case .approve:
|
||||
if await !(self.approve(requestId: request.requestId)) {
|
||||
// Stale request (expired or superseded on the gateway): re-sync the
|
||||
// queue with gateway truth so accumulated stale alerts collapse at once.
|
||||
// queue with gateway truth so accumulated stale cards collapse at once.
|
||||
await self.loadPendingRequestsFromGateway()
|
||||
return
|
||||
}
|
||||
case .alertSecondButtonReturn:
|
||||
shouldRemove = false
|
||||
if let idx = self.queue.firstIndex(of: request) {
|
||||
self.queue.remove(at: idx)
|
||||
case .reject:
|
||||
if await !(self.reject(requestId: request.requestId)) {
|
||||
// Failed reject leaves the request pending on the gateway;
|
||||
// re-sync instead of hiding a still-live card.
|
||||
await self.loadPendingRequestsFromGateway()
|
||||
return
|
||||
}
|
||||
self.queue.append(request)
|
||||
return
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Discard any in-flight list snapshot: it predates this resolution
|
||||
// and applying it would resurrect the just-resolved card.
|
||||
self.listFetchGeneration += 1
|
||||
self.queue.removeAll { $0.requestId == request.requestId }
|
||||
self.updatePendingCounts()
|
||||
self.syncCards()
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
await PairingAlertSupport.approveRequest(
|
||||
await PairingPromptSupport.approveRequest(
|
||||
requestId: requestId,
|
||||
kind: "device",
|
||||
logger: self.logger)
|
||||
|
|
@ -175,8 +183,8 @@ final class DevicePairingApprovalPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
await PairingAlertSupport.rejectRequest(
|
||||
private func reject(requestId: String) async -> Bool {
|
||||
await PairingPromptSupport.rejectRequest(
|
||||
requestId: requestId,
|
||||
kind: "device",
|
||||
logger: self.logger)
|
||||
|
|
@ -185,10 +193,6 @@ final class DevicePairingApprovalPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(state: self.alertState)
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "device.pair.requested":
|
||||
|
|
@ -217,7 +221,7 @@ final class DevicePairingApprovalPrompter {
|
|||
|
||||
/// The gateway keeps at most one live pending request per device, so a new
|
||||
/// requestId for the same device supersedes anything still queued for it.
|
||||
/// Without this, missed/dropped resolve pushes pile up as alerts whose
|
||||
/// Without this, missed/dropped resolve pushes pile up as cards whose
|
||||
/// approval can no longer succeed. Returns nil when the request is already queued.
|
||||
static func coalescedQueue(_ queue: [PendingRequest], adding req: PendingRequest) -> [PendingRequest]? {
|
||||
guard !queue.contains(where: { $0.requestId == req.requestId }) else { return nil }
|
||||
|
|
@ -226,199 +230,23 @@ final class DevicePairingApprovalPrompter {
|
|||
|
||||
private func enqueue(_ req: PendingRequest) {
|
||||
guard let next = Self.coalescedQueue(self.queue, adding: req) else { return }
|
||||
let supersededActiveId = self.alertState.activeRequestId.flatMap { activeId in
|
||||
self.queue.contains(where: {
|
||||
$0.requestId == activeId && $0.deviceId == req.deviceId
|
||||
}) ? activeId : nil
|
||||
}
|
||||
self.queue = next
|
||||
if let supersededActiveId {
|
||||
// The visible alert is for a superseded requestId; close it so the
|
||||
// fresh request presents instead of an approve that would no-op.
|
||||
self.resolvedByRequestId.insert(supersededActiveId)
|
||||
self.endActiveAlert()
|
||||
}
|
||||
self.trustUnknownRequestIds.insert(req.requestId)
|
||||
self.updatePendingCounts()
|
||||
self.presentNextIfNeeded()
|
||||
self.syncCards()
|
||||
// The "previously paired" trust signal must not come from a stale
|
||||
// startup snapshot; re-fetch gateway truth for each new request.
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.loadPendingRequestsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
|
||||
? PairingAlertSupport.PairingResolution.approved
|
||||
: PairingAlertSupport.PairingResolution.rejected
|
||||
if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
|
||||
self.resolvedByRequestId.insert(resolved.requestId)
|
||||
self.endActiveAlert()
|
||||
let decision = resolution.rawValue
|
||||
self.logger.info(
|
||||
"device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " +
|
||||
"decision=\(decision, privacy: .public)")
|
||||
return
|
||||
}
|
||||
// Discard any in-flight list snapshot taken before this resolution
|
||||
// so it cannot resurrect the resolved card.
|
||||
self.listFetchGeneration += 1
|
||||
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||
self.updatePendingCounts()
|
||||
}
|
||||
|
||||
static func alertTitle(for req: PendingRequest) -> String {
|
||||
self.isMac(req.platform) ? "New Mac wants to connect" : "New device wants to connect"
|
||||
}
|
||||
|
||||
static func alertSummary(for req: PendingRequest) -> String {
|
||||
let subject = self.isMac(req.platform) ? "this Mac app" : "this device"
|
||||
return "Approve \(subject) to control OpenClaw. Only approve if this is yours; you can remove it later in Settings."
|
||||
}
|
||||
|
||||
static func approveButtonTitle(for req: PendingRequest) -> String {
|
||||
self.isMac(req.platform) ? "Approve Mac" : "Approve Device"
|
||||
}
|
||||
|
||||
static func buildAccessoryView(for req: PendingRequest) -> NSView {
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 8
|
||||
stack.edgeInsets = NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
|
||||
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Device", value: self.deviceName(for: req)))
|
||||
if let platform = self.prettyPlatform(req.platform) {
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Platform", value: platform))
|
||||
}
|
||||
if let role = self.prettyRole(req.role) {
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Role", value: role))
|
||||
}
|
||||
let accessItems = self.friendlyScopeNames(req.scopes)
|
||||
if !accessItems.isEmpty {
|
||||
stack.addArrangedSubview(self.makeSectionLabel("Access requested"))
|
||||
for item in accessItems {
|
||||
stack.addArrangedSubview(self.makeBullet(item))
|
||||
}
|
||||
}
|
||||
stack.addArrangedSubview(self.makeDetailLine(req))
|
||||
|
||||
let fitting = stack.fittingSize
|
||||
stack.frame = NSRect(x: 0, y: 0, width: 420, height: fitting.height)
|
||||
return stack
|
||||
}
|
||||
|
||||
static func deviceName(for req: PendingRequest) -> String {
|
||||
let trimmedName = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmedName, !trimmedName.isEmpty, trimmedName != req.deviceId {
|
||||
return trimmedName
|
||||
}
|
||||
return self.isMac(req.platform) ? "OpenClaw Mac app" : "New device"
|
||||
}
|
||||
|
||||
static func prettyPlatform(_ raw: String?) -> String? {
|
||||
let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let platform, !platform.isEmpty else { return nil }
|
||||
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 platform.lowercased().contains("mac") {
|
||||
return "Mac"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyRole(_ raw: String?) -> String? {
|
||||
let role = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let role, !role.isEmpty else { return nil }
|
||||
return role == "operator" ? "Operator" : role
|
||||
}
|
||||
|
||||
static func friendlyScopeNames(_ scopes: [String]?) -> [String] {
|
||||
guard let scopes else { return [] }
|
||||
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 "Admin access"
|
||||
case "operator.read":
|
||||
return "Read OpenClaw data"
|
||||
case "operator.write":
|
||||
return "Send messages and make changes"
|
||||
case "operator.approvals":
|
||||
return "Manage approvals"
|
||||
case "operator.pairing":
|
||||
return "Pair and repair devices"
|
||||
case "operator.talk.secrets":
|
||||
return "Use Talk credentials"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))"
|
||||
}
|
||||
|
||||
private static func isMac(_ platform: String?) -> Bool {
|
||||
guard let platform else { return false }
|
||||
let lower = platform.lowercased()
|
||||
return lower.contains("mac") || lower.contains("darwin")
|
||||
}
|
||||
|
||||
private static func makeValueRow(label: String, value: String) -> NSView {
|
||||
let row = NSStackView()
|
||||
row.orientation = .horizontal
|
||||
row.alignment = .firstBaseline
|
||||
row.spacing = 8
|
||||
|
||||
let labelField = self.makeLabel("\(label):", font: .systemFont(ofSize: 12, weight: .semibold))
|
||||
labelField.textColor = .secondaryLabelColor
|
||||
labelField.setContentHuggingPriority(.required, for: .horizontal)
|
||||
let valueField = self.makeLabel(value, font: .systemFont(ofSize: 12, weight: .regular))
|
||||
valueField.maximumNumberOfLines = 2
|
||||
|
||||
row.addArrangedSubview(labelField)
|
||||
row.addArrangedSubview(valueField)
|
||||
return row
|
||||
}
|
||||
|
||||
private static func makeSectionLabel(_ text: String) -> NSTextField {
|
||||
let label = self.makeLabel(text, font: .systemFont(ofSize: 12, weight: .semibold))
|
||||
label.textColor = .secondaryLabelColor
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeBullet(_ text: String) -> NSTextField {
|
||||
let label = self.makeLabel("• \(text)", font: .systemFont(ofSize: 12, weight: .regular))
|
||||
label.maximumNumberOfLines = 2
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeDetailLine(_ req: PendingRequest) -> NSTextField {
|
||||
var parts = ["ID \(self.shortIdentifier(req.deviceId))"]
|
||||
if let remoteIp = req.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines), !remoteIp.isEmpty {
|
||||
parts.append("IP \(remoteIp.replacingOccurrences(of: "::ffff:", with: ""))")
|
||||
}
|
||||
if req.isRepair == true {
|
||||
parts.append("repair request")
|
||||
}
|
||||
let label = self.makeLabel(
|
||||
parts.joined(separator: " · "),
|
||||
font: .monospacedSystemFont(ofSize: 11, weight: .regular))
|
||||
label.textColor = .tertiaryLabelColor
|
||||
label.maximumNumberOfLines = 2
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeLabel(_ text: String, font: NSFont) -> NSTextField {
|
||||
let label = NSTextField(labelWithString: text)
|
||||
label.font = font
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.textColor = .labelColor
|
||||
return label
|
||||
self.syncCards()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Screenshot/demo helper: show the pairing panel with sample requests.
|
||||
if ProcessInfo.processInfo.environment["OPENCLAW_DEBUG_PAIRING_DEMO"] == "1" {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
DebugActions.showPairingPanelDemo()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
||||
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
||||
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
|
||||
|
|
|
|||
|
|
@ -49,18 +49,14 @@ struct MenuContent: View {
|
|||
self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color)
|
||||
}
|
||||
if self.pairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.pairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
self.statusLine(
|
||||
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)",
|
||||
color: .orange)
|
||||
self.pairingStatusLine(
|
||||
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))")
|
||||
}
|
||||
if self.devicePairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.devicePairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
self.statusLine(
|
||||
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)",
|
||||
color: .orange)
|
||||
self.pairingStatusLine(
|
||||
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -233,6 +229,13 @@ struct MenuContent: View {
|
|||
} label: {
|
||||
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
#if DEBUG
|
||||
Button {
|
||||
DebugActions.showPairingPanelDemo()
|
||||
} label: {
|
||||
Label("Show Pairing Panel (Demo)", systemImage: "checkmark.shield")
|
||||
}
|
||||
#endif
|
||||
if self.state.connectionMode == .remote {
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
|
|
@ -415,6 +418,17 @@ struct MenuContent: View {
|
|||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
/// Pending-pairing status lines reopen the approval panel (e.g. after "Not Now").
|
||||
private func pairingStatusLine(label: String) -> some View {
|
||||
Button {
|
||||
PairingApprovalCenter.shared.showPanel()
|
||||
} label: {
|
||||
self.statusLine(label: label, color: .orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Show pairing requests")
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ enum NodePairingReconcilePolicy {
|
|||
static let activeIntervalMs: UInt64 = 15000
|
||||
static let resyncDelayMs: UInt64 = 250
|
||||
|
||||
static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool {
|
||||
pendingCount > 0 || isPresenting
|
||||
static func shouldPoll(pendingCount: Int) -> Bool {
|
||||
pendingCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,13 +41,26 @@ final class NodePairingApprovalPrompter {
|
|||
private var reconcileOnceTask: Task<Void, Never>?
|
||||
private var reconcileInFlight = false
|
||||
private var isStopping = false
|
||||
private var isPresenting = false
|
||||
private var queue: [PendingRequest] = []
|
||||
var pendingCount: Int = 0
|
||||
var pendingRepairCount: Int = 0
|
||||
private let alertState = PairingAlertState()
|
||||
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||
/// Node ids already paired on the gateway (from the last list fetch);
|
||||
/// drives the "previously paired" trust signal on cards.
|
||||
private var pairedNodeIds: Set<String> = []
|
||||
/// Requests that arrived via push after the last list fetch; their trust
|
||||
/// state is unknown until fresh gateway truth applies (stale snapshots
|
||||
/// must not produce a positive "previously paired" claim).
|
||||
private var trustUnknownRequestIds: Set<String> = []
|
||||
private var autoApproveAttempts: Set<String> = []
|
||||
/// Requests hidden from the panel while a silent/local auto-approve runs.
|
||||
private var autoApproveInFlight: Set<String> = []
|
||||
/// The gateway broadcasts `node.pair.resolved` before our approve/reject
|
||||
/// RPC returns. Ids here mark decisions whose RPC is still in flight;
|
||||
/// resolutions echoed for them are parked in
|
||||
/// `echoedResolutionsByRequestId` so the awaiting path can report the
|
||||
/// authoritative outcome exactly once (another operator may win the race
|
||||
/// with the opposite decision).
|
||||
private var pendingLocalDecisionRequestIds: Set<String> = []
|
||||
private var echoedResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
|
|
@ -63,14 +76,18 @@ final class NodePairingApprovalPrompter {
|
|||
let remoteIp: String?
|
||||
}
|
||||
|
||||
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
let requestId: String
|
||||
let nodeId: String
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let version: String?
|
||||
let coreVersion: String?
|
||||
let deviceFamily: String?
|
||||
let modelIdentifier: String?
|
||||
let caps: [String]?
|
||||
let commands: [String]?
|
||||
let remoteIp: String?
|
||||
let isRepair: Bool?
|
||||
let silent: Bool?
|
||||
let ts: Double
|
||||
|
||||
|
|
@ -79,17 +96,20 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||
private typealias PairingResolution = PairingAlertSupport.PairingResolution
|
||||
private typealias PairingResolvedEvent = PairingPromptSupport.PairingResolvedEvent
|
||||
private typealias PairingResolution = PairingPromptSupport.PairingResolution
|
||||
|
||||
func start() {
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = nil
|
||||
PairingApprovalCenter.shared.register(kind: .node) { [weak self] card, decision in
|
||||
await self?.handleDecision(card: card, decision: decision)
|
||||
}
|
||||
self.startPushTask()
|
||||
}
|
||||
|
||||
private func startPushTask() {
|
||||
PairingAlertSupport.startPairingPushTask(
|
||||
PairingPromptSupport.startPairingPushTask(
|
||||
task: &self.task,
|
||||
isStopping: &self.isStopping,
|
||||
loadPending: self.loadPendingRequestsFromGateway,
|
||||
|
|
@ -97,23 +117,21 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
|
||||
func stop() {
|
||||
self.stopPushTask()
|
||||
PairingPromptSupport.stopPairingPrompter(
|
||||
isStopping: &self.isStopping,
|
||||
task: &self.task,
|
||||
queue: &self.queue)
|
||||
PairingApprovalCenter.shared.unregister(kind: .node)
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = nil
|
||||
self.reconcileOnceTask?.cancel()
|
||||
self.reconcileOnceTask = nil
|
||||
self.updatePendingCounts()
|
||||
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||
self.autoApproveAttempts.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func stopPushTask() {
|
||||
PairingAlertSupport.stopPairingPrompter(
|
||||
isStopping: &self.isStopping,
|
||||
task: &self.task,
|
||||
queue: &self.queue,
|
||||
isPresenting: &self.isPresenting,
|
||||
state: self.alertState)
|
||||
self.autoApproveInFlight.removeAll(keepingCapacity: false)
|
||||
self.pendingLocalDecisionRequestIds.removeAll(keepingCapacity: false)
|
||||
self.echoedResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||
self.trustUnknownRequestIds.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
|
|
@ -121,7 +139,9 @@ final class NodePairingApprovalPrompter {
|
|||
// pending pairing prompts are still shown on launch.
|
||||
var delayMs: UInt64 = 200
|
||||
for attempt in 1...8 {
|
||||
if Task.isCancelled { return }
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.list",
|
||||
|
|
@ -133,7 +153,7 @@ final class NodePairingApprovalPrompter {
|
|||
guard pendingCount > 0 else { return }
|
||||
self.logger.info(
|
||||
"loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup")
|
||||
await self.apply(list: list)
|
||||
self.apply(list: list)
|
||||
return
|
||||
} catch {
|
||||
if attempt == 8 {
|
||||
|
|
@ -150,9 +170,11 @@ final class NodePairingApprovalPrompter {
|
|||
|
||||
private func reconcileLoop() async {
|
||||
// Reconcile requests periodically so multiple running apps stay in sync
|
||||
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
|
||||
// (e.g. close cards + notify if another machine approves/rejects via app or CLI).
|
||||
while !Task.isCancelled {
|
||||
if self.isStopping { break }
|
||||
if self.isStopping {
|
||||
break
|
||||
}
|
||||
if !self.shouldPoll {
|
||||
self.reconcileTask = nil
|
||||
return
|
||||
|
|
@ -172,8 +194,14 @@ final class NodePairingApprovalPrompter {
|
|||
return try JSONDecoder().decode(PairingList.self, from: data)
|
||||
}
|
||||
|
||||
private func apply(list: PairingList) async {
|
||||
if self.isStopping { return }
|
||||
private func apply(list: PairingList) {
|
||||
if self.isStopping {
|
||||
return
|
||||
}
|
||||
|
||||
self.pairedNodeIds = Set((list.paired ?? []).map(\.nodeId))
|
||||
// This snapshot is authoritative for every pending request in it.
|
||||
self.trustUnknownRequestIds.removeAll()
|
||||
|
||||
let pendingById = Dictionary(
|
||||
uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) })
|
||||
|
|
@ -184,38 +212,27 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
|
||||
// Detect resolved requests (approved/rejected elsewhere).
|
||||
let queued = self.queue
|
||||
for req in queued {
|
||||
if pendingById[req.requestId] != nil { continue }
|
||||
for req in self.queue where pendingById[req.requestId] == nil {
|
||||
let resolution = self.inferResolution(for: req, list: list)
|
||||
|
||||
if self.alertState.activeRequestId == req.requestId, self.alertState.activeAlert != nil {
|
||||
self.remoteResolutionsByRequestId[req.requestId] = resolution
|
||||
self.logger.info(
|
||||
"""
|
||||
pairing request resolved elsewhere; closing dialog \
|
||||
requestId=\(req.requestId, privacy: .public) \
|
||||
resolution=\(resolution.rawValue, privacy: .public)
|
||||
""")
|
||||
self.endActiveAlert()
|
||||
continue
|
||||
}
|
||||
|
||||
self.logger.info(
|
||||
"""
|
||||
pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \
|
||||
resolution=\(resolution.rawValue, privacy: .public)
|
||||
""")
|
||||
self.queue.removeAll { $0 == req }
|
||||
Task { @MainActor in
|
||||
await self.notify(resolution: resolution, request: req, via: "remote")
|
||||
// Same coordination as handleResolved: while our own RPC is in
|
||||
// flight the awaiting path reports the outcome, not this one.
|
||||
if self.pendingLocalDecisionRequestIds.contains(req.requestId) {
|
||||
self.echoedResolutionsByRequestId[req.requestId] = resolution
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
await self.notify(resolution: resolution, request: req, via: "remote")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.queue.isEmpty {
|
||||
self.isPresenting = false
|
||||
}
|
||||
self.presentNextIfNeeded()
|
||||
self.updatePendingCounts()
|
||||
self.syncCards()
|
||||
self.updateReconcileLoop()
|
||||
}
|
||||
|
||||
|
|
@ -224,23 +241,27 @@ final class NodePairingApprovalPrompter {
|
|||
guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else {
|
||||
return .rejected
|
||||
}
|
||||
if request.isRepair == true, let approvedAtMs = node.approvedAtMs {
|
||||
// A previously paired node stays in the paired list even when this
|
||||
// request was rejected; only an approval newer than the request proves approval.
|
||||
if let approvedAtMs = node.approvedAtMs {
|
||||
return approvedAtMs >= request.ts ? .approved : .rejected
|
||||
}
|
||||
return .approved
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(state: self.alertState)
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "node.pair.requested":
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||
self.trustUnknownRequestIds.insert(req.requestId)
|
||||
self.enqueue(req)
|
||||
self.syncCards()
|
||||
self.updateReconcileLoop()
|
||||
// Refresh the paired list now so the card's "previously
|
||||
// paired" trust signal reflects current gateway truth.
|
||||
self.scheduleReconcileOnce(delayMs: 0)
|
||||
} catch {
|
||||
self.logger
|
||||
.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)")
|
||||
|
|
@ -265,81 +286,118 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
|
||||
private func enqueue(_ req: PendingRequest) {
|
||||
if self.queue.contains(where: { $0.requestId == req.requestId }) { return }
|
||||
if self.queue.contains(where: { $0.requestId == req.requestId }) {
|
||||
return
|
||||
}
|
||||
// The gateway keeps at most one live pending request per node; a newer
|
||||
// request supersedes queued ones so missed resolve pushes cannot stack
|
||||
// stale alerts. The actively presented request stays owned by its alert.
|
||||
self.queue.removeAll { $0.nodeId == req.nodeId && $0.requestId != self.alertState.activeRequestId }
|
||||
// stale cards.
|
||||
self.queue.removeAll { $0.nodeId == req.nodeId }
|
||||
self.queue.append(req)
|
||||
self.updatePendingCounts()
|
||||
self.presentNextIfNeeded()
|
||||
self.beginAutoApproveIfEligible(req)
|
||||
}
|
||||
|
||||
/// Auto-approve runs before the request surfaces in the panel: the app's
|
||||
/// own local node pairs silently, and `silent` requests are approved after
|
||||
/// an SSH trust probe. Only failed attempts fall through to the UI.
|
||||
private func beginAutoApproveIfEligible(_ req: PendingRequest) {
|
||||
guard !self.autoApproveAttempts.contains(req.requestId) else { return }
|
||||
guard self.isAutoApproveCandidate(req) else { return }
|
||||
self.autoApproveInFlight.insert(req.requestId)
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let approved = await self.tryAutomaticApproveIfPossible(req)
|
||||
self.autoApproveInFlight.remove(req.requestId)
|
||||
if approved {
|
||||
self.queue.removeAll { $0.requestId == req.requestId }
|
||||
self.updatePendingCounts()
|
||||
}
|
||||
self.syncCards()
|
||||
self.updateReconcileLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func isAutoApproveCandidate(_ req: PendingRequest) -> Bool {
|
||||
if req.silent == true {
|
||||
return true
|
||||
}
|
||||
let localNodeId = DeviceIdentityStore.loadOrCreate(
|
||||
profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId
|
||||
return Self.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: AppStateStore.shared.connectionMode,
|
||||
requestNodeId: req.nodeId,
|
||||
localNodeId: localNodeId)
|
||||
}
|
||||
|
||||
private func syncCards() {
|
||||
guard !self.isStopping else { return }
|
||||
let cards = self.queue
|
||||
.filter { !self.autoApproveInFlight.contains($0.requestId) }
|
||||
.map { self.card(for: $0) }
|
||||
PairingApprovalCenter.shared.sync(kind: .node, cards: cards)
|
||||
}
|
||||
|
||||
private func card(for req: PendingRequest) -> PairingApprovalCenter.Card {
|
||||
PairingApprovalCenter.Card(
|
||||
kind: .node,
|
||||
requestId: req.requestId,
|
||||
subjectId: req.nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
version: req.version,
|
||||
coreVersion: req.coreVersion,
|
||||
remoteIp: req.remoteIp,
|
||||
role: nil,
|
||||
scopes: [],
|
||||
caps: req.caps ?? [],
|
||||
commands: req.commands ?? [],
|
||||
isRepair: false,
|
||||
previouslyPaired: self.trustUnknownRequestIds.contains(req.requestId)
|
||||
? nil
|
||||
: self.pairedNodeIds.contains(req.nodeId),
|
||||
requestedAt: Date(timeIntervalSince1970: req.ts / 1000))
|
||||
}
|
||||
|
||||
private func handleDecision(card: PairingApprovalCenter.Card, decision: PairingApprovalCenter.Decision) async {
|
||||
guard !self.isStopping else { return }
|
||||
guard let request = self.queue.first(where: { $0.requestId == card.requestId }) else { return }
|
||||
|
||||
self.pendingLocalDecisionRequestIds.insert(request.requestId)
|
||||
let expected: PairingResolution = decision == .approve ? .approved : .rejected
|
||||
let rpcOk: Bool = switch decision {
|
||||
case .approve:
|
||||
await self.approve(requestId: request.requestId)
|
||||
case .reject:
|
||||
await self.reject(requestId: request.requestId)
|
||||
}
|
||||
self.pendingLocalDecisionRequestIds.remove(request.requestId)
|
||||
|
||||
if let echoed = self.echoedResolutionsByRequestId.removeValue(forKey: request.requestId) {
|
||||
// The gateway resolved this request while our RPC was in flight
|
||||
// (possibly another operator with the opposite decision); report
|
||||
// the authoritative outcome, not what the user asked for.
|
||||
let via = rpcOk && echoed == expected ? "local" : "remote"
|
||||
await self.notify(resolution: echoed, request: request, via: via)
|
||||
} else if rpcOk {
|
||||
await self.notify(resolution: expected, request: request, via: "local")
|
||||
} else {
|
||||
// RPC failed and nothing resolved it elsewhere: keep the card and
|
||||
// re-sync with gateway truth instead of claiming an outcome.
|
||||
self.scheduleReconcileOnce(delayMs: 0)
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.removeAll { $0.requestId == request.requestId }
|
||||
self.updatePendingCounts()
|
||||
self.syncCards()
|
||||
self.updateReconcileLoop()
|
||||
}
|
||||
|
||||
private func presentNextIfNeeded() {
|
||||
guard !self.isStopping else { return }
|
||||
guard !self.isPresenting else { return }
|
||||
guard let next = self.queue.first else { return }
|
||||
self.isPresenting = true
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.tryAutomaticApproveIfPossible(next) {
|
||||
return
|
||||
}
|
||||
self.presentAlert(for: next)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow node to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Node"),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
defer {
|
||||
if self.queue.first == request {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
self.queue.removeAll { $0 == request }
|
||||
}
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.presentNextIfNeeded()
|
||||
self.updateReconcileLoop()
|
||||
}
|
||||
|
||||
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
|
||||
guard !self.isStopping else { return }
|
||||
|
||||
if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) {
|
||||
await self.notify(resolution: resolved, request: request, via: "remote")
|
||||
return
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
await self.notify(resolution: .approved, request: request, via: "local")
|
||||
case .alertSecondButtonReturn:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
await self.notify(resolution: .rejected, request: request, via: "local")
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
await PairingAlertSupport.approveRequest(
|
||||
await PairingPromptSupport.approveRequest(
|
||||
requestId: requestId,
|
||||
kind: "node",
|
||||
logger: self.logger)
|
||||
|
|
@ -348,8 +406,8 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
await PairingAlertSupport.rejectRequest(
|
||||
private func reject(requestId: String) async -> Bool {
|
||||
await PairingPromptSupport.rejectRequest(
|
||||
requestId: requestId,
|
||||
kind: "node",
|
||||
logger: self.logger)
|
||||
|
|
@ -358,35 +416,6 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private static func describe(_ req: PendingRequest) -> String {
|
||||
let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let platform = self.prettyPlatform(req.platform)
|
||||
let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let ip = self.prettyIP(req.remoteIp)
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")")
|
||||
lines.append("Node ID: \(req.nodeId)")
|
||||
if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") }
|
||||
if let version, !version.isEmpty { lines.append("App: \(version)") }
|
||||
if let ip, !ip.isEmpty { lines.append("IP: \(ip)") }
|
||||
if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") }
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private 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: "")
|
||||
}
|
||||
|
||||
private static func prettyPlatform(_ platform: String?) -> String? {
|
||||
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty }
|
||||
return raw
|
||||
}
|
||||
|
||||
private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
|
|
@ -449,6 +478,11 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
|
||||
private func approveAutomatically(_ req: PendingRequest, via: String, notify: Bool) async -> Bool {
|
||||
self.pendingLocalDecisionRequestIds.insert(req.requestId)
|
||||
defer {
|
||||
self.pendingLocalDecisionRequestIds.remove(req.requestId)
|
||||
self.echoedResolutionsByRequestId.removeValue(forKey: req.requestId)
|
||||
}
|
||||
guard await self.approve(requestId: req.requestId) else {
|
||||
self.logger.info("automatic pairing approve failed requestId=\(req.requestId, privacy: .public)")
|
||||
return false
|
||||
|
|
@ -459,16 +493,6 @@ final class NodePairingApprovalPrompter {
|
|||
if notify {
|
||||
await self.notify(resolution: .approved, request: req, via: via)
|
||||
}
|
||||
if self.queue.first == req {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
self.queue.removeAll { $0 == req }
|
||||
}
|
||||
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.presentNextIfNeeded()
|
||||
self.updateReconcileLoop()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -552,9 +576,7 @@ final class NodePairingApprovalPrompter {
|
|||
}
|
||||
|
||||
private var shouldPoll: Bool {
|
||||
NodePairingReconcilePolicy.shouldPoll(
|
||||
pendingCount: self.queue.count,
|
||||
isPresenting: self.isPresenting)
|
||||
NodePairingReconcilePolicy.shouldPoll(pendingCount: self.queue.count)
|
||||
}
|
||||
|
||||
private func updateReconcileLoop() {
|
||||
|
|
@ -574,17 +596,20 @@ final class NodePairingApprovalPrompter {
|
|||
private func updatePendingCounts() {
|
||||
// Keep a cheap observable summary for the menu bar status line.
|
||||
self.pendingCount = self.queue.count
|
||||
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
||||
}
|
||||
|
||||
private func reconcileOnce(timeoutMs: Double) async {
|
||||
if self.isStopping { return }
|
||||
if self.reconcileInFlight { return }
|
||||
if self.isStopping {
|
||||
return
|
||||
}
|
||||
if self.reconcileInFlight {
|
||||
return
|
||||
}
|
||||
self.reconcileInFlight = true
|
||||
defer { self.reconcileInFlight = false }
|
||||
do {
|
||||
let list = try await self.fetchPairingList(timeoutMs: timeoutMs)
|
||||
await self.apply(list: list)
|
||||
self.apply(list: list)
|
||||
} catch {
|
||||
// best effort: ignore transient connectivity failures
|
||||
}
|
||||
|
|
@ -605,30 +630,21 @@ final class NodePairingApprovalPrompter {
|
|||
let resolution: PairingResolution =
|
||||
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
|
||||
|
||||
if self.alertState.activeRequestId == resolved.requestId, self.alertState.activeAlert != nil {
|
||||
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
|
||||
self.logger.info(
|
||||
"""
|
||||
pairing request resolved elsewhere; closing dialog \
|
||||
requestId=\(resolved.requestId, privacy: .public) \
|
||||
resolution=\(resolution.rawValue, privacy: .public)
|
||||
""")
|
||||
self.endActiveAlert()
|
||||
return
|
||||
}
|
||||
|
||||
guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else {
|
||||
return
|
||||
}
|
||||
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||
self.updatePendingCounts()
|
||||
Task { @MainActor in
|
||||
await self.notify(resolution: resolution, request: request, via: "remote")
|
||||
self.syncCards()
|
||||
if self.pendingLocalDecisionRequestIds.contains(resolved.requestId) {
|
||||
// Our own approve/reject RPC is still in flight; park the
|
||||
// authoritative outcome for that path to report exactly once.
|
||||
self.echoedResolutionsByRequestId[resolved.requestId] = resolution
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
await self.notify(resolution: resolution, request: request, via: "remote")
|
||||
}
|
||||
}
|
||||
if self.queue.isEmpty {
|
||||
self.isPresenting = false
|
||||
}
|
||||
self.presentNextIfNeeded()
|
||||
self.updateReconcileLoop()
|
||||
}
|
||||
}
|
||||
|
|
@ -648,8 +664,12 @@ extension NodePairingApprovalPrompter {
|
|||
displayName: "Node One",
|
||||
platform: "macos",
|
||||
version: "1.0.0",
|
||||
coreVersion: "1.0.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,3",
|
||||
caps: ["screen"],
|
||||
commands: ["system.run"],
|
||||
remoteIp: "127.0.0.1",
|
||||
isRepair: false,
|
||||
silent: true,
|
||||
ts: 1_700_000_000_000)
|
||||
let paired = PairedNode(
|
||||
|
|
@ -661,9 +681,7 @@ extension NodePairingApprovalPrompter {
|
|||
remoteIp: "127.0.0.1")
|
||||
let list = PairingList(pending: [pending], paired: [paired])
|
||||
|
||||
_ = Self.describe(pending)
|
||||
_ = Self.prettyIP(pending.remoteIp)
|
||||
_ = Self.prettyPlatform(pending.platform)
|
||||
_ = prompter.card(for: pending)
|
||||
_ = prompter.inferResolution(for: pending, list: list)
|
||||
|
||||
prompter.queue = [pending]
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
import AppKit
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
final class PairingAlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PairingAlertState {
|
||||
var activeAlert: NSAlert?
|
||||
var activeRequestId: String?
|
||||
var alertHostWindow: NSWindow?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum PairingAlertSupport {
|
||||
enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
|
||||
struct ButtonTitles {
|
||||
var approve: String = "Approve"
|
||||
var postpone: String = "Not Now"
|
||||
var reject: String = "Reject"
|
||||
|
||||
init(approve: String = "Approve", postpone: String = "Not Now", reject: String = "Reject") {
|
||||
self.approve = approve
|
||||
self.postpone = postpone
|
||||
self.reject = reject
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let decision: String
|
||||
let ts: Double
|
||||
}
|
||||
|
||||
static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
|
||||
guard let alert = activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
activeAlert = nil
|
||||
activeRequestId = nil
|
||||
}
|
||||
|
||||
static func endActiveAlert(state: PairingAlertState) {
|
||||
self.endActiveAlert(activeAlert: &state.activeAlert, activeRequestId: &state.activeRequestId)
|
||||
}
|
||||
|
||||
static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow {
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = PairingAlertHostWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = ""
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.isOpaque = false
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
alertHostWindow = window
|
||||
return window
|
||||
}
|
||||
|
||||
static func configureDefaultPairingAlert(
|
||||
_ alert: NSAlert,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil)
|
||||
{
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = messageText
|
||||
alert.informativeText = informativeText
|
||||
alert.accessoryView = accessoryView
|
||||
alert.addButton(withTitle: buttonTitles.approve)
|
||||
alert.addButton(withTitle: buttonTitles.postpone)
|
||||
alert.addButton(withTitle: buttonTitles.reject)
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
}
|
||||
|
||||
static func beginCenteredSheet(
|
||||
alert: NSAlert,
|
||||
hostWindow: NSWindow,
|
||||
completionHandler: @escaping (NSApplication.ModalResponse) -> Void)
|
||||
{
|
||||
let sheetSize = alert.window.frame.size
|
||||
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||
let bounds = screen.visibleFrame
|
||||
let x = bounds.midX - (sheetSize.width / 2)
|
||||
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||
} else {
|
||||
hostWindow.center()
|
||||
}
|
||||
hostWindow.makeKeyAndOrderFront(nil)
|
||||
alert.beginSheetModal(for: hostWindow, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
static func runPairingPushTask(
|
||||
bufferingNewest: Int = 200,
|
||||
loadPending: @escaping @MainActor () async -> Void,
|
||||
handlePush: @escaping @MainActor (GatewayPush) -> Void) async
|
||||
{
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
await loadPending()
|
||||
await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush)
|
||||
}
|
||||
|
||||
static func startPairingPushTask(
|
||||
task: inout Task<Void, Never>?,
|
||||
isStopping: inout Bool,
|
||||
bufferingNewest: Int = 200,
|
||||
loadPending: @escaping @MainActor () async -> Void,
|
||||
handlePush: @escaping @MainActor (GatewayPush) -> Void)
|
||||
{
|
||||
guard task == nil else { return }
|
||||
isStopping = false
|
||||
task = Task {
|
||||
await self.runPairingPushTask(
|
||||
bufferingNewest: bufferingNewest,
|
||||
loadPending: loadPending,
|
||||
handlePush: handlePush)
|
||||
}
|
||||
}
|
||||
|
||||
static func beginPairingAlert(
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil,
|
||||
alertHostWindow: inout NSWindow?,
|
||||
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert
|
||||
{
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
self.configureDefaultPairingAlert(
|
||||
alert,
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
buttonTitles: buttonTitles,
|
||||
accessoryView: accessoryView)
|
||||
|
||||
let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow)
|
||||
self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in
|
||||
completion(response, hostWindow)
|
||||
}
|
||||
return alert
|
||||
}
|
||||
|
||||
static func presentPairingAlert(
|
||||
requestId: String,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil,
|
||||
activeAlert: inout NSAlert?,
|
||||
activeRequestId: inout String?,
|
||||
alertHostWindow: inout NSWindow?,
|
||||
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void)
|
||||
{
|
||||
activeRequestId = requestId
|
||||
activeAlert = self.beginPairingAlert(
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
buttonTitles: buttonTitles,
|
||||
accessoryView: accessoryView,
|
||||
alertHostWindow: &alertHostWindow,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
static func presentPairingAlert<Request>(
|
||||
request: Request,
|
||||
requestId: String,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil,
|
||||
state: PairingAlertState,
|
||||
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
|
||||
{
|
||||
self.presentPairingAlert(
|
||||
requestId: requestId,
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
buttonTitles: buttonTitles,
|
||||
accessoryView: accessoryView,
|
||||
activeAlert: &state.activeAlert,
|
||||
activeRequestId: &state.activeRequestId,
|
||||
alertHostWindow: &state.alertHostWindow,
|
||||
completion: { response, hostWindow in
|
||||
Task { @MainActor in
|
||||
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
|
||||
await onResponse(response, request)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static func clearActivePairingAlert(
|
||||
activeAlert: inout NSAlert?,
|
||||
activeRequestId: inout String?,
|
||||
hostWindow: NSWindow)
|
||||
{
|
||||
activeRequestId = nil
|
||||
activeAlert = nil
|
||||
hostWindow.orderOut(nil)
|
||||
}
|
||||
|
||||
static func clearActivePairingAlert(state: PairingAlertState, hostWindow: NSWindow) {
|
||||
self.clearActivePairingAlert(
|
||||
activeAlert: &state.activeAlert,
|
||||
activeRequestId: &state.activeRequestId,
|
||||
hostWindow: hostWindow)
|
||||
}
|
||||
|
||||
static func stopPairingPrompter(
|
||||
isStopping: inout Bool,
|
||||
activeAlert: inout NSAlert?,
|
||||
activeRequestId: inout String?,
|
||||
task: inout Task<Void, Never>?,
|
||||
queue: inout [some Any],
|
||||
isPresenting: inout Bool,
|
||||
alertHostWindow: inout NSWindow?)
|
||||
{
|
||||
isStopping = true
|
||||
self.endActiveAlert(activeAlert: &activeAlert, activeRequestId: &activeRequestId)
|
||||
task?.cancel()
|
||||
task = nil
|
||||
queue.removeAll(keepingCapacity: false)
|
||||
isPresenting = false
|
||||
activeRequestId = nil
|
||||
alertHostWindow?.orderOut(nil)
|
||||
alertHostWindow?.close()
|
||||
alertHostWindow = nil
|
||||
}
|
||||
|
||||
static func stopPairingPrompter(
|
||||
isStopping: inout Bool,
|
||||
task: inout Task<Void, Never>?,
|
||||
queue: inout [some Any],
|
||||
isPresenting: inout Bool,
|
||||
state: PairingAlertState)
|
||||
{
|
||||
self.stopPairingPrompter(
|
||||
isStopping: &isStopping,
|
||||
activeAlert: &state.activeAlert,
|
||||
activeRequestId: &state.activeRequestId,
|
||||
task: &task,
|
||||
queue: &queue,
|
||||
isPresenting: &isPresenting,
|
||||
alertHostWindow: &state.alertHostWindow)
|
||||
}
|
||||
|
||||
static func approveRequest(
|
||||
requestId: String,
|
||||
kind: String,
|
||||
logger: Logger,
|
||||
action: @escaping () async throws -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
try await action()
|
||||
logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func rejectRequest(
|
||||
requestId: String,
|
||||
kind: String,
|
||||
logger: Logger,
|
||||
action: @escaping () async throws -> Void) async
|
||||
{
|
||||
do {
|
||||
try await action()
|
||||
logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
289
apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift
Normal file
289
apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// Unified store + window owner for node/device pairing approvals. Both
|
||||
/// prompters feed request cards here; one floating panel renders them all so
|
||||
/// simultaneous requests never stack serial dialogs.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PairingApprovalCenter {
|
||||
static let shared = PairingApprovalCenter()
|
||||
|
||||
enum Kind: String, CaseIterable {
|
||||
case node
|
||||
case device
|
||||
}
|
||||
|
||||
enum Decision {
|
||||
case approve
|
||||
case reject
|
||||
}
|
||||
|
||||
struct Card: Identifiable, Equatable {
|
||||
let kind: Kind
|
||||
let requestId: String
|
||||
/// nodeId or deviceId.
|
||||
let subjectId: String
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let deviceFamily: String?
|
||||
let modelIdentifier: String?
|
||||
let version: String?
|
||||
let coreVersion: String?
|
||||
let remoteIp: String?
|
||||
/// Device pairing operator role.
|
||||
let role: String?
|
||||
/// Device pairing operator scopes.
|
||||
let scopes: [String]
|
||||
/// Node capability surface.
|
||||
let caps: [String]
|
||||
/// Node command surface (drives the elevated-access warning).
|
||||
let commands: [String]
|
||||
let isRepair: Bool
|
||||
/// nil until a fresh gateway list confirms pairing history; a positive
|
||||
/// trust claim must never come from a stale snapshot.
|
||||
let previouslyPaired: Bool?
|
||||
let requestedAt: Date
|
||||
|
||||
var id: String {
|
||||
self.requestId
|
||||
}
|
||||
}
|
||||
|
||||
typealias DecisionHandler = @MainActor (Card, Decision) async -> Void
|
||||
|
||||
private(set) var cards: [Card] = []
|
||||
private(set) var decisionsInFlight: Set<String> = []
|
||||
private var handlersByKind: [Kind: DecisionHandler] = [:]
|
||||
private var panel: PairingApprovalPanelController?
|
||||
/// Request ids visible when the user chose "Not Now"; the panel reopens
|
||||
/// automatically only when a request they have not seen yet arrives.
|
||||
private var snoozedRequestIds: Set<String> = []
|
||||
/// Request ids the visible panel has already presented. Periodic queue
|
||||
/// syncs must not re-activate the app; only genuinely new requests may.
|
||||
private var presentedRequestIds: Set<String> = []
|
||||
|
||||
func register(kind: Kind, handler: @escaping DecisionHandler) {
|
||||
self.handlersByKind[kind] = handler
|
||||
}
|
||||
|
||||
func unregister(kind: Kind) {
|
||||
self.handlersByKind[kind] = nil
|
||||
self.sync(kind: kind, cards: [])
|
||||
}
|
||||
|
||||
/// Replace all cards of one kind (the owning prompter's queue is the
|
||||
/// source of truth). Cards stay ordered oldest-first across both kinds.
|
||||
func sync(kind: Kind, cards: [Card]) {
|
||||
var others = self.cards.filter { $0.kind != kind }
|
||||
#if DEBUG
|
||||
others += self.cards.filter { $0.kind == kind && self.demoRequestIds.contains($0.requestId) }
|
||||
#endif
|
||||
// requestId tiebreaker: Swift sort is not stable and equal timestamps
|
||||
// would let cards swap positions between syncs.
|
||||
self.cards = (others + cards).sorted {
|
||||
($0.requestedAt, $0.requestId) < ($1.requestedAt, $1.requestId)
|
||||
}
|
||||
self.snoozedRequestIds.formIntersection(self.cards.map(\.id))
|
||||
self.updatePanel()
|
||||
}
|
||||
|
||||
func decide(_ card: Card, _ decision: Decision) {
|
||||
#if DEBUG
|
||||
if self.demoRequestIds.contains(card.requestId) {
|
||||
self.demoRequestIds.remove(card.requestId)
|
||||
self.cards.removeAll { $0.requestId == card.requestId }
|
||||
self.updatePanel()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard let handler = self.handlersByKind[card.kind] else { return }
|
||||
guard self.decisionsInFlight.insert(card.requestId).inserted else { return }
|
||||
Task { @MainActor in
|
||||
await handler(card, decision)
|
||||
self.decisionsInFlight.remove(card.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Not Now": hide the panel without resolving anything. Requests stay
|
||||
/// pending on the gateway (TTL applies) and in the menu-bar count.
|
||||
func snooze() {
|
||||
self.snoozedRequestIds = Set(self.cards.map(\.id))
|
||||
self.panel?.hide()
|
||||
}
|
||||
|
||||
func showPanel() {
|
||||
guard !self.cards.isEmpty else { return }
|
||||
self.snoozedRequestIds.removeAll()
|
||||
self.presentedRequestIds.formUnion(self.cards.map(\.id))
|
||||
self.ensurePanel().show()
|
||||
}
|
||||
|
||||
static func shouldAutoPresent(cardIds: [String], snoozedIds: Set<String>) -> Bool {
|
||||
!cardIds.isEmpty && !cardIds.allSatisfy { snoozedIds.contains($0) }
|
||||
}
|
||||
|
||||
private func updatePanel() {
|
||||
if self.cards.isEmpty {
|
||||
self.snoozedRequestIds.removeAll()
|
||||
self.presentedRequestIds.removeAll()
|
||||
self.panel?.hide()
|
||||
return
|
||||
}
|
||||
let cardIds = self.cards.map(\.id)
|
||||
guard Self.shouldAutoPresent(cardIds: cardIds, snoozedIds: self.snoozedRequestIds) else {
|
||||
self.panel?.refreshLayout()
|
||||
return
|
||||
}
|
||||
// Activate (app focus + key panel) only for the first presentation or
|
||||
// a request not shown yet; periodic reconcile syncs merely refresh, so
|
||||
// the panel cannot steal focus every poll interval.
|
||||
let hasNewRequests = !cardIds.allSatisfy { self.presentedRequestIds.contains($0) }
|
||||
self.snoozedRequestIds.removeAll()
|
||||
self.presentedRequestIds.formUnion(cardIds)
|
||||
let panel = self.ensurePanel()
|
||||
if panel.isPanelVisible, !hasNewRequests {
|
||||
panel.refreshLayout()
|
||||
} else {
|
||||
panel.show()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePanel() -> PairingApprovalPanelController {
|
||||
if let panel = self.panel {
|
||||
return panel
|
||||
}
|
||||
let panel = PairingApprovalPanelController(center: self)
|
||||
self.panel = panel
|
||||
return panel
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Demo/screenshot hook: decisions on injected cards resolve locally
|
||||
/// instead of routing to a prompter.
|
||||
private var demoRequestIds: Set<String> = []
|
||||
|
||||
func injectDemoCards(_ cards: [Card]) {
|
||||
self.demoRequestIds.formUnion(cards.map(\.id))
|
||||
let existing = Set(self.cards.map(\.id))
|
||||
self.cards += cards.filter { !existing.contains($0.id) }
|
||||
self.snoozedRequestIds.removeAll()
|
||||
self.updatePanel()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Borderless floating panel hosting the SwiftUI approval UI. Replaces the
|
||||
/// old invisible-host-window + NSAlert sheet machinery.
|
||||
@MainActor
|
||||
final class PairingApprovalPanelController {
|
||||
private final class KeyablePanel: NSPanel {
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private let center: PairingApprovalCenter
|
||||
private var panel: NSPanel?
|
||||
private var hostingView: NSHostingView<PairingApprovalPanelView>?
|
||||
|
||||
static let panelWidth: CGFloat = 460
|
||||
|
||||
init(center: PairingApprovalCenter) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
var isPanelVisible: Bool {
|
||||
self.panel?.isVisible == true
|
||||
}
|
||||
|
||||
func show() {
|
||||
let panel = self.ensurePanel()
|
||||
self.applyFittingFrame()
|
||||
// Approval is a security decision: bring the app forward like the old
|
||||
// NSAlert flow so the prompt is keyboard-actionable immediately.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
panel.makeKeyAndOrderFront(nil)
|
||||
// No initial focus ring (matches NSAlert); Tab starts keyboard
|
||||
// navigation, Return approves a single request, Esc snoozes.
|
||||
panel.makeFirstResponder(nil)
|
||||
self.refreshLayout()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
self.panel?.orderOut(nil)
|
||||
}
|
||||
|
||||
func refreshLayout() {
|
||||
// The root view stays installed (it observes the center directly);
|
||||
// replacing it would reset SwiftUI focus. Re-measure on the next tick
|
||||
// so pending Observation-driven updates are applied first.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.applyFittingFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFittingFrame() {
|
||||
guard let panel = self.panel, let host = self.hostingView else { return }
|
||||
var size = host.fittingSize
|
||||
// Request metadata sizes the panel; clamp to the screen so the action
|
||||
// buttons stay reachable (the card list scrolls inside).
|
||||
if let screen = panel.screen ?? NSScreen.main {
|
||||
size.height = min(size.height, screen.visibleFrame.height * 0.85)
|
||||
}
|
||||
let target = self.centeredFrame(for: size)
|
||||
if target == panel.frame {
|
||||
return
|
||||
}
|
||||
if panel.isVisible {
|
||||
panel.setFrame(target, display: true, animate: true)
|
||||
} else {
|
||||
panel.setFrame(target, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePanel() -> NSPanel {
|
||||
if let panel = self.panel {
|
||||
return panel
|
||||
}
|
||||
let panel = KeyablePanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: Self.panelWidth, height: 200),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
// The SwiftUI view draws its own rounded shadow; the window shadow
|
||||
// would trace the square window frame and look like a border.
|
||||
panel.hasShadow = false
|
||||
panel.level = .floating
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovableByWindowBackground = true
|
||||
panel.isReleasedWhenClosed = false
|
||||
panel.becomesKeyOnlyIfNeeded = false
|
||||
|
||||
let host = NSHostingView(rootView: PairingApprovalPanelView(center: self.center))
|
||||
panel.contentView = host
|
||||
self.hostingView = host
|
||||
self.panel = panel
|
||||
return panel
|
||||
}
|
||||
|
||||
private func centeredFrame(for size: NSSize) -> NSRect {
|
||||
guard let screen = self.panel?.screen ?? NSScreen.main else {
|
||||
return NSRect(origin: .zero, size: size)
|
||||
}
|
||||
let bounds = screen.visibleFrame
|
||||
// Slightly above center, where system alerts sit.
|
||||
let x = bounds.midX - size.width / 2
|
||||
let y = bounds.midY - size.height / 2 + bounds.height * 0.08
|
||||
return NSRect(x: x, y: y, width: size.width, height: size.height)
|
||||
}
|
||||
}
|
||||
570
apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift
Normal file
570
apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
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)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Not Now") { self.center.snooze() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var approveButton: some View {
|
||||
let button = Button {
|
||||
self.onDecision(.approve)
|
||||
} label: {
|
||||
Text(self.card.kind == .node ? "Approve Node" : "Approve Device")
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
let shortcut: KeyboardShortcut? = self.isOnlyRequest ? .defaultAction : nil
|
||||
if #available(macOS 26.0, *) {
|
||||
button
|
||||
.buttonStyle(.glassProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.keyboardShortcut(shortcut)
|
||||
} else {
|
||||
button
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.keyboardShortcut(shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rejectButton: some View {
|
||||
let button = Button(role: .destructive) {
|
||||
self.onDecision(.reject)
|
||||
} label: {
|
||||
Text("Reject")
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
if #available(macOS 26.0, *) {
|
||||
button
|
||||
.buttonStyle(.glass)
|
||||
.buttonBorderShape(.capsule)
|
||||
} else {
|
||||
button
|
||||
.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")
|
||||
}
|
||||
}
|
||||
92
apps/macos/Sources/OpenClaw/PairingPromptSupport.swift
Normal file
92
apps/macos/Sources/OpenClaw/PairingPromptSupport.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
/// Shared plumbing for the node/device pairing prompters: gateway push
|
||||
/// subscription lifecycle and approve/reject RPC logging.
|
||||
@MainActor
|
||||
enum PairingPromptSupport {
|
||||
enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
|
||||
struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let decision: String
|
||||
let ts: Double
|
||||
}
|
||||
|
||||
static func runPairingPushTask(
|
||||
bufferingNewest: Int = 200,
|
||||
loadPending: @escaping @MainActor () async -> Void,
|
||||
handlePush: @escaping @MainActor (GatewayPush) -> Void) async
|
||||
{
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
await loadPending()
|
||||
await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush)
|
||||
}
|
||||
|
||||
static func startPairingPushTask(
|
||||
task: inout Task<Void, Never>?,
|
||||
isStopping: inout Bool,
|
||||
bufferingNewest: Int = 200,
|
||||
loadPending: @escaping @MainActor () async -> Void,
|
||||
handlePush: @escaping @MainActor (GatewayPush) -> Void)
|
||||
{
|
||||
guard task == nil else { return }
|
||||
isStopping = false
|
||||
task = Task {
|
||||
await self.runPairingPushTask(
|
||||
bufferingNewest: bufferingNewest,
|
||||
loadPending: loadPending,
|
||||
handlePush: handlePush)
|
||||
}
|
||||
}
|
||||
|
||||
static func stopPairingPrompter(
|
||||
isStopping: inout Bool,
|
||||
task: inout Task<Void, Never>?,
|
||||
queue: inout [some Any])
|
||||
{
|
||||
isStopping = true
|
||||
task?.cancel()
|
||||
task = nil
|
||||
queue.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
static func approveRequest(
|
||||
requestId: String,
|
||||
kind: String,
|
||||
logger: Logger,
|
||||
action: @escaping () async throws -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
try await action()
|
||||
logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func rejectRequest(
|
||||
requestId: String,
|
||||
kind: String,
|
||||
logger: Logger,
|
||||
action: @escaping () async throws -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
try await action()
|
||||
logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,62 +36,6 @@ struct NodePairingApprovalPrompterTests {
|
|||
await NodePairingApprovalPrompter.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `pairing alert makes approve the primary action`() {
|
||||
let alert = NSAlert()
|
||||
PairingAlertSupport.configureDefaultPairingAlert(
|
||||
alert,
|
||||
messageText: "New Mac wants to connect",
|
||||
informativeText: "Approve this Mac app to control OpenClaw.",
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Mac"))
|
||||
|
||||
#expect(alert.alertStyle == .informational)
|
||||
#expect(alert.buttons.map(\.title) == ["Approve Mac", "Not Now", "Reject"])
|
||||
if #available(macOS 11.0, *) {
|
||||
#expect(alert.buttons[2].hasDestructiveAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `device pairing copy summarizes Mac requests`() {
|
||||
let request = DevicePairingApprovalPrompter.PendingRequest(
|
||||
requestId: "req-1",
|
||||
deviceId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd",
|
||||
publicKey: "pub",
|
||||
displayName: nil,
|
||||
platform: "MacIntel",
|
||||
clientId: nil,
|
||||
clientMode: nil,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
],
|
||||
remoteIp: "192.0.2.10",
|
||||
silent: nil,
|
||||
isRepair: nil,
|
||||
ts: 1)
|
||||
|
||||
#expect(DevicePairingApprovalPrompter.alertTitle(for: request) == "New Mac wants to connect")
|
||||
#expect(DevicePairingApprovalPrompter.approveButtonTitle(for: request) == "Approve Mac")
|
||||
#expect(DevicePairingApprovalPrompter.deviceName(for: request) == "OpenClaw Mac app")
|
||||
#expect(DevicePairingApprovalPrompter.prettyPlatform(request.platform) == "Mac (Intel)")
|
||||
#expect(DevicePairingApprovalPrompter.shortIdentifier(request.deviceId) == "4a865684...f4e82bd")
|
||||
#expect(DevicePairingApprovalPrompter.friendlyScopeNames(request.scopes) == [
|
||||
"Admin access",
|
||||
"Read OpenClaw data",
|
||||
"Send messages and make changes",
|
||||
"Manage approvals",
|
||||
"Pair and repair devices",
|
||||
])
|
||||
#expect(!DevicePairingApprovalPrompter.alertSummary(for: request).contains(request.deviceId))
|
||||
|
||||
let accessory = DevicePairingApprovalPrompter.buildAccessoryView(for: request)
|
||||
#expect(accessory.frame.width >= 380)
|
||||
#expect(accessory.frame.height > 80)
|
||||
}
|
||||
|
||||
@Test func `a newer device pairing request supersedes queued requests for the same device`() {
|
||||
func request(_ requestId: String, deviceId: String, ts: Double) -> DevicePairingApprovalPrompter
|
||||
.PendingRequest
|
||||
|
|
@ -125,3 +69,172 @@ struct NodePairingApprovalPrompterTests {
|
|||
#expect(DevicePairingApprovalPrompter.coalescedQueue([stale1, other], adding: stale1) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PairingCardPresentationTests {
|
||||
private func nodeCard(
|
||||
displayName: String? = "Peter's MacBook Pro",
|
||||
platform: String? = "macos 26.5",
|
||||
modelIdentifier: String? = "MacBookPro18,3",
|
||||
caps: [String] = [],
|
||||
commands: [String] = [],
|
||||
previouslyPaired: Bool? = false) -> PairingApprovalCenter.Card
|
||||
{
|
||||
PairingApprovalCenter.Card(
|
||||
kind: .node,
|
||||
requestId: "req-1",
|
||||
subjectId: "19cec1c3301a7469d4fd71f5f81339508390dadda91b34aee15faf2849dccdc7",
|
||||
displayName: displayName,
|
||||
platform: platform,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: modelIdentifier,
|
||||
version: "2026.6.11",
|
||||
coreVersion: "2026.6.10",
|
||||
remoteIp: "::ffff:192.168.1.42",
|
||||
role: nil,
|
||||
scopes: [],
|
||||
caps: caps,
|
||||
commands: commands,
|
||||
isRepair: false,
|
||||
previouslyPaired: previouslyPaired,
|
||||
requestedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
}
|
||||
|
||||
private func deviceCard(scopes: [String], isRepair: Bool = false) -> PairingApprovalCenter.Card {
|
||||
PairingApprovalCenter.Card(
|
||||
kind: .device,
|
||||
requestId: "req-2",
|
||||
subjectId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd",
|
||||
displayName: nil,
|
||||
platform: "MacIntel",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
remoteIp: "192.0.2.10",
|
||||
role: "operator",
|
||||
scopes: scopes,
|
||||
caps: [],
|
||||
commands: [],
|
||||
isRepair: isRepair,
|
||||
previouslyPaired: false,
|
||||
requestedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
}
|
||||
|
||||
@Test func `card copy summarizes the requesting hardware`() {
|
||||
let card = self.nodeCard()
|
||||
#expect(PairingCardPresentation.title(for: card) == "Peter's MacBook Pro")
|
||||
#expect(PairingCardPresentation.subtitle(for: card) == "macOS 26.5 · MacBookPro18,3")
|
||||
#expect(PairingCardPresentation.deviceSymbol(for: card) == "macbook")
|
||||
#expect(PairingCardPresentation.identityLine(for: card) == "ID 19cec1c3...9dccdc7 · 192.168.1.42")
|
||||
#expect(PairingCardPresentation.versionTooltip(for: card) == "App 2026.6.11 · Core 2026.6.10")
|
||||
#expect(PairingCardPresentation.headerSummary(for: [card]) == "A node wants to connect to OpenClaw.")
|
||||
}
|
||||
|
||||
@Test func `device symbols map hardware families`() {
|
||||
#expect(PairingCardPresentation.deviceSymbol(for: self.deviceCard(scopes: [])) == "laptopcomputer")
|
||||
let iphone = PairingApprovalCenter.Card(
|
||||
kind: .device, requestId: "r", subjectId: "d", displayName: nil,
|
||||
platform: "ios 26", deviceFamily: nil, modelIdentifier: nil, version: nil,
|
||||
coreVersion: nil, remoteIp: nil, role: nil, scopes: [], caps: [], commands: [],
|
||||
isRepair: false, previouslyPaired: false, requestedAt: Date(timeIntervalSince1970: 0))
|
||||
#expect(PairingCardPresentation.deviceSymbol(for: iphone) == "iphone")
|
||||
}
|
||||
|
||||
@Test func `node access rows flag command execution as elevated`() {
|
||||
let card = self.nodeCard(caps: ["screen", "camera"], commands: ["system.run", "system.notify"])
|
||||
let rows = PairingCardPresentation.accessRows(for: card)
|
||||
#expect(rows.first?.isElevated == true)
|
||||
#expect(rows.first?.text == "Can run system commands")
|
||||
#expect(rows.map(\.text).contains("Screen capture"))
|
||||
#expect(rows.map(\.text).contains("Camera"))
|
||||
|
||||
// system.which alone is admin-scope (NODE_SYSTEM_RUN_COMMANDS).
|
||||
let whichOnly = self.nodeCard(commands: ["system.which"])
|
||||
#expect(PairingCardPresentation.accessRows(for: whichOnly).first?.isElevated == true)
|
||||
|
||||
// Non-system commands are still part of the approved surface and
|
||||
// must render rather than being granted invisibly.
|
||||
let notifyOnly = self.nodeCard(commands: ["system.notify", "contacts.search"])
|
||||
let notifyRows = PairingCardPresentation.accessRows(for: notifyOnly)
|
||||
#expect(notifyRows.allSatisfy { !$0.isElevated })
|
||||
#expect(notifyRows.map(\.text).contains("Commands: system.notify, contacts.search"))
|
||||
}
|
||||
|
||||
@Test func `every requested access row renders and admin can not hide`() {
|
||||
// No cap: hiding a row could conceal what approval grants.
|
||||
let caps = (1...12).map { "cap-\($0)" }
|
||||
#expect(PairingCardPresentation.accessRows(for: self.nodeCard(caps: caps)).count == caps.count)
|
||||
|
||||
// operator.admin buried behind ordinary scopes surfaces first, elevated.
|
||||
let sneaky = self.deviceCard(scopes: [
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.talk.secrets",
|
||||
"operator.admin",
|
||||
])
|
||||
let rows = PairingCardPresentation.accessRows(for: sneaky)
|
||||
#expect(rows.count == 6)
|
||||
#expect(rows.first?.text == "Admin access")
|
||||
#expect(rows.first?.isElevated == true)
|
||||
}
|
||||
|
||||
@Test func `device scopes render friendly names`() {
|
||||
let card = self.deviceCard(scopes: [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
])
|
||||
#expect(PairingCardPresentation.accessRows(for: card).map(\.text) == [
|
||||
"Admin access",
|
||||
"Read OpenClaw data",
|
||||
"Send messages and make changes",
|
||||
"Manage approvals",
|
||||
"Pair and repair devices",
|
||||
])
|
||||
#expect(PairingCardPresentation.title(for: card) == "OpenClaw Mac app")
|
||||
#expect(PairingCardPresentation.subtitle(for: card) == "Mac (Intel) · Operator")
|
||||
}
|
||||
|
||||
@Test func `trust line distinguishes first contact repair and reused ids`() {
|
||||
let fresh = self.nodeCard()
|
||||
#expect(PairingCardPresentation.trustLine(for: fresh).tone == .neutral)
|
||||
|
||||
// The requester-claimed id is unauthenticated, so an already-paired id
|
||||
// must render as a caution (token replacement), never positive trust.
|
||||
let reusedId = self.nodeCard(previouslyPaired: true)
|
||||
let reusedLine = PairingCardPresentation.trustLine(for: reusedId)
|
||||
#expect(reusedLine.tone == .caution)
|
||||
#expect(reusedLine.text.contains("already paired"))
|
||||
|
||||
let repair = self.deviceCard(scopes: [], isRepair: true)
|
||||
let repairLine = PairingCardPresentation.trustLine(for: repair)
|
||||
#expect(repairLine.tone == .caution)
|
||||
#expect(repairLine.text.contains("Repair"))
|
||||
|
||||
// Unverified history (stale snapshot) must stay neutral.
|
||||
let unknown = self.nodeCard(previouslyPaired: nil)
|
||||
let unknownLine = PairingCardPresentation.trustLine(for: unknown)
|
||||
#expect(unknownLine.tone == .neutral)
|
||||
#expect(!unknownLine.text.contains("already paired"))
|
||||
}
|
||||
|
||||
@Test func `identifier shortening keeps head and tail`() {
|
||||
let long = "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd"
|
||||
#expect(PairingCardPresentation.shortIdentifier(long) == "4a865684...f4e82bd")
|
||||
#expect(PairingCardPresentation.shortIdentifier("short-id") == "short-id")
|
||||
}
|
||||
|
||||
@Test func `panel auto presents only for unseen requests`() {
|
||||
#expect(!PairingApprovalCenter.shouldAutoPresent(cardIds: [], snoozedIds: []))
|
||||
#expect(PairingApprovalCenter.shouldAutoPresent(cardIds: ["a"], snoozedIds: []))
|
||||
// Everything on screen was snoozed: stay hidden.
|
||||
#expect(!PairingApprovalCenter.shouldAutoPresent(cardIds: ["a"], snoozedIds: ["a"]))
|
||||
// A request the user has not seen yet reopens the panel.
|
||||
#expect(PairingApprovalCenter.shouldAutoPresent(cardIds: ["a", "b"], snoozedIds: ["a"]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import Testing
|
|||
@testable import OpenClaw
|
||||
|
||||
struct NodePairingReconcilePolicyTests {
|
||||
@Test func `policy polls only when active`() {
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false)
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false))
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true))
|
||||
@Test func `policy polls only while requests are pending`() {
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0) == false)
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1))
|
||||
}
|
||||
|
||||
@Test func `policy uses slow safety interval`() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue