From 761188cd1d48527c8039c087b5be820ec0a67691 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sat, 31 Jan 2026 20:02:49 +0100 Subject: [PATCH] iOS: fix node notify and identity --- apps/ios/Sources/Device/NodeDisplayName.swift | 48 +++++++ .../Gateway/GatewayConnectionController.swift | 19 ++- apps/ios/Sources/Model/NodeAppModel.swift | 119 +++++++++++++++--- apps/ios/Sources/Settings/SettingsTab.swift | 3 +- apps/ios/SwiftSources.input.xcfilelist | 1 + .../GatewayConnectionControllerTests.swift | 1 + apps/ios/Tests/NodeDisplayNameTests.swift | 34 +++++ .../OpenClawKit/GatewayNodeSession.swift | 64 ++++++++-- .../OpenClawKit/Resources/tool-display.json | 4 + src/agents/system-prompt.ts | 4 +- src/agents/tool-display.json | 4 + 11 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 apps/ios/Sources/Device/NodeDisplayName.swift create mode 100644 apps/ios/Tests/NodeDisplayNameTests.swift diff --git a/apps/ios/Sources/Device/NodeDisplayName.swift b/apps/ios/Sources/Device/NodeDisplayName.swift new file mode 100644 index 00000000000..9ddf38b24a7 --- /dev/null +++ b/apps/ios/Sources/Device/NodeDisplayName.swift @@ -0,0 +1,48 @@ +import Foundation +import UIKit + +enum NodeDisplayName { + private static let genericNames: Set = ["iOS Node", "iPhone Node", "iPad Node"] + + static func isGeneric(_ name: String) -> Bool { + Self.genericNames.contains(name) + } + + static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String { + switch interfaceIdiom { + case .phone: + return "iPhone Node" + case .pad: + return "iPad Node" + default: + return "iOS Node" + } + } + + static func resolve( + existing: String?, + deviceName: String, + interfaceIdiom: UIUserInterfaceIdiom + ) -> String { + let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) { + return trimmedExisting + } + + let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalized = Self.normalizedDeviceName(trimmedDevice) { + return normalized + } + + return Self.defaultValue(for: interfaceIdiom) + } + + private static func normalizedDeviceName(_ deviceName: String) -> String? { + guard !deviceName.isEmpty else { return nil } + let lower = deviceName.lowercased() + if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") { + return deviceName + } + return nil + } +} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 171c28dfe1c..25fd2ac2f89 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -301,17 +301,16 @@ final class GatewayConnectionController { private func resolvedDisplayName(defaults: UserDefaults) -> String { let key = "node.displayName" - let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !existing.isEmpty, existing != "iOS Node" { return existing } - - let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) - let candidate = deviceName.isEmpty ? "iOS Node" : deviceName - - if existing.isEmpty || existing == "iOS Node" { - defaults.set(candidate, forKey: key) + let existingRaw = defaults.string(forKey: key) + let resolved = NodeDisplayName.resolve( + existing: existingRaw, + deviceName: UIDevice.current.name, + interfaceIdiom: UIDevice.current.userInterfaceIdiom) + let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if existing.isEmpty || NodeDisplayName.isGeneric(existing) { + defaults.set(resolved, forKey: key) } - - return candidate + return resolved } private func currentCaps() -> [String] { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ef51f58790b..b8d2b46112c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -5,6 +5,36 @@ import SwiftUI import UIKit import UserNotifications +private struct NotificationCallError: Error, Sendable { + let message: String +} + +private final class NotificationInvokeLatch: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation, Never>? + private var resumed = false + + func setContinuation(_ continuation: CheckedContinuation, Never>) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func resume(_ response: Result) { + let cont: CheckedContinuation, Never>? + self.lock.lock() + if self.resumed { + self.lock.unlock() + return + } + self.resumed = true + cont = self.continuation + self.continuation = nil + self.lock.unlock() + cont?.resume(returning: response) + } +} + @MainActor @Observable final class NodeAppModel { @@ -139,7 +169,10 @@ final class NodeAppModel { return raw.isEmpty ? "-" : raw }() - let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name + let host = NodeDisplayName.resolve( + existing: UserDefaults.standard.string(forKey: "node.displayName"), + deviceName: UIDevice.current.name, + interfaceIdiom: UIDevice.current.userInterfaceIdiom) let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) let sessionKey = self.mainSessionKey @@ -920,11 +953,7 @@ final class NodeAppModel { error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification")) } - let status = await self.notificationCenter.authorizationStatus() - if status == .notDetermined { - _ = try await self.notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) - } - let finalStatus = await self.notificationCenter.authorizationStatus() + let finalStatus = await self.requestNotificationAuthorizationIfNeeded() guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else { return BridgeInvokeResponse( id: req.id, @@ -932,17 +961,79 @@ final class NodeAppModel { error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications")) } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil) - try await self.notificationCenter.add(request) + let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let content = UNMutableNotificationContent() + content.title = title + content.body = body + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil) + try await notificationCenter.add(request) + } + if case let .failure(error) = addResult { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) + } return BridgeInvokeResponse(id: req.id, ok: true) } + private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus { + let status = await self.notificationAuthorizationStatus() + guard status == .notDetermined else { return status } + + // Avoid hanging invoke requests if the permission prompt is never answered. + _ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) + } + + return await self.notificationAuthorizationStatus() + } + + private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { + let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in + await notificationCenter.authorizationStatus() + } + switch result { + case let .success(status): + return status + case .failure: + return .denied + } + } + + private func runNotificationCall( + timeoutSeconds: Double, + operation: @escaping @Sendable () async throws -> T + ) async -> Result { + let latch = NotificationInvokeLatch() + var opTask: Task? + var timeoutTask: Task? + let result = await withCheckedContinuation { (cont: CheckedContinuation, Never>) in + latch.setContinuation(cont) + opTask = Task { @MainActor in + do { + let value = try await operation() + latch.resume(.success(value)) + } catch { + latch.resume(.failure(NotificationCallError(message: error.localizedDescription))) + } + } + timeoutTask = Task.detached { + let clamped = max(0.0, timeoutSeconds) + if clamped > 0 { + try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) + } + latch.resume(.failure(NotificationCallError(message: "notification request timed out"))) + } + } + opTask?.cancel() + timeoutTask?.cancel() + return result + } + private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { switch req.command { case OpenClawDeviceCommand.status.rawValue: diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index c1ee6099480..f5c808e2751 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -17,7 +17,8 @@ struct SettingsTab: View { @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController @Environment(\.dismiss) private var dismiss - @AppStorage("node.displayName") private var displayName: String = "iOS Node" + @AppStorage("node.displayName") private var displayName: String = NodeDisplayName.defaultValue( + for: UIDevice.current.userInterfaceIdiom) @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 81e869e2c64..759da0da3b7 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -8,6 +8,7 @@ Sources/Chat/ChatSheet.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/Contacts/ContactsService.swift Sources/Device/DeviceStatusService.swift +Sources/Device/NodeDisplayName.swift Sources/Device/NetworkStatusService.swift Sources/OpenClawApp.swift Sources/Location/LocationService.swift diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 3941d581984..44c82d1372a 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -40,6 +40,7 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> let resolved = controller._test_resolvedDisplayName(defaults: defaults) #expect(!resolved.isEmpty) + #expect(resolved != "iOS Node") #expect(defaults.string(forKey: displayKey) == resolved) } } diff --git a/apps/ios/Tests/NodeDisplayNameTests.swift b/apps/ios/Tests/NodeDisplayNameTests.swift new file mode 100644 index 00000000000..06623b56433 --- /dev/null +++ b/apps/ios/Tests/NodeDisplayNameTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import OpenClaw + +struct NodeDisplayNameTests { + @Test func keepsCustomName() { + let resolved = NodeDisplayName.resolve( + existing: "Razor Phone", + deviceName: "iPhone", + interfaceIdiom: .phone) + #expect(resolved == "Razor Phone") + } + + @Test func usesDeviceNameWhenMatchesIphone() { + let resolved = NodeDisplayName.resolve( + existing: "iOS Node", + deviceName: "iPhone 17 Pro", + interfaceIdiom: .phone) + #expect(resolved == "iPhone 17 Pro") + } + + @Test func usesDefaultWhenDeviceNameIsGeneric() { + let resolved = NodeDisplayName.resolve( + existing: nil, + deviceName: "Work Phone", + interfaceIdiom: .phone) + #expect(NodeDisplayName.isGeneric(resolved)) + } + + @Test func identifiesGenericValues() { + #expect(NodeDisplayName.isGeneric("iOS Node")) + #expect(NodeDisplayName.isGeneric("iPhone Node")) + #expect(NodeDisplayName.isGeneric("iPad Node")) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index e64e279d21a..217e795a052 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -16,6 +16,7 @@ public actor GatewayNodeSession { private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") private let decoder = JSONDecoder() private let encoder = JSONEncoder() + private static let defaultInvokeTimeoutMs = 30_000 private var channel: GatewayChannelActor? private var activeURL: URL? private var activeToken: String? @@ -33,28 +34,66 @@ public actor GatewayNodeSession { timeoutMs: Int?, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse ) async -> BridgeInvokeResponse { - let timeout = max(0, timeoutMs ?? 0) + let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway") + let timeout: Int = { + if let timeoutMs { return max(0, timeoutMs) } + return Self.defaultInvokeTimeoutMs + }() guard timeout > 0 else { return await onInvoke(request) } - return await withTaskGroup(of: BridgeInvokeResponse.self) { group in - group.addTask { await onInvoke(request) } - group.addTask { + final class InvokeLatch: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var resumed = false + + func setContinuation(_ continuation: CheckedContinuation) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func resume(_ response: BridgeInvokeResponse) { + let cont: CheckedContinuation? + self.lock.lock() + if self.resumed { + self.lock.unlock() + return + } + self.resumed = true + cont = self.continuation + self.continuation = nil + self.lock.unlock() + cont?.resume(returning: response) + } + } + + let latch = InvokeLatch() + var onInvokeTask: Task? + var timeoutTask: Task? + let response = await withCheckedContinuation { (cont: CheckedContinuation) in + latch.setContinuation(cont) + onInvokeTask = Task.detached { + let result = await onInvoke(request) + latch.resume(result) + } + timeoutTask = Task.detached { try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000) - return BridgeInvokeResponse( + timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)") + latch.resume(BridgeInvokeResponse( id: request.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "node invoke timed out") - ) + )) } - - let first = await group.next()! - group.cancelAll() - return first } + onInvokeTask?.cancel() + timeoutTask?.cancel() + timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + return response } private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] private var canvasHostUrl: String? @@ -255,14 +294,17 @@ public actor GatewayNodeSession { guard let payload = evt.payload else { return } do { let request = try self.decodeInvokeRequest(from: payload) - self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public)") + let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" + self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") guard let onInvoke else { return } let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) + self.logger.info("node invoke executing id=\(request.id, privacy: .public)") let response = await Self.invokeWithTimeout( request: req, timeoutMs: request.timeoutMs, onInvoke: onInvoke ) + self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") await self.sendInvokeResult(request: request, response: response) } catch { self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 9c0e57fc6ae..78148cd6c4e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -123,6 +123,10 @@ "screen_record": { "label": "screen record", "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] + }, + "invoke": { + "label": "invoke", + "detailKeys": ["node", "nodeId", "invokeCommand"] } } }, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index b1fde37325e..67e353e21d0 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -229,7 +229,7 @@ export function buildAgentSystemPrompt(params: { // Channel docking: add login tools here when a channel needs interactive linking. browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", - nodes: "List/describe/notify/camera/screen on paired nodes", + nodes: "List/describe/notify/camera/screen/invoke on paired nodes", cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running OpenClaw process", @@ -382,7 +382,7 @@ export function buildAgentSystemPrompt(params: { `- ${processToolName}: manage background exec sessions`, "- browser: control openclaw's dedicated browser", "- canvas: present/eval/snapshot the Canvas", - "- nodes: list/describe/notify/camera/screen on paired nodes", + "- nodes: list/describe/notify/camera/screen/invoke on paired nodes", "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", "- sessions_list: list sessions", "- sessions_history: fetch session history", diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 3fea81405ef..625d6046bac 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -140,6 +140,10 @@ "screen_record": { "label": "screen record", "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] + }, + "invoke": { + "label": "invoke", + "detailKeys": ["node", "nodeId", "invokeCommand"] } } },