├── .github └── workflows │ ├── test.yml │ └── update_formula.yml ├── .gitignore ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Scipio │ ├── Commands │ │ ├── BuildCommand.swift │ │ ├── RunCommand.swift │ │ └── UploadCommand.swift │ ├── Runner.swift │ └── main.swift └── ScipioKit │ ├── Cache Engines │ ├── CacheEngine.swift │ ├── CacheEngineDelegator.swift │ ├── HTTPCacheEngine.swift │ ├── LocalCacheEngine.swift │ └── S3CacheEngine.swift │ ├── Dependency Processors │ ├── BinaryProcessor.swift │ ├── CocoaPodProcessor.swift │ ├── DependencyProcessor.swift │ └── PackageProcessor.swift │ ├── Extensions │ ├── Collection+Extensions.swift │ ├── Collection+Operators.swift │ ├── Data+Checksum.swift │ ├── Future+Deferred.swift │ ├── Future+Try.swift │ ├── Int+Spaces.swift │ ├── Path+Checksum.swift │ ├── Path+Extensions.swift │ ├── Path+Gzip.swift │ ├── Path+Untar.swift │ ├── Publisher+TryFlatMap.swift │ ├── Publisher+Wait.swift │ ├── String+Extensions.swift │ ├── URLSession+Extensions.swift │ └── XcodeProj+Products.swift │ ├── Helpers │ ├── CancelBag.swift │ ├── Log.swift │ ├── ShellCommand.swift │ ├── Xcode.swift │ └── Xcodebuild.swift │ └── Models │ ├── Architecture.swift │ ├── Config.swift │ ├── Dependency.swift │ ├── Platform.swift │ ├── ScipioError.swift │ ├── SwiftPackageDescriptor.swift │ └── SwiftPackageFile.swift └── Tests └── ScipioTests ├── ArchitectureTests.swift ├── ConfigTests.swift ├── DependencyProcessorTests.swift ├── Helpers ├── CachedArtifact+Mock.swift ├── Path+Extensions.swift └── XCTestCase+Config.swift ├── SwiftPackageDescriptorTests.swift └── SwiftPackageFileTests.swift /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | strategy: 10 | matrix: 11 | os: [macos-11] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Run tests 16 | run: make test -------------------------------------------------------------------------------- /.github/workflows/update_formula.yml: -------------------------------------------------------------------------------- 1 | name: Update Formula 2 | 3 | on: 4 | pull_request: 5 | types: [ labeled ] 6 | 7 | jobs: 8 | release: 9 | runs-on: macos-11 10 | if: ${{ github.event.label.name == 'pr-formula' }} 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Update Formula 14 | id: update_formula 15 | run: | 16 | BRANCH=${GITHUB_HEAD_REF/refs\/heads\//} 17 | VERSION=${BRANCH#"$BRANCH_PREFIX"} 18 | URL="https://github.com/evandcoleman/Scipio/archive/refs/tags/$VERSION.tar.gz" 19 | TAP_REPO="evandcoleman/homebrew-tap" 20 | REPO_PATH=$(pwd) 21 | 22 | git config --global user.email "$GITHUB_ACTOR@users.noreply.github.com" 23 | git config --global user.name $GITHUB_ACTOR 24 | 25 | git tag $VERSION 26 | git push origin $VERSION 27 | 28 | brew tap evandcoleman/tap https://$GITHUB_USER:$GITHUB_TOKEN@github.com/$TAP_REPO.git 29 | cd $(brew --repository $TAP_REPO) 30 | 31 | git checkout -b formula/scipio/$VERSION 32 | 33 | sed -E -i '' 's~^ url ".*"~ url "'$URL\"~ Formula/scipio.rb 34 | brew fetch Formula/scipio.rb || true 35 | SHA256=$(shasum --algorithm 256 $(brew --cache --build-from-source Formula/scipio.rb) | awk '{print $1}') 36 | sed -E -i '' 's/^ sha256 ".*"/ sha256 "'$SHA256\"/ Formula/scipio.rb 37 | 38 | git add Formula/scipio.rb 39 | git commit -m "scipio $VERSION" 40 | git push --force origin formula/scipio/$VERSION 41 | 42 | echo ::set-output name=version::$VERSION 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} 45 | GITHUB_USER: ${{ secrets.HOMEBREW_GITHUB_USER }} 46 | BRANCH_PREFIX: release/ 47 | - name: Create Release 48 | id: create_release 49 | uses: actions/create-release@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | tag_name: ${{ steps.update_formula.outputs.version }} 54 | release_name: Release ${{ steps.update_formula.outputs.version }} 55 | draft: false 56 | prerelease: false 57 | - name: Open Pull Request 58 | id: open_pull_request 59 | run: | 60 | cd /usr/local/Homebrew/Library/Taps/evandcoleman/homebrew-tap 61 | curl -u $GITHUB_USER:$GITHUB_TOKEN -d '{"title": "Update Scipio to '"$VERSION"'", "base": "main", "head": "formula/scipio/'"$VERSION"'"}' https://api.github.com/repos/evandcoleman/homebrew-tap/pulls 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} 64 | GITHUB_USER: ${{ secrets.HOMEBREW_GITHUB_USER }} 65 | VERSION: ${{ steps.update_formula.outputs.version }} 66 | - name: Merge Pull Request 67 | id: merge_pull_request 68 | run: | 69 | curl -u $GITHUB_USER:$GITHUB_TOKEN -X PUT https://api.github.com/repos/evandcoleman/Scipio/pulls/$PR_NUMBER/merge 70 | env: 71 | PR_NUMBER: ${{ github.event.number }} 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | GITHUB_USER: ${{ github.actor }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | 9 | # Created by https://www.toptal.com/developers/gitignore/api/swift 10 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift 11 | 12 | ### Swift ### 13 | # Xcode 14 | # 15 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 16 | 17 | ## User settings 18 | xcuserdata/ 19 | 20 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 21 | *.xcscmblueprint 22 | *.xccheckout 23 | 24 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 25 | build/ 26 | DerivedData/ 27 | *.moved-aside 28 | *.pbxuser 29 | !default.pbxuser 30 | *.mode1v3 31 | !default.mode1v3 32 | *.mode2v3 33 | !default.mode2v3 34 | *.perspectivev3 35 | !default.perspectivev3 36 | 37 | ## Obj-C/Swift specific 38 | *.hmap 39 | 40 | ## App packaging 41 | *.ipa 42 | *.dSYM.zip 43 | *.dSYM 44 | 45 | ## Playgrounds 46 | timeline.xctimeline 47 | playground.xcworkspace 48 | 49 | # Swift Package Manager 50 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 51 | # Packages/ 52 | # Package.pins 53 | # Package.resolved 54 | # *.xcodeproj 55 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 56 | # hence it is not needed unless you have added a package configuration file to your project 57 | # .swiftpm 58 | 59 | .build/ 60 | 61 | # CocoaPods 62 | # We recommend against adding the Pods directory to your .gitignore. However 63 | # you should judge for yourself, the pros and cons are mentioned at: 64 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 65 | # Pods/ 66 | # Add this line if you want to avoid checking in source code from the Xcode workspace 67 | # *.xcworkspace 68 | 69 | # Carthage 70 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 71 | # Carthage/Checkouts 72 | 73 | Carthage/Build/ 74 | 75 | # Accio dependency management 76 | Dependencies/ 77 | .accio/ 78 | 79 | # fastlane 80 | # It is recommended to not store the screenshots in the git repo. 81 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 82 | # For more information about the recommended setup visit: 83 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 84 | 85 | fastlane/report.xml 86 | fastlane/Preview.html 87 | fastlane/screenshots/**/*.png 88 | fastlane/test_output 89 | 90 | # Code Injection 91 | # After new code Injection tools there's a generated folder /iOSInjectionProject 92 | # https://github.com/johnno1962/injectionforxcode 93 | 94 | iOSInjectionProject/ 95 | 96 | # End of https://www.toptal.com/developers/gitignore/api/swift 97 | 98 | # Created by https://www.toptal.com/developers/gitignore/api/macos 99 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos 100 | 101 | ### macOS ### 102 | # General 103 | .DS_Store 104 | .AppleDouble 105 | .LSOverride 106 | 107 | # Icon must end with two \r 108 | Icon 109 | 110 | 111 | # Thumbnails 112 | ._* 113 | 114 | # Files that might appear in the root of a volume 115 | .DocumentRevisions-V100 116 | .fseventsd 117 | .Spotlight-V100 118 | .TemporaryItems 119 | .Trashes 120 | .VolumeIcon.icns 121 | .com.apple.timemachine.donotpresent 122 | 123 | # Directories potentially created on remote AFP share 124 | .AppleDB 125 | .AppleDesktop 126 | Network Trash Folder 127 | Temporary Items 128 | .apdisk 129 | 130 | # End of https://www.toptal.com/developers/gitignore/api/macos 131 | 132 | *.xcodeproj 133 | .swiftpm 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Evan Coleman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/xcrun make -f 2 | 3 | SCIPIO_TEMPORARY_FOLDER?=/tmp/Scipio.dst 4 | PREFIX?=/usr/local 5 | 6 | INTERNAL_PACKAGE=ScipioApp.pkg 7 | OUTPUT_PACKAGE=Scipio.pkg 8 | 9 | SCIPIO_EXECUTABLE=./.build/release/scipio 10 | BINARIES_FOLDER=$(PREFIX)/bin 11 | 12 | SWIFT_BUILD_FLAGS=--configuration release -Xswiftc -suppress-warnings 13 | 14 | SWIFTPM_DISABLE_SANDBOX_SHOULD_BE_FLAGGED:=$(shell test -n "$${HOMEBREW_SDKROOT}" && echo should_be_flagged) 15 | ifeq ($(SWIFTPM_DISABLE_SANDBOX_SHOULD_BE_FLAGGED), should_be_flagged) 16 | SWIFT_BUILD_FLAGS+= --disable-sandbox 17 | endif 18 | SWIFT_STATIC_STDLIB_SHOULD_BE_FLAGGED:=$(shell test -d $$(dirname $$(xcrun --find swift))/../lib/swift_static/macosx && echo should_be_flagged) 19 | ifeq ($(SWIFT_STATIC_STDLIB_SHOULD_BE_FLAGGED), should_be_flagged) 20 | SWIFT_BUILD_FLAGS+= -Xswiftc -static-stdlib 21 | endif 22 | 23 | # ZSH_COMMAND · run single command in `zsh` shell, ignoring most `zsh` startup files. 24 | ZSH_COMMAND := ZDOTDIR='/var/empty' zsh -o NO_GLOBAL_RCS -c 25 | # RM_SAFELY · `rm -rf` ensuring first and only parameter is non-null, contains more than whitespace, non-root if resolving absolutely. 26 | RM_SAFELY := $(ZSH_COMMAND) '[[ ! $${1:?} =~ "^[[:space:]]+\$$" ]] && [[ $${1:A} != "/" ]] && [[ $${\#} == "1" ]] && noglob rm -rf $${1:A}' -- 27 | 28 | VERSION_STRING=$(shell git describe --abbrev=0 --tags) 29 | DISTRIBUTION_PLIST=Source/scipio/Distribution.plist 30 | 31 | RM=rm -f 32 | MKDIR=mkdir -p 33 | SUDO=sudo 34 | CP=cp 35 | 36 | ifdef DISABLE_SUDO 37 | override SUDO:= 38 | endif 39 | 40 | .PHONY: all clean install package test uninstall 41 | 42 | all: installables 43 | 44 | clean: 45 | swift package clean 46 | 47 | test: 48 | swift build --build-tests -Xswiftc -suppress-warnings 49 | swift test --skip-build 50 | 51 | installables: 52 | swift build $(SWIFT_BUILD_FLAGS) 53 | 54 | package: installables 55 | $(MKDIR) "$(SCIPIO_TEMPORARY_FOLDER)$(BINARIES_FOLDER)" 56 | $(CP) "$(SCIPIO_EXECUTABLE)" "$(SCIPIO_TEMPORARY_FOLDER)$(BINARIES_FOLDER)" 57 | 58 | pkgbuild \ 59 | --identifier "net.evancoleman.scipio" \ 60 | --install-location "/" \ 61 | --root "$(SCIPIO_TEMPORARY_FOLDER)" \ 62 | --version "$(VERSION_STRING)" \ 63 | "$(INTERNAL_PACKAGE)" 64 | 65 | productbuild \ 66 | --distribution "$(DISTRIBUTION_PLIST)" \ 67 | --package-path "$(INTERNAL_PACKAGE)" \ 68 | "$(OUTPUT_PACKAGE)" 69 | 70 | prefix_install: installables 71 | $(MKDIR) "$(BINARIES_FOLDER)" 72 | $(CP) -f "$(SCIPIO_EXECUTABLE)" "$(BINARIES_FOLDER)/" 73 | 74 | install: installables 75 | if [ ! -d "$(BINARIES_FOLDER)" ]; then $(SUDO) $(MKDIR) "$(BINARIES_FOLDER)"; fi 76 | $(SUDO) $(CP) -f "$(SCIPIO_EXECUTABLE)" "$(BINARIES_FOLDER)" 77 | 78 | uninstall: 79 | $(RM) "$(BINARIES_FOLDER)/scipio" 80 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AEXML", 6 | "repositoryURL": "https://github.com/tadija/AEXML", 7 | "state": { 8 | "branch": null, 9 | "revision": "8623e73b193386909566a9ca20203e33a09af142", 10 | "version": "4.5.0" 11 | } 12 | }, 13 | { 14 | "package": "BitByteData", 15 | "repositoryURL": "https://github.com/tsolomko/BitByteData", 16 | "state": { 17 | "branch": null, 18 | "revision": "c119e12decae8f56066df3345a4ac1a05b2f06d5", 19 | "version": "2.0.1" 20 | } 21 | }, 22 | { 23 | "package": "Colorizer", 24 | "repositoryURL": "https://github.com/getGuaka/Colorizer.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "2ccc99bf1715e73c4139e8d40b6e6b30be975586", 28 | "version": "0.2.1" 29 | } 30 | }, 31 | { 32 | "package": "GraphViz", 33 | "repositoryURL": "https://github.com/SwiftDocOrg/GraphViz.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "70bebcf4597b9ce33e19816d6bbd4ba9b7bdf038", 37 | "version": "0.2.0" 38 | } 39 | }, 40 | { 41 | "package": "JSONUtilities", 42 | "repositoryURL": "https://github.com/yonaskolb/JSONUtilities.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "128d2ffc22467f69569ef8ff971683e2393191a0", 46 | "version": "4.2.0" 47 | } 48 | }, 49 | { 50 | "package": "PathKit", 51 | "repositoryURL": "https://github.com/kylef/PathKit", 52 | "state": { 53 | "branch": null, 54 | "revision": "73f8e9dca9b7a3078cb79128217dc8f2e585a511", 55 | "version": "1.0.0" 56 | } 57 | }, 58 | { 59 | "package": "Rainbow", 60 | "repositoryURL": "https://github.com/onevcat/Rainbow.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "626c3d4b6b55354b4af3aa309f998fae9b31a3d9", 64 | "version": "3.2.0" 65 | } 66 | }, 67 | { 68 | "package": "Regex", 69 | "repositoryURL": "https://github.com/sharplet/Regex", 70 | "state": { 71 | "branch": null, 72 | "revision": "76c2b73d4281d77fc3118391877efd1bf972f515", 73 | "version": "2.1.1" 74 | } 75 | }, 76 | { 77 | "package": "Spectre", 78 | "repositoryURL": "https://github.com/kylef/Spectre.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "f79d4ecbf8bc4e1579fbd86c3e1d652fb6876c53", 82 | "version": "0.9.2" 83 | } 84 | }, 85 | { 86 | "package": "SWCompression", 87 | "repositoryURL": "https://github.com/tsolomko/SWCompression.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "ff8113eb32ac04124b06372f030c08efdfcfc9fc", 91 | "version": "4.6.0" 92 | } 93 | }, 94 | { 95 | "package": "swift-argument-parser", 96 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 97 | "state": { 98 | "branch": null, 99 | "revision": "83b23d940471b313427da226196661856f6ba3e0", 100 | "version": "0.4.4" 101 | } 102 | }, 103 | { 104 | "package": "SwiftCLI", 105 | "repositoryURL": "https://github.com/jakeheis/SwiftCLI.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "2816678bcc37f4833d32abeddbdf5e757fa891d8", 109 | "version": "6.0.2" 110 | } 111 | }, 112 | { 113 | "package": "Version", 114 | "repositoryURL": "https://github.com/mxcl/Version", 115 | "state": { 116 | "branch": null, 117 | "revision": "1fe824b80d89201652e7eca7c9252269a1d85e25", 118 | "version": "2.0.1" 119 | } 120 | }, 121 | { 122 | "package": "xcbeautify", 123 | "repositoryURL": "https://github.com/thii/xcbeautify", 124 | "state": { 125 | "branch": null, 126 | "revision": "21c64495bb3eb9a46ecc9b5eea056d06383eb17c", 127 | "version": "0.9.1" 128 | } 129 | }, 130 | { 131 | "package": "XcodeGen", 132 | "repositoryURL": "https://github.com/yonaskolb/XcodeGen", 133 | "state": { 134 | "branch": null, 135 | "revision": "270ef8b27963b9fbfae3d02a253cc32a82f04ab1", 136 | "version": "2.24.0" 137 | } 138 | }, 139 | { 140 | "package": "XcodeProj", 141 | "repositoryURL": "https://github.com/tuist/XcodeProj", 142 | "state": { 143 | "branch": null, 144 | "revision": "0b18c3e7a10c241323397a80cb445051f4494971", 145 | "version": "8.0.0" 146 | } 147 | }, 148 | { 149 | "package": "Yams", 150 | "repositoryURL": "https://github.com/jpsim/Yams", 151 | "state": { 152 | "branch": null, 153 | "revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", 154 | "version": "4.0.6" 155 | } 156 | }, 157 | { 158 | "package": "Zip", 159 | "repositoryURL": "https://github.com/marmelroy/Zip", 160 | "state": { 161 | "branch": null, 162 | "revision": "bd19d974e8a38cc8d3a88c90c8a107386c3b8ccf", 163 | "version": "2.1.1" 164 | } 165 | } 166 | ] 167 | }, 168 | "version": 1 169 | } 170 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Scipio", 8 | platforms: [ 9 | .macOS(.v10_15) 10 | ], 11 | products: [ 12 | .executable(name: "Scipio", targets: ["Scipio"]) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/kylef/PathKit", from: "1.0.0"), 16 | .package(url: "https://github.com/sharplet/Regex", from: "2.1.1"), 17 | .package(url: "https://github.com/tsolomko/SWCompression.git", from: "4.6.0"), 18 | .package(url: "https://github.com/apple/swift-argument-parser", from: "0.4.4"), 19 | .package(url: "https://github.com/thii/xcbeautify", from: "0.9.1"), 20 | .package(name: "XcodeGen", url: "https://github.com/yonaskolb/XcodeGen", from: "2.24.0"), 21 | .package(url: "https://github.com/tuist/XcodeProj", from: "8.0.0"), 22 | .package(url: "https://github.com/jpsim/Yams", from: "4.0.6"), 23 | .package(url: "https://github.com/marmelroy/Zip", from: "2.1.1"), 24 | ], 25 | targets: [ 26 | .target( 27 | name: "Scipio", 28 | dependencies: [ 29 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 30 | "ScipioKit" 31 | ] 32 | ), 33 | .target( 34 | name: "ScipioKit", 35 | dependencies: [ 36 | "PathKit", 37 | "Regex", 38 | "SWCompression", 39 | .product(name: "XcbeautifyLib", package: "xcbeautify"), 40 | .product(name: "XcodeGenKit", package: "XcodeGen"), 41 | "XcodeProj", 42 | "Yams", 43 | "Zip", 44 | ] 45 | ), 46 | .testTarget( 47 | name: "ScipioTests", 48 | dependencies: ["ScipioKit"] 49 | ), 50 | ] 51 | ) 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scipio 2 | 3 | ![GitHub](https://img.shields.io/github/license/evandcoleman/Scipio) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/evandcoleman/Scipio) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/evandcoleman/Scipio/test/main) ![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/evandcoleman/Scipio) 4 | 5 | `Scipio` is a tool that takes existing Swift packages, binary frameworks, or CocoaPods, compiles them into XCFrameworks and delivers them as a single Swift package. 6 | 7 | 🔨 **Scipio is currrently in alpha. Some things may not work right. If you run into problems, please open an issue.** 8 | 9 | **_The Problem_**: Each dependency manager has its own drawbacks and advantages. `CocoaPods` is incredibly easy to setup, but requires you to compile each dependency from source. This can add a significant amount of time to your builds if you have a lot of dependencies. `Carthage` solves this problem, but not every library supports it and it adds several steps to your build pipeline. 10 | 11 | `Scipio` aims to solve these problems by leveraging the Swift package manager support built into Xcode 11+ along with SPM's ability to distribute binary frameworks. 12 | 13 | ## How it works 14 | 15 | `Scipio` takes existing Swift packages, CocoaPods, and pre-built frameworks and generates a Swift package that uses pre-built frameworks stored locally or on a remote server (like S3). 16 | 17 | ### Supported Inputs 18 | 19 | - Swift packages 20 | - CocoaPods 21 | - `.xcframework` via URL packaged as a `.zip` or `.tar.gz` 22 | 23 | ## Installation 24 | 25 | ### Homebrew (Recommended) 26 | 27 | 1. Ensure that you have `homebrew` installed. Visit [brew.sh](https://brew.sh) for more info. 28 | 2. Run `brew install evandcoleman/tap/scipio` 29 | 30 | ### Manually from Source 31 | 32 | 1. Clone the project and enter the directory 33 | 34 | ```bash 35 | $ git clone https://github.com/evandcoleman/Scipio.git 36 | $ cd Scipio 37 | ``` 38 | 39 | 2. Run `make install` 40 | 41 | ## Usage 42 | 43 | You have a few options for how to integrate Scipio into your project. 44 | 45 | - **If you want to share dependencies between multiple users of your project(s) across many machines:** 46 | 47 | 1. Create a new `git` repository somewhere on your machine. 48 | 2. Add your `scipio.yml` file (see Configuration section below). Be sure to use a non-local cache engine (such as `http`). 49 | 3. Run `scipio`. 50 | 4. Once Scipio completes, you'll have a new `Package.swift` file. Push this file to the new repository and create a new release tag. 51 | 5. Integrate the new package into your main project using the `git` URL for the new repository and specifying your release tag as the version. 52 | 53 | - **If you want to share dependencies between multiple projects:** 54 | 55 | 1. Create a new folder somewhere on your machine. 56 | 2. Add your `scipio.yml` file (see Configuration section below). Be sure to use the `local` cache engine. 57 | 3. Run `scipio`. 58 | 4. Once Scipio completes, you'll have a new `Package.swift` file. 59 | 5. Integrate the new package into your projects by dragging the containing directory into the Xcode project navigator. 60 | 61 | - **If your use case doesn't fall into one of the buckets above, use the basic setup:** 62 | 63 | 1. Create a new folder inside your project. 64 | 2. Add your `scipio.yml` file (see Configuration section below). Be sure to use the `local` cache engine. 65 | 3. Run `scipio`. 66 | 4. Once Scipio completes, you'll have a new `Package.swift` file. 67 | 5. Integrate the new package into your project by dragging the containing directory into the Xcode project navigator. 68 | 69 | ## Configuration 70 | 71 | Configuration is managed via a [YAML](https://yaml.org) file, `scipio.yml`. By default, Scipio looks for this file in the current directory, but you can override this behavior by specifying the `--config` flag followed by a path to a directory containing a `scipio.yml` file. 72 | 73 | It is recommended to create a separate repository to store your Scipio configuration. This is where the generated `Package.swift` will live. 74 | 75 | ### Top Level Keys 76 | 77 | **`name`**: The name you want to use for the Swift package that Scipio generates. If you're using a local cache engine, this should be the name of the enclosing folder. 78 | 79 | **`deploymentTarget`**: The deployment targets that you'd like to build dependencies for. 80 | 81 | **`cache`**: The cache engine to use. Currently `http` (with a `url`) and `local` (with a `path`) are supported. 82 | 83 | **`binaries`**: An array of binary frameworks to download and include. These must be `zip` or `tar.gz` archives that contain either xcframeworks or Universal frameworks. 84 | 85 | **`packages`**: An array of Swift packages to build and include. See [here](https://github.com/evandcoleman/Scipio/blob/main/Sources/ScipioKit/Models/Dependency.swift#L56) for supported options. 86 | 87 | **`pods`**: An array of CocoaPods to build and include. See [here](https://github.com/evandcoleman/Scipio/blob/main/Sources/ScipioKit/Models/Dependency.swift#L44) for supported options. 88 | 89 | ### Example 90 | 91 | ```yaml 92 | name: MyAppCore 93 | 94 | deploymentTarget: 95 | iOS: "12.0" 96 | 97 | cache: 98 | http: 99 | url: https://.s3.amazonaws.com/ 100 | 101 | binaries: 102 | - name: Facebook 103 | url: https://github.com/facebook/facebook-ios-sdk/releases/download/v9.1.0/FacebookSDK.xcframework.zip 104 | version: 9.1.0 105 | - name: Firebase 106 | url: https://github.com/firebase/firebase-ios-sdk/releases/download/8.6.0/Firebase.zip 107 | version: 8.6.0 108 | 109 | packages: 110 | - name: SDWebImage 111 | url: https://github.com/SDWebImage/SDWebImage 112 | branch: 5.9.2 113 | - name: SnapKit 114 | url: https://github.com/SnapKit/SnapKit 115 | branch: 5.0.0 116 | 117 | pods: 118 | - name: GoogleTagManager 119 | from: 7.4.0 120 | # We must exclude these dependencies of GoogleTagManager because they 121 | # are included in the `Firebase` binary package above. 122 | # Scipio will error if you omit this. 123 | excludes: 124 | - FBLPromises 125 | - FirebaseAnalytics 126 | - FirebaseCore 127 | - FirebaseCoreDiagnostics 128 | - FirebaseInstallations 129 | - GoogleAppMeasurement 130 | - GoogleDataTransport 131 | - GoogleUtilities 132 | - nanopb 133 | - name: IGListKit 134 | version: 4.0.0 135 | ``` 136 | 137 | ## Development 138 | 139 | Clone the project and open `Package.swift` in Xcode 140 | 141 | ```bash 142 | $ git clone https://github.com/evandcoleman/Scipio.git 143 | $ cd Scipio && open Package.swift 144 | ``` 145 | 146 | ## License 147 | 148 | Scipio is released under the [MIT License](LICENSE.md). 149 | -------------------------------------------------------------------------------- /Sources/Scipio/Commands/BuildCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import PathKit 3 | import ScipioKit 4 | 5 | extension Command { 6 | struct Build: ParsableCommand { 7 | static var configuration: CommandConfiguration { 8 | .init( 9 | commandName: "build", 10 | abstract: "Builds some or all packages" 11 | ) 12 | } 13 | 14 | @OptionGroup var options: Run.Options 15 | @OptionGroup var buildOptions: Options 16 | 17 | func run() throws { 18 | log.useColors = !options.noColors 19 | log.level = options.logLevel 20 | 21 | if let path = options.config { 22 | Config.setPath(Path(path), buildDirectory: options.buildPath) 23 | } else { 24 | Config.readConfig() 25 | } 26 | 27 | _ = try Runner.build( 28 | dependencies: options.packages, 29 | platforms: Config.current.platforms, 30 | force: options.force || buildOptions.forceBuild, 31 | skipClean: options.skipClean 32 | ) 33 | 34 | log.success("✅ Done!") 35 | } 36 | } 37 | } 38 | 39 | extension Command.Build { 40 | struct Options: ParsableArguments { 41 | @Flag(help: "If true will force building dependencies") 42 | var forceBuild: Bool = false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Scipio/Commands/RunCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import PathKit 3 | import ScipioKit 4 | 5 | extension Command { 6 | struct Run: ParsableCommand { 7 | static var configuration: CommandConfiguration { 8 | .init( 9 | commandName: "run", 10 | abstract: "Builds and uploads packages" 11 | ) 12 | } 13 | 14 | @OptionGroup var options: Options 15 | @OptionGroup var buildOptions: Build.Options 16 | @OptionGroup var uploadOptions: Upload.Options 17 | 18 | func run() throws { 19 | log.useColors = !options.noColors 20 | log.level = options.logLevel 21 | 22 | if let path = options.config { 23 | Config.setPath(Path(path), buildDirectory: options.buildPath) 24 | } else { 25 | Config.readConfig() 26 | } 27 | 28 | let artifacts = try Runner.build( 29 | dependencies: options.packages, 30 | platforms: Config.current.platforms, 31 | force: options.force || buildOptions.forceBuild, 32 | skipClean: options.skipClean 33 | ).filter { artifact in 34 | if let packages = options.packages { 35 | return packages.contains(artifact.parentName) 36 | } else { 37 | return true 38 | } 39 | } 40 | 41 | let cachedArtifacts = try Runner.upload( 42 | artifacts: artifacts, 43 | force: options.force || uploadOptions.forceUpload, 44 | skipClean: options.skipClean 45 | ) 46 | 47 | try Runner.updatePackageManifest(at: Config.current.packageRoot, with: cachedArtifacts, removeMissing: options.packages?.isEmpty != false) 48 | 49 | log.success("✅ Done!") 50 | } 51 | } 52 | } 53 | 54 | extension Command.Run { 55 | struct Options: ParsableArguments { 56 | @Flag(help: "Enable verbose logging.") 57 | var verbose: Bool = false 58 | 59 | @Flag(help: "Enable quiet logging.") 60 | var quiet: Bool = false 61 | 62 | @Flag(help: "Disable color output") 63 | var noColors: Bool = false 64 | 65 | @Option(help: "Path to a config file") 66 | var config: String? 67 | 68 | @Argument(help: "An array of dependencies to process", transform: { $0.components(separatedBy: ",") }) 69 | var packages: [String]? 70 | 71 | @Option(help: "Path to store and find build artifacts") 72 | var buildPath: String? 73 | 74 | @Flag(help: "If true will force build and upload packages") 75 | var force: Bool = false 76 | 77 | @Flag(help: "If true will reuse existing artifacts") 78 | var skipClean: Bool = false 79 | 80 | var logLevel: Log.Level { 81 | if verbose { 82 | return .verbose 83 | } else if quiet { 84 | return .error 85 | } else { 86 | return .info 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Scipio/Commands/UploadCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import PathKit 3 | import ScipioKit 4 | 5 | extension Command { 6 | struct Upload: ParsableCommand { 7 | static var configuration: CommandConfiguration { 8 | .init( 9 | commandName: "upload", 10 | abstract: "Uploads some or all packages" 11 | ) 12 | } 13 | 14 | @OptionGroup var options: Run.Options 15 | @OptionGroup var uploadOptions: Options 16 | 17 | func run() throws { 18 | log.useColors = !options.noColors 19 | log.level = options.logLevel 20 | 21 | if let path = options.config { 22 | Config.setPath(Path(path), buildDirectory: options.buildPath) 23 | } else { 24 | Config.readConfig() 25 | } 26 | 27 | let processorOptions = ProcessorOptions( 28 | platforms: Config.current.platforms, 29 | force: options.force || uploadOptions.forceUpload, 30 | skipClean: options.skipClean 31 | ) 32 | 33 | var artifacts: [AnyArtifact] = [] 34 | 35 | if let packages = Config.current.packages, !packages.isEmpty { 36 | let processor = PackageProcessor(dependencies: packages, options: processorOptions) 37 | let filtered = options.packages? 38 | .compactMap { name in packages.first { $0.name == name } } 39 | artifacts <<< try processor.existingArtifacts(dependencies: filtered).wait() 40 | } 41 | 42 | if let binaries = Config.current.binaries, !binaries.isEmpty { 43 | let processor = BinaryProcessor(dependencies: binaries, options: processorOptions) 44 | let filtered = options.packages? 45 | .compactMap { name in binaries.first { $0.name == name } } 46 | artifacts <<< try processor.existingArtifacts(dependencies: filtered).wait() 47 | } 48 | 49 | if let pods = Config.current.pods, !pods.isEmpty { 50 | let processor = CocoaPodProcessor(dependencies: pods, options: processorOptions) 51 | let filtered = options.packages? 52 | .compactMap { name in pods.first { $0.name == name } } 53 | artifacts <<< try processor.existingArtifacts(dependencies: filtered).wait() 54 | } 55 | 56 | let cachedArtifacts = try Runner.upload( 57 | artifacts: artifacts, 58 | force: options.force || uploadOptions.forceUpload, 59 | skipClean: options.skipClean 60 | ) 61 | 62 | try Runner.updatePackageManifest( 63 | at: Config.current.packageRoot, 64 | with: cachedArtifacts, 65 | removeMissing: options.packages?.isEmpty != false 66 | ) 67 | 68 | log.success("✅ Done!") 69 | } 70 | } 71 | } 72 | 73 | extension Command.Upload { 74 | struct Options: ParsableArguments { 75 | @Flag(help: "If true will force uploading dependencies") 76 | var forceUpload: Bool = false 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/Scipio/Runner.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | import ScipioKit 4 | 5 | enum Runner { 6 | 7 | static func build(dependencies: [String]?, platforms: [Platform], force: Bool, skipClean: Bool) throws -> [AnyArtifact] { 8 | let processorOptions = ProcessorOptions( 9 | platforms: platforms, 10 | force: force, 11 | skipClean: skipClean 12 | ) 13 | 14 | var artifacts: [AnyArtifact] = [] 15 | var resolvedDependencies: [DependencyProducts] = [] 16 | 17 | if let packages = Config.current.packages, !packages.isEmpty { 18 | let processor = PackageProcessor(dependencies: packages, options: processorOptions) 19 | let filtered = dependencies? 20 | .compactMap { name in packages.first { $0.name == name } } 21 | let (a, r) = try processor.process(dependencies: filtered, accumulatedResolvedDependencies: resolvedDependencies).wait() ?? ([], []) 22 | artifacts <<< a 23 | resolvedDependencies <<< r 24 | } 25 | 26 | if let binaries = Config.current.binaries, !binaries.isEmpty { 27 | let processor = BinaryProcessor(dependencies: binaries, options: processorOptions) 28 | let filtered = dependencies? 29 | .compactMap { name in binaries.first { $0.name == name } } 30 | let (a, r) = try processor.process(dependencies: filtered, accumulatedResolvedDependencies: resolvedDependencies).wait() ?? ([], []) 31 | artifacts <<< a 32 | resolvedDependencies <<< r 33 | } 34 | 35 | if let pods = Config.current.pods, !pods.isEmpty { 36 | let processor = CocoaPodProcessor(dependencies: pods, options: processorOptions) 37 | let filtered = dependencies? 38 | .compactMap { name in pods.first { $0.name == name } } 39 | let (a, r) = try processor.process(dependencies: filtered, accumulatedResolvedDependencies: resolvedDependencies).wait() ?? ([], []) 40 | artifacts <<< a 41 | resolvedDependencies <<< r 42 | } 43 | 44 | return artifacts 45 | } 46 | 47 | static func upload(artifacts: [AnyArtifact], force: Bool, skipClean: Bool) throws -> [CachedArtifact] { 48 | return try Config.current.cacheDelegator 49 | .upload(artifacts, force: force, skipClean: skipClean) 50 | .wait() ?? [] 51 | } 52 | 53 | static func updatePackageManifest(at path: Path, with artifacts: [CachedArtifact], removeMissing: Bool) throws { 54 | let packageFile = try SwiftPackageFile( 55 | name: Config.current.name, 56 | path: path, 57 | platforms: Config.current.platformVersions, 58 | artifacts: artifacts, 59 | removeMissing: removeMissing 60 | ) 61 | 62 | if packageFile.needsWrite(relativeTo: Config.current.packageRoot) { 63 | log.info("✍️ Writing \(Config.current.name) package manifest...") 64 | 65 | try packageFile.write(relativeTo: Config.current.packageRoot) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Scipio/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import PathKit 3 | import ScipioKit 4 | 5 | enum Command {} 6 | 7 | extension Command { 8 | struct Main: ParsableCommand { 9 | static var configuration: CommandConfiguration { 10 | .init( 11 | commandName: "Scipio", 12 | abstract: "A program to pre-build and cache Swift packages", 13 | version: "0.2.5", 14 | subcommands: [ 15 | Command.Run.self, 16 | Command.Build.self, 17 | Command.Upload.self, 18 | ], 19 | defaultSubcommand: Command.Run.self 20 | ) 21 | } 22 | } 23 | } 24 | 25 | Command.Main.main() 26 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Cache Engines/CacheEngine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | 5 | public protocol CacheEngine { 6 | associatedtype ArtifactType: ArtifactProtocol 7 | 8 | var requiresCompression: Bool { get } 9 | 10 | func downloadUrl(for product: String, version: String) -> URL 11 | func exists(product: String, version: String) -> AnyPublisher 12 | func get(product: String, in parentName: String, version: String, destination: Path) -> AnyPublisher 13 | func put(artifact: ArtifactType) -> AnyPublisher 14 | } 15 | 16 | public extension CacheEngine { 17 | var requiresCompression: Bool { 18 | return true 19 | } 20 | 21 | func downloadUrl(for artifact: AnyArtifact) -> URL { 22 | return downloadUrl(for: artifact.name, version: artifact.version) 23 | } 24 | 25 | func exists(artifact: AnyArtifact) -> AnyPublisher { 26 | return exists(product: artifact.name, version: artifact.version) 27 | } 28 | } 29 | 30 | public struct AnyCacheEngine { 31 | 32 | public let requiresCompression: Bool 33 | 34 | private let _downloadUrl: (String, String) -> URL 35 | private let _exists: (String, String) -> AnyPublisher 36 | private let _get: (String, String, String, Path) -> AnyPublisher 37 | private let _put: (AnyArtifact) -> AnyPublisher 38 | 39 | public init(_ base: T) { 40 | requiresCompression = base.requiresCompression 41 | _downloadUrl = base.downloadUrl 42 | _exists = base.exists 43 | _get = { base.get(product: $0, in: $1, version: $2, destination: $3) 44 | .map { AnyArtifact($0) }.eraseToAnyPublisher() } 45 | _put = { base.put(artifact: $0.base as! T.ArtifactType) } 46 | } 47 | 48 | public func downloadUrl(for product: String, version: String) -> URL { 49 | return _downloadUrl(product, version) 50 | } 51 | 52 | public func exists(product: String, version: String) -> AnyPublisher { 53 | return _exists(product, version) 54 | } 55 | 56 | public func get(product: String, in parentName: String, version: String, destination: Path) -> AnyPublisher { 57 | return _get(product, parentName, version, destination) 58 | } 59 | 60 | public func put(artifact: AnyArtifact) -> AnyPublisher { 61 | return _put(artifact) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Cache Engines/CacheEngineDelegator.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | import Zip 5 | 6 | public final class CacheEngineDelegator: Decodable, Equatable, CacheEngine { 7 | let local: LocalCacheEngine? 8 | let s3: S3CacheEngine? 9 | let http: HTTPCacheEngine? 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case local 13 | case s3 14 | case http 15 | } 16 | 17 | private var cache: AnyCacheEngine { 18 | if let cache = _cache { 19 | return cache 20 | } else if let local = local { 21 | return AnyCacheEngine(local) 22 | } else if let s3 = s3 { 23 | return AnyCacheEngine(s3) 24 | } else if let http = http { 25 | return AnyCacheEngine(http) 26 | } else { 27 | log.fatal("At least one cache engine must be specified") 28 | } 29 | } 30 | 31 | private var _cache: AnyCacheEngine? 32 | private var existsCache: [String: Bool] = [:] 33 | 34 | public static func == (lhs: CacheEngineDelegator, rhs: CacheEngineDelegator) -> Bool { 35 | return lhs.local == rhs.local 36 | && lhs.s3 == rhs.s3 37 | && lhs.http == rhs.http 38 | } 39 | 40 | public init(cache: T) { 41 | self.local = nil 42 | self.s3 = nil 43 | self.http = nil 44 | self._cache = AnyCacheEngine(cache) 45 | } 46 | 47 | public func downloadUrl(for product: String, version: String) -> URL { 48 | return cache.downloadUrl(for: product, version: version) 49 | } 50 | 51 | public func exists(product: String, version: String) -> AnyPublisher { 52 | if let exists = existsCache[[product, version].joined(separator: "-")] { 53 | return Just(exists) 54 | .setFailureType(to: Error.self) 55 | .eraseToAnyPublisher() 56 | } 57 | 58 | log.verbose("Checking if \(product)-\(version) exists") 59 | 60 | return cache.exists(product: product, version: version) 61 | .handleEvents(receiveOutput: { exists in 62 | self.existsCache[[product, version].joined(separator: "-")] = exists 63 | }) 64 | .eraseToAnyPublisher() 65 | } 66 | 67 | public func get(product: String, in parentName: String, version: String, destination: Path) -> AnyPublisher { 68 | log.verbose("Fetching \(product)-\(version)") 69 | 70 | let normalizedDestination = cache.requiresCompression && destination.extension != "zip" ? destination.parent() + "\(destination.lastComponent).zip" : destination 71 | 72 | return Future.try { 73 | if normalizedDestination.exists, self.versionCachePath(for: product, version: version).exists, try normalizedDestination.checksum(.sha256) == (try self.versionCachePath(for: product, version: version).read()) { 74 | if self.cache.requiresCompression { 75 | return AnyArtifact(CompressedArtifact( 76 | name: product, 77 | parentName: parentName, 78 | version: version, 79 | path: normalizedDestination 80 | )) 81 | } else { 82 | return AnyArtifact(Artifact( 83 | name: product, 84 | parentName: parentName, 85 | version: version, 86 | path: destination 87 | )) 88 | } 89 | } else { 90 | return nil 91 | } 92 | } 93 | .flatMap { artifact -> AnyPublisher in 94 | if let artifact = artifact { 95 | return Just(artifact) 96 | .setFailureType(to: Error.self) 97 | .eraseToAnyPublisher() 98 | } else { 99 | return self.cache 100 | .get(product: product, in: parentName, version: version, destination: normalizedDestination) 101 | .tryMap { artifact in 102 | if artifact.path.exists, artifact.path.isFile { 103 | try self.versionCachePath(for: artifact.name, version: artifact.version) 104 | .write(artifact.path.checksum(.sha256)) 105 | } 106 | 107 | return artifact 108 | } 109 | .eraseToAnyPublisher() 110 | } 111 | } 112 | .eraseToAnyPublisher() 113 | } 114 | 115 | public func put(artifact: AnyArtifact) -> AnyPublisher { 116 | log.verbose("Caching \(artifact.name)-\(artifact.version)") 117 | 118 | return cache.put(artifact: artifact) 119 | .tryMap { cachedArtifact in 120 | if artifact.path.isFile { 121 | try self.versionCachePath(for: artifact.name, version: artifact.version) 122 | .write(artifact.path.checksum(.sha256)) 123 | } 124 | 125 | return cachedArtifact 126 | } 127 | .eraseToAnyPublisher() 128 | } 129 | 130 | private func versionCachePath(for product: String, version: String) -> Path { 131 | return Config.current.buildPath + ".version-\(product)-\(version)" 132 | } 133 | } 134 | 135 | extension CacheEngineDelegator { 136 | public func upload(_ artifacts: [AnyArtifact], force: Bool, skipClean: Bool) -> AnyPublisher<[CachedArtifact], Error> { 137 | return artifacts 138 | .publisher 139 | .setFailureType(to: Error.self) 140 | .flatMap(maxPublishers: .max(1)) { artifact -> AnyPublisher in 141 | return self.exists(artifact: artifact) 142 | .tryFlatMap { exists -> AnyPublisher in 143 | if !exists || force { 144 | log.info("☁️ Uploading \(artifact.name)...") 145 | 146 | if self.cache.requiresCompression { 147 | return self.compress(artifact, skipClean: skipClean) 148 | .flatMap { self.put(artifact: AnyArtifact($0)) } 149 | .eraseToAnyPublisher() 150 | } else { 151 | return self.put(artifact: artifact) 152 | } 153 | } else { 154 | if let compressed = artifact.base as? CompressedArtifact { 155 | return Just(try CachedArtifact(name: artifact.name, parentName: artifact.parentName, url: self.downloadUrl(for: artifact), localPath: compressed.path)) 156 | .setFailureType(to: Error.self) 157 | .eraseToAnyPublisher() 158 | } else { 159 | return Just(CachedArtifact(name: artifact.name, parentName: artifact.parentName, url: self.downloadUrl(for: artifact))) 160 | .setFailureType(to: Error.self) 161 | .eraseToAnyPublisher() 162 | } 163 | } 164 | } 165 | .eraseToAnyPublisher() 166 | } 167 | .collect() 168 | .eraseToAnyPublisher() 169 | } 170 | 171 | public func compress(_ artifact: AnyArtifact, skipClean: Bool) -> AnyPublisher { 172 | return Future.try { 173 | 174 | if let base = artifact.base as? CompressedArtifact { 175 | return base 176 | } 177 | 178 | let compressed = CompressedArtifact( 179 | name: artifact.name, 180 | parentName: artifact.parentName, 181 | version: artifact.version, 182 | path: artifact.path.parent() + "\(artifact.path.lastComponent).zip" 183 | ) 184 | 185 | if compressed.path.exists, !skipClean { 186 | try compressed.path.delete() 187 | } else if compressed.path.exists { 188 | return compressed 189 | } 190 | 191 | do { 192 | log.info("Compressing \(artifact.name):") 193 | try Zip.zipFiles( 194 | paths: [artifact.path.url], 195 | zipFilePath: compressed.path.url, 196 | password: nil, 197 | progress: { log.progress(percent: $0) } 198 | ) 199 | } catch ZipError.zipFail { 200 | throw ScipioError.zipFailure(artifact) 201 | } 202 | 203 | return compressed 204 | } 205 | .eraseToAnyPublisher() 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Cache Engines/HTTPCacheEngine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | 5 | public protocol HTTPCacheEngineProtocol: CacheEngine { 6 | var uploadBaseUrl: URL { get } 7 | var downloadBaseUrl: URL { get } 8 | var urlSession: URLSession { get } 9 | 10 | func uploadUrlRequest(url: URL) -> URLRequest 11 | } 12 | 13 | public enum HTTPCacheEngineError: Error { 14 | case requestFailed(statusCode: Int, body: String? = nil) 15 | case downloadFailed 16 | } 17 | 18 | extension HTTPCacheEngineProtocol { 19 | 20 | public var uploadBaseUrl: URL { downloadBaseUrl } 21 | 22 | public func uploadUrl(for product: String, version: String) -> URL { 23 | return url(for: product, version: version, baseUrl: uploadBaseUrl) 24 | } 25 | 26 | public func downloadUrl(for product: String, version: String) -> URL { 27 | return url(for: product, version: version, baseUrl: downloadBaseUrl) 28 | } 29 | 30 | public func uploadUrlRequest(url: URL) -> URLRequest { 31 | var request = URLRequest(url: url) 32 | 33 | request.httpMethod = "PUT" 34 | request.allHTTPHeaderFields = [ 35 | "Content-Type": "application/zip" 36 | ] 37 | 38 | return request 39 | } 40 | 41 | public func exists(product: String, version: String) -> AnyPublisher { 42 | var request = URLRequest(url: downloadUrl(for: product, version: version)) 43 | request.httpMethod = "HEAD" 44 | 45 | return urlSession 46 | .dataTaskPublisher(for: request) 47 | .map { (($0.response as? HTTPURLResponse)?.statusCode ?? 500) < 400 } 48 | .mapError { $0 as Error } 49 | .eraseToAnyPublisher() 50 | } 51 | 52 | public func put(artifact: CompressedArtifact) -> AnyPublisher { 53 | return Future { promise in 54 | let request = uploadUrlRequest(url: uploadUrl(for: artifact.name, version: artifact.version)) 55 | 56 | let task = urlSession 57 | .uploadTask(with: request, fromFile: artifact.path.url, progressHandler: { log.progress(percent: $0) }) { data, response, error in 58 | if let error = error { 59 | promise(.failure(error)) 60 | } else if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode >= 400 { 61 | promise(.failure(HTTPCacheEngineError.requestFailed(statusCode: statusCode, body: String(data: data ?? Data(), encoding: .utf8) ?? ""))) 62 | } else { 63 | do { 64 | promise(.success(try CachedArtifact( 65 | name: artifact.name, 66 | parentName: artifact.parentName, 67 | url: downloadUrl(for: artifact.name, version: artifact.version), 68 | localPath: artifact.path 69 | ))) 70 | } catch { 71 | promise(.failure(error)) 72 | } 73 | } 74 | } 75 | 76 | log.info("Uploading \(artifact.name)") 77 | 78 | task.resume() 79 | } 80 | .eraseToAnyPublisher() 81 | } 82 | 83 | public func get(product: String, in parentName: String, version: String, destination: Path) -> AnyPublisher { 84 | return Future { promise in 85 | let url = downloadUrl(for: product, version: version) 86 | 87 | let task = urlSession 88 | .downloadTask(with: url, progressHandler: { log.progress(percent: $0) }) { url, response, error in 89 | if let error = error { 90 | promise(.failure(error)) 91 | } else if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode >= 400 { 92 | promise(.failure(HTTPCacheEngineError.requestFailed(statusCode: statusCode))) 93 | } else if let url = url { 94 | promise(.success(url)) 95 | } else { 96 | promise(.failure(HTTPCacheEngineError.downloadFailed)) 97 | } 98 | } 99 | 100 | log.info("Downloading \(product):") 101 | 102 | task.resume() 103 | } 104 | .tryMap { url in 105 | if destination.exists { 106 | try destination.delete() 107 | } 108 | 109 | try Path(url.path).copy(destination) 110 | 111 | return CompressedArtifact( 112 | name: product, 113 | parentName: parentName, 114 | version: version, 115 | path: destination.isDirectory ? destination + url.lastPathComponent : destination 116 | ) 117 | } 118 | .eraseToAnyPublisher() 119 | } 120 | 121 | public func url(for product: String, version: String, baseUrl: URL) -> URL { 122 | let encodedProduct = product.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! 123 | let encodedVersion = version.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! 124 | 125 | return baseUrl 126 | .appendingPathComponent(encodedProduct) 127 | .appendingPathComponent("\(encodedProduct)-\(encodedVersion).xcframework.zip") 128 | } 129 | } 130 | 131 | public struct HTTPCacheEngine: HTTPCacheEngineProtocol, Decodable, Equatable { 132 | 133 | public let url: URL 134 | 135 | public let urlSession: URLSession = .createWithExtensionsSupport() 136 | 137 | public var downloadBaseUrl: URL { url } 138 | 139 | enum CodingKeys: String, CodingKey { 140 | case url 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Cache Engines/LocalCacheEngine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | 5 | public struct LocalCacheEngine: CacheEngine, Decodable, Equatable { 6 | private let path: String 7 | 8 | public var normalizedPath: Path { 9 | if path.hasPrefix("/") { 10 | return Path(path) 11 | } else { 12 | return (Config.current.directory + Path(path)) 13 | .normalize() 14 | } 15 | } 16 | 17 | public var requiresCompression: Bool { false } 18 | 19 | public enum LocalCacheEngineError: Error { 20 | case fileNotFound 21 | } 22 | 23 | public init(path: Path) { 24 | self.path = path.string 25 | } 26 | 27 | public func downloadUrl(for product: String, version: String) -> URL { 28 | return localPath(for: product, version: version).url 29 | } 30 | 31 | public func exists(product: String, version: String) -> AnyPublisher { 32 | return Just(localPath(for: product, version: version).exists) 33 | .setFailureType(to: Error.self) 34 | .eraseToAnyPublisher() 35 | } 36 | 37 | public func put(artifact: Artifact) -> AnyPublisher { 38 | let cachePath = localPath(for: artifact.name, version: artifact.version) 39 | 40 | return Just(cachePath) 41 | .tryMap { cachePath -> CachedArtifact in 42 | if cachePath.exists { 43 | try cachePath.delete() 44 | } 45 | 46 | if !cachePath.parent().exists { 47 | try cachePath.parent().mkpath() 48 | } 49 | 50 | try artifact.path.copy(cachePath) 51 | 52 | return CachedArtifact( 53 | name: artifact.name, 54 | parentName: artifact.parentName, 55 | url: cachePath.url 56 | ) 57 | } 58 | .eraseToAnyPublisher() 59 | } 60 | 61 | public func get(product: String, in parentName: String, version: String, destination: Path) -> AnyPublisher { 62 | let cachePath = localPath(for: product, version: version) 63 | 64 | return Just(cachePath) 65 | .tryMap { cachePath -> Artifact in 66 | if cachePath.exists { 67 | if destination.exists { 68 | try destination.delete() 69 | } 70 | 71 | try cachePath.copy(destination) 72 | 73 | return Artifact( 74 | name: product, 75 | parentName: parentName, 76 | version: version, 77 | path: destination 78 | ) 79 | } else { 80 | throw LocalCacheEngineError.fileNotFound 81 | } 82 | } 83 | .eraseToAnyPublisher() 84 | } 85 | 86 | private func localPath(for product: String, version: String) -> Path { 87 | return normalizedPath + product + "\(product)-\(version).xcframework" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Cache Engines/S3CacheEngine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | 5 | public struct S3CacheEngine: HTTPCacheEngineProtocol, Decodable, Equatable { 6 | 7 | public let bucket: String 8 | public let path: String? 9 | public let cdnUrl: URL? 10 | 11 | public var uploadBaseUrl: URL { 12 | return bucketS3Url 13 | } 14 | 15 | public var downloadBaseUrl: URL { 16 | return bucketUrl 17 | } 18 | 19 | public let urlSession: URLSession = .createWithExtensionsSupport() 20 | 21 | private var bucketUrl: URL { 22 | if let url = cdnUrl { 23 | if let path = path { 24 | return url 25 | .appendingPathComponent(path) 26 | } else { 27 | return url 28 | } 29 | } else { 30 | return bucketS3Url 31 | } 32 | } 33 | 34 | private var bucketS3Url: URL { 35 | let baseUrl = URL(string: "https://\(bucket).s3.amazonaws.com")! 36 | 37 | if let path = path { 38 | return baseUrl 39 | .appendingPathComponent(path) 40 | } else { 41 | return baseUrl 42 | } 43 | } 44 | 45 | enum CodingKeys: String, CodingKey { 46 | case bucket 47 | case path 48 | case cdnUrl 49 | } 50 | 51 | public func uploadUrlRequest(url: URL) -> URLRequest { 52 | var request = URLRequest(url: url) 53 | 54 | request.httpMethod = "PUT" 55 | request.allHTTPHeaderFields = [ 56 | "Content-Type": "application/zip", 57 | "x-amz-acl": "bucket-owner-full-control", 58 | ] 59 | 60 | return request 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Dependency Processors/BinaryProcessor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | import Zip 5 | 6 | public final class BinaryProcessor: DependencyProcessor { 7 | 8 | public let dependencies: [BinaryDependency] 9 | public let options: ProcessorOptions 10 | 11 | private let urlSession: URLSession = .createWithExtensionsSupport() 12 | 13 | public init(dependencies: [BinaryDependency], options: ProcessorOptions) { 14 | self.dependencies = dependencies 15 | self.options = options 16 | } 17 | 18 | public func preProcess() -> AnyPublisher<[BinaryDependency], Error> { 19 | log.info("🔗 Processing binary dependencies...") 20 | 21 | return Just(dependencies) 22 | .setFailureType(to: Error.self) 23 | .eraseToAnyPublisher() 24 | } 25 | 26 | public func process(_ dependency: BinaryDependency?, resolvedTo resolvedDependency: BinaryDependency) -> AnyPublisher<[AnyArtifact], Error> { 27 | return Just(dependency) 28 | .setFailureType(to: Error.self) 29 | .tryFlatMap { dependency -> AnyPublisher<(BinaryDependency, Path), Error> in 30 | let downloadPath = Config.current.cachePath + resolvedDependency.url.lastPathComponent 31 | let checksumCache = Config.current.cachePath + ".binary-\(resolvedDependency.name)-\(resolvedDependency.version)" 32 | 33 | if downloadPath.exists, checksumCache.exists, 34 | try downloadPath.checksum(.sha256) == (try checksumCache.read()) { 35 | 36 | return Just(downloadPath) 37 | .setFailureType(to: Error.self) 38 | .tryFlatMap { path in 39 | return Future.try { 40 | return try self.decompress(dependency: resolvedDependency, at: path) 41 | } 42 | .catch { error -> AnyPublisher in 43 | log.verbose("Error decompressing, will delete and download again: \(error)") 44 | 45 | return self.downloadAndDecompress(resolvedDependency, path: downloadPath, checksumCache: checksumCache) 46 | } 47 | } 48 | .map { (resolvedDependency, $0) } 49 | .eraseToAnyPublisher() 50 | } else { 51 | return self.downloadAndDecompress(resolvedDependency, path: downloadPath, checksumCache: checksumCache) 52 | .map { (resolvedDependency, $0) } 53 | .eraseToAnyPublisher() 54 | } 55 | } 56 | .tryMap { dependency, path -> [AnyArtifact] in 57 | let xcFrameworks = try path 58 | .recursiveChildren() 59 | .filter { $0.extension == "xcframework" } 60 | .compactMap { framework -> AnyArtifact? in 61 | let targetPath = Config.current.buildPath + framework.lastComponent 62 | 63 | if targetPath.exists { 64 | try targetPath.delete() 65 | } 66 | 67 | if let excludes = dependency.excludes, 68 | excludes.contains(framework.lastComponentWithoutExtension) { 69 | 70 | return nil 71 | } 72 | 73 | try framework.copy(targetPath) 74 | 75 | return AnyArtifact(Artifact( 76 | name: targetPath.lastComponentWithoutExtension, 77 | parentName: dependency.name, 78 | version: dependency.version, 79 | path: targetPath 80 | )) 81 | } 82 | 83 | if xcFrameworks.isEmpty { 84 | return try path 85 | .recursiveChildren() 86 | .filter { $0.extension == "framework" } 87 | .compactMap { framework -> AnyArtifact? in 88 | let targetPath = Config.current.buildPath + "\(framework.lastComponentWithoutExtension).xcframework" 89 | 90 | if targetPath.exists { 91 | try targetPath.delete() 92 | } 93 | 94 | if let excludes = dependency.excludes, 95 | excludes.contains(framework.lastComponentWithoutExtension) { 96 | 97 | return nil 98 | } 99 | 100 | _ = try self.convertUniversalFrameworkToXCFramework(input: framework) 101 | 102 | return AnyArtifact(Artifact( 103 | name: targetPath.lastComponentWithoutExtension, 104 | parentName: dependency.name, 105 | version: dependency.version, 106 | path: targetPath 107 | )) 108 | } 109 | } 110 | 111 | return xcFrameworks 112 | } 113 | .collect() 114 | .map { $0.flatMap { $0 } } 115 | .tryMap { artifacts in 116 | let filtered: [AnyArtifact] = artifacts 117 | .reduce(into: []) { acc, next in 118 | if !acc.contains(where: { $0.name == next.name }) { 119 | acc.append(next) 120 | } 121 | } 122 | 123 | try resolvedDependency.cache(filtered.map(\.name)) 124 | 125 | return filtered 126 | } 127 | .eraseToAnyPublisher() 128 | } 129 | 130 | public func postProcess() -> AnyPublisher<(), Error> { 131 | return Just(()) 132 | .setFailureType(to: Error.self) 133 | .eraseToAnyPublisher() 134 | } 135 | 136 | private func downloadAndDecompress(_ dependency: BinaryDependency, path: Path, checksumCache: Path) -> AnyPublisher { 137 | return Future.try { 138 | if path.exists { 139 | try path.delete() 140 | } 141 | 142 | return path 143 | } 144 | .flatMap { _ -> AnyPublisher in 145 | return self.download(dependency: dependency) 146 | .tryMap { path in 147 | return try self.decompress(dependency: dependency, at: path) 148 | } 149 | .handleEvents(receiveOutput: { _ in 150 | do { 151 | try checksumCache.write(try path.checksum(.sha256)) 152 | } catch { 153 | log.debug("Failed to write checksum cache for \(path)") 154 | } 155 | }) 156 | .eraseToAnyPublisher() 157 | } 158 | .eraseToAnyPublisher() 159 | } 160 | 161 | private func download(dependency: BinaryDependency) -> AnyPublisher { 162 | let url = dependency.url 163 | let targetPath = Config.current.cachePath + Path(url.path).lastComponentWithoutExtension 164 | let targetRawPath = Config.current.cachePath + url.lastPathComponent 165 | 166 | return Future { promise in 167 | let task = self.urlSession 168 | .downloadTask(with: url, progressHandler: { log.progress(percent: $0) }) { url, response, error in 169 | if let error = error { 170 | promise(.failure(error)) 171 | } else if let url = url { 172 | promise(.success(url)) 173 | } else { 174 | log.fatal("Unexpected download result") 175 | } 176 | } 177 | 178 | log.info("Downloading \(url.lastPathComponent):") 179 | 180 | task.resume() 181 | } 182 | .tryMap { downloadUrl -> Path in 183 | if targetPath.exists { 184 | try targetPath.delete() 185 | } 186 | if targetRawPath.exists { 187 | try targetRawPath.delete() 188 | } 189 | 190 | let downloadedPath = Path(downloadUrl.path) 191 | 192 | try downloadedPath.move(targetRawPath) 193 | 194 | return targetRawPath 195 | } 196 | .eraseToAnyPublisher() 197 | } 198 | 199 | private func decompress(dependency: BinaryDependency, at path: Path) throws -> Path { 200 | log.info("Decompressing \(path.lastComponent)...") 201 | 202 | guard let compression = FileCompression(dependency.url) else { 203 | log.fatal("Unsupported package url extension \"\(dependency.url.pathExtension)\"") 204 | } 205 | 206 | let targetPath = Config.current.cachePath + compression.decompressedName(url: dependency.url) 207 | 208 | if options.skipClean, targetPath.exists { 209 | return targetPath 210 | } else if targetPath.exists { 211 | try targetPath.delete() 212 | } 213 | 214 | switch compression { 215 | case .zip: 216 | try Zip.unzipFile(path.url, destination: targetPath.url, overwrite: true, password: nil, progress: { log.progress(percent: $0) }) 217 | case .gzip: 218 | let gunzippedPath = try path.gunzipped() 219 | try gunzippedPath.move(targetPath) 220 | case .tarGzip: 221 | let gunzippedPath = try path.gunzipped() 222 | let untaredPath = try gunzippedPath.untar() 223 | try untaredPath.move(targetPath) 224 | } 225 | 226 | return targetPath 227 | } 228 | 229 | private func convertUniversalFrameworkToXCFramework(input: Path) throws -> [Path] { 230 | let frameworkName = input.lastComponentWithoutExtension 231 | let binaryPath = input + frameworkName 232 | 233 | guard binaryPath.exists else { 234 | throw ScipioError.invalidFramework(input.lastComponent) 235 | } 236 | 237 | let rawArchitectures = try sh("/usr/bin/lipo", "-info", binaryPath.string) 238 | .outputString() 239 | .components(separatedBy: ":") 240 | .last? 241 | .trimmingCharacters(in: .whitespacesAndNewlines) 242 | .components(separatedBy: " ") ?? [] 243 | let architectures = rawArchitectures 244 | .compactMap { Architecture(rawValue: $0) } 245 | let unknownArchitectures = rawArchitectures 246 | .filter { Architecture(rawValue: $0) == nil } 247 | 248 | guard architectures == Architecture.allCases else { 249 | throw ScipioError.missingArchitectures( 250 | input.lastComponent, 251 | Array(Set(Architecture.allCases).subtracting(Set(architectures))) 252 | ) 253 | } 254 | 255 | let platformSDKs = options.platforms.flatMap(\.sdks).uniqued() 256 | 257 | // TODO: Support macOS and tvOS 258 | guard platformSDKs.count == 2, 259 | platformSDKs.contains(.iphoneos), 260 | platformSDKs.contains(.iphonesimulator) else { 261 | 262 | fatalError("Only iOS is supported right now") 263 | } 264 | 265 | let sdkArchitectures = architectures.sdkArchitectures 266 | let sdks = Set(platformSDKs).intersection(sdkArchitectures.keys) 267 | 268 | let archivePaths = try sdks.map { sdk -> Path in 269 | let archivePath = Config.current.buildPath + "\(frameworkName)-\(sdk.rawValue)" 270 | let frameworksFolder = archivePath + "Products/Library/Frameworks" 271 | 272 | if archivePath.exists { 273 | try archivePath.delete() 274 | } 275 | 276 | try frameworksFolder.mkpath() 277 | 278 | try input.copy(frameworksFolder + input.lastComponent) 279 | 280 | let removeArchs = Set(architectures).subtracting(sdk.architectures) 281 | let removeArgs = (removeArchs 282 | .map(\.rawValue) + unknownArchitectures) 283 | .flatMap { ["-remove", $0] } 284 | let sdkBinaryPath = frameworksFolder + "\(input.lastComponent)/\(frameworkName)" 285 | 286 | try sh("/usr/bin/lipo", removeArgs + [binaryPath.string, "-o", sdkBinaryPath.string]) 287 | 288 | return archivePath 289 | } 290 | 291 | return try Xcode.createXCFramework(archivePaths: archivePaths, skipIfExists: options.skipClean) 292 | } 293 | } 294 | 295 | private enum FileCompression { 296 | case zip 297 | case gzip 298 | case tarGzip 299 | 300 | init?(_ path: Path) { 301 | self.init(path.url) 302 | } 303 | 304 | init?(_ url: URL) { 305 | switch url.pathExtension { 306 | case "zip": 307 | self = .zip 308 | case "gz": 309 | if let ext = Path(url.path).withoutLastExtension().extension { 310 | switch ext { 311 | case "tar": 312 | self = .tarGzip 313 | default: 314 | return nil 315 | } 316 | } else { 317 | self = .gzip 318 | } 319 | default: 320 | return nil 321 | } 322 | } 323 | 324 | func decompressedName(url: URL) -> String { 325 | switch self { 326 | case .zip, .gzip: 327 | return Path(url.path) 328 | .lastComponentWithoutExtension 329 | case .tarGzip: 330 | return Path(url.path) 331 | .withoutLastExtension() 332 | .lastComponentWithoutExtension 333 | } 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Dependency Processors/CocoaPodProcessor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | import ProjectSpec 5 | import Regex 6 | import XcodeGenKit 7 | import XcodeProj 8 | 9 | public enum CocoaPodProcessorError: LocalizedError { 10 | case cocoaPodsNotInstalled 11 | case missingVersion(CocoaPodDependency) 12 | 13 | public var errorDescription: String? { 14 | switch self { 15 | case .cocoaPodsNotInstalled: 16 | return "CocoaPods must be installed to use CocoaPod dependencies. Please refer to https://cocoapods.org and ensure that the \"pod\" command is available in your PATH." 17 | case .missingVersion(let dependency): 18 | return "\(dependency.name) is missing version information from the CocoaPods manifest." 19 | } 20 | } 21 | } 22 | 23 | public final class CocoaPodProcessor: DependencyProcessor { 24 | 25 | public let dependencies: [CocoaPodDependency] 26 | public let options: ProcessorOptions 27 | 28 | private var projectPath: Path! 29 | 30 | public init(dependencies: [CocoaPodDependency], options: ProcessorOptions) { 31 | self.dependencies = dependencies 32 | self.options = options 33 | } 34 | 35 | public func preProcess() -> AnyPublisher<[CocoaPodDescriptor], Error> { 36 | return Future.try { 37 | let path = Config.current.cachePath + Config.current.name 38 | 39 | let (_, projectPath) = try self.writePodfile(in: path) 40 | 41 | return try self.installPods(in: path, projectPath: projectPath) 42 | } 43 | .eraseToAnyPublisher() 44 | } 45 | 46 | public func process(_ dependency: CocoaPodDependency?, resolvedTo resolvedDependency: CocoaPodDescriptor) -> AnyPublisher<[AnyArtifact], Error> { 47 | return Future.try { 48 | let derivedDataPath = Config.current.cachePath + "DerivedData" + Config.current.name 49 | 50 | var paths = try self.options.platforms.flatMap { platform -> [Path] in 51 | let archivePaths = try platform.sdks.map { sdk -> Path in 52 | let scheme = "\(resolvedDependency.name)-\(platform.rawValue)" 53 | 54 | if self.options.skipClean, Xcode.getArchivePath(for: scheme, sdk: sdk).exists { 55 | return Xcode.getArchivePath(for: scheme, sdk: sdk) 56 | } 57 | 58 | return try Xcode.archive( 59 | scheme: scheme, 60 | in: self.projectPath.parent() + "\(self.projectPath.lastComponentWithoutExtension).xcworkspace", 61 | for: sdk, 62 | derivedDataPath: derivedDataPath, 63 | additionalBuildSettings: dependency?.additionalBuildSettings 64 | ) 65 | } 66 | 67 | return try Xcode.createXCFramework( 68 | archivePaths: archivePaths, 69 | skipIfExists: self.options.skipClean, 70 | filter: { !$0.hasPrefix("Pods_") && $0 != "\(resolvedDependency.name)-\(platform.rawValue)" && resolvedDependency.productNames?.contains($0) == true } 71 | ) 72 | } 73 | 74 | let vendoredFrameworks = try resolvedDependency 75 | .vendoredFrameworks 76 | .filter { dependency?.excludes?.contains($0.lastComponentWithoutExtension) != true } 77 | .map { path -> Path in 78 | let targetPath = Config.current.buildPath + path.lastComponent 79 | 80 | if targetPath.exists, !self.options.skipClean { 81 | try targetPath.delete() 82 | } 83 | 84 | if !targetPath.exists { 85 | try path.copy(targetPath) 86 | 87 | if !resolvedDependency.resourceBundles.isEmpty { 88 | let resourcesPath = targetPath + "Resources" 89 | 90 | if !resourcesPath.exists { 91 | try resourcesPath.mkdir() 92 | } 93 | 94 | for bundlePath in resolvedDependency.resourceBundles { 95 | try bundlePath.copy(resourcesPath + bundlePath.lastComponent) 96 | } 97 | } 98 | } 99 | 100 | return targetPath 101 | } 102 | 103 | paths <<< vendoredFrameworks 104 | 105 | return paths.compactMap { path in 106 | return AnyArtifact(Artifact( 107 | name: path.lastComponentWithoutExtension, 108 | parentName: resolvedDependency.name, 109 | version: resolvedDependency.version(for: path.lastComponentWithoutExtension), 110 | path: path 111 | )) 112 | } 113 | } 114 | .eraseToAnyPublisher() 115 | } 116 | 117 | public func postProcess() -> AnyPublisher<(), Error> { 118 | return Just(()) 119 | .setFailureType(to: Error.self) 120 | .eraseToAnyPublisher() 121 | } 122 | 123 | private func writePodfile(in path: Path) throws -> (podfilePath: Path, projectPath: Path) { 124 | let podfilePath = path + "Podfile" 125 | projectPath = path + "\(Config.current.name)-Pods.xcodeproj" 126 | 127 | if projectPath.exists { 128 | try projectPath.delete() 129 | } 130 | 131 | let projectGenerator = ProjectGenerator(project: .init( 132 | basePath: path, 133 | name: projectPath.lastComponentWithoutExtension, 134 | targets: dependencies 135 | .flatMap { dependency in 136 | return Config.current.platformVersions 137 | .map { Target(name: "\(dependency.name)-\($0.key.rawValue)", type: .framework, platform: .iOS) } 138 | }, 139 | schemes: dependencies 140 | .flatMap { dependency in 141 | return Config.current.platformVersions 142 | .map { Scheme( 143 | name: "\(dependency.name)-\($0.key.rawValue)", 144 | build: Scheme.Build(targets: [.init(target: .init(name: "\(dependency.name)-\($0.key.rawValue)", location: .local))]), 145 | archive: Scheme.Archive(config: "Release") 146 | ) } 147 | } 148 | )) 149 | let project = try projectGenerator.generateXcodeProject(in: path) 150 | try project.write(path: projectPath) 151 | 152 | try podfilePath.write(""" 153 | use_frameworks! 154 | project '\(projectPath.string)' 155 | 156 | \(dependencies 157 | .map { dep in Config.current.platformVersions.map { "target '\(dep.name)-\($0.key.rawValue)' do\n\(4.spaces)platform :\($0.key.rawValue), '\($0.value)'\n\(4.spaces)\(dep.asString())\nend" }.joined(separator: "\n\n") } 158 | .joined(separator: "\n\n")) 159 | """) 160 | 161 | return (podfilePath, projectPath) 162 | } 163 | 164 | private func installPods(in path: Path, projectPath: Path) throws -> [CocoaPodDescriptor] { 165 | let podCommandPath = try which("pod") 166 | 167 | do { 168 | log.info("🍫 Installing Pods...") 169 | 170 | try sh(podCommandPath, "install", in: path) 171 | } catch ShellError.commandNotFound { 172 | throw CocoaPodProcessorError.cocoaPodsNotInstalled 173 | } catch ScipioError.commandFailed(_, _, let output, _) where output?.contains("could not find compatible versions") == true { 174 | try sh(podCommandPath, "install", "--repo-update", in: path) 175 | } 176 | 177 | let sandboxPath = path + "Pods" 178 | let manifestPath = path + "Pods/Manifest.lock" 179 | let podsProjectPath = sandboxPath + "Pods.xcodeproj" 180 | let project = try XcodeProj(path: podsProjectPath) 181 | let parentProject = try XcodeProj(path: projectPath) 182 | let lockFile: String = try manifestPath.read() 183 | 184 | return try dependencies.map { dependency in 185 | let projectProducts = project.productNames(for: dependency.name, podsRoot: sandboxPath) 186 | let versionRegex = try Regex(string: "- \(dependency.name)\\s\\((.*)\\)") 187 | let match = versionRegex.firstMatch(in: lockFile) 188 | 189 | guard let version = match?.captures.last??.components(separatedBy: " ").last else { 190 | throw CocoaPodProcessorError.missingVersion(dependency) 191 | } 192 | 193 | let versions: [String: String] = try projectProducts 194 | .map { (product: $0.name, regex: try Regex(string: "- \($0.name)\\s\\((.*)\\)")) } 195 | .reduce(into: [:]) { $0[$1.product] = $1.regex.firstMatch(in: lockFile)?.captures.last??.components(separatedBy: " ").last ?? version } 196 | let filteredProductNames = projectProducts 197 | .map(\.name) 198 | .filter { dependency.excludes?.contains($0) != true } 199 | let vendoredFrameworks = projectProducts 200 | .compactMap { projectProduct -> Path? in 201 | switch projectProduct { 202 | case .product: 203 | return nil 204 | case .path(let path): 205 | return path 206 | } 207 | } 208 | let resourceBundles = vendoredFrameworks.isEmpty ? [] : parentProject.resourceBundles( 209 | for: "\(dependency.name)-\(options.platforms[0].rawValue)", 210 | podsRoot: sandboxPath, 211 | notIn: projectProducts 212 | ) 213 | 214 | if filteredProductNames.contains(dependency.name) { 215 | for target in project.pbxproj.targets(named: dependency.name) { 216 | for config in target.buildConfigurationList?.buildConfigurations ?? [] { 217 | config.buildSettings["PRODUCT_NAME"] = "\(dependency.name)Package" 218 | } 219 | } 220 | 221 | try project.write(path: podsProjectPath) 222 | } 223 | 224 | return CocoaPodDescriptor( 225 | name: dependency.name, 226 | resolvedVersions: versions, 227 | productNames: filteredProductNames, 228 | vendoredFrameworks: vendoredFrameworks, 229 | resourceBundles: resourceBundles 230 | ) 231 | } 232 | } 233 | } 234 | 235 | public struct CocoaPodDescriptor: DependencyProducts { 236 | public let name: String 237 | public let resolvedVersions: [String: String] 238 | public let productNames: [String]? 239 | public let vendoredFrameworks: [Path] 240 | public let resourceBundles: [Path] 241 | 242 | public func version(for productName: String) -> String { 243 | return resolvedVersions[productName]! 244 | } 245 | } 246 | 247 | private extension CocoaPodDependency { 248 | func asString() -> String { 249 | var result = "pod '\(name)'" 250 | 251 | if let git = git { 252 | result += ", :git => '\(git.absoluteString)'" 253 | 254 | if let commit = commit { 255 | result += ", :commit => '\(commit)'" 256 | } else if let branch = branch { 257 | result += ", :branch => '\(branch)'" 258 | } 259 | } else if let podspec = podspec { 260 | result += ", :podspec => '\(podspec.absoluteString)'" 261 | } else if let version = version { 262 | result += ", '\(version)'" 263 | } else if let from = from { 264 | result += ", '~> \(from)'" 265 | } 266 | 267 | return result 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Dependency Processors/DependencyProcessor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | 5 | public protocol DependencyProcessor { 6 | associatedtype Input: Dependency 7 | associatedtype ResolvedInput: DependencyProducts 8 | 9 | var dependencies: [Input] { get } 10 | var options: ProcessorOptions { get } 11 | 12 | init(dependencies: [Input], options: ProcessorOptions) 13 | 14 | func preProcess() -> AnyPublisher<[ResolvedInput], Error> 15 | func process(_ dependency: Input?, resolvedTo resolvedDependency: ResolvedInput) -> AnyPublisher<[AnyArtifact], Error> 16 | func postProcess() -> AnyPublisher<(), Error> 17 | } 18 | 19 | public protocol DependencyProducts { 20 | var name: String { get } 21 | var productNames: [String]? { get } 22 | 23 | func version(for productName: String) -> String 24 | } 25 | 26 | extension DependencyProcessor { 27 | public func existingArtifacts(dependencies onlyDependencies: [Input]? = nil) -> AnyPublisher<[AnyArtifact], Error> { 28 | let dependencies = onlyDependencies ?? self.dependencies 29 | 30 | return preProcess() 31 | .map { resolvedDependencies -> [AnyArtifact] in 32 | return resolvedDependencies 33 | .filter { resolved in dependencies.contains(where: { $0.name == resolved.name }) } 34 | .flatMap { dependency -> [AnyArtifact] in 35 | return (dependency 36 | .productNames ?? []) 37 | .compactMap { productName in 38 | let path = Config.current.buildPath + "\(productName).xcframework.zip" 39 | 40 | guard path.exists else { 41 | log.warning("Skipping \(path.lastComponent) because it doesn't exist.") 42 | return nil 43 | } 44 | 45 | return AnyArtifact(Artifact( 46 | name: productName, 47 | parentName: dependency.name, 48 | version: dependency.version(for: productName), 49 | path: path 50 | )) 51 | } 52 | } 53 | } 54 | .eraseToAnyPublisher() 55 | } 56 | 57 | public func process(dependencies onlyDependencies: [Input]? = nil, accumulatedResolvedDependencies: [DependencyProducts]) -> AnyPublisher<([AnyArtifact], [DependencyProducts]), Error> { 58 | return preProcess() 59 | .tryFlatMap { dependencyProducts -> AnyPublisher<([AnyArtifact], [DependencyProducts]), Error> in 60 | 61 | let conflictingDependencies: [String: [String]] = (dependencyProducts + accumulatedResolvedDependencies) 62 | .reduce(into: [:]) { accumulated, dependency in 63 | let productNames = Dictionary( 64 | uniqueKeysWithValues: (dependency.productNames ?? []) 65 | .map { ($0, [dependency.name]) } 66 | .filter { !$0.0.isEmpty } 67 | ) 68 | 69 | accumulated.merge(productNames) { $0 + $1 } 70 | } 71 | .filter { $0.value.count > 1 } 72 | 73 | if let conflict = conflictingDependencies.first { 74 | throw ScipioError.conflictingDependencies( 75 | product: conflict.key, 76 | conflictingDependencies: conflict.value 77 | ) 78 | } 79 | 80 | return dependencyProducts 81 | .publisher 82 | .setFailureType(to: Error.self) 83 | .tryFlatMap(maxPublishers: .max(1)) { dependencyProduct -> AnyPublisher<[AnyArtifact], Error> in 84 | let dependencies = onlyDependencies ?? self.dependencies 85 | let dependency = dependencies.first(where: { $0.name == dependencyProduct.name }) 86 | 87 | if let onlyDependencies = onlyDependencies, 88 | !onlyDependencies.contains(where: { $0.name == dependencyProduct.name }) { 89 | return Empty() 90 | .setFailureType(to: Error.self) 91 | .eraseToAnyPublisher() 92 | } 93 | 94 | guard let productNames = dependencyProduct.productNames else { 95 | return self.process(dependency, resolvedTo: dependencyProduct) 96 | } 97 | 98 | return productNames 99 | .publisher 100 | .setFailureType(to: Error.self) 101 | .flatMap(maxPublishers: .max(2)) { productName -> AnyPublisher in 102 | if self.options.force { 103 | return Just(productName) 104 | .setFailureType(to: Error.self) 105 | .eraseToAnyPublisher() 106 | } 107 | 108 | return Config.current.cacheDelegator 109 | .exists(product: productName, version: dependencyProduct.version(for: productName)) 110 | .filter { !$0 } 111 | .map { _ in productName } 112 | .eraseToAnyPublisher() 113 | } 114 | .collect() 115 | .flatMap { missingProducts -> AnyPublisher<[AnyArtifact], Error> in 116 | if missingProducts.isEmpty { 117 | return productNames 118 | .publisher 119 | .setFailureType(to: Error.self) 120 | .tryFlatMap(maxPublishers: .max(1)) { productName -> AnyPublisher in 121 | let path = Config.current.buildPath + "\(productName).xcframework" 122 | 123 | if path.exists, self.options.skipClean { 124 | return Just(AnyArtifact(Artifact( 125 | name: productName, 126 | parentName: dependencyProduct.name, 127 | version: dependencyProduct.version(for: productName), 128 | path: path 129 | ))) 130 | .setFailureType(to: Error.self) 131 | .eraseToAnyPublisher() 132 | } else { 133 | return Config.current.cacheDelegator 134 | .get( 135 | product: productName, 136 | in: dependencyProduct.name, 137 | version: dependencyProduct.version(for: productName), 138 | destination: path 139 | ) 140 | .eraseToAnyPublisher() 141 | } 142 | } 143 | .collect() 144 | .eraseToAnyPublisher() 145 | } else { 146 | return self.process(dependency, resolvedTo: dependencyProduct) 147 | } 148 | } 149 | .eraseToAnyPublisher() 150 | } 151 | .collect() 152 | .map { ($0.flatMap { $0 }, dependencyProducts) } 153 | .eraseToAnyPublisher() 154 | } 155 | .flatMap { next in self.postProcess().map { _ in next } } 156 | .eraseToAnyPublisher() 157 | } 158 | } 159 | 160 | public struct ProcessorOptions { 161 | public let platforms: [Platform] 162 | public let force: Bool 163 | public let skipClean: Bool 164 | 165 | public init(platforms: [Platform], force: Bool, skipClean: Bool) { 166 | self.platforms = platforms 167 | self.force = force 168 | self.skipClean = skipClean 169 | } 170 | } 171 | 172 | public protocol ArtifactProtocol { 173 | var name: String { get } 174 | var parentName: String { get } 175 | var version: String { get } 176 | var resource: URL { get } 177 | } 178 | 179 | public struct AnyArtifact: ArtifactProtocol { 180 | public let name: String 181 | public let parentName: String 182 | public let version: String 183 | public let resource: URL 184 | 185 | public var path: Path { 186 | return Path(resource.path) 187 | } 188 | 189 | public let base: Any 190 | 191 | public init(_ base: T) { 192 | self.base = base 193 | 194 | name = base.name 195 | parentName = base.parentName 196 | version = base.version 197 | resource = base.resource 198 | } 199 | } 200 | 201 | public struct Artifact: ArtifactProtocol { 202 | public let name: String 203 | public let parentName: String 204 | public let version: String 205 | public let path: Path 206 | 207 | public var resource: URL { path.url } 208 | } 209 | 210 | public struct CompressedArtifact: ArtifactProtocol { 211 | public let name: String 212 | public let parentName: String 213 | public let version: String 214 | public let path: Path 215 | 216 | public var resource: URL { path.url } 217 | 218 | public func checksum() throws -> String { 219 | return try path.checksum(.sha256) 220 | } 221 | } 222 | 223 | public struct CachedArtifact { 224 | public let name: String 225 | public let parentName: String 226 | public let url: URL 227 | public let checksum: String? 228 | 229 | internal var localPath: Path? 230 | 231 | init(name: String, parentName: String, url: URL, localPath: Path) throws { 232 | self.name = name 233 | self.parentName = parentName 234 | self.url = url 235 | self.checksum = try localPath.checksum(.sha256) 236 | self.localPath = localPath 237 | } 238 | 239 | init(name: String, parentName: String, url: URL) { 240 | self.name = name 241 | self.parentName = parentName 242 | self.url = url 243 | self.checksum = nil 244 | self.localPath = nil 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Dependency Processors/PackageProcessor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | import ProjectSpec 5 | import Regex 6 | import Version 7 | import XcodeGenKit 8 | import Zip 9 | 10 | public final class PackageProcessor: DependencyProcessor { 11 | 12 | public let dependencies: [PackageDependency] 13 | public let options: ProcessorOptions 14 | 15 | private var derivedDataPath: Path { 16 | return Config.current.cachePath + "DerivedData" + Config.current.name 17 | } 18 | 19 | private var sourcePackagesPath: Path { 20 | return Config.current.cachePath + "SourcePackages" + Config.current.name 21 | } 22 | 23 | private let urlSession: URLSession = .createWithExtensionsSupport() 24 | 25 | public init(dependencies: [PackageDependency], options: ProcessorOptions) { 26 | self.dependencies = dependencies 27 | self.options = options 28 | } 29 | 30 | public func preProcess() -> AnyPublisher<[SwiftPackageDescriptor], Error> { 31 | return Future.try { 32 | let projectPath = try self.writeProject() 33 | 34 | if !self.derivedDataPath.exists { 35 | try self.derivedDataPath.mkpath() 36 | } 37 | 38 | try self.resolvePackageDependencies(in: projectPath, sourcePackagesPath: self.sourcePackagesPath) 39 | 40 | return try self.readPackages(sourcePackagesPath: self.sourcePackagesPath) 41 | } 42 | .eraseToAnyPublisher() 43 | } 44 | 45 | public func process(_ dependency: PackageDependency?, resolvedTo resolvedDependency: SwiftPackageDescriptor) -> AnyPublisher<[AnyArtifact], Error> { 46 | return Future<([AnyArtifact], [AnyPublisher]), Error>.try { 47 | 48 | var xcFrameworks: [Artifact] = [] 49 | var downloads: [AnyPublisher] = [] 50 | 51 | for product in resolvedDependency.buildables { 52 | if case .binaryTarget(let target) = product { 53 | let targetPath = Config.current.buildPath + "\(product.name).xcframework" 54 | let artifact = Artifact( 55 | name: product.name, 56 | parentName: resolvedDependency.name, 57 | version: resolvedDependency.version, 58 | path: targetPath 59 | ) 60 | 61 | if self.options.skipClean, targetPath.exists { 62 | xcFrameworks <<< artifact 63 | continue 64 | } 65 | 66 | if let urlString = target.url, let url = URL(string: urlString), 67 | let checksum = target.checksum { 68 | downloads <<< Future.deferred { promise in 69 | let task = self.urlSession 70 | .downloadTask(with: url, progressHandler: { log.progress(percent: $0) }) { url, response, error in 71 | if let error = error { 72 | promise(.failure(error)) 73 | } else if let url = url { 74 | promise(.success(Path(url.path))) 75 | } else { 76 | log.fatal("Unexpected download result") 77 | } 78 | } 79 | 80 | log.info("Downloading \(url.lastPathComponent):") 81 | 82 | task.resume() 83 | } 84 | .tryMap { downloadPath -> AnyArtifact in 85 | let zipPath = Config.current.cachePath + url.lastPathComponent 86 | 87 | if zipPath.exists { 88 | try zipPath.delete() 89 | } 90 | 91 | try downloadPath.copy(zipPath) 92 | 93 | guard try zipPath.checksum(.sha256) == checksum else { 94 | throw ScipioError.checksumMismatch(product: product.name) 95 | } 96 | 97 | if targetPath.exists { 98 | try targetPath.delete() 99 | } 100 | 101 | log.info("Decompressing \(zipPath.lastComponent):") 102 | 103 | try Zip.unzipFile(zipPath.url, destination: targetPath.parent().url, overwrite: true, password: nil, progress: { log.progress(percent: $0) }) 104 | 105 | return AnyArtifact(artifact) 106 | } 107 | .eraseToAnyPublisher() 108 | } else if let targetPath = target.path { 109 | let fullPath = resolvedDependency.path + Path(targetPath) 110 | let targetPath = Config.current.buildPath + fullPath.lastComponent 111 | 112 | if targetPath.exists { 113 | try targetPath.delete() 114 | } 115 | 116 | try fullPath.copy(targetPath) 117 | 118 | xcFrameworks <<< artifact 119 | } 120 | } else { 121 | xcFrameworks <<< try self.buildAndExport( 122 | buildable: product, 123 | package: resolvedDependency, 124 | dependency: dependency 125 | ) 126 | } 127 | } 128 | 129 | return (xcFrameworks.map { AnyArtifact($0) }, downloads) 130 | } 131 | .flatMap { frameworks, downloads -> AnyPublisher<[AnyArtifact], Error> in 132 | return downloads 133 | .publisher 134 | .setFailureType(to: Error.self) 135 | .flatMap(maxPublishers: .max(1)) { $0 } 136 | .collect() 137 | .map { frameworks + $0 } 138 | .eraseToAnyPublisher() 139 | } 140 | .eraseToAnyPublisher() 141 | } 142 | 143 | public func postProcess() -> AnyPublisher<(), Error> { 144 | return Just(()) 145 | .setFailureType(to: Error.self) 146 | .eraseToAnyPublisher() 147 | } 148 | 149 | private func writeProject() throws -> Path { 150 | let projectName = "Packages.xcodeproj" 151 | let projectPath = Config.current.cachePath + Config.current.name + projectName 152 | 153 | if projectPath.exists { 154 | try projectPath.delete() 155 | } 156 | if !projectPath.parent().exists { 157 | try projectPath.parent().mkpath() 158 | } 159 | 160 | let projectSpec = Project( 161 | basePath: Config.current.cachePath, 162 | name: projectName, 163 | packages: dependencies.reduce(into: [:]) { $0[$1.name] = .remote(url: $1.url.absoluteString, versionRequirement: $1.versionRequirement) }, 164 | options: .init( 165 | deploymentTarget: .init( 166 | iOS: Version(Config.current.deploymentTarget["iOS"] ?? ""), 167 | tvOS: Version(Config.current.deploymentTarget["tvOS"] ?? ""), 168 | watchOS: Version(Config.current.deploymentTarget["watchOS"] ?? ""), 169 | macOS: Version(Config.current.deploymentTarget["macOS"] ?? "") 170 | ) 171 | )) 172 | let projectGenerator = ProjectGenerator(project: projectSpec) 173 | let project = try projectGenerator.generateXcodeProject(in: Config.current.cachePath) 174 | try project.write(path: projectPath) 175 | 176 | return projectPath 177 | } 178 | 179 | private func resolvePackageDependencies(in project: Path, sourcePackagesPath: Path) throws { 180 | log.info("📦 Resolving package dependencies...") 181 | 182 | let command = Xcodebuild( 183 | command: .resolvePackageDependencies, 184 | project: project.string, 185 | clonedSourcePackageDirectory: sourcePackagesPath.string 186 | ) 187 | 188 | try command.run() 189 | } 190 | 191 | private func readPackages(sourcePackagesPath: Path) throws -> [SwiftPackageDescriptor] { 192 | log.info("🧮 Loading Swift packages...") 193 | 194 | let decoder = JSONDecoder() 195 | let workspacePath = sourcePackagesPath + "workspace-state.json" 196 | let workspaceState = try decoder.decode(WorkspaceState.self, from: try workspacePath.read()) 197 | 198 | return try workspaceState.object 199 | .dependencies 200 | .map { try SwiftPackageDescriptor(path: workspacePath.parent() + "checkouts" + Path($0.subpath), name: $0.packageRef.name) } 201 | } 202 | 203 | private func setupWorkingPath(for dependency: SwiftPackageDescriptor) throws -> Path { 204 | let workingPath = Config.current.cachePath + dependency.name 205 | // Copy the repo to a temporary directory first so we don't modify 206 | // it in place. 207 | if workingPath.exists { 208 | try workingPath.delete() 209 | } 210 | try dependency.path.copy(workingPath) 211 | 212 | return workingPath 213 | } 214 | 215 | private func preBuild(path: Path) throws { 216 | // Xcodebuild doesn't provide an option for specifying a Package.swift 217 | // file to build from and if there's an xcodeproj in the same directory 218 | // it will favor that. So we need to hide them from xcodebuild 219 | // temporarily while we build. 220 | try path.glob("*.xcodeproj").forEach { try $0.move($0.parent() + "\($0.lastComponent).bak") } 221 | try path.glob("*.xcworkspace").forEach { try $0.move($0.parent() + "\($0.lastComponent).bak") } 222 | } 223 | 224 | private func postBuild(path: Path) throws { 225 | try path.glob("*.xcodeproj.bak").forEach { try $0.move($0.parent() + "\($0.lastComponentWithoutExtension)") } 226 | try path.glob("*.xcworkspace.bak").forEach { try $0.move($0.parent() + "\($0.lastComponentWithoutExtension)") } 227 | 228 | try path.delete() 229 | } 230 | 231 | private func buildAndExport(buildable: SwiftPackageBuildable, package: SwiftPackageDescriptor, dependency: PackageDependency?) throws -> [Artifact] { 232 | var path: Path? = nil 233 | 234 | let archivePaths = try options.platforms.sdks.map { sdk -> Path in 235 | 236 | if options.skipClean, Xcode.getArchivePath(for: buildable.name, sdk: sdk).exists { 237 | return Xcode.getArchivePath(for: buildable.name, sdk: sdk) 238 | } 239 | 240 | if path == nil { 241 | path = try self.setupWorkingPath(for: package) 242 | try self.preBuild(path: path!) 243 | } 244 | 245 | try forceDynamicFrameworkProduct(scheme: buildable.name, in: path!) 246 | 247 | let archivePath = try Xcode.archive( 248 | scheme: buildable.name, 249 | in: path!, 250 | for: sdk, 251 | derivedDataPath: derivedDataPath, 252 | additionalBuildSettings: dependency?.additionalBuildSettings 253 | ) 254 | 255 | try copyModulesAndHeaders( 256 | package: package, 257 | scheme: buildable.name, 258 | sdk: sdk, 259 | archivePath: archivePath, 260 | derivedDataPath: derivedDataPath 261 | ) 262 | 263 | return archivePath 264 | } 265 | 266 | let artifacts = try Xcode.createXCFramework( 267 | archivePaths: archivePaths, 268 | skipIfExists: options.skipClean 269 | ).map { path in 270 | return Artifact( 271 | name: path.lastComponentWithoutExtension, 272 | parentName: package.name, 273 | version: package.version, 274 | path: path 275 | ) 276 | } 277 | 278 | if let path = path { 279 | try self.postBuild(path: path) 280 | } 281 | 282 | return artifacts 283 | } 284 | 285 | // We need to rewrite Package.swift to force build a dynamic framework 286 | // https://forums.swift.org/t/how-to-build-swift-package-as-xcframework/41414/4 287 | private func forceDynamicFrameworkProduct(scheme: String, in path: Path) throws { 288 | precondition(path.exists, "You must call preBuild() before calling this function") 289 | 290 | var contents: String = try (path + "Package.swift").read() 291 | 292 | let productRegex = try Regex(string: #"(\.library\([\n\r\s]*name\s?:\s"\#(scheme)"[^,]*,)"#) 293 | 294 | if let _ = productRegex.firstMatch(in: contents) { 295 | // TODO: This should be rewritten using the Regex library 296 | let packagePath = path + "Package.swift" 297 | try sh("/usr/bin/perl", "-i", "-p0e", #"s/(\.library\([\n\r\s]*name\s?:\s"\#(scheme)"[^,]*,)[^,]*type: \.static[^,]*,/$1/g"#, packagePath.string) 298 | try sh("/usr/bin/perl", "-i", "-p0e", #"s/(\.library\([\n\r\s]*name\s?:\s"\#(scheme)"[^,]*,)[^,]*type: \.dynamic[^,]*,/$1/g"#, packagePath.string) 299 | try sh("/usr/bin/perl", "-i", "-p0e", #"s/(\.library\([\n\r\s]*name\s?:\s"\#(scheme)"[^,]*,)/$1 type: \.dynamic,/g"#, packagePath.string) 300 | } else { 301 | let insertRegex = Regex(#"products:[^\[]*\["#) 302 | guard let match = insertRegex.firstMatch(in: contents)?.range else { 303 | fatalError() 304 | } 305 | 306 | contents.insert(contentsOf: #".library(name: "\#(scheme)", type: .dynamic, targets: ["\#(scheme)"]),"#, at: match.upperBound) 307 | try (path + "Package.swift").write(contents) 308 | } 309 | } 310 | 311 | private func copyModulesAndHeaders(package: SwiftPackageDescriptor, scheme: String, sdk: Xcodebuild.SDK, archivePath: Path, derivedDataPath: Path) throws { 312 | // https://forums.swift.org/t/how-to-build-swift-package-as-xcframework/41414/4 313 | let frameworksPath = archivePath + "Products/Library/Frameworks" 314 | 315 | for frameworkPath in frameworksPath.glob("*.framework") { 316 | let frameworkName = frameworkPath.lastComponentWithoutExtension 317 | let modulesPath = frameworkPath + "Modules" 318 | let headersPath = frameworkPath + "Headers" 319 | 320 | if !modulesPath.exists { 321 | try modulesPath.mkdir() 322 | } 323 | 324 | let archiveIntermediatesPath = derivedDataPath + "Build/Intermediates.noindex/ArchiveIntermediates/\(frameworkName)" 325 | let buildProductsPath = archiveIntermediatesPath + "BuildProductsPath" 326 | let releasePath = buildProductsPath + "Release-\(sdk.rawValue)" 327 | let swiftModulePath = releasePath + "\(frameworkName).swiftmodule" 328 | let resourcesBundlePath = releasePath + "\(frameworkName)_\(frameworkName).bundle" 329 | 330 | let target = package.manifest.targets.first(where: { $0.name == frameworkName }) 331 | 332 | if swiftModulePath.exists { 333 | // Swift projects 334 | try swiftModulePath.copy(modulesPath + "\(frameworkName).swiftmodule") 335 | } 336 | 337 | if !swiftModulePath.exists || target?.settings?.contains(where: { $0.name == .headerSearchPath }) == true { 338 | // Objective-C projects 339 | let moduleMapDirectory = archiveIntermediatesPath + "IntermediateBuildFilesPath/\(package.name).build/Release-\(sdk.rawValue)/\(frameworkName).build" 340 | var moduleMapPath = moduleMapDirectory.glob("*.modulemap").first 341 | var moduleMapContent = "module \(frameworkName) { export * }" 342 | var includeDirectory: Path? = nil 343 | 344 | // If we can't find the generated modulemap, we check 345 | // to see if the package includes its own. 346 | if (moduleMapPath == nil || moduleMapPath?.exists == false), 347 | let target = package.manifest.targets.first(where: { $0.name == frameworkName }), 348 | target.type != .binary, 349 | let path = target.path { 350 | 351 | moduleMapPath = try (package.path + Path(path)) 352 | .normalize() 353 | .recursiveChildren() 354 | .filter { $0.extension == "modulemap" } 355 | .first 356 | 357 | if let moduleMapPath = moduleMapPath, moduleMapPath.parent().lastComponent == "include" { 358 | includeDirectory = moduleMapPath.parent() 359 | } 360 | } 361 | 362 | if let moduleMapPath = moduleMapPath, moduleMapPath.exists { 363 | let umbrellaHeaderRegex = Regex(#"umbrella (?:header )?"(.*)""#) 364 | let umbrellaHeaderMatch = umbrellaHeaderRegex.firstMatch(in: try moduleMapPath.read()) 365 | 366 | if let match = umbrellaHeaderMatch, !match.captures.isEmpty, 367 | let umbrellaHeaderPathString = match.captures[0] { 368 | 369 | var umbrellaHeaderPath = Path(umbrellaHeaderPathString) 370 | if umbrellaHeaderPath.isRelative { 371 | umbrellaHeaderPath = (moduleMapPath.parent() + umbrellaHeaderPath).normalize() 372 | } 373 | var sourceHeadersDirectory = umbrellaHeaderPath.isFile ? umbrellaHeaderPath.parent() : umbrellaHeaderPath + frameworkName 374 | 375 | if umbrellaHeaderPath.isDirectory, !sourceHeadersDirectory.exists { 376 | sourceHeadersDirectory = umbrellaHeaderPath 377 | } 378 | 379 | if !headersPath.exists { 380 | try headersPath.mkdir() 381 | } 382 | 383 | // If the modulemap declares an umbrella header instead of an 384 | // umbrella directory, we make sure the umbrella header references 385 | // its headers using syntax. 386 | // And then we recusively look through the header files for 387 | // imports to gather a list of files to include. 388 | if umbrellaHeaderPath.isFile, includeDirectory == nil { 389 | let headerContent = try umbrellaHeaderPath 390 | .read() 391 | .replacingFirst(matching: Regex(#"^#import "(.*).h""#, options: [.anchorsMatchLines]), with: "#import <\(frameworkName)/$1.h>") 392 | let path = headersPath + umbrellaHeaderPath.lastComponent 393 | try path.write(headerContent) 394 | } else if includeDirectory == nil { 395 | umbrellaHeaderPath = headersPath + "\(frameworkName).h" 396 | let umbrellaHeaderContent = sourceHeadersDirectory 397 | .glob("*.h") 398 | .map { "#import <\(frameworkName)/\($0.lastComponent)>" } 399 | .joined(separator: "\n") 400 | try umbrellaHeaderPath.write(umbrellaHeaderContent) 401 | } 402 | 403 | let allHeaderPaths: [Path] 404 | 405 | if let includeDirectory = includeDirectory { 406 | allHeaderPaths = try (includeDirectory + frameworkName) 407 | .recursiveChildren() 408 | .filter { $0.extension == "h" } 409 | } else { 410 | allHeaderPaths = try getHeaders(in: umbrellaHeaderPath, frameworkName: frameworkName, sourceHeadersDirectory: sourceHeadersDirectory) 411 | } 412 | 413 | if !headersPath.exists, !allHeaderPaths.isEmpty { 414 | try headersPath.mkdir() 415 | } 416 | 417 | for headerPath in allHeaderPaths { 418 | let targetPath = headersPath + headerPath.lastComponent 419 | 420 | if !targetPath.exists, headerPath.exists { 421 | if headerPath.isSymlink { 422 | try headerPath.symlinkDestination().copy(targetPath) 423 | } else { 424 | try headerPath.copy(targetPath) 425 | } 426 | } 427 | } 428 | 429 | moduleMapContent = """ 430 | framework module \(frameworkName) { 431 | umbrella header "\(umbrellaHeaderPath.lastComponent)" 432 | 433 | export * 434 | module * { export * } 435 | } 436 | """ 437 | } 438 | } else { 439 | let targets = package 440 | .manifest 441 | .products 442 | .filter { $0.name == frameworkName } 443 | .flatMap(\.targets) 444 | .compactMap { target in package.manifest.targets.first { $0.name == target } } 445 | let dependencies = targets 446 | .flatMap { $0.dependencies } 447 | .flatMap { $0.names } 448 | .compactMap { target in package.manifest.targets.first { $0.name == target } } 449 | let allTargets: [PackageManifest.Target] = (targets + dependencies) 450 | let headerPaths: [Path] = allTargets 451 | .compactMap { target in 452 | guard let publicHeadersPath = target.publicHeadersPath else { return nil } 453 | 454 | if let path = target.path { 455 | return Path(path) + Path(publicHeadersPath) 456 | } else { 457 | return Path(publicHeadersPath) 458 | } 459 | } 460 | let headers = try headerPaths 461 | .flatMap { headerPath -> [Path] in 462 | guard headerPath.exists else { return [] } 463 | 464 | return try (package.path + headerPath) 465 | .recursiveChildren() 466 | .filter { $0.extension == "h" } 467 | } 468 | 469 | if !headersPath.exists, !headers.isEmpty { 470 | try headersPath.mkdir() 471 | } 472 | 473 | for headerPath in headers { 474 | let targetPath = headersPath + headerPath.lastComponent 475 | 476 | if !targetPath.exists, headerPath.exists { 477 | try headerPath.copy(targetPath) 478 | } 479 | } 480 | 481 | moduleMapContent = """ 482 | framework module \(frameworkName) { 483 | \(headers.map { " header \"\($0.lastComponent)\"" }.joined(separator: "\n")) 484 | 485 | export * 486 | } 487 | """ 488 | } 489 | 490 | try (modulesPath + "module.modulemap").write(moduleMapContent) 491 | } 492 | 493 | if resourcesBundlePath.exists { 494 | try resourcesBundlePath.copy(frameworkPath) 495 | } 496 | } 497 | } 498 | 499 | private func getHeaders(in header: Path, frameworkName: String, sourceHeadersDirectory: Path, allHeaders: [Path] = []) throws -> [Path] { 500 | guard header.exists else { return [] } 501 | 502 | let localHeaderRegex = Regex(#"^#import "(.*)\.h""#, options: [.anchorsMatchLines]) 503 | let frameworkHeaderRegex = try Regex(string: #"^#import <\#(frameworkName)/(.*)\.h>"#, options: [.anchorsMatchLines]) 504 | 505 | let contents: String = try header.read() 506 | let headerMatches = localHeaderRegex.allMatches(in: contents) 507 | + frameworkHeaderRegex.allMatches(in: contents) 508 | 509 | guard !headerMatches.isEmpty else { return [header] } 510 | 511 | let headerPaths = headerMatches 512 | .map { sourceHeadersDirectory + "\($0.captures[0] ?? "").h" } 513 | .filter { !allHeaders.contains($0) && $0 != header } 514 | .uniqued() 515 | var accumulated = allHeaders + [header] 516 | 517 | for headerPath in headerPaths where !accumulated.contains(headerPath) { 518 | accumulated.append(contentsOf: try getHeaders(in: headerPath, frameworkName: frameworkName, sourceHeadersDirectory: sourceHeadersDirectory, allHeaders: accumulated)) 519 | } 520 | 521 | return accumulated.uniqued() 522 | } 523 | } 524 | 525 | // MARK: - WorkspaceState 526 | private struct WorkspaceState: Decodable { 527 | let object: Object 528 | } 529 | 530 | extension WorkspaceState { 531 | struct Object: Codable { 532 | let artifacts: [Artifact] 533 | let dependencies: [Dependency] 534 | 535 | struct Dependency: Codable { 536 | let packageRef: PackageRef 537 | let state: State 538 | let subpath: String 539 | 540 | struct State: Codable { 541 | let checkoutState: CheckoutState 542 | let name: Name 543 | 544 | enum Name: String, Codable { 545 | case checkout 546 | } 547 | 548 | struct CheckoutState: Codable { 549 | let branch: String? 550 | let revision: String 551 | let version: String? 552 | } 553 | } 554 | } 555 | 556 | struct PackageRef: Codable { 557 | let identity: String 558 | let kind: Kind 559 | let name: String 560 | let path: String? 561 | } 562 | 563 | enum Kind: String, Codable { 564 | case local 565 | case remote 566 | } 567 | 568 | struct Artifact: Codable { 569 | let packageRef: PackageRef 570 | let source: Source 571 | let targetName: String 572 | 573 | struct Source: Codable { 574 | let path: String? 575 | let type: Kind 576 | let checksum, subpath: String? 577 | let url: String? 578 | } 579 | } 580 | } 581 | } 582 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | var nilIfEmpty: Self? { 5 | return isEmpty ? nil : self 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Collection+Operators.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | infix operator <<< : AssignmentPrecedence 4 | 5 | public func <<< (lhs: inout T, rhs: T.Iterator.Element) { 6 | lhs.append(rhs) 7 | } 8 | 9 | public func <<< (lhs: inout T, rhs: T?) { 10 | lhs.append(contentsOf: rhs ?? T()) 11 | } 12 | 13 | public func + (lhs: T?, rhs: T?) -> T { 14 | return (lhs ?? T()) + (rhs ?? T()) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Data+Checksum.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | 4 | extension Data { 5 | enum ChecksumStrategy { 6 | case sha256 7 | } 8 | 9 | func checksum(_ strategy: ChecksumStrategy) -> String { 10 | switch strategy { 11 | case .sha256: 12 | return SHA256.hash(data: self) 13 | .compactMap { String(format: "%02x", $0) } 14 | .joined() 15 | } 16 | } 17 | } 18 | 19 | extension String { 20 | func checksum(_ strategy: Data.ChecksumStrategy) -> String { 21 | return data(using: .utf8)!.checksum(strategy) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Future+Deferred.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Future where Failure == Error { 4 | public static func deferred(_ handler: @escaping (@escaping Future.Promise) -> Void) -> Deferred { 5 | return Deferred { 6 | Future { handler($0) } 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Future+Try.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Future where Failure == Error { 4 | public static func `try`(_ handler: @escaping () throws -> Future.Output) -> Future { 5 | return Future { promise in 6 | do { 7 | promise(.success(try handler())) 8 | } catch { 9 | promise(.failure(error)) 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Int+Spaces.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Int { 4 | var spaces: String { 5 | return (0.. String { 7 | return try read().checksum(strategy) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Path+Extensions.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | 3 | extension Path { 4 | func withoutLastExtension() -> Path { 5 | return parent() + lastComponentWithoutExtension 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Path+Gzip.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | import SWCompression 4 | 5 | extension Path { 6 | func gunzipped() throws -> Path { 7 | let outPath = parent() + lastComponentWithoutExtension 8 | try outPath.write(try GzipArchive.unarchive(archive: try read())) 9 | 10 | return outPath 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Path+Untar.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | import SWCompression 4 | 5 | public extension Path { 6 | func untar() throws -> Path { 7 | let path = parent() + lastComponentWithoutExtension 8 | let entries = try TarContainer.open(container: try read()) 9 | 10 | try createFilesAndDirectories( 11 | path: path, 12 | entries: entries 13 | ) 14 | 15 | return path 16 | } 17 | 18 | private func createFilesAndDirectories(path: Path, entries: [TarEntry]) throws { 19 | for entry in entries { 20 | let entryPath = (path + entry.info.name) 21 | .normalize() 22 | 23 | log.debug(entryPath.string) 24 | 25 | switch entry.info.type { 26 | case .regular: 27 | try entryPath.write(entry.data ?? Data()) 28 | case .directory: 29 | try entryPath.mkpath() 30 | case .symbolicLink: 31 | let linkPath = (path + entry.info.linkName).normalize() 32 | try linkPath.symlink(entryPath) 33 | case .hardLink: 34 | let linkPath = (path + entry.info.linkName).normalize() 35 | try linkPath.link(entryPath) 36 | default: 37 | break 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Publisher+TryFlatMap.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Publisher { 4 | public func tryFlatMap(maxPublishers: Subscribers.Demand = .unlimited, _ transform: @escaping (Output) throws -> Pub) -> Publishers.FlatMap, Self> { 5 | return flatMap(maxPublishers: maxPublishers, { input -> AnyPublisher in 6 | do { 7 | return try transform(input) 8 | .mapError { $0 as Error } 9 | .eraseToAnyPublisher() 10 | } catch { 11 | return Fail(outputType: Pub.Output.self, failure: error) 12 | .eraseToAnyPublisher() 13 | } 14 | }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/Publisher+Wait.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | private let cancelBag = CancelBag() 5 | 6 | extension Publisher { 7 | @discardableResult 8 | public func wait() throws -> Output? { 9 | var result: Result? 10 | 11 | let semaphore = DispatchSemaphore(value: 0) 12 | 13 | DispatchQueue.global().async { 14 | self.sink { completion in 15 | switch completion { 16 | case .failure(let error): 17 | result = .failure(error) 18 | case .finished: 19 | break 20 | } 21 | semaphore.signal() 22 | } receiveValue: { value in 23 | result = .success(value) 24 | } 25 | .store(in: cancelBag) 26 | } 27 | 28 | semaphore.wait() 29 | 30 | if let result = result { 31 | switch result { 32 | case .failure(let error): 33 | throw error 34 | case .success(let value): 35 | return value 36 | } 37 | } else { 38 | return nil 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | var nilIfEmpty: String? { 5 | return isEmpty ? nil : self 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/URLSession+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private var internalDelegateKey: UInt8 = 0 4 | 5 | extension URLSession { 6 | 7 | private var internalDelegate: Delegate? { 8 | get { objc_getAssociatedObject(self, &internalDelegateKey) as? Delegate } 9 | set { objc_setAssociatedObject(self, &internalDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } 10 | } 11 | 12 | static func createWithExtensionsSupport() -> URLSession { 13 | let delegate = Delegate() 14 | let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: delegate.operationQueue) 15 | session.internalDelegate = delegate 16 | return session 17 | } 18 | 19 | func downloadTask(with url: URL, progressHandler: @escaping (Double) -> Void, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask { 20 | 21 | internalDelegate?.downloadRequests[url] = (progressHandler, completionHandler) 22 | 23 | return downloadTask(with: url) 24 | } 25 | 26 | func uploadTask(with request: URLRequest, fromFile file: URL, progressHandler: @escaping (Double) -> Void, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionUploadTask { 27 | 28 | internalDelegate?.uploadRequests[request.url!] = (progressHandler, completionHandler) 29 | 30 | return uploadTask(with: request, fromFile: file) 31 | } 32 | } 33 | 34 | private final class Delegate: NSObject, URLSessionDownloadDelegate, URLSessionDataDelegate { 35 | 36 | let operationQueue = OperationQueue() 37 | 38 | var downloadRequests: [URL: (progressHandler: (Double) -> Void, completionHandler: (URL?, URLResponse?, Error?) -> Void)] = [:] 39 | var uploadRequests: [URL: (progressHandler: (Double) -> Void, completionHandler: (Data?, URLResponse?, Error?) -> Void)] = [:] 40 | 41 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 42 | if let url = downloadTask.originalRequest?.url, 43 | let (progressHandler, _) = downloadRequests[url] { 44 | 45 | progressHandler(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) 46 | } 47 | } 48 | 49 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 50 | if let url = downloadTask.originalRequest?.url, 51 | let (_, completionHandler) = downloadRequests[url] { 52 | 53 | completionHandler(location, downloadTask.response, downloadTask.error) 54 | downloadRequests.removeValue(forKey: url) 55 | } 56 | } 57 | 58 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { 59 | if let url = task.originalRequest?.url, 60 | let (progressHandler, _) = uploadRequests[url] { 61 | 62 | progressHandler(Double(totalBytesSent) / Double(totalBytesExpectedToSend)) 63 | } 64 | } 65 | 66 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 67 | if let url = task.originalRequest?.url, 68 | let (_, completionHandler) = uploadRequests[url] { 69 | 70 | completionHandler(nil, task.response, error) 71 | uploadRequests.removeValue(forKey: url) 72 | } 73 | } 74 | 75 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 76 | if let url = dataTask.originalRequest?.url, 77 | let (_, completionHandler) = uploadRequests[url] { 78 | 79 | completionHandler(data, dataTask.response, nil) 80 | uploadRequests.removeValue(forKey: url) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Extensions/XcodeProj+Products.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | import XcodeProj 3 | 4 | enum ProjectProduct: Hashable { 5 | case product(String) 6 | case path(Path) 7 | 8 | var name: String { 9 | switch self { 10 | case .product(let name): 11 | return name 12 | case .path(let path): 13 | return path.lastComponentWithoutExtension 14 | } 15 | } 16 | 17 | func hash(into hasher: inout Hasher) { 18 | hasher.combine(name) 19 | } 20 | } 21 | 22 | extension XcodeProj { 23 | var productNames: [String] { 24 | return pbxproj 25 | .groups 26 | .first { $0.name == "Products" }? 27 | .children 28 | .compactMap { $0.name?.components(separatedBy: ".").first } ?? [] 29 | } 30 | 31 | func productNames(for targetName: String, podsRoot: Path) -> [ProjectProduct] { 32 | return pbxproj 33 | .targets(named: targetName) 34 | .flatMap { $0.productNames(podsRoot: podsRoot) } 35 | .filter { !$0.name.isEmpty } 36 | .uniqued() 37 | } 38 | 39 | func resourceBundles(for targetName: String, podsRoot: Path, notIn notInProjectProducts: [ProjectProduct]) -> [Path] { 40 | return pbxproj 41 | .targets(named: targetName) 42 | .flatMap { $0.resourceBundles(podsRoot: podsRoot, notIn: notInProjectProducts) } 43 | .uniqued() 44 | } 45 | } 46 | 47 | extension PBXTarget { 48 | func productNames(podsRoot: Path) -> [ProjectProduct] { 49 | var names: [ProjectProduct] = [] 50 | 51 | if let productPath = product?.path { 52 | names <<< .product(Path(productPath).lastComponentWithoutExtension) 53 | } 54 | 55 | if let aggregateTarget = self as? PBXAggregateTarget { 56 | let vendoredFrameworkNames = aggregateTarget 57 | .buildPhases 58 | .flatMap { $0.inputFileListPaths ?? [] } 59 | .flatMap { path -> [ProjectProduct] in 60 | let fixedPath = path 61 | .replacingOccurrences(of: "${PODS_ROOT}/", with: "") 62 | .replacingOccurrences(of: "$PODS_ROOT/", with: "") 63 | let contents = (try? (podsRoot + Path(fixedPath)).read()) ?? "" 64 | 65 | return contents 66 | .components(separatedBy: "\n") 67 | .compactMap { pathString in 68 | let path = podsRoot + Path( 69 | pathString 70 | .replacingOccurrences(of: "${PODS_ROOT}/", with: "") 71 | .replacingOccurrences(of: "$PODS_ROOT/", with: "") 72 | ) 73 | 74 | return path.extension == "xcframework" ? .path(path) : nil 75 | } 76 | } 77 | 78 | names <<< vendoredFrameworkNames 79 | } 80 | 81 | for dependency in dependencies { 82 | if let target = dependency.target { 83 | names <<< target.productNames(podsRoot: podsRoot) 84 | } 85 | } 86 | 87 | return names 88 | } 89 | 90 | func resourceBundles(podsRoot: Path, notIn notInProjectProducts: [ProjectProduct]) -> [Path] { 91 | var paths: [Path] = [] 92 | 93 | let resourceBundlePaths = buildPhases 94 | .flatMap { $0.inputFileListPaths ?? [] } 95 | .flatMap { path -> [Path] in 96 | let fixedPath = path 97 | .replacingOccurrences(of: "${PODS_ROOT}/", with: "") 98 | .replacingOccurrences(of: "$PODS_ROOT/", with: "") 99 | .replacingOccurrences(of: "${CONFIGURATION}", with: "Release") 100 | .replacingOccurrences(of: "$CONFIGURATION", with: "Release") 101 | let contents = (try? (podsRoot + Path(fixedPath)).read()) ?? "" 102 | 103 | return contents 104 | .components(separatedBy: "\n") 105 | .compactMap { pathString -> Path? in 106 | let path = podsRoot + Path( 107 | pathString 108 | .replacingOccurrences(of: "${PODS_ROOT}/", with: "") 109 | .replacingOccurrences(of: "$PODS_ROOT/", with: "") 110 | .replacingOccurrences(of: "${CONFIGURATION}", with: "Release") 111 | .replacingOccurrences(of: "$CONFIGURATION", with: "Release") 112 | ) 113 | 114 | return path.extension == "bundle" ? path : nil 115 | } 116 | .filter { path in 117 | return !notInProjectProducts 118 | .contains { path.components.contains("\($0.name).xcframework") } 119 | } 120 | } 121 | 122 | paths <<< resourceBundlePaths 123 | 124 | for dependency in dependencies { 125 | if let target = dependency.target { 126 | paths <<< target.resourceBundles(podsRoot: podsRoot, notIn: notInProjectProducts) 127 | } 128 | } 129 | 130 | return paths 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Helpers/CancelBag.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public final class CancelBag { 4 | fileprivate var cancellables: Set = [] 5 | 6 | deinit { 7 | cancel() 8 | } 9 | 10 | public init() {} 11 | 12 | public func cancel() { 13 | cancellables.forEach { $0.cancel() } 14 | cancellables.removeAll() 15 | } 16 | } 17 | 18 | extension AnyCancellable { 19 | public func store(in bag: CancelBag) { 20 | bag.cancellables.insert(self) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Helpers/Log.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public let log: Log = .shared 4 | 5 | public final class Log: NSObject { 6 | 7 | public static let shared = Log() 8 | 9 | internal let operationQueue = OperationQueue() 10 | 11 | public enum Level { 12 | case passthrough 13 | case debug 14 | case verbose 15 | case info 16 | case success 17 | case warning 18 | case error 19 | 20 | internal var color: Color { 21 | switch self { 22 | case .passthrough: return .default 23 | case .debug: return .blue 24 | case .verbose: return .default 25 | case .info: return .white 26 | case .success: return .green 27 | case .warning: return .yellow 28 | case .error: return .red 29 | } 30 | } 31 | 32 | internal var levelValue: Int { 33 | switch self { 34 | case .debug: return 0 35 | case .verbose: return 1 36 | case .info: return 2 37 | case .success: return 2 38 | case .warning: return 2 39 | case .error: return 3 40 | case .passthrough: return 2 41 | } 42 | } 43 | } 44 | 45 | public var level: Level = .info 46 | public var debugLevel: Level = .debug 47 | public var useColors: Bool = true 48 | 49 | private static let dateFormatter: DateFormatter = { 50 | let formatter = DateFormatter() 51 | formatter.dateFormat = "HH:mm:ss" 52 | return formatter 53 | }() 54 | 55 | public func debug(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 56 | self.log(level: .debug, file: file, line: line, column: column, message) 57 | } 58 | 59 | public func verbose(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 60 | self.log(level: .verbose, file: file, line: line, column: column, message) 61 | } 62 | 63 | public func info(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 64 | self.log(level: .info, file: file, line: line, column: column, message) 65 | } 66 | 67 | public func success(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 68 | self.log(level: .success, file: file, line: line, column: column, message) 69 | } 70 | 71 | public func warning(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 72 | self.log(level: .warning, file: file, line: line, column: column, message) 73 | } 74 | 75 | public func error(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 76 | self.log(level: .error, file: file, line: line, column: column, message) 77 | } 78 | 79 | public func passthrough(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) { 80 | self.log(level: .passthrough, file: file, line: line, column: column, message) 81 | } 82 | 83 | public func progress(file: String = #file, line: Int = #line, column: Int = #column, percent: Double) { 84 | let width: Int = 40 85 | let message = "[" + stride(from: 0, to: width, by: 1) 86 | .map { Double($0) / Double(width) > min(percent, 1) ? "-" : "=" } 87 | .joined() + "] \(Int((percent * 100).rounded()))%" 88 | 89 | if percent >= 1 { 90 | self.log(level: .success, file: file, line: line, column: column, [message]) 91 | } else { 92 | self.log(level: .info, terminator: "\r", file: file, line: line, column: column, [message]) 93 | } 94 | } 95 | 96 | public func fatal(file: String = #file, line: Int = #line, column: Int = #column, _ message: Any...) -> Never { 97 | self.log(level: .error, file: file, line: line, column: column, message) 98 | exit(EXIT_FAILURE) 99 | } 100 | 101 | private var isDebug: Bool { 102 | #if DEBUG 103 | return true 104 | #else 105 | return false 106 | #endif 107 | } 108 | 109 | private func log(level: Level, terminator: String = "\n", file: String = #file, line: Int = #line, column: Int = #column, _ message: [Any]) { 110 | if isDebug { 111 | guard level.levelValue >= debugLevel.levelValue else { return } 112 | } else { 113 | guard level.levelValue >= self.level.levelValue else { return } 114 | } 115 | 116 | let filename = URL(fileURLWithPath: file).lastPathComponent 117 | let formattedMessage = message.map { String(describing: $0) } .joined(separator: " ") 118 | let dateText = Log.dateFormatter.string(from: Date()) 119 | let debugMessage = "[\(dateText)]: [\(filename):\(line):\(column)] | \(formattedMessage)" 120 | let defaultMessage = "[\(dateText)]: \(formattedMessage)" 121 | 122 | switch level { 123 | case .debug where isDebug: 124 | if useColors { 125 | print("\(debugMessage, color: level.color)", terminator: terminator) 126 | } else { 127 | print(debugMessage, terminator: terminator) 128 | } 129 | case .passthrough: 130 | print(isDebug ? debugMessage : defaultMessage, terminator: terminator) 131 | case .debug: 132 | break 133 | default: 134 | if useColors { 135 | print("\(isDebug ? debugMessage : defaultMessage, color: level.color)", terminator: terminator) 136 | } else { 137 | print(isDebug ? debugMessage : defaultMessage, terminator: terminator) 138 | } 139 | } 140 | 141 | fflush(__stdoutp) 142 | } 143 | } 144 | 145 | public extension Log { 146 | enum Color: String { 147 | case black = "\u{001B}[0;30m" 148 | case red = "\u{001B}[0;31m" 149 | case green = "\u{001B}[0;32m" 150 | case yellow = "\u{001B}[0;33m" 151 | case blue = "\u{001B}[0;34m" 152 | case magenta = "\u{001B}[0;35m" 153 | case cyan = "\u{001B}[0;36m" 154 | case white = "\u{001B}[0;37m" 155 | case `default` = "\u{001B}[0;0m" 156 | } 157 | } 158 | 159 | public extension DefaultStringInterpolation { 160 | mutating func appendInterpolation(_ value: T, color: Log.Color) { 161 | appendInterpolation("\(color.rawValue)\(value)\(Log.Color.default.rawValue)") 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Helpers/ShellCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | 4 | @discardableResult 5 | func sh(_ command: Path, _ arguments: String..., in path: Path? = nil, passEnvironment: Bool = false, lineReader: ((String) -> Void)? = nil) throws -> ShellCommand { 6 | try ShellCommand.sh(command: command.string, arguments: arguments, in: path, passEnvironment: passEnvironment, lineReader: lineReader) 7 | } 8 | 9 | @discardableResult 10 | func sh(_ command: Path, _ arguments: [String], in path: Path? = nil, passEnvironment: Bool = false, lineReader: ((String) -> Void)? = nil) throws -> ShellCommand { 11 | try ShellCommand.sh(command: command.string, arguments: arguments, in: path, passEnvironment: passEnvironment, lineReader: lineReader) 12 | } 13 | 14 | @discardableResult 15 | func sh(_ command: String, _ arguments: String..., in path: Path? = nil, passEnvironment: Bool = false, lineReader: ((String) -> Void)? = nil) throws -> ShellCommand { 16 | try ShellCommand.sh(command: command, arguments: arguments, in: path, passEnvironment: passEnvironment, lineReader: lineReader) 17 | } 18 | 19 | @discardableResult 20 | func sh(_ command: String, _ arguments: [String], in path: Path? = nil, passEnvironment: Bool = false, lineReader: ((String) -> Void)? = nil) throws -> ShellCommand { 21 | try ShellCommand.sh(command: command, arguments: arguments, in: path, passEnvironment: passEnvironment, lineReader: lineReader) 22 | } 23 | 24 | @discardableResult 25 | func which(_ command: String) throws -> Path { 26 | do { 27 | let output = try sh("/usr/bin/which", command, passEnvironment: true) 28 | .outputString() 29 | .trimmingCharacters(in: .whitespacesAndNewlines) 30 | 31 | return Path(output) 32 | } catch { 33 | throw ShellError.commandNotFound(command) 34 | } 35 | } 36 | 37 | public enum ShellError: LocalizedError { 38 | case commandNotFound(String) 39 | 40 | public var errorDescription: String? { 41 | switch self { 42 | case .commandNotFound(let command): 43 | return "Command '\(command)' could not be found." 44 | } 45 | } 46 | } 47 | 48 | final class ShellCommand { 49 | 50 | @discardableResult 51 | static func sh(command: String, arguments: [String], in path: Path? = nil, passEnvironment: Bool = false, lineReader: ((String) -> Void)? = nil) throws -> ShellCommand { 52 | let shell = ShellCommand(command: command, arguments: arguments) 53 | try shell.run(in: path, passEnvironment: passEnvironment, lineReader: lineReader) 54 | return shell 55 | } 56 | 57 | let command: String 58 | let arguments: [String] 59 | 60 | let outputPipe = Pipe() 61 | let errorPipe = Pipe() 62 | let inputPipe = Pipe() 63 | 64 | private let task = Process() 65 | private var outputString: String = "" 66 | private var errorString: String = "" 67 | private var outputQueue = DispatchQueue(label: "outputQueue") 68 | 69 | init(command: String, arguments: [String]) { 70 | self.command = command 71 | self.arguments = arguments 72 | } 73 | 74 | func run(in path: Path? = nil, passEnvironment: Bool = false, lineReader: ((String) -> Void)? = nil) throws { 75 | 76 | defer { 77 | outputPipe.fileHandleForReading.readabilityHandler = nil 78 | errorPipe.fileHandleForReading.readabilityHandler = nil 79 | } 80 | 81 | task.standardOutput = outputPipe 82 | task.standardError = errorPipe 83 | 84 | outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in 85 | if let line = String(data: handle.availableData, encoding: .utf8), !line.isEmpty { 86 | log.verbose(line) 87 | lineReader?(line) 88 | self?.outputQueue.async { 89 | self?.outputString.append(line) 90 | } 91 | } 92 | } 93 | errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in 94 | if let line = String(data: handle.availableData, encoding: .utf8), !line.isEmpty { 95 | log.verbose(line) 96 | lineReader?(line) 97 | self?.outputQueue.async { 98 | self?.errorString.append(line) 99 | } 100 | } 101 | } 102 | 103 | if passEnvironment { 104 | task.environment = ProcessInfo.processInfo.environment 105 | } 106 | 107 | if let path = path { 108 | task.currentDirectoryURL = path.url 109 | } 110 | 111 | if command.contains("/") { 112 | task.arguments = arguments 113 | task.launchPath = command 114 | } else { 115 | task.launchPath = "/bin/bash" 116 | task.arguments = ["-c", command] + arguments 117 | } 118 | 119 | log.verbose(task.launchPath ?? "", task.arguments?.joined(separator: " ") ?? "") 120 | 121 | task.launch() 122 | task.waitUntilExit() 123 | 124 | if task.terminationStatus != EXIT_SUCCESS { 125 | try outputQueue.sync { 126 | throw ScipioError.commandFailed( 127 | command: ([command] + arguments).joined(separator: " "), 128 | status: Int(task.terminationStatus), 129 | output: outputString, 130 | error: errorString 131 | ) 132 | } 133 | } 134 | } 135 | 136 | func output(stdout: Bool = true, stderr: Bool = false) throws -> Data { 137 | return outputQueue.sync { 138 | let out = stdout ? outputString.data(using: .utf8) ?? Data() : Data() 139 | let err = stderr ? errorString.data(using: .utf8) ?? Data() : Data() 140 | 141 | return out + err 142 | } 143 | } 144 | 145 | func outputString(stdout: Bool = true, stderr: Bool = false) throws -> String { 146 | return String(data: try output(stdout: stdout, stderr: stderr), encoding: .utf8) ?? "" 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Helpers/Xcode.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PathKit 4 | 5 | struct Xcode { 6 | static func getArchivePath(for scheme: String, sdk: Xcodebuild.SDK) -> Path { 7 | return Config.current.buildPath + "\(scheme)-\(sdk.rawValue).xcarchive" 8 | } 9 | 10 | static func archive(scheme: String, in path: Path, for sdk: Xcodebuild.SDK, derivedDataPath: Path, sourcePackagesPath: Path? = nil, additionalBuildSettings: [String: String]?) throws -> Path { 11 | 12 | var buildSettings: [String: String] = [ 13 | "BUILD_LIBRARY_FOR_DISTRIBUTION": "YES", 14 | "SKIP_INSTALL": "NO", 15 | "INSTALL_PATH": "/Library/Frameworks" 16 | ] 17 | 18 | if let additionalBuildSettings = additionalBuildSettings { 19 | buildSettings.merge(additionalBuildSettings) { l, r in r } 20 | } 21 | 22 | let archivePath = getArchivePath(for: scheme, sdk: sdk) 23 | 24 | log.info("🏗 Building \(scheme)-\(sdk.rawValue)...") 25 | 26 | let command = Xcodebuild( 27 | command: .archive, 28 | workspace: path.extension == "xcworkspace" ? path.string : nil, 29 | project: path.extension == "xcodeproj" ? path.string : nil, 30 | scheme: scheme, 31 | archivePath: archivePath.string, 32 | derivedDataPath: derivedDataPath.string, 33 | clonedSourcePackageDirectory: sourcePackagesPath?.string, 34 | sdk: sdk, 35 | additionalBuildSettings: buildSettings 36 | ) 37 | 38 | try path.chdir { 39 | try command.run() 40 | } 41 | 42 | return archivePath 43 | } 44 | 45 | static func createXCFramework(archivePaths: [Path], skipIfExists: Bool, filter isIncluded: ((String) -> Bool)? = nil) throws -> [Path] { 46 | precondition(!archivePaths.isEmpty, "Cannot create XCFramework from zero archives") 47 | 48 | let firstArchivePath = archivePaths[0] 49 | let buildDirectory = firstArchivePath.parent() 50 | let frameworkPaths = (firstArchivePath + "Products/Library/Frameworks") 51 | .glob("*.framework") 52 | .filter { isIncluded?($0.lastComponentWithoutExtension) ?? true } 53 | 54 | return try frameworkPaths.compactMap { frameworkPath in 55 | let productName = frameworkPath.lastComponentWithoutExtension 56 | let frameworks = archivePaths 57 | .map { $0 + "Products/Library/Frameworks/\(productName).framework" } 58 | let output = buildDirectory + "\(productName).xcframework" 59 | 60 | if skipIfExists, output.exists { 61 | return output 62 | } 63 | 64 | log.info("📦 Creating \(productName).xcframework...") 65 | 66 | if output.exists { 67 | try output.delete() 68 | } 69 | 70 | let command = Xcodebuild( 71 | command: .createXCFramework, 72 | additionalArguments: frameworks 73 | .flatMap { ["-framework", $0.string] } + ["-output", output.string] 74 | ) 75 | try buildDirectory.chdir { 76 | try command.run() 77 | } 78 | 79 | return output 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Helpers/Xcodebuild.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | import XcbeautifyLib 4 | 5 | public struct Xcodebuild { 6 | var command: Command 7 | var workspace: String? 8 | var project: String? 9 | var scheme: String? 10 | var target: String? 11 | var archivePath: String? 12 | var derivedDataPath: String? 13 | var clonedSourcePackageDirectory: String? 14 | var sdk: SDK? 15 | var useSystemSourceControlManagement: Bool = true 16 | var disableAutomaticPackageResolution: Bool = false 17 | var additionalArguments: [String] = [] 18 | var additionalBuildSettings: [String: String] = [:] 19 | 20 | init( 21 | command: Command, 22 | workspace: String? = nil, 23 | project: String? = nil, 24 | scheme: String? = nil, 25 | target: String? = nil, 26 | archivePath: String? = nil, 27 | derivedDataPath: String? = nil, 28 | clonedSourcePackageDirectory: String? = nil, 29 | sdk: SDK? = nil, 30 | useSystemSourceControlManagement: Bool = true, 31 | disableAutomaticPackageResolution: Bool = false, 32 | additionalArguments: [String] = [], 33 | additionalBuildSettings: [String: String] = [:] 34 | ) { 35 | self.command = command 36 | self.workspace = workspace 37 | self.project = project 38 | self.scheme = scheme 39 | self.target = target 40 | self.archivePath = archivePath 41 | self.derivedDataPath = derivedDataPath 42 | self.clonedSourcePackageDirectory = clonedSourcePackageDirectory 43 | self.sdk = sdk 44 | self.useSystemSourceControlManagement = useSystemSourceControlManagement 45 | self.disableAutomaticPackageResolution = disableAutomaticPackageResolution 46 | self.additionalArguments = additionalArguments 47 | self.additionalBuildSettings = additionalBuildSettings 48 | } 49 | 50 | func run() throws { 51 | let parser = Parser() 52 | let output = OutputHandler(quiet: false, quieter: false, isCI: false, { log.passthrough($0) }) 53 | 54 | let arguments = getArguments() 55 | log.verbose((["xcodebuild"] + arguments).joined(separator: " ")) 56 | try sh("/usr/bin/xcodebuild", arguments) { line in 57 | if log.level.levelValue <= Log.Level.verbose.levelValue { 58 | log.verbose(line) 59 | } else { 60 | guard let formatted = parser.parse(line: line, colored: log.useColors) else { return } 61 | output.write(parser.outputType, formatted) 62 | } 63 | } 64 | 65 | if let summary = parser.summary { 66 | print(summary.format()) 67 | } 68 | } 69 | 70 | private func getArguments() -> [String] { 71 | var args: [String] = [] 72 | 73 | switch command { 74 | case .archive: 75 | args.append("archive") 76 | case .resolvePackageDependencies: 77 | args.append("-resolvePackageDependencies") 78 | case .createXCFramework: 79 | args.append("-create-xcframework") 80 | } 81 | 82 | if let workspace = workspace { 83 | args.append(contentsOf: ["-workspace", workspace]) 84 | } 85 | if let project = project { 86 | args.append(contentsOf: ["-project", project]) 87 | } 88 | if let scheme = scheme { 89 | args.append(contentsOf: ["-scheme", scheme]) 90 | } 91 | if let target = target { 92 | args.append(contentsOf: ["-target", target]) 93 | } 94 | if let archivePath = archivePath { 95 | args.append(contentsOf: ["-archivePath", archivePath]) 96 | } 97 | if let derivedDataPath = derivedDataPath { 98 | args.append(contentsOf: ["-derivedDataPath", derivedDataPath]) 99 | } 100 | if let clonedSourcePackageDirectory = clonedSourcePackageDirectory { 101 | args.append(contentsOf: ["-clonedSourcePackagesDirPath", clonedSourcePackageDirectory]) 102 | } 103 | if let destination = sdk?.destination { 104 | args.append(contentsOf: ["-destination", destination]) 105 | } 106 | if useSystemSourceControlManagement, command != .createXCFramework { 107 | args.append(contentsOf: ["-scmProvider", "system"]) 108 | } 109 | if disableAutomaticPackageResolution { 110 | args.append("-disableAutomaticPackageResolution") 111 | } 112 | 113 | args.append(contentsOf: additionalArguments) 114 | args.append(contentsOf: additionalBuildSettings.map { "\($0)=\($1)" }) 115 | 116 | return args 117 | } 118 | } 119 | 120 | public extension Xcodebuild { 121 | enum Command: String { 122 | case archive 123 | case resolvePackageDependencies 124 | case createXCFramework 125 | } 126 | 127 | enum SDK: String, CaseIterable { 128 | case iphoneos 129 | case iphonesimulator 130 | case macos 131 | 132 | var destination: String { 133 | switch self { 134 | case .iphoneos: 135 | return "generic/platform=iOS" 136 | case .iphonesimulator: 137 | return "generic/platform=iOS Simulator" 138 | case .macos: 139 | return "generic/platform=macOS" 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/Architecture.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Architecture: String, CaseIterable, CustomStringConvertible { 4 | case armv7 5 | case x86_64 6 | case arm64 7 | 8 | public var description: String { rawValue } 9 | 10 | public var possibleSDKs: [Xcodebuild.SDK] { 11 | switch self { 12 | case .armv7: 13 | return [.iphoneos] 14 | case .x86_64: 15 | return [.iphonesimulator] 16 | case .arm64: 17 | return [.iphoneos, .iphonesimulator] 18 | } 19 | } 20 | } 21 | 22 | public extension Xcodebuild.SDK { 23 | var architectures: [Architecture] { 24 | switch self { 25 | case .iphoneos: 26 | return [.armv7, .arm64] 27 | case .iphonesimulator, .macos: 28 | return [.x86_64, .arm64] 29 | } 30 | } 31 | } 32 | 33 | extension Collection where Iterator.Element == Architecture { 34 | public var sdkArchitectures: [Xcodebuild.SDK: [Architecture]] { 35 | return Set(flatMap(\.possibleSDKs)) 36 | .reduce(into: [:]) { $0[$1] = $1.architectures } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/Config.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | import Yams 4 | 5 | public struct Config: Decodable, Equatable { 6 | 7 | public internal(set) static var current: Config! 8 | 9 | public let name: String 10 | public let cacheDelegator: CacheEngineDelegator 11 | public let binaries: [BinaryDependency]? 12 | public let packages: [PackageDependency]? 13 | public let pods: [CocoaPodDependency]? 14 | 15 | public var buildDirectory: String? 16 | public let deploymentTarget: [String: String] 17 | 18 | public var path: Path { _path } 19 | public var directory: Path { _path.parent() } 20 | 21 | public var buildPath: Path { 22 | if let buildDirectory = buildDirectory { 23 | return Path(buildDirectory) 24 | } else { 25 | return directory + ".scipio" 26 | } 27 | } 28 | 29 | public var platformVersions: [Platform: String] { 30 | return deploymentTarget 31 | .reduce(into: [:]) { acc, next in 32 | if let platform = Platform(rawValue: next.key) { 33 | acc[platform] = next.value 34 | } else { 35 | log.fatal("Invalid platform \"\(next.key)\"") 36 | } 37 | } 38 | } 39 | 40 | public var platforms: [Platform] { 41 | return Array(platformVersions.keys) 42 | } 43 | 44 | public let cachePath: Path = { 45 | let path = Path(FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].path) + "Scipio" 46 | 47 | if !path.exists { 48 | try! path.mkdir() 49 | } 50 | 51 | return path 52 | }() 53 | 54 | public var packageRoot: Path { 55 | if let localCache = cacheDelegator.local { 56 | return localCache.normalizedPath 57 | } else { 58 | return directory 59 | } 60 | } 61 | 62 | private var _path: Path! 63 | 64 | public init(name: String, cache: Cache, deploymentTarget: [String: String], binaries: [BinaryDependency]? = nil, packages: [PackageDependency]? = nil, pods: [CocoaPodDependency]? = nil) { 65 | self.name = name 66 | self.cacheDelegator = CacheEngineDelegator(cache: cache) 67 | self.deploymentTarget = deploymentTarget 68 | self.binaries = binaries 69 | self.packages = packages 70 | self.pods = pods 71 | } 72 | 73 | enum CodingKeys: String, CodingKey { 74 | case name 75 | case cacheDelegator = "cache" 76 | case binaries 77 | case packages 78 | case pods 79 | case buildDirectory 80 | case deploymentTarget 81 | } 82 | 83 | public static func setPath(_ path: Path, buildDirectory: String?) { 84 | let correctedPath = path.isFile ? path : path + "scipio.yml" 85 | current = readConfig(from: correctedPath) 86 | current._path = correctedPath 87 | current.buildDirectory = buildDirectory 88 | } 89 | 90 | @discardableResult 91 | public static func readConfig(from path: Path = Path.current + "scipio.yml") -> Config { 92 | guard path.exists else { log.fatal("Couldn't find config file at path: \(path.string)") } 93 | 94 | do { 95 | let data = try Data(contentsOf: path.url) 96 | let decoder = YAMLDecoder() 97 | var config = try decoder.decode(Config.self, from: data) 98 | config._path = path 99 | Config.current = config 100 | 101 | if !config.buildPath.exists { 102 | try config.buildPath.mkpath() 103 | } 104 | 105 | return config 106 | } catch { 107 | log.fatal("Error read config file at path \(path): \(error)") 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/Dependency.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | import ProjectSpec 4 | 5 | public protocol Dependency: Decodable, Equatable { 6 | var name: String { get } 7 | } 8 | 9 | public struct BinaryDependency: Dependency, DependencyProducts { 10 | public let name: String 11 | public let url: URL 12 | public let version: String 13 | public let excludes: [String]? 14 | 15 | public var productNames: [String]? { 16 | let names = try? productNamesCachePath.read() 17 | .components(separatedBy: ",") 18 | .filter { !$0.isEmpty } 19 | .nilIfEmpty 20 | 21 | if let excludes = excludes { 22 | return names? 23 | .filter { !excludes.contains($0) } 24 | } 25 | 26 | return names 27 | } 28 | 29 | public var productNamesCachePath: Path { 30 | return Config.current.cachePath + ".binary-products-\(name)-\(version)" 31 | } 32 | 33 | public func version(for productName: String) -> String { 34 | return version 35 | } 36 | 37 | public func cache(_ productNames: [String]) throws { 38 | if productNames.filter(\.isEmpty).isEmpty { 39 | try productNamesCachePath.write(productNames.joined(separator: ",")) 40 | } 41 | } 42 | } 43 | 44 | public struct CocoaPodDependency: Dependency { 45 | public let name: String 46 | public let version: String? 47 | public let from: String? 48 | public let git: URL? 49 | public let branch: String? 50 | public let commit: String? 51 | public let podspec: URL? 52 | public let excludes: [String]? 53 | public let additionalBuildSettings: [String: String]? 54 | } 55 | 56 | public struct PackageDependency: Dependency { 57 | public let name: String 58 | public let url: URL 59 | public let from: String? 60 | public let revision: String? 61 | public let branch: String? 62 | public let exactVersion: String? 63 | public let additionalBuildSettings: [String: String]? 64 | 65 | public var versionRequirement: SwiftPackage.VersionRequirement { 66 | if let from = from { 67 | return .upToNextMajorVersion(from) 68 | } else if let revision = revision { 69 | return .revision(revision) 70 | } else if let branch = branch { 71 | return .branch(branch) 72 | } else if let exactVersion = exactVersion { 73 | return .exact(exactVersion) 74 | } else { 75 | fatalError("Unsupported package version requirement") 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/Platform.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Platform: String { 4 | case iOS = "ios" 5 | case macOS = "macos" 6 | 7 | public var sdks: [Xcodebuild.SDK] { 8 | switch self { 9 | case .iOS: 10 | return [.iphoneos, .iphonesimulator] 11 | case .macOS: 12 | return [.macos] 13 | } 14 | } 15 | 16 | public init?(rawValue: String) { 17 | switch rawValue.lowercased() { 18 | case "ios": 19 | self = .iOS 20 | case "macos", "mac", "osx": 21 | self = .macOS 22 | default: 23 | return nil 24 | } 25 | } 26 | 27 | public func asPackagePlatformString(version: String) -> String { 28 | return ".\(packagePlatformRawValue)(.v\(version.components(separatedBy: ".0").dropLast().joined().replacingOccurrences(of: ".", with: "_")))" 29 | } 30 | 31 | private var packagePlatformRawValue: String { 32 | switch self { 33 | case .iOS: 34 | return "iOS" 35 | case .macOS: 36 | return "macOS" 37 | } 38 | } 39 | } 40 | 41 | extension Sequence where Element == Platform { 42 | public var sdks: [Xcodebuild.SDK] { 43 | return flatMap(\.sdks) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/ScipioError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ScipioError: LocalizedError { 4 | case zipFailure(AnyArtifact) 5 | case commandFailed(command: String, status: Int, output: String?, error: String?) 6 | case checksumMismatch(product: String) 7 | case unknownPackage(String) 8 | case conflictingDependencies(product: String, conflictingDependencies: [String]) 9 | case invalidFramework(String) 10 | case missingArchitectures(String, [Architecture]) 11 | 12 | public var errorDescription: String? { 13 | switch self { 14 | case .zipFailure(let artifact): 15 | return "Failed to zip artifact (\(artifact.name)) at \(artifact.path)" 16 | case .commandFailed(let command, let status, _, let error): 17 | return "Command `\(command)` failed with status \(status)\(error == nil ? "" : ": \(error!)")" 18 | case .checksumMismatch(let product): 19 | return "Checksum does not match for \"\(product)\"" 20 | case .unknownPackage(let name): 21 | return "Unknown package \"\(name)\"" 22 | case .conflictingDependencies(let product, let dependencies): 23 | return "\(dependencies.count) dependencies (\(dependencies.joined(separator: ", "))) produce \(product). It is recommended to add \(product) as an explicit dependency and/or exclude it from the conflicting packages via the configuration file." 24 | case .invalidFramework(let name): 25 | return "Invalid framework \(name)" 26 | case .missingArchitectures(let name, let archs): 27 | return "\(name) is missing required architectures \(archs.map(\.description).joined(separator: ", "))" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/SwiftPackageDescriptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | 4 | public struct SwiftPackageDescriptor: DependencyProducts { 5 | 6 | public let name: String 7 | public let version: String 8 | public let path: Path 9 | public let manifest: PackageManifest 10 | public let buildables: [SwiftPackageBuildable] 11 | 12 | public var productNames: [String]? { 13 | return buildables.map(\.name) 14 | } 15 | 16 | public init(path: Path, name: String) throws { 17 | self.name = name 18 | self.path = path 19 | 20 | var gitPath = path + ".git" 21 | 22 | guard gitPath.exists else { 23 | log.fatal("Missing git directory for package: \(name)") 24 | } 25 | 26 | if gitPath.isFile { 27 | guard let actualPath = (try gitPath.read()).components(separatedBy: "gitdir: ").last?.trimmingCharacters(in: .whitespacesAndNewlines) else { 28 | log.fatal("Couldn't parse .git file in \(path)") 29 | } 30 | 31 | gitPath = (gitPath.parent() + Path(actualPath)).normalize() 32 | } 33 | 34 | let headPath = gitPath + "HEAD" 35 | 36 | guard headPath.exists else { 37 | log.fatal("Missing HEAD file in \(gitPath)") 38 | } 39 | 40 | self.version = (try headPath.read()) 41 | .trimmingCharacters(in: .whitespacesAndNewlines) 42 | let manifest: PackageManifest = try .load(from: path) 43 | self.manifest = manifest 44 | self.buildables = manifest.getBuildables() 45 | } 46 | 47 | public func version(for productName: String) -> String { 48 | return version 49 | } 50 | } 51 | 52 | // MARK: - PackageManifest 53 | public struct PackageManifest: Codable, Equatable { 54 | public let name: String 55 | public let products: [Product] 56 | public let targets: [Target] 57 | 58 | public static func load(from path: Path) throws -> PackageManifest { 59 | precondition(path.isDirectory) 60 | 61 | let cachedManifestPath = Config.current.cachePath + "\(path.lastComponent)-\(try (path + "Package.swift").checksum(.sha256)).json" 62 | let data: Data 63 | if cachedManifestPath.exists { 64 | log.verbose("Loading cached Package.swift for \(path.lastComponent)") 65 | data = try cachedManifestPath.read() 66 | } else { 67 | log.verbose("Reading Package.swift for \(path.lastComponent)") 68 | data = try sh("/usr/bin/swift", "package", "dump-package", "--package-path", "\(path.string)") 69 | .output() 70 | try cachedManifestPath.write(data) 71 | } 72 | let decoder = JSONDecoder() 73 | 74 | do { 75 | return try decoder.decode(PackageManifest.self, from: data) 76 | } catch { 77 | try cachedManifestPath.delete() 78 | 79 | return try load(from: path) 80 | } 81 | } 82 | 83 | public func getBuildables() -> [SwiftPackageBuildable] { 84 | return products 85 | .flatMap { getBuildables(in: $0) } 86 | .uniqued() 87 | } 88 | 89 | private func getBuildables(in product: Product) -> [SwiftPackageBuildable] { 90 | let targets = recursiveTargets(in: product) 91 | 92 | return targets 93 | .compactMap { target -> SwiftPackageBuildable? in 94 | let dependencies = target.dependencies.flatMap(\.names) 95 | 96 | if target.type == .binary { 97 | return .binaryTarget(target) 98 | } else if dependencies.count == 1, 99 | targets.first(where: { $0.name == dependencies[0] })?.type == .binary { 100 | 101 | return nil 102 | } else { 103 | return .target(target.name) 104 | } 105 | } 106 | } 107 | 108 | private func recursiveTargets(in product: Product) -> [PackageManifest.Target] { 109 | return product 110 | .targets 111 | .compactMap { target in targets.first { $0.name == target } } 112 | .flatMap { recursiveTargets(in: $0) } 113 | } 114 | 115 | private func recursiveTargets(in target: Target) -> [PackageManifest.Target] { 116 | return [target] + target 117 | .dependencies 118 | .flatMap { recursiveTargets(in: $0) } 119 | } 120 | 121 | private func recursiveTargets(in dependency: TargetDependency) -> [PackageManifest.Target] { 122 | let byName = dependency.byName?.compactMap { $0?.name } 123 | 124 | return (dependency.target?.compactMap({ $0?.name }) + byName) 125 | .compactMap { target in targets.first { $0.name == target } } 126 | .flatMap { recursiveTargets(in: $0) } 127 | } 128 | } 129 | 130 | extension PackageManifest { 131 | 132 | public struct Product: Codable, Equatable, Hashable { 133 | public let name: String 134 | public let targets: [String] 135 | } 136 | 137 | public struct Target: Codable, Equatable, Hashable { 138 | public let dependencies: [TargetDependency] 139 | public let name: String 140 | public let path: String? 141 | public let publicHeadersPath: String? 142 | public let type: TargetType 143 | public let checksum: String? 144 | public let url: String? 145 | public let settings: [Setting]? 146 | 147 | public struct Setting: Codable, Equatable, Hashable { 148 | public let name: Name 149 | public let value: [String] 150 | 151 | public enum Name: String, Codable, Equatable { 152 | case define 153 | case headerSearchPath 154 | case linkedFramework 155 | case linkedLibrary 156 | } 157 | } 158 | } 159 | 160 | public struct TargetDependency: Codable, Equatable, Hashable { 161 | public let byName: [Dependency?]? 162 | public let product: [Dependency?]? 163 | public let target: [Dependency?]? 164 | 165 | public var names: [String] { 166 | return [byName, product, target] 167 | .compactMap { $0 } 168 | .flatMap { $0 } 169 | .compactMap(\.?.name) 170 | } 171 | 172 | public enum Dependency: Codable, Equatable, Hashable { 173 | case name(String) 174 | case constraint(platforms: [String]) 175 | 176 | public var name: String? { 177 | switch self { 178 | case .name(let name): 179 | return name 180 | case .constraint: 181 | return nil 182 | } 183 | } 184 | 185 | enum CodingKeys: String, CodingKey { 186 | case platformNames 187 | } 188 | 189 | public init(from decoder: Decoder) throws { 190 | if let container = try? decoder.singleValueContainer(), 191 | let stringValue = try? container.decode(String.self) { 192 | 193 | self = .name(stringValue) 194 | } else { 195 | let container = try decoder.container(keyedBy: CodingKeys.self) 196 | 197 | self = .constraint(platforms: try container.decode([String].self, forKey: .platformNames)) 198 | } 199 | } 200 | 201 | public func encode(to encoder: Encoder) throws { 202 | switch self { 203 | case .name(let name): 204 | var container = encoder.singleValueContainer() 205 | try container.encode(name) 206 | case .constraint(let platforms): 207 | var container = encoder.container(keyedBy: CodingKeys.self) 208 | try container.encode(platforms, forKey: .platformNames) 209 | } 210 | } 211 | } 212 | } 213 | 214 | public enum TargetType: String, Codable { 215 | case binary = "binary" 216 | case regular = "regular" 217 | case test = "test" 218 | } 219 | } 220 | 221 | public enum SwiftPackageBuildable: Equatable, Hashable { 222 | case target(String) 223 | case binaryTarget(PackageManifest.Target) 224 | 225 | public var name: String { 226 | switch self { 227 | case .target(let name): 228 | return name 229 | case .binaryTarget(let target): 230 | if let urlString = target.url, let url = URL(string: urlString) { 231 | return url.lastPathComponent 232 | .components(separatedBy: ".")[0] 233 | } else if let path = target.path { 234 | return Path(path).lastComponent 235 | .components(separatedBy: ".")[0] 236 | } else { 237 | return target.name 238 | } 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /Sources/ScipioKit/Models/SwiftPackageFile.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | 4 | public struct SwiftPackageFile { 5 | public var name: String 6 | public var path: Path 7 | public var platforms: [Platform: String] 8 | public var products: [Product] = [] 9 | public var targets: [Target] = [] 10 | 11 | public var artifacts: [CachedArtifact] 12 | public var removeMissing: Bool 13 | 14 | public init(name: String, path: Path, platforms: [Platform: String], artifacts: [CachedArtifact], removeMissing: Bool) throws { 15 | self.name = name 16 | self.path = path.lastComponent == "Package.swift" ? path : path + "Package.swift" 17 | self.platforms = platforms 18 | self.artifacts = artifacts 19 | self.removeMissing = removeMissing 20 | 21 | try read() 22 | } 23 | 24 | public func needsWrite(relativeTo: Path) -> Bool { 25 | let existing: String? = try? path.read() 26 | 27 | return existing != asString(relativeTo: relativeTo) 28 | } 29 | 30 | public mutating func read() throws { 31 | let manifest = path.exists ? try PackageManifest.load(from: path.parent()) : nil 32 | var artifactsAndTargets: [(name: String, artifact: CachedArtifact?, target: Target?)] = manifest? 33 | .targets 34 | .compactMap { target -> (String, CachedArtifact?, Target?)? in 35 | if let artifact = artifacts.first(where: { $0.name == target.name }) { 36 | return (artifact.name, artifact, nil) 37 | } else if !removeMissing { 38 | return (target.name, nil, Target(target)) 39 | } else { 40 | return nil 41 | } 42 | } ?? artifacts.map { ($0.name, $0, nil) } 43 | 44 | if let targets = manifest?.targets.map(\.name) { 45 | for artifact in artifacts where !targets.contains(artifact.name) { 46 | artifactsAndTargets <<< (artifact.name, artifact, nil) 47 | } 48 | } 49 | 50 | let sortedArtifacts = artifactsAndTargets 51 | .sorted { $0.name < $1.name } 52 | 53 | products = sortedArtifacts 54 | .map { Product(name: $0.name, targets: [$0.name]) } 55 | 56 | targets = try sortedArtifacts 57 | .map { name, artifact, target in 58 | if let artifact = artifact { 59 | if let checksum = artifact.checksum { 60 | return Target( 61 | name: name, 62 | url: artifact.url, 63 | checksum: checksum 64 | ) 65 | } else if let checksum = manifest?.targets.first(where: { $0.name == name })?.checksum { 66 | return Target( 67 | name: name, 68 | url: artifact.url, 69 | checksum: checksum 70 | ) 71 | } else if artifact.url.isFileURL { 72 | return Target( 73 | name: name, 74 | url: artifact.url, 75 | checksum: nil 76 | ) 77 | } else { 78 | let existingPath = Config.current.buildPath + "\(name).xcframework.zip" 79 | 80 | if existingPath.exists { 81 | return Target(name: name, url: artifact.url, checksum: try existingPath.checksum(.sha256)) 82 | } 83 | 84 | fatalError("Missing checksum for \(artifact.name)") 85 | } 86 | } else if let target = target { 87 | return target 88 | } else { 89 | fatalError() 90 | } 91 | } 92 | } 93 | 94 | public func write(relativeTo: Path) throws { 95 | try path.write(asString(relativeTo: relativeTo)) 96 | } 97 | 98 | func asString(relativeTo: Path) -> String { 99 | return """ 100 | // swift-tools-version:5.3 101 | import PackageDescription 102 | 103 | let package = Package( 104 | name: "\(name)", 105 | platforms: [ 106 | \(platforms 107 | .map { $0.key.asPackagePlatformString(version: $0.value) } 108 | .joined(separator: ",\n\(8.spaces)")) 109 | ], 110 | products: [ 111 | \(products 112 | .map { $0.asString(indenting: 8.spaces) } 113 | .joined(separator: ",\n")) 114 | ], 115 | targets: [ 116 | \(targets 117 | .map { $0.asString(indenting: 8.spaces, relativeTo: relativeTo) } 118 | .joined(separator: ",\n")) 119 | ] 120 | ) 121 | """ 122 | } 123 | } 124 | 125 | extension SwiftPackageFile { 126 | public struct Product { 127 | public var name: String 128 | public var targets: [String] 129 | 130 | func asString(indenting: String) -> String { 131 | let targetsString = targets 132 | .map { "\"\($0)\"" } 133 | .joined(separator: ", ") 134 | 135 | return #"\#(indenting).library(name: "\#(name)", targets: [\#(targetsString)])"# 136 | } 137 | } 138 | 139 | public struct Target { 140 | public var name: String 141 | public var url: URL 142 | public var checksum: String? 143 | 144 | public init(_ target: PackageManifest.Target) { 145 | name = target.name 146 | checksum = target.checksum 147 | 148 | if let urlString = target.url, let url = URL(string: urlString) { 149 | self.url = url 150 | } else if let path = target.path { 151 | self.url = URL(fileURLWithPath: path) 152 | } else { 153 | fatalError() 154 | } 155 | } 156 | 157 | public init(name: String, url: URL, checksum: String?) { 158 | self.name = name 159 | self.url = url 160 | self.checksum = checksum 161 | } 162 | 163 | func asString(indenting: String, relativeTo: Path) -> String { 164 | if url.isFileURL { 165 | return """ 166 | \(indenting).binaryTarget( 167 | \(indenting) name: "\(name)", 168 | \(indenting) path: "\(url.path.replacingOccurrences(of: relativeTo.string, with: "").trimmingCharacters(in: .init(charactersIn: "/")))" 169 | \(indenting)) 170 | """ 171 | } else { 172 | return """ 173 | \(indenting).binaryTarget( 174 | \(indenting) name: "\(name)", 175 | \(indenting) url: "\(url.absoluteString)", 176 | \(indenting) checksum: "\(checksum!)" 177 | \(indenting)) 178 | """ 179 | } 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Tests/ScipioTests/ArchitectureTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ScipioKit 2 | import XCTest 3 | 4 | final class ArchitectureTests: XCTestCase { 5 | func testSdkArchitectures() throws { 6 | let architectures = Architecture.allCases 7 | 8 | XCTAssertEqual(architectures.sdkArchitectures, [ 9 | .iphonesimulator: [.x86_64, .arm64], 10 | .iphoneos: [.armv7, .arm64], 11 | ]) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/ScipioTests/ConfigTests.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | @testable import ScipioKit 3 | import XCTest 4 | 5 | final class ConfigTests: XCTestCase { 6 | func testReadConfig() throws { 7 | let configText = """ 8 | name: Test 9 | deploymentTarget: 10 | iOS: "12.0" 11 | cache: 12 | local: 13 | path: ~/Desktop/TestCache 14 | packages: 15 | - name: SnapKit 16 | url: https://github.com/SnapKit/SnapKit 17 | branch: 5.0.0 18 | - name: SwiftyJSON 19 | url: https://github.com/SwiftyJSON/SwiftyJSON 20 | from: 5.0.0 21 | """ 22 | let path = Path.temporary + "scipio.yml" 23 | try path.write(configText) 24 | let config = Config.readConfig(from: path) 25 | 26 | XCTAssertEqual(config.packages?.count, 2) 27 | XCTAssertEqual(config.name, "Test") 28 | XCTAssertEqual(config.platformVersions, [.iOS: "12.0"]) 29 | XCTAssertNotNil(config.packages?.contains { $0.name == "SnapKit" }) 30 | XCTAssertNotNil(config.packages?.contains { $0.name == "SwiftyJSON" }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/ScipioTests/DependencyProcessorTests.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | @testable import ScipioKit 3 | import XCTest 4 | 5 | final class DependencyProcessorTests: XCTestCase { 6 | 7 | override func setUpWithError() throws { 8 | setupConfig() 9 | 10 | 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/ScipioTests/Helpers/CachedArtifact+Mock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | @testable import ScipioKit 4 | 5 | extension CachedArtifact { 6 | static func mock(name: String, parentName: String) throws -> CachedArtifact { 7 | let path = try Path.temporaryForTests() + "\(name).xcframework.zip" 8 | 9 | if path.exists { 10 | try path.delete() 11 | } 12 | 13 | try path.write("\(parentName)-\(name)") 14 | 15 | return try CachedArtifact( 16 | name: name, 17 | parentName: parentName, 18 | url: URL(string: "https://scipio.test/packages/\(name)/\(name).xcframework.zip")!, 19 | localPath: path 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/ScipioTests/Helpers/Path+Extensions.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | import XCTest 3 | 4 | extension Path { 5 | static func temporaryForTests() throws -> Path { 6 | let path = Path.temporary + "ScipioKitTests" 7 | 8 | if !path.exists { 9 | try path.mkpath() 10 | } 11 | 12 | return path 13 | } 14 | 15 | static func temporary(for testCase: XCTestCase) -> Path { 16 | return Path.temporary + testCase.name.trimmingCharacters(in: .alphanumerics.inverted).replacingOccurrences(of: " ", with: "-") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/ScipioTests/Helpers/XCTestCase+Config.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PathKit 3 | @testable import ScipioKit 4 | import XCTest 5 | 6 | extension XCTest { 7 | func setupConfig(_ config: Config = .init(name: "TestProject", cache: LocalCacheEngine(path: Path.temporary + "TestProjectCache"), deploymentTarget: ["iOS": "12.0"])) { 8 | Config.current = config 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Tests/ScipioTests/SwiftPackageDescriptorTests.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | @testable import ScipioKit 3 | import XCTest 4 | 5 | final class SwiftPackageDescriptorTests: XCTestCase { 6 | 7 | private lazy var path = Path.temporary(for: self) + "Package.swift" 8 | 9 | override func setUpWithError() throws { 10 | setupConfig() 11 | 12 | if !path.parent().exists { 13 | try path.parent().mkpath() 14 | } 15 | } 16 | 17 | func testComputeBuildablesWithTargetDependency() throws { 18 | let packageText = """ 19 | // swift-tools-version:5.3 20 | import PackageDescription 21 | let package = Package( 22 | name: "JWT", 23 | products: [ 24 | .library(name: "JWT", targets: ["JWT"]), 25 | ], 26 | dependencies: [ 27 | .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", from: "0.10.0") 28 | ], 29 | targets: [ 30 | .target(name: "JWA", dependencies: ["CryptoSwift"]), 31 | .target(name: "JWT", dependencies: ["JWA"]), 32 | .testTarget(name: "JWATests", dependencies: ["JWA"]), 33 | .testTarget(name: "JWTTests", dependencies: ["JWT"]), 34 | ] 35 | ) 36 | """ 37 | try path.write(packageText) 38 | let package = try PackageManifest.load(from: path.parent()) 39 | 40 | XCTAssertEqual(package.name, "JWT") 41 | XCTAssertEqual(package.getBuildables(), [.target("JWT"), .target("JWA")]) 42 | } 43 | 44 | func testComputeProductNamesWithSingleBinaryTargetDependency() throws { 45 | let packageText = """ 46 | // swift-tools-version:5.3 47 | import PackageDescription 48 | let package = Package( 49 | name: "JWT", 50 | products: [ 51 | .library(name: "JWT", targets: ["JWT"]), 52 | ], 53 | targets: [ 54 | .binaryTarget(name: "JWT", path: "JWT.xcframework"), 55 | ] 56 | ) 57 | """ 58 | try path.write(packageText) 59 | let package = try PackageManifest.load(from: path.parent()) 60 | 61 | XCTAssertEqual(package.name, "JWT") 62 | XCTAssertEqual(package.getBuildables(), [.binaryTarget(.init(dependencies: [], name: "JWT", path: "JWT.xcframework", publicHeadersPath: nil, type: .binary, checksum: nil, url: nil, settings: []))]) 63 | } 64 | 65 | func testComputeProductNamesWithBinaryTargetDependency() throws { 66 | let packageText = """ 67 | // swift-tools-version:5.3 68 | import PackageDescription 69 | let package = Package( 70 | name: "JWT", 71 | products: [ 72 | .library(name: "JWT", targets: ["JWTTarget"]), 73 | ], 74 | targets: [ 75 | .binaryTarget(name: "JWT", path: "JWT.xcframework"), 76 | .target(name: "JWTTarget", dependencies: ["JWT"]), 77 | ] 78 | ) 79 | """ 80 | try path.write(packageText) 81 | let package = try PackageManifest.load(from: path.parent()) 82 | 83 | XCTAssertEqual(package.name, "JWT") 84 | XCTAssertEqual(package.getBuildables(), [.binaryTarget(.init(dependencies: [], name: "JWT", path: "JWT.xcframework", publicHeadersPath: nil, type: .binary, checksum: nil, url: nil, settings: []))]) 85 | } 86 | 87 | func testComputeProductNamesForSDWebImage() throws { 88 | let packageText = """ 89 | // swift-tools-version:5.0 90 | // The swift-tools-version declares the minimum version of Swift required to build this package. 91 | import PackageDescription 92 | 93 | let package = Package( 94 | name: "SDWebImage", 95 | platforms: [ 96 | .macOS(.v10_11), 97 | .iOS(.v9), 98 | .tvOS(.v9), 99 | .watchOS(.v2) 100 | ], 101 | products: [ 102 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 103 | .library( 104 | name: "SDWebImage", 105 | targets: ["SDWebImage"]), 106 | .library( 107 | name: "SDWebImageMapKit", 108 | targets: ["SDWebImageMapKit"]) 109 | ], 110 | dependencies: [ 111 | // Dependencies declare other packages that this package depends on. 112 | // .package(url: /* package url */, from: "1.0.0"), 113 | ], 114 | targets: [ 115 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 116 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 117 | .target( 118 | name: "SDWebImage", 119 | dependencies: [], 120 | path: "SDWebImage", 121 | sources: ["Core", "Private"], 122 | cSettings: [ 123 | .headerSearchPath("Core"), 124 | .headerSearchPath("Private") 125 | ] 126 | ), 127 | .target( 128 | name: "SDWebImageMapKit", 129 | dependencies: ["SDWebImage"], 130 | path: "SDWebImageMapKit", 131 | sources: ["MapKit"] 132 | ) 133 | ] 134 | ) 135 | """ 136 | try path.write(packageText) 137 | let package = try PackageManifest.load(from: path.parent()) 138 | 139 | XCTAssertEqual(package.name, "SDWebImage") 140 | XCTAssertEqual(package.getBuildables(), [.target("SDWebImage"), .target("SDWebImageMapKit")]) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Tests/ScipioTests/SwiftPackageFileTests.swift: -------------------------------------------------------------------------------- 1 | import PathKit 2 | @testable import ScipioKit 3 | import XCTest 4 | 5 | final class SwiftPackageFileTests: XCTestCase { 6 | 7 | private lazy var path = Path.temporary(for: self) + "Package.swift" 8 | 9 | override func setUpWithError() throws { 10 | setupConfig() 11 | 12 | if !path.parent().exists { 13 | try path.parent().mkpath() 14 | } else if path.exists { 15 | try path.delete() 16 | } 17 | } 18 | 19 | func testWritingNewFile() throws { 20 | let file = try SwiftPackageFile( 21 | name: "TestPackage", 22 | path: path, 23 | platforms: [.iOS: "12.0"], 24 | artifacts: [ 25 | .mock(name: "Product1", parentName: "Package1"), 26 | .mock(name: "Product2", parentName: "Package1"), 27 | .mock(name: "Product3", parentName: "Package2"), 28 | ], 29 | removeMissing: true 30 | ) 31 | let result = file.asString(relativeTo: path.parent()) 32 | let expectedResult = """ 33 | // swift-tools-version:5.3 34 | import PackageDescription 35 | 36 | let package = Package( 37 | name: "TestPackage", 38 | platforms: [ 39 | .iOS(.v12) 40 | ], 41 | products: [ 42 | .library(name: "Product1", targets: ["Product1"]), 43 | .library(name: "Product2", targets: ["Product2"]), 44 | .library(name: "Product3", targets: ["Product3"]) 45 | ], 46 | targets: [ 47 | .binaryTarget( 48 | name: "Product1", 49 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 50 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 51 | ), 52 | .binaryTarget( 53 | name: "Product2", 54 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 55 | checksum: "909a4b17649cd39ca05e33be0f7a5e60d34e272b918058bd57ed19a4afc21549" 56 | ), 57 | .binaryTarget( 58 | name: "Product3", 59 | url: "https://scipio.test/packages/Product3/Product3.xcframework.zip", 60 | checksum: "5be156b10ff7c684ff48c59a085560e347253a01d0093e47f122be9ed80f7646" 61 | ) 62 | ] 63 | ) 64 | """ 65 | 66 | XCTAssertEqual(result, expectedResult) 67 | } 68 | 69 | func testUpdateExistingFile() throws { 70 | var artifact: CachedArtifact = try .mock(name: "Product1", parentName: "Package1") 71 | 72 | let existingFile = """ 73 | // swift-tools-version:5.3 74 | import PackageDescription 75 | 76 | let package = Package( 77 | name: "TestPackage", 78 | platforms: [ 79 | .iOS(.v12) 80 | ], 81 | products: [ 82 | .library(name: "Product1", targets: ["Product1"]), 83 | .library(name: "Product2", targets: ["Product2"]), 84 | .library(name: "Product3", targets: ["Product3"]) 85 | ], 86 | targets: [ 87 | .binaryTarget( 88 | name: "Product1", 89 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 90 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 91 | ), 92 | .binaryTarget( 93 | name: "Product2", 94 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 95 | checksum: "909a4b17649cd39ca05e33be0f7a5e60d34e272b918058bd57ed19a4afc21549" 96 | ), 97 | .binaryTarget( 98 | name: "Product3", 99 | url: "https://scipio.test/packages/Product3/Product3.xcframework.zip", 100 | checksum: "5be156b10ff7c684ff48c59a085560e347253a01d0093e47f122be9ed80f7646" 101 | ) 102 | ] 103 | ) 104 | """ 105 | var file = try SwiftPackageFile( 106 | name: "TestPackage", 107 | path: path, 108 | platforms: [.iOS: "12.0"], 109 | artifacts: [ 110 | artifact, 111 | .mock(name: "Product2", parentName: "Package1"), 112 | .mock(name: "Product3", parentName: "Package2"), 113 | ], 114 | removeMissing: true 115 | ) 116 | 117 | XCTAssertEqual(existingFile, file.asString(relativeTo: path.parent())) 118 | 119 | try artifact.localPath!.write("new file contents") 120 | artifact = try CachedArtifact(name: artifact.name, parentName: artifact.parentName, url: artifact.url, localPath: artifact.localPath!) 121 | file.artifacts[0] = artifact 122 | try file.read() 123 | 124 | let result = file.asString(relativeTo: path.parent()) 125 | let expectedResult = """ 126 | // swift-tools-version:5.3 127 | import PackageDescription 128 | 129 | let package = Package( 130 | name: "TestPackage", 131 | platforms: [ 132 | .iOS(.v12) 133 | ], 134 | products: [ 135 | .library(name: "Product1", targets: ["Product1"]), 136 | .library(name: "Product2", targets: ["Product2"]), 137 | .library(name: "Product3", targets: ["Product3"]) 138 | ], 139 | targets: [ 140 | .binaryTarget( 141 | name: "Product1", 142 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 143 | checksum: "428189279ce0f27c1e26d0555538043bae351115e3795b1d3ffcb2948de131dd" 144 | ), 145 | .binaryTarget( 146 | name: "Product2", 147 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 148 | checksum: "909a4b17649cd39ca05e33be0f7a5e60d34e272b918058bd57ed19a4afc21549" 149 | ), 150 | .binaryTarget( 151 | name: "Product3", 152 | url: "https://scipio.test/packages/Product3/Product3.xcframework.zip", 153 | checksum: "5be156b10ff7c684ff48c59a085560e347253a01d0093e47f122be9ed80f7646" 154 | ) 155 | ] 156 | ) 157 | """ 158 | 159 | XCTAssertEqual(result, expectedResult) 160 | } 161 | 162 | func testUpdateExistingFileWithOnlyOneArtifact() throws { 163 | let existingFile = """ 164 | // swift-tools-version:5.3 165 | import PackageDescription 166 | 167 | let package = Package( 168 | name: "TestPackage", 169 | platforms: [ 170 | .iOS(.v12) 171 | ], 172 | products: [ 173 | .library(name: "Product1", targets: ["Product1"]), 174 | .library(name: "Product2", targets: ["Product2"]), 175 | .library(name: "Product3", targets: ["Product3"]) 176 | ], 177 | targets: [ 178 | .binaryTarget( 179 | name: "Product1", 180 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 181 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 182 | ), 183 | .binaryTarget( 184 | name: "Product2", 185 | url: "https://scipio.test/packages/Product1/Product2.xcframework.zip", 186 | checksum: "7324b29c1b0d61131adfa0035669a1717761f2b211fadb2be2dd2ea9a4396a7b" 187 | ), 188 | .binaryTarget( 189 | name: "Product3", 190 | url: "https://scipio.test/packages/Product2/Product3.xcframework.zip", 191 | checksum: "909a4b17649cd39ca05e33be0f7a5e60d34e272b918058bd57ed19a4afc21549" 192 | ) 193 | ] 194 | ) 195 | """ 196 | try path.write(existingFile) 197 | let file = try SwiftPackageFile( 198 | name: "TestPackage", 199 | path: path, 200 | platforms: [.iOS: "12.0"], 201 | artifacts: [ 202 | .mock(name: "Product1", parentName: "Package1"), 203 | ], 204 | removeMissing: false 205 | ) 206 | let result = file.asString(relativeTo: path.parent()) 207 | let expectedResult = """ 208 | // swift-tools-version:5.3 209 | import PackageDescription 210 | 211 | let package = Package( 212 | name: "TestPackage", 213 | platforms: [ 214 | .iOS(.v12) 215 | ], 216 | products: [ 217 | .library(name: "Product1", targets: ["Product1"]), 218 | .library(name: "Product2", targets: ["Product2"]), 219 | .library(name: "Product3", targets: ["Product3"]) 220 | ], 221 | targets: [ 222 | .binaryTarget( 223 | name: "Product1", 224 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 225 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 226 | ), 227 | .binaryTarget( 228 | name: "Product2", 229 | url: "https://scipio.test/packages/Product1/Product2.xcframework.zip", 230 | checksum: "7324b29c1b0d61131adfa0035669a1717761f2b211fadb2be2dd2ea9a4396a7b" 231 | ), 232 | .binaryTarget( 233 | name: "Product3", 234 | url: "https://scipio.test/packages/Product2/Product3.xcframework.zip", 235 | checksum: "909a4b17649cd39ca05e33be0f7a5e60d34e272b918058bd57ed19a4afc21549" 236 | ) 237 | ] 238 | ) 239 | """ 240 | 241 | XCTAssertEqual(result, expectedResult) 242 | } 243 | 244 | func testOnlyAddNewArtifact() throws { 245 | let existingFile = """ 246 | // swift-tools-version:5.3 247 | import PackageDescription 248 | 249 | let package = Package( 250 | name: "TestPackage", 251 | platforms: [ 252 | .iOS(.v12) 253 | ], 254 | products: [ 255 | .library(name: "Product1", targets: ["Product1"]), 256 | .library(name: "Product2", targets: ["Product2"]) 257 | ], 258 | targets: [ 259 | .binaryTarget( 260 | name: "Product1", 261 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 262 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 263 | ), 264 | .binaryTarget( 265 | name: "Product2", 266 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 267 | checksum: "7324b29c1b0d61131adfa0035669a1717761f2b211fadb2be2dd2ea9a4396a7b" 268 | ) 269 | ] 270 | ) 271 | """ 272 | try path.write(existingFile) 273 | let file = try SwiftPackageFile( 274 | name: "TestPackage", 275 | path: path, 276 | platforms: [.iOS: "12.0"], 277 | artifacts: [ 278 | .mock(name: "Product3", parentName: "Package1"), 279 | ], 280 | removeMissing: false 281 | ) 282 | let result = file.asString(relativeTo: path.parent()) 283 | let expectedResult = """ 284 | // swift-tools-version:5.3 285 | import PackageDescription 286 | 287 | let package = Package( 288 | name: "TestPackage", 289 | platforms: [ 290 | .iOS(.v12) 291 | ], 292 | products: [ 293 | .library(name: "Product1", targets: ["Product1"]), 294 | .library(name: "Product2", targets: ["Product2"]), 295 | .library(name: "Product3", targets: ["Product3"]) 296 | ], 297 | targets: [ 298 | .binaryTarget( 299 | name: "Product1", 300 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 301 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 302 | ), 303 | .binaryTarget( 304 | name: "Product2", 305 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 306 | checksum: "7324b29c1b0d61131adfa0035669a1717761f2b211fadb2be2dd2ea9a4396a7b" 307 | ), 308 | .binaryTarget( 309 | name: "Product3", 310 | url: "https://scipio.test/packages/Product3/Product3.xcframework.zip", 311 | checksum: "8e5dc7eca162a90a3fe83caf53add89c3d977385c2de29045521ccfb45319481" 312 | ) 313 | ] 314 | ) 315 | """ 316 | 317 | XCTAssertEqual(result, expectedResult) 318 | } 319 | 320 | func testAddNewArtifact() throws { 321 | let existingFile = """ 322 | // swift-tools-version:5.3 323 | import PackageDescription 324 | 325 | let package = Package( 326 | name: "TestPackage", 327 | platforms: [ 328 | .iOS(.v12) 329 | ], 330 | products: [ 331 | .library(name: "Product1", targets: ["Product1"]), 332 | .library(name: "Product2", targets: ["Product2"]) 333 | ], 334 | targets: [ 335 | .binaryTarget( 336 | name: "Product1", 337 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 338 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 339 | ), 340 | .binaryTarget( 341 | name: "Product2", 342 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 343 | checksum: "7324b29c1b0d61131adfa0035669a1717761f2b211fadb2be2dd2ea9a4396a7b" 344 | ) 345 | ] 346 | ) 347 | """ 348 | try path.write(existingFile) 349 | let file = try SwiftPackageFile( 350 | name: "TestPackage", 351 | path: path, 352 | platforms: [.iOS: "12.0"], 353 | artifacts: [ 354 | .mock(name: "Product1", parentName: "Package1"), 355 | .mock(name: "Product3", parentName: "Package1"), 356 | .mock(name: "Product2", parentName: "Package2"), 357 | ], 358 | removeMissing: true 359 | ) 360 | let result = file.asString(relativeTo: path.parent()) 361 | let expectedResult = """ 362 | // swift-tools-version:5.3 363 | import PackageDescription 364 | 365 | let package = Package( 366 | name: "TestPackage", 367 | platforms: [ 368 | .iOS(.v12) 369 | ], 370 | products: [ 371 | .library(name: "Product1", targets: ["Product1"]), 372 | .library(name: "Product2", targets: ["Product2"]), 373 | .library(name: "Product3", targets: ["Product3"]) 374 | ], 375 | targets: [ 376 | .binaryTarget( 377 | name: "Product1", 378 | url: "https://scipio.test/packages/Product1/Product1.xcframework.zip", 379 | checksum: "28b66030599490a87113433e84f39d788cdcb40be104fd00156d90cd8ec0656d" 380 | ), 381 | .binaryTarget( 382 | name: "Product2", 383 | url: "https://scipio.test/packages/Product2/Product2.xcframework.zip", 384 | checksum: "473c481f625984d3c917fdb7edfb213251653837c0fb10c927f0bbbaf7420ea7" 385 | ), 386 | .binaryTarget( 387 | name: "Product3", 388 | url: "https://scipio.test/packages/Product3/Product3.xcframework.zip", 389 | checksum: "8e5dc7eca162a90a3fe83caf53add89c3d977385c2de29045521ccfb45319481" 390 | ) 391 | ] 392 | ) 393 | """ 394 | 395 | XCTAssertEqual(result, expectedResult) 396 | } 397 | } 398 | --------------------------------------------------------------------------------