Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

### Provisional iPhone Duo support

- Add `Device.iPhoneDuo` with the A20 Pro chip and `isFoldable`, true only for iPhone Duo and its simulator.
- Recognize the `iPhone19,4` hardware identifier and omit the image. Display metrics remain unknown (`-1` for diagonal/ratio, `nil` for PPI); other unconfirmed capabilities use false/empty defaults.

### Bug fixes

- Make `isSimulator` return `true` for simulators of unknown devices, so detecting a simulator does not require support for its model.

## Version 5.9.0

Releasedate: 2026-09-22
Expand Down
31 changes: 28 additions & 3 deletions Source/Device.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ public enum Device {
case iPhone18Pro
/// Device is an [iPhone 18 Pro Max](https://support.apple.com/en-us/148591)
case iPhone18ProMax
/// Provisional iPhone Duo; display metrics and other capabilities await verification.
case iPhoneDuo
/// Device is an [iPad 2](https://support.apple.com/kb/SP622)
///
/// ![Image](https://support.apple.com/library/APPLE/APPLECARE_ALLGEOS/SP622/SP622_01-ipad2-mul.png)
Expand Down Expand Up @@ -638,6 +640,7 @@ public enum Device {
case "iPhone18,5": return iPhone17e
case "iPhone19,2": return iPhone18Pro
case "iPhone19,3", "iPhone19,7": return iPhone18ProMax
case "iPhone19,4": return iPhoneDuo
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return iPad2
case "iPad3,1", "iPad3,2", "iPad3,3": return iPad3
case "iPad3,4", "iPad3,5", "iPad3,6": return iPad4
Expand Down Expand Up @@ -744,6 +747,19 @@ public enum Device {
#endif
}

/// Returns whether the device has a foldable display, including simulators.
public var isFoldable: Bool {
#if os(iOS)
switch self {
case .iPhoneDuo: return true
case .simulator(let model): return model.isFoldable
default: return false
}
#else
return false
#endif
}

/// Get the real device from a device.
/// If the device is a an iPhone8Plus simulator this function returns .iPhone8Plus (the real device).
/// If the parameter is a real device, this function returns just that passed parameter.
Expand Down Expand Up @@ -818,6 +834,7 @@ public enum Device {
case .iPhone17e: return 6.1
case .iPhone18Pro: return 6.3
case .iPhone18ProMax: return 6.9
case .iPhoneDuo: return -1
case .iPad2: return 9.7
case .iPad3: return 9.7
case .iPad4: return 9.7
Expand Down Expand Up @@ -969,6 +986,7 @@ public enum Device {
case .iPhone17e: return (width: 9, height: 19.5)
case .iPhone18Pro: return (width: 9, height: 19.5)
case .iPhone18ProMax: return (width: 9, height: 19.5)
case .iPhoneDuo: return (width: -1, height: -1)
case .iPad2: return (width: 3, height: 4)
case .iPad3: return (width: 3, height: 4)
case .iPad4: return (width: 3, height: 4)
Expand Down Expand Up @@ -1073,7 +1091,7 @@ public enum Device {

/// All iPhones
public static var allPhones: [Device] {
return [.iPhone4, .iPhone4s, .iPhone5, .iPhone5c, .iPhone5s, .iPhone6, .iPhone6Plus, .iPhone6s, .iPhone6sPlus, .iPhone7, .iPhone7Plus, .iPhoneSE, .iPhone8, .iPhone8Plus, .iPhoneX, .iPhoneXS, .iPhoneXSMax, .iPhoneXR, .iPhone11, .iPhone11Pro, .iPhone11ProMax, .iPhoneSE2, .iPhone12, .iPhone12Mini, .iPhone12Pro, .iPhone12ProMax, .iPhone13, .iPhone13Mini, .iPhone13Pro, .iPhone13ProMax, .iPhoneSE3, .iPhone14, .iPhone14Plus, .iPhone14Pro, .iPhone14ProMax, .iPhone15, .iPhone15Plus, .iPhone15Pro, .iPhone15ProMax, .iPhone16, .iPhone16Plus, .iPhone16Pro, .iPhone16ProMax, .iPhone16e, .iPhone17, .iPhone17Pro, .iPhone17ProMax, .iPhoneAir, .iPhone17e, .iPhone18Pro, .iPhone18ProMax]
return [.iPhone4, .iPhone4s, .iPhone5, .iPhone5c, .iPhone5s, .iPhone6, .iPhone6Plus, .iPhone6s, .iPhone6sPlus, .iPhone7, .iPhone7Plus, .iPhoneSE, .iPhone8, .iPhone8Plus, .iPhoneX, .iPhoneXS, .iPhoneXSMax, .iPhoneXR, .iPhone11, .iPhone11Pro, .iPhone11ProMax, .iPhoneSE2, .iPhone12, .iPhone12Mini, .iPhone12Pro, .iPhone12ProMax, .iPhone13, .iPhone13Mini, .iPhone13Pro, .iPhone13ProMax, .iPhoneSE3, .iPhone14, .iPhone14Plus, .iPhone14Pro, .iPhone14ProMax, .iPhone15, .iPhone15Plus, .iPhone15Pro, .iPhone15ProMax, .iPhone16, .iPhone16Plus, .iPhone16Pro, .iPhone16ProMax, .iPhone16e, .iPhone17, .iPhone17Pro, .iPhone17ProMax, .iPhoneAir, .iPhone17e, .iPhone18Pro, .iPhone18ProMax, .iPhoneDuo]
}

/// All iPads
Expand Down Expand Up @@ -1379,10 +1397,13 @@ public enum Device {
return allRealDevices.map(Device.simulator)
}

/// Returns whether the device is any of the simulator
/// Returns whether the device is a simulator, including simulators of unknown devices.
/// Useful when there is a need to check and skip running a portion of code (location request or others)
public var isSimulator: Bool {
return isOneOf(Device.allSimulators)
if case .simulator = self {
return true
}
return false
}

/**
Expand Down Expand Up @@ -1551,6 +1572,7 @@ public enum Device {
case .iPhone17e: return 460
case .iPhone18Pro: return 460
case .iPhone18ProMax: return 460
case .iPhoneDuo: return nil
case .iPad2: return 132
case .iPad3: return 264
case .iPad4: return 264
Expand Down Expand Up @@ -1734,6 +1756,7 @@ extension Device: CustomStringConvertible {
case .iPhone17e: return "iPhone 17e"
case .iPhone18Pro: return "iPhone 18 Pro"
case .iPhone18ProMax: return "iPhone 18 Pro Max"
case .iPhoneDuo: return "iPhone Duo"
case .iPad2: return "iPad 2"
case .iPad3: return "iPad (3rd generation)"
case .iPad4: return "iPad (4th generation)"
Expand Down Expand Up @@ -1904,6 +1927,7 @@ extension Device: CustomStringConvertible {
case .iPhone17e: return "iPhone 17e"
case .iPhone18Pro: return "iPhone 18 Pro"
case .iPhone18ProMax: return "iPhone 18 Pro Max"
case .iPhoneDuo: return "iPhone Duo"
case .iPad2: return "iPad 2"
case .iPad3: return "iPad (3rd generation)"
case .iPad4: return "iPad (4th generation)"
Expand Down Expand Up @@ -2634,6 +2658,7 @@ extension Device {
case .iPhone17e: return .a19
case .iPhone18Pro: return .a20Pro
case .iPhone18ProMax: return .a20Pro
case .iPhoneDuo: return .a20Pro
case .iPad2: return .a5
case .iPad3: return .a5X
case .iPad4: return .a6X
Expand Down
30 changes: 28 additions & 2 deletions Source/Device.swift.gyb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Device:
has5gSupport=False,
hasEsimSupport=False,
hasDualEsimSupport=False,
isFoldable=False,
):
self.caseName = caseName
self.comment = comment
Expand Down Expand Up @@ -61,6 +62,7 @@ class Device:
self.has5gSupport = has5gSupport
self.hasEsimSupport = hasEsimSupport
self.hasDualEsimSupport = hasDualEsimSupport
self.isFoldable = isFoldable

# iOS
iPods = [
Expand Down Expand Up @@ -284,6 +286,10 @@ iPhones = [
hasDynamicIsland=True, cameras=123, hasLidarSensor=True, hasUSBCConnectivity=True,
has5gSupport=True,
hasEsimSupport=True, hasDualEsimSupport=True),

# Duo capabilities other than its chip and foldability are unconfirmed (false/empty defaults).
Device("iPhoneDuo", "Provisional iPhone Duo; display metrics and other capabilities await verification.", "", ["iPhone19,4"], -1, (-1, -1), "iPhone Duo", "iPhone Duo", "nil",
cpu="a20Pro", isFoldable=True),
]

iPads = [
Expand Down Expand Up @@ -828,6 +834,23 @@ public enum Device {
#endif
}

/// Returns whether the device has a foldable display, including simulators.
public var isFoldable: Bool {
#if os(iOS)
switch self {
% for device in iOSDevices:
% if device.isFoldable:
case .${device.caseName}: return true
% end
% end
case .simulator(let model): return model.isFoldable
default: return false
}
#else
return false
#endif
}

/// Get the real device from a device.
/// If the device is a an iPhone8Plus simulator this function returns .iPhone8Plus (the real device).
/// If the parameter is a real device, this function returns just that passed parameter.
Expand Down Expand Up @@ -1205,10 +1228,13 @@ public enum Device {
return allRealDevices.map(Device.simulator)
}

/// Returns whether the device is any of the simulator
/// Returns whether the device is a simulator, including simulators of unknown devices.
/// Useful when there is a need to check and skip running a portion of code (location request or others)
public var isSimulator: Bool {
return isOneOf(Device.allSimulators)
if case .simulator = self {
return true
}
return false
}

/**
Expand Down
71 changes: 69 additions & 2 deletions Tests/Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,43 @@ class DeviceKitTests: XCTestCase {

let device = Device.current

func testIsFoldable() {
for model in Device.allRealDevices {
#if os(iOS)
let expected = model == .iPhoneDuo
#else
let expected = false
#endif
XCTAssertEqual(model.isFoldable, expected, model.description)
XCTAssertEqual(Device.simulator(model).isFoldable, expected, model.description)
}
XCTAssertFalse(Device.unknown("unrecognized").isFoldable)
XCTAssertFalse(Device.simulator(.unknown("unrecognized")).isFoldable)
}

#if os(iOS)
func testProvisionalIPhoneDuo() {
let model = Device.iPhoneDuo
XCTAssertEqual(Device.mapToDevice(identifier: "iPhone19,4"), model)
XCTAssertEqual(Device.mapToDevice(identifier: "placeholder:iPhoneDuo"), .unknown("placeholder:iPhoneDuo"))
XCTAssertEqual(model.description, "iPhone Duo")
XCTAssertEqual(model.safeDescription, "iPhone Duo")
XCTAssertTrue(Device.allPhones.contains(model))
XCTAssertTrue(Device.allRealDevices.contains(model))
XCTAssertTrue(Device.allSimulators.contains(.simulator(model)))
for variant in [model, .simulator(model)] {
XCTAssertTrue(variant.isPhone)
XCTAssertFalse(variant.isPad)
XCTAssertTrue(variant.isFoldable)
XCTAssertEqual(variant.cpu, .a20Pro)
XCTAssertEqual(variant.diagonal, -1)
XCTAssertEqual(variant.screenRatio.width, -1)
XCTAssertEqual(variant.screenRatio.height, -1)
XCTAssertNil(variant.ppi)
}
}
#endif

#if os(iOS) || os(watchOS)
func testSeptember2026Devices() {
struct ExpectedDevice {

@github-actions github-actions Bot Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Types should be nested at most 1 level deep
nesting Tests.swift:58

Expand All @@ -32,8 +69,8 @@ class DeviceKitTests: XCTestCase {
]
let chip = Device.CPU.a20Pro
XCTAssertEqual(chip.description, "A20 Pro")
// The deferred model and unverified identifiers must remain unknown.
for identifier in ["iPhone19,1", "iPhone19,4", "iPhone19,5", "iPhone19,6", "placeholder:iPhone18Pro", "placeholder:iPhone18ProMax"] {
// Unverified identifiers must remain unknown.
for identifier in ["iPhone19,1", "iPhone19,5", "iPhone19,6", "placeholder:iPhone18Pro", "placeholder:iPhone18ProMax"] {
XCTAssertEqual(Device.mapToDevice(identifier: identifier), .unknown(identifier))
}
#else
Expand Down Expand Up @@ -91,6 +128,36 @@ class DeviceKitTests: XCTestCase {
#endif
}

func testIsSimulatorForKnownAndUnknownDevices() {
for model in Device.allRealDevices + [.unknown("unrecognized")] {
XCTAssertFalse(model.isSimulator, model.description)
XCTAssertTrue(Device.simulator(model).isSimulator, model.description)
}
}

#if os(iOS) && targetEnvironment(simulator)
func testCurrentSimulatorWithKnownAndUnknownModels() {
let originalModelIdentifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"]
defer {
if let originalModelIdentifier = originalModelIdentifier {
setenv("SIMULATOR_MODEL_IDENTIFIER", originalModelIdentifier, 1)
} else {
unsetenv("SIMULATOR_MODEL_IDENTIFIER")
}
}

let models: [(String, Device)] = [
("iPhone19,4", .iPhoneDuo),
("iPhone999,1", .unknown("iPhone999,1"))
]
for (identifier, model) in models {
setenv("SIMULATOR_MODEL_IDENTIFIER", identifier, 1)
XCTAssertEqual(Device.current, .simulator(model), identifier)
XCTAssertTrue(Device.current.isSimulator, identifier)
}
}
#endif

func testDeviceDescription() {
#if os(macOS)
#else
Expand Down
Loading