diff --git a/Package.swift b/Package.swift index 357622af..c560b043 100644 --- a/Package.swift +++ b/Package.swift @@ -111,6 +111,11 @@ let package = Package( condition: .when(platforms: [.macOS, .linux]) ), ], + resources: [ + .embedInCode("Resources/gradlew"), + .embedInCode("Resources/gradle-wrapper.jar"), + .embedInCode("Resources/DefaultAndroidIcon.webp"), + ], swiftSettings: [ .define("SUPPORT_HOT_RELOADING", .when(platforms: [.macOS, .linux])), .define("SUPPORT_XCODEPROJ", .when(platforms: [.macOS])), diff --git a/Sources/SwiftBundler/Bundler/APKBundler.swift b/Sources/SwiftBundler/Bundler/APKBundler.swift new file mode 100644 index 00000000..78122c84 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/APKBundler.swift @@ -0,0 +1,745 @@ +import Foundation +import Version +import Parsing + +/// A bundler targeting Android. +enum APKBundler: Bundler { + static let outputIsRunnable = true + static let requiresBuildAsDylib = true + + typealias Context = Void + + static func computeContext( + context: BundlerContext, + command: BundleCommand, + manifest: PackageManifest + ) throws(Error) {} + + static func intendedOutput( + in context: BundlerContext, + _ additionalContext: Context + ) -> BundlerOutputStructure { + let bundle = context.outputDirectory / "\(context.appName).apk" + return BundlerOutputStructure( + bundle: bundle, + executable: bundle + ) + } + + static func checkHostCompatibility() throws(Error) { + // Ref: https://github.com/android/ndk/issues/1752 + guard BuildArchitecture.host == .x86_64 || HostPlatform.hostPlatform == .macOS else { + throw Error(.hostRequiresX86_64Compatibility) + } + } + + static func bundle( + _ context: BundlerContext, + _ additionalContext: Context + ) async throws(Error) -> BundlerOutputStructure { + let outputAPK = intendedOutput(in: context, additionalContext).bundle + let appBundleName = outputAPK.lastPathComponent + + log.info("Bundling '\(appBundleName)'") + + // Locate Android SDK + let androidSDK = try Error.catch { + try AndroidSDKManager.locateAndroidSDK() + } + + let compilationSDKVersion = try Error.catch { + try AndroidSDKManager.getDefaultCompilationSDKVersion(forSDK: androidSDK) + } + + // Create project structure. + let identifier = context.appConfiguration.identifier + let projectDirectory = context.outputDirectory / "\(context.appName).project" + let project = ProjectStructure(at: projectDirectory, forAppWithIdentifier: identifier) + + if project.root.exists() { + try Error.catch { + try FileManager.default.removeItem(at: project.root) + } + } + try project.createDirectories() + + // Create gradle wrapper files + let gradlew = Data(PackageResources.gradlew) + let gradleWrapperJar = Data(PackageResources.gradle_wrapper_jar) + let gradleWrapperProperties = generateGradleWrapperProperties() + let gradleLibsVersions = generateGradleLibsVersions() + try Error.catch(withMessage: .failedToCreateGradleWrapperFiles) { + try gradlew.write(to: project.gradlew) + try gradleWrapperJar.write(to: project.gradleWrapperJar) + try gradleWrapperProperties.write(to: project.gradleWrapperProperties) + try gradleLibsVersions.write(to: project.gradleLibsVersionsFile) + + // Add executable permission + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(0o755)], + ofItemAtPath: project.gradlew.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(0o755)], + ofItemAtPath: project.gradleWrapperJar.path + ) + } + + // Create Gradle configuration files + let packageIdentifier = context.appConfiguration.identifier.lowercased() + let targetAPI = 33 + let gradleBuildConfig = generateGradleBuildConfig( + packageIdentifier: packageIdentifier, + appVersion: context.appConfiguration.version, + targetAPI: targetAPI, + compileSDK: compilationSDKVersion.major, + architectures: context.architectures, + projectStructure: project + ) + let gradleSettings = generateGradleSettings(forApp: context.appName) + let gradleProperties = generateGradleProperties() + let localProperties = generateLocalProperties(androidSDK: androidSDK) + + let themeName = "AppTheme" + let parentTheme = "Theme.Material3.DayNight.NoActionBar" + let appNameStringKey = "app_name" + let androidManifest = generateAndroidManifest( + targetAPI: targetAPI, + themeName: themeName, + appNameStringKey: appNameStringKey, + projectStructure: project + ) + + let cmakeLists = generateCMakeLists(product: context.appConfiguration.product) + + try Error.catch(withMessage: .failedToCreateGradleConfigurationFiles) { + try gradleBuildConfig.write(to: project.gradleBuildConfig) + try gradleSettings.write(to: project.gradleSettings) + try gradleProperties.write(to: project.gradleProperties) + try localProperties.write(to: project.localProperties) + try androidManifest.write(to: project.androidManifest) + try cmakeLists.write(to: project.cmakeLists) + } + + // Generate source code files + let mainActivity = generateMainActivity(packageIdentifier: packageIdentifier) + let shimSource = generateShimSource( + packageIdentifier: packageIdentifier, + swiftEntryPoint: "AndroidBackend_entrypoint", + projectStructure: project + ) + let shimHeader = generateShimHeader( + packageIdentifier: packageIdentifier, + mainActivityName: project.mainActivityName + ) + + try Error.catch(withMessage: .failedToCreateGradleProjectSourceFiles) { + try mainActivity.write(to: project.mainActivitySource) + try shimSource.write(to: project.shimSource) + try shimHeader.write(to: project.shimHeader) + } + + if let iconPath = context.appConfiguration.icon { + let icon = context.packageDirectory / iconPath + do { + try FileManager.default.copyItem(at: icon, to: project.icon) + } catch { + throw Error(.failedToCopyIcon(source: icon, destination: project.icon), cause: error) + } + } else { + let iconData = Data(PackageResources.DefaultAndroidIcon_webp) + try Error.catch(withMessage: .failedToCreateDefaultIcon(project.icon)) { + try iconData.write(to: project.icon) + } + } + + // Generate resource files + let themesXML = generateThemesXML(themeName: themeName, parentTheme: parentTheme) + let nightThemesXML = generateNightThemesXML(themeName: themeName, parentTheme: parentTheme) + let stringsXML = generateStringsXML( + appName: context.appName, + appNameStringKey: appNameStringKey + ) + try Error.catch(withMessage: .failedToCreateGradleProjectResourceFiles) { + try themesXML.write(to: project.themesFile) + try nightThemesXML.write(to: project.nightThemesFile) + try stringsXML.write(to: project.stringsFile) + } + + guard + let architecture = context.architectures.first, + context.architectures.count == 1 + else { + // TODO: Implement this before merging. Will require bundle context to support + // having one product directory per architecture. + throw Error(.multiArchitectureBuildsNotSupported) + } + + let jniLibs = project.jniLibsSubdirectory(for: architecture) + let library = context.productsDirectory / "lib\(context.appConfiguration.product).so" + let libraryDestination = jniLibs / library.lastPathComponent + do { + try FileManager.default.createDirectory(at: jniLibs) + try FileManager.default.copyItem(at: library, to: libraryDestination) + } catch { + let message = ErrorMessage.failedToCopyExecutable( + source: library, + destination: libraryDestination + ) + throw Error(message, cause: error) + } + + let ndk = try Error.catch { + try AndroidSDKManager.getLatestNDK(availableIn: androidSDK) + } + + let readelfTool = try Error.catch { + try AndroidSDKManager.locateReadelfTool( + inNDK: ndk, + hostPlatform: .hostPlatform, + hostArchitecture: .host + ) + } + + let androidAPI = Platform.androidAPI + let sdk = try Error.catch { + try SwiftSDKManager.locateSDKMatching( + hostPlatform: .hostPlatform, + hostArchitecture: .host, + targetTriple: .android(architecture, api: androidAPI) + ) + } + + let subdirectory = switch architecture { + case .arm64: + "aarch64-linux-android" + case .armv7: + "arm-linux-androideabi" + case .x86_64: + "x86_64-linux-android" + } + + let androidLibrarySearchDirectories = [ + DynamicLibrarySearchDirectory(context.productsDirectory), + DynamicLibrarySearchDirectory(sdk.resourcesDirectory / "android"), + ] + sdk.librarySearchDirectories.flatMap { searchDirectory in + // TODO: Is this the standard way that Swift uses the library search + // directory? The directory itself is just a bunch of triple-specific + // subdirectories which seems a bit strange. + [ + DynamicLibrarySearchDirectory( + searchDirectory / subdirectory, + requiresCopying: true + ), + DynamicLibrarySearchDirectory( + searchDirectory / subdirectory / "\(androidAPI)", + requiresCopying: false + ), + ] + } + + let dynamicDependencies = try await enumerateRecursiveDynamicDependencies( + ofLibrary: library, + readelfTool: readelfTool, + searchDirectories: androidLibrarySearchDirectories + ) + + for dependency in dynamicDependencies where dependency.copy { + let destination = jniLibs / dependency.location.lastPathComponent + try Error.catch(withMessage: .failedToCopyDynamicDependency(dependency.location)) { + try FileManager.default.copyItem(at: dependency.location, to: destination) + } + } + + // Run Gradle build + let task = "assembleDebug" + var gradleArguments = [task] + if log.logLevel <= .debug { + gradleArguments.append("--debug") + } + let process = Process.create( + project.gradlew.path, + arguments: gradleArguments, + directory: project.root, + runSilentlyWhenNotVerbose: false + ) + let inputPipe = Pipe() + process.standardInput = inputPipe + + log.info("Running gradle \(task) task") + try await Error.catch { + // If we don't close the writing end of stdin, then gradlew hangs for reasons + // unknown to me. It seems related to gradle having interactive output, but + // even with '--console=plain' the process hangs after supposedly finishing + // the build (and logging everything). Without a plain console, it hangs around + // when it first tries doing interactive output (afaict). + try inputPipe.fileHandleForWriting.close() + + try await process.runAndWait() + } + + // Copy APK to output location + let apk = project.root / "build/outputs/apk/debug/\(context.appName)-debug.apk" + try Error.catch(withMessage: .failedToCopyAPK(apk, outputAPK)) { + try FileManager.default.copyItem(at: apk, to: outputAPK) + } + + return BundlerOutputStructure( + bundle: outputAPK, + executable: outputAPK + ) + } + + struct SharedObject: Hashable { + /// The location of the shared object. + var location: URL + /// Whether or not the shared object should be copied into the bundle. + /// + /// `false` generally means that the library is shipped with the system. + var copy: Bool + } + + struct DynamicLibrarySearchDirectory { + /// The directory to search. + var location: URL + /// Whether or not libraries found in this location should be copied into + /// the application's bundle. `false` generally means that the libraries in + /// this directory are shipped with the system. + var requiresCopying: Bool + + init(_ location: URL, requiresCopying: Bool = true) { + self.location = location + self.requiresCopying = requiresCopying + } + } + + private static func enumerateRecursiveDynamicDependencies( + ofLibrary library: URL, + readelfTool: URL, + searchDirectories: [DynamicLibrarySearchDirectory] + ) async throws(Error) -> [SharedObject] { + var queue = [library] + var seen: Set = [] + var dependencies: [SharedObject] = [] + + while let library = queue.popLast() { + let libraryDependencies = try await enumerateDynamicDependencies( + ofLibrary: library, + readelfTool: readelfTool, + searchDirectories: searchDirectories + ) + + for dependency in libraryDependencies { + guard seen.insert(dependency).inserted else { + continue + } + + dependencies.append(dependency) + queue.append(dependency.location) + } + } + + return dependencies + } + + private static func enumerateDynamicDependencies( + ofLibrary library: URL, + readelfTool: URL, + searchDirectories: [DynamicLibrarySearchDirectory] + ) async throws(Error) -> [SharedObject] { + let process = Process.create( + readelfTool.path, + arguments: ["-d", library.path] + ) + + let output: String + do { + output = try await process.getOutput() + } catch { + throw Error(.failedToEnumerateDynamicDependenciesOfLibrary(library), cause: error) + } + + let lines = output.split(separator: "\n").dropFirst(2) + let parser = Parse(input: Substring.self) { + Skip { + Whitespace() + "0x" + Int.parser(radix: 16) + Whitespace() + "(NEEDED)" + Whitespace() + "Shared library: [" + } + PrefixUpTo("]") + Skip { + Rest() + } + } + + var dependencyNames: [String] = [] + for line in lines { + guard let libraryName = try? parser.parse(line) else { + continue + } + dependencyNames.append(String(libraryName)) + } + + var dependencies: [SharedObject] = [] + for dependencyName in dependencyNames { + let guesses = searchDirectories.map { directory in + SharedObject( + location: directory.location / dependencyName, + copy: directory.requiresCopying + ) + } + guard let dependency = guesses.first(where: { $0.location.exists() }) else { + throw Error(.failedToLocateDynamicDependencyOfLibrary( + library, + dependencyName: dependencyName, + guesses: guesses.map(\.location) + )) + } + + dependencies.append(dependency) + } + + return dependencies + } + + private static func generateGradleBuildConfig( + packageIdentifier: String, + appVersion: Version, + targetAPI: Int, + compileSDK: Int, + architectures: [BuildArchitecture], + projectStructure: ProjectStructure + ) -> String { + let architectureNames = architectures.map(\.androidName) + let abiFilters = architectureNames.map { architecture in + "\"\(architecture)\"" + }.joined(separator: ", ") + let keepDebugSymols = architectureNames.map { architecture in + " keepDebugSymbols += \"\(architecture)/*.so\"" + }.joined(separator: "\n") + + let cmakePath = projectStructure.cmakeLists.path(relativeTo: projectStructure.root) + + // TODO: Make version code configurable + return """ + plugins { + alias(libs.plugins.android.application) + } + + android { + namespace = "\(packageIdentifier)" + compileSdk = \(compileSDK) + + defaultConfig { + applicationId = "\(packageIdentifier)" + minSdk = \(targetAPI) + targetSdk = \(targetAPI) + versionCode = 1 + versionName = "\(appVersion)" + + ndk { + abiFilters.addAll(setOf(\(abiFilters))) + } + } + + externalNativeBuild { + cmake { + path = file("\(cmakePath)") + } + } + + packaging { + jniLibs { + \(keepDebugSymols) + } + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + + dependencies { + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.activity) + implementation(libs.constraintlayout) + } + + """ + } + + private static func generateGradleSettings(forApp appName: String) -> String { + return """ + pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\\\.android.*") + includeGroupByRegex("com\\\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } + } + + dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } + } + + rootProject.name = "\(appName)" + + """ + } + + private static func generateGradleProperties() -> String { + return """ + # Specifies the JVM arguments used for the daemon process. + # The setting is particularly useful for tweaking memory settings. + org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + # AndroidX package structure to make it clearer which packages are bundled with the + # Android operating system, and which are packaged with your app's APK + # https://developer.android.com/topic/libraries/support-library/androidx-rn + android.useAndroidX=true + # Enables namespacing of each library's R class so that its R class includes only the + # resources declared in the library itself and none from the library's dependencies, + # thereby reducing the size of the R class for that library + android.nonTransitiveRClass=true + + """ + } + + private static func generateLocalProperties(androidSDK: URL) -> String { + return "sdk.dir=\(androidSDK.path)\n" + } + + private static func generateAndroidManifest( + targetAPI: Int, + themeName: String, + appNameStringKey: String, + projectStructure: ProjectStructure + ) -> String { + let iconName = projectStructure.icon.deletingPathExtension().lastPathComponent + return """ + + + + + + + + + + + + + + """ + } + + private static func generateCMakeLists(product: String) -> String { + // We don't have any particular reason for having a minimum of 3.22 other + // than that it's what the sample code I based my template off used. I don't + // want to drop the minimum without testing it, but if someone needs a lower + // version and can test it, then I have no issue with dropping it. + return """ + cmake_minimum_required(VERSION 3.22) + project(shim) + + add_library(app SHARED IMPORTED) + set_target_properties(app PROPERTIES IMPORTED_LOCATION + ${CMAKE_SOURCE_DIR}/jniLibs/${ANDROID_ABI}/lib\(product).so) + + add_library(shim SHARED shim/shim.h shim/shim.c) + target_link_libraries(shim app) + + """ + } + + private static func generateMainActivity(packageIdentifier: String) -> String { + return """ + package \(packageIdentifier); + + import android.graphics.Insets; + import android.os.Bundle; + import android.view.WindowInsets; + import android.view.WindowMetrics; + + import androidx.appcompat.app.AppCompatActivity; + + public class MainActivity extends AppCompatActivity { + private native void setup(); + + public int getWindowWidth() { + WindowMetrics windowMetrics = this.getWindowManager().getCurrentWindowMetrics(); + Insets insets = windowMetrics.getWindowInsets() + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); + return windowMetrics.getBounds().width() - insets.left - insets.right; + } + + public int getWindowHeight() { + WindowMetrics windowMetrics = this.getWindowManager().getCurrentWindowMetrics(); + Insets insets = windowMetrics.getWindowInsets() + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); + return windowMetrics.getBounds().height() - insets.top - insets.bottom; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + System.loadLibrary("shim"); + + setup(); + } + } + + """ + } + + private static func getSetupFunctionName( + packageIdentifier: String, + mainActivityName: String + ) -> String { + let namespace = packageIdentifier.replacingOccurrences(of: ".", with: "_") + return "Java_\(namespace)_\(mainActivityName)_setup" + } + + private static func generateShimSource( + packageIdentifier: String, + swiftEntryPoint: String, + projectStructure: ProjectStructure + ) -> String { + let header = projectStructure.shimHeader.path( + relativeTo: projectStructure.shimSource.deletingLastPathComponent() + ) + let setupFunction = getSetupFunctionName( + packageIdentifier: packageIdentifier, + mainActivityName: projectStructure.mainActivityName + ) + return """ + #include "\(header)" + + void \(swiftEntryPoint)(JNIEnv *env, jobject activity); + + JNIEXPORT void JNICALL + \(setupFunction)(JNIEnv *env, jobject activity) { + \(swiftEntryPoint)(env, activity); + } + + """ + } + + private static func generateShimHeader( + packageIdentifier: String, + mainActivityName: String + ) -> String { + let setupFunction = getSetupFunctionName( + packageIdentifier: packageIdentifier, + mainActivityName: mainActivityName + ) + return """ + #include + + JNIEXPORT void JNICALL + \(setupFunction)(JNIEnv *env, jobject activity); + + """ + } + + private static func generateStringsXML(appName: String, appNameStringKey: String) -> String { + let appName = appName.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + return """ + + \(appName) + + + """ + } + + private static func generateGradleLibsVersions() -> String { + // TODO: Make all of this configurable + return """ + [versions] + agp = "8.13.0" + junit = "4.13.2" + junitVersion = "1.1.5" + espressoCore = "3.5.1" + appcompat = "1.6.1" + material = "1.10.0" + activity = "1.8.0" + constraintlayout = "2.1.4" + + [libraries] + junit = { group = "junit", name = "junit", version.ref = "junit" } + ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } + espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } + appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + material = { group = "com.google.android.material", name = "material", version.ref = "material" } + activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } + constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } + + [plugins] + android-application = { id = "com.android.application", version.ref = "agp" } + + """ + } + + private static func generateGradleWrapperProperties() -> String { + // Make Gradle version configurable + return """ + #Thu Apr 03 10:39:48 AEST 2025 + distributionBase=GRADLE_USER_HOME + distributionPath=wrapper/dists + distributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip + zipStoreBase=GRADLE_USER_HOME + zipStorePath=wrapper/dists + + """ + } + + private static func generateThemesXML(themeName: String, parentTheme: String) -> String { + return """ + + + + + + """ + } +} diff --git a/Sources/SwiftBundler/Bundler/APKBundlerError.swift b/Sources/SwiftBundler/Bundler/APKBundlerError.swift new file mode 100644 index 00000000..a8127fd7 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/APKBundlerError.swift @@ -0,0 +1,82 @@ +import Foundation +import ErrorKit + +extension APKBundler { + typealias Error = RichError + + /// An error message related to ``APKBundler``. + enum ErrorMessage: Throwable { + case failedToCreateProjectStructure(root: URL) + case failedToCreateGradleWrapperFiles + case failedToCreateGradleConfigurationFiles + case failedToCreateGradleProjectSourceFiles + case failedToCreateGradleProjectResourceFiles + case failedToCopyIcon(source: URL, destination: URL) + case failedToCreateDefaultIcon(_ destination: URL) + case multiArchitectureBuildsNotSupported + case failedToCopyExecutable(source: URL, destination: URL) + case hostRequiresX86_64Compatibility + case failedToEnumerateDynamicDependenciesOfLibrary(_ library: URL) + case failedToLocateDynamicDependencyOfLibrary( + _ library: URL, + dependencyName: String, + guesses: [URL] + ) + case failedToCopyDynamicDependency(URL) + case failedToCopyAPK(_ source: URL, _ destination: URL) + + var userFriendlyMessage: String { + switch self { + case .failedToCreateProjectStructure(let root): + let path = root.path(relativeTo: .currentDirectory) + return "Failed to create Gradle project structure at '\(path)'" + case .failedToCreateGradleWrapperFiles: + return "Failed to create gradle wrapper files" + case .failedToCreateGradleConfigurationFiles: + return "Failed to create Gradle configuration files" + case .failedToCreateGradleProjectSourceFiles: + return "Failed to create Gradle project source files" + case .failedToCreateGradleProjectResourceFiles: + return "Failed to create Gradle project resource files" + case .failedToCopyIcon(let source, let destination): + let source = source.path(relativeTo: .currentDirectory) + let destination = destination.path(relativeTo: .currentDirectory) + return "Failed to copy icon from '\(source)' to '\(destination)'" + case .failedToCreateDefaultIcon(let destination): + let destination = destination.path(relativeTo: .currentDirectory) + return "Failed to create default Android app icon at \(destination)" + case .multiArchitectureBuildsNotSupported: + return "Multi-architecture builds not supported" + case .failedToCopyExecutable(let source, let destination): + let source = source.path(relativeTo: .currentDirectory) + let destination = destination.path(relativeTo: .currentDirectory) + return "Failed to copy executable from '\(source)' to '\(destination)'" + case .hostRequiresX86_64Compatibility: + // Ref: https://github.com/android/ndk/issues/1752 + return """ + APKBundler requires an x86_64-compatible host due to Android NDK \ + limitations. Apple Silicon Macs count as x86_64-compatible because \ + of Rosetta + """ + case .failedToEnumerateDynamicDependenciesOfLibrary(let library): + return """ + Failed to enumerate dynamic dependencies of library at '\(library.path)' + """ + case .failedToLocateDynamicDependencyOfLibrary( + let library, let dependencyName, let guesses + ): + let joinedGuesses = guesses.map(\.path).joinedGrammatically() + return """ + Failed to locate dependency '\(dependencyName)' of library '\(library.path)'; \ + tried \(joinedGuesses) + """ + case .failedToCopyDynamicDependency(let location): + return "Failed to copy dynamic dependency '\(location.path)' into bundle" + case .failedToCopyAPK(let source, let destination): + let source = source.path(relativeTo: .currentDirectory) + let destination = destination.path(relativeTo: .currentDirectory) + return "Failed to copy APK from '\(source)' to '\(destination)'" + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/APKBundlerProjectStructure.swift b/Sources/SwiftBundler/Bundler/APKBundlerProjectStructure.swift new file mode 100644 index 00000000..5554f428 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/APKBundlerProjectStructure.swift @@ -0,0 +1,135 @@ +import Foundation + +extension APKBundler { + /// Describes the structure of a Gradle project generated by ``APKBundler``. + struct ProjectStructure { + /// The root directory of the project. + var root: URL + /// The `gradlew` executable. + var gradlew: URL + /// The `gradle` directory. + var gradleDirectory: URL + /// The `gradle/libs.versions.toml` file. + var gradleLibsVersionsFile: URL + /// The `gradle/wrapper` directory. + var gradleWrapperDirectory: URL + /// The `gradle/wrapper/gradle-wrapper.jar` file. + var gradleWrapperJar: URL + /// The `gradle/wrapper/gradle-wrapper.properties` config file. + var gradleWrapperProperties: URL + /// The `build.gradle.kts` config file. + var gradleBuildConfig: URL + /// The `settings.gradle.kts` config file. + var gradleSettings: URL + /// The `gradle.properties` config file. + var gradleProperties: URL + /// The `local.properties` config file. + var localProperties: URL + /// The `src` directory. + var src: URL + /// The `src/main` directory. + var srcMain: URL + /// The `AndroidManifest.xml` file. + var androidManifest: URL + /// The directory containing Java source files for the app's main package. + var javaSourceDirectory: URL + /// The `MainActivity.java` source file. + var mainActivitySource: URL + /// The project's root `CMakeLists.txt` file. + var cmakeLists: URL + /// The directory containing the shim Swift Bundler uses to access + /// arbitrarily-named functions from the app's native binary. + var shimDirectory: URL + /// The C file containing the source code for the shim. + var shimSource: URL + /// The shim's header file. + var shimHeader: URL + /// The directory containing the compiled native libraries for each + /// supported architecture. + var jniLibsDirectory: URL + /// The `src/main/res` directory. + var resourcesDirectory: URL + /// The resource directory holding value resources (such as `strings.xml`). + var valuesDirectory: URL + /// The resource directory holding the night/dark mode variants of value resources. + var nightValuesDirectory: URL + /// The app's themes file. + var themesFile: URL + /// The night/dark mode variant of the app's themes file. + var nightThemesFile: URL + /// The resource file holding string values. + var stringsFile: URL + /// The resource directory holding drawable resources (such as PNG files). + var drawableDirectory: URL + /// The resource directory holding drawables that must be preserved as-is + /// even in density-specific builds. See + /// https://developer.android.com/training/multiscreen/screendensities#mipmap + var mipmapDirectory: URL + /// The app's main icon file. + var icon: URL + + /// All directories in the structure. Used when creating the structure + /// on disk. + private var directories: [URL] { + [ + root, javaSourceDirectory, shimDirectory, jniLibsDirectory, + resourcesDirectory, valuesDirectory, nightValuesDirectory, + gradleWrapperDirectory, drawableDirectory, mipmapDirectory, + ] + } + + /// The name of the app's main activity. + let mainActivityName = "MainActivity" + + init(at root: URL, forAppWithIdentifier appIdentifier: String) { + self.root = root + gradlew = root / "gradlew" + gradleDirectory = root / "gradle" + gradleLibsVersionsFile = gradleDirectory / "libs.versions.toml" + gradleWrapperDirectory = gradleDirectory / "wrapper" + gradleWrapperProperties = gradleWrapperDirectory / "gradle-wrapper.properties" + gradleWrapperJar = gradleWrapperDirectory / "gradle-wrapper.jar" + gradleBuildConfig = root / "build.gradle.kts" + gradleSettings = root / "settings.gradle.kts" + gradleProperties = root / "gradle.properties" + localProperties = root / "local.properties" + src = root / "src" + srcMain = src / "main" + androidManifest = srcMain / "AndroidManifest.xml" + + let identifierPath = appIdentifier.lowercased() + .replacingOccurrences(of: ".", with: "/") + javaSourceDirectory = srcMain / "java" / identifierPath + mainActivitySource = javaSourceDirectory / "\(mainActivityName).java" + cmakeLists = srcMain / "CMakeLists.txt" + shimDirectory = srcMain / "shim" + shimSource = shimDirectory / "shim.c" + shimHeader = shimDirectory / "shim.h" + jniLibsDirectory = srcMain / "jniLibs" + resourcesDirectory = srcMain / "res" + valuesDirectory = resourcesDirectory / "values" + nightValuesDirectory = resourcesDirectory / "values-night" + themesFile = valuesDirectory / "themes.xml" + nightThemesFile = nightValuesDirectory / "themes.xml" + stringsFile = valuesDirectory / "strings.xml" + drawableDirectory = resourcesDirectory / "drawable" + mipmapDirectory = resourcesDirectory / "mipmap" + icon = mipmapDirectory / "icon.png" + } + + /// Creates all directories (including intermediate directories) required to + /// create this project structure. + func createDirectories() throws(Error) { + try Error.catch(withMessage: .failedToCreateProjectStructure(root: root)) { + for directory in directories { + try FileManager.default.createDirectory(at: directory) + } + } + } + + /// Gets the subdirectory of 'jniLibs' used for the given architecture. + func jniLibsSubdirectory(for architecture: BuildArchitecture) -> URL { + jniLibsDirectory / architecture.androidName + } + } +} diff --git a/Sources/SwiftBundler/Bundler/AndroidDebugBridge.swift b/Sources/SwiftBundler/Bundler/AndroidDebugBridge.swift new file mode 100644 index 00000000..6bd47612 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidDebugBridge.swift @@ -0,0 +1,106 @@ +import Foundation + +/// A utility that wraps the `adb` cli. +enum AndroidDebugBridge { + /// Locates the `adb` executable. + static func locateADBExecutable() throws(Error) -> URL { + let sdk = try Error.catch { + try AndroidSDKManager.locateAndroidSDK() + } + return try locateADBExecutable(inAndroidSDK: sdk) + } + + /// Locates the `adb` executable in the given Android SDK. + static func locateADBExecutable(inAndroidSDK sdk: URL) throws(Error) -> URL { + let executable = sdk / "platform-tools/adb" + guard executable.exists() else { + throw Error(.failedToLocateADBExecutable(executable)) + } + return executable + } + + /// A connected device as seen by ADB. + struct ConnectedDevice: Hashable, Sendable { + /// The device's ADB identifier. + var identifier: String + } + + /// Lists connected Android devices. + static func listConnectedDevices() async throws(Error) -> [ConnectedDevice] { + let adb = try locateADBExecutable() + let output = try await Error.catch { + try await Process.create( + adb.path, + arguments: ["devices"] + ).getOutput() + } + + // Example output: + // + // List of devices attached + // adb-53271JEKB02001-QRdsLi._adb-tls-connect._tcp. device + let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\n") + .dropFirst() + var devices: [ConnectedDevice] = [] + for line in lines { + let parts = line.split(separator: "\t") + guard parts.count == 2, parts[1] == "device" else { + log.warning("Failed to parse line of 'adb devices' output: '\(line)'") + continue + } + + devices.append(ConnectedDevice(identifier: String(parts[0]))) + } + + return devices + } + + /// Checks whether the given device is an emulator or not. + static func checkIsEmulator(_ device: ConnectedDevice) async throws(Error) -> Bool { + let adb = try locateADBExecutable() + let process = Process.create( + adb.path, + arguments: ["-s", device.identifier, "emu"] + ) + + do { + try await process.runAndWait() + return true + } catch { + switch error.message { + case .nonZeroExitStatusWithOutput(_, _, 1): + return false + default: + throw Error(.failedToCheckWhetherDeviceIsEmulator(device.identifier), cause: error) + } + } + } + + /// Gets the name of the device's corresponding AVD assuming that the device + /// is an emulator. + static func getEmulatorAVDName(_ device: ConnectedDevice) async throws(Error) -> String { + let adb = try locateADBExecutable() + let process = Process.create( + adb.path, + arguments: ["-s", device.identifier, "emu", "avd", "name"] + ) + + let message = ErrorMessage.failedToGetEmulatorAVDName(device.identifier) + let output = try await Error.catch(withMessage: message) { + try await process.getOutput() + } + + // Example output: + // + // Pixel_6_API_33 + // OK + let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\r\n") + guard lines.count == 2, lines[1] == "OK" else { + print(lines) + throw Error(.failedToParseEmulatorAVDNameOutput(output)) + } + + return String(lines[0]) + } +} diff --git a/Sources/SwiftBundler/Bundler/AndroidDebugBridgeError.swift b/Sources/SwiftBundler/Bundler/AndroidDebugBridgeError.swift new file mode 100644 index 00000000..1fb3450e --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidDebugBridgeError.swift @@ -0,0 +1,31 @@ +import Foundation +import ErrorKit + +extension AndroidDebugBridge { + typealias Error = RichError + + /// An error message related to ``AndroidDebugBridge``. + enum ErrorMessage: Throwable { + case failedToLocateADBExecutable(_ expectedLocation: URL?) + case failedToCheckWhetherDeviceIsEmulator(_ identifier: String) + case failedToGetEmulatorAVDName(_ identifier: String) + case failedToParseEmulatorAVDNameOutput(_ output: String) + + var userFriendlyMessage: String { + switch self { + case .failedToLocateADBExecutable(let expectedLocation): + var message = "Failed to locate 'adb' executable" + if let expectedLocation { + message += "; expected location was '\(expectedLocation.path)'" + } + return message + case .failedToCheckWhetherDeviceIsEmulator(let identifier): + return "Failed to check whether adb connected device '\(identifier)' is an emulator" + case .failedToGetEmulatorAVDName(let identifier): + return "Failed to get AVD name of adb connected emulator '\(identifier)'" + case .failedToParseEmulatorAVDNameOutput(let output): + return "Failed to parse emulator avd name from adb output:\n\(output)" + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/AndroidSDKManager.swift b/Sources/SwiftBundler/Bundler/AndroidSDKManager.swift new file mode 100644 index 00000000..8a86ece6 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidSDKManager.swift @@ -0,0 +1,167 @@ +import Foundation +import Version + +enum AndroidSDKManager { + static let buildToolsRelativePath = "build-tools" + + /// Locates the user's installed Android SDK. + /// + /// Checks for the presence of the `ANDROID_HOME` environment variable first, + /// and otherwise searches standard platform-specific locations for the SDK. + static func locateAndroidSDK() throws(Error) -> URL { + let environmentVariable = "ANDROID_HOME" + if let androidHome = ProcessInfo.processInfo.environment[environmentVariable] { + let androidHome = URL(fileURLWithPath: androidHome) + guard androidHome.exists(withType: .directory) else { + throw Error(.androidHomeDoesNotExist( + environmentVariable: environmentVariable, + value: androidHome + )) + } + return androidHome + } + + // Source: https://stackoverflow.com/a/51585165 + let guesses: [URL] + #if os(macOS) + let libraryDirectories = FileManager.default.urls( + for: .libraryDirectory, + in: .userDomainMask + ) + guesses = libraryDirectories.map { $0 / "Android/sdk" } + #elseif os(Linux) + guesses = [ + FileManager.default.homeDirectoryForCurrentUser / "Android/Sdk" + ] + #elseif os(Windows) + // The applicationSupportDirectory for user domain mask is %LOCALAPPDATA%. + // Source: https://github.com/swiftlang/swift-foundation/blob/a49715d6f1c2b866b91ea06468e58ee5f8ca41dd/Sources/FoundationEssentials/FileManager/SearchPaths/FileManager%2BWindowsSearchPaths.swift#L45-L46 + let localAppDataDirectories = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ) + guesses = localAppDataDirectories.map { $0 / "Android/sdk" } + #else + #error("Default Android SDK location unknown for target platform") + #endif + + for guess in guesses { + if guess.exists(withType: .directory) { + return guess + } + } + + throw Error(.failedToLocateAndroidSDK( + environmentVariable: environmentVariable, + guesses: guesses + )) + } + + /// Enumerates the build tool versions available in the given sdk. + static func enumerateBuildToolVersions(availableIn sdk: URL) throws(Error) -> [Version] { + let buildTools = sdk / "build-tools" + let contents = try Error.catch(withMessage: .sdkMissingBuildTools(sdk: sdk)) { + try FileManager.default.contentsOfDirectory(at: buildTools) + } + + var versions: [Version] = [] + for directory in contents where directory.exists(withType: .directory) { + guard let version = Version(tolerant: directory.lastPathComponent) else { + log.warning("Failed to parse build tools version of tools at '\(directory.path)'") + continue + } + versions.append(version) + } + + return versions + } + + /// Gets the default SDK version to use for compilation (i.e. the latest SDK version). + static func getDefaultCompilationSDKVersion(forSDK sdk: URL) throws(Error) -> Version { + let buildToolVersions = try enumerateBuildToolVersions(availableIn: sdk) + // Take the highest version + guard let version = buildToolVersions.sorted().last else { + throw Error(.noBuildToolsFound(sdk)) + } + return version + } + + static func ndkDirectory(forSDK sdk: URL) -> URL { + sdk / "ndk" + } + + /// Enumerates all available NDK versions in the given SDK. + static func enumerateNDKVersions(availableIn sdk: URL) throws(Error) -> [Version] { + let ndkDirectory = ndkDirectory(forSDK: sdk) + guard ndkDirectory.exists() else { + return [] + } + + let contents = try Error.catch { + try FileManager.default.contentsOfDirectory(at: ndkDirectory) + } + + var versions: [Version] = [] + for directory in contents where directory.exists(withType: .directory) { + guard let version = Version(tolerant: directory.lastPathComponent) else { + log.warning("Failed to parse NDK version of NDK at '\(directory.path)'") + continue + } + versions.append(version) + } + + return versions + } + + /// Gets the path of the latest NDK version available in the given SDK. + static func getLatestNDK(availableIn sdk: URL) throws(Error) -> URL { + let ndkVersions = try enumerateNDKVersions(availableIn: sdk) + let ndkDirectory = ndkDirectory(forSDK: sdk) + guard let ndkVersion = ndkVersions.sorted().last else { + throw Error(.ndkNotInstalled(ndkDirectory)) + } + + return ndkDirectory / "\(ndkVersion)" + } + + static func llvmPrebuiltDirectory( + forNDK ndk: URL, + hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture + ) throws(Error) -> URL { + // Ref: https://github.com/android/ndk/issues/1752 + guard hostPlatform == .macOS || hostArchitecture == .x86_64 else { + throw Error(.ndkLLVMPrebuiltsOnlyDistributedForX86_64(hostPlatform, hostArchitecture)) + } + + let platformName = switch hostPlatform { + case .linux: "linux" + case .macOS: "darwin" + case .windows: "windows" + } + + let prebuiltDirectory = ndk / "toolchains/llvm/prebuilt/\(platformName)-x86_64" + guard prebuiltDirectory.exists(withType: .directory) else { + throw Error(.ndkMissingNDKPrebuilts(prebuiltDirectory)) + } + + return prebuiltDirectory + } + + static func locateReadelfTool( + inNDK ndk: URL, + hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture + ) throws(Error) -> URL { + let prebuiltDirectory = try llvmPrebuiltDirectory( + forNDK: ndk, + hostPlatform: .hostPlatform, + hostArchitecture: .host + ) + let readelfTool = prebuiltDirectory / "bin/llvm-readelf" + guard readelfTool.exists() else { + throw Error(.ndkMissingReadelfTool(readelfTool)) + } + return readelfTool + } +} diff --git a/Sources/SwiftBundler/Bundler/AndroidSDKManagerError.swift b/Sources/SwiftBundler/Bundler/AndroidSDKManagerError.swift new file mode 100644 index 00000000..4e862990 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidSDKManagerError.swift @@ -0,0 +1,66 @@ +import Foundation +import ErrorKit + +extension AndroidSDKManager { + typealias Error = RichError + + /// An error message related to ``AndroidSDKManager``. + enum ErrorMessage: Throwable { + case failedToLocateAndroidSDK(environmentVariable: String, guesses: [URL]) + case sdkMissingBuildTools(sdk: URL) + case androidHomeDoesNotExist(environmentVariable: String, value: URL) + case noBuildToolsFound(_ sdk: URL) + case ndkNotInstalled(_ ndkDirectory: URL) + case ndkLLVMPrebuiltsOnlyDistributedForX86_64(HostPlatform, BuildArchitecture) + case ndkMissingNDKPrebuilts(_ prebuiltDirectory: URL) + case ndkMissingReadelfTool(_ readelfTool: URL) + + var userFriendlyMessage: String { + switch self { + case .failedToLocateAndroidSDK(let environmentVariable, let guesses): + let guesses = ["$\(environmentVariable)"] + guesses.map(\.path) + let joinedGuesses = guesses.joinedGrammatically() + return """ + Failed to locate the Android SDK. Tried \(joinedGuesses). If the SDK \ + is correctly installed, set the \(environmentVariable) environment \ + variable to the absolute path of the SDK + """ + case .sdkMissingBuildTools(let sdk): + return """ + The Android SDK '\(sdk.path)' is missing a build-tools subdirectory + """ + case .androidHomeDoesNotExist(let environmentVariable, let value): + return """ + The \(environmentVariable) environment variable points to a directory \ + that does not exist (\(value)). Either update its value to point to a \ + valid Android SDK, or unset the environment variable and let Swift \ + Bundler attempt to locate the SDK automatically. + """ + case .noBuildToolsFound(let sdk): + return """ + No build tools found at ./\(AndroidSDKManager.buildToolsRelativePath) \ + in Android SDK at \(sdk.path) + """ + case .ndkNotInstalled(let ndkDirectory): + return "No NDK installations found. Searched '\(ndkDirectory.path)'" + case .ndkLLVMPrebuiltsOnlyDistributedForX86_64(let platform, let architecture): + return """ + NDK LLVM prebuilts are only distributed for x86_64, meaning that \ + Android development is only supported on x86_64 machines and Apple \ + Silicon Macs with Rosetta. \(platform) + \(architecture) is not \ + supported. + """ + case .ndkMissingNDKPrebuilts(let prebuiltDirectory): + return """ + Expected NDK LLVM prebuilts to be located at '\(prebuiltDirectory.path)', \ + but the directory does not exist + """ + case .ndkMissingReadelfTool(let readelfTool): + return """ + Expected llvm-readelf to be located at '\(readelfTool.path)', but \ + the file does not exist + """ + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/AndroidVirtualDevice.swift b/Sources/SwiftBundler/Bundler/AndroidVirtualDevice.swift new file mode 100644 index 00000000..9a3a8f4c --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidVirtualDevice.swift @@ -0,0 +1,3 @@ +struct AndroidVirtualDevice: Hashable, Sendable { + var name: String +} diff --git a/Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManager.swift b/Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManager.swift new file mode 100644 index 00000000..33bfafdf --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManager.swift @@ -0,0 +1,129 @@ +import Foundation + +/// A utility for interacting with the Android avdmanager CLI and emulator CLI, +/// responsible for managing and launching Android Virtual Devices. +enum AndroidVirtualDeviceManager { + /// Locates the `avdmanager` executable. + static func locateAVDManagerExecutable() throws(Error) -> URL { + let sdk = try Error.catch(withMessage: .failedToLocateAVDManagerExecutable(nil)) { + try AndroidSDKManager.locateAndroidSDK() + } + return try locateAVDManagerExecutable(inAndroidSDK: sdk) + } + + /// Locates the `avdmanager` executable in the given Android SDK. + static func locateAVDManagerExecutable(inAndroidSDK sdk: URL) throws(Error) -> URL { + // There a few different copies of avdmanager in the Android SDK. This seems + // to be the one that works best. Others fail when you have a Java version + // newer than Java 8 installed... (at least on macOS) + let executable = sdk / "cmdline-tools/latest/bin/avdmanager" + guard executable.exists() else { + throw Error(.failedToLocateAVDManagerExecutable(executable)) + } + return executable + } + + /// Locates the `emulator` command executable. + static func locateEmulatorCommandExecutable() throws(Error) -> URL { + let sdk = try Error.catch(withMessage: .failedToLocateEmulatorCommandExecutable(nil)) { + try AndroidSDKManager.locateAndroidSDK() + } + return try locateEmulatorCommandExecutable(inAndroidSDK: sdk) + } + + /// Locates the `emulator` command executable in the given Android SDK. + static func locateEmulatorCommandExecutable(inAndroidSDK sdk: URL) throws(Error) -> URL { + // There a few different copies of emulator in the Android SDK. This seems + // to be the one that works best. The one at tools/emulator can be an x86_64 + // executable on Apple Silicon Macs, which causes it to look for a non-existent + // QEMU installation (for x86_64). + let executable = sdk / "emulator/emulator" + guard executable.exists() else { + throw Error(.failedToLocateEmulatorCommandExecutable(executable)) + } + return executable + } + + /// Enumerates available virtual devices. + static func enumerateVirtualDevices() async throws(Error) -> [AndroidVirtualDevice] { + let avdManager = try locateAVDManagerExecutable() + let output = try await Error.catch { + try await Process.create( + avdManager.path, + arguments: ["list", "avd", "--compact"] + ).getOutput(excludeStdError: true) + } + let lines = output.trimmingCharacters(in: .newlines).split(separator: "\n") + return lines.map { deviceName in + AndroidVirtualDevice(name: String(deviceName)) + } + } + + /// Enumerates booted Android virtual devices. + static func enumerateBootedVirtualDevices() async throws(Error) -> [AndroidVirtualDevice] { + let connectedDevices = try await Error.catch { + try await AndroidDebugBridge.listConnectedDevices() + } + let connectedEmulators = try await connectedDevices.typedAsyncFilter { (device) async throws(Error) in + try await Error.catch { + try await AndroidDebugBridge.checkIsEmulator(device) + } + } + return try await connectedEmulators.asyncMap { (emulator) async throws(Error) in + try await Error.catch { + let name = try await AndroidDebugBridge.getEmulatorAVDName(emulator) + return AndroidVirtualDevice(name: name) + } + } + } + + /// Boots a given Android virtual device. + /// - Parameters: + /// - device: The device to boot. + /// - additionalArguments: Additional arguments to pass to the 'emulator' CLI. + /// - checkAlreadyBooted: If `false`, skips checking whether the emulator has + /// already been booted. Setting this to `false` can lead to unintuitive + /// behaviour when `detach` is `true`. + /// - detach: If `true` the device gets started in a background process that + /// doesn't have its lifetime linked to Swift Bundler and gets its + /// stdout/stderr routed to /dev/null. + static func bootVirtualDevice( + _ device: AndroidVirtualDevice, + additionalArguments: [String], + checkAlreadyBooted: Bool = true, + detach: Bool = true + ) async throws(Error) { + let emulatorCommand = try locateEmulatorCommandExecutable() + + if checkAlreadyBooted { + let bootedDevices = try await enumerateBootedVirtualDevices() + guard !bootedDevices.contains(device) else { + if detach { + log.warning("Device already booted") + return + } else { + throw Error(.cannotAttachToAlreadyBootedEmulator(device.name)) + } + } + } + + // Create the process without Process.create so that we can manage whether + // or not the process gets killed along with Swift Bundler. + let process = Process() + process.executableURL = emulatorCommand + process.arguments = ["-avd", device.name] + additionalArguments + if detach { + let devNull = FileHandle(forWritingAtPath: "/dev/null") + process.standardError = devNull + process.standardOutput = devNull + try Error.catch { + try process.run() + } + } else { + Process.processes.append(process) + try await Error.catch { + try await process.runAndWait() + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManagerError.swift b/Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManagerError.swift new file mode 100644 index 00000000..d2684424 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/AndroidVirtualDeviceManagerError.swift @@ -0,0 +1,32 @@ +import Foundation +import ErrorKit + +extension AndroidVirtualDeviceManager { + typealias Error = RichError + + /// An error message related to ``AndroidVirtualDeviceManager``. + enum ErrorMessage: Throwable { + case failedToLocateAVDManagerExecutable(_ expectedLocation: URL?) + case failedToLocateEmulatorCommandExecutable(_ expectedLocation: URL?) + case cannotAttachToAlreadyBootedEmulator(_ name: String) + + var userFriendlyMessage: String { + switch self { + case .failedToLocateAVDManagerExecutable(let expectedLocation): + var message = "Failed to locate 'avdmanager' executable" + if let expectedLocation { + message += "; expected location was '\(expectedLocation.path)'" + } + return message + case .failedToLocateEmulatorCommandExecutable(let expectedLocation): + var message = "Failed to locate 'emulator' command executable" + if let expectedLocation { + message += "; expected location was '\(expectedLocation.path)'" + } + return message + case .cannotAttachToAlreadyBootedEmulator(let name): + return "Cannot attach to already booted emulator (name = \(name))" + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/AppImageBundler.swift b/Sources/SwiftBundler/Bundler/AppImageBundler.swift index c2114fb9..60210777 100644 --- a/Sources/SwiftBundler/Bundler/AppImageBundler.swift +++ b/Sources/SwiftBundler/Bundler/AppImageBundler.swift @@ -6,6 +6,7 @@ enum AppImageBundler: Bundler { typealias Context = Void static let outputIsRunnable = true + static let requiresBuildAsDylib = false /// Computes the location of the desktop file created in the given context. static func desktopFileLocation(for context: BundlerContext) -> URL { diff --git a/Sources/SwiftBundler/Bundler/Bundler.swift b/Sources/SwiftBundler/Bundler/Bundler.swift index 550adb9b..39f12a0e 100644 --- a/Sources/SwiftBundler/Bundler/Bundler.swift +++ b/Sources/SwiftBundler/Bundler/Bundler.swift @@ -10,6 +10,17 @@ protocol Bundler { /// ``AppImageBundler`` is. static var outputIsRunnable: Bool { get } + /// Indicates whether the bundler requires the app to be built as a dylib. + /// If true, Swift Bundler will build the app as a dylib instead of an + /// executable before passing it to the bundler. + static var requiresBuildAsDylib: Bool { get } + + /// Checks whether the bundler is compatible with the current host. This + /// may include checking the host's architecture, it's OS, or the presence + /// installed dependencies, among other things. An error is thrown to indicate + /// incompatibility. + static func checkHostCompatibility() throws(Error) + /// Computes the bundler's own context given the generic bundler context /// and Swift bundler's parsed command-line arguments, options, and flags. /// @@ -44,6 +55,10 @@ protocol Bundler { ) -> BundlerOutputStructure } +extension Bundler { + static func checkHostCompatibility() throws(Error) {} +} + extension Bundler where Context == Void { static func computeContext( context: BundlerContext, @@ -67,6 +82,9 @@ struct BundlerContext { /// The directory to output the app into. var outputDirectory: URL + /// The architectures that the app has been built for. + var architectures: [BuildArchitecture] + /// The target platform. var platform: Platform /// The target device if any. diff --git a/Sources/SwiftBundler/Bundler/DarwinAppBundleStructure.swift b/Sources/SwiftBundler/Bundler/DarwinAppBundleStructure.swift index 187a41fb..daf1994f 100644 --- a/Sources/SwiftBundler/Bundler/DarwinAppBundleStructure.swift +++ b/Sources/SwiftBundler/Bundler/DarwinAppBundleStructure.swift @@ -19,26 +19,24 @@ struct DarwinAppBundleStructure { let os = platform.os switch os { case .macOS: - contentsDirectory = bundleDirectory.appendingPathComponent("Contents") - executableDirectory = contentsDirectory.appendingPathComponent("MacOS") - resourcesDirectory = contentsDirectory.appendingPathComponent("Resources") + contentsDirectory = bundleDirectory / "Contents" + executableDirectory = contentsDirectory / "MacOS" + resourcesDirectory = contentsDirectory / "Resources" case .iOS, .tvOS, .visionOS: contentsDirectory = bundleDirectory executableDirectory = contentsDirectory resourcesDirectory = contentsDirectory } - librariesDirectory = contentsDirectory.appendingPathComponent("Libraries") - frameworksDirectory = contentsDirectory.appendingPathComponent("Frameworks") + librariesDirectory = contentsDirectory / "Libraries" + frameworksDirectory = contentsDirectory / "Frameworks" - infoPlistFile = contentsDirectory.appendingPathComponent("Info.plist") - pkgInfoFile = contentsDirectory.appendingPathComponent("PkgInfo") - provisioningProfileFile = contentsDirectory.appendingPathComponent( - "embedded.mobileprovision" - ) - appIconFile = resourcesDirectory.appendingPathComponent("AppIcon.icns") + infoPlistFile = contentsDirectory / "Info.plist" + pkgInfoFile = contentsDirectory / "PkgInfo" + provisioningProfileFile = contentsDirectory / "embedded.mobileprovision" + appIconFile = resourcesDirectory / "AppIcon.icns" - mainExecutable = executableDirectory.appendingPathComponent(appName) + mainExecutable = executableDirectory / appName } /// Attempts to create all directories within the app bundle. Ignores directories which diff --git a/Sources/SwiftBundler/Bundler/DarwinBundler.swift b/Sources/SwiftBundler/Bundler/DarwinBundler.swift index 271c47ee..ae3abfec 100644 --- a/Sources/SwiftBundler/Bundler/DarwinBundler.swift +++ b/Sources/SwiftBundler/Bundler/DarwinBundler.swift @@ -3,6 +3,7 @@ import Foundation /// The bundler for creating macOS apps. enum DarwinBundler: Bundler { static let outputIsRunnable = true + static let requiresBuildAsDylib = false struct Context { /// Whether the build products were created by Xcode or not. @@ -255,7 +256,7 @@ enum DarwinBundler: Bundler { // Simulators and hosts don't require provisioning profiles guard - case .connected(let device) = context.device, + case .connectedAppleDevice(let device) = context.device, !device.platform.isSimulator else { return nil diff --git a/Sources/SwiftBundler/Bundler/DeviceManager.swift b/Sources/SwiftBundler/Bundler/DeviceManager.swift index 261f9d93..1e13e936 100644 --- a/Sources/SwiftBundler/Bundler/DeviceManager.swift +++ b/Sources/SwiftBundler/Bundler/DeviceManager.swift @@ -18,7 +18,8 @@ enum DeviceManager { try await SwiftPackageManager.createPackage( in: dummyProject, - name: dummyProjectName + name: dummyProjectName, + toolchain: nil ) } catch { throw Error(.failedToCreateDummyProject) @@ -189,7 +190,7 @@ enum DeviceManager { applePlatform: parsedPlatform, name: name, id: id, - status: dictionary["error"].map(ConnectedDevice.Status.unavailable) + status: dictionary["error"].map(ConnectedAppleDevice.Status.unavailable) ?? .available ) } @@ -218,9 +219,9 @@ enum DeviceManager { // no way to disambiguate). Also put available devices above // unavailable ones. switch (first, second) { - case (.host, .connected): + case (.host, .connectedAppleDevice): return false - case (.connected(let first), .connected(let second)): + case (.connectedAppleDevice(let first), .connectedAppleDevice(let second)): if first.platform.isSimulator && !second.platform.isSimulator { return false } else if first.name.count > second.name.count { diff --git a/Sources/SwiftBundler/Bundler/FileSystem.swift b/Sources/SwiftBundler/Bundler/FileSystem.swift new file mode 100644 index 00000000..5272529e --- /dev/null +++ b/Sources/SwiftBundler/Bundler/FileSystem.swift @@ -0,0 +1,46 @@ +import Foundation + +enum FileSystem { + static func cacheDirectory() throws(Error) -> URL { + let directory = try Error.catch(withMessage: .failedToGetCacheDirectory) { + try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } + + let cacheDirectory = directory / SwiftBundler.identifier + if !cacheDirectory.exists() { + try Error.catch(withMessage: .failedToGetCacheDirectory) { + try FileManager.default.createDirectory(at: cacheDirectory) + } + } + return cacheDirectory + } + + static func swiftSDKSilosDirectory() throws(Error) -> URL { + let cacheDirectory = try cacheDirectory() + let silosDirectory = cacheDirectory / "sdk-silos" + if !silosDirectory.exists() { + try Error.catch(withMessage: .failedToCreateSwiftSDKSilosDirectory) { + try FileManager.default.createDirectory(at: silosDirectory) + } + } + return silosDirectory + } + + static func swiftSDKSiloDirectory( + forArtifactIdentifier identifier: String + ) throws(Error) -> URL { + let silos = try swiftSDKSilosDirectory() + let silo = silos / identifier + if !silo.exists() { + try Error.catch(withMessage: .failedToCreateSwiftSDKSiloDirectory(silo)) { + try FileManager.default.createDirectory(at: silo) + } + } + return silo + } +} diff --git a/Sources/SwiftBundler/Bundler/FileSystemError.swift b/Sources/SwiftBundler/Bundler/FileSystemError.swift new file mode 100644 index 00000000..efe81749 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/FileSystemError.swift @@ -0,0 +1,23 @@ +import Foundation +import ErrorKit + +extension FileSystem { + typealias Error = RichError + + enum ErrorMessage: Throwable { + case failedToGetCacheDirectory + case failedToCreateSwiftSDKSilosDirectory + case failedToCreateSwiftSDKSiloDirectory(URL) + + var userFriendlyMessage: String { + switch self { + case .failedToGetCacheDirectory: + return "Failed to locate or create a suitable cache directory" + case .failedToCreateSwiftSDKSilosDirectory: + return "Failed to create directory for Swift SDK silos" + case .failedToCreateSwiftSDKSiloDirectory(let silo): + return "Failed to create Swift SDK silo at '\(silo.path)'" + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/GenericLinuxBundler.swift b/Sources/SwiftBundler/Bundler/GenericLinuxBundler.swift index 8bc276f3..7fe13fe0 100644 --- a/Sources/SwiftBundler/Bundler/GenericLinuxBundler.swift +++ b/Sources/SwiftBundler/Bundler/GenericLinuxBundler.swift @@ -15,6 +15,7 @@ import Parsing /// or standalone executable. enum GenericLinuxBundler: Bundler { static let outputIsRunnable = true + static let requiresBuildAsDylib = false struct Context { /// Used in log messages to avoid exposing that everything's just the diff --git a/Sources/SwiftBundler/Bundler/GenericLinuxBundlerBundleStructure.swift b/Sources/SwiftBundler/Bundler/GenericLinuxBundlerBundleStructure.swift index 3bf136b8..bc02da7e 100644 --- a/Sources/SwiftBundler/Bundler/GenericLinuxBundlerBundleStructure.swift +++ b/Sources/SwiftBundler/Bundler/GenericLinuxBundlerBundleStructure.swift @@ -61,12 +61,10 @@ extension GenericLinuxBundler { /// Creates all directories (including intermediate directories) required to /// create this bundle structure. func createDirectories() throws(Error) { - do { + try Error.catch(withMessage: .failedToCreateBundleStructure(root: root)) { for directory in directories { try FileManager.default.createDirectory(at: directory) } - } catch { - throw Error(.failedToCreateBundleStructure(root: root)) } } diff --git a/Sources/SwiftBundler/Bundler/GenericWindowsBundler.swift b/Sources/SwiftBundler/Bundler/GenericWindowsBundler.swift index 3a4bf5ab..bf1169bb 100644 --- a/Sources/SwiftBundler/Bundler/GenericWindowsBundler.swift +++ b/Sources/SwiftBundler/Bundler/GenericWindowsBundler.swift @@ -12,9 +12,10 @@ import Foundation /// take the output and bundle it up into an often distro-specific package file /// or standalone executable. enum GenericWindowsBundler: Bundler { - static let outputIsRunnable = true + typealias Context = Void - struct Context {} + static let outputIsRunnable = true + static let requiresBuildAsDylib = false private static let dllBundlingAllowList: [String] = [ "swiftCore", @@ -57,12 +58,7 @@ enum GenericWindowsBundler: Bundler { context: BundlerContext, command: BundleCommand, manifest: PackageManifest - ) throws(Error) -> Context { - // GenericWindowsBundler's additional context only exists to allow other - // bundlers to configure it when building on top of it, so for command-line - // usage we can just use the defaults. - Context() - } + ) throws(Error) -> Context {} static func intendedOutput( in context: BundlerContext, diff --git a/Sources/SwiftBundler/Bundler/HotReloadingServer.swift b/Sources/SwiftBundler/Bundler/HotReloadingServer.swift index 316ea713..d9c139bc 100644 --- a/Sources/SwiftBundler/Bundler/HotReloadingServer.swift +++ b/Sources/SwiftBundler/Bundler/HotReloadingServer.swift @@ -110,6 +110,7 @@ func start( product: String, buildContext: GenericBuildContext, + swiftToolchain: URL?, appConfiguration: AppConfiguration.Flat ) async throws(Error) { let connection = try await accept() @@ -144,6 +145,7 @@ let context = SwiftPackageManager.BuildContext( genericContext: buildContext, + toolchain: swiftToolchain, hotReloadingEnabled: true, isGUIExecutable: true, compiledMetadata: compiledMetadata diff --git a/Sources/SwiftBundler/Bundler/Java.swift b/Sources/SwiftBundler/Bundler/Java.swift new file mode 100644 index 00000000..99bfa3ca --- /dev/null +++ b/Sources/SwiftBundler/Bundler/Java.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A utility for interacting with java. +enum Java { + /// Locates the user's configured Java executable. + static func locateJavaExecutable() async throws(Error) -> URL { + // Logic adapted from gradlew shell script + if let javaHomePath = ProcessInfo.processInfo.environment["JAVA_HOME"] { + let javaHome = URL(fileURLWithPath: javaHomePath) + + // IBM's JDK on AIX uses strange locations for the executables + let strangeLocation = javaHome / "jre/sh/java" + if strangeLocation.exists() { + return strangeLocation + } + + let location = javaHome / "bin/java" + guard location.exists() else { + throw Error(.invalidJavaHome(javaHome, executable: location)) + } + + return location + } else { + let location = try await Error.catch(withMessage: .javaNotFound) { + try await Process.locate("java") + } + return URL(fileURLWithPath: location) + } + } +} diff --git a/Sources/SwiftBundler/Bundler/JavaError.swift b/Sources/SwiftBundler/Bundler/JavaError.swift new file mode 100644 index 00000000..1bb49aa9 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/JavaError.swift @@ -0,0 +1,28 @@ +import ErrorKit +import Foundation + +extension Java { + typealias Error = RichError + + /// An error message related to ``Java``. + enum ErrorMessage: Throwable { + case invalidJavaHome(URL, executable: URL) + case javaNotFound + + var userFriendlyMessage: String { + switch self { + case .invalidJavaHome(let javaHome, let executable): + let executablePath = executable.path(relativeTo: javaHome) + return """ + $JAVA_HOME is set to an invalid directory '\(javaHome.path)'; execpted \ + to find java executable at '\(executablePath)' + """ + case .javaNotFound: + return """ + Could not locate java executable; java not found on $PATH, and \ + $JAVA_HOME not set + """ + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/MSIBundler.swift b/Sources/SwiftBundler/Bundler/MSIBundler.swift index a2689687..192b716f 100644 --- a/Sources/SwiftBundler/Bundler/MSIBundler.swift +++ b/Sources/SwiftBundler/Bundler/MSIBundler.swift @@ -9,6 +9,7 @@ enum MSIBundler: Bundler { typealias Context = Void static let outputIsRunnable = false + static let requiresBuildAsDylib = false static func intendedOutput( in context: BundlerContext, diff --git a/Sources/SwiftBundler/Bundler/MetadataInserter.swift b/Sources/SwiftBundler/Bundler/MetadataInserter.swift index cc8cda6c..c41ffb18 100644 --- a/Sources/SwiftBundler/Bundler/MetadataInserter.swift +++ b/Sources/SwiftBundler/Bundler/MetadataInserter.swift @@ -1,4 +1,5 @@ import Foundation +import Version /// Inserts metadata into executable files. /// @@ -15,7 +16,7 @@ enum MetadataInserter { /// The app's identifier. var appIdentifier: String /// The app's version. - var appVersion: String + var appVersion: Version /// Additional user-defined metadata. var additionalMetadata: [String: MetadataValue] } @@ -143,10 +144,12 @@ enum MetadataInserter { var platformArguments: [String] = [] if let platform = platform.asApplePlatform { - let target = platform.platform.targetTriple( - withArchitecture: architecture, - andPlatformVersion: platform.minimumSwiftSupportedVersion - ) + let target = try Error.catch { + try platform.platform.targetTriple( + withArchitecture: architecture, + andPlatformVersion: platform.minimumSwiftSupportedVersion + ) + } platformArguments += ["-target", target.description] let sdkPath = try await Error.catch(withMessage: .failedToGetSDKPath) { diff --git a/Sources/SwiftBundler/Bundler/PlistCreator.swift b/Sources/SwiftBundler/Bundler/PlistCreator.swift index e00c8fbf..afd9b79c 100644 --- a/Sources/SwiftBundler/Bundler/PlistCreator.swift +++ b/Sources/SwiftBundler/Bundler/PlistCreator.swift @@ -86,8 +86,8 @@ enum PlistCreator { "CFBundleInfoDictionaryVersion": "6.0", "CFBundleName": appName, "CFBundlePackageType": "APPL", - "CFBundleShortVersionString": configuration.version, - "CFBundleVersion": configuration.version, + "CFBundleShortVersionString": configuration.version.description, + "CFBundleVersion": configuration.version.description, "LSApplicationCategoryType": configuration.category, ] @@ -123,7 +123,7 @@ enum PlistCreator { ] entries["UINativeSizeClass"] = 1 entries["UIDeviceFamily"] = [7] - case .linux, .windows: + case .linux, .windows, .android: break } @@ -180,7 +180,7 @@ enum PlistCreator { // TODO: Make the produced Info.plist for visionOS identical to Xcode's entries["MinimumOSVersion"] = platformVersion entries["CFBundleSupportedPlatforms"] = ["XROS"] - case .linux, .windows: + case .linux, .windows, .android: break } diff --git a/Sources/SwiftBundler/Bundler/ProjectBuilder.swift b/Sources/SwiftBundler/Bundler/ProjectBuilder.swift index 7d6ae080..b044832d 100644 --- a/Sources/SwiftBundler/Bundler/ProjectBuilder.swift +++ b/Sources/SwiftBundler/Bundler/ProjectBuilder.swift @@ -20,6 +20,7 @@ enum ProjectBuilder { _ dependencies: [AppConfiguration.Dependency], packageConfiguration: PackageConfiguration.Flat, context: GenericBuildContext, + swiftToolchain: URL?, appName: String, dryRun: Bool ) async throws(Error) -> [String: BuiltProduct] { @@ -28,8 +29,9 @@ enum ProjectBuilder { for dependency in dependencies { try await buildDependency( dependency, - context: context, packageConfiguration: packageConfiguration, + context: context, + swiftToolchain: swiftToolchain, appName: appName, dryRun: dryRun, builtProjects: &builtProjects, @@ -41,8 +43,9 @@ enum ProjectBuilder { private static func buildDependency( _ dependency: AppConfiguration.Dependency, - context: GenericBuildContext, packageConfiguration: PackageConfiguration.Flat, + context: GenericBuildContext, + swiftToolchain: URL?, appName: String, dryRun: Bool, builtProjects: inout Set, @@ -59,6 +62,7 @@ enum ProjectBuilder { let (productName, builtProduct) = try await buildRootProjectProduct( dependency.product, context: context, + swiftToolchain: swiftToolchain, dryRun: dryRun ) builtProducts[productName] = builtProduct @@ -113,7 +117,8 @@ enum ProjectBuilder { projectName, configuration: project, packageDirectory: context.projectDirectory, - scratchDirectory: projectScratchDirectory + scratchDirectory: projectScratchDirectory, + swiftToolchain: swiftToolchain ) } catch { throw Error(.failedToBuildProject(name: projectName), cause: error) @@ -180,12 +185,14 @@ enum ProjectBuilder { static func buildRootProjectProduct( _ product: String, context: GenericBuildContext, + swiftToolchain: URL?, dryRun: Bool ) async throws(Error) -> (String, BuiltProduct) { let manifest: PackageManifest do { manifest = try await SwiftPackageManager.loadPackageManifest( - from: context.projectDirectory + from: context.projectDirectory, + toolchain: swiftToolchain ) } catch { throw Error(.failedToBuildRootProjectProduct(name: product), cause: error) @@ -211,6 +218,7 @@ enum ProjectBuilder { // Build product let buildContext = SwiftPackageManager.BuildContext( genericContext: context, + toolchain: swiftToolchain, hotReloadingEnabled: false, isGUIExecutable: false ) @@ -347,7 +355,8 @@ enum ProjectBuilder { _ name: String, configuration: ProjectConfiguration.Flat, packageDirectory: URL, - scratchDirectory: ScratchDirectoryStructure + scratchDirectory: ScratchDirectoryStructure, + swiftToolchain: URL? ) async throws(Error) { // Just sitting here to raise alarms when more types are added switch configuration.builder.type { @@ -364,11 +373,13 @@ enum ProjectBuilder { try await createBuilderPackage( for: configuration, packageDirectory: packageDirectory, - scratchDirectory: scratchDirectory + scratchDirectory: scratchDirectory, + swiftToolchain: swiftToolchain ) let builder = try await buildBuilder( for: configuration, - scratchDirectory: scratchDirectory + scratchDirectory: scratchDirectory, + swiftToolchain: swiftToolchain ) try await runBuilder( builder, @@ -380,7 +391,8 @@ enum ProjectBuilder { static func createBuilderPackage( for configuration: ProjectConfiguration.Flat, packageDirectory: URL, - scratchDirectory: ScratchDirectoryStructure + scratchDirectory: ScratchDirectoryStructure, + swiftToolchain: URL? ) async throws(Error) { // Create builder source file symlink try Error.catch(withMessage: .failedToSymlinkBuilderSourceFile) { @@ -396,7 +408,7 @@ enum ProjectBuilder { // Create/update the builder's Package.swift let toolsVersion = try await Error.catch { - try await SwiftPackageManager.getToolsVersion(packageDirectory) + try await SwiftPackageManager.getToolsVersion(packageDirectory, toolchain: swiftToolchain) } let manifestContents = generateBuilderPackageManifest( @@ -415,7 +427,8 @@ enum ProjectBuilder { static func buildBuilder( for configuration: ProjectConfiguration.Flat, - scratchDirectory: ScratchDirectoryStructure + scratchDirectory: ScratchDirectoryStructure, + swiftToolchain: URL? ) async throws(Error) -> URL { // Build the builder let buildContext = SwiftPackageManager.BuildContext( @@ -423,10 +436,11 @@ enum ProjectBuilder { projectDirectory: scratchDirectory.builder, scratchDirectory: scratchDirectory.builder / ".build", configuration: .debug, - architectures: [.current], + architectures: [.host], platform: HostPlatform.hostPlatform.platform, additionalArguments: [] ), + toolchain: swiftToolchain, isGUIExecutable: false ) diff --git a/Sources/SwiftBundler/Bundler/RPMBuildDirectory.swift b/Sources/SwiftBundler/Bundler/RPMBuildDirectory.swift index bacc9400..421fddba 100644 --- a/Sources/SwiftBundler/Bundler/RPMBuildDirectory.swift +++ b/Sources/SwiftBundler/Bundler/RPMBuildDirectory.swift @@ -1,4 +1,5 @@ import Foundation +import Version extension RPMBundler { /// The structure of an `rpmbuild` directory. @@ -23,7 +24,7 @@ extension RPMBundler { /// Describes the structure of an `rpmbuild` directory. Doesn't create /// anything on disk (see ``RPMBuildDirectory/createDirectories()``). - init(at root: URL, escapedAppName: String, appVersion: String) { + init(at root: URL, escapedAppName: String, appVersion: Version) { self.root = root build = root / "BUILD" buildRoot = root / "BUILDROOT" diff --git a/Sources/SwiftBundler/Bundler/RPMBundler.swift b/Sources/SwiftBundler/Bundler/RPMBundler.swift index cc504770..18a55756 100644 --- a/Sources/SwiftBundler/Bundler/RPMBundler.swift +++ b/Sources/SwiftBundler/Bundler/RPMBundler.swift @@ -1,5 +1,6 @@ import Foundation import Parsing +import Version /// The bundler for creating Linux RPM packages. The output of this bundler /// isn't executable. @@ -7,6 +8,7 @@ enum RPMBundler: Bundler { typealias Context = Void static let outputIsRunnable = false + static let requiresBuildAsDylib = false static func intendedOutput( in context: BundlerContext, @@ -146,7 +148,7 @@ enum RPMBundler: Bundler { static func generateSpec( escapedAppName: String, appIdentifier: String, - appVersion: String, + appVersion: Version, appDescription: String, appLicense: String, bundleStructure: GenericLinuxBundler.BundleStructure, diff --git a/Sources/SwiftBundler/Bundler/ResourceBundler.swift b/Sources/SwiftBundler/Bundler/ResourceBundler.swift index 5fa87a5a..7551cf18 100644 --- a/Sources/SwiftBundler/Bundler/ResourceBundler.swift +++ b/Sources/SwiftBundler/Bundler/ResourceBundler.swift @@ -141,9 +141,9 @@ enum ResourceBundler { ) case .iOS, .iOSSimulator, .visionOS, .visionOSSimulator, .tvOS, .tvOSSimulator: destinationBundleResources = destinationBundle - case .linux, .windows: + case .linux, .windows, .android: // TODO: Implement on Linux and Windows if neccessary - fatalError("TODO: Implement resource bundle fixing for linux and Windows") + fatalError("TODO: Implement resource bundle fixing for Linux, Windows, and Android") } } @@ -227,9 +227,9 @@ enum ResourceBundler { directory = bundleResources case .iOS, .iOSSimulator, .visionOS, .visionOSSimulator, .tvOS, .tvOSSimulator: directory = bundle - case .linux, .windows: + case .linux, .windows, .android: // TODO: Implement for linux - fatalError("TODO: Implement resource bundle fixing on Linux and Windows") + fatalError("TODO: Implement resource bundle fixing on Linux, Windows, and Android") } try FileManager.default.createDirectory( @@ -262,9 +262,9 @@ enum ResourceBundler { infoPlist = bundle .appendingPathComponent("Info.plist") - case .linux, .windows: + case .linux, .windows, .android: // TODO: Implement for Linux and Windows - fatalError("Implement for Linux and Windows") + fatalError("Implement for Linux, Windows, and Android") } do { diff --git a/Sources/SwiftBundler/Bundler/Runner/ConnectedAndroidDevice.swift b/Sources/SwiftBundler/Bundler/Runner/ConnectedAndroidDevice.swift new file mode 100644 index 00000000..c6977346 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/Runner/ConnectedAndroidDevice.swift @@ -0,0 +1,5 @@ +/// A connected android device. +struct ConnectedAndroidDevice: Equatable { + let name: String + let id: String +} diff --git a/Sources/SwiftBundler/Bundler/Runner/ConnectedDevice.swift b/Sources/SwiftBundler/Bundler/Runner/ConnectedAppleDevice.swift similarity index 85% rename from Sources/SwiftBundler/Bundler/Runner/ConnectedDevice.swift rename to Sources/SwiftBundler/Bundler/Runner/ConnectedAppleDevice.swift index f08fde74..7b25412e 100644 --- a/Sources/SwiftBundler/Bundler/Runner/ConnectedDevice.swift +++ b/Sources/SwiftBundler/Bundler/Runner/ConnectedAppleDevice.swift @@ -1,5 +1,5 @@ -/// A connected device or simulator. -struct ConnectedDevice: Equatable { +/// A connected apple device or simulator. +struct ConnectedAppleDevice: Equatable { let platform: NonMacApplePlatform let name: String let id: String diff --git a/Sources/SwiftBundler/Bundler/Runner/Device.swift b/Sources/SwiftBundler/Bundler/Runner/Device.swift index 13d430df..313b8972 100644 --- a/Sources/SwiftBundler/Bundler/Runner/Device.swift +++ b/Sources/SwiftBundler/Bundler/Runner/Device.swift @@ -8,7 +8,8 @@ enum Device: Equatable, CustomStringConvertible { /// Mac Catalyst, so it can't live under the `.host` case. But for all intents /// and purposes, this `.macCatalyst` case functions very similarly to `.host`. case macCatalyst - case connected(ConnectedDevice) + case connectedAppleDevice(ConnectedAppleDevice) + case connectedAndroidDevice(ConnectedAndroidDevice) var description: String { switch self { @@ -16,8 +17,10 @@ enum Device: Equatable, CustomStringConvertible { return "\(platform.platform.name) host machine" case .macCatalyst: return "Mac Catalyst host machine" - case .connected(let device): + case .connectedAppleDevice(let device): return "\(device.name) (\(device.platform.platform), id: \(device.id))" + case .connectedAndroidDevice(let device): + return "\(device.name)" } } @@ -25,7 +28,9 @@ enum Device: Equatable, CustomStringConvertible { switch self { case .host, .macCatalyst: return nil - case .connected(let device): + case .connectedAppleDevice(let device): + return device.id + case .connectedAndroidDevice(let device): return device.id } } @@ -36,8 +41,10 @@ enum Device: Equatable, CustomStringConvertible { return platform.platform case .macCatalyst: return .macCatalyst - case .connected(let device): + case .connectedAppleDevice(let device): return device.platform.platform + case .connectedAndroidDevice: + return .android } } @@ -45,7 +52,7 @@ enum Device: Equatable, CustomStringConvertible { applePlatform platform: ApplePlatform, name: String, id: String, - status: ConnectedDevice.Status + status: ConnectedAppleDevice.Status ) { switch platform.partitioned { case .macOS: @@ -68,14 +75,14 @@ enum Device: Equatable, CustomStringConvertible { nonMacApplePlatform platform: NonMacApplePlatform, name: String, id: String, - status: ConnectedDevice.Status + status: ConnectedAppleDevice.Status ) { - let device = ConnectedDevice( + let device = ConnectedAppleDevice( platform: platform, name: name, id: id, status: status ) - self = .connected(device) + self = .connectedAppleDevice(device) } } diff --git a/Sources/SwiftBundler/Bundler/Runner/Runner.swift b/Sources/SwiftBundler/Bundler/Runner/Runner.swift index fa6ba4d9..bc3ab0dc 100644 --- a/Sources/SwiftBundler/Bundler/Runner/Runner.swift +++ b/Sources/SwiftBundler/Bundler/Runner/Runner.swift @@ -59,9 +59,17 @@ enum Runner { arguments: arguments, environmentVariables: environmentVariables ) - case .connected(let connectedDevice): + case .connectedAppleDevice(let connectedAppleDevice): try await runApp( - on: connectedDevice, + on: connectedAppleDevice, + bundlerOutput: bundlerOutput, + bundleIdentifier: bundleIdentifier, + arguments: arguments, + environmentVariables: environmentVariables + ) + case .connectedAndroidDevice(let connectedAndroidDevice): + try await runApp( + on: connectedAndroidDevice, bundlerOutput: bundlerOutput, bundleIdentifier: bundleIdentifier, arguments: arguments, @@ -179,7 +187,7 @@ enum Runner { } } - /// Runs an app on a connected device or simulator. + /// Runs an app on a connected Apple device or Apple simulator. /// - Parameters: /// - connectedDevice: The device/simulator to run the app on. /// - bundlerOutput: The output of the bundler. @@ -187,7 +195,7 @@ enum Runner { /// - arguments: Command line arguments to pass to the app. /// - environmentVariables: Environment variables to pass to the app. static func runApp( - on connectedDevice: ConnectedDevice, + on connectedDevice: ConnectedAppleDevice, bundlerOutput: BundlerOutputStructure, bundleIdentifier: String, arguments: [String], @@ -212,6 +220,23 @@ enum Runner { } } + /// Runs an app on a connected Android device or Android emulator. + /// - Parameters: + /// - connectedDevice: The device/simulator to run the app on. + /// - bundlerOutput: The output of the bundler. + /// - bundleIdentifier: The app's bundle identifier. + /// - arguments: Command line arguments to pass to the app. + /// - environmentVariables: Environment variables to pass to the app. + static func runApp( + on connectedDevice: ConnectedAndroidDevice, + bundlerOutput: BundlerOutputStructure, + bundleIdentifier: String, + arguments: [String], + environmentVariables: [String: String] + ) async throws(Error) { + fatalError("TODO") + } + static func runAppOnPhysicalDevice( deviceId: String, bundlerOutput: BundlerOutputStructure, diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/ArtifactBundleMetadata.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/ArtifactBundleMetadata.swift new file mode 100644 index 00000000..24f534f1 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/ArtifactBundleMetadata.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A parsed SwiftPM artifactbundle info.json file. +struct ArtifactBundleMetadata: Decodable, Equatable, Sendable { + struct Artifact: Decodable, Equatable, Sendable { + var variants: [Variant] + var version: String + var type: ArtifactType + + struct Variant: Decodable, Equatable, Sendable { + var path: String + var supportedHostTriples: [String]? + } + } + + enum ArtifactType: Decodable, Hashable, Sendable { + case swiftSDK + case unknown(String) + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + switch value { + case "swiftSDK": + self = .swiftSDK + default: + self = .unknown(value) + } + } + } + + var schemaVersion: String + var artifacts: [String: Artifact] +} diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/BuildArchitecture.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/BuildArchitecture.swift index 13321760..dba92014 100644 --- a/Sources/SwiftBundler/Bundler/SwiftPackageManager/BuildArchitecture.swift +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/BuildArchitecture.swift @@ -5,25 +5,39 @@ import Foundation enum BuildArchitecture: String, CaseIterable, ExpressibleByArgument { case x86_64 // swiftlint:disable:this identifier_name case arm64 + case armv7 #if arch(x86_64) - static let current: BuildArchitecture = .x86_64 + static let host: BuildArchitecture = .x86_64 #elseif arch(arm64) - static let current: BuildArchitecture = .arm64 + static let host: BuildArchitecture = .arm64 #endif var defaultValueDescription: String { rawValue } - /// Gets the argument's name in the form required for use in build arguments. Some platforms use - /// different names for architectures. + /// Gets the argument's name in the form required for use in build arguments. + /// Some platforms use different names for architectures. func argument(for platform: Platform) -> String { switch (platform, self) { - case (.linux, .arm64), (.windows, .arm64): + case (.linux, .arm64), (.android, .arm64), (.windows, .arm64): return "aarch64" default: return rawValue } } + + /// The name that Android tools use for the architecture. This is + /// different to the name that SwiftPM uses. + var androidName: String { + switch self { + case .arm64: + "arm64-v8a" + case .armv7: + "armeabi-v7a" + case .x86_64: + "x86_64" + } + } } diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/LLVMTargetTriple.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/LLVMTargetTriple.swift index df29dcca..c1bb6916 100644 --- a/Sources/SwiftBundler/Bundler/SwiftPackageManager/LLVMTargetTriple.swift +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/LLVMTargetTriple.swift @@ -11,9 +11,23 @@ struct LLVMTargetTriple: CustomStringConvertible { var abi: ABI? var description: String { - var triple = "\(architecture.rawValue)-\(vendor.rawValue)-\(system.description)" + // TODO: Ideally we'd be able to reuse BuildArchitecture.argument(for:) here + var triple = "" + switch architecture { + case .x86_64, .armv7: + triple = architecture.rawValue + case .arm64: + switch vendor { + case .apple: + triple = architecture.rawValue + case .unknown: + triple = "aarch64" + } + } + + triple += "-\(vendor.rawValue)-\(system.description)" if let abi { - triple += "-\(abi.rawValue)" + triple += "-\(abi.description)" } return triple } @@ -50,9 +64,11 @@ struct LLVMTargetTriple: CustomStringConvertible { static let linux = Self(name: "linux") static let windows = Self(name: "windows") + + static let darwin = Self(name: "darwin") } - enum ABI: String { + enum ABI: CustomStringConvertible { /// The ABI used to target Apple platform simulators. case simulator /// The ABI usually used by Swift on Linux. @@ -61,6 +77,18 @@ struct LLVMTargetTriple: CustomStringConvertible { case msvc /// The ABI used by Mac Catalyst. case macabi + /// The ABI used by Android. + case android(api: Int) + + var description: String { + switch self { + case .simulator: "simulator" + case .gnu: "gnu" + case .msvc: "msvc" + case .macabi: "macabi" + case .android(let api): "android\(api)" + } + } } /// Creates the target triple for the specified Apple platform. @@ -94,4 +122,13 @@ struct LLVMTargetTriple: CustomStringConvertible { abi: .msvc ) } + + static func android(_ architecture: BuildArchitecture, api: Int) -> Self { + Self( + architecture: architecture, + vendor: .unknown, + system: .linux, + abi: .android(api: api) + ) + } } diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/OS.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/OS.swift index 8934d4cc..6a967e86 100644 --- a/Sources/SwiftBundler/Bundler/SwiftPackageManager/OS.swift +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/OS.swift @@ -8,6 +8,7 @@ enum OS: String, CaseIterable { case tvOS case linux case windows + case android /// The display name of the os. var name: String { @@ -18,6 +19,8 @@ enum OS: String, CaseIterable { return "Linux" case .windows: return "Windows" + case .android: + return "Android" } } } diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/Platform.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/Platform.swift index 48d6f880..6b90c299 100644 --- a/Sources/SwiftBundler/Bundler/SwiftPackageManager/Platform.swift +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/Platform.swift @@ -1,7 +1,25 @@ import Foundation +import ErrorKit /// A platform to build for. enum Platform: String, CaseIterable { + typealias Error = RichError + + /// An error message related to ``Platform``. + enum ErrorMessage: Throwable { + case invalidAndroidAPIVersion(String) + case platformVersionRequiredForPlatform(Platform) + + var userFriendlyMessage: String { + switch self { + case .invalidAndroidAPIVersion(let version): + "Invalid Android API version '\(version)'; expected integer" + case .platformVersionRequiredForPlatform(let platform): + "Platform '\(platform)' requires a platform version to create a target triple" + } + } + } + case macOS case macCatalyst case iOS @@ -12,10 +30,12 @@ enum Platform: String, CaseIterable { case tvOSSimulator case linux case windows + case android enum Partitioned { case linux case windows + case android case apple(ApplePlatform) } @@ -27,6 +47,8 @@ enum Platform: String, CaseIterable { return .linux case .windows: return .windows + case .android: + return .android case .macOS: return .apple(.macOS) case .macCatalyst: @@ -69,6 +91,8 @@ enum Platform: String, CaseIterable { return "Linux" case .windows: return "Windows" + case .android: + return "Android" } } @@ -86,7 +110,7 @@ enum Platform: String, CaseIterable { "maccatalyst" case .iOS, .iOSSimulator, .visionOS, .visionOSSimulator, .tvOS, .tvOSSimulator: sdkName - case .macOS, .linux, .windows: + case .macOS, .linux, .windows, .android: nil } } @@ -113,6 +137,8 @@ enum Platform: String, CaseIterable { return "linux" case .windows: return "windows" + case .android: + return "android" } } @@ -122,11 +148,51 @@ enum Platform: String, CaseIterable { switch partitioned { case .apple(let applePlatform): applePlatform.usesHostArchitecture + case .android: + false case .linux, .windows: true } } + /// The set of compilation architectures supported by the platform. + var supportedCompilationArchitectures: [BuildArchitecture] { + switch self { + case .macOS, .macCatalyst: + [.x86_64, .arm64] + case .iOS, .visionOS, .tvOS: + [.arm64] + case .linux, .windows: + [.x86_64, .arm64] + case .android: + [.x86_64, .arm64, .armv7] + case .iOSSimulator, .visionOSSimulator, .tvOSSimulator: + [.x86_64, .arm64] + } + } + + /// Whether the platform supports multi-architecture builds. + var supportsMultiArchitectureBuilds: Bool { + switch self { + case .macOS, .macCatalyst, .iOSSimulator, .visionOSSimulator, .tvOSSimulator: + true + case .linux, .windows, .android, .iOS, .visionOS, .tvOS: + false + } + } + + /// Default compilation architecture used when targeting the platform without + /// a user-specified target architecture. + func defaultCompilationArchitecture(_ host: BuildArchitecture) -> BuildArchitecture { + switch self { + case .macOS, .macCatalyst, .linux, .windows, .iOSSimulator, + .visionOSSimulator, .tvOSSimulator: + host + case .android, .iOS, .visionOS, .tvOS: + .arm64 + } + } + /// Whether the platform is a simulator or not. var isSimulator: Bool { asApplePlatform?.isSimulator == true @@ -144,7 +210,7 @@ enum Platform: String, CaseIterable { case .visionOSSimulator: return .visionOSSimulator case .tvOS: return .tvOS case .tvOSSimulator: return .tvOSSimulator - case .linux, .windows: return nil + case .linux, .windows, .android: return nil } } @@ -163,6 +229,7 @@ enum Platform: String, CaseIterable { case .tvOS, .tvOSSimulator: return .tvOS case .linux: return .linux case .windows: return .windows + case .android: return .android } } @@ -171,7 +238,7 @@ enum Platform: String, CaseIterable { switch self { case .windows: return "exe" - case .macOS, .macCatalyst, .linux, .iOS, .iOSSimulator, + case .macOS, .macCatalyst, .linux, .android, .iOS, .iOSSimulator, .tvOS, .tvOSSimulator, .visionOS, .visionOSSimulator: return nil } @@ -191,31 +258,61 @@ enum Platform: String, CaseIterable { } } + /// Gets the target triple corresponding to this platform, the given architecture, + /// and the given platform version. func targetTriple( withArchitecture architecture: BuildArchitecture, - andPlatformVersion platformVersion: String - ) -> LLVMTargetTriple { - switch self { - case .iOS: - return .apple(architecture, .iOS(platformVersion)) - case .visionOS: - return .apple(architecture, .visionOS(platformVersion)) - case .tvOS: - return .apple(architecture, .tvOS(platformVersion)) - case .iOSSimulator: - return .apple(architecture, .iOS(platformVersion), .simulator) - case .visionOSSimulator: - return .apple(architecture, .visionOS(platformVersion), .simulator) - case .tvOSSimulator: - return .apple(architecture, .tvOS(platformVersion), .simulator) - case .macOS: - return .apple(architecture, .macOS(platformVersion)) - case .macCatalyst: - return .apple(architecture, .iOS(platformVersion), .macabi) + andPlatformVersion platformVersion: String? + ) throws(Error) -> LLVMTargetTriple { + switch self.partitioned { + case .apple(let applePlatform): + guard let platformVersion else { + throw Error(.platformVersionRequiredForPlatform(self)) + } + switch applePlatform { + case .iOS: + return .apple(architecture, .iOS(platformVersion)) + case .visionOS: + return .apple(architecture, .visionOS(platformVersion)) + case .tvOS: + return .apple(architecture, .tvOS(platformVersion)) + case .iOSSimulator: + return .apple(architecture, .iOS(platformVersion), .simulator) + case .visionOSSimulator: + return .apple(architecture, .visionOS(platformVersion), .simulator) + case .tvOSSimulator: + return .apple(architecture, .tvOS(platformVersion), .simulator) + case .macOS: + return .apple(architecture, .macOS(platformVersion)) + case .macCatalyst: + return .apple(architecture, .iOS(platformVersion), .macabi) + } case .linux: return .linux(architecture) case .windows: return .windows(architecture) + case .android: + guard let platformVersion else { + throw Error(.platformVersionRequiredForPlatform(self)) + } + guard let api = Int(platformVersion) else { + throw Error(.invalidAndroidAPIVersion(platformVersion)) + } + return .android(architecture, api: api) + } + } + + static let androidAPI = 28 + + /// Gets the platform version corresponding to this platform. + func platformVersion(from manifest: PackageManifest) -> String? { + if let platform = self.asApplePlatform { + manifest.platformVersion(for: platform) + } else if self == .android { + // TODO: Make this configurable + String(Self.androidAPI) + } else { + nil } } } diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManager.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManager.swift index 135999e3..cb7218fd 100644 --- a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManager.swift +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManager.swift @@ -11,6 +11,8 @@ enum SwiftPackageManager { struct BuildContext { /// Generic build context properties shared between most build systems. var genericContext: GenericBuildContext + /// An alternative Swift toolchain to use. + var toolchain: URL? /// Controls whether the hot reloading environment variables are added to /// the build command or not. var hotReloadingEnabled: Bool = false @@ -28,10 +30,12 @@ enum SwiftPackageManager { /// - Parameters: /// - directory: The package's root directory (will be created if it doesn't exist). /// - name: The name for the package. + /// - toolchain: An alternative Swift toolchain to use. /// - Returns: If an error occurs, a failure is returned. static func createPackage( in directory: URL, - name: String + name: String, + toolchain: URL? ) async throws(Error) { // Create the package directory if it doesn't exist if !directory.exists(withType: .directory) { @@ -49,7 +53,7 @@ enum SwiftPackageManager { ] let process = Process.create( - "swift", + swiftPath(toolchain: toolchain), arguments: arguments, directory: directory ) @@ -60,6 +64,13 @@ enum SwiftPackageManager { } } + /// Gets the path of the Swift executable for the given toolchain (if any). + /// Returns the literal string `"swift"` when no toolchain is specified, so + /// that Process can perform its usual executable path resolution. + private static func swiftPath(toolchain: URL?) -> String { + toolchain.map { $0 / "usr/bin/swift" }.map(\.path) ?? "swift" + } + /// Builds the specified product of a Swift package. /// - Parameters: /// - product: The product to build. @@ -75,7 +86,7 @@ enum SwiftPackageManager { ) let process = Process.create( - "swift", + swiftPath(toolchain: buildContext.toolchain), arguments: arguments, directory: buildContext.genericContext.projectDirectory, runSilentlyWhenNotVerbose: false @@ -117,7 +128,16 @@ enum SwiftPackageManager { ) async throws(Error) -> URL { #if os(macOS) let productsDirectory = try await SwiftPackageManager.getProductsDirectory(buildContext) - let dylibFile = productsDirectory / "lib\(product).dylib" + let dylibExtension: String + switch buildContext.genericContext.platform { + case .macOS: + dylibExtension = "dylib" + case .android: + dylibExtension = "so" + case let platform: + throw Error(.cannotCompileExecutableAsDylibForPlatform(platform)) + } + let dylibFile = productsDirectory / "lib\(product).\(dylibExtension)" try await build( product: product, @@ -128,11 +148,26 @@ enum SwiftPackageManager { / "\(buildContext.genericContext.configuration).yaml" let buildPlan = try readBuildPlan(buildPlanFile) - let targetInfo = try await getHostTargetInfo() + let triple: String + switch buildContext.genericContext.platform { + case .macOS: + let targetInfo = try await getHostTargetInfo(toolchain: buildContext.toolchain) + triple = targetInfo.target.triple + case .android: + triple = try Error.catch { + try buildContext.genericContext.platform.targetTriple( + // TODO: Clean this up so that we don't have to assume that there's exactly 1 + // architecture specified for Android builds (make it more verifiable). + withArchitecture: buildContext.genericContext.architectures[0], + andPlatformVersion: buildContext.genericContext.platformVersion + ).description + } + case let platform: + throw Error(.cannotCompileExecutableAsDylibForPlatform(platform)) + } // Swift versions before 6.0 or so named commands differently in the build plan. // We check for the newer format (with triple) then the older format (no triple). - let triple = targetInfo.target.triple let configuration = buildContext.genericContext.configuration let commandName = "C.\(product)-\(triple)-\(configuration).exe" let oldCommandName = "C.\(product)-\(configuration).exe" @@ -159,12 +194,28 @@ enum SwiftPackageManager { modifiedArguments.remove(at: index) modifiedArguments.remove(at: index) - modifiedArguments.append(contentsOf: [ - "-o", - dylibFile.path, - "-Xcc", - "-dynamiclib", - ]) + modifiedArguments.append(contentsOf: ["-o", dylibFile.path]) + + switch buildContext.genericContext.platform { + case .macOS: + modifiedArguments.append(contentsOf: [ + "-Xcc", + "-dynamiclib", + ]) + case .android: + modifiedArguments.removeAll { $0 == "-emit-executable" } + modifiedArguments.append("-emit-library") + // If we don't set an soname, then the library gets linked at its + // absolute path on the host machine when used with CMake in gradle + // projects. That leads to a runtime linker error when running the + // built app on an Android device, because the absolute path of the + // library on the host machine doesn't exist on the Android device. + modifiedArguments.append(contentsOf: [ + "-Xlinker", "-soname", "-Xlinker", dylibFile.lastPathComponent + ]) + case let platform: + throw Error(.cannotCompileExecutableAsDylibForPlatform(platform)) + } do { let process = Process.create( @@ -229,12 +280,14 @@ enum SwiftPackageManager { guard let platformVersion = buildContext.genericContext.platformVersion else { throw Error(.missingDarwinPlatformVersion(platform)) } - let hostArchitecture = BuildArchitecture.current + let hostArchitecture = BuildArchitecture.host - let targetTriple = platform.targetTriple( - withArchitecture: platform.usesHostArchitecture ? hostArchitecture : .arm64, - andPlatformVersion: platformVersion - ) + let targetTriple = try Error.catch { + try platform.targetTriple( + withArchitecture: platform.usesHostArchitecture ? hostArchitecture : .arm64, + andPlatformVersion: platformVersion + ) + } platformArguments = [ @@ -256,14 +309,53 @@ enum SwiftPackageManager { "-isystem", "\(sdkPath)/System/iOSSupport/usr/include" ].flatMap { ["-Xcc", $0] } } + case .android: + guard buildContext.genericContext.architectures.count == 1 else { + throw Error(.cannotBuildForMultipleAndroidArchitecturesAtOnce) + } + + let targetTriple = try Error.catch { + try platform.targetTriple( + withArchitecture: buildContext.genericContext.architectures[0], + andPlatformVersion: "28" + ) + } + + let sdk = try Error.catch { + try SwiftSDKManager.locateSDKMatching( + hostPlatform: .hostPlatform, + hostArchitecture: .host, + targetTriple: targetTriple + ) + } + + let silo = try Error.catch { + try SwiftSDKManager.getPopulatedSDKSilo(forSDK: sdk) + } + + log.debug("Using Swift SDK silo at '\(silo.path)'") + + let debugArguments = buildContext.genericContext.configuration == .debug + ? ["-Xswiftc", "-g"] + : [] + + platformArguments = [ + "--swift-sdks-path", silo.path, + "--swift-sdk", sdk.triple + ] + debugArguments case .macOS, .linux: platformArguments = buildContext.genericContext.configuration == .debug ? ["-Xswiftc", "-g"] : [] } - let architectureArguments = buildContext.genericContext.architectures.flatMap { architecture in - ["--arch", architecture.argument(for: buildContext.genericContext.platform)] + let architectureArguments: [String] + if platformArguments.contains("--triple") { + architectureArguments = [] + } else { + architectureArguments = buildContext.genericContext.architectures.flatMap { architecture in + ["--arch", architecture.argument(for: buildContext.genericContext.platform)] + } } let productArguments = product.map { ["--product", $0] } ?? [] @@ -348,11 +440,12 @@ enum SwiftPackageManager { } /// Gets the version of the current Swift installation. + /// - Parameter toolchain: An alternative Swift toolchain to use. /// - Returns: The swift version. - static func getSwiftVersion() async throws(Error) -> Version { + static func getSwiftVersion(toolchain: URL?) async throws(Error) -> Version { let output = try await Error.catch(withMessage: .failedToGetSwiftVersion) { try await Process.create( - "swift", + swiftPath(toolchain: toolchain), arguments: ["--version"] ).getOutput() } @@ -375,7 +468,7 @@ enum SwiftPackageManager { ) let process = Process.create( - "swift", + swiftPath(toolchain: buildContext.toolchain), arguments: arguments + ["--show-bin-path"], directory: buildContext.genericContext.projectDirectory ) @@ -392,13 +485,16 @@ enum SwiftPackageManager { } /// Loads a root package manifest from a package's root directory. - /// - Parameter packageDirectory: The package's root directory. + /// - Parameters: + /// - packageDirectory: The package's root directory. + /// - toolchain: An alternative Swift toolchain to use. /// - Returns: The loaded manifest. static func loadPackageManifest( - from packageDirectory: URL + from packageDirectory: URL, + toolchain: URL? ) async throws(Error) -> PackageManifest { let process = Process.create( - "swift", + swiftPath(toolchain: toolchain), arguments: ["package", "describe", "--type", "json"], directory: packageDirectory ) @@ -424,10 +520,12 @@ enum SwiftPackageManager { } } - static func getHostTargetInfo() async throws(Error) -> SwiftTargetInfo { + /// Gets build target info about the host machine. + /// - Parameter toolchain: An alternative Swift toolchain to use. + static func getHostTargetInfo(toolchain: URL?) async throws(Error) -> SwiftTargetInfo { // TODO: This could be a nice easy one to unit test let process = Process.create( - "swift", + swiftPath(toolchain: toolchain), arguments: ["-print-target-info"] ) @@ -443,10 +541,15 @@ enum SwiftPackageManager { } } - static func getToolsVersion(_ packageDirectory: URL) async throws(Error) -> Version { + /// Gets the Swift tools version. + /// - Parameter toolchain: An alternative Swift toolchain version to use. + static func getToolsVersion( + _ packageDirectory: URL, + toolchain: URL? + ) async throws(Error) -> Version { let version = try await Error.catch(withMessage: .failedToGetToolsVersion) { try await Process.create( - "swift", + swiftPath(toolchain: toolchain), arguments: ["package", "tools-version"], directory: packageDirectory ).getOutput() @@ -461,4 +564,34 @@ enum SwiftPackageManager { } return parsedVersion } + + /// Returns the standard locations of SwiftPM's config/data directory. Only + /// returns those that actually exist. + static func standardSwiftPMDirectories() -> [URL] { + var directories = [ + FileManager.default.homeDirectoryForCurrentUser / ".swiftpm" + ] + #if os(macOS) + directories += FileManager.default.urls( + for: .libraryDirectory, + in: .userDomainMask + ).map { $0 / "org.swift.swiftpm" } + #endif + directories = directories.map { directory in + directory.actuallyResolvingSymlinksInPath() + }.uniqued().filter { directory in + directory.exists() + } + return directories + } + + /// Parses the metadata of the provided artifactbundle. Metadata is read from + /// the bundle's info.json file. + static func parseArtifactBundle(_ bundle: URL) throws(Error) -> ArtifactBundleMetadata { + let infoFile = bundle / "info.json" + return try Error.catch(withMessage: .failedToReadArtifactBundleInfoJSON(infoFile)) { + let data = try Data(contentsOf: infoFile) + return try JSONDecoder().decode(ArtifactBundleMetadata.self, from: data) + } + } } diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManagerError.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManagerError.swift index 71551a5d..a86c07da 100644 --- a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManagerError.swift +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftPackageManagerError.swift @@ -22,6 +22,9 @@ extension SwiftPackageManager { case missingDarwinPlatformVersion(Platform) case failedToGetToolsVersion case invalidToolsVersion(String) + case cannotCompileExecutableAsDylibForPlatform(Platform) + case cannotBuildForMultipleAndroidArchitecturesAtOnce + case failedToReadArtifactBundleInfoJSON(URL) var userFriendlyMessage: String { switch self { @@ -63,6 +66,12 @@ extension SwiftPackageManager { return "Failed to get Swift package manifest tools version" case .invalidToolsVersion(let version): return "Invalid Swift tools version '\(version)' (expected a semantic version)" + case .cannotCompileExecutableAsDylibForPlatform(let platform): + return "Cannot compile executable as dylib for platform '\(platform)'" + case .cannotBuildForMultipleAndroidArchitecturesAtOnce: + return "Cannot build for multiple Android architectures at once" + case .failedToReadArtifactBundleInfoJSON(let file): + return "Failed to read artifactbundle's info.json at '\(file.path)'" } } } diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDK.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDK.swift new file mode 100644 index 00000000..638ec1d8 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDK.swift @@ -0,0 +1,118 @@ +import Foundation + +/// A Swift SDK that can be used to cross-compile a SwiftPM product. +struct SwiftSDK: Hashable, Sendable { + /// `nil` means assume that the host platform is supported. + var supportedHostTriples: [String]? + var triple: String + + var root: URL + var resourcesDirectory: URL + var staticResourcesDirectory: URL + var includeSearchDirectories: [URL] + var librarySearchDirectories: [URL] + var toolsetFiles: [URL] + + var bundle: URL + var artifactVariant: URL + var artifactIdentifier: String + + /// A unique identifier for the SDK assuming that it was loaded from disk. + /// Two non-equal programmatically-synthesized SDKs may have the same + /// 'uniqueIdentifier'. + var generallyUniqueIdentifier: String { + let variantPath = artifactVariant.path(relativeTo: bundle) + return "\(bundle.path):\(variantPath):\(triple)" + } + + init( + supportedHostTriples: [String]?, + triple: String, + bundle: URL, + artifactVariant: URL, + artifactIdentifier: String, + sdk: SwiftSDKManifest.SDK + ) { + self.supportedHostTriples = supportedHostTriples + self.triple = triple + self.bundle = bundle + self.artifactVariant = artifactVariant + self.artifactIdentifier = artifactIdentifier + root = artifactVariant / sdk.sdkRootPath + + // Ref: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0387-cross-compilation-destinations.md#swift-sdkjson-files + if let resourcesPath = sdk.swiftResourcesPath { + resourcesDirectory = artifactVariant / resourcesPath + } else { + resourcesDirectory = root / "usr/lib/swift" + } + if let staticResourcesPath = sdk.swiftStaticResourcesPath { + staticResourcesDirectory = artifactVariant / staticResourcesPath + } else { + staticResourcesDirectory = root / "usr/lib/swift_static" + } + if let includeSearchPaths = sdk.includeSearchPaths { + includeSearchDirectories = includeSearchPaths.map { [artifactVariant] in + artifactVariant / $0 + } + } else { + includeSearchDirectories = [root / "usr/include"] + } + if let librarySearchPaths = sdk.librarySearchPaths { + librarySearchDirectories = librarySearchPaths.map { [artifactVariant] in + artifactVariant / $0 + } + } else { + librarySearchDirectories = [root / "usr/lib"] + } + if let toolsetPaths = sdk.toolsetPaths { + toolsetFiles = toolsetPaths.map { [artifactVariant] in + artifactVariant / $0 + } + } else { + toolsetFiles = [] + } + } + + /// Gets whether the SDK supports the given host triple. + func supportsHostTriple(_ triple: LLVMTargetTriple) -> Bool { + if let supportedHostTriples { + supportedHostTriples.contains(triple.description) + } else { + true + } + } +} + +/// Custom `Encodable` implementation to represent URLs as paths instead of +/// `file://` URLs. +extension SwiftSDK: Encodable { + enum CodingKeys: String, CodingKey { + case supportedHostTriples + case triple + case root + case resourcesDirectory + case staticResourcesDirectory + case includeSearchDirectories + case librarySearchDirectories + case toolsetFiles + case bundle + case artifactVariant + case artifactIdentifier + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(supportedHostTriples, forKey: .supportedHostTriples) + try container.encode(triple, forKey: .triple) + try container.encode(root.path, forKey: .root) + try container.encode(resourcesDirectory.path, forKey: .resourcesDirectory) + try container.encode(staticResourcesDirectory.path, forKey: .staticResourcesDirectory) + try container.encode(includeSearchDirectories.map(\.path), forKey: .includeSearchDirectories) + try container.encode(librarySearchDirectories.map(\.path), forKey: .librarySearchDirectories) + try container.encode(toolsetFiles.map(\.path), forKey: .toolsetFiles) + try container.encode(bundle.path, forKey: .bundle) + try container.encode(artifactVariant.path, forKey: .artifactVariant) + try container.encode(artifactIdentifier, forKey: .artifactIdentifier) + } +} diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManager.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManager.swift new file mode 100644 index 00000000..48e8a5a0 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManager.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Locates and manages Swift SDKs. Does not involve itself with Xcode's platform +/// SDKs, only SwiftPM SDKs ([introduced in Swift 6.1](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0387-cross-compilation-destinations.md)). +enum SwiftSDKManager { + /// Swift SDK schema versions supported by this SDK manager. + static let supportedSchemaVersions = ["4.0"] + + /// Returns the standard locations of Swift's SDK installation directory. Only + /// returns those that actually exist. Resolves symlinks and guarantees no duplicates. + static func standardSDKDirectories() -> [URL] { + SwiftPackageManager.standardSwiftPMDirectories().map { directory in + directory / "swift-sdks" + }.map { directory in + directory.actuallyResolvingSymlinksInPath() + }.uniqued().filter { directory in + directory.exists() + } + } + + /// The host triple for the given platform and architecture as specified in + /// Swift SDK supported host triple lists. The main difference to usual triples + /// is that macOS gets represented as a `darwin` system instead of a `macosx` system. + static func sdkHostTriple( + forHostPlatform hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture + ) -> LLVMTargetTriple { + let vendor: LLVMTargetTriple.Vendor = switch hostPlatform { + case .macOS: .apple + case .linux, .windows: .unknown + } + let system: LLVMTargetTriple.System = switch hostPlatform { + case .macOS: .darwin + case .linux: .linux + case .windows: .windows + } + return LLVMTargetTriple( + architecture: hostArchitecture, + vendor: vendor, + system: system + ) + } + + /// Locates installed Swift SDKs matching the given host and target properties. + static func locateSDKsMatching( + hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture, + targetTriple: LLVMTargetTriple + ) throws(Error) -> [SwiftSDK] { + let sdks = try enumerateInstalledSwiftSDKs() + return filterSDKs( + sdks, + hostPlatform: hostPlatform, + hostArchitecture: hostArchitecture, + targetTriple: targetTriple + ) + } + + /// Locates a Swift SDK matching the given host and target properties. If multiple + /// matching SDKs are found, a warning is printed and one of the multiple SDKs is + /// returned. The SDK returned is unspecified, but should be consistent across runs. + static func locateSDKMatching( + hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture, + targetTriple: LLVMTargetTriple + ) throws(Error) -> SwiftSDK { + let sdks = try locateSDKsMatching( + hostPlatform: hostPlatform, + hostArchitecture: hostArchitecture, + targetTriple: targetTriple + ).sorted { first, second in + first.generallyUniqueIdentifier <= second.generallyUniqueIdentifier + } + + guard let sdk = sdks.first else { + throw Error(.noSDKsMatchQuery( + hostPlatform: hostPlatform, + hostArchitecture: hostArchitecture, + targetTriple: targetTriple + )) + } + + if sdks.count > 1 { + log.warning( + """ + Multiple SDKs match host platform '\(hostPlatform.platform.displayName)', \ + host architecture '\(hostArchitecture)', and target triple '\(targetTriple)': + \(sdks.map(\.generallyUniqueIdentifier).map { "* \($0)" }.joined(separator: "\n")) + Using \(sdk.generallyUniqueIdentifier) + """ + ) + } + + return sdk + } + + /// Filters a set of Swift SDKs to those matching the given host and target + /// properties. + static func filterSDKs( + _ sdks: [SwiftSDK], + hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture, + targetTriple: LLVMTargetTriple, + ) -> [SwiftSDK] { + let hostTriple = sdkHostTriple( + forHostPlatform: hostPlatform, + hostArchitecture: hostArchitecture + ) + let targetTriple = targetTriple.description + + return sdks.filter { sdk in + return sdk.supportsHostTriple(hostTriple) + && sdk.triple == targetTriple + } + } + + /// Enumerates Swift SDKs installed at standard locations. + static func enumerateInstalledSwiftSDKs() throws(Error) -> [SwiftSDK] { + try standardSDKDirectories().flatMap { sdkDirectory throws(Error) in + let artifactBundles = try Error.catch(withMessage: .failedToEnumerateSDKs(sdkDirectory)) { + try FileManager.default.contentsOfDirectory(at: sdkDirectory) + } + + var sdks: [SwiftSDK] = [] + for bundle in artifactBundles { + do { + let bundleSDKs = try enumerateSwiftSDKs(inArtifactBundle: bundle) + sdks.append(contentsOf: bundleSDKs) + } catch { + log.warning("\(chainDescription(for: error, verbose: log.logLevel <= .debug))") + } + } + + return sdks + } + } + + /// Enumerates the SDKs declared by a given artifact bundle. + static func enumerateSwiftSDKs(inArtifactBundle bundle: URL) throws(Error) -> [SwiftSDK] { + let metadata: ArtifactBundleMetadata + do { + metadata = try SwiftPackageManager.parseArtifactBundle(bundle) + } catch { + throw Error(.failedToParseArtifactBundleInfo(bundle), cause: error) + } + + // Each artifact bundle can contain multiple Swift SDKs + var sdks: [SwiftSDK] = [] + for (name, artifact) in metadata.artifacts where artifact.type == .swiftSDK { + for variant in artifact.variants { + let artifactVariant = bundle / variant.path + let manifest = try loadManifest(forSDKVariant: artifactVariant) + + for (triple, sdkManifest) in manifest.targetTriples { + let sdk = SwiftSDK( + supportedHostTriples: variant.supportedHostTriples, + triple: triple, + bundle: bundle, + artifactVariant: artifactVariant, + artifactIdentifier: name, + sdk: sdkManifest + ) + sdks.append(sdk) + } + } + } + + return sdks + } + + /// Loads the swift-sdk.json manifest file from the given Swift SDK artifact variant. + static func loadManifest(forSDKVariant variant: URL) throws(Error) -> SwiftSDKManifest { + let file = variant / "swift-sdk.json" + let data = try Error.catch { + try Data(contentsOf: file) + } + + // Loosely check schema version + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let schemaVersion = object[SwiftSDKManifest.CodingKeys.schemaVersion.rawValue] as? String + { + if !supportedSchemaVersions.contains(schemaVersion) { + log.warning( + """ + Unsupported Swift SDK schema version '\(schemaVersion)' in manifest \ + at '\(file.path)' (supported schema versions: \ + \(supportedSchemaVersions.joinedGrammatically())). Attempting to load \ + SDK anyway. Please create an issue at \ + https://github.com/moreSwift/swift-bundler/issues/new to notify us \ + about the new schema version + """ + ) + } + } else { + log.warning("Failed to extract schemaVersion from Swift SDK manifest at '\(file.path)'") + } + + return try Error.catch { + try JSONDecoder().decode(SwiftSDKManifest.self, from: data) + } + } + + /// Gets a ready-to-use SDK silo for the given SDK. + /// + /// An SDK silo is a directory containing a single SDK artifact bundle. We + /// use these to circumvent SwiftPM's SDK selection logic so that we can be + /// sure that the SDK we have located matches the one that Swift decides to + /// use. The main reason that this is necessary is that SwiftPM currently + /// (as of 29/01/2025) doesn't support disambiguating between SDKs that have + /// more than one supported target triple in common. The other reason is that + /// we will be able to easily support user supplied SDKs at arbitrary locations. + /// + /// We use symbolic links to efficiently construct silos. + static func getPopulatedSDKSilo(forSDK sdk: SwiftSDK) throws(Error) -> URL { + let silo = try Error.catch { + try FileSystem.swiftSDKSiloDirectory(forArtifactIdentifier: sdk.artifactIdentifier) + } + + // We add a stable hash to the end of the silo's name to prevent potential + // race conditions if two distinct SDKs with the same identifier are used + // in two concurrent Swift Bundler build invocations. + let pathHash = UInt32(truncatingIfNeeded: sdk.bundle.path.stableHash) + let pathHashString = String(format: "%08x", pathHash) + let link = silo / "\(sdk.bundle.lastPathComponent)-\(pathHashString)" + + let destination = sdk.bundle + if !link.exists() || link.actuallyResolvingSymlinksInPath() != destination { + try Error.catch { + // Even if the link exists, we could technically have hit a hash collision. + // It should be exceedingly for hashes to clash at the same time as a race + // condition, and if the hashes collide now then they'll collide every time, + // so we just fix the link if it's wrong. This way, the only bug we're opening + // ourselves to is race conditions, rather than race conditions AND hash + // collisions. And we've successfully reduced the chance of race conditions + // to basically zero assuming someone isn't intentionally trying to make the + // hashes clash. + if link.exists() { + try FileManager.default.removeItem(at: link) + } + try FileManager.default.createSymbolicLink( + at: link, + withDestinationURL: destination + ) + } + } + + return silo + } +} diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManagerError.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManagerError.swift new file mode 100644 index 00000000..b8e9172c --- /dev/null +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManagerError.swift @@ -0,0 +1,31 @@ +import Foundation +import ErrorKit + +extension SwiftSDKManager { + typealias Error = RichError + + /// An error message related to ``SwiftSDKManager``. + enum ErrorMessage: Throwable { + case failedToEnumerateSDKs(_ directory: URL) + case failedToParseArtifactBundleInfo(_ bundle: URL) + case noSDKsMatchQuery( + hostPlatform: HostPlatform, + hostArchitecture: BuildArchitecture, + targetTriple: LLVMTargetTriple + ) + + var userFriendlyMessage: String { + switch self { + case .failedToEnumerateSDKs(let directory): + return "Failed to enumerate SDKs in '\(directory.path)'" + case .failedToParseArtifactBundleInfo(let bundle): + return "Failed to parse info.json of artifactbundle at '\(bundle.path)'" + case .noSDKsMatchQuery(let hostPlatform, let hostArchitecture, let targetTriple): + return """ + No SDKs match host platform '\(hostPlatform.platform.displayName)', \ + host architecture '\(hostArchitecture)', and target triple '\(targetTriple)' + """ + } + } + } +} diff --git a/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManifest.swift b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManifest.swift new file mode 100644 index 00000000..63735d70 --- /dev/null +++ b/Sources/SwiftBundler/Bundler/SwiftPackageManager/SwiftSDKManifest.swift @@ -0,0 +1,21 @@ +import Foundation + +struct SwiftSDKManifest: Decodable { + var schemaVersion: String + var targetTriples: [String: SDK] + + // Exists to make the coding keys accessible from outside of the type. + enum CodingKeys: String, CodingKey { + case schemaVersion + case targetTriples + } + + struct SDK: Decodable { + var sdkRootPath: String + var swiftResourcesPath: String? + var swiftStaticResourcesPath: String? + var includeSearchPaths: [String]? + var librarySearchPaths: [String]? + var toolsetPaths: [String]? + } +} diff --git a/Sources/SwiftBundler/Bundler/Templater/Templater.swift b/Sources/SwiftBundler/Bundler/Templater/Templater.swift index c24b1bf4..f6fdfeab 100644 --- a/Sources/SwiftBundler/Bundler/Templater/Templater.swift +++ b/Sources/SwiftBundler/Bundler/Templater/Templater.swift @@ -22,6 +22,7 @@ enum Templater { /// - addVSCodeOverlay: If `true`, the VSCode overlay (containing /// `launch.json` and `.vscode/tasks.json`), will be added to the package /// (enabling ergonomic debugging with VSCode). + /// - swiftToolchain: An alternative Swift toolchain to use. /// - Returns: The template that the package was created from (or nil if none /// were used), or a failure if package creation failed. static func createPackage( @@ -31,7 +32,8 @@ enum Templater { configuration: AppConfiguration, forceCreation: Bool, indentationStyle: IndentationStyle, - addVSCodeOverlay: Bool + addVSCodeOverlay: Bool, + swiftToolchain: URL? ) async throws(Error) -> Template? { if FileManager.default.fileExists(atPath: outputDirectory.path) { throw RichError(.packageDirectoryAlreadyExists(outputDirectory)) @@ -46,7 +48,8 @@ enum Templater { do { try await SwiftPackageManager.createPackage( in: outputDirectory, - name: packageName + name: packageName, + toolchain: swiftToolchain ) } catch { throw Error(.failedToCreateBareMinimumPackage, cause: error) @@ -82,7 +85,8 @@ enum Templater { configuration: configuration, forceCreation: forceCreation, indentationStyle: indentationStyle, - addVSCodeOverlay: addVSCodeOverlay + addVSCodeOverlay: addVSCodeOverlay, + swiftToolchain: swiftToolchain ) } @@ -97,6 +101,7 @@ enum Templater { /// - indentationStyle: The indentation style to use. /// - addVSCodeOverlay: If `true`, the VSCode overlay (containing `launch.json` and `.vscode/tasks.json`), /// will be added to the package (enabling ergonomic debugging with VSCode). + /// - swiftToolchain: An alternative Swift toolchain to use. /// - Returns: The template that the package was created from, or a failure if package creation failed. static func createPackage( in outputDirectory: URL, @@ -106,7 +111,8 @@ enum Templater { configuration: AppConfiguration, forceCreation: Bool, indentationStyle: IndentationStyle, - addVSCodeOverlay: Bool + addVSCodeOverlay: Bool, + swiftToolchain: URL? ) async throws(Error) -> Template { if FileManager.default.fileExists(atPath: outputDirectory.path) { throw Error(.packageDirectoryAlreadyExists(outputDirectory)) @@ -131,7 +137,7 @@ enum Templater { if !forceCreation { // Verify that this machine's Swift version is supported - try await verifyTemplateIsSupported(template, manifest) + try await verifyTemplateIsSupported(template, manifest, swiftToolchain: swiftToolchain) } // Create the output directory @@ -253,14 +259,19 @@ enum Templater { } /// Verifies that the given template supports this machine's Swift version. + /// - Parameters: + /// - name: The name of the template being checked. + /// - manifest: The manifest of the template to check. + /// - swiftToolchain: An alternative Swift toolchain to use. /// - Returns: An error if this machine's Swift version is not supported by the template. static func verifyTemplateIsSupported( _ name: String, - _ manifest: TemplateManifest + _ manifest: TemplateManifest, + swiftToolchain: URL? ) async throws(Error) { // Verify that the installed Swift version is supported let version = try await Error.catch { - try await SwiftPackageManager.getSwiftVersion() + try await SwiftPackageManager.getSwiftVersion(toolchain: swiftToolchain) } if version < manifest.minimumSwiftVersion { diff --git a/Sources/SwiftBundler/Bundler/VariableEvaluator.swift b/Sources/SwiftBundler/Bundler/VariableEvaluator.swift index 3a646aee..c07c182d 100644 --- a/Sources/SwiftBundler/Bundler/VariableEvaluator.swift +++ b/Sources/SwiftBundler/Bundler/VariableEvaluator.swift @@ -1,4 +1,5 @@ import Foundation +import Version import Parsing // Due to https://github.com/swiftlang/swift/issues/83510, we need to 'decouple' @@ -18,7 +19,7 @@ enum VariableEvaluator { /// The root directory of the package. var packageDirectory: URL? /// The app's version. - var version: String? + var version: Version? /// The app's identifier. var identifier: String? } @@ -170,7 +171,7 @@ enum VariableEvaluator { throw Error(.failedToEvaluateRevisionNumber(directory: packageDirectory), cause: error) } case "VERSION", "MARKETING_VERSION", "CURRENT_PROJECT_VERSION": - value = context.version + value = context.version?.description case "PRODUCT_BUNDLE_IDENTIFIER": value = context.identifier case "PRODUCT_NAME": diff --git a/Sources/SwiftBundler/Bundler/WXSFile.swift b/Sources/SwiftBundler/Bundler/WXSFile.swift index 7e56b357..c0ca4e4d 100644 --- a/Sources/SwiftBundler/Bundler/WXSFile.swift +++ b/Sources/SwiftBundler/Bundler/WXSFile.swift @@ -1,4 +1,5 @@ import XMLCoder +import Version extension MSIBundler { struct WXSFile: Codable { @@ -20,7 +21,7 @@ extension MSIBundler { @Attribute var manufacturer: String @Attribute var name: String @Attribute var upgradeCode: String - @Attribute var version: String + @Attribute var version: Version @Element var majorUpgrade: MajorUpgrade @Element var mediaTemplate: MediaTemplate @@ -54,7 +55,7 @@ extension MSIBundler { manufacturer: String, name: String, upgradeCode: String, - version: String, + version: Version, majorUpgrade: MajorUpgrade, mediaTemplate: MediaTemplate, icons: [Icon] = [], diff --git a/Sources/SwiftBundler/Bundler/XcodeprojConverter/XcodeprojConverter.swift b/Sources/SwiftBundler/Bundler/XcodeprojConverter/XcodeprojConverter.swift index 0b24a378..82af13f7 100644 --- a/Sources/SwiftBundler/Bundler/XcodeprojConverter/XcodeprojConverter.swift +++ b/Sources/SwiftBundler/Bundler/XcodeprojConverter/XcodeprojConverter.swift @@ -383,9 +383,12 @@ enum XcodeprojConverter { var apps: [String: AppConfiguration] = [:] for target in targets { do { + let version = target.version.map(Version.parseOrFallback(_:)) + ?? Version.defaultFallback + apps[target.name] = try AppConfiguration.create( appName: target.name, - version: target.version ?? "0.1.0", + version: version, identifier: target.identifier ?? "com.example.\(target.name)", category: nil, infoPlistFile: target.infoPlist, diff --git a/Sources/SwiftBundler/Commands/BundleArguments.swift b/Sources/SwiftBundler/Commands/BundleArguments.swift index 3e43ff72..5b911365 100644 --- a/Sources/SwiftBundler/Commands/BundleArguments.swift +++ b/Sources/SwiftBundler/Commands/BundleArguments.swift @@ -7,6 +7,7 @@ struct BundleArguments: ParsableArguments { help: "The name of the app to build.") var appName: String? + /// The bundler to use. @Option( help: "The bundler to use \(BundlerChoice.possibleValuesDescription).", transform: { @@ -15,7 +16,13 @@ struct BundleArguments: ParsableArguments { } return choice }) - var bundler = BundlerChoice.defaultForHostPlatform + var bundler: BundlerChoice? + + /// An alternative Swift toolchain to use. + @Option( + help: "An alternative Swift toolchain to use", + transform: URL.init(fileURLWithPath:)) + var toolchain: URL? /// The directory containing the package to build. @Option( @@ -75,7 +82,7 @@ struct BundleArguments: ParsableArguments { parsing: .singleValue, help: { let possibleValues = BuildArchitecture.possibleValuesDescription - let defaultValue = BuildArchitecture.current.rawValue + let defaultValue = BuildArchitecture.host.rawValue return "The architectures to build for \(possibleValues). (default: [\(defaultValue)])" }(), transform: { string in diff --git a/Sources/SwiftBundler/Commands/BundleCommand.swift b/Sources/SwiftBundler/Commands/BundleCommand.swift index c6c44600..806fd654 100644 --- a/Sources/SwiftBundler/Commands/BundleCommand.swift +++ b/Sources/SwiftBundler/Commands/BundleCommand.swift @@ -101,59 +101,42 @@ struct BundleCommand: ErrorHandledCommand { return false } - guard arguments.bundler.isSupportedOnHostPlatform else { - log.error( - """ - The '\(arguments.bundler.rawValue)' bundler is not supported on the \ - current host platform. Supported bundlers: \ - \(BundlerChoice.supportedHostValuesDescription) - """ - ) - return false - } + if let bundler = arguments.bundler { + if !bundler.isSupportedOnHostPlatform { + log.error( + """ + The '\(arguments.bundler?.rawValue ?? "")' bundler is not supported on the \ + current host platform. Supported bundlers: \ + \(BundlerChoice.supportedHostValuesDescription) + """ + ) + return false + } - guard arguments.bundler.supportedTargetPlatforms.contains(platform) else { - let alternatives = BundlerChoice.allCases.filter { choice in - choice.supportedTargetPlatforms.contains(platform) + if !bundler.supportedTargetPlatforms.contains(platform) { + let alternatives = BundlerChoice.allCases.filter { choice in + choice.supportedTargetPlatforms.contains(platform) + } + let alternativesDescription = "(\(alternatives.map(\.rawValue).joined(separator: "|")))" + log.error( + """ + The '\(bundler.rawValue)' bundler doesn't support bundling \ + for '\(platform)'. Supported target platforms: \ + \(BundlerChoice.supportedHostValuesDescription). Valid alternative \ + bundlers: \(alternativesDescription) + """ + ) + return false } - let alternativesDescription = "(\(alternatives.map(\.rawValue).joined(separator: "|")))" - log.error( - """ - The '\(arguments.bundler.rawValue)' bundler doesn't support bundling \ - for '\(platform)'. Supported target platforms: \ - \(BundlerChoice.supportedHostValuesDescription). Valid alternative \ - bundlers: \(alternativesDescription) - """ - ) + } + + if platform != .macOS && arguments.standAlone { + log.error("'--experimental-stand-alone' only works when targeting macOS (and that excludes Mac Catalyst)") return false } // macOS-only arguments #if os(macOS) - if platform.isApplePlatform && ![.macOS, .macCatalyst].contains(platform) { - if arguments.universal { - log.error( - """ - '--universal' is not compatible with '--platform \ - \(platform.rawValue)' - """ - ) - return false - } - - if !arguments.architectures.isEmpty { - log.error( - "'--arch' is not compatible with '--platform \(platform.rawValue)'" - ) - return false - } - } - - if platform != .macOS && arguments.standAlone { - log.error("'--experimental-stand-alone' only works when targeting macOS (and that excludes Mac Catalyst)") - return false - } - switch platform { case .iOS, .visionOS, .tvOS: break @@ -436,22 +419,42 @@ struct BundleCommand: ErrorHandledCommand { } } - func getArchitectures(platform: Platform) -> [BuildArchitecture] { - let architectures: [BuildArchitecture] - switch platform { - case .macOS, .macCatalyst: - if arguments.universal { - architectures = [.arm64, .x86_64] - } else { - architectures = - !arguments.architectures.isEmpty - ? arguments.architectures - : [BuildArchitecture.current] - } - case .iOS, .visionOS, .tvOS: - architectures = [.arm64] - case .linux, .windows, .iOSSimulator, .visionOSSimulator, .tvOSSimulator: - architectures = [BuildArchitecture.current] + /// Gets the architectures to use for the current build. Validates the '--arch' + /// arguments passed in by the user. + func getArchitectures(platform: Platform) + throws(RichError) -> [BuildArchitecture] + { + guard !arguments.universal || platform.supportsMultiArchitectureBuilds else { + let message = SwiftBundlerError.platformDoesNotSupportMultiArchitectureBuilds( + platform, + universalFlag: true + ) + throw RichError(message) + } + + let supportedArchitectures = platform.supportedCompilationArchitectures + let architectures = arguments.universal ? supportedArchitectures : arguments.architectures + guard !architectures.isEmpty else { + return [platform.defaultCompilationArchitecture(.host)] + } + + var unsupportedArchitectures: [BuildArchitecture] = [] + for architecture in architectures { + if !supportedArchitectures.contains(architecture) { + unsupportedArchitectures.append(architecture) + } + } + + guard unsupportedArchitectures.isEmpty else { + throw RichError(.unsupportedTargetArchitectures(unsupportedArchitectures, platform)) + } + + guard architectures.count == 1 || platform.supportsMultiArchitectureBuilds else { + let message = SwiftBundlerError.platformDoesNotSupportMultiArchitectureBuilds( + platform, + universalFlag: false + ) + throw RichError(message) } return architectures @@ -463,7 +466,15 @@ struct BundleCommand: ErrorHandledCommand { deviceSpecifier: arguments.deviceSpecifier, simulatorSpecifier: arguments.simulatorSpecifier ) - _ = try await doBundling(resolvedPlatform: platform, resolvedDevice: device) + + let bundler = arguments.bundler + ?? BundlerChoice.defaultForTargetPlatform(platform) + + _ = try await doBundling( + resolvedPlatform: platform, + resolvedBundler: bundler, + resolvedDevice: device + ) } // swiftlint:disable cyclomatic_complexity @@ -476,12 +487,14 @@ struct BundleCommand: ErrorHandledCommand { /// arguments users can use to specify it. This parameter purely exists /// to allow ``RunCommand`` to avoid resolving the target platform twice /// (once for its own use and once when this method gets called). + /// - resolvedBundler: The bundler to use. /// - resolvedDevice: Must be provided when provisioning profiles are /// expected to be generated. /// - Returns: A description of the structure of the bundler's output. func doBundling( dryRun: Bool = false, resolvedPlatform: Platform, + resolvedBundler: BundlerChoice, resolvedDevice: Device? = nil ) async throws(RichError) -> BundlerOutputStructure { let resolvedCodesigningContext = try await Self.resolveCodesigningContext( @@ -492,6 +505,24 @@ struct BundleCommand: ErrorHandledCommand { platform: resolvedPlatform ) + try RichError.catch { + try resolvedBundler.bundler.checkHostCompatibility() + } + + guard + Self.validateArguments( + arguments, + platform: resolvedPlatform, + skipBuild: skipBuild, + builtWithXcode: builtWithXcode + ) + else { + Foundation.exit(1) + } + + // Get relevant configuration + let architectures = try getArchitectures(platform: resolvedPlatform) + // Time execution so that we can report it to the user. let (elapsed, bundlerOutputStructure) = try await Stopwatch.time { () async throws(RichError) in // Load configuration @@ -504,25 +535,11 @@ struct BundleCommand: ErrorHandledCommand { packageDirectory: packageDirectory, context: ConfigurationFlattener.Context( platform: resolvedPlatform, - bundler: arguments.bundler + bundler: resolvedBundler ), customFile: arguments.configurationFileOverride ) - guard - Self.validateArguments( - arguments, - platform: resolvedPlatform, - skipBuild: skipBuild, - builtWithXcode: builtWithXcode - ) - else { - Foundation.exit(1) - } - - // Get relevant configuration - let architectures = getArchitectures(platform: resolvedPlatform) - // Whether or not we are building with xcodebuild instead of swiftpm. let isUsingXcodebuild = Xcodebuild.isUsingXcodebuild( for: self, @@ -557,13 +574,13 @@ struct BundleCommand: ErrorHandledCommand { // Load package manifest log.info("Loading package manifest") let manifest = try await RichError.catch { - try await SwiftPackageManager.loadPackageManifest(from: packageDirectory) + try await SwiftPackageManager.loadPackageManifest( + from: packageDirectory, + toolchain: arguments.toolchain + ) } - let platformVersion = - resolvedPlatform.asApplePlatform.map { platform in - manifest.platformVersion(for: platform) - } ?? nil + let platformVersion = resolvedPlatform.platformVersion(from: manifest) let metadataDirectory = outputDirectory / "metadata" if !metadataDirectory.exists() { @@ -574,13 +591,20 @@ struct BundleCommand: ErrorHandledCommand { ) } } - let compiledMetadata = try await RichError.catch { - return try await MetadataInserter.compileMetadata( - in: metadataDirectory, - for: MetadataInserter.metadata(for: appConfiguration), - architectures: architectures, - platform: resolvedPlatform - ) + + // TODO: Support metadata compilation on Android. Requires us to be able to locate Swift SDKs ourselves. + let compiledMetadata: MetadataInserter.CompiledMetadata? + if resolvedPlatform != .android { + compiledMetadata = try await RichError.catch { + return try await MetadataInserter.compileMetadata( + in: metadataDirectory, + for: MetadataInserter.metadata(for: appConfiguration), + architectures: architectures, + platform: resolvedPlatform + ) + } + } else { + compiledMetadata = nil } let buildContext = SwiftPackageManager.BuildContext( @@ -595,6 +619,7 @@ struct BundleCommand: ErrorHandledCommand { ? arguments.additionalXcodeBuildArguments : arguments.additionalSwiftPMArguments ), + toolchain: arguments.toolchain, hotReloadingEnabled: hotReloadingEnabled, isGUIExecutable: true, compiledMetadata: compiledMetadata @@ -651,6 +676,7 @@ struct BundleCommand: ErrorHandledCommand { packageDirectory: packageDirectory, productsDirectory: productsDirectory, outputDirectory: outputDirectory, + architectures: buildContext.genericContext.architectures, platform: resolvedPlatform, device: resolvedDevice, darwinCodeSigningContext: resolvedCodesigningContext, @@ -661,7 +687,7 @@ struct BundleCommand: ErrorHandledCommand { // If this is a dry run, drop out just before we start actually do stuff. guard !dryRun else { return try Self.intendedOutput( - of: arguments.bundler.bundler, + of: resolvedBundler.bundler, context: bundlerContext, command: self, manifest: manifest @@ -677,6 +703,7 @@ struct BundleCommand: ErrorHandledCommand { appConfiguration.dependencies, packageConfiguration: configuration, context: dependencyContext, + swiftToolchain: arguments.toolchain, appName: appName, dryRun: skipBuild ) @@ -724,15 +751,25 @@ struct BundleCommand: ErrorHandledCommand { log.info("Starting \(buildContext.genericContext.configuration.rawValue) build") try await RichError.catch { if isUsingXcodebuild { + guard !resolvedBundler.bundler.requiresBuildAsDylib else { + throw SwiftBundlerError.xcodeCannotBuildAsDylib + } try await Xcodebuild.build( product: appConfiguration.product, buildContext: buildContext ) } else { - try await SwiftPackageManager.build( - product: appConfiguration.product, - buildContext: buildContext - ) + if resolvedBundler.bundler.requiresBuildAsDylib { + _ = try await SwiftPackageManager.buildExecutableAsDylib( + product: appConfiguration.product, + buildContext: buildContext + ) + } else { + try await SwiftPackageManager.build( + product: appConfiguration.product, + buildContext: buildContext + ) + } } } @@ -774,7 +811,7 @@ struct BundleCommand: ErrorHandledCommand { ) return try await Self.bundle( - with: arguments.bundler.bundler, + with: resolvedBundler.bundler, context: bundlerContext, command: self, manifest: manifest diff --git a/Sources/SwiftBundler/Commands/BundlerChoice.swift b/Sources/SwiftBundler/Commands/BundlerChoice.swift index 9b6bf546..8cec0388 100644 --- a/Sources/SwiftBundler/Commands/BundlerChoice.swift +++ b/Sources/SwiftBundler/Commands/BundlerChoice.swift @@ -6,6 +6,7 @@ enum BundlerChoice: String, CaseIterable { case linuxRPM case windowsGeneric case windowsMSI + case androidAPK /// The bundler this choice corresponds to. var bundler: any Bundler.Type { @@ -22,6 +23,8 @@ enum BundlerChoice: String, CaseIterable { return GenericWindowsBundler.self case .windowsMSI: return MSIBundler.self + case .androidAPK: + return APKBundler.self } } @@ -42,6 +45,21 @@ enum BundlerChoice: String, CaseIterable { } } + /// Gets the default bundler for the given target platform. + static func defaultForTargetPlatform(_ platform: Platform) -> Self { + switch platform { + case .macOS, .macCatalyst, .iOS, .iOSSimulator, + .tvOS, .tvOSSimulator, .visionOS, .visionOSSimulator: + .darwinApp + case .linux: + .linuxGeneric + case .windows: + .windowsGeneric + case .android: + .androidAPK + } + } + /// A list of supported values for human consumption. static var supportedHostValuesDescription: String { let supportedChoices = allCases.filter { choice in @@ -64,6 +82,8 @@ enum BundlerChoice: String, CaseIterable { return [.linux] case .windowsGeneric, .windowsMSI: return [.windows] + case .androidAPK: + return [.android] } } @@ -76,6 +96,8 @@ enum BundlerChoice: String, CaseIterable { return [.linux] case .windowsGeneric, .windowsMSI: return [.windows] + case .androidAPK: + return [.macOS, .linux, .windows] } } } diff --git a/Sources/SwiftBundler/Commands/CreateCommand.swift b/Sources/SwiftBundler/Commands/CreateCommand.swift index dc7f815b..edcf6d41 100644 --- a/Sources/SwiftBundler/Commands/CreateCommand.swift +++ b/Sources/SwiftBundler/Commands/CreateCommand.swift @@ -23,8 +23,14 @@ struct CreateCommand: ErrorHandledCommand { /// The app's initial version. @Option( name: .long, - help: "The app's initial version.") - var version: String? + help: "The app's initial version.", + transform: { versionString in + guard let version = Version(tolerant: versionString) else { + throw SwiftBundlerError.invalidVersionString(versionString) + } + return version + }) + var version: Version? /// The app's category. @Option( @@ -66,6 +72,12 @@ struct CreateCommand: ErrorHandledCommand { transform: URL.init(fileURLWithPath:)) var templateRepository: URL? + /// An alternative Swift toolchain to use. + @Option( + help: "An alternative Swift toolchain to use", + transform: URL.init(fileURLWithPath:)) + var toolchain: URL? + /// The indentation style to create the package with. @Option( name: .long, @@ -136,7 +148,8 @@ struct CreateCommand: ErrorHandledCommand { configuration: configuration, forceCreation: force, indentationStyle: indentation, - addVSCodeOverlay: addVSCodeOverlay + addVSCodeOverlay: addVSCodeOverlay, + swiftToolchain: toolchain ) } } else { @@ -148,7 +161,8 @@ struct CreateCommand: ErrorHandledCommand { configuration: configuration, forceCreation: force, indentationStyle: indentation, - addVSCodeOverlay: addVSCodeOverlay + addVSCodeOverlay: addVSCodeOverlay, + swiftToolchain: toolchain ) } } diff --git a/Sources/SwiftBundler/Commands/DebugCommand.swift b/Sources/SwiftBundler/Commands/DebugCommand.swift new file mode 100644 index 00000000..e85dfac5 --- /dev/null +++ b/Sources/SwiftBundler/Commands/DebugCommand.swift @@ -0,0 +1,60 @@ +import ArgumentParser +import Foundation + +/// The subcommand containing debug commands to use when debugging Swift +/// Bundler issues or working on Swift Bundler features. +struct DebugCommand: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "_debug", + abstract: "A home for debugging commands", + shouldDisplay: false, + subcommands: [ + ListSDKs.self + ] + ) + + struct ListSDKs: ErrorHandledCommand { + static var configuration = CommandConfiguration( + commandName: "list-sdks", + abstract: "Lists all SDKs that Swift Bundler knows about" + ) + + @Flag( + name: .shortAndLong, + help: "Print verbose error messages.") + var verbose = false + + @Flag(help: "Display the output as JSON (includes more information)") + var json = false + + func wrappedRun() async throws(RichError) { + let sdks = try RichError.catch { + try SwiftSDKManager.enumerateInstalledSwiftSDKs() + } + + if json { + let encoder = JSONEncoder() + encoder.outputFormatting.insert(.prettyPrinted) + encoder.outputFormatting.insert(.withoutEscapingSlashes) + let jsonOutput = try RichError.catch { + try encoder.encode(sdks) + } + guard let string = String(data: jsonOutput, encoding: .utf8) else { + throw RichError(.failedToEncodeJSONOutput) + } + print(string) + } else { + let output = KeyedList { + for sdk in sdks { + let name = "\(sdk.artifactIdentifier):\(sdk.triple)" + KeyedList.Entry(name.bold) { + sdk.artifactVariant.path + } + } + } + + print(output.description) + } + } + } +} diff --git a/Sources/SwiftBundler/Commands/Devices/DevicesListCommand.swift b/Sources/SwiftBundler/Commands/Devices/DevicesListCommand.swift index 1b35c26c..0a17f2c8 100644 --- a/Sources/SwiftBundler/Commands/Devices/DevicesListCommand.swift +++ b/Sources/SwiftBundler/Commands/Devices/DevicesListCommand.swift @@ -19,11 +19,11 @@ struct DevicesListCommand: ErrorHandledCommand { .filter { device in !device.platform.isSimulator } - .compactMap { device -> ConnectedDevice? in + .compactMap { device -> ConnectedAppleDevice? in switch device { - case .host, .macCatalyst: + case .host, .macCatalyst, .connectedAndroidDevice: return nil - case .connected(let device): + case .connectedAppleDevice(let device): return device } } diff --git a/Sources/SwiftBundler/Commands/Emulators/EmulatorsBootCommand.swift b/Sources/SwiftBundler/Commands/Emulators/EmulatorsBootCommand.swift new file mode 100644 index 00000000..905d427f --- /dev/null +++ b/Sources/SwiftBundler/Commands/Emulators/EmulatorsBootCommand.swift @@ -0,0 +1,42 @@ +import ArgumentParser +import Foundation + +/// The subcommand for booting Android emulators. +struct EmulatorsBootCommand: ErrorHandledCommand { + static var configuration = CommandConfiguration( + commandName: "boot", + abstract: "Boot an Android emulator." + ) + + /// The name of the emulator to boot. + @Argument( + help: "The name of the emulator to start.") + var name: String + + /// Arguments to pass through to the 'emulator' CLI. + @Argument( + parsing: .postTerminator, + help: "Additional arguments for the 'emulator' CLI.") + var emulatorArguments: [String] = [] + + @Flag( + name: .shortAndLong, + help: "Print verbose error messages.") + public var verbose = false + + @Flag( + name: .long, + help: "Attach to 'emulator' CLI after booting the emulator.") + var attach = false + + func wrappedRun() async throws(RichError) { + try await RichError.catch { + log.info("Booting '\(name)'") + try await AndroidVirtualDeviceManager.bootVirtualDevice( + AndroidVirtualDevice(name: name), + additionalArguments: emulatorArguments, + detach: !attach + ) + } + } +} diff --git a/Sources/SwiftBundler/Commands/Emulators/EmulatorsListCommand.swift b/Sources/SwiftBundler/Commands/Emulators/EmulatorsListCommand.swift new file mode 100644 index 00000000..0931c1e9 --- /dev/null +++ b/Sources/SwiftBundler/Commands/Emulators/EmulatorsListCommand.swift @@ -0,0 +1,71 @@ +import ArgumentParser +import Foundation + +/// The subcommand for listing available emulators. +struct EmulatorsListCommand: ErrorHandledCommand { + static var configuration = CommandConfiguration( + commandName: "list", + abstract: "List available Android emulators." + ) + + @Flag( + name: .shortAndLong, + help: "Print verbose error messages.") + public var verbose = false + + @Flag( + name: .customLong("booted"), + help: "Only show booted emulators.") + public var filterBooted = false + + @Flag( + name: .customLong("not-booted"), + help: "Only show emulators that aren't booted.") + public var filterNotBooted = false + + func wrappedValidate() throws(RichError) { + if filterBooted && filterNotBooted { + log.error("'--booted' and '--not-booted' cannot be used simultaneously") + Foundation.exit(1) + } + } + + func wrappedRun() async throws(RichError) { + let bootedEmulators = try await RichError.catch { + try await AndroidVirtualDeviceManager.enumerateBootedVirtualDevices() + } + var emulators: [AndroidVirtualDevice] + if filterBooted { + emulators = bootedEmulators + } else { + emulators = try await RichError.catch { + try await AndroidVirtualDeviceManager.enumerateVirtualDevices() + } + + if filterNotBooted { + emulators = emulators.filter { !bootedEmulators.contains($0) } + } + } + + Output { + Section("Emulators") { + if emulators.isEmpty { + "No emulators found".italic + } else { + List { + for emulator in emulators { + if bootedEmulators.contains(emulator) { + "\(emulator.name) (booted)" + } else { + emulator.name + } + } + } + } + } + Section("Booting an emulator") { + ExampleCommand("swift bundler emulators boot [name]") + } + }.show() + } +} diff --git a/Sources/SwiftBundler/Commands/EmulatorsCommand.swift b/Sources/SwiftBundler/Commands/EmulatorsCommand.swift new file mode 100644 index 00000000..01e724af --- /dev/null +++ b/Sources/SwiftBundler/Commands/EmulatorsCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser +import Foundation + +/// The subcommand for managing and listing available emulators. +struct EmulatorsCommand: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "emulators", + abstract: "Manage and list available Android emulators.", + subcommands: [ + EmulatorsListCommand.self, + EmulatorsBootCommand.self, + ], + defaultSubcommand: EmulatorsListCommand.self + ) +} diff --git a/Sources/SwiftBundler/Commands/RunCommand.swift b/Sources/SwiftBundler/Commands/RunCommand.swift index 3b3db03a..f8d7a25a 100644 --- a/Sources/SwiftBundler/Commands/RunCommand.swift +++ b/Sources/SwiftBundler/Commands/RunCommand.swift @@ -56,18 +56,6 @@ struct RunCommand: ErrorHandledCommand { Foundation.exit(1) } - guard arguments.bundler.bundler.outputIsRunnable else { - log.error( - """ - The chosen bundler (\(arguments.bundler.rawValue)) is bundling-only \ - (i.e. it doesn't output a runnable bundle). Choose a different bundler \ - or stick to bundling and manually install the bundle on your system to \ - run your app. - """ - ) - Foundation.exit(1) - } - #if !SUPPORT_HOT_RELOADING if hot { log.error( @@ -90,12 +78,27 @@ struct RunCommand: ErrorHandledCommand { simulatorSpecifier: arguments.simulatorSpecifier ) + let resolvedBundler = arguments.bundler + ?? BundlerChoice.defaultForTargetPlatform(device.platform) + + guard resolvedBundler.bundler.outputIsRunnable else { + log.error( + """ + The chosen bundler (\(resolvedBundler.rawValue)) is bundling-only \ + (i.e. it doesn't output a runnable bundle). Choose a different bundler \ + or stick to bundling and manually install the bundle on your system to \ + run your app. + """ + ) + Foundation.exit(1) + } + let (_, appConfiguration, _) = try await BundleCommand.getConfiguration( arguments.appName, packageDirectory: packageDirectory, context: ConfigurationFlattener.Context( platform: device.platform, - bundler: arguments.bundler + bundler: resolvedBundler ), customFile: arguments.configurationFileOverride ) @@ -112,6 +115,7 @@ struct RunCommand: ErrorHandledCommand { let bundlerOutput = try await bundleCommand.doBundling( dryRun: skipBuild, resolvedPlatform: device.platform, + resolvedBundler: resolvedBundler, resolvedDevice: device ) @@ -124,15 +128,13 @@ struct RunCommand: ErrorHandledCommand { // TODO: Avoid loading manifest twice let manifest = try await RichError.catch { try await SwiftPackageManager.loadPackageManifest( - from: packageDirectory + from: packageDirectory, + toolchain: arguments.toolchain ) } - let platformVersion = - device.platform.asApplePlatform.map { platform in - manifest.platformVersion(for: platform) - } ?? nil - let architectures = bundleCommand.getArchitectures( + let platformVersion = device.platform.platformVersion(from: manifest) + let architectures = try bundleCommand.getArchitectures( platform: device.platform ) @@ -159,6 +161,7 @@ struct RunCommand: ErrorHandledCommand { try await server.start( product: appConfiguration.product, buildContext: buildContext, + swiftToolchain: arguments.toolchain, appConfiguration: appConfiguration ) } catch { diff --git a/Sources/SwiftBundler/Commands/Simulators/SimulatorsBootCommand.swift b/Sources/SwiftBundler/Commands/Simulators/SimulatorsBootCommand.swift index ec6b2999..209d9ab7 100644 --- a/Sources/SwiftBundler/Commands/Simulators/SimulatorsBootCommand.swift +++ b/Sources/SwiftBundler/Commands/Simulators/SimulatorsBootCommand.swift @@ -5,7 +5,7 @@ import Foundation struct SimulatorsBootCommand: ErrorHandledCommand { static var configuration = CommandConfiguration( commandName: "boot", - abstract: "Boot an iOS or visionOS simulator." + abstract: "Boot an iOS, tvOS or visionOS simulator." ) /// The id or name of the simulator to start. diff --git a/Sources/SwiftBundler/Commands/Simulators/SimulatorsListCommand.swift b/Sources/SwiftBundler/Commands/Simulators/SimulatorsListCommand.swift index e3bf8b0b..339b21b1 100644 --- a/Sources/SwiftBundler/Commands/Simulators/SimulatorsListCommand.swift +++ b/Sources/SwiftBundler/Commands/Simulators/SimulatorsListCommand.swift @@ -25,9 +25,13 @@ struct SimulatorsListCommand: ErrorHandledCommand { Output { Section("Simulators") { - KeyedList { - for simulator in simulators { - KeyedList.Entry(simulator.id, simulator.name) + if simulators.isEmpty { + "No simulators found".italic + } else { + KeyedList { + for simulator in simulators { + KeyedList.Entry(simulator.id, simulator.name) + } } } } diff --git a/Sources/SwiftBundler/Commands/SimulatorsCommand.swift b/Sources/SwiftBundler/Commands/SimulatorsCommand.swift index 7d2b7118..7faf59f2 100644 --- a/Sources/SwiftBundler/Commands/SimulatorsCommand.swift +++ b/Sources/SwiftBundler/Commands/SimulatorsCommand.swift @@ -5,7 +5,7 @@ import Foundation struct SimulatorsCommand: AsyncParsableCommand { static var configuration = CommandConfiguration( commandName: "simulators", - abstract: "Manage and list available simulators.", + abstract: "Manage and list available iOS, tvOS and visionOS simulators.", subcommands: [ SimulatorsListCommand.self, SimulatorsBootCommand.self, diff --git a/Sources/SwiftBundler/Commands/SwiftBundlerError.swift b/Sources/SwiftBundler/Commands/SwiftBundlerError.swift index 92a1c5b8..daa7b1eb 100644 --- a/Sources/SwiftBundler/Commands/SwiftBundlerError.swift +++ b/Sources/SwiftBundler/Commands/SwiftBundlerError.swift @@ -15,6 +15,11 @@ enum SwiftBundlerError: Throwable { case failedToResolveCodesigningConfiguration(reason: String) case failedToCopyOutBundle case missingConfigurationFile(URL) + case xcodeCannotBuildAsDylib + case unsupportedTargetArchitectures([BuildArchitecture], Platform) + case platformDoesNotSupportMultiArchitectureBuilds(Platform, universalFlag: Bool) + case failedToEncodeJSONOutput + case invalidVersionString(String) var userFriendlyMessage: String { switch self { @@ -74,6 +79,31 @@ enum SwiftBundlerError: Throwable { Could not find \(file.lastPathComponent) at standard location. Are you \ sure that you're in the root of a Swift Bundler project? """ + case .xcodeCannotBuildAsDylib: + return """ + The xcodebuild backend can't be used to build executable products as \ + dynamic libraries, but the currently selected bundler requires a \ + dynamic library. + """ + case .unsupportedTargetArchitectures(let architectures, let platform): + return """ + The architectures \(architectures) are not supported when targeting \ + \(platform.displayName). + """ + case .platformDoesNotSupportMultiArchitectureBuilds(let platform, let universalFlag): + if universalFlag { + return "\(platform.displayName) does not support '--universal' builds." + } else { + return "\(platform.displayName) does not support multi-architecture builds." + } + case .failedToEncodeJSONOutput: + return "Failed to encode JSON output." + case .invalidVersionString(let versionString): + return """ + Failed to parse version '\(versionString)'. Swift Bundler expects \ + semantic versions, but uses tolerant parsing so it also supports \ + versions such as 10.0 and v3 + """ } } } diff --git a/Sources/SwiftBundler/Configuration/AppConfiguration.swift b/Sources/SwiftBundler/Configuration/AppConfiguration.swift index ce1e337d..a06d0cab 100644 --- a/Sources/SwiftBundler/Configuration/AppConfiguration.swift +++ b/Sources/SwiftBundler/Configuration/AppConfiguration.swift @@ -1,5 +1,6 @@ import Foundation import Parsing +import Version /// The configuration for an app. @Configuration(overlayable: true) @@ -11,7 +12,7 @@ struct AppConfiguration: Codable { var product: String /// The app's current version. - var version: String + var version: Version /// A short summary describing the purpose of the app. @ConfigurationKey("description") @@ -137,7 +138,7 @@ struct AppConfiguration: Codable { /// - Returns: The app configuration, or a failure if an error occurs. static func create( appName: String, - version: String?, + version: Version?, identifier: String?, category: String?, infoPlistFile: URL?, @@ -157,7 +158,7 @@ struct AppConfiguration: Codable { } if version == nil, case let .string(versionString) = plist["CFBundleShortVersionString"] { - version = versionString + version = Version(tolerant: versionString) } if identifier == nil, case let .string(identifierString) = plist["CFBundleIdentifier"] { @@ -174,7 +175,7 @@ struct AppConfiguration: Codable { let configuration = AppConfiguration( identifier: identifier ?? "com.example.\(appName)", product: appName, - version: version ?? "0.1.0", + version: version ?? Version.defaultFallback, category: category, icon: iconFile?.lastPathComponent ) diff --git a/Sources/SwiftBundler/Configuration/PackageConfiguration.swift b/Sources/SwiftBundler/Configuration/PackageConfiguration.swift index 99f40452..89929e7d 100644 --- a/Sources/SwiftBundler/Configuration/PackageConfiguration.swift +++ b/Sources/SwiftBundler/Configuration/PackageConfiguration.swift @@ -1,5 +1,6 @@ import Foundation import TOMLKit +import Version /// The configuration for a package. @Configuration(overlayable: false) @@ -73,7 +74,10 @@ struct PackageConfiguration: Codable { let configuration: PackageConfiguration do { configuration = try Error.catch(withMessage: .failedToDeserializeConfiguration) { - try TOMLDecoder(strictDecoding: true).decode( + var decoder = TOMLDecoder(strictDecoding: true) + // Tolerant version parsing + decoder.userInfo[.decodingMethod] = DecodingMethod.tolerant + return try decoder.decode( PackageConfiguration.self, from: contents ) diff --git a/Sources/SwiftBundler/Configuration/ProjectConfiguration.swift b/Sources/SwiftBundler/Configuration/ProjectConfiguration.swift index ea35f605..36a0da00 100644 --- a/Sources/SwiftBundler/Configuration/ProjectConfiguration.swift +++ b/Sources/SwiftBundler/Configuration/ProjectConfiguration.swift @@ -226,7 +226,7 @@ extension ProjectConfiguration.Product.Flat { switch type { case .dynamicLibrary, .staticLibrary: switch platform.partitioned { - case .linux, .apple: + case .linux, .apple, .android: baseName = "lib\(name)" case .windows: baseName = name @@ -249,7 +249,7 @@ extension ProjectConfiguration.Product.Flat { switch type { case .dynamicLibrary: switch platform.partitioned { - case .linux: + case .linux, .android: fileExtension = ".so" case .windows: fileExtension = ".dll" @@ -263,7 +263,7 @@ extension ProjectConfiguration.Product.Flat { switch platform.partitioned { case .windows: fileExtension = ".exe" - case .linux, .apple: + case .linux, .android, .apple: fileExtension = "" } } @@ -281,7 +281,7 @@ extension ProjectConfiguration.Product.Flat { } else { return [] } - case .linux, .apple: + case .linux, .android, .apple: return [] } diff --git a/Sources/SwiftBundler/Configuration/V1/PackageConfigurationV1.swift b/Sources/SwiftBundler/Configuration/V1/PackageConfigurationV1.swift index 813dd205..fcb710a4 100644 --- a/Sources/SwiftBundler/Configuration/V1/PackageConfigurationV1.swift +++ b/Sources/SwiftBundler/Configuration/V1/PackageConfigurationV1.swift @@ -1,4 +1,5 @@ import Foundation +import Version /// The old configuration format (from swift-bundler 1.x.x). Kept for use in automatic configuration migration. struct PackageConfigurationV1: Codable { @@ -90,10 +91,11 @@ struct PackageConfigurationV1: Codable { """ ) + let version = Version.parseOrFallback(versionString) let appConfiguration = AppConfiguration( identifier: bundleIdentifier, product: target, - version: versionString, + version: version, category: category, plist: extraPlistEntries.isEmpty ? nil : extraPlistEntries ) diff --git a/Sources/SwiftBundler/Configuration/V2/AppConfigurationV2.swift b/Sources/SwiftBundler/Configuration/V2/AppConfigurationV2.swift index 2c045818..06b02263 100644 --- a/Sources/SwiftBundler/Configuration/V2/AppConfigurationV2.swift +++ b/Sources/SwiftBundler/Configuration/V2/AppConfigurationV2.swift @@ -1,4 +1,5 @@ import Foundation +import Version /// The configuration for an app made with Swift Bundler v2. struct AppConfigurationV2: Codable { @@ -46,10 +47,11 @@ struct AppConfigurationV2: Codable { } } + let parsedVersion = Version.parseOrFallback(version) return AppConfiguration( identifier: bundleIdentifier, product: product, - version: version, + version: parsedVersion, category: category, icon: icon, plist: plist diff --git a/Sources/SwiftBundler/Extensions/Array.swift b/Sources/SwiftBundler/Extensions/Array.swift index 829d8772..ea64405a 100644 --- a/Sources/SwiftBundler/Extensions/Array.swift +++ b/Sources/SwiftBundler/Extensions/Array.swift @@ -39,6 +39,50 @@ extension Array { } return result } + + /// A typed-throws async version of `map`. + func asyncMap( + _ transform: (Element) async throws(E) -> NewElement + ) async throws(E) -> [NewElement] { + var result: [NewElement] = [] + for element in self { + result.append(try await transform(element)) + } + return result + } + + /// A typed-throws version of `filter`. + func filter( + _ predicate: (Element) throws(E) -> Bool + ) throws(E) -> [Element] { + var result: [Element] = [] + for element in self where try predicate(element) { + result.append(element) + } + return result + } + + /// A typed-throws async version of `filter`. + func typedAsyncFilter( + _ predicate: (Element) async throws(E) -> Bool + ) async throws(E) -> [Element] { + var result: [Element] = [] + for element in self where try await predicate(element) { + result.append(element) + } + return result + } + + /// A typed-throws version of `flatMap`. + func flatMap( + _ transform: (Element) throws(E) -> [NewElement] + ) throws(E) -> [NewElement] { + var result: [NewElement] = [] + for element in self { + result.append(contentsOf: try transform(element)) + } + return result + } } struct Verb { @@ -94,3 +138,17 @@ extension Array { } } } + +extension Array where Element: Hashable { + /// Return the array with all duplicates removed. + /// + /// i.e. `[ 1, 2, 3, 1, 2 ].uniqued() == [ 1, 2, 3 ]` + /// + /// - note: Taken from stackoverflow.com/a/46354989/3141234, as + /// per @Alexander's comment. + /// - note: Taken from https://github.com/tuist/XcodeProj + public func uniqued() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/Sources/SwiftBundler/Extensions/String.swift b/Sources/SwiftBundler/Extensions/String.swift index 5f82602f..acd00fc3 100644 --- a/Sources/SwiftBundler/Extensions/String.swift +++ b/Sources/SwiftBundler/Extensions/String.swift @@ -32,6 +32,15 @@ extension String { return "a \(self)" } } + + /// A stable hash of the string using the djb2 algorithm. + var stableHash: UInt64 { + // Ref: http://www.cse.yorku.ca/~oz/hash.html + // Code adapted from: https://stackoverflow.com/a/39238545 + utf8.reduce(5381) { hash, byte in + (hash << 5) &+ hash &+ UInt64(byte) + } + } } extension String.Index { diff --git a/Sources/SwiftBundler/Extensions/Version.swift b/Sources/SwiftBundler/Extensions/Version.swift index 9dc4495f..efc74fc5 100644 --- a/Sources/SwiftBundler/Extensions/Version.swift +++ b/Sources/SwiftBundler/Extensions/Version.swift @@ -15,4 +15,23 @@ extension Version { } return string } + + /// The default fallback version to use when we fail to parse a version + /// or the user doesn't supply one. + static let defaultFallback = Version(0, 1, 0) + + /// Parses a version or falls back to ``Self/defaultFallback``. + /// + /// Exists as a standalone method because we do it in a few places and + /// the logging of the warning makes the code a bit verbose. + static func parseOrFallback(_ string: String) -> Version { + if let version = Version(tolerant: string) { + return version + } else { + log.warning("Failed to parse version '\(string)', falling back to \(defaultFallback)") + return defaultFallback + } + } } + +extension Version: TriviallyFlattenable {} diff --git a/Sources/SwiftBundler/Resources/DefaultAndroidIcon.webp b/Sources/SwiftBundler/Resources/DefaultAndroidIcon.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/Sources/SwiftBundler/Resources/DefaultAndroidIcon.webp differ diff --git a/Sources/SwiftBundler/Resources/gradle-wrapper.jar b/Sources/SwiftBundler/Resources/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/Sources/SwiftBundler/Resources/gradle-wrapper.jar differ diff --git a/Sources/SwiftBundler/Resources/gradlew b/Sources/SwiftBundler/Resources/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/Sources/SwiftBundler/Resources/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/Sources/SwiftBundler/SwiftBundler.swift b/Sources/SwiftBundler/SwiftBundler.swift index 85802cc8..580baa7c 100644 --- a/Sources/SwiftBundler/SwiftBundler.swift +++ b/Sources/SwiftBundler/SwiftBundler.swift @@ -5,6 +5,7 @@ import Version /// The root command of Swift Bundler. public struct SwiftBundler: AsyncParsableCommand { public static let version = Version(3, 0, 0) + public static let identifier = "dev.moreswift.swift-bundler" public static let configuration = CommandConfiguration( commandName: "swift-bundler", @@ -20,9 +21,11 @@ public struct SwiftBundler: AsyncParsableCommand { MigrateCommand.self, DevicesCommand.self, SimulatorsCommand.self, + EmulatorsCommand.self, TemplatesCommand.self, GenerateXcodeSupportCommand.self, ListIdentitiesCommand.self, + DebugCommand.self ] ) diff --git a/Sources/SwiftBundler/Utility/OutputBuilder/List.swift b/Sources/SwiftBundler/Utility/OutputBuilder/List.swift index 3797365b..ae6accf4 100644 --- a/Sources/SwiftBundler/Utility/OutputBuilder/List.swift +++ b/Sources/SwiftBundler/Utility/OutputBuilder/List.swift @@ -4,7 +4,7 @@ struct List: OutputComponent { var elements: [String] var body: String { - for element in elements { + for element in elements where !element.isEmpty { "* " + element } }