mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:01:34 +00:00
fix(ios): avoid phantom iPad nodes on Apple Silicon Macs (#112744)
This commit is contained in:
@@ -41835,7 +41835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 52,
|
||||
"line": 116,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift",
|
||||
"source": "Apple Watch",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user