mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 06:27:27 +00:00
iOS: fix node notify and identity
This commit is contained in:
committed by
Mariano Belinky
parent
d9cadf9737
commit
761188cd1d
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal file
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
34
apps/ios/Tests/NodeDisplayNameTests.swift
Normal file
34
apps/ios/Tests/NodeDisplayNameTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user