Add support for CocoaPods dependencies (#465)

* Add TargetDependency.cocoapods

* Add Dependency.cocoapods

* Add CocoaPodsNode model and support for caching it to the GraphLoaderCache

* Add tests to CocoaPodsNode

* Support loading a CocoaPods target dependency

* Test TargetNode changes

* Lint that the directories referenced by CocoaPods dependencies contain a Podfile

* Run 'pod install' after the workspace generation

* Add documentation

* Update Pod specs repository automtically

* Update CHANGELOG

* Add acceptance tests

* Remove empty code block and fix acceptance test
This commit is contained in:
Pedro Piñera Buendía 2019-08-04 07:22:49 -04:00 committed by GitHub
parent a3684c66d6
commit 22854327d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 897 additions and 66 deletions

View File

@ -15,6 +15,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
- Support multiple header paths https://github.com/tuist/tuist/pull/459 by @adamkhazi
- Allow specifying multiple configurations within project manifests https://github.com/tuist/tuist/pull/451 by @kwridan
- Add linting for mismatching build configurations in a workspace https://github.com/tuist/tuist/pull/474 by @kwridan
- Support for CocoaPods dependencies https://github.com/tuist/tuist/pull/465 by @pepibumur
### Fixed

View File

@ -15,3 +15,4 @@ gem "danger-swiftlint", "~> 0.23.0"
gem "encrypted-environment", "~> 0.2.0"
gem "google-cloud-storage", "~> 1.19"
gem "colorize", "~> 0.8.1"
gem "cocoapods", "~> 1.7"

View File

@ -2,19 +2,59 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.0)
activesupport (4.2.11.1)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.6.0)
public_suffix (>= 2.0.2, < 4.0)
ast (2.4.0)
atomos (0.1.3)
backports (3.11.4)
builder (3.2.3)
byebug (10.0.2)
claide (1.0.2)
claide (1.0.3)
claide-plugins (0.9.2)
cork
nap
open4 (~> 1.3)
cocoapods (1.7.5)
activesupport (>= 4.0.2, < 5)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.7.5)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.2.2, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-stats (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.3.1, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.6.6)
nap (~> 1.0)
ruby-macho (~> 1.4)
xcodeproj (>= 1.10.0, < 2.0)
cocoapods-core (1.7.5)
activesupport (>= 4.0.2, < 6)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
cocoapods-deintegrate (1.0.4)
cocoapods-downloader (1.2.2)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.0)
cocoapods-stats (1.1.0)
cocoapods-trunk (1.3.1)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.1.0)
colored2 (3.1.2)
colorize (0.8.1)
concurrent-ruby (1.1.5)
cork (0.3.0)
colored2 (~> 3.1)
cucumber (3.1.2)
@ -64,10 +104,14 @@ GEM
ejson (1.2.1)
encrypted-environment (0.2.0)
ejson (~> 1.2)
escape (0.0.4)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
faraday-http-cache (2.0.0)
faraday (~> 0.8)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
gherkin (5.1.0)
git (1.5.0)
google-api-client (0.30.5)
@ -97,6 +141,8 @@ GEM
os (>= 0.9, < 2.0)
signet (~> 0.7)
httpclient (2.8.3)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
jaro_winkler (1.5.3)
jwt (2.2.1)
kramdown (2.1.0)
@ -108,11 +154,14 @@ GEM
mime-types-data (3.2019.0331)
mini_mime (1.0.2)
minitest (5.11.3)
molinillo (0.6.6)
multi_json (1.13.1)
multi_test (0.1.2)
multipart-post (2.1.1)
nanaimo (0.2.6)
nap (1.1.0)
naturally (2.2.0)
netrc (0.11.0)
no_proxy_fix (0.1.2)
octokit (4.14.0)
sawyer (~> 0.8.0, >= 0.5.3)
@ -136,6 +185,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
ruby-macho (1.4.0)
ruby-progressbar (1.10.1)
sawyer (0.8.2)
addressable (>= 2.3.5)
@ -151,14 +201,24 @@ GEM
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
thor (0.20.3)
thread_safe (0.3.6)
tzinfo (1.2.5)
thread_safe (~> 0.1)
uber (0.1.0)
unicode-display_width (1.6.0)
xcodeproj (1.12.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.2.6)
PLATFORMS
ruby
DEPENDENCIES
byebug (~> 10.0)
cocoapods (~> 1.7)
colorize (~> 0.8.1)
cucumber (~> 3.1)
danger (~> 6.0)

View File

@ -48,6 +48,12 @@ public enum TargetDependency: Codable, Equatable {
/// - status: The dependency status (optional dependencies are weakly linked)
case sdk(name: String, status: SDKStatus)
/// Dependency on CocoaPods pods.
///
/// - Parameters:
/// - path: Path to the directory that contains the Podfile.
case cocoapods(path: String)
/// Dependency on system library or framework
///
/// - Parameters:
@ -71,6 +77,8 @@ public enum TargetDependency: Codable, Equatable {
return "library"
case .sdk:
return "sdk"
case .cocoapods:
return "cocoapods"
}
}
}
@ -125,6 +133,9 @@ extension TargetDependency {
self = .sdk(name: try container.decode(String.self, forKey: .name),
status: try container.decode(SDKStatus.self, forKey: .status))
case "cocoapods":
self = .cocoapods(path: try container.decode(String.self, forKey: .path))
default:
throw CodingError.unknownType(type)
}
@ -150,6 +161,8 @@ extension TargetDependency {
case let .sdk(name, status):
try container.encode(name, forKey: .name)
try container.encode(status, forKey: .status)
case let .cocoapods(path):
try container.encode(path, forKey: .path)
}
}
}

View File

@ -38,14 +38,14 @@ public class Printer: Printing {
public func print(error: Error) {
let writer = InteractiveWriter.stderr
writer.write("Error: ", inColor: .red, bold: true)
writer.write("Error: ", inColor: .red, bold: true)
writer.write(error.localizedDescription)
writer.write("\n")
}
public func print(success: String) {
let writer = InteractiveWriter.stdout
writer.write("Success: ", inColor: .green, bold: true)
writer.write("Success: ", inColor: .green, bold: true)
writer.write(success)
writer.write("\n")
}
@ -62,14 +62,14 @@ public class Printer: Printing {
public func print(warning: String) {
let writer = InteractiveWriter.stdout
writer.write("⚠️ Warning: ", inColor: .yellow, bold: true)
writer.write("Warning: ", inColor: .yellow, bold: true)
writer.write(warning, inColor: .yellow, bold: false)
writer.write("\n")
}
public func print(errorMessage: String) {
let writer = InteractiveWriter.stderr
writer.write("Error: ", inColor: .red, bold: true)
writer.write("Error: ", inColor: .red, bold: true)
writer.write(errorMessage, inColor: .red, bold: false)
writer.write("\n")
}

View File

@ -85,6 +85,16 @@ public protocol Systeming {
/// - Throws: An error if the command fails.
func runAndPrint(_ arguments: [String], verbose: Bool, environment: [String: String]) throws
/// Runs a command in the shell printing its output.
///
/// - Parameters:
/// - arguments: Command.
/// - verbose: When true it prints the command that will be executed before executing it.
/// - environment: Environment that should be used when running the task.
/// - redirection: Instance through which the output will be redirected.
/// - Throws: An error if the command fails.
func runAndPrint(_ arguments: [String], verbose: Bool, environment: [String: String], redirection: Basic.Process.OutputRedirection) throws
/// Runs a command in the shell asynchronously.
/// When the process that triggers the command gets killed, the command continues its execution.
///
@ -283,7 +293,6 @@ public final class System: Systeming {
/// - Parameters:
/// - arguments: Arguments to be passed.
/// - verbose: When true it prints the command that will be executed before executing it.
/// - workingDirectoryPath: The working directory path the task is executed from.
/// - environment: Environment that should be used when running the task.
/// - Throws: An error if the command fails.
public func runAndPrint(_ arguments: String...,
@ -304,16 +313,34 @@ public final class System: Systeming {
public func runAndPrint(_ arguments: [String],
verbose: Bool,
environment: [String: String]) throws {
try runAndPrint(arguments,
verbose: verbose,
environment: environment,
redirection: .none)
}
/// Runs a command in the shell printing its output.
///
/// - Parameters:
/// - arguments: Command.
/// - verbose: When true it prints the command that will be executed before executing it.
/// - environment: Environment that should be used when running the task.
/// - redirection: Instance through which the output will be redirected.
/// - Throws: An error if the command fails.
public func runAndPrint(_ arguments: [String], verbose: Bool, environment: [String: String], redirection: Basic.Process.OutputRedirection) throws {
let process = Process(arguments: arguments,
environment: environment,
outputRedirection: .stream(stdout: { bytes in
FileHandle.standardOutput.write(Data(bytes))
redirection.outputClosures?.stdoutClosure(bytes)
}, stderr: { bytes in
FileHandle.standardError.write(Data(bytes))
redirection.outputClosures?.stderrClosure(bytes)
}), verbose: verbose,
startNewProcessGroup: false)
try process.launch()
try process.waitUntilExit()
let result = try process.waitUntilExit()
try result.throwIfErrored()
}
/// Runs a command in the shell asynchronously.

View File

@ -1,4 +1,4 @@
import struct Basic.AbsolutePath
import Basic
import Foundation
import TuistCore
@ -13,7 +13,7 @@ public final class MockSystem: Systeming {
public init() {}
public func errorCommand(_ arguments: String..., error: String? = nil) {
self.errorCommand(arguments, error: error)
errorCommand(arguments, error: error)
}
public func errorCommand(_ arguments: [String], error: String? = nil) {
@ -21,7 +21,7 @@ public final class MockSystem: Systeming {
}
public func succeedCommand(_ arguments: String..., output: String? = nil) {
self.succeedCommand(arguments, output: output)
succeedCommand(arguments, output: output)
}
public func succeedCommand(_ arguments: [String], output: String? = nil) {
@ -39,19 +39,19 @@ public final class MockSystem: Systeming {
}
public func run(_ arguments: String...) throws {
try self.run(arguments)
try run(arguments)
}
public func capture(_ arguments: [String]) throws -> String {
return try self.capture(arguments, verbose: false, environment: [:])
return try capture(arguments, verbose: false, environment: [:])
}
public func capture(_ arguments: String...) throws -> String {
return try self.capture(arguments, verbose: false, environment: [:])
return try capture(arguments, verbose: false, environment: [:])
}
public func capture(_ arguments: String..., verbose: Bool, environment: [String: String]) throws -> String {
return try self.capture(arguments, verbose: verbose, environment: environment)
return try capture(arguments, verbose: verbose, environment: environment)
}
public func capture(_ arguments: [String], verbose _: Bool, environment _: [String: String]) throws -> String {
@ -66,29 +66,36 @@ public final class MockSystem: Systeming {
}
public func runAndPrint(_ arguments: String...) throws {
try self.runAndPrint(arguments)
try runAndPrint(arguments)
}
public func runAndPrint(_ arguments: [String]) throws {
try self.runAndPrint(arguments, verbose: false, environment: [:])
try runAndPrint(arguments, verbose: false, environment: [:])
}
public func runAndPrint(_ arguments: String..., verbose: Bool, environment: [String: String]) throws {
try self.runAndPrint(arguments, verbose: verbose, environment: environment)
try runAndPrint(arguments, verbose: verbose, environment: environment)
}
public func runAndPrint(_ arguments: [String], verbose _: Bool, environment _: [String: String]) throws {
public func runAndPrint(_ arguments: [String], verbose: Bool, environment: [String: String]) throws {
try runAndPrint(arguments, verbose: verbose, environment: environment, redirection: .none)
}
public func runAndPrint(_ arguments: [String], verbose _: Bool, environment _: [String: String], redirection: Basic.Process.OutputRedirection) throws {
let command = arguments.joined(separator: " ")
guard let stub = self.stubs[command] else {
throw SystemError.terminated(code: 1, error: "command '\(command)' not stubbed")
}
if stub.exitstatus != 0 {
if let error = stub.stderror {
redirection.outputClosures?.stderrClosure([UInt8](error.data(using: .utf8)!))
}
throw SystemError.terminated(code: 1, error: stub.stderror ?? "")
}
}
public func async(_ arguments: [String]) throws {
try self.async(arguments, verbose: false, environment: [:])
try async(arguments, verbose: false, environment: [:])
}
public func async(_ arguments: [String], verbose _: Bool, environment _: [String: String]) throws {

View File

@ -66,11 +66,13 @@ public class Generator: Generating {
system: system,
fileHandler: fileHandler)
let workspaceStructureGenerator = WorkspaceStructureGenerator(fileHandler: fileHandler)
let cocoapodsInteractor = CocoaPodsInteractor()
let workspaceGenerator = WorkspaceGenerator(system: system,
printer: printer,
projectGenerator: projectGenerator,
fileHandler: fileHandler,
workspaceStructureGenerator: workspaceStructureGenerator)
workspaceStructureGenerator: workspaceStructureGenerator,
cocoapodsInteractor: cocoapodsInteractor)
self.init(graphLoader: graphLoader,
workspaceGenerator: workspaceGenerator,
projectGenerator: projectGenerator)

View File

@ -45,13 +45,15 @@ final class WorkspaceGenerator: WorkspaceGenerating {
private let printer: Printing
private let fileHandler: FileHandling
private let workspaceStructureGenerator: WorkspaceStructureGenerating
private let cocoapodsInteractor: CocoaPodsInteracting
// MARK: - Init
convenience init(system: Systeming = System(),
printer: Printing = Printer(),
fileHandler: FileHandling = FileHandler(),
defaultSettingsProvider: DefaultSettingsProviding = DefaultSettingsProvider()) {
defaultSettingsProvider: DefaultSettingsProviding = DefaultSettingsProvider(),
cocoapodsInteractor: CocoaPodsInteracting = CocoaPodsInteractor()) {
let configGenerator = ConfigGenerator(defaultSettingsProvider: defaultSettingsProvider)
let targetGenerator = TargetGenerator(configGenerator: configGenerator)
let projectGenerator = ProjectGenerator(targetGenerator: targetGenerator,
@ -63,19 +65,22 @@ final class WorkspaceGenerator: WorkspaceGenerating {
printer: printer,
projectGenerator: projectGenerator,
fileHandler: fileHandler,
workspaceStructureGenerator: WorkspaceStructureGenerator(fileHandler: fileHandler))
workspaceStructureGenerator: WorkspaceStructureGenerator(fileHandler: fileHandler),
cocoapodsInteractor: cocoapodsInteractor)
}
init(system: Systeming,
printer: Printing,
projectGenerator: ProjectGenerating,
fileHandler: FileHandling,
workspaceStructureGenerator: WorkspaceStructureGenerating) {
workspaceStructureGenerator: WorkspaceStructureGenerating,
cocoapodsInteractor: CocoaPodsInteracting) {
self.system = system
self.printer = printer
self.projectGenerator = projectGenerator
self.fileHandler = fileHandler
self.workspaceStructureGenerator = workspaceStructureGenerator
self.cocoapodsInteractor = cocoapodsInteractor
}
// MARK: - WorkspaceGenerating
@ -123,6 +128,10 @@ final class WorkspaceGenerator: WorkspaceGenerating {
try write(xcworkspace: xcWorkspace, to: workspacePath)
// CocoaPods
try cocoapodsInteractor.install(graph: graph)
return workspacePath
}

View File

@ -0,0 +1,43 @@
import Basic
import Foundation
class CocoaPodsNode: GraphNode {
/// Path to the Podfile.
var podfilePath: AbsolutePath {
return path.appending(component: "Podfile")
}
/// Initializes the node with the path to the directory
/// that contains the Podfile.
///
/// - Parameter path: Path to the directory that contains the Podfile.
init(path: AbsolutePath) {
super.init(path: path, name: "CocoaPods")
}
/// Compares the CocoaPods node with another node and returns true if both nodes are equal.
///
/// - Parameter otherNode: The other node to be compared with.
/// - Returns: True if the two instances are equal.
override func isEqual(to otherNode: GraphNode) -> Bool {
guard let otherTagetNode = otherNode as? CocoaPodsNode else {
return false
}
return super.isEqual(to: otherTagetNode)
}
/// Reads the CocoaPods node. If it it exists in the cache, it returns it from the cache.
/// Otherwise, it initializes it, stores it in the cache, and then returns it.
///
/// - Parameters:
/// - path: Path to the directory that contains the Podfile.
/// - cache: Cache instance where the nodes are cached.
/// - Returns: The initialized instance of the CocoaPods node.
static func read(path: AbsolutePath,
cache: GraphLoaderCaching) -> CocoaPodsNode {
if let cached = cache.cocoapods(path) { return cached }
let node = CocoaPodsNode(path: path)
cache.add(cocoapods: node)
return node
}
}

View File

@ -75,6 +75,11 @@ protocol Graphing: AnyObject, Encodable {
var entryPath: AbsolutePath { get }
var entryNodes: [GraphNode] { get }
var projects: [Project] { get }
/// Returns all the CocoaPods nodes that are part of the graph.
var cocoapods: [CocoaPodsNode] { get }
/// Returns all the frameorks that are part of the graph.
var frameworks: [FrameworkNode] { get }
/// Returns all the precompiled nodes that are part of the graph.
@ -133,6 +138,12 @@ class Graph: Graphing {
// MARK: - Internal
/// Returns all the CocoaPods nodes that are part of the graph.
var cocoapods: [CocoaPodsNode] {
return Array(cache.cocoapodsNodes.values)
}
/// Returns all the frameworks that are part of the graph
var frameworks: [FrameworkNode] {
return cache.precompiledNodes.values.compactMap { $0 as? FrameworkNode }
}

View File

@ -5,6 +5,7 @@ protocol GraphLoaderCaching: AnyObject {
var projects: [AbsolutePath: Project] { get }
var targetNodes: [AbsolutePath: [String: TargetNode]] { get }
var precompiledNodes: [AbsolutePath: PrecompiledNode] { get }
func project(_ path: AbsolutePath) -> Project?
func add(project: Project)
func add(precompiledNode: PrecompiledNode)
@ -13,6 +14,20 @@ protocol GraphLoaderCaching: AnyObject {
func targetNode(_ path: AbsolutePath, name: String) -> TargetNode?
func tuistConfig(_ path: AbsolutePath) -> TuistConfig?
func add(tuistConfig: TuistConfig, path: AbsolutePath)
/// Cached CocoaPods nodes
var cocoapodsNodes: [AbsolutePath: CocoaPodsNode] { get }
/// Returns, if it exists, the CocoaPods node at the given path.
///
/// - Parameter path: Path to the directory where the Podfile is defined.
/// - Returns: The CocoaPods node if it exists in the cache.
func cocoapods(_ path: AbsolutePath) -> CocoaPodsNode?
/// Adds a parsed CocoaPods graph node to the cache.
///
/// - Parameter cocoapods: Node to be added to the cache.
func add(cocoapods: CocoaPodsNode)
}
/// Graph loader cache.
@ -24,6 +39,24 @@ class GraphLoaderCache: GraphLoaderCaching {
var precompiledNodes: [AbsolutePath: PrecompiledNode] = [:]
var targetNodes: [AbsolutePath: [String: TargetNode]] = [:]
/// Cached CocoaPods nodes
var cocoapodsNodes: [AbsolutePath: CocoaPodsNode] = [:]
/// Returns, if it exists, the CocoaPods node at the given path.
///
/// - Parameter path: Path to the directory where the Podfile is defined.
/// - Returns: The CocoaPods node if it exists in the cache.
func cocoapods(_ path: AbsolutePath) -> CocoaPodsNode? {
return cocoapodsNodes[path]
}
/// Adds a parsed CocoaPods graph node to the cache.
///
/// - Parameter cocoapods: Node to be added to the cache.
func add(cocoapods: CocoaPodsNode) {
cocoapodsNodes[cocoapods.path] = cocoapods
}
func tuistConfig(_ path: AbsolutePath) -> TuistConfig? {
return tuistConfigs[path]
}

View File

@ -89,6 +89,8 @@ class TargetNode: GraphNode {
return targetDependency.target.name
} else if let precompiledDependency = dependency as? PrecompiledNode {
return precompiledDependency.name
} else if let cocoapodsDependency = dependency as? CocoaPodsNode {
return cocoapodsDependency.name
} else {
return nil
}
@ -128,6 +130,8 @@ class TargetNode: GraphNode {
fileHandler: fileHandler, cache: cache)
case let .sdk(name, status):
return try SDKNode(name: name, platform: platform, status: status)
case let .cocoapods(podsPath):
return CocoaPodsNode.read(path: path.appending(podsPath), cache: cache)
}
}
}

View File

@ -55,10 +55,25 @@ class GraphLinter: GraphLinting {
}
issues.append(contentsOf: lintCarthageDependencies(graph: graph))
issues.append(contentsOf: lintCocoaPodsDependencies(graph: graph))
return issues
}
/// It verifies that the directory specified by the CocoaPods dependencies contains a Podfile file.
///
/// - Parameter graph: Project graph.
/// - Returns: Linting issues.
private func lintCocoaPodsDependencies(graph: Graphing) -> [LintingIssue] {
return graph.cocoapods.compactMap { node in
let podfilePath = node.podfilePath
if !fileHandler.exists(podfilePath) {
return LintingIssue(reason: "The Podfile at path \(podfilePath) referenced by some projects does not exist", severity: .error)
}
return nil
}
}
private func lintCarthageDependencies(graph: Graphing) -> [LintingIssue] {
let frameworks = graph.frameworks
let carthageFrameworks = frameworks.filter { $0.isCarthage }

View File

@ -1,4 +1,3 @@
import Foundation
import TuistCore

View File

@ -12,4 +12,5 @@ public enum Dependency: Equatable {
case framework(path: RelativePath)
case library(path: RelativePath, publicHeaders: RelativePath, swiftModuleMap: RelativePath?)
case sdk(name: String, status: SDKStatus)
case cocoapods(path: RelativePath)
}

View File

@ -0,0 +1,148 @@
import Foundation
import TuistCore
enum CocoaPodsInteractorError: FatalError, Equatable {
/// Thrown when CocoaPods cannot be found.
case cocoapodsNotFound
case outdatedRepository
/// Error type.
var type: ErrorType {
switch self {
case .cocoapodsNotFound:
return .abort
case .outdatedRepository:
return .abort
}
}
/// Error description.
var description: String {
switch self {
case .cocoapodsNotFound:
return "CocoaPods was not found either in Bundler nor in the environment"
case .outdatedRepository:
return "The installation of CocoaPods dependencies might have failed because the CocoaPods repository is outdated"
}
}
}
protocol CocoaPodsInteracting {
/// Runs 'pod install' for all the CocoaPods dependencies that have been indicated in the graph.
///
/// - Parameter graph: Project graph.
/// - Throws: An error if the installation of the pods fails.
func install(graph: Graphing) throws
}
final class CocoaPodsInteractor: CocoaPodsInteracting {
/// Instance to output information to the user.
let printer: Printing
/// Instance to run commands in the system.
let system: Systeming
/// Initializes the CocoaPods
///
/// - Parameters:
/// - printer: Instance to output information to the user.
/// - system: Instance to run commands in the system.
init(printer: Printing = Printer(),
system: Systeming = System()) {
self.printer = printer
self.system = system
}
/// Runs 'pod install' for all the CocoaPods dependencies that have been indicated in the graph.
///
/// - Parameter graph: Project graph.
/// - Throws: An error if the installation of the pods fails.
func install(graph: Graphing) throws {
do {
try install(graph: graph, updatingRepo: false)
} catch let error as CocoaPodsInteractorError {
if case CocoaPodsInteractorError.outdatedRepository = error {
printer.print(warning: "The local CocoaPods specs repository is outdated. Re-running 'pod install' updating the repository.")
try self.install(graph: graph, updatingRepo: true)
} else {
throw error
}
}
}
fileprivate func install(graph: Graphing, updatingRepo: Bool) throws {
guard !graph.cocoapods.isEmpty else {
return
}
let canUseBundler = canUseCocoaPodsThroughBundler()
let canUseSystem = canUseSystemPod()
try graph.cocoapods.forEach { node in
var command: [String]
if canUseBundler {
command = ["bundle", "exec", "pod"]
} else if canUseSystem {
command = ["pod"]
} else {
throw CocoaPodsInteractorError.cocoapodsNotFound
}
command.append(contentsOf: ["install", "--project-directory=\(node.path.pathString)"])
if updatingRepo {
command.append("--repo-update")
}
// The installation of Pods might fail if the local repository that contains the specs
// is outdated.
printer.print(section: "Installing CocoaPods dependencies defined in \(node.podfilePath)")
var mightNeedRepoUpdate: Bool = false
let outputClosure: ([UInt8]) -> Void = { bytes in
let content = String(data: Data(bytes), encoding: .utf8)
if content?.contains("CocoaPods could not find compatible versions for pod") == true {
mightNeedRepoUpdate = true
}
}
do {
try system.runAndPrint(command,
verbose: false,
environment: system.env,
redirection: .stream(stdout: outputClosure,
stderr: outputClosure))
} catch {
if mightNeedRepoUpdate {
throw CocoaPodsInteractorError.outdatedRepository
} else {
throw error
}
}
}
}
/// Returns true if CocoaPods is accessible through Bundler,
/// and shoudl be used instead of the global CocoaPods.
///
/// - Returns: True if Bundler can execute CocoaPods.
fileprivate func canUseCocoaPodsThroughBundler() -> Bool {
do {
try system.run(["bundle", "show", "cocoapods"])
return true
} catch {
return false
}
}
/// Returns true if CocoaPods is avaiable in the environment.
///
/// - Returns: True if CocoaPods is available globally in the system.
fileprivate func canUseSystemPod() -> Bool {
do {
_ = try system.which("pod")
return true
} catch {
return false
}
}
}

View File

@ -481,6 +481,8 @@ extension TuistGenerator.Dependency {
case let .sdk(name, status):
return .sdk(name: name,
status: .from(manifest: status))
case let .cocoapods(path):
return .cocoapods(path: RelativePath(path))
}
}
}

View File

@ -1,4 +1,3 @@
import Foundation
import ProjectDescription
import TuistCore

View File

@ -48,4 +48,12 @@ final class TargetDependencyTests: XCTestCase {
// Then
XCTAssertEqual(decoded, sdks)
}
func test_cocoapods_codable() throws {
// Given
let subject = TargetDependency.cocoapods(path: "./path")
// Then
XCTAssertCodable(subject)
}
}

View File

@ -9,6 +9,7 @@ final class WorkspaceGeneratorTests: XCTestCase {
var subject: WorkspaceGenerator!
var path: AbsolutePath!
var fileHandler: MockFileHandler!
var cocoapodsInteractor: MockCocoaPodsInteractor!
override func setUp() {
super.setUp()
@ -16,11 +17,13 @@ final class WorkspaceGeneratorTests: XCTestCase {
do {
fileHandler = try MockFileHandler()
path = fileHandler.currentPath
cocoapodsInteractor = MockCocoaPodsInteractor()
subject = WorkspaceGenerator(
system: MockSystem(),
printer: MockPrinter(),
fileHandler: fileHandler
fileHandler: fileHandler,
cocoapodsInteractor: cocoapodsInteractor
)
} catch {
@ -107,6 +110,27 @@ final class WorkspaceGeneratorTests: XCTestCase {
])
}
func test_generate_runsPodInstall() throws {
// Given
let target = anyTarget()
let project = Project.test(path: path,
name: "Test",
settings: .default,
targets: [target])
let graph = Graph.create(project: project,
dependencies: [(target, [])])
let workspace = Workspace.test(projects: [project.path])
// When
_ = try subject.generate(workspace: workspace,
path: path,
graph: graph,
tuistConfig: .test())
// Then
XCTAssertEqual(cocoapodsInteractor.installArgs.count, 1)
}
// MARK: - Helpers
func anyTarget() -> Target {

View File

@ -0,0 +1,37 @@
import Basic
import Foundation
import XCTest
@testable import TuistGenerator
final class CocoaPodsNodeTests: XCTestCase {
func test_name() {
// Given
let path = AbsolutePath("/")
let subject = CocoaPodsNode(path: path)
// When
let got = subject.name
// Then
XCTAssertEqual(got, "CocoaPods")
}
func test_isEqual_returnsTrue_when_thePathsAreTheSame() {
// Given
let lhs = CocoaPodsNode(path: AbsolutePath("/"))
let rhs = CocoaPodsNode(path: AbsolutePath("/"))
// Then
XCTAssertEqual(lhs, rhs)
}
func test_isEqual_returnsFalse_when_thePathsAreTheSame() {
// Given
let lhs = CocoaPodsNode(path: AbsolutePath("/"))
let rhs = CocoaPodsNode(path: AbsolutePath("/other"))
// Then
XCTAssertNotEqual(lhs, rhs)
}
}

View File

@ -18,6 +18,17 @@ final class MockGraphLoaderCache: GraphLoaderCaching {
var targetNodeStub: ((AbsolutePath, String) -> TargetNode?)?
var tuistConfigStub: [AbsolutePath: TuistConfig] = [:]
var addTuistConfigArgs: [(tuistConfig: TuistConfig, path: AbsolutePath)] = []
var cocoapodsNodes: [AbsolutePath: CocoaPodsNode] = [:]
var cocoapodsStub: [AbsolutePath: CocoaPodsNode] = [:]
var addCococaPodsArgs: [CocoaPodsNode] = []
func cocoapods(_ path: AbsolutePath) -> CocoaPodsNode? {
return cocoapodsStub[path]
}
func add(cocoapods: CocoaPodsNode) {
addCococaPodsArgs.append(cocoapods)
}
func tuistConfig(_ path: AbsolutePath) -> TuistConfig? {
return tuistConfigStub[path]

View File

@ -53,9 +53,10 @@ final class TargetNodeTests: XCTestCase {
// Given
let library = LibraryNode.test()
let framework = FrameworkNode.test()
let cocoapods = CocoaPodsNode.test()
let node = TargetNode(project: .test(path: "/"),
target: .test(name: "Target"),
dependencies: [library, framework])
dependencies: [library, framework, cocoapods])
let expected = """
{
@ -66,7 +67,8 @@ final class TargetNodeTests: XCTestCase {
"name" : "\(node.target.name)",
"dependencies" : [
"\(library.name)",
"\(framework.name)"
"\(framework.name)",
"\(cocoapods.name)"
],
"platform" : "\(node.target.platform.rawValue)"
}

View File

@ -0,0 +1,10 @@
import Basic
import Foundation
@testable import TuistGenerator
extension CocoaPodsNode {
static func test(path: AbsolutePath = "/") -> CocoaPodsNode {
return CocoaPodsNode(path: path)
}
}

View File

@ -0,0 +1,10 @@
import Basic
import Foundation
@testable import TuistGenerator
extension FrameworkNode {
static func test(path: AbsolutePath = "/Test.framework") -> FrameworkNode {
return FrameworkNode(path: path)
}
}

View File

@ -0,0 +1,11 @@
import Basic
import Foundation
@testable import TuistGenerator
extension LibraryNode {
static func test(path: AbsolutePath = "/libTest.a",
publicHeaders: AbsolutePath = "/TestHeaders/") -> LibraryNode {
return LibraryNode(path: path, publicHeaders: publicHeaders)
}
}

View File

@ -12,16 +12,3 @@ extension TargetNode {
dependencies: dependencies)
}
}
extension FrameworkNode {
static func test(path: AbsolutePath = "/Test.framework") -> FrameworkNode {
return FrameworkNode(path: path)
}
}
extension LibraryNode {
static func test(path: AbsolutePath = "/libTest.a",
publicHeaders: AbsolutePath = "/TestHeaders/") -> LibraryNode {
return LibraryNode(path: path, publicHeaders: publicHeaders)
}
}

View File

@ -35,6 +35,21 @@ final class GraphLinterTests: XCTestCase {
XCTAssertTrue(result.contains(LintingIssue(reason: "Framework not found at path \(frameworkBPath.pathString). The path might be wrong or Carthage dependencies not fetched", severity: .warning)))
}
func test_lint_when_podfiles_are_missing() throws {
// Given
let cache = GraphLoaderCache()
let graph = Graph.test(cache: cache)
let cocoapods = CocoaPodsNode(path: fileHandler.currentPath)
cache.add(cocoapods: cocoapods)
let podfilePath = fileHandler.currentPath.appending(component: "Podfile")
// When
let result = subject.lint(graph: graph)
// Then
XCTAssertTrue(result.contains(LintingIssue(reason: "The Podfile at path \(podfilePath) referenced by some projects does not exist", severity: .error)))
}
func test_lint_when_frameworks_are_missing() throws {
let cache = GraphLoaderCache()
let graph = Graph.test(cache: cache)

View File

@ -0,0 +1,105 @@
import Foundation
import TuistCore
import XCTest
@testable import TuistCoreTesting
@testable import TuistGenerator
final class CocoaPodsInteractorErrorTests: XCTestCase {
func test_type() {
XCTAssertEqual(CocoaPodsInteractorError.cocoapodsNotFound.type, .abort)
XCTAssertEqual(CocoaPodsInteractorError.outdatedRepository.type, .abort)
}
func test_description() {
XCTAssertEqual(CocoaPodsInteractorError.cocoapodsNotFound.description, "CocoaPods was not found either in Bundler nor in the environment")
XCTAssertEqual(CocoaPodsInteractorError.outdatedRepository.description, "The installation of CocoaPods dependencies might have failed because the CocoaPods repository is outdated")
}
}
final class CocoaPodsInteractorTests: XCTestCase {
var printer: MockPrinter!
var system: MockSystem!
var subject: CocoaPodsInteractor!
override func setUp() {
super.setUp()
printer = MockPrinter()
system = MockSystem()
subject = CocoaPodsInteractor(printer: printer, system: system)
}
func test_install_when_cocoapods_cannot_be_found() {
// Given
system.errorCommand(["bundle", "show", "cocoapods"])
system.whichStub = { _ in
throw NSError.test()
}
let cache = GraphLoaderCache()
let graph = Graph.test(cache: cache)
let cocoapods = CocoaPodsNode.test()
cache.add(cocoapods: cocoapods)
// Then
XCTAssertThrowsError(try subject.install(graph: graph)) {
XCTAssertEqual($0 as? CocoaPodsInteractorError, CocoaPodsInteractorError.cocoapodsNotFound)
}
}
func test_install_when_theCocoaPodsFromBundlerCanBeUsed() throws {
// Given
let cache = GraphLoaderCache()
let graph = Graph.test(cache: cache)
let cocoapods = CocoaPodsNode.test()
cache.add(cocoapods: cocoapods)
system.succeedCommand(["bundle", "show", "cocoapods"])
system.succeedCommand(["bundle", "exec", "pod", "install", "--project-directory=\(cocoapods.path.pathString)"])
// When
try subject.install(graph: graph)
// Then
XCTAssertTrue(printer.printSectionArgs.contains("Installing CocoaPods dependencies defined in \(cocoapods.podfilePath)"))
}
func test_install_when_theCocoaPodsFromTheSystemCanBeUsed() throws {
// Given
let cache = GraphLoaderCache()
let graph = Graph.test(cache: cache)
let cocoapods = CocoaPodsNode.test()
cache.add(cocoapods: cocoapods)
system.errorCommand(["bundle", "show", "cocoapods"])
system.whichStub = {
if $0 == "pod" { return "/path/to/pod" }
else { throw NSError.test() }
}
system.succeedCommand(["pod", "install", "--project-directory=\(cocoapods.path.pathString)"])
// When
try subject.install(graph: graph)
// Then
XCTAssertTrue(printer.printSectionArgs.contains("Installing CocoaPods dependencies defined in \(cocoapods.podfilePath)"))
}
func test_install_when_theCocoaPodsSpecsRepoIsOutdated() throws {
// Given
let cache = GraphLoaderCache()
let graph = Graph.test(cache: cache)
let cocoapods = CocoaPodsNode.test()
cache.add(cocoapods: cocoapods)
system.succeedCommand(["bundle", "show", "cocoapods"])
system.errorCommand(["bundle", "exec", "pod", "install", "--project-directory=\(cocoapods.path.pathString)"], error: "[!] CocoaPods could not find compatible versions for pod")
system.succeedCommand(["bundle", "exec", "pod", "install", "--project-directory=\(cocoapods.path.pathString)", "--repo-update"])
// When
try subject.install(graph: graph)
// Then
XCTAssertTrue(printer.printWarningArgs.contains("The local CocoaPods specs repository is outdated. Re-running 'pod install' updating the repository."))
XCTAssertTrue(printer.printSectionArgs.contains("Installing CocoaPods dependencies defined in \(cocoapods.podfilePath)"))
}
}

View File

@ -0,0 +1,13 @@
import Foundation
@testable import TuistGenerator
final class MockCocoaPodsInteractor: CocoaPodsInteracting {
var installArgs: [Graphing] = []
var installStub: Error?
func install(graph: Graphing) throws {
installArgs.append(graph)
if let error = installStub { throw error }
}
}

View File

@ -296,6 +296,21 @@ class GeneratorModelLoaderTest: XCTestCase {
assert(settings: model, matches: manifest, at: path)
}
func test_dependency_when_cocoapods() throws {
// Given
let dependency = TargetDependency.cocoapods(path: "./path/to/project")
// When
let got = TuistGenerator.Dependency.from(manifest: dependency)
// Then
guard case let .cocoapods(path) = got else {
XCTFail("Dependency should be cocoapods")
return
}
XCTAssertEqual(path, RelativePath("./path/to/project"))
}
func test_headers() throws {
// Given
try fileHandler.createFiles([

View File

@ -2,6 +2,8 @@
name: Dependencies
---
import Message from '../components/message'
# Dependencies
**Setting up dependencies in Xcode projects isn't straightforward**. When dependencies have transitive dependencies things get complicated because it requires changes in the targets that are part of the branch where the transitive dependency is. To illustrate that, think about an app, depending on a dynamic framework `Search`, which has no dependencies. If at some point in the future we add a new dynamic framework `Core`, on which `Search` depends, well need to update not only `Search`, but the app to embed the framework into the product.
@ -76,6 +78,27 @@ It defines a dependency with a pre-compiled library. It allows specifying the pa
It defines a dependency on a system library (`.tbd`) or framework (`.framework`) and optionally if it is `required` or `optional` (i.e. gets weakly linked).
-----
## CocoaPods dependencies
Targets can indicate that they have [CocoaPods](https://cocoapods.org) dependencies defined in a `Podfile`:
```swift
.cocoapods(path: ".") // Expects a Podfile in the directory of the target's project
```
Tuist looks up CocoaPods using Bundler. If it's not defined, it falls back to the system's CocoaPods. If CocoaPods can't be found in the environment, the installation of the dependencies will fail.
<Message
info
title="Repository update"
description="The underlying 'pod install' is executed with the '--update-repo' argument to ensure the local repository of pod specs is up to date."
/>
<Message
warning
title="Podfile validation"
description="Tuist does not parse the CocoaPods dependency graph nor runs any validation. It's the user responsibility ensure the right format of the 'Podfile'."
/>
---
As we mentioned, the beauty of defining your dependencies with Tuist is that when you generate the project, things are set up and ready for you to successfully compile your targets.

View File

@ -44,7 +44,7 @@ Feature: Generate a new project using Tuist
Given that tuist is available
And I have a working directory
Then I copy the fixture invalid_workspace_manifest_name into the working directory
Then tuist generates reports error " Error: Manifest not found at path ${ARG_PATH}"
Then tuist generates reports error "Error: Manifest not found at path ${ARG_PATH}"
Scenario: The project is an iOS application with frameworks and tests (ios_app_with_static_libraries)
Given that tuist is available
@ -160,3 +160,10 @@ Scenario: The project is an iOS application with multiple configurations (ios_ap
Then the scheme Framework2 has a build setting CUSTOM_FLAG with value "Debug" for the configuration Debug
Then the scheme Framework2 has a build setting CUSTOM_FLAG with value "Target.Beta" for the configuration Beta
Then the scheme Framework2 has a build setting CUSTOM_FLAG with value "Release" for the configuration Release
Scenario: The project is an iOS application with CocoaPods dependencies (ios_app_with_pods)
Given that tuist is available
And I have a working directory
Then I copy the fixture ios_app_with_pods into the working directory
Then tuist generates the project
Then I should be able to build the scheme App

View File

@ -9,7 +9,7 @@ Contains a single file `Workspac.swift`, incorrectly named workspace manifest fi
## ios_app_with_custom_workspace
Contains a few projects and a `Workspace.swift` manifest file.
Contains a few projects and a `Workspace.swift` manifest file.
The workspace manifest defines:
@ -20,7 +20,7 @@ The workspace manifest defines:
The App's project manifest leverages `additionalFiles` tha defines:
- glob patterns to include documentation files
- Includes a swift `Danger.swift` file that shouldn't get included in any buid phase
- Includes a swift `Danger.swift` file that shouldn't get included in any buid phase
- folder references to a directory with json files
## ios_app_with_tests
@ -49,9 +49,10 @@ Workspace:
```
Dependencies:
- App -> Framework1
- App -> Framework2
- Framework1 -> Framework2
- App -> Framework1
- App -> Framework2
- Framework1 -> Framework2
## ios_app_with_framework_and_resources
@ -67,8 +68,9 @@ Workspace:
```
Dependencies:
- App -> Framework1
- App -> Framework1
## ios_app_with_framework_linking_static_framework
An example project demonstrating an iOS application linking a dynamic framework which itself depends on a static framework with transitive static dependencies.
@ -95,10 +97,11 @@ Workspace:
```
Dependencies:
- App -> Framework1
- Framework1 -> Framework2
- Framework1 -> Framework3
- Framework3 -> Framework4
- App -> Framework1
- Framework1 -> Framework2
- Framework1 -> Framework3
- Framework3 -> Framework4
## ios_app_with_multi_configs
@ -128,6 +131,7 @@ Workspace:
```
A standalone C project is used to generate a prebuilt static library:
```
- C:
- C (static library iOS)
@ -135,11 +139,12 @@ A standalone C project is used to generate a prebuilt static library:
```
Dependencies:
- App -> A
- A -> B
- A -> prebuild C (libC.a)
Note: to re-create `libC.a` run `fixtures/ios_app_with_static_libraries/Modules/C/build.sh`
- App -> A
- A -> B
- A -> prebuild C (libC.a)
Note: to re-create `libC.a` run `fixtures/ios_app_with_static_libraries/Modules/C/build.sh`
## ios_app_with_static_frameworks
@ -164,11 +169,12 @@ Workspace:
```
Dependencies:
- App -> A
- App -> C
- A -> B
- A -> C
- C -> D
- App -> A
- App -> C
- A -> B
- A -> C
- C -> D
## ios_app_with_tests
@ -187,14 +193,19 @@ Workspace:
```
A standalone Framework2 project is used to generate a prebuilt dynamic framework :
```
- Framework2:
- Framework2 (dynamic iOS framework)
```
Dependencies:
- App -> Framework1
- Framework1 -> Framework2 (prebuilt)
Note: to re-create `Framework2.framework` run `fixtures/ios_app_with_transitive_framework/Framework2/build.sh`
- App -> Framework1
- Framework1 -> Framework2 (prebuilt)
Note: to re-create `Framework2.framework` run `fixtures/ios_app_with_transitive_framework/Framework2/build.sh`
## ios_app_with_pods
An iOS application with CocoaPods dependencies

64
fixtures/ios_app_with_pods/.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Xcode ###
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
### Projects ###
*.xcodeproj
*.xcworkspace
Pods/

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright ©. All rights reserved.</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,6 @@
platform :ios, '12.0'
use_frameworks!
target 'App' do
pod 'RxSwift', '~> 5'
end

View File

@ -0,0 +1,16 @@
PODS:
- RxSwift (5.0.0)
DEPENDENCIES:
- RxSwift (~> 5)
SPEC REPOS:
https://github.com/cocoapods/specs.git:
- RxSwift
SPEC CHECKSUMS:
RxSwift: 8b0671caa829a763bbce7271095859121cbd895f
PODFILE CHECKSUM: 3baa4c5df87f8aaf5441c016e9d9914ea1463c1f
COCOAPODS: 1.7.5

View File

@ -0,0 +1,14 @@
import ProjectDescription
let project = Project(name: "App",
targets: [
Target(name: "App",
platform: .iOS,
product: .app,
bundleId: "io.tuist.app",
infoPlist: "Info.plist",
sources: ["Sources/**"],
dependencies: [
.cocoapods(path: ".")
]),
])

View File

@ -0,0 +1,24 @@
import UIKit
import RxSwift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
// To make sure RxSwift is available
let observable = Observable.just("Test")
window = UIWindow(frame: UIScreen.main.bounds)
let viewController = UIViewController()
viewController.view.backgroundColor = .white
window?.rootViewController = viewController
window?.makeKeyAndVisible()
return true
}
}