diff --git a/Sources/SkipBuild/Commands/CheckupCommand.swift b/Sources/SkipBuild/Commands/CheckupCommand.swift index 10a608e3..5de1d986 100644 --- a/Sources/SkipBuild/Commands/CheckupCommand.swift +++ b/Sources/SkipBuild/Commands/CheckupCommand.swift @@ -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" @@ -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 @@ -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) diff --git a/Sources/SkipBuild/Commands/DevicesCommand.swift b/Sources/SkipBuild/Commands/DevicesCommand.swift index b1b840fd..756cd9e3 100644 --- a/Sources/SkipBuild/Commands/DevicesCommand.swift +++ b/Sources/SkipBuild/Commands/DevicesCommand.swift @@ -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() - - 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) } } diff --git a/Sources/SkipBuild/Commands/DoctorCommand.swift b/Sources/SkipBuild/Commands/DoctorCommand.swift index 18c9bbf0..293d6251 100644 --- a/Sources/SkipBuild/Commands/DoctorCommand.swift +++ b/Sources/SkipBuild/Commands/DoctorCommand.swift @@ -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")) @@ -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 { @@ -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 { @@ -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 { diff --git a/Sources/SkipBuild/Commands/InitCommand.swift b/Sources/SkipBuild/Commands/InitCommand.swift index a0102ac5..e67dd992 100644 --- a/Sources/SkipBuild/Commands/InitCommand.swift +++ b/Sources/SkipBuild/Commands/InitCommand.swift @@ -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 @@ -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 ) @@ -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 { func returnFileSize(_ result: Result?) -> (result: Result?, message: MessageBlock?) { @@ -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 @@ -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]) } @@ -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"]) diff --git a/Sources/SkipBuild/SkipCommandSupport.swift b/Sources/SkipBuild/SkipCommandSupport.swift index e982f970..8be04708 100644 --- a/Sources/SkipBuild/SkipCommandSupport.swift +++ b/Sources/SkipBuild/SkipCommandSupport.swift @@ -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 {