refactor(macos): share pairing and ui dedupe utilities

This commit is contained in:
Peter Steinberger
2026-03-02 12:13:35 +00:00
parent d85d3c88d5
commit 87316e07d8
13 changed files with 196 additions and 161 deletions

View File

@@ -25,22 +25,11 @@ extension CanvasWindowController {
}
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
LoopbackHost.parseIPv4(host)
}
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
LoopbackHost.isLocalNetworkIPv4(ip)
}
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {

View File

@@ -17,9 +17,7 @@ final class DevicePairingApprovalPrompter {
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
private var activeAlert: NSAlert?
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private let alertState = PairingAlertState()
private var resolvedByRequestId: Set<String> = []
private struct PairingList: Codable {
@@ -78,12 +76,10 @@ final class DevicePairingApprovalPrompter {
private func stopPushTask() {
PairingAlertSupport.stopPairingPrompter(
isStopping: &self.isStopping,
activeAlert: &self.activeAlert,
activeRequestId: &self.activeRequestId,
task: &self.task,
queue: &self.queue,
isPresenting: &self.isPresenting,
alertHostWindow: &self.alertHostWindow)
state: self.alertState)
}
private func loadPendingRequestsFromGateway() async {
@@ -121,20 +117,10 @@ final class DevicePairingApprovalPrompter {
requestId: req.requestId,
messageText: "Allow device to connect?",
informativeText: Self.describe(req),
activeAlert: &self.activeAlert,
activeRequestId: &self.activeRequestId,
alertHostWindow: &self.alertHostWindow,
clearActive: self.clearActiveAlert(hostWindow:),
state: self.alertState,
onResponse: self.handleAlertResponse)
}
private func clearActiveAlert(hostWindow: NSWindow) {
PairingAlertSupport.clearActivePairingAlert(
activeAlert: &self.activeAlert,
activeRequestId: &self.activeRequestId,
hostWindow: hostWindow)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
var shouldRemove = response != .alertFirstButtonReturn
defer {
@@ -194,7 +180,7 @@ final class DevicePairingApprovalPrompter {
}
private func endActiveAlert() {
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
@@ -234,7 +220,7 @@ final class DevicePairingApprovalPrompter {
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
? PairingAlertSupport.PairingResolution.approved
: PairingAlertSupport.PairingResolution.rejected
if let activeRequestId, activeRequestId == resolved.requestId {
if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
self.resolvedByRequestId.insert(resolved.requestId)
self.endActiveAlert()
let decision = resolution.rawValue

View File

@@ -48,27 +48,11 @@ struct GatewayDiscoveryInlineList: View {
.truncationMode(.middle)
}
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
SelectionStateIndicator(selected: selected)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground(
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
.openClawSelectableRowChrome(
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -106,12 +90,6 @@ struct GatewayDiscoveryInlineList: View {
}
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
if selected { return Color.accentColor.opacity(0.12) }
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}

View File

@@ -347,21 +347,8 @@ actor GatewayEndpointStore {
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
func ensureRemoteControlTunnel() async throws -> UInt16 {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = OpenClawConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
try await self.requireRemoteMode()
if let url = try self.resolveDirectRemoteURL() {
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
@@ -425,22 +412,9 @@ actor GatewayEndpointStore {
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
try await self.requireRemoteMode()
let root = OpenClawConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
if let url = try self.resolveDirectRemoteURL() {
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
@@ -491,6 +465,27 @@ actor GatewayEndpointStore {
}
}
private func requireRemoteMode() async throws {
guard await self.deps.mode() == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
}
private func resolveDirectRemoteURL() throws -> URL? {
let root = OpenClawConfigFile.loadDict()
guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
return url
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}

View File

@@ -32,9 +32,7 @@ final class NodePairingApprovalPrompter {
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
private var activeAlert: NSAlert?
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private let alertState = PairingAlertState()
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private var autoApproveAttempts: Set<String> = []
@@ -99,12 +97,10 @@ final class NodePairingApprovalPrompter {
private func stopPushTask() {
PairingAlertSupport.stopPairingPrompter(
isStopping: &self.isStopping,
activeAlert: &self.activeAlert,
activeRequestId: &self.activeRequestId,
task: &self.task,
queue: &self.queue,
isPresenting: &self.isPresenting,
alertHostWindow: &self.alertHostWindow)
state: self.alertState)
}
private func loadPendingRequestsFromGateway() async {
@@ -180,7 +176,7 @@ final class NodePairingApprovalPrompter {
if pendingById[req.requestId] != nil { continue }
let resolution = self.inferResolution(for: req, list: list)
if self.activeRequestId == req.requestId, self.activeAlert != nil {
if self.alertState.activeRequestId == req.requestId, self.alertState.activeAlert != nil {
self.remoteResolutionsByRequestId[req.requestId] = resolution
self.logger.info(
"""
@@ -222,7 +218,7 @@ final class NodePairingApprovalPrompter {
}
private func endActiveAlert() {
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
@@ -284,20 +280,10 @@ final class NodePairingApprovalPrompter {
requestId: req.requestId,
messageText: "Allow node to connect?",
informativeText: Self.describe(req),
activeAlert: &self.activeAlert,
activeRequestId: &self.activeRequestId,
alertHostWindow: &self.alertHostWindow,
clearActive: self.clearActiveAlert(hostWindow:),
state: self.alertState,
onResponse: self.handleAlertResponse)
}
private func clearActiveAlert(hostWindow: NSWindow) {
PairingAlertSupport.clearActivePairingAlert(
activeAlert: &self.activeAlert,
activeRequestId: &self.activeRequestId,
hostWindow: hostWindow)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
defer {
if self.queue.first == request {
@@ -575,7 +561,7 @@ final class NodePairingApprovalPrompter {
let resolution: PairingResolution =
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
if self.alertState.activeRequestId == resolved.requestId, self.alertState.activeAlert != nil {
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
self.logger.info(
"""

View File

@@ -311,29 +311,13 @@ extension OnboardingView {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.truncationMode(.middle)
}
}
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
SelectionStateIndicator(selected: selected)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(selected ? Color.accentColor.opacity(0.12) : Color.clear))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
.openClawSelectableRowChrome(selected: selected)
}
.buttonStyle(.plain)
}

View File

@@ -12,6 +12,13 @@ final class PairingAlertHostWindow: NSWindow {
}
}
@MainActor
final class PairingAlertState {
var activeAlert: NSAlert?
var activeRequestId: String?
var alertHostWindow: NSWindow?
}
@MainActor
enum PairingAlertSupport {
enum PairingResolution: String {
@@ -34,6 +41,10 @@ enum PairingAlertSupport {
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
@@ -179,6 +190,30 @@ enum PairingAlertSupport {
}
}
static func presentPairingAlert<Request>(
request: Request,
requestId: String,
messageText: String,
informativeText: String,
state: PairingAlertState,
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
{
self.presentPairingAlert(
request: request,
requestId: requestId,
messageText: messageText,
informativeText: informativeText,
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
alertHostWindow: &state.alertHostWindow)
{ 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?,
@@ -189,6 +224,13 @@ enum PairingAlertSupport {
hostWindow.orderOut(nil)
}
static func clearActivePairingAlert(state: PairingAlertState, hostWindow: NSWindow) {
self.clearActivePairingAlert(
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
hostWindow: hostWindow)
}
static func stopPairingPrompter<Request>(
isStopping: inout Bool,
activeAlert: inout NSAlert?,
@@ -210,6 +252,23 @@ enum PairingAlertSupport {
alertHostWindow = nil
}
static func stopPairingPrompter<Request>(
isStopping: inout Bool,
task: inout Task<Void, Never>?,
queue: inout [Request],
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,

View File

@@ -0,0 +1,40 @@
import SwiftUI
struct SelectionStateIndicator: View {
let selected: Bool
var body: some View {
Group {
if self.selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
}
}
}
extension View {
func openClawSelectableRowChrome(selected: Bool, hovered: Bool = false) -> some View {
self
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.openClawRowBackground(selected: selected, hovered: hovered)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
}
private func openClawRowBackground(selected: Bool, hovered: Bool) -> Color {
if selected { return Color.accentColor.opacity(0.12) }
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
}

View File

@@ -285,16 +285,12 @@ actor GatewayWizardClient {
nonce: connectNonce,
platform: platform,
deviceFamily: "Mac")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
if let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
payload: payload,
identity: identity,
signedAtMs: signedAtMs,
nonce: connectNonce)
{
let device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
"nonce": ProtoAnyCodable(connectNonce),
]
params["device"] = ProtoAnyCodable(device)
}

View File

@@ -70,13 +70,7 @@ private struct ChatBubbleShape: InsettableShape {
to: baseBottom,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r)
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
@@ -108,13 +102,7 @@ private struct ChatBubbleShape: InsettableShape {
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r)
path.addLine(to: baseBottom)
path.addCurve(
to: tip,
@@ -131,6 +119,22 @@ private struct ChatBubbleShape: InsettableShape {
return path
}
private func addBottomEdge(
path: inout Path,
bubbleMinX: CGFloat,
bubbleMaxX: CGFloat,
bubbleMaxY: CGFloat,
radius: CGFloat)
{
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - radius, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + radius, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - radius),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
}
}
@MainActor

View File

@@ -1,4 +1,5 @@
import Foundation
import OpenClawProtocol
public enum GatewayDeviceAuthPayload {
public static func buildV3(
@@ -52,4 +53,24 @@ public enum GatewayDeviceAuthPayload {
}
return output
}
public static func signedDeviceDictionary(
payload: String,
identity: DeviceIdentity,
signedAtMs: Int,
nonce: String) -> [String: OpenClawProtocol.AnyCodable]?
{
guard let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
else {
return nil
}
return [
"id": OpenClawProtocol.AnyCodable(identity.deviceId),
"publicKey": OpenClawProtocol.AnyCodable(publicKey),
"signature": OpenClawProtocol.AnyCodable(signature),
"signedAt": OpenClawProtocol.AnyCodable(signedAtMs),
"nonce": OpenClawProtocol.AnyCodable(nonce),
]
}
}

View File

@@ -406,15 +406,12 @@ public actor GatewayChannelActor {
nonce: connectNonce,
platform: platform,
deviceFamily: InstanceIdentity.deviceFamily)
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
let device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
"nonce": ProtoAnyCodable(connectNonce),
]
if let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
payload: payload,
identity: identity,
signedAtMs: signedAtMs,
nonce: connectNonce)
{
params["device"] = ProtoAnyCodable(device)
}
}

View File

@@ -53,7 +53,7 @@ public enum LoopbackHost {
return self.isLocalNetworkIPv4(ipv4)
}
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
@@ -61,7 +61,7 @@ public enum LoopbackHost {
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
// 10.0.0.0/8
if a == 10 { return true }