fix(ios): avoid phantom iPad nodes on Apple Silicon Macs (#112744)

This commit is contained in:
metaforismo
2026-07-23 14:25:59 +02:00
committed by GitHub
parent 28971b4143
commit 33044992c1
6 changed files with 249 additions and 60 deletions

View File

@@ -41835,7 +41835,7 @@
},
{
"kind": "conditional-branch",
"line": 52,
"line": 116,
"path": "apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift",
"source": "Apple Watch",
"surface": "apple",

View File

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

View File

@@ -2,14 +2,27 @@ import Foundation
import UIKit
enum NodeDisplayName {
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
private static let genericNames: Set<String> = [
"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? {

View File

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

View File

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

View File

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