mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
ac89350327
* feat(apps): Android, iPhone, and Watch approval clients Squash-rebased #103912 segment onto the deep-links tip on current main. Native approval surfaces: iOS approval presentation with gateway-switch lease preservation and resolution fencing, watchOS inbox + approval actions with shipped-shape payload codec, Android approval notices with publication-tokened dismissal. Native i18n inventory regenerated. (cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b) (cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6) (cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e) (cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28) (cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1) (cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9) (cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3) (cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca) (cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740) (cherry picked from commit f36a95213e561ceadc9da799e7f7803f9905844f) (cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac) (cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235) (cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1) (cherry picked from commit 2a606197227b0221d5f21f0fb92bdce5bf57eeec) (cherry picked from commit 6f0c3865677f5988f4d1bccce8e46a0949c18ea2) (cherry picked from commit 784a5857b7ade84b42866b8b7789d315ff04eadd) (cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db) * fix(apps): harden approval reconciliation and watch states
199 lines
7.9 KiB
Swift
199 lines
7.9 KiB
Swift
import Foundation
|
|
import Network
|
|
import Observation
|
|
import OpenClawKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class GatewayDiscoveryModel {
|
|
struct DebugLogEntry: Identifiable, Equatable {
|
|
var id = UUID()
|
|
var ts: Date
|
|
var message: String
|
|
}
|
|
|
|
struct DiscoveredGateway: Identifiable, Equatable {
|
|
var id: GatewayStableIdentifier.Key {
|
|
GatewayStableIdentifier.Key(self.stableID)
|
|
}
|
|
|
|
var name: String
|
|
var endpoint: NWEndpoint
|
|
var stableID: String
|
|
var debugID: String
|
|
var lanHost: String?
|
|
var tailnetDns: String?
|
|
var gatewayPort: Int?
|
|
var canvasPort: Int?
|
|
var tlsEnabled: Bool
|
|
var tlsFingerprintSha256: String?
|
|
var cliPath: String?
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.name == rhs.name &&
|
|
lhs.endpoint == rhs.endpoint &&
|
|
GatewayStableIdentifier.matches(lhs.stableID, rhs.stableID) &&
|
|
lhs.debugID == rhs.debugID &&
|
|
lhs.lanHost == rhs.lanHost &&
|
|
lhs.tailnetDns == rhs.tailnetDns &&
|
|
lhs.gatewayPort == rhs.gatewayPort &&
|
|
lhs.canvasPort == rhs.canvasPort &&
|
|
lhs.tlsEnabled == rhs.tlsEnabled &&
|
|
lhs.tlsFingerprintSha256 == rhs.tlsFingerprintSha256 &&
|
|
lhs.cliPath == rhs.cliPath
|
|
}
|
|
}
|
|
|
|
var gateways: [DiscoveredGateway] = []
|
|
var statusText: String = "Idle"
|
|
private(set) var debugLog: [DebugLogEntry] = []
|
|
|
|
private var browsers: [String: NWBrowser] = [:]
|
|
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
|
private var statesByDomain: [String: NWBrowser.State] = [:]
|
|
private var debugLoggingEnabled = false
|
|
private var lastStableIDs = Set<GatewayStableIdentifier.Key>()
|
|
|
|
func setDebugLoggingEnabled(_ enabled: Bool) {
|
|
let wasEnabled = self.debugLoggingEnabled
|
|
self.debugLoggingEnabled = enabled
|
|
if !enabled {
|
|
self.debugLog = []
|
|
} else if !wasEnabled {
|
|
self.appendDebugLog("debug logging enabled")
|
|
self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)")
|
|
}
|
|
}
|
|
|
|
func start() {
|
|
if !self.browsers.isEmpty { return }
|
|
self.appendDebugLog("start()")
|
|
|
|
for domain in OpenClawBonjour.gatewayServiceDomains {
|
|
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
|
serviceType: OpenClawBonjour.gatewayServiceType,
|
|
domain: domain,
|
|
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
|
|
onState: { [weak self] state in
|
|
guard let self else { return }
|
|
self.statesByDomain[domain] = state
|
|
self.updateStatusText()
|
|
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
|
|
},
|
|
onResults: { [weak self] results in
|
|
guard let self else { return }
|
|
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
|
switch result.endpoint {
|
|
case let .service(name, _, _, _):
|
|
let decodedName = BonjourEscapes.decode(name)
|
|
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
|
let advertisedName = txt["displayName"]
|
|
let prettyAdvertised = advertisedName
|
|
.map(Self.prettifyInstanceName)
|
|
.flatMap { $0.isEmpty ? nil : $0 }
|
|
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
|
|
return DiscoveredGateway(
|
|
name: prettyName,
|
|
endpoint: result.endpoint,
|
|
stableID: GatewayEndpointID.stableID(result.endpoint),
|
|
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
|
|
lanHost: Self.txtValue(txt, key: "lanHost"),
|
|
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
|
|
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
|
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
|
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
|
|
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
|
|
cliPath: Self.txtValue(txt, key: "cliPath"))
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
self.recomputeGateways()
|
|
})
|
|
|
|
self.browsers[domain] = browser
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
self.appendDebugLog("stop()")
|
|
for browser in self.browsers.values {
|
|
browser.cancel()
|
|
}
|
|
self.browsers = [:]
|
|
self.gatewaysByDomain = [:]
|
|
self.statesByDomain = [:]
|
|
self.gateways = []
|
|
self.statusText = "Stopped"
|
|
}
|
|
|
|
private func recomputeGateways() {
|
|
let next = self.gatewaysByDomain.values
|
|
.flatMap(\.self)
|
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
|
|
let nextIDs = Set(next.map { GatewayStableIdentifier.Key($0.stableID) })
|
|
let added = nextIDs.subtracting(self.lastStableIDs)
|
|
let removed = self.lastStableIDs.subtracting(nextIDs)
|
|
if !added.isEmpty || !removed.isEmpty {
|
|
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
|
|
}
|
|
self.lastStableIDs = nextIDs
|
|
self.gateways = next
|
|
}
|
|
|
|
private func updateStatusText() {
|
|
self.statusText = GatewayDiscoveryStatusText.make(
|
|
states: Array(self.statesByDomain.values),
|
|
hasBrowsers: !self.browsers.isEmpty)
|
|
}
|
|
|
|
private static func prettyState(_ state: NWBrowser.State) -> String {
|
|
switch state {
|
|
case .setup:
|
|
"setup"
|
|
case .ready:
|
|
"ready"
|
|
case let .failed(err):
|
|
"failed (\(err))"
|
|
case .cancelled:
|
|
"cancelled"
|
|
case let .waiting(err):
|
|
"waiting (\(err))"
|
|
@unknown default:
|
|
"unknown"
|
|
}
|
|
}
|
|
|
|
private func appendDebugLog(_ message: String) {
|
|
guard self.debugLoggingEnabled else { return }
|
|
self.debugLog.append(DebugLogEntry(ts: Date(), message: message))
|
|
if self.debugLog.count > 200 {
|
|
self.debugLog.removeFirst(self.debugLog.count - 200)
|
|
}
|
|
}
|
|
|
|
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
|
let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "")
|
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
|
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return raw.isEmpty ? nil : raw
|
|
}
|
|
|
|
private static func txtIntValue(_ dict: [String: String], key: String) -> Int? {
|
|
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
|
return Int(raw)
|
|
}
|
|
|
|
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
|
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
|
return raw == "1" || raw == "true" || raw == "yes"
|
|
}
|
|
}
|