Skip to content

Commit cd4adf6

Browse files
committed
Merge 'simulators' and 'emulators' subcommands
1 parent 3bd4b49 commit cd4adf6

32 files changed

+697
-290
lines changed

Sources/SwiftBundler/Bundler/APKBundlerError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ extension APKBundler {
1515
case failedToCreateDefaultIcon(_ destination: URL)
1616
case multiArchitectureBuildsNotSupported
1717
case failedToCopyExecutable(source: URL, destination: URL)
18+
// swiftlint:disable:next identifier_name
1819
case hostRequiresX86_64Compatibility
1920
case failedToEnumerateDynamicDependenciesOfLibrary(_ library: URL)
2021
case failedToLocateDynamicDependencyOfLibrary(

Sources/SwiftBundler/Bundler/AndroidSDKManagerError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension AndroidSDKManager {
1111
case androidHomeDoesNotExist(environmentVariable: String, value: URL)
1212
case noBuildToolsFound(_ sdk: URL)
1313
case ndkNotInstalled(_ ndkDirectory: URL)
14+
// swiftlint:disable:next identifier_name
1415
case ndkLLVMPrebuiltsOnlyDistributedForX86_64(HostPlatform, BuildArchitecture)
1516
case ndkMissingNDKPrebuilts(_ prebuiltDirectory: URL)
1617
case ndkMissingReadelfTool(_ readelfTool: URL)

Sources/SwiftBundler/Bundler/NonMacAppleOS.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// A non-macOS Apple operating system.
2-
enum NonMacAppleOS: CaseIterable {
2+
enum NonMacAppleOS: String, CaseIterable {
33
case iOS
44
case tvOS
55
case visionOS
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
/// A connected android device.
2-
struct ConnectedAndroidDevice: Equatable {
3-
let name: String
4-
let id: String
2+
struct ConnectedAndroidDevice: Sendable, Equatable {
3+
var id: String
4+
var name: String
5+
var isEmulator: Bool
6+
var status: Status
7+
8+
/// The status of a connected Android device.
9+
enum Status: Sendable, Hashable {
10+
case available
11+
case unavailable
12+
}
513
}

Sources/SwiftBundler/Bundler/Runner/Device.swift

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,13 @@ enum Device: Equatable, CustomStringConvertible {
6262
case .macCatalyst:
6363
self = .macCatalyst
6464
case .other(let nonMacPlatform):
65-
self.init(
66-
nonMacApplePlatform: nonMacPlatform,
65+
let device = ConnectedAppleDevice(
66+
platform: nonMacPlatform,
6767
name: name,
6868
id: id,
6969
status: status
7070
)
71+
self = .connectedAppleDevice(device)
7172
}
7273
}
73-
74-
init(
75-
nonMacApplePlatform platform: NonMacApplePlatform,
76-
name: String,
77-
id: String,
78-
status: ConnectedAppleDevice.Status
79-
) {
80-
let device = ConnectedAppleDevice(
81-
platform: platform,
82-
name: name,
83-
id: id,
84-
status: status
85-
)
86-
self = .connectedAppleDevice(device)
87-
}
8874
}

Sources/SwiftBundler/Bundler/Runner/Runner.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,16 +338,16 @@ enum Runner {
338338
) async throws(Error) {
339339
do {
340340
log.info("Preparing simulator")
341-
try await SimulatorManager.bootSimulator(id: simulatorId)
341+
try await AppleSimulatorManager.bootSimulator(id: simulatorId)
342342

343343
log.info("Installing app")
344-
try await SimulatorManager.installApp(bundlerOutput.bundle, simulatorId: simulatorId)
344+
try await AppleSimulatorManager.installApp(bundlerOutput.bundle, simulatorId: simulatorId)
345345

346346
log.info("Opening Simulator")
347-
try await SimulatorManager.openSimulatorApp()
347+
try await AppleSimulatorManager.openSimulatorApp()
348348

349349
log.info("Launching \(bundleIdentifier)")
350-
try await SimulatorManager.launchApp(
350+
try await AppleSimulatorManager.launchApp(
351351
bundleIdentifier,
352352
simulatorId: simulatorId,
353353
connectConsole: true,

Sources/SwiftBundler/Bundler/AndroidVirtualDevice.swift renamed to Sources/SwiftBundler/Bundler/SimulatorManager/AndroidVirtualDevice.swift

File renamed without changes.

Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManager.swift renamed to Sources/SwiftBundler/Bundler/SimulatorManager/AndroidVirtualDeviceManager.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ enum AndroidVirtualDeviceManager {
5959
}
6060
}
6161

62+
/// Converts a virtual device to its simulator representation.
63+
///
64+
/// We take the list of booted virtual devices as input rather than fetching
65+
/// it within the function in order to force more efficient usage of the
66+
/// function. Otherwise, if someone wanted to convert an array of `n` virtual
67+
/// devices, we'd have to fetch the list of booted virtual devices `n` times.
68+
static func virtualDeviceToSimulator(
69+
_ device: AndroidVirtualDevice,
70+
bootedVirtualDevices: [AndroidVirtualDevice]
71+
) async throws(Error) -> Simulator {
72+
Simulator(
73+
id: device.name,
74+
name: device.name,
75+
isAvailable: true,
76+
isBooted: bootedVirtualDevices.contains { $0.name == device.name },
77+
os: .android
78+
)
79+
}
80+
6281
/// Enumerates booted Android virtual devices.
6382
static func enumerateBootedVirtualDevices() async throws(Error) -> [AndroidVirtualDevice] {
6483
let connectedDevices = try await Error.catch {

Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManagerError.swift renamed to Sources/SwiftBundler/Bundler/SimulatorManager/AndroidVirtualDeviceManagerError.swift

File renamed without changes.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import Foundation
2+
3+
/// A utility for managing Apple simulators.
4+
enum AppleSimulatorManager {
5+
/// Lists available simulators.
6+
/// - Parameter searchTerm: If provided, the simulators will be filtered using
7+
/// the search term.
8+
/// - Returns: A list of available simulators matching the search term (if
9+
/// provided), or a failure if an error occurs.
10+
static func listAvailableSimulators(
11+
searchTerm: String? = nil
12+
) async throws(Error) -> [Simulator] {
13+
let data = try await Error.catch(withMessage: .failedToRunSimCTL) {
14+
try await Process.create(
15+
"/usr/bin/xcrun",
16+
arguments: [
17+
"simctl", "list", "devices",
18+
searchTerm, "available", "--json",
19+
].compactMap { $0 }
20+
).getOutputData(excludeStdError: true)
21+
}
22+
23+
let simulatorList = try Error.catch(withMessage: .failedToDecodeJSON) {
24+
try JSONDecoder().decode(SimulatorList.self, from: data)
25+
}
26+
27+
return simulatorList.devices
28+
.compactMap { (platform, platformSimulators) -> [Simulator]? in
29+
guard
30+
let os = NonMacAppleOS.allCases.first(where: { osCandidate in
31+
platform.hasPrefix(osCandidate.simulatorRuntimePrefix)
32+
})
33+
else {
34+
return nil
35+
}
36+
37+
return platformSimulators.map { simulator in
38+
Simulator(
39+
id: simulator.id,
40+
name: simulator.name,
41+
isAvailable: simulator.isAvailable,
42+
isBooted: simulator.state == .booted,
43+
os: .apple(os)
44+
)
45+
}
46+
}
47+
.flatMap { $0 }
48+
}
49+
50+
/// Boots a simulator. If it's already running, nothing is done.
51+
/// - Parameter id: The name or id of the simulator to start.
52+
static func bootSimulator(
53+
id: String,
54+
architecture: BuildArchitecture? = nil,
55+
additionalArguments: [String]? = nil
56+
) async throws(Error) {
57+
var arguments = ["simctl", "boot", id]
58+
if let architecture {
59+
arguments += ["--arch=\(architecture.rawValue)"]
60+
}
61+
if let additionalArguments {
62+
arguments += additionalArguments
63+
}
64+
65+
do {
66+
// We use getOutputData to access the data on error
67+
_ = try await Process.create(
68+
"/usr/bin/xcrun",
69+
arguments: arguments
70+
).getOutputData()
71+
} catch {
72+
// If the device is already booted, count it as a success
73+
guard
74+
case let .nonZeroExitStatusWithOutput(data, _, _) = error.message,
75+
let output = String(data: data, encoding: .utf8),
76+
output.hasSuffix("Unable to boot device in current state: Booted\n")
77+
else {
78+
throw Error(.failedToRunSimCTL, cause: error)
79+
}
80+
}
81+
}
82+
83+
/// Launches an app on the simulator (the app must already be installed).
84+
/// - Parameters:
85+
/// - bundleIdentifier: The app's bundle identifier.
86+
/// - simulatorId: The name or id of the simulator to launch in.
87+
/// - connectConsole: If `true`, the function will block and the current
88+
/// process will print the stdout and stderr of the running app.
89+
/// - arguments: Command line arguments to pass to the app.
90+
/// - environmentVariables: Additional environment variables to pass to the
91+
/// app.
92+
static func launchApp(
93+
_ bundleIdentifier: String,
94+
simulatorId: String,
95+
connectConsole: Bool,
96+
arguments: [String],
97+
environmentVariables: [String: String]
98+
) async throws(Error) {
99+
let process = Process.create(
100+
"/usr/bin/xcrun",
101+
arguments: [
102+
"simctl", "launch", connectConsole ? "--console-pty" : nil,
103+
simulatorId, bundleIdentifier,
104+
].compactMap { $0 } + arguments,
105+
runSilentlyWhenNotVerbose: false
106+
)
107+
108+
// TODO: Ensure that environment variables are passed correctly
109+
var prefixedVariables: [String: String] = [:]
110+
for (key, value) in environmentVariables {
111+
prefixedVariables["SIMCTL_CHILD_" + key] = value
112+
}
113+
114+
process.addEnvironmentVariables(prefixedVariables)
115+
116+
try await Error.catch(withMessage: .failedToRunSimCTL) {
117+
try await process.runAndWait()
118+
}
119+
}
120+
121+
/// Installs an app on the simulator.
122+
/// - Parameters:
123+
/// - bundle: The app bundle to install.
124+
/// - simulatorId: The name or id of the simulator to install on.
125+
/// - Returns: A failure if an error occurs.
126+
static func installApp(
127+
_ bundle: URL,
128+
simulatorId: String
129+
) async throws(Error) {
130+
try await Error.catch(withMessage: .failedToRunSimCTL) {
131+
try await Process.create(
132+
"/usr/bin/xcrun",
133+
arguments: [
134+
"simctl", "install", simulatorId, bundle.path,
135+
]
136+
).runAndWait()
137+
}
138+
}
139+
140+
/// Opens the latest booted simulator in the simulator app.
141+
/// - Returns: A failure if an error occurs.
142+
static func openSimulatorApp() async throws(Error) {
143+
try await Error.catch(withMessage: .failedToOpenSimulator) {
144+
try await Process.create(
145+
"/usr/bin/open",
146+
arguments: [
147+
"-a", "Simulator",
148+
]
149+
).runAndWait()
150+
}
151+
}
152+
}

0 commit comments

Comments
 (0)