Skip to content

Commit 2318861

Browse files
committed
Update device system to handle Android devices/emulators
1 parent cd4adf6 commit 2318861

21 files changed

+653
-360
lines changed

Sources/SwiftBundler/Bundler/AndroidDebugBridge.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ enum AndroidDebugBridge {
2525
var identifier: String
2626
}
2727

28-
/// Lists connected Android devices.
28+
/// Lists connected Android devices and emulators.
2929
static func listConnectedDevices() async throws(Error) -> [ConnectedDevice] {
3030
let adb = try locateADBExecutable()
3131
let output = try await Error.catch {
@@ -44,6 +44,12 @@ enum AndroidDebugBridge {
4444
.dropFirst()
4545
var devices: [ConnectedDevice] = []
4646
for line in lines {
47+
// Skip known status lines. If turn out to be more than just these two then
48+
// we should handle this more generally.
49+
if line == "* daemon started successfully" || line == "List of devices attached" {
50+
continue
51+
}
52+
4753
let parts = line.split(separator: "\t")
4854
guard parts.count == 2, parts[1] == "device" else {
4955
log.warning("Failed to parse line of 'adb devices' output: '\(line)'")
@@ -56,6 +62,20 @@ enum AndroidDebugBridge {
5662
return devices
5763
}
5864

65+
/// Gets the model of the given device.
66+
static func getModel(of device: ConnectedDevice) async throws(Error) -> String {
67+
let adb = try locateADBExecutable()
68+
let process = Process.create(
69+
adb.path,
70+
arguments: ["-s", device.identifier, "shell", "getprop", "ro.product.model"]
71+
)
72+
73+
let output = try await Error.catch {
74+
try await process.getOutput()
75+
}
76+
return output.trimmingCharacters(in: .whitespacesAndNewlines)
77+
}
78+
5979
/// Checks whether the given device is an emulator or not.
6080
static func checkIsEmulator(_ device: ConnectedDevice) async throws(Error) -> Bool {
6181
let adb = try locateADBExecutable()
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import Foundation
2+
import ErrorKit
3+
4+
/// A manager for connected Apple devices.
5+
enum AppleDeviceManager {
6+
/// List Apple devices (including simulators) known to Swift Bundler. Only
7+
/// works on macOS.
8+
static func listDevices() async throws(Error) -> [Device] {
9+
let dummyProject =
10+
FileManager.default.temporaryDirectory
11+
/ "dev.stackotter.swift-bundler/SwiftBundlerDummyPackage"
12+
let dummyProjectName = "Dummy"
13+
14+
do {
15+
if dummyProject.exists() {
16+
try FileManager.default.removeItem(at: dummyProject)
17+
}
18+
19+
try FileManager.default.createDirectory(at: dummyProject)
20+
21+
try await SwiftPackageManager.createPackage(
22+
in: dummyProject,
23+
name: dummyProjectName,
24+
toolchain: nil
25+
)
26+
} catch {
27+
throw Error(.failedToCreateDummyProject)
28+
}
29+
30+
let output = try await Error.catch(withMessage: .failedToListXcodeDestinations) {
31+
try await Process.create(
32+
"xcodebuild",
33+
arguments: [
34+
"-showdestinations", "-scheme", dummyProjectName,
35+
],
36+
directory: dummyProject
37+
).getOutput()
38+
}
39+
40+
let lines = output.split(
41+
separator: "\n",
42+
omittingEmptySubsequences: false
43+
).map(String.init)
44+
45+
guard
46+
let startIndex =
47+
lines.firstIndex(
48+
of: "\tAvailable destinations for the \"\(dummyProjectName)\" scheme:"
49+
)?.advanced(by: 1),
50+
let endIndex = lines[startIndex...].firstIndex(of: "")
51+
else {
52+
throw Error(
53+
.failedToParseXcodeDestinationList(
54+
output,
55+
reason: "Couldn't locate destination section in output"
56+
)
57+
)
58+
}
59+
60+
return try Array(lines[startIndex..<endIndex])
61+
.compactMap(parseXcodeDestination)
62+
// AppleDeviceManager is for connected devices and simulators, so filter
63+
// out host devices (that's handled by the overarching DeviceManager).
64+
.filter { !$0.isHost }
65+
}
66+
67+
/// Returns nil if the line represents a device that Swift Bundler doesn't
68+
/// support.
69+
private static func parseXcodeDestination(
70+
_ line: String
71+
) throws(Error) -> Device? {
72+
var index = line.startIndex
73+
var dictionary: [String: String] = [:]
74+
75+
while index < line.endIndex, line[index].isWhitespace {
76+
index = line.index(after: index)
77+
continue
78+
}
79+
80+
func failure(_ reason: String) -> Error {
81+
Error(.failedToParseXcodeDestination(line, reason: reason))
82+
}
83+
84+
func read(_ count: Int) -> String? {
85+
let endIndex = line.index(index, offsetBy: count)
86+
if endIndex <= line.endIndex {
87+
let result = String(line[index..<endIndex])
88+
index = endIndex
89+
return result
90+
} else {
91+
return nil
92+
}
93+
}
94+
95+
func readUntil(oneOf characters: Set<Character>) throws(Error) -> String {
96+
var endIndex = index
97+
while endIndex != line.endIndex, !characters.contains(line[endIndex]) {
98+
endIndex = line.index(after: endIndex)
99+
}
100+
101+
guard endIndex != line.endIndex else {
102+
let characterList = characters.map { "'\($0)'" }.joined(separator: ", ")
103+
throw failure(
104+
"Expected to encounter one of [\(characterList)] but got end of line"
105+
)
106+
}
107+
108+
let startIndex = index
109+
index = endIndex
110+
return String(line[startIndex..<endIndex])
111+
}
112+
113+
func expect(_ pattern: String) throws(Error) {
114+
let startIndex = index
115+
guard let slice = read(pattern.count) else {
116+
throw failure(
117+
"""
118+
Expected '\(pattern)' at index \(startIndex.offset(in: line)), but \
119+
got end of line
120+
"""
121+
)
122+
}
123+
guard pattern == slice else {
124+
throw failure(
125+
"""
126+
Expected '\(pattern)' at index \(startIndex.offset(in: line)), but \
127+
got '\(slice)'
128+
"""
129+
)
130+
}
131+
}
132+
133+
try expect("{ ")
134+
135+
while line[index] != "}" {
136+
let key = try readUntil(oneOf: [":"])
137+
try expect(":")
138+
139+
var value = try readUntil(oneOf: ["[", ",", "}"])
140+
if line[index] == "[" {
141+
// We have to handle a horrid edge case because one of the Mac
142+
// targets usually has the 'variant' field set to
143+
// 'Designed for [iPad, iPhone]', which contains a comma and
144+
// messes everything up. We could technically make a smarter
145+
// parser that can handle that specific case without making it
146+
// an edge case, but given that this format isn't even a proper
147+
// data format, Apple's probably always gonna cook up a few more
148+
// edge cases, so I don't think it'd be worth it.
149+
let bracketedContent = try readUntil(oneOf: ["]"])
150+
let trailingContent = try readUntil(oneOf: [",", "}"])
151+
152+
value += bracketedContent + trailingContent
153+
}
154+
155+
if line[index] == "}" {
156+
// Remove trailing space
157+
value = String(value.dropLast())
158+
}
159+
160+
// Skip space between each key-value pair
161+
if index < line.endIndex, line[index] == "," {
162+
index = line.index(index, offsetBy: 2)
163+
}
164+
165+
dictionary[key] = value
166+
}
167+
168+
try expect("}")
169+
170+
guard let platform = dictionary["platform"] else {
171+
throw failure("Missing platform")
172+
}
173+
174+
let variant = dictionary["variant"]
175+
guard let parsedPlatform = ApplePlatform.parseXcodeDestinationName(platform, variant) else {
176+
// Skip devices for platforms that we don't handle, such as DriverKit
177+
return nil
178+
}
179+
180+
guard let id = dictionary["id"] else {
181+
// Skip generic destinations without ids
182+
return nil
183+
}
184+
185+
guard !id.hasSuffix(":placeholder") else {
186+
// Skip generic destinations with ids
187+
return nil
188+
}
189+
190+
guard let name = dictionary["name"] else {
191+
throw failure("Missing name")
192+
}
193+
194+
return Device(
195+
applePlatform: parsedPlatform,
196+
name: name,
197+
id: id,
198+
status: dictionary["error"].map(Device.Status.unavailable)
199+
?? .available
200+
)
201+
}
202+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
import ErrorKit
3+
4+
extension AppleDeviceManager {
5+
typealias Error = RichError<ErrorMessage>
6+
7+
/// An error message related to ``AppleDeviceManager``.
8+
enum ErrorMessage: Throwable {
9+
case failedToListXcodeDestinations
10+
case failedToCreateDummyProject
11+
case failedToParseXcodeDestinationList(
12+
_ xcodeDestinationList: String,
13+
reason: String
14+
)
15+
case failedToParseXcodeDestination(
16+
_ xcodeDestination: String,
17+
reason: String
18+
)
19+
20+
var userFriendlyMessage: String {
21+
switch self {
22+
case .failedToCreateDummyProject:
23+
return "Failed to create dummy project required to list Xcode destinations"
24+
case .failedToListXcodeDestinations:
25+
return "Failed to list Xcode destinations"
26+
case .failedToParseXcodeDestinationList(_, let reason):
27+
return "Failed to parse Xcode destination list: \(reason)"
28+
case .failedToParseXcodeDestination(_, let reason):
29+
return "Failed to parse Xcode destination: \(reason)"
30+
}
31+
}
32+
}
33+
}

Sources/SwiftBundler/Bundler/CodeSigner/CodeSigner.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,9 @@ enum CodeSigner {
240240

241241
if matchingIdentities.count > 1 {
242242
log.warning(
243-
"Multiple identities matched short name '\(shortName)', using \(identity)"
243+
"Multiple identities matched short name '\(shortName)'; using '\(identity)'"
244244
)
245+
log.debug("Matching identities: \(matchingIdentities)")
245246
}
246247

247248
return identity

Sources/SwiftBundler/Bundler/DarwinBundler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ enum DarwinBundler: Bundler {
256256

257257
// Simulators and hosts don't require provisioning profiles
258258
guard
259-
case .connectedAppleDevice(let device) = context.device,
259+
case .appleDevice(let device) = context.device,
260260
!device.platform.isSimulator
261261
else {
262262
return nil

0 commit comments

Comments
 (0)