From 33044992c189e69b4bcbb3d7c79af61611dcade6 Mon Sep 17 00:00:00 2001 From: metaforismo <39646696+metaforismo@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:25:59 +0200 Subject: [PATCH] fix(ios): avoid phantom iPad nodes on Apple Silicon Macs (#112744) --- apps/.i18n/native-source.json | 2 +- .../ios/Sources/Device/DeviceInfoHelper.swift | 36 +----- apps/ios/Sources/Device/NodeDisplayName.swift | 28 ++++- apps/ios/Tests/DeviceInfoHelperTests.swift | 80 +++++++++++++ .../OpenClawKit/InstanceIdentity.swift | 108 ++++++++++++++---- .../InstanceIdentityTests.swift | 55 +++++++++ 6 files changed, 249 insertions(+), 60 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/InstanceIdentityTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 77ba87108a87..3cbe8b3ea31f 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -41835,7 +41835,7 @@ }, { "kind": "conditional-branch", - "line": 52, + "line": 116, "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift", "source": "Apple Watch", "surface": "apple", diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift index ab976eabcf86..0d2fac88ea52 100644 --- a/apps/ios/Sources/Device/DeviceInfoHelper.swift +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -1,23 +1,12 @@ -import Darwin import Foundation import OpenClawKit -import UIKit /// Shared device and platform info for Settings, gateway node payloads, and device status. enum DeviceInfoHelper { - /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. + /// Gateway platform metadata, including compatibility-app handling on Apple Silicon Macs. @MainActor static func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - let name = switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPadOS" - case .phone: - "iOS" - default: - "iOS" - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + InstanceIdentity.platformString } /// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad. @@ -34,28 +23,15 @@ enum DeviceInfoHelper { "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" } - /// Device family for display: "iPad", "iPhone", or "iOS". + /// Device family for gateway payloads: "iPad", "iPhone", or "iOS". @MainActor static func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } + InstanceIdentity.deviceFamily } - /// Machine model identifier from uname (e.g. "iPhone17,1"). + /// Machine model identifier, or a compatibility-host description when running on a Mac. static func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed + InstanceIdentity.modelIdentifier ?? "unknown" } /// Canonical app version when present, otherwise the Apple marketing version. diff --git a/apps/ios/Sources/Device/NodeDisplayName.swift b/apps/ios/Sources/Device/NodeDisplayName.swift index 5d9410342794..0fddb139637c 100644 --- a/apps/ios/Sources/Device/NodeDisplayName.swift +++ b/apps/ios/Sources/Device/NodeDisplayName.swift @@ -2,14 +2,27 @@ import Foundation import UIKit enum NodeDisplayName { - private static let genericNames: Set = ["iOS Node", "iPhone Node", "iPad Node"] + private static let genericNames: Set = [ + "iOS Node", + "iPhone", + "iPhone Node", + "iPad", + "iPad Node", + "OpenClaw Mac App", + ] static func isGeneric(_ name: String) -> Bool { self.genericNames.contains(name) } - static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String { - switch interfaceIdiom { + static func defaultValue( + for interfaceIdiom: UIUserInterfaceIdiom, + isIOSAppOnMac: Bool = ProcessInfo.processInfo.isiOSAppOnMac) -> String + { + if isIOSAppOnMac { + return "OpenClaw Mac App" + } + return switch interfaceIdiom { case .phone: "iPhone Node" case .pad: @@ -22,19 +35,24 @@ enum NodeDisplayName { static func resolve( existing: String?, deviceName: String, - interfaceIdiom: UIUserInterfaceIdiom) -> String + interfaceIdiom: UIUserInterfaceIdiom, + isIOSAppOnMac: Bool = ProcessInfo.processInfo.isiOSAppOnMac) -> String { let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) { return trimmedExisting } + if isIOSAppOnMac { + return Self.defaultValue(for: interfaceIdiom, isIOSAppOnMac: true) + } + let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) if let normalized = Self.normalizedDeviceName(trimmedDevice) { return normalized } - return Self.defaultValue(for: interfaceIdiom) + return Self.defaultValue(for: interfaceIdiom, isIOSAppOnMac: false) } private static func normalizedDeviceName(_ deviceName: String) -> String? { diff --git a/apps/ios/Tests/DeviceInfoHelperTests.swift b/apps/ios/Tests/DeviceInfoHelperTests.swift index ffbcdbbc7b0e..eb8df0f25c5f 100644 --- a/apps/ios/Tests/DeviceInfoHelperTests.swift +++ b/apps/ios/Tests/DeviceInfoHelperTests.swift @@ -18,4 +18,84 @@ struct DeviceInfoHelperTests { #expect(metadata.versionDisplay == "2026.7.10 (42)") } + + @Test func `iOS app on Mac replaces generic iPad node name`() { + let name = NodeDisplayName.resolve( + existing: "iPad Node", + deviceName: "Studio Mac", + interfaceIdiom: .pad, + isIOSAppOnMac: true) + + #expect(name == "OpenClaw Mac App") + } + + @Test func `iOS app on Mac replaces persisted generic iPad device name`() { + let name = NodeDisplayName.resolve( + existing: "iPad", + deviceName: "iPad", + interfaceIdiom: .pad, + isIOSAppOnMac: true) + + #expect(name == "OpenClaw Mac App") + } + + @Test func `iOS app on Mac replaces persisted generic iPhone device name`() { + let name = NodeDisplayName.resolve( + existing: "iPhone", + deviceName: "iPhone", + interfaceIdiom: .phone, + isIOSAppOnMac: true) + + #expect(name == "OpenClaw Mac App") + } + + @Test func `iOS app on Mac preserves a custom node name`() { + let name = NodeDisplayName.resolve( + existing: "Studio controller", + deviceName: "Studio Mac", + interfaceIdiom: .pad, + isIOSAppOnMac: true) + + #expect(name == "Studio controller") + } + + @Test func `physical iPad keeps its device name`() { + let name = NodeDisplayName.resolve( + existing: "iPad Node", + deviceName: "Office iPad", + interfaceIdiom: .pad, + isIOSAppOnMac: false) + + #expect(name == "Office iPad") + } + + @Test func `physical iPad keeps its generic device name`() { + let name = NodeDisplayName.resolve( + existing: "iPad", + deviceName: "iPad", + interfaceIdiom: .pad, + isIOSAppOnMac: false) + + #expect(name == "iPad") + } + + @Test func `physical iPhone replaces stale Mac compatibility default`() { + let name = NodeDisplayName.resolve( + existing: "OpenClaw Mac App", + deviceName: "QA iPhone", + interfaceIdiom: .phone, + isIOSAppOnMac: false) + + #expect(name == "QA iPhone") + } + + @Test func `blank device name falls back to physical device family`() { + let name = NodeDisplayName.resolve( + existing: nil, + deviceName: "", + interfaceIdiom: .phone, + isIOSAppOnMac: false) + + #expect(name == "iPhone Node") + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift index a7ab072c9c5f..b3e7c0be9542 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift @@ -6,6 +6,50 @@ import UIKit import WatchKit #endif +enum AppleMobileInterfaceIdiom: Sendable { + case phone + case pad + case other +} + +struct AppleMobileInstanceMetadata: Equatable, Sendable { + let platformString: String + let deviceFamily: String + let modelIdentifier: String? + + static func resolve( + version: OperatingSystemVersion, + interfaceIdiom: AppleMobileInterfaceIdiom, + isIOSAppOnMac: Bool, + rawModelIdentifier: String?) -> Self + { + let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + let trimmedModel = rawModelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + + if isIOSAppOnMac { + // Keep the iOS protocol family so the gateway applies the mobile + // command policy, while making the compatibility host explicit. + return Self( + platformString: "iOS \(versionString)", + deviceFamily: "iOS", + modelIdentifier: "Apple Silicon Mac") + } + + let identity = switch interfaceIdiom { + case .phone: + (platform: "iOS", family: "iPhone") + case .pad: + (platform: "iPadOS", family: "iPad") + case .other: + (platform: "iOS", family: "iOS") + } + return Self( + platformString: "\(identity.platform) \(versionString)", + deviceFamily: identity.family, + modelIdentifier: trimmedModel?.isEmpty == false ? trimmedModel : nil) + } +} + public enum InstanceIdentity { private static let suiteName = "ai.openclaw.shared" private static let instanceIdKey = "instanceId" @@ -25,6 +69,23 @@ public enum InstanceIdentity { } #endif + #if os(iOS) + private static let appleMobileMetadata: AppleMobileInstanceMetadata = { + let interfaceIdiom = Self.readMainActor { + switch UIDevice.current.userInterfaceIdiom { + case .phone: AppleMobileInterfaceIdiom.phone + case .pad: AppleMobileInterfaceIdiom.pad + default: AppleMobileInterfaceIdiom.other + } + } + return AppleMobileInstanceMetadata.resolve( + version: ProcessInfo.processInfo.operatingSystemVersion, + interfaceIdiom: interfaceIdiom, + isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac, + rawModelIdentifier: Self.mobileMachineIdentifier()) + }() + #endif + public static let instanceId: String = { let defaults = Self.defaults if let existing = defaults.string(forKey: instanceIdKey)? @@ -41,6 +102,9 @@ public enum InstanceIdentity { public static let displayName: String = { #if os(iOS) + if ProcessInfo.processInfo.isiOSAppOnMac { + return "OpenClaw Mac App" + } let name = Self.readMainActor { UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -61,14 +125,10 @@ public enum InstanceIdentity { }() public static let modelIdentifier: String? = { - #if os(iOS) || os(watchOS) - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed + #if os(iOS) + return Self.appleMobileMetadata.modelIdentifier + #elseif os(watchOS) + return Self.mobileMachineIdentifier() #else var size = 0 guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } @@ -83,15 +143,21 @@ public enum InstanceIdentity { #endif }() + #if os(iOS) || os(watchOS) + private static func mobileMachineIdentifier() -> String? { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + #endif + public static let deviceFamily: String = { #if os(iOS) - return Self.readMainActor { - switch UIDevice.current.userInterfaceIdiom { - case .pad: "iPad" - case .phone: "iPhone" - default: "iOS" - } - } + return Self.appleMobileMetadata.deviceFamily #elseif os(watchOS) return "Apple Watch" #else @@ -100,19 +166,13 @@ public enum InstanceIdentity { }() public static let platformString: String = { - let v = ProcessInfo.processInfo.operatingSystemVersion #if os(iOS) - let name = Self.readMainActor { - switch UIDevice.current.userInterfaceIdiom { - case .pad: "iPadOS" - case .phone: "iOS" - default: "iOS" - } - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + return Self.appleMobileMetadata.platformString #elseif os(watchOS) + let v = ProcessInfo.processInfo.operatingSystemVersion return "watchOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" #else + let v = ProcessInfo.processInfo.operatingSystemVersion return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" #endif }() diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/InstanceIdentityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/InstanceIdentityTests.swift new file mode 100644 index 000000000000..3a9fdd1df4db --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/InstanceIdentityTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import OpenClawKit + +struct InstanceIdentityTests { + private let version = OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0) + + @Test func `iOS app on Mac avoids physical iPad identity`() { + let metadata = AppleMobileInstanceMetadata.resolve( + version: self.version, + interfaceIdiom: .pad, + isIOSAppOnMac: true, + rawModelIdentifier: "iPad16,6") + + #expect(metadata.platformString == "iOS 26.5.0") + #expect(metadata.deviceFamily == "iOS") + #expect(metadata.modelIdentifier == "Apple Silicon Mac") + } + + @Test func `physical iPad keeps iPad metadata`() { + let metadata = AppleMobileInstanceMetadata.resolve( + version: self.version, + interfaceIdiom: .pad, + isIOSAppOnMac: false, + rawModelIdentifier: " iPad16,6 ") + + #expect(metadata.platformString == "iPadOS 26.5.0") + #expect(metadata.deviceFamily == "iPad") + #expect(metadata.modelIdentifier == "iPad16,6") + } + + @Test func `physical iPhone keeps iPhone metadata`() { + let metadata = AppleMobileInstanceMetadata.resolve( + version: self.version, + interfaceIdiom: .phone, + isIOSAppOnMac: false, + rawModelIdentifier: "iPhone17,1") + + #expect(metadata.platformString == "iOS 26.5.0") + #expect(metadata.deviceFamily == "iPhone") + #expect(metadata.modelIdentifier == "iPhone17,1") + } + + @Test func `blank physical model identifier is omitted`() { + let metadata = AppleMobileInstanceMetadata.resolve( + version: self.version, + interfaceIdiom: .other, + isIOSAppOnMac: false, + rawModelIdentifier: " \n ") + + #expect(metadata.platformString == "iOS 26.5.0") + #expect(metadata.deviceFamily == "iOS") + #expect(metadata.modelIdentifier == nil) + } +}