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

Automate copy-frameworks #2731

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
be91674
Add automatic copying of the linked Carthage frameworks
dimazen Mar 12, 2019
9f7997d
Add filtering out of the Static frameworks during linked frameworks l…
dimazen Mar 13, 2019
d63fd53
Remove relying on the @rpath during linked frameworks inferring
dimazen Mar 14, 2019
94dba4c
Add automatic option for copy-frameworks command
dimazen Mar 16, 2019
ab0c144
Add priority evaluation for user input files and inferred input files
dimazen Mar 16, 2019
ed8f05a
Extract input files inferring to separate class; Add spec for inferring
dimazen Mar 24, 2019
7b9b856
Add excluding of the user input files from inferred files to prevent …
dimazen Mar 24, 2019
b683b99
Update CopyFrameworks to use new InputFilesInferrer
dimazen Mar 24, 2019
89888aa
Update otool response handling to handle Mac frameworks and c99 ident…
dimazen Mar 24, 2019
2ad8830
Remove dashes from otool response handling
dimazen Mar 24, 2019
faac09d
Add doc for automatic copying to README
dimazen Mar 25, 2019
44000e4
Add `verbose` mode and shorten `automatic` to `auto` for copy-framewo…
dimazen Mar 25, 2019
7726b85
Fix README commands for Automatic copy-frameworks
dimazen Mar 26, 2019
62f4e2e
Add FRAMEWORK_SEARCH_PATHS usage by InputFilesInferrer
dimazen Apr 14, 2019
7dd48cf
Add expansion of the recursive framework search path entry
dimazen Apr 14, 2019
f75fa80
Add tests for path resolving by InputFilesInferrer
dimazen Apr 21, 2019
3434045
Add tests for path resolving duplicates / standartization
dimazen Apr 21, 2019
efd3173
Move FRAMEWORK_SEARCH_PATHS mapping to InputFilesInferrer
dimazen Apr 22, 2019
e94ee95
Add ignorance of the directory descendants based on extension; Add al…
dimazen May 1, 2019
e956858
Add spec for InputFilesInferrer framework search paths resolution
dimazen May 1, 2019
5b59eef
Add skipIfOutdated for copyProduct
dimazen May 3, 2019
ba50f98
Fix modification date comparison key to prevent attributes modification
dimazen May 3, 2019
2229832
Fix tests for InputFileInferrer to skip framework file type check
dimazen May 3, 2019
99b1f48
Replace "Nested" by "Transient" dependency
dimazen May 21, 2019
3661c19
Add path quoting and escaping for otool invocation
dimazen May 23, 2019
d779680
Add copy skip before framework stripping / processing
dimazen Jun 2, 2019
241525c
Update logging for automatic frameworks copying to prevent threading …
dimazen Jun 2, 2019
51836c9
Align logging of copy-frameworks command
dimazen Jun 3, 2019
689bea3
Merge branch 'master' of https://github.com/Carthage/Carthage
dimazen Jun 3, 2019
1d25d71
Minor verbose logging improvements for copy-frameworks
dimazen Jun 3, 2019
308ad18
PR review fixes
dimazen Jun 22, 2019
9dbe871
Fix typos
dimazen Jul 12, 2019
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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ Carthage builds your dependencies and provides you with binary frameworks, but y
1. On your application targets’ _Build Phases_ settings tab, click the _+_ icon and choose _New Run Script Phase_. Create a Run Script in which you specify your shell (ex: `/bin/sh`), add the following contents to the script area below the shell:

```sh
/usr/local/bin/carthage copy-frameworks
tmspzz marked this conversation as resolved.
Show resolved Hide resolved
/usr/local/bin/carthage copy-frameworks --auto
```

From that point all of the Carthage's frameworks that are linked againts your target will be copied automatically.

In case you need to specify path to your framework manually for whatever reason, do:

- Add the paths to the frameworks you want to use under “Input Files". For example:

```
Expand Down Expand Up @@ -109,6 +113,18 @@ Additionally, you'll need to copy debug symbols for debugging and crash reportin
1. On your application targets’ _General_ settings tab, in the “Linked Frameworks and Libraries” section, drag and drop each framework you want to use from the [Carthage/Build][] folder on disk.
1. On your application targets’ _Build Phases_ settings tab, click the _+_ icon and choose _New Run Script Phase_. Create a Run Script in which you specify your shell (ex: `/bin/sh`), add the following contents to the script area below the shell:

###### Automatic

```sh
/usr/local/bin/carthage copy-frameworks --auto
```

From this point Carthage will infer and copy all Carthage's frameworks that are linked against target. It also capable to copy transitive frameworks. For example, you have linked to your app `SocialSDK-Swift` that links internally `SocialSDK-ObjC` which in turns uses utilitary dependency `SocialTools`. In this case you don't need transient dependencies it should be enough to link against your target only `SocialSDK-Swift`. Transient dependencies will be resolved and copied automatically to your app.

Optionally you can add `--verbose` flag to see which frameworks are being copied by Carthage.

###### Manual

```sh
/usr/local/bin/carthage copy-frameworks
```
Expand Down Expand Up @@ -137,6 +153,15 @@ With the debug information copied into the built products directory, Xcode will

When archiving your application for submission to the App Store or TestFlight, Xcode will also copy these files into the dSYMs subdirectory of your application’s `.xcarchive` bundle.

###### Combining Automatic and Manual copying

Note that you can combine both automatic and manual ways to copy frameworks, however manually specified frameworks always take precedence over automatically inferred. Therefore in case you have `SomeFramework.framework` located anywhere as well as `SomeFramework.framework` located at `./Carthage/Build/<platform>/`, Carthage will pick manually specified framework. This is useful when you're working with development frameworks and want to copy your version of the framework instead of default one.
Important to undestand, that Carthage won't resolve transient dependencies for your custom framework unless they either located at `./Carthage/Build/<platform>/` or specified manually in “Input Files".

###### Automatic depencencies copying FRAMEWORK_SEARCH_PATHS

If you're working on a development dependencies and would like to utilize `--auto` flag to automate copying of the build artifacts you also can be interested in using `--use-framework-search-paths` flag. This will instruct Carthage to search for a linked dependcies and copy them using `FRAMEWORK_SEARCH_PATHS` environment variable.
tmspzz marked this conversation as resolved.
Show resolved Hide resolved

##### For both platforms

Along the way, Carthage will have created some [build artifacts][Artifacts]. The most important of these is the [Cartfile.resolved][] file, which lists the versions that were actually built for each framework. **Make sure to commit your [Cartfile.resolved][]**, because anyone else using the project will need that file to build the same framework versions.
Expand Down
48 changes: 48 additions & 0 deletions Source/CarthageKit/FoundationExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

import Foundation

extension Collection where Element: Hashable {

func unique() -> [Element] {
var set = Set<Element>(minimumCapacity: count)

return filter {
return set.insert($0).inserted
}
}
}

extension FileManager {

public func allDirectories(at directoryURL: URL, ignoringExtensions: Set<String> = []) -> [URL] {
func isDirectory(at url: URL) -> Bool {
let values = try? url.resourceValues(forKeys: [.isDirectoryKey])
return values?.isDirectory == true
}

let options: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles]
let keys: [URLResourceKey] = [.isDirectoryKey]
guard
directoryURL.isFileURL,
isDirectory(at: directoryURL),
let enumerator = self.enumerator(at: directoryURL, includingPropertiesForKeys: keys, options: options)
else
{
tmspzz marked this conversation as resolved.
Show resolved Hide resolved
return []
}

var result: [URL] = [directoryURL]

for url in enumerator {
if let url = url as? URL, isDirectory(at: url) {
if !url.pathExtension.isEmpty && ignoringExtensions.contains(url.pathExtension) {
enumerator.skipDescendants()
} else {
result.append(url)
}
}
}

return result.map { $0.standardizedFileURL }
}
}
238 changes: 238 additions & 0 deletions Source/CarthageKit/InputFilesInferrer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@

import Foundation
import Result
import ReactiveSwift
import Tentacle
import XCDBLD
import ReactiveTask

public final class InputFilesInferrer {

typealias LinkedFrameworksResolver = (URL) -> Result<[String], CarthageError>

/// For test-use only
var executableResolver: (URL) -> URL? = { Bundle(url: $0)?.executableURL }

/// For test-use only
var builtFrameworkFilter: (URL) -> Bool = { url in
if
let executableURL = Bundle(url: url)?.executableURL,
let header = MachHeader.headers(forMachOFileAtUrl: executableURL).single()?.value
{
return header.fileType == MH_DYLIB
}
return false
}

private let builtFrameworks: SignalProducer<URL, CarthageError>
private let linkedFrameworksResolver: LinkedFrameworksResolver

// MARK: - Init

init(builtFrameworks: SignalProducer<URL, CarthageError>, linkedFrameworksResolver: @escaping LinkedFrameworksResolver) {
self.builtFrameworks = builtFrameworks
self.linkedFrameworksResolver = linkedFrameworksResolver
}

public convenience init(projectDirectory: URL, platform: Platform, frameworkSearchPaths: [URL]) {
let allFrameworkSearchPath = InputFilesInferrer.allFrameworkSearchPaths(
forProjectIn: projectDirectory,
platform: platform,
frameworkSearchPaths: frameworkSearchPaths
)
let enumerator = SignalProducer(allFrameworkSearchPath).flatMap(.concat, frameworksInDirectory)

self.init(builtFrameworks: enumerator, linkedFrameworksResolver: linkedFrameworks(for:))
}

// MARK: - Inferring

public func inputFiles(for executableURL: URL, userInputFiles: SignalProducer<URL, CarthageError>) -> SignalProducer<URL, CarthageError> {
let userFrameworksMap = userInputFiles.reduce(into: [String: URL]()) { (map, frameworkURL) in
let name = frameworkURL.deletingPathExtension().lastPathComponent
map[name] = frameworkURL
}

let builtFrameworksMap = builtFrameworks
.filter(builtFrameworkFilter)
.reduce(into: [String: URL]()) { (map, frameworkURL) in
let name = frameworkURL.deletingPathExtension().lastPathComponent
// Framework potentially can be presented in multiple directories from FRAMEWORK_SEARCH_PATHS.
// We're only interested in the first occurrence to preserve order of the paths.
if map[name] == nil {
map[name] = frameworkURL
}
}

return SignalProducer.combineLatest(userFrameworksMap, builtFrameworksMap)
.flatMap(.latest) { userFrameworksMap, builtFrameworksMap -> SignalProducer<URL, CarthageError> in
let availableFrameworksMap = userFrameworksMap.merging(builtFrameworksMap) { (lhs, rhs) in
// user's framework path always takes precedence over default Carthage's path.
return lhs
}

if availableFrameworksMap.isEmpty {
return .empty
}

return SignalProducer(result: self.resolveFrameworks(at: executableURL, frameworksMap: availableFrameworksMap))
.flatten()
.filter { url in
// We have to omit paths already specified by User.
// Can't use direct URLs comparison, because it is not guaranteed that same framework will have
// same URL all the time. i.e. '/A.framework/' and '/A.framework' will lead to the same result but are not equal.
let name = url.deletingPathExtension().lastPathComponent
return userFrameworksMap[name] == nil
}
}
}

private func resolveFrameworks(at executableURL: URL, frameworksMap: [String: URL]) -> Result<[URL], CarthageError> {
var resolvedFrameworks: Set<String> = []
do {
try collectFrameworks(at: executableURL, accumulator: &resolvedFrameworks, frameworksMap: frameworksMap)
} catch let error as CarthageError {
return .failure(error)
} catch {
return .failure(CarthageError.internalError(description: "Failed to infer linked frameworks"))
}

return .success(resolvedFrameworks.compactMap { frameworksMap[$0] })
}

private func collectFrameworks(at executableURL: URL, accumulator: inout Set<String>, frameworksMap: [String: URL]) throws {
let name = executableURL.deletingPathExtension().lastPathComponent
if !accumulator.insert(name).inserted {
return
}

switch linkedFrameworksResolver(executableURL) {
case .success(let values):
let frameworksToCollect = values
.filter { !accumulator.contains($0) }
.compactMap { frameworksMap[$0] }
.compactMap(executableResolver)

try frameworksToCollect.forEach {
try collectFrameworks(at: $0, accumulator: &accumulator, frameworksMap: frameworksMap)
}

case .failure(let error):
throw error
}
}

// MARK: - Utility

static func allFrameworkSearchPaths(forProjectIn directory: URL, platform: Platform, frameworkSearchPaths: [URL]) -> [URL] {
// Carthage's default framework search path should always be presented. Under rare circumstances
// framework located at the non-default path can be linked against Carthage's framework.
// Since we're allowing user to specify only first-level frameworks, App might not link transient framework,
// therefore FRAMEWORKS_SEARCH_PATHS won't contain Carthage default search path.
// To prevent such failure we're appending default path at the end.
let defaultSearchPath = defaultFrameworkSearchPath(forProjectIn: directory, platform: platform)
// To throw away all duplicating paths.
// `standardizedFileURL` is needed to normalize paths like `/tmp/search/path` and `/private/tmp/search/path` to
// make them equal. Otherwise we'll end up having duplicates of the same path. Latter one even might be invalid.
let result = (frameworkSearchPaths + [defaultSearchPath]).map { $0.standardizedFileURL }.unique()
return result
}

static func defaultFrameworkSearchPath(forProjectIn directory: URL, platform: Platform) -> URL {
return directory.appendingPathComponent(platform.relativePath, isDirectory: true)
}

/// Maps Xcode's `FRAMEWORK_SEARCH_PATHS` string to an array or file URLs as well as resolves recursive directories.
///
/// - Parameter rawFrameworkSearchPaths: Value of `FRAMEWORK_SEARCH_PATHS`
/// - Returns: Array of corresponding file URLs.
public static func frameworkSearchPaths(from rawFrameworkSearchPaths: String) -> [URL] {
// During expansion of the recursive path we don't want to enumarate over a directories that are known
// to be used as resources / other xcode-specific files that do not contain any frameworks.
let ignoredDirectoryExtensions: Set<String> = [
"app",
"dSYM",
"docset",
"framework",
"git",
"lproj",
"playground",
"xcassets",
"scnassets",
"xcstickers",
"xcbaseline",
"xcdatamodel",
"xcmappingmodel",
"xcodeproj",
"xctemplate",
"xctest",
"xcworkspace"
]

// We can not split by ' ' or by '\n' since it will give us invalid results because of the escaped spaces.
// To handle this we're replacing escaped spaces by ':' which seems to be the only invalid symbol on macOS,
// making conversion and then reverting replacement.
let escapingSymbol = ":"
return rawFrameworkSearchPaths
.replacingOccurrences(of: "\\ ", with: escapingSymbol)
.split(separator: " ")
.map { $0.replacingOccurrences(of: escapingSymbol, with: " ") }
.flatMap { path -> [URL] in
// For recursive paths Xcode adds a "**" suffix. i.e. /search/path turns into a /search/path/**
// We need to collect all the nested paths to act like an Xcode.
let recursiveSymbol = "**"

if path.hasSuffix(recursiveSymbol) {
let normalizedURL = URL(fileURLWithPath: String(path.dropLast(recursiveSymbol.count)), isDirectory: true)
return FileManager.default.allDirectories(at: normalizedURL, ignoringExtensions: ignoredDirectoryExtensions)
} else {
return [URL(fileURLWithPath: path, isDirectory: true)]
}
}
.map { $0.standardizedFileURL }
}
}

/// Invokes otool -L for a given executable URL.
///
/// - Parameter executableURL: URL to a valid executable.
/// - Returns: Array of the Shared Library ID that are linked against given executable (`Alamofire`, `Realm`, etc).
/// System libraries and dylibs are omited.
internal func linkedFrameworks(for executable: URL) -> Result<[String], CarthageError> {
return Task("/usr/bin/xcrun", arguments: ["otool", "-L", executable.path.spm_shellEscaped()])
tmspzz marked this conversation as resolved.
Show resolved Hide resolved
.launch()
.mapError(CarthageError.taskError)
.ignoreTaskData()
.filterMap { data -> String? in
return String(data: data, encoding: .utf8)
}
.map(linkedFrameworks(from:))
.single() ?? .success([])
}

/// Stripping linked shared frameworks from
/// @rpath/Alamofire.framework/Alamofire (compatibility version 1.0.0, current version 1.0.0)
/// to Alamofire as well as filtering out system frameworks and various dylibs.
/// Static frameworks and libraries won't show up here, so we can ignore them.
///
/// - Parameter input: Output of the otool -L
/// - Returns: Array of Shared Framework IDs.
internal func linkedFrameworks(from input: String) -> [String] {
// Executable name matches c99 extended identifier.
// This regex ignores dylibs but we don't need them.
guard let regex = try? NSRegularExpression(pattern: "\\/([\\w_]+) ") else {
return []
}
return input.components(separatedBy: "\n").compactMap { value in
let fullNSRange = NSRange(value.startIndex..<value.endIndex, in: value)
if

let match = regex.firstMatch(in: value, range: fullNSRange),
match.numberOfRanges > 1,
match.range(at: 1).length > 0
{
return Range(match.range(at: 1), in: value).map { String(value[$0]) }
}
return nil
}
}
20 changes: 19 additions & 1 deletion Source/CarthageKit/Xcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -964,12 +964,15 @@ private func stripBinary(_ binaryURL: URL, keepingArchitectures: [String]) -> Si
/// does not already exist, and any pre-existing version of the product in the
/// destination folder will be deleted before the copy of the new version.
///
/// Passing `true` for `skipIfOutdated` will result into a no-op in case pre-existing version of the product
/// exists and has been modified later than the destination product. Default is `false`.
///
/// If the `from` URL has the same path as the `to` URL, and there is a resource
/// at the given path, no operation is needed and the returned signal will just
/// send `.success`.
///
/// Returns a signal that will send the URL after copying upon .success.
public func copyProduct(_ from: URL, _ to: URL) -> SignalProducer<URL, CarthageError> { // swiftlint:disable:this identifier_name
public func copyProduct(_ from: URL, _ to: URL, skipIfOutdated: Bool = false) -> SignalProducer<URL, CarthageError> { // swiftlint:disable:this identifier_name
return SignalProducer<URL, CarthageError> { () -> Result<URL, CarthageError> in
let manager = FileManager.default

Expand All @@ -982,6 +985,21 @@ public func copyProduct(_ from: URL, _ to: URL) -> SignalProducer<URL, CarthageE
if manager.fileExists(atPath: to.path) && from.absoluteURL == to.absoluteURL {
return .success(to)
}

if skipIfOutdated {
let key: URLResourceKey = .contentModificationDateKey
let fromAttributes = try? from.resourceValues(forKeys: [key])
let toAttributes = try? to.resourceValues(forKeys: [key])

// File at `to` has been modified later than `from`, therefore we need to skip copying.
if
let fromModificationDate = fromAttributes?.contentModificationDate,
let toModificationDate = toAttributes?.contentModificationDate,
fromModificationDate <= toModificationDate
{
return .success(to)
}
}

// Although some methods’ documentation say: “YES if createIntermediates
// is set and the directory already exists)”, it seems to rarely
Expand Down