Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a utility to build xcframeworks #759

Merged
merged 3 commits into from Dec 7, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Expand Up @@ -10,7 +10,7 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: GitHub Action for SwiftLint
uses: pepibumur/action-swiftlint@0d4afd006bb24e4525b5afcefd4ab5e2537193ac
uses: norio-nomura/action-swiftlint@3c67ce2e382be797d968883944140ffa0113f737
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”₯

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
changelog:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -29,6 +29,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
- `SimulatorController` with method to fetch the runtimes https://github.com/tuist/tuist/pull/746 by @pepibumur.
- Add RxSwift as a dependency of `TuistKit` https://github.com/tuist/tuist/pull/760 by @pepibumur.
- Add cache command https://github.com/tuist/tuist/pull/762 by @pepibumur.
- Utility to build xcframeworks https://github.com/tuist/tuist/pull/759 by @pepibumur.

### Fixed

Expand Down
42 changes: 42 additions & 0 deletions Sources/TuistCore/Models/Platform.swift
Expand Up @@ -30,6 +30,48 @@ extension Platform {
}
}

/// Returns whether the platform has simulators.
public var hasSimulators: Bool {
switch self {
case .macOS: return false
default: return true
}
}

/// It returns the destination that should be used to
/// compile a product for this platform's simulator.
public var xcodeSimulatorDestination: String? {
switch self {
case .macOS: return nil
default: return "\(caseValue) Simulator"
}
}

/// Returns the SDK of the platform's simulator
/// If the platform doesn't have simulators, like macOS, it returns nil.
public var xcodeSimulatorSDK: String? {
switch self {
case .tvOS: return "appletvsimulator"
case .iOS: return "iphonesimulator"
case .watchOS: return "watchsimulator"
case .macOS: return nil
}
}

/// Returns the SDK to build for the platform's device.
public var xcodeDeviceSDK: String {
switch self {
case .tvOS:
return "appletvos"
case .iOS:
return "iphoneos"
case .macOS:
return "macosx"
case .watchOS:
return "watchos"
}
}

public var xcodeSupportedPlatforms: String {
switch self {
case .tvOS:
Expand Down
175 changes: 175 additions & 0 deletions Sources/TuistKit/Cache/XCFrameworkBuilder.swift
@@ -0,0 +1,175 @@
import Basic
import Foundation
import TuistCore
import TuistSupport

enum XCFrameworkBuilderError: FatalError {
case nonFrameworkTarget(String)

/// Error type.
var type: ErrorType {
switch self {
case .nonFrameworkTarget: return .abort
}
}

/// Error description.
var description: String {
switch self {
case let .nonFrameworkTarget(name):
return "Can't generate an .xcframework from the target '\(name)' because it's not a framework target"
}
}
}

protocol XCFrameworkBuilding {
/// It builds an xcframework for the given target.
/// The target must have framework as product.
///
/// - Parameters:
/// - workspacePath: Path to the generated .xcworkspace that contains the given target.
/// - target: Target whose .xcframework will be generated.
/// - Returns: Path to the compiled .xcframework.
func build(workspacePath: AbsolutePath, target: Target) throws -> AbsolutePath

/// It builds an xcframework for the given target.
/// The target must have framework as product.
///
/// - Parameters:
/// - projectPath: Path to the generated .xcodeproj that contains the given target.
/// - target: Target whose .xcframework will be generated.
/// - Returns: Path to the compiled .xcframework.
func build(projectPath: AbsolutePath, target: Target) throws -> AbsolutePath
}

final class XCFrameworkBuilder: XCFrameworkBuilding {
// MARK: - Attributes

/// When true the builder outputs the output from xcodebuild.
private let printOutput: Bool

// MARK: - Init

/// Initializes the builder.
/// - Parameter printOutput: When true the builder outputs the output from xcodebuild.
init(printOutput: Bool = true) {
self.printOutput = printOutput
}

// MARK: - XCFrameworkBuilding

func build(workspacePath: AbsolutePath, target: Target) throws -> AbsolutePath {
try build(arguments: ["-workspace", workspacePath.pathString], target: target)
}

func build(projectPath: AbsolutePath, target: Target) throws -> AbsolutePath {
try build(arguments: ["-project", projectPath.pathString], target: target)
}

// MARK: - Fileprivate

fileprivate func build(arguments: [String], target: Target) throws -> AbsolutePath {
if target.product != .framework {
throw XCFrameworkBuilderError.nonFrameworkTarget(target.name)
}

// Create temporary directories
let outputDirectory = try TemporaryDirectory(removeTreeOnDeinit: false)
let derivedDataPath = try TemporaryDirectory(removeTreeOnDeinit: true)

Printer.shared.print(section: "Building .xcframework for \(target.productName)")

// Build for the device
let deviceArchivePath = derivedDataPath.path.appending(component: "device.xcarchive")
var deviceArguments = xcodebuildCommand(scheme: target.name,
destination: deviceDestination(platform: target.platform),
sdk: target.platform.xcodeDeviceSDK,
derivedDataPath: derivedDataPath.path)
deviceArguments.append(contentsOf: ["-archivePath", deviceArchivePath.pathString])
deviceArguments.append(contentsOf: arguments)
Printer.shared.print(subsection: "Building \(target.productName) for device")
try runCommand(deviceArguments)

// Build for the simulator
var simulatorArchivePath: AbsolutePath?
if target.platform.hasSimulators {
simulatorArchivePath = derivedDataPath.path.appending(component: "simulator.xcarchive")
var simulatorArguments = xcodebuildCommand(scheme: target.name,
destination: target.platform.xcodeSimulatorDestination!,
sdk: target.platform.xcodeSimulatorSDK!,
derivedDataPath: derivedDataPath.path)
simulatorArguments.append(contentsOf: ["-archivePath", simulatorArchivePath!.pathString])
simulatorArguments.append(contentsOf: arguments)
Printer.shared.print(subsection: "Building \(target.productName) for simulator")
try runCommand(simulatorArguments)
}

// Build the xcframework
Printer.shared.print(subsection: "Exporting xcframework for \(target.productName)")
let xcframeworkPath = outputDirectory.path.appending(component: "\(target.productName).xcframework")
let xcframeworkArguments = xcodebuildXcframeworkCommand(deviceArchivePath: deviceArchivePath,
simulatorArchivePath: simulatorArchivePath,
productName: target.productName,
xcframeworkPath: xcframeworkPath)
try runCommand(xcframeworkArguments)

return xcframeworkPath
}

/// Runs the given command.
/// - Parameter arguments: Command arguments.
fileprivate func runCommand(_ arguments: [String]) throws {
if printOutput {
try System.shared.runAndPrint(arguments)
} else {
try System.shared.run(arguments)
}
}

/// Returns the arguments that should be passed to xcodebuild to compile for a device on the given platform.
/// - Parameter platform: Platform we are compiling for.
fileprivate func deviceDestination(platform: Platform) -> String {
switch platform {
case .macOS: return "osx"
default: return "generic/platform=\(platform.caseValue)"
}
}

/// Returns the xcodebuild command to generate the .xcframework from the device
/// and the simulator frameworks.
///
/// - Parameters:
/// - deviceArchivePath: Path to the archive that contains the framework for the device.
/// - simulatorArchivePath: Path to the archive that contains the framework for the simulator.
/// - productName: Name of the product.
/// - xcframeworkPath: Path where the .xcframework should be exported to (e.g. /path/to/MyFeature.xcframework).
fileprivate func xcodebuildXcframeworkCommand(deviceArchivePath: AbsolutePath,
simulatorArchivePath: AbsolutePath?,
productName: String,
xcframeworkPath: AbsolutePath) -> [String] {
var command = ["xcrun", "xcodebuild", "-create-xcframework"]
command.append(contentsOf: ["-framework", deviceArchivePath.appending(RelativePath("Products/Library/Frameworks/\(productName).framework")).pathString])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Products/Library/Frameworks fixed paths are code smell to me, I guess that would break if the Xcode project configuration may define something custom here. Probably not a problem in 99% of cases but maybe you could offer a default + optional override input flag here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Products/Library/Frameworks fixed paths are code smell to me, I guess that would break if the Xcode project configuration may define something custom here.

Yeah, some other things might break too if Apple changes something in the API of their tools or the structure of the frameworks. To catch that we have acceptance & integration tests that should fail if that happens.

Probably not a problem in 99% of cases but maybe you could offer a default + optional override input flag here?

That would not change anything, would it? Instead of having the path defined in this line, it'd be defined a few lines above.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote that cause I thought you can provide the product path in the build settings. But I may be wrong and anyway as you say, this can be solved later πŸ‘

if let simulatorArchivePath = simulatorArchivePath {
command.append(contentsOf: ["-framework", simulatorArchivePath.appending(RelativePath("Products/Library/Frameworks/\(productName).framework")).pathString])
}
command.append(contentsOf: ["-output", xcframeworkPath.pathString])
return command
}

/// It returns the xcodebuild command to archive the .framework.
/// - Parameters:
/// - scheme: Name of the scheme that archives the framework.
/// - destination: Compilation destination.
/// - sdk: Compilation SDK.
/// - derivedDataPath: Derived data directory.
fileprivate func xcodebuildCommand(scheme: String, destination: String, sdk: String, derivedDataPath: AbsolutePath) -> [String] {
var command = ["xcrun", "xcodebuild", "clean", "archive"]
command.append(contentsOf: ["-scheme", scheme.spm_shellEscaped()])
command.append(contentsOf: ["-sdk", sdk])
command.append(contentsOf: ["-destination='\(destination)'"])
command.append(contentsOf: ["-derivedDataPath", derivedDataPath.pathString])
// Without the BUILD_LIBRARY_FOR_DISTRIBUTION argument xcodebuild doesn't generate the .swiftinterface file
command.append(contentsOf: ["SKIP_INSTALL=NO", "BUILD_LIBRARY_FOR_DISTRIBUTION=YES"])
return command
}
}
8 changes: 8 additions & 0 deletions Sources/TuistSupportTesting/TestCase/TuistTestCase.swift
Expand Up @@ -102,4 +102,12 @@ public class TuistTestCase: XCTestCase {
"""
XCTAssertTrue(printer.standardErrorMatches(with: expected), message, file: file, line: line)
}

public func temporaryFixture(_ pathString: String) throws -> AbsolutePath {
let path = RelativePath(pathString)
let fixturePath = self.fixturePath(path: path)
let destinationPath = (try temporaryPath()).appending(component: path.basename)
try FileHandler.shared.copy(from: fixturePath, to: destinationPath)
return destinationPath
}
}
1 change: 1 addition & 0 deletions Tests/Fixtures/Frameworks/.gitignore
@@ -0,0 +1 @@
!Frameworks.xcodeproj