Skip to content

Commit 4d87946

Browse files
authored
feat: add support for device previews (tuist#6800)
* feat: add support for device previews * Address PR feedback
1 parent aa96b38 commit 4d87946

58 files changed

Lines changed: 2255 additions & 426 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Package.resolved

Lines changed: 15 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ let targets: [Target] = [
187187
"TuistSupport",
188188
"Mockable",
189189
"FileSystem",
190+
"Command",
190191
],
191192
swiftSettings: [
192193
.define("MOCKING", .when(configuration: .debug)),
@@ -359,7 +360,7 @@ let targets: [Target] = [
359360

360361
let package = Package(
361362
name: "tuist",
362-
platforms: [.macOS(.v12)],
363+
platforms: [.macOS(.v13)],
363364
products: [
364365
.executable(name: "tuistbenchmark", targets: ["tuistbenchmark"]),
365366
.executable(name: "tuistfixturegenerator", targets: ["tuistfixturegenerator"]),
@@ -465,7 +466,7 @@ let package = Package(
465466
.package(url: "https://github.com/tuist/XcodeProj", exact: "8.19.0"),
466467
.package(url: "https://github.com/cpisciotta/xcbeautify", .upToNextMajor(from: "2.5.0")),
467468
.package(url: "https://github.com/krzysztofzablocki/Difference.git", from: "1.0.2"),
468-
.package(url: "https://github.com/Kolos65/Mockable.git", from: "0.0.9"),
469+
.package(url: "https://github.com/Kolos65/Mockable.git", exact: "0.0.10"),
469470
.package(
470471
url: "https://github.com/tuist/swift-openapi-runtime", branch: "swift-tools-version"
471472
),
@@ -475,6 +476,7 @@ let package = Package(
475476
.package(url: "https://github.com/tuist/Path", .upToNextMajor(from: "0.3.0")),
476477
.package(url: "https://github.com/tuist/XcodeGraph.git", exact: "0.12.1"),
477478
.package(url: "https://github.com/tuist/FileSystem.git", .upToNextMajor(from: "0.2.0")),
479+
.package(url: "https://github.com/tuist/Command.git", exact: "0.8.0"),
478480
],
479481
targets: targets
480482
)
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import Command
2+
import FileSystem
3+
import Foundation
4+
import Mockable
5+
import Path
6+
import TuistSupport
7+
import XcodeGraph
8+
9+
enum DeviceControllerError: FatalError {
10+
case applicationVerificationFailed
11+
12+
var description: String {
13+
switch self {
14+
case .applicationVerificationFailed:
15+
"The app could not be installed because the verification failed. Make sure that your device is registered in your Apple Developer account."
16+
}
17+
}
18+
19+
var type: ErrorType {
20+
switch self {
21+
case .applicationVerificationFailed:
22+
.abort
23+
}
24+
}
25+
}
26+
27+
@Mockable
28+
/// Utility to interact with the `devicectl` CLI.
29+
public protocol DeviceControlling {
30+
func findAvailableDevices() async throws -> [PhysicalDevice]
31+
func installApp(
32+
at path: AbsolutePath,
33+
device: PhysicalDevice
34+
) async throws
35+
func launchApp(
36+
bundleId: String,
37+
device: PhysicalDevice
38+
) async throws
39+
}
40+
41+
public final class DeviceController: DeviceControlling {
42+
private let fileSystem: FileSysteming
43+
private let commandRunner: CommandRunning
44+
45+
public init(
46+
fileSystem: FileSysteming = FileSystem(),
47+
commandRunner: CommandRunning = CommandRunner()
48+
) {
49+
self.fileSystem = fileSystem
50+
self.commandRunner = commandRunner
51+
}
52+
53+
public func findAvailableDevices() async throws -> [PhysicalDevice] {
54+
try await fileSystem.runInTemporaryDirectory(prefix: "device-controller-find-available-devices") { temporaryPath in
55+
let devicesListOutputPath = temporaryPath.appending(component: "devices-list-output-path.json")
56+
_ = try await commandRunner.run(
57+
arguments: [
58+
"/usr/bin/xcrun", "devicectl",
59+
"list", "devices",
60+
"--json-output", devicesListOutputPath.pathString,
61+
]
62+
)
63+
.concatenatedString()
64+
65+
let deviceList = try JSONDecoder().decode(
66+
DeviceList.self,
67+
from: try await fileSystem.readFile(at: devicesListOutputPath)
68+
)
69+
70+
return deviceList.result.devices
71+
.map(PhysicalDevice.init)
72+
}
73+
}
74+
75+
public func installApp(
76+
at path: AbsolutePath,
77+
device: PhysicalDevice
78+
) async throws {
79+
logger.debug("Installing app at \(path) on simulator device with id \(device.id)")
80+
do {
81+
_ = try await commandRunner.run(
82+
arguments: [
83+
"/usr/bin/xcrun", "devicectl",
84+
"device", "install", "app",
85+
"--device", device.id,
86+
path.pathString,
87+
]
88+
)
89+
.concatenatedString()
90+
} catch let error as CommandError {
91+
if case let .terminated(_, stderr) = error, stderr.contains("ApplicationVerificationFailed") {
92+
throw DeviceControllerError.applicationVerificationFailed
93+
} else {
94+
throw error
95+
}
96+
}
97+
}
98+
99+
public func launchApp(
100+
bundleId: String,
101+
device: PhysicalDevice
102+
) async throws {
103+
logger.debug("Launching app with bundle id \(bundleId) on a physical device with id \(device.id)")
104+
_ = try await commandRunner.run(
105+
arguments: [
106+
"/usr/bin/xcrun", "devicectl",
107+
"device", "process", "launch",
108+
"--device", device.id,
109+
bundleId,
110+
]
111+
)
112+
.concatenatedString()
113+
}
114+
}
115+
116+
private struct DeviceList: Codable {
117+
let result: Result
118+
119+
struct Result: Codable {
120+
let devices: [Device]
121+
122+
struct Device: Codable {
123+
let connectionProperties: ConnectionProperties
124+
let deviceProperties: DeviceProperties
125+
let hardwareProperties: HardwareProperties
126+
let identifier: String
127+
128+
struct ConnectionProperties: Codable {
129+
let transportType: String?
130+
}
131+
132+
struct DeviceProperties: Codable {
133+
let name: String
134+
let osVersionNumber: String
135+
}
136+
137+
struct HardwareProperties: Codable {
138+
enum Platform: String, Codable {
139+
case iOS
140+
case tvOS
141+
case watchOS
142+
case visionOS
143+
}
144+
145+
let udid: String
146+
let platform: Platform
147+
}
148+
}
149+
}
150+
}
151+
152+
extension PhysicalDevice {
153+
fileprivate init(_ device: DeviceList.Result.Device) {
154+
let platform: Platform = switch device.hardwareProperties.platform {
155+
case .iOS: .iOS
156+
case .tvOS: .tvOS
157+
case .visionOS: .visionOS
158+
case .watchOS: .watchOS
159+
}
160+
161+
self.init(
162+
id: device.hardwareProperties.udid,
163+
name: device.deviceProperties.name,
164+
platform: platform,
165+
osVersion: device.deviceProperties.osVersionNumber
166+
)
167+
}
168+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
import XcodeGraph
3+
4+
/// Represents a physical device, such as an iPhone
5+
public struct PhysicalDevice: Codable, Equatable, Identifiable {
6+
public let id: String
7+
public let name: String
8+
public let platform: Platform
9+
public let osVersion: String
10+
}
11+
12+
#if DEBUG
13+
extension PhysicalDevice {
14+
public static func test(
15+
id: String = "id",
16+
name: String = "My iPhone",
17+
platform: Platform = .iOS,
18+
osVersion: String = "17.4.1"
19+
) -> Self {
20+
.init(
21+
id: id,
22+
name: name,
23+
platform: platform,
24+
osVersion: osVersion
25+
)
26+
}
27+
}
28+
#endif

Sources/TuistAutomation/Utilities/AppBundle.swift

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Path
2+
import TuistCore
23
import TuistSupport
34
import XcodeGraph
45

@@ -40,16 +41,15 @@ public struct AppBundle: Equatable {
4041
/// Minimum OS version
4142
public let minimumOSVersion: Version
4243

43-
/// Supported simulator platforms.
44-
/// Device is currently not supported.
45-
public let supportedPlatforms: [SupportedPlatform]
44+
/// Supported destination platforms.
45+
public let supportedPlatforms: [DestinationType]
4646

4747
init(
4848
version: Version,
4949
name: String,
5050
bundleId: String,
5151
minimumOSVersion: Version,
52-
supportedPlatforms: [SupportedPlatform]
52+
supportedPlatforms: [DestinationType]
5353
) {
5454
self.version = version
5555
self.name = name
@@ -58,11 +58,6 @@ public struct AppBundle: Equatable {
5858
self.supportedPlatforms = supportedPlatforms
5959
}
6060

61-
public enum SupportedPlatform: Codable, Equatable {
62-
case simulator(Platform)
63-
case device(Platform)
64-
}
65-
6661
enum CodingKeys: String, CodingKey {
6762
case version = "CFBundleShortVersionString"
6863
case name = "CFBundleName"
@@ -85,7 +80,9 @@ public struct AppBundle: Equatable {
8580
)
8681
supportedPlatforms = try container.decode([String].self, forKey: AppBundle.InfoPlist.CodingKeys.supportedPlatforms)
8782
.map { platformSDK in
88-
if let platform = Platform(commandLineValue: platformSDK) {
83+
if let platform = Platform.allCases
84+
.first(where: { platformSDK.lowercased() == $0.xcodeDeviceSDK })
85+
{
8986
return .device(platform)
9087
} else if let platform = Platform.allCases
9188
.first(where: { platformSDK.lowercased() == $0.xcodeSimulatorSDK })
@@ -118,7 +115,7 @@ public struct AppBundle: Equatable {
118115
name: String = "App",
119116
bundleId: String = "io.tuist.App",
120117
minimumOSVersion: Version = Version("17.4"),
121-
supportedPlatforms: [SupportedPlatform] = [.simulator(.iOS)]
118+
supportedPlatforms: [DestinationType] = [.simulator(.iOS)]
122119
) -> Self {
123120
.init(
124121
version: version,

0 commit comments

Comments
 (0)