From 44b80e989bccbaf8744005954616c53e98beea41 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Fri, 27 Feb 2026 18:43:38 -0800 Subject: [PATCH 1/2] doctor: Log a warning if we don't detect any running devices --- .../SkipBuild/Commands/DoctorCommand.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Sources/SkipBuild/Commands/DoctorCommand.swift b/Sources/SkipBuild/Commands/DoctorCommand.swift index 18c9bbf0..0ed7d539 100644 --- a/Sources/SkipBuild/Commands/DoctorCommand.swift +++ b/Sources/SkipBuild/Commands/DoctorCommand.swift @@ -142,6 +142,28 @@ 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.]+)") + + /// Check for connected Android devices/emulators; warn if none are running + func checkAdbDevices() async throws { + func checkResult(_ result: Result?) -> (result: Result?, message: MessageBlock?) { + guard let res = try? result?.get() else { + return (result: result, message: MessageBlock(status: .warn, "Android devices: error running adb devices")) + } + let output = res.stdout + // When no devices: "List of devices attached" followed by blank line(s) only + // When devices exist: "List of devices attached\nemulator-5554\tdevice\n" + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + let deviceLines = lines.dropFirst().filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + if deviceLines.isEmpty { + return (result: result, message: 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: result, message: MessageBlock(status: .pass, "Android devices: \(deviceLines.count) connected")) + } + } + try await run(with: out, "Android devices", ["adb", "devices"], watch: false, resultHandler: checkResult) + } + try await checkAdbDevices() + if let androidHome = ProcessInfo.androidHome { let exists = FileManager.default.fileExists(atPath: androidHome) if !exists { From 98739605a900be47b7382d36719cfbee0e193a94 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Fri, 27 Feb 2026 19:10:02 -0800 Subject: [PATCH 2/2] checkup: launch the app if we detect an installed device --- .../SkipBuild/Commands/CheckupCommand.swift | 9 ++++---- .../SkipBuild/Commands/DoctorCommand.swift | 16 +++++++++----- Sources/SkipBuild/Commands/InitCommand.swift | 22 ++++++++++++++++--- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Sources/SkipBuild/Commands/CheckupCommand.swift b/Sources/SkipBuild/Commands/CheckupCommand.swift index 1ce1393a..fb51c470 100644 --- a/Sources/SkipBuild/Commands/CheckupCommand.swift +++ b/Sources/SkipBuild/Commands/CheckupCommand.swift @@ -99,9 +99,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, 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 +141,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/DoctorCommand.swift b/Sources/SkipBuild/Commands/DoctorCommand.swift index 0ed7d539..1bf7245a 100644 --- a/Sources/SkipBuild/Commands/DoctorCommand.swift +++ b/Sources/SkipBuild/Commands/DoctorCommand.swift @@ -38,7 +38,7 @@ This command will check for system configuration and prerequisites. It is a subs 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, 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 +50,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. + func runDoctor(checkNative: Bool, 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 { @@ -144,7 +145,8 @@ extension ToolOptionsCommand where Self : StreamingCommand { try await checkVersion(title: "Android Debug Bridge version", cmd: ["adb", "version"], min: Version("1.0.40"), pattern: "version ([0-9.]+)") /// Check for connected Android devices/emulators; warn if none are running - func checkAdbDevices() async throws { + func checkAdbDevices() async throws -> Bool { + var hasDevices = false func checkResult(_ result: Result?) -> (result: Result?, message: MessageBlock?) { guard let res = try? result?.get() else { return (result: result, message: MessageBlock(status: .warn, "Android devices: error running adb devices")) @@ -154,6 +156,7 @@ extension ToolOptionsCommand where Self : StreamingCommand { // When devices exist: "List of devices attached\nemulator-5554\tdevice\n" let lines = output.split(separator: "\n", omittingEmptySubsequences: false) let deviceLines = lines.dropFirst().filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + hasDevices = !deviceLines.isEmpty if deviceLines.isEmpty { return (result: result, message: 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 { @@ -161,8 +164,9 @@ extension ToolOptionsCommand where Self : StreamingCommand { } } try await run(with: out, "Android devices", ["adb", "devices"], watch: false, resultHandler: checkResult) + return hasDevices } - try await checkAdbDevices() + let hasAndroidDevices = try await checkAdbDevices() if let androidHome = ProcessInfo.androidHome { let exists = FileManager.default.fileExists(atPath: androidHome) @@ -183,6 +187,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 4eae1784..77039e1b 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?) { @@ -370,7 +382,7 @@ extension ToolOptionsCommand where Self : StreamingCommand { return hashes } - 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 @@ -400,10 +412,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]) } @@ -430,6 +442,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"])