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
9 changes: 5 additions & 4 deletions Sources/SkipBuild/Commands/CheckupCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
34 changes: 31 additions & 3 deletions Sources/SkipBuild/Commands/DoctorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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 {

Expand Down Expand Up @@ -142,6 +143,31 @@ 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 -> Bool {
var hasDevices = false
func checkResult(_ result: Result<ProcessOutput, Error>?) -> (result: Result<ProcessOutput, Error>?, 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 }
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 {
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)
return hasDevices
}
let hasAndroidDevices = try await checkAdbDevices()

if let androidHome = ProcessInfo.androidHome {
let exists = FileManager.default.fileExists(atPath: androidHome)
if !exists {
Expand All @@ -161,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 {
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 @@ -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

Expand Down Expand Up @@ -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])
}

Expand All @@ -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"])
Expand Down