iOS: fix node notify and identity

This commit is contained in:
Mariano Belinky
2026-01-31 20:02:49 +01:00
committed by Mariano Belinky
parent d9cadf9737
commit 761188cd1d
11 changed files with 263 additions and 38 deletions

View File

@@ -0,0 +1,48 @@
import Foundation
import UIKit
enum NodeDisplayName {
private static let genericNames: Set<String> = ["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
}
}

View File

@@ -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] {

View File

@@ -5,6 +5,36 @@ import SwiftUI
import UIKit
import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
private var resumed = false
func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
self.lock.lock()
defer { self.lock.unlock() }
self.continuation = continuation
}
func resume(_ response: Result<T, NotificationCallError>) {
let cont: CheckedContinuation<Result<T, NotificationCallError>, 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<T: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T
) async -> Result<T, NotificationCallError> {
let latch = NotificationInvokeLatch<T>()
var opTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>?
let result = await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, 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:

View File

@@ -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

View File

@@ -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

View File

@@ -40,6 +40,7 @@ private func withUserDefaults<T>(_ 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)
}
}

View File

@@ -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"))
}
}

View File

@@ -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<BridgeInvokeResponse, Never>?
private var resumed = false
func setContinuation(_ continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
self.lock.lock()
defer { self.lock.unlock() }
self.continuation = continuation
}
func resume(_ response: BridgeInvokeResponse) {
let cont: CheckedContinuation<BridgeInvokeResponse, 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)
}
}
let latch = InvokeLatch()
var onInvokeTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>?
let response = await withCheckedContinuation { (cont: CheckedContinuation<BridgeInvokeResponse, Never>) 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<EventFrame>.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)")

View File

@@ -123,6 +123,10 @@
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
},
"invoke": {
"label": "invoke",
"detailKeys": ["node", "nodeId", "invokeCommand"]
}
}
},

View File

@@ -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",

View File

@@ -140,6 +140,10 @@
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
},
"invoke": {
"label": "invoke",
"detailKeys": ["node", "nodeId", "invokeCommand"]
}
}
},