mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:30:42 +00:00
iOS: fix gateway connect roles; improve discovery/connect UX; stabilize chat sending
This commit is contained in:
@@ -2,8 +2,10 @@ import OpenClawChatUI
|
|||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||||
private let gateway: GatewayNodeSession
|
private let gateway: GatewayNodeSession
|
||||||
|
|
||||||
init(gateway: GatewayNodeSession) {
|
init(gateway: GatewayNodeSession) {
|
||||||
@@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||||
struct Subscribe: Codable { var sessionKey: String }
|
// Operator clients receive chat events without node-style subscriptions.
|
||||||
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
|
// (chat.subscribe is a node event, not an operator RPC method.)
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||||
@@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
|||||||
idempotencyKey: String,
|
idempotencyKey: String,
|
||||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||||
{
|
{
|
||||||
|
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
|
||||||
struct Params: Codable {
|
struct Params: Codable {
|
||||||
var sessionKey: String
|
var sessionKey: String
|
||||||
var message: String
|
var message: String
|
||||||
@@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
|||||||
idempotencyKey: idempotencyKey)
|
idempotencyKey: idempotencyKey)
|
||||||
let data = try JSONEncoder().encode(params)
|
let data = try JSONEncoder().encode(params)
|
||||||
let json = String(data: data, encoding: .utf8)
|
let json = String(data: data, encoding: .utf8)
|
||||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
do {
|
||||||
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||||
|
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||||
|
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||||
|
return decoded
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||||
|
|||||||
@@ -56,23 +56,42 @@ final class GatewayConnectionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
||||||
|
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
||||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if instanceId.isEmpty {
|
||||||
|
return "Missing instanceId (node.instanceId). Try restarting the app."
|
||||||
|
}
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
|
||||||
let port = gateway.gatewayPort ?? 18789
|
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||||
guard let url = self.buildGatewayURL(
|
let resolvedHost: String?
|
||||||
host: host,
|
let resolvedPort: Int?
|
||||||
port: port,
|
if let host = self.resolveGatewayHost(gateway) {
|
||||||
useTLS: tlsParams?.required == true)
|
resolvedHost = host
|
||||||
else { return }
|
resolvedPort = gateway.gatewayPort ?? 18789
|
||||||
|
} else if let fallback = await self.resolveHostPortFromBonjourEndpoint(gateway.endpoint) {
|
||||||
|
resolvedHost = fallback.host
|
||||||
|
resolvedPort = fallback.port > 0 ? fallback.port : (gateway.gatewayPort ?? 18789)
|
||||||
|
} else {
|
||||||
|
return "Discovery found a gateway service, but no connectable host was advertised (missing lanHost/tailnetDns TXT) and the Bonjour service did not resolve to an address. Try Manual connect."
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let host = resolvedHost, let port = resolvedPort else {
|
||||||
|
return "Failed to resolve a connectable host/port from discovery."
|
||||||
|
}
|
||||||
|
|
||||||
|
let useTLS = tlsParams?.required == true || gateway.tlsEnabled
|
||||||
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: useTLS) else {
|
||||||
|
return "Failed to build gateway URL (host=\(host) port=\(port) tls=\(useTLS))."
|
||||||
|
}
|
||||||
|
|
||||||
GatewaySettingsStore.saveLastGatewayConnection(
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
useTLS: tlsParams?.required == true,
|
useTLS: useTLS,
|
||||||
stableID: gateway.stableID)
|
stableID: gateway.stableID)
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
@@ -81,6 +100,11 @@ final class GatewayConnectionController {
|
|||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
password: password)
|
password: password)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||||
|
_ = await self.connectWithDiagnostics(gateway)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
||||||
@@ -381,6 +405,125 @@ final class GatewayConnectionController {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||||
|
switch endpoint {
|
||||||
|
case let .hostPort(host, port):
|
||||||
|
return (host: host.debugDescription, port: Int(port.rawValue))
|
||||||
|
case let .service(name, type, domain, _):
|
||||||
|
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveBonjourServiceToHostPort(
|
||||||
|
name: String,
|
||||||
|
type: String,
|
||||||
|
domain: String,
|
||||||
|
timeoutSeconds: TimeInterval = 3.0
|
||||||
|
) async -> (host: String, port: Int)? {
|
||||||
|
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||||
|
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||||
|
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||||
|
// resume the continuation exactly once (timeout/cancel safe).
|
||||||
|
@MainActor
|
||||||
|
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
|
||||||
|
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
|
||||||
|
private let service: NetService
|
||||||
|
private var timeoutTask: Task<Void, Never>?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
|
||||||
|
self.cont = cont
|
||||||
|
self.service = service
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(timeoutSeconds: TimeInterval) {
|
||||||
|
self.service.delegate = self
|
||||||
|
self.service.schedule(in: .main, forMode: .default)
|
||||||
|
|
||||||
|
// NetService has its own timeout, but we keep a manual one as a backstop in case
|
||||||
|
// callbacks never arrive (e.g. local network permission issues).
|
||||||
|
self.timeoutTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: ns)
|
||||||
|
self.finish(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.service.resolve(withTimeout: timeoutSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||||
|
self.finish(Self.extractHostPort(sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||||
|
_ = errorDict // currently best-effort; callers surface a generic failure
|
||||||
|
self.finish(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: (host: String, port: Int)?) {
|
||||||
|
guard !self.finished else { return }
|
||||||
|
self.finished = true
|
||||||
|
|
||||||
|
self.timeoutTask?.cancel()
|
||||||
|
self.timeoutTask = nil
|
||||||
|
|
||||||
|
self.service.stop()
|
||||||
|
self.service.remove(from: .main, forMode: .default)
|
||||||
|
|
||||||
|
let c = self.cont
|
||||||
|
self.cont = nil
|
||||||
|
c?.resume(returning: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
|
||||||
|
let port = svc.port
|
||||||
|
|
||||||
|
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
|
||||||
|
return (host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let addrs = svc.addresses else { return nil }
|
||||||
|
for addrData in addrs {
|
||||||
|
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||||
|
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
|
||||||
|
let rc = getnameinfo(
|
||||||
|
base.assumingMemoryBound(to: sockaddr.self),
|
||||||
|
socklen_t(ptr.count),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard rc == 0 else { return nil }
|
||||||
|
return String(cString: buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let host, !host.isEmpty {
|
||||||
|
return (host: host, port: port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
Task { @MainActor in
|
||||||
|
let service = NetService(domain: domain, type: type, name: name)
|
||||||
|
let resolver = Resolver(cont: cont, service: service)
|
||||||
|
// Keep the resolver alive for the lifetime of the NetService resolve.
|
||||||
|
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||||
|
resolver.start(timeoutSeconds: timeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||||
let scheme = useTLS ? "wss" : "ws"
|
let scheme = useTLS ? "wss" : "ws"
|
||||||
var components = URLComponents()
|
var components = URLComponents()
|
||||||
|
|||||||
@@ -75,7 +75,16 @@ final class GatewayDiscoveryModel {
|
|||||||
switch result.endpoint {
|
switch result.endpoint {
|
||||||
case let .service(name, _, _, _):
|
case let .service(name, _, _, _):
|
||||||
let decodedName = BonjourEscapes.decode(name)
|
let decodedName = BonjourEscapes.decode(name)
|
||||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
// Some iOS versions return TXT records via metadata, not endpoint.txtRecord.
|
||||||
|
var txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||||
|
if txt.isEmpty {
|
||||||
|
switch result.metadata {
|
||||||
|
case let .bonjour(meta):
|
||||||
|
txt = meta.dictionary
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
let advertisedName = txt["displayName"]
|
let advertisedName = txt["displayName"]
|
||||||
let prettyAdvertised = advertisedName
|
let prettyAdvertised = advertisedName
|
||||||
.map(Self.prettifyInstanceName)
|
.map(Self.prettifyInstanceName)
|
||||||
|
|||||||
113
apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift
Normal file
113
apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GatewayQuickSetupSheet: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
|
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||||
|
@State private var connecting: Bool = false
|
||||||
|
@State private var connectError: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Connect to a Gateway?")
|
||||||
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
if let candidate = self.bestCandidate {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(verbatim: candidate.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(verbatim: candidate.debugID)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||||
|
// localized format strings (which can crash with Objective-C exceptions).
|
||||||
|
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
|
||||||
|
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
|
||||||
|
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
|
||||||
|
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
self.connectError = nil
|
||||||
|
self.connecting = true
|
||||||
|
Task {
|
||||||
|
let err = await self.gatewayController.connectWithDiagnostics(candidate)
|
||||||
|
await MainActor.run {
|
||||||
|
self.connecting = false
|
||||||
|
self.connectError = err
|
||||||
|
// If we kicked off a connect, leave the sheet up so the user can see status evolve.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Group {
|
||||||
|
if self.connecting {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().progressViewStyle(.circular)
|
||||||
|
Text("Connecting…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.connecting)
|
||||||
|
|
||||||
|
if let connectError {
|
||||||
|
Text(connectError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
self.dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("Not now")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.connecting)
|
||||||
|
|
||||||
|
Toggle("Don’t show this again", isOn: self.$quickSetupDismissed)
|
||||||
|
.padding(.top, 4)
|
||||||
|
} else {
|
||||||
|
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Quick Setup")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
self.quickSetupDismissed = true
|
||||||
|
self.dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
|
||||||
|
// Prefer whatever discovery says is first; the list is already name-sorted.
|
||||||
|
self.gatewayController.gateways.first
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ final class NodeAppModel {
|
|||||||
private let camera: any CameraServicing
|
private let camera: any CameraServicing
|
||||||
private let screenRecorder: any ScreenRecordingServicing
|
private let screenRecorder: any ScreenRecordingServicing
|
||||||
var gatewayStatusText: String = "Offline"
|
var gatewayStatusText: String = "Offline"
|
||||||
|
var nodeStatusText: String = "Offline"
|
||||||
|
var operatorStatusText: String = "Offline"
|
||||||
var gatewayServerName: String?
|
var gatewayServerName: String?
|
||||||
var gatewayRemoteAddress: String?
|
var gatewayRemoteAddress: String?
|
||||||
var connectedGatewayID: String?
|
var connectedGatewayID: String?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OpenClawApp: App {
|
struct OpenClawApp: App {
|
||||||
@@ -7,6 +8,7 @@ struct OpenClawApp: App {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
Self.installUncaughtExceptionLogger()
|
||||||
GatewaySettingsStore.bootstrapPersistence()
|
GatewaySettingsStore.bootstrapPersistence()
|
||||||
let appModel = NodeAppModel()
|
let appModel = NodeAppModel()
|
||||||
_appModel = State(initialValue: appModel)
|
_appModel = State(initialValue: appModel)
|
||||||
@@ -29,3 +31,18 @@ struct OpenClawApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension OpenClawApp {
|
||||||
|
private static func installUncaughtExceptionLogger() {
|
||||||
|
NSLog("OpenClaw: installing uncaught exception handler")
|
||||||
|
NSSetUncaughtExceptionHandler { exception in
|
||||||
|
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
|
||||||
|
// produce a normal Swift error backtrace.
|
||||||
|
let reason = exception.reason ?? "(no reason)"
|
||||||
|
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
|
||||||
|
for line in exception.callStackSymbols {
|
||||||
|
NSLog(" %@", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import UIKit
|
|||||||
struct RootCanvas: View {
|
struct RootCanvas: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||||
|
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||||
@Environment(\.colorScheme) private var systemColorScheme
|
@Environment(\.colorScheme) private var systemColorScheme
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||||
@@ -14,6 +15,7 @@ struct RootCanvas: View {
|
|||||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||||
|
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||||
@State private var presentedSheet: PresentedSheet?
|
@State private var presentedSheet: PresentedSheet?
|
||||||
@State private var voiceWakeToastText: String?
|
@State private var voiceWakeToastText: String?
|
||||||
@State private var toastDismissTask: Task<Void, Never>?
|
@State private var toastDismissTask: Task<Void, Never>?
|
||||||
@@ -22,11 +24,13 @@ struct RootCanvas: View {
|
|||||||
private enum PresentedSheet: Identifiable {
|
private enum PresentedSheet: Identifiable {
|
||||||
case settings
|
case settings
|
||||||
case chat
|
case chat
|
||||||
|
case quickSetup
|
||||||
|
|
||||||
var id: Int {
|
var id: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .settings: 0
|
case .settings: 0
|
||||||
case .chat: 1
|
case .chat: 1
|
||||||
|
case .quickSetup: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,12 +66,16 @@ struct RootCanvas: View {
|
|||||||
sessionKey: self.appModel.mainSessionKey,
|
sessionKey: self.appModel.mainSessionKey,
|
||||||
agentName: self.appModel.activeAgentName,
|
agentName: self.appModel.activeAgentName,
|
||||||
userAccent: self.appModel.seamColor)
|
userAccent: self.appModel.seamColor)
|
||||||
|
case .quickSetup:
|
||||||
|
GatewayQuickSetupSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { self.updateIdleTimer() }
|
.onAppear { self.updateIdleTimer() }
|
||||||
.onAppear { self.maybeAutoOpenSettings() }
|
.onAppear { self.maybeAutoOpenSettings() }
|
||||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||||
|
.onAppear { self.maybeShowQuickSetup() }
|
||||||
|
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||||
.onAppear { self.updateCanvasDebugStatus() }
|
.onAppear { self.updateCanvasDebugStatus() }
|
||||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||||
@@ -154,6 +162,14 @@ struct RootCanvas: View {
|
|||||||
self.didAutoOpenSettings = true
|
self.didAutoOpenSettings = true
|
||||||
self.presentedSheet = .settings
|
self.presentedSheet = .settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func maybeShowQuickSetup() {
|
||||||
|
guard !self.quickSetupDismissed else { return }
|
||||||
|
guard self.presentedSheet == nil else { return }
|
||||||
|
guard self.appModel.gatewayServerName == nil else { return }
|
||||||
|
guard !self.gatewayController.gateways.isEmpty else { return }
|
||||||
|
self.presentedSheet = .quickSetup
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CanvasContent: View {
|
private struct CanvasContent: View {
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ struct SettingsTab: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisclosureGroup("Advanced") {
|
|
||||||
if self.appModel.gatewayServerName == nil {
|
if self.appModel.gatewayServerName == nil {
|
||||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||||
}
|
}
|
||||||
@@ -148,67 +147,69 @@ struct SettingsTab: View {
|
|||||||
self.gatewayList(showing: .all)
|
self.gatewayList(showing: .all)
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
DisclosureGroup("Advanced") {
|
||||||
|
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||||
|
|
||||||
TextField("Host", text: self.$manualGatewayHost)
|
TextField("Host", text: self.$manualGatewayHost)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
TextField("Port (optional)", text: self.manualPortBinding)
|
TextField("Port (optional)", text: self.manualPortBinding)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await self.connectManual() }
|
Task { await self.connectManual() }
|
||||||
} label: {
|
} label: {
|
||||||
if self.connectingGatewayID == "manual" {
|
if self.connectingGatewayID == "manual" {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
Text("Connecting…")
|
Text("Connecting…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Connect (Manual)")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text("Connect (Manual)")
|
|
||||||
}
|
}
|
||||||
}
|
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.isEmpty || !self.manualPortIsValid)
|
||||||
.isEmpty || !self.manualPortIsValid)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink("Discovery Logs") {
|
||||||
|
GatewayDiscoveryDebugLogView()
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink("Discovery Logs") {
|
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||||
GatewayDiscoveryDebugLogView()
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
TextField("Gateway Token", text: self.$gatewayToken)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
TextField("Gateway Token", text: self.$gatewayToken)
|
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
|
|
||||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Debug")
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
.font(.footnote.weight(.semibold))
|
||||||
Text("Debug")
|
.foregroundStyle(.secondary)
|
||||||
.font(.footnote.weight(.semibold))
|
Text(self.gatewayDebugText())
|
||||||
.foregroundStyle(.secondary)
|
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||||
Text(self.gatewayDebugText())
|
.foregroundStyle(.secondary)
|
||||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.foregroundStyle(.secondary)
|
.padding(10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.padding(10)
|
}
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -418,10 +419,11 @@ struct SettingsTab: View {
|
|||||||
ForEach(rows) { gateway in
|
ForEach(rows) { gateway in
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(gateway.name)
|
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
|
||||||
|
Text(verbatim: gateway.name)
|
||||||
let detailLines = self.gatewayDetailLines(gateway)
|
let detailLines = self.gatewayDetailLines(gateway)
|
||||||
ForEach(detailLines, id: \.self) { line in
|
ForEach(detailLines, id: \.self) { line in
|
||||||
Text(line)
|
Text(verbatim: line)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -507,7 +509,10 @@ struct SettingsTab: View {
|
|||||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||||
defer { self.connectingGatewayID = nil }
|
defer { self.connectingGatewayID = nil }
|
||||||
|
|
||||||
await self.gatewayController.connect(gateway)
|
let err = await self.gatewayController.connectWithDiagnostics(gateway)
|
||||||
|
if let err {
|
||||||
|
self.connectStatus.text = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connectLastKnown() async {
|
private func connectLastKnown() async {
|
||||||
|
|||||||
@@ -814,23 +814,14 @@ final class TalkModeManager: NSObject {
|
|||||||
private func subscribeChatIfNeeded(sessionKey: String) async {
|
private func subscribeChatIfNeeded(sessionKey: String) async {
|
||||||
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !key.isEmpty else { return }
|
guard !key.isEmpty else { return }
|
||||||
guard let gateway else { return }
|
|
||||||
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
||||||
|
|
||||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
// Operator clients receive chat events without node-style subscriptions.
|
||||||
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
|
||||||
self.chatSubscribedSessionKeys.insert(key)
|
self.chatSubscribedSessionKeys.insert(key)
|
||||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unsubscribeAllChats() async {
|
private func unsubscribeAllChats() async {
|
||||||
guard let gateway else { return }
|
|
||||||
let keys = self.chatSubscribedSessionKeys
|
|
||||||
self.chatSubscribedSessionKeys.removeAll()
|
self.chatSubscribedSessionKeys.removeAll()
|
||||||
for key in keys {
|
|
||||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
|
||||||
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildPrompt(transcript: String) -> String {
|
private func buildPrompt(transcript: String) -> String {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public final class OpenClawChatViewModel {
|
|||||||
let decoded = raw.compactMap { item in
|
let decoded = raw.compactMap { item in
|
||||||
(try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self))
|
(try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self))
|
||||||
}
|
}
|
||||||
return Self.dedupeMessages(decoded)
|
return Self.filterHeartbeatNoise(Self.dedupeMessages(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||||
@@ -205,6 +205,56 @@ public final class OpenClawChatViewModel {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func filterHeartbeatNoise(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||||
|
messages.filter { !Self.isHeartbeatNoiseMessage($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isHeartbeatNoiseMessage(_ message: OpenClawChatMessage) -> Bool {
|
||||||
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return false }
|
||||||
|
|
||||||
|
if role == "assistant", Self.isHeartbeatAckText(text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if role == "user", Self.isHeartbeatPollText(text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Some models occasionally echo the heartbeat prompt text as an assistant reply.
|
||||||
|
if role == "assistant", Self.isHeartbeatPollText(text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isHeartbeatPollText(_ text: String) -> Bool {
|
||||||
|
let lower = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
// Match the default heartbeat prompt without requiring the entire multi-sentence string.
|
||||||
|
return lower.hasPrefix("read heartbeat.md if it exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isHeartbeatAckText(_ text: String) -> Bool {
|
||||||
|
// Heartbeat acks are intended to be internal. Treat common markup wrappers as equivalent.
|
||||||
|
var t = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if t.isEmpty { return false }
|
||||||
|
|
||||||
|
// Strip a few common wrappers (markdown/HTML) so **HEARTBEAT_OK** or <b>HEARTBEAT_OK</b> still matches.
|
||||||
|
let wrappers = ["**", "__", "`", "<b>", "</b>", "<strong>", "</strong>"]
|
||||||
|
for w in wrappers {
|
||||||
|
t = t.replacingOccurrences(of: w, with: "")
|
||||||
|
}
|
||||||
|
t = t.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Allow a tiny amount of padding (some channels append a marker/emoji).
|
||||||
|
if t == "HEARTBEAT_OK" { return true }
|
||||||
|
if t.hasPrefix("HEARTBEAT_OK") {
|
||||||
|
let rest = t.dropFirst("HEARTBEAT_OK".count)
|
||||||
|
return rest.trimmingCharacters(in: .whitespacesAndNewlines).count <= 10
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private static func dedupeKey(for message: OpenClawChatMessage) -> String? {
|
private static func dedupeKey(for message: OpenClawChatMessage) -> String? {
|
||||||
guard let timestamp = message.timestamp else { return nil }
|
guard let timestamp = message.timestamp else { return nil }
|
||||||
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
||||||
@@ -214,14 +264,22 @@ public final class OpenClawChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func performSend() async {
|
private func performSend() async {
|
||||||
guard !self.isSending else { return }
|
if self.isSending {
|
||||||
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
chatUILogger.info("performSend ignored: already sending")
|
||||||
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
|
|
||||||
|
|
||||||
guard self.healthOK else {
|
|
||||||
self.errorText = "Gateway health not OK; cannot send"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty && self.attachments.isEmpty {
|
||||||
|
chatUILogger.info("performSend ignored: empty input and no attachments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health checks are best-effort. If they fail (or the gateway doesn't implement them),
|
||||||
|
// we still attempt to send and let the RPC result determine success.
|
||||||
|
if !self.healthOK {
|
||||||
|
self.errorText = "Gateway health unknown; attempting send anyway"
|
||||||
|
}
|
||||||
|
chatUILogger.info("performSend sending len=\(trimmed.count, privacy: .public) attachments=\(self.attachments.count, privacy: .public) sessionKey=\(self.sessionKey, privacy: .public)")
|
||||||
|
|
||||||
self.isSending = true
|
self.isSending = true
|
||||||
self.errorText = nil
|
self.errorText = nil
|
||||||
|
|||||||
@@ -399,8 +399,12 @@ public actor GatewayChannelActor {
|
|||||||
role: String
|
role: String
|
||||||
) async throws {
|
) async throws {
|
||||||
if res.ok == false {
|
if res.ok == false {
|
||||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
let code = res.error?["code"]?.value as? String
|
||||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
let msg = res.error?["message"]?.value as? String
|
||||||
|
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||||
|
acc[pair.key] = AnyCodable(pair.value.value)
|
||||||
|
}
|
||||||
|
throw GatewayResponseError(method: "connect", code: code, message: msg, details: details)
|
||||||
}
|
}
|
||||||
guard let payload = res.payload else {
|
guard let payload = res.payload else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|||||||
Reference in New Issue
Block a user