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
12 changes: 8 additions & 4 deletions Sources/SkipBuild/Commands/CheckupCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ This command performs a full system checkup to ensure that Skip can create and b
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Fail immediately when an error occurs"))
var failFast: Bool = true

@Flag(inversion: .prefixedNo, help: ArgumentHelp("Check for connected Android devices/emulators", valueName: "check-devices"))
var checkDevices: Bool = true

@Option(name: [.long], help: ArgumentHelp("Name of checkup project", valueName: "name"))
var projectName: String = "hello-skip"

Expand Down Expand Up @@ -99,9 +102,9 @@ This command performs a full system checkup to ensure that Skip can create and b
}

func runCheckup(with out: MessageQueue) async throws {
try await runDoctor(checkNative: isNative, with: out)
let hasAndroidDevices = try await runDoctor(checkNative: isNative, checkDevices: checkDevices, with: out)

@Sendable func buildSampleProject(packageResolvedURL: URL? = nil) async throws -> (projectURL: URL, project: AppProjectLayout, artifacts: [URL: String?]) {
@Sendable func buildSampleProject(packageResolvedURL: URL? = nil, launchAndroid: Bool) async throws -> (projectURL: URL, project: AppProjectLayout, artifacts: [URL: String?]) {
let primary = packageResolvedURL == nil
// a random temporary folder for the project
let tmpdir = NSTemporaryDirectory() + "/" + UUID().uuidString
Expand Down Expand Up @@ -141,18 +144,19 @@ This command performs a full system checkup to ensure that Skip can create and b
packageResolved: packageResolvedURL,
apk: true,
ipa: true,
launchAndroid: launchAndroid,
with: out
)
}

// build a sample project (twice when performing a double-check)
let (p1URL, project, p1) = try await buildSampleProject()
let (p1URL, project, p1) = try await buildSampleProject(launchAndroid: hasAndroidDevices)
let packageResolvedURL = p1URL.appendingPathComponent("Package.resolved", isDirectory: false)
try registerPluginFingerprint(for: packageResolvedURL)
if doubleCheck {
// use the Package.resolved from the initial build to ensure that use double-check build uses the same dependency versions as the initial build
// otherwise if a new version of a Skip library is tagged in between the two builds, the checksums won't match
let (_, project2, p2) = try await buildSampleProject(packageResolvedURL: packageResolvedURL)
let (_, project2, p2) = try await buildSampleProject(packageResolvedURL: packageResolvedURL, launchAndroid: hasAndroidDevices)

let (_, _) = (project, project2)

Expand Down
40 changes: 4 additions & 36 deletions Sources/SkipBuild/Commands/DevicesCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,42 +55,10 @@ This command will list all the connected Android emulators and devices and iOS s
}

func listAndroidDevices(with out: MessageQueue) async throws {
let adbDevicesPattern = try NSRegularExpression(pattern: #"^(\S+)\s+(\S+)(.*)$"#)

var seenDevicesHeader = false
for try await pout in try await launchTool("adb", arguments: ["devices", "-l"]) {
let line = pout.line
// ignore everything output before the "List of devices" header
if line.hasPrefix("List of devices") {
seenDevicesHeader = true
} else if seenDevicesHeader {
guard let parts = adbDevicesPattern.extract(from: line) else {
continue // unable to parse
}
guard let deviceID = parts.first,
let deviceState = parts.dropFirst(1).first,
let deviceInfo = parts.dropFirst(2).first else {
continue
}

let _ = deviceState

func trim(_ string: String) -> String {
string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}

// create a dictionary from the device info string: "product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1"
var deviceInfoMap = Dictionary<String, String>()

for keyValue in deviceInfo.split(separator: " ").map({ $0.split(separator: ":") }) {
if keyValue.count == 2 {
deviceInfoMap[keyValue[0].description] = keyValue[1].description
}
}

let info = DevicesOutput(id: deviceID, type: .device, platform: .android, info: .init(deviceInfoMap))
await out.yield(info)
}
let devices = try await getAndroidDevices()
for device in devices {
let info = DevicesOutput(id: device.id, type: .device, platform: .android, info: .init(device.info))
await out.yield(info)
}
}

Expand Down
32 changes: 29 additions & 3 deletions Sources/SkipBuild/Commands/DoctorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ This command will check for system configuration and prerequisites. It is a subs
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Fail immediately when an error occurs"))
var failFast: Bool = false

@Flag(inversion: .prefixedNo, help: ArgumentHelp("Check for connected Android devices/emulators", valueName: "check-devices"))
var checkDevices: Bool = true

func performCommand(with out: MessageQueue) async {
await withLogStream(with: out) {
await out.yield(MessageBlock(status: nil, "Skip Doctor"))

try await runDoctor(checkNative: self.native, with: out)
_ = try await runDoctor(checkNative: self.native, checkDevices: self.checkDevices, with: out)
let latestVersion = await checkSkipUpdates(with: out)
if let latestVersion = latestVersion, latestVersion != skipVersion {
await out.yield(MessageBlock(status: .warn, "A new version is Skip (\(latestVersion)) is available to update with: skip upgrade"))
Expand All @@ -50,8 +53,9 @@ This command will check for system configuration and prerequisites. It is a subs
extension ToolOptionsCommand where Self : StreamingCommand {
// TODO: check license validity: https://github.com/skiptools/skip/issues/388

/// Runs the `skip doctor` command and stream the results to the messenger
func runDoctor(checkNative: Bool, with out: MessageQueue) async throws {
/// Runs the `skip doctor` command and stream the results to the messenger.
/// Returns true if Android devices/emulators are attached, false otherwise (or if checkDevices is false).
func runDoctor(checkNative: Bool, checkDevices: Bool = true, with out: MessageQueue) async throws -> Bool {
/// Invokes the given command and attempts to parse the output against the given regular expression pattern to validate that it is a semantic version string
func checkVersion(title: String, cmd: [String], min: Version? = nil, pattern: String, watch: Bool = false, hint: String? = nil) async throws {

Expand Down Expand Up @@ -142,6 +146,26 @@ extension ToolOptionsCommand where Self : StreamingCommand {
try await checkVersion(title: "Gradle version", cmd: ["gradle", "-version"], min: Version("8.6.0"), pattern: "Gradle ([0-9.]+)", hint: " (install with: brew install gradle)")
try await checkVersion(title: "Java version", cmd: ["java", "-version"], min: Version("17.0.0"), pattern: "version \"([0-9._]+)\"", hint: ProcessInfo.processInfo.environment["JAVA_HOME"] == nil ? nil : " (check JAVA_HOME environment: \(ProcessInfo.processInfo.environment["JAVA_HOME"] ?? "unset"))") // we don't necessarily need java in the path (which it doesn't seem to be by default with Homebrew)
try await checkVersion(title: "Android Debug Bridge version", cmd: ["adb", "version"], min: Version("1.0.40"), pattern: "version ([0-9.]+)")

var hasAndroidDevices = false
if checkDevices {
_ = await outputOptions.monitor(with: out, "Android devices", watch: false, resultHandler: { result in
do {
let devices = try result?.get() ?? []
hasAndroidDevices = !devices.isEmpty
if devices.isEmpty {
return (result, MessageBlock(status: .warn, "No Android devices running. Xcode builds will fail until you attach a device, launch an emulator in Android Studio, or run: skip android emulator launch"))
} else {
return (result, MessageBlock(status: .pass, "Android devices: \(devices.count) connected"))
}
} catch {
return (result, MessageBlock(status: .fail, "Android devices: error running adb devices"))
}
}, monitorAction: { _ in
try await getAndroidDevices()
})
}

if let androidHome = ProcessInfo.androidHome {
let exists = FileManager.default.fileExists(atPath: androidHome)
if !exists {
Expand All @@ -161,6 +185,8 @@ extension ToolOptionsCommand where Self : StreamingCommand {
// we no longer require that Android Studio be installed with the advent of `skip android emulator create`
//await checkAndroidStudioVersion(with: out)
#endif

return hasAndroidDevices
}

func checkXcodeCommandLineTools(with out: MessageQueue) async {
Expand Down
22 changes: 19 additions & 3 deletions Sources/SkipBuild/Commands/InitCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ This command will create a conventional Skip app or library project.
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Build the iOS .ipa file"))
var ipa: Bool = false

@Flag(inversion: .prefixedNo, help: ArgumentHelp("Launch the Android app on an attached device or emulator"))
var launchAndroid: Bool = false

@Flag(help: ArgumentHelp("Open the resulting Xcode project"))
var openXcode: Bool = false

Expand Down Expand Up @@ -178,6 +181,7 @@ This command will create a conventional Skip app or library project.
validatePackage: self.createOptions.validatePackage,
apk: apk,
ipa: ipa,
launchAndroid: launchAndroid,
with: out
)

Expand Down Expand Up @@ -253,6 +257,14 @@ extension ToolOptionsCommand where Self : StreamingCommand {
return hashes
}

/// Launch the Android app on an attached device or emulator (runs gradle launchDebug/launchRelease).
func launchAndroidApp(projectURL: URL, appModuleName: String, configuration: BuildConfiguration, out: MessageQueue, prefix re: String) async throws {
let env = ProcessInfo.processInfo.environmentWithDefaultToolPaths
let gradleProjectDir = projectURL.path + "/Android"
let action = "launch" + configuration.rawValue.capitalized // "launchDebug" or "launchRelease"
try await run(with: out, "\(re)Launching Android app \(action)", ["gradle", action, "--console=plain", "--project-dir", gradleProjectDir], environment: env)
}

/// Zip up the given folder.
@discardableResult func zipFolder(with out: MessageQueue, message msg: String, compressionLevel: Int = 9, zipFile: URL, folder: URL) async throws -> Result<ProcessOutput, Error> {
func returnFileSize(_ result: Result<ProcessOutput, Error>?) -> (result: Result<ProcessOutput, Error>?, message: MessageBlock?) {
Expand Down Expand Up @@ -406,7 +418,7 @@ extension ToolOptionsCommand where Self : StreamingCommand {
try await zipFolder(with: out, message: "Archive \(simAppURL.lastPathComponent)", zipFile: simAppURL, folder: appBundleURL)
}

func initSkipProject(options: ProjectOptionValues, modules: [PackageModule], resourceFolder: String?, dir outputFolder: URL, verify: Bool, configuration: BuildConfiguration, build: Bool, test: Bool, returnHashes: Bool, messagePrefix: String? = nil, showTree: Bool, app isApp: Bool, appid: String?, appModuleName: String = "app", icon: IconParameters?, version: String?, nativeMode: NativeMode, moduleMode: ModuleMode, moduleTests: Bool, validatePackage: Bool, packageResolved packageResolvedURL: URL? = nil, apk: Bool, ipa: Bool, with out: MessageQueue) async throws -> (projectURL: URL, project: AppProjectLayout, artifacts: [URL: String?]) {
func initSkipProject(options: ProjectOptionValues, modules: [PackageModule], resourceFolder: String?, dir outputFolder: URL, verify: Bool, configuration: BuildConfiguration, build: Bool, test: Bool, returnHashes: Bool, messagePrefix: String? = nil, showTree: Bool, app isApp: Bool, appid: String?, appModuleName: String = "app", icon: IconParameters?, version: String?, nativeMode: NativeMode, moduleMode: ModuleMode, moduleTests: Bool, validatePackage: Bool, packageResolved packageResolvedURL: URL? = nil, apk: Bool, ipa: Bool, launchAndroid: Bool = false, with out: MessageQueue) async throws -> (projectURL: URL, project: AppProjectLayout, artifacts: [URL: String?]) {
var options = options
let baseName = options.projectName

Expand Down Expand Up @@ -436,10 +448,10 @@ extension ToolOptionsCommand where Self : StreamingCommand {
let (projectURL, project) = try await AppProjectLayout.createSkipAppProject(options: options, productName: primaryModuleFrameworkName, modules: modules, resourceFolder: resourceFolder, dir: outputFolder, configuration: configuration, build: build, test: test, app: isApp, appid: appid, icon: icon, version: version, nativeMode: nativeMode, moduleMode: moduleMode, moduleTests: moduleTests, packageResolved: packageResolvedURL)
let projectPath = try projectURL.absolutePath

if build == true || apk == true {
if build == true || apk == true || launchAndroid == true {
try await run(with: out, "\(re)Resolve dependencies", ["swift", "package", "resolve", "-v", "--package-path", projectURL.path])

// we need to build regardless of preference in order to build the apk
// we need to build regardless of preference in order to build the apk or launch
try await run(with: out, "\(re)Build \(projectName)", ["swift", "build", "-v", "-c", debugConfiguration, "--package-path", projectURL.path])
}

Expand Down Expand Up @@ -467,6 +479,10 @@ extension ToolOptionsCommand where Self : StreamingCommand {
artifactHashes.merge(apkFiles, uniquingKeysWith: { $1 })
}

if launchAndroid == true {
try await launchAndroidApp(projectURL: projectURL, appModuleName: appModuleName, configuration: configuration, out: out, prefix: re)
}

if options.gitRepo == true {
// https://github.com/skiptools/skip/issues/407
try await run(with: out, "Initializing git repository", ["git", "-C", projectURL.path, "init"])
Expand Down
33 changes: 33 additions & 0 deletions Sources/SkipBuild/SkipCommandSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ extension ToolOptionsCommand where Self: StreamingCommand {

return try await run(with: messenger, message, cmdArgs, environment: penv, permitFailure: permitFailure, resultHandler: finalResultHandler)
}

/// Returns parsed Android devices from `adb devices -l`. Throws if adb fails to run.
func getAndroidDevices() async throws -> [AndroidDeviceInfo] {
let adbDevicesPattern = try NSRegularExpression(pattern: #"^(\S+)\s+(\S+)(.*)$"#)
var devices: [AndroidDeviceInfo] = []
var seenDevicesHeader = false

for try await pout in try await launchTool("adb", arguments: ["devices", "-l"]) {
let line = pout.line
if line.hasPrefix("List of devices") {
seenDevicesHeader = true
} else if seenDevicesHeader, let parts = adbDevicesPattern.extract(from: line),
let deviceID = parts.first,
let deviceState = parts.dropFirst(1).first,
let deviceInfo = parts.dropFirst(2).first {
var deviceInfoMap: [String: String] = [:]
for keyValue in deviceInfo.split(separator: " ").map({ $0.split(separator: ":") }) {
if keyValue.count == 2 {
deviceInfoMap[String(keyValue[0])] = String(keyValue[1])
}
}
devices.append(AndroidDeviceInfo(id: deviceID, state: deviceState, info: deviceInfoMap))
}
}
return devices
}
}

/// Parsed Android device info from `adb devices -l`.
struct AndroidDeviceInfo {
let id: String
let state: String
let info: [String: String]
}

extension AsyncLineOutput {
Expand Down
Loading