├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug-report.yml │ └── 2-feature-request.yml └── workflows │ ├── deploy.yml │ ├── milestone-assignment.yml │ ├── milestone-creation.yml │ └── weekly-auto-deploy.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .rubocop.yml ├── FUNDING.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── Makefile ├── README.md ├── VERSION ├── bin └── xccache ├── docs ├── README.md ├── case-study-kickstarter.md ├── configuration.md ├── contributing-guidelines.md ├── ex-viz │ ├── assets │ │ ├── cachemap.js │ │ └── style.css │ └── cachemap.html ├── features-roadmap.md ├── getting-started.md ├── how-to-install.md ├── overview.md ├── res │ ├── binary_macro.png │ ├── cachemap.png │ ├── kickstarter_clean_build.png │ ├── lockfile_add_new_dep.png │ ├── proxy_binary.png │ ├── proxy_local.png │ ├── resource_build_structure.png │ ├── resource_bundle_module.png │ ├── resource_bundle_overriding.png │ ├── switching_binary_to_source.gif │ ├── switching_binary_to_source.mp4 │ ├── umbrella_product_dependencies.png │ ├── unknown_deps_err.png │ ├── xccache.png │ └── xcode_process_xcframeworks.png ├── troubleshooting.md └── under-the-hood │ ├── ensuring-bundle-module.md │ ├── macro-as-binary.md │ ├── packaging-as-xcframework.md │ └── proxy-packages.md ├── examples ├── .gitignore ├── EX.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── EX.xcscheme ├── EX.xctestplan ├── EX │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── EX-Bridging-Header.h │ ├── EXApp.swift │ ├── Extensions.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── SPMPlayground.swift │ ├── SPMPlayground_ObjC.h │ └── SPMPlayground_ObjC.m ├── EXMac │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── EXMac.entitlements │ └── EXMacApp.swift ├── EXTests │ ├── ResourceTests.swift │ └── SPMPlayground.swift ├── LocalPackages │ ├── core-utils │ │ ├── Package.swift │ │ └── Sources │ │ │ ├── Core │ │ │ └── dummy.swift │ │ │ ├── DebugKitObjc │ │ │ ├── DebugKit.m │ │ │ ├── Diagnose.m │ │ │ ├── Headers │ │ │ │ └── DebugKit.h │ │ │ ├── PrivateHeaders │ │ │ │ └── Diagnose.h │ │ │ └── token.txt │ │ │ ├── DisplayKit │ │ │ └── DisplayKit.swift │ │ │ ├── ResourceKit │ │ │ ├── ResourceKit.swift │ │ │ └── greetings.txt │ │ │ ├── Swizzler │ │ │ ├── Swizzler.m │ │ │ └── include │ │ │ │ └── Swizzler.h │ │ │ └── TestKit │ │ │ └── TestKit.swift │ └── wizard │ │ ├── Package.swift │ │ └── Sources │ │ ├── Wizard │ │ └── Wizard.swift │ │ ├── WizardImpl │ │ ├── HexColorMacro.swift │ │ └── WizardImpl.swift │ │ └── WizardPlayground │ │ └── main.swift ├── Makefile ├── config.xcconfig ├── xccache.lock └── xccache.yml ├── lib ├── xccache.rb └── xccache │ ├── assets │ └── templates │ │ ├── cachemap.html.template │ │ ├── cachemap.js.template │ │ ├── cachemap.style.css.template │ │ ├── framework.info.plist.template │ │ ├── framework.modulemap.template │ │ ├── resource_bundle_accessor.m.template │ │ ├── resource_bundle_accessor.swift.template │ │ └── xccache.yml.template │ ├── cache │ └── cachemap.rb │ ├── command.rb │ ├── command │ ├── base.rb │ ├── build.rb │ ├── cache.rb │ ├── cache │ │ ├── clean.rb │ │ └── list.rb │ ├── off.rb │ ├── pkg.rb │ ├── pkg │ │ └── build.rb │ ├── remote.rb │ ├── remote │ │ ├── pull.rb │ │ └── push.rb │ ├── rollback.rb │ └── use.rb │ ├── core.rb │ ├── core │ ├── cacheable.rb │ ├── config.rb │ ├── error.rb │ ├── git.rb │ ├── hash.rb │ ├── live_log.rb │ ├── lockfile.rb │ ├── log.rb │ ├── parallel.rb │ ├── sh.rb │ ├── syntax.rb │ ├── syntax │ │ ├── hash.rb │ │ ├── json.rb │ │ ├── plist.rb │ │ └── yml.rb │ └── system.rb │ ├── installer.rb │ ├── installer │ ├── build.rb │ ├── integration.rb │ ├── integration │ │ ├── build.rb │ │ ├── descs.rb │ │ ├── supporting_files.rb │ │ └── viz.rb │ ├── rollback.rb │ └── use.rb │ ├── main.rb │ ├── spm.rb │ ├── spm │ ├── build.rb │ ├── desc.rb │ ├── desc │ │ ├── base.rb │ │ ├── dep.rb │ │ ├── desc.rb │ │ ├── product.rb │ │ ├── target.rb │ │ └── target │ │ │ ├── binary.rb │ │ │ └── macro.rb │ ├── macro.rb │ ├── mixin.rb │ ├── pkg.rb │ ├── pkg │ │ ├── base.rb │ │ ├── proxy.rb │ │ └── proxy_executable.rb │ ├── xcframework.rb │ └── xcframework │ │ ├── metadata.rb │ │ ├── slice.rb │ │ └── xcframework.rb │ ├── storage.rb │ ├── storage │ ├── base.rb │ ├── git.rb │ └── s3.rb │ ├── swift │ ├── sdk.rb │ └── swiftc.rb │ ├── utils │ └── template.rb │ ├── xcodeproj.rb │ └── xcodeproj │ ├── build_configuration.rb │ ├── config.rb │ ├── file_system_synchronized_root_group.rb │ ├── group.rb │ ├── pkg.rb │ ├── pkg_product_dependency.rb │ ├── project.rb │ └── target.rb └── xccache.gemspec /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report (if something is not working properly) 3 | title: "[Bug] " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this form! 10 | 11 | Make sure you've searched [existing issues](https://github.com/trinhngocthuyen/xccache/issues) for the bug you encountered. 12 | - type: textarea 13 | attributes: 14 | label: What happened? 15 | description: Also tell us, what did you expect to happen? 16 | placeholder: Tell us what you see! Remember to run `xccache` with `--verbose` for more useful logs 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Anything else? 22 | description: | 23 | Links? References? Anything that will give us more context about the issue you are encountering! 24 | 25 | 💡 Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a feature (if something can be improved, to make the plugin better) 3 | title: "[Feature Request] <title>" 4 | labels: ["feature-request"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Motivation 9 | description: Share with us why we should have this change. What does it benefit the community? 10 | placeholder: Tell us what you think! 11 | validations: 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Summary 16 | description: Share with us what are the expected behaviors with this request. 17 | placeholder: Tell us what you think! 18 | validations: 19 | required: true 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | deploy-rc: 6 | description: Deploy a RC version 7 | type: boolean 8 | deploy-stable: 9 | description: Deploy a stable version 10 | type: boolean 11 | 12 | jobs: 13 | deploy: 14 | uses: trinhngocthuyen/gh-actions/.github/workflows/rb-deploy.yml@main 15 | with: 16 | deploy-rc: ${{ inputs.deploy-rc }} 17 | deploy-stable: ${{ inputs.deploy-stable }} 18 | secrets: inherit 19 | -------------------------------------------------------------------------------- /.github/workflows/milestone-assignment.yml: -------------------------------------------------------------------------------- 1 | name: milestone-assignment 2 | on: 3 | pull_request: 4 | types: ["closed"] 5 | 6 | jobs: 7 | milestone-assignment: 8 | if: github.event.pull_request.merged == true 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | pull-requests: write 13 | issues: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" 17 | id: calc_version 18 | shell: bash 19 | - uses: trinhngocthuyen/gh-actions/gh/pr/assign_milestone@main 20 | with: 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | title: ${{ steps.calc_version.outputs.version }} 23 | -------------------------------------------------------------------------------- /.github/workflows/milestone-creation.yml: -------------------------------------------------------------------------------- 1 | name: milestone-creation 2 | on: 3 | release: 4 | types: ["published"] 5 | 6 | jobs: 7 | create-milestone: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Bump version 13 | uses: trinhngocthuyen/gh-actions/core/bump_version@main 14 | id: bump_version 15 | with: 16 | version-file: VERSION 17 | - name: Create new milestone 18 | uses: trinhngocthuyen/gh-actions/gh/milestone/create@main 19 | with: 20 | github-token: ${{ secrets.GH_TOKEN }} 21 | title: ${{ steps.bump_version.outputs.version }} 22 | - name: Close prev milestone 23 | uses: trinhngocthuyen/gh-actions/gh/milestone/close@main 24 | with: 25 | github-token: ${{ secrets.GH_TOKEN }} 26 | title: ${{ steps.bump_version.outputs.prev_version }} 27 | -------------------------------------------------------------------------------- /.github/workflows/weekly-auto-deploy.yml: -------------------------------------------------------------------------------- 1 | name: weekly-auto-deploy 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * 0' # 12AM, Sunday (UTC) 6 | 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - run: | 13 | echo "VERSION=$(cat VERSION)" >> $GITHUB_ENV 14 | shell: bash 15 | - uses: actions/github-script@v7 16 | env: 17 | VERSION: ${{ env.VERSION }} 18 | with: 19 | github-token: ${{ secrets.GH_TOKEN }} 20 | script: | 21 | const { VERSION } = process.env; 22 | const title = VERSION; 23 | const milestones = await github.rest.issues.listMilestones({ 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | }); 27 | const milestone = milestones.data.find(m => m.title === title); 28 | if (!milestone) { 29 | console.error(`Milestone '${title}' not found.`); 30 | return; 31 | } 32 | console.log(`There are ${milestone.closed_issues} associated PRs/issues`); 33 | if (milestone.closed_issues <= 0) { 34 | console.log('No associated PRs/issues -> Do nothing'); 35 | return; 36 | } 37 | console.log('Will trigger deploy workflow...'); 38 | await github.rest.actions.createWorkflowDispatch({ 39 | owner: context.repo.owner, 40 | repo: context.repo.repo, 41 | workflow_id: ".github/workflows/deploy.yml", 42 | ref: "main", 43 | inputs: {"deploy-stable": true} 44 | }); 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | .env 13 | 14 | # Used by dotenv library to load environment variables. 15 | # .env 16 | 17 | # Ignore Byebug command history file. 18 | .byebug_history 19 | 20 | ## Specific to RubyMotion: 21 | .dat* 22 | .repl_history 23 | build/ 24 | *.bridgesupport 25 | build-iPhoneOS/ 26 | build-iPhoneSimulator/ 27 | 28 | ## Specific to RubyMotion (use of CocoaPods): 29 | # 30 | # We recommend against adding the Pods directory to your .gitignore. However 31 | # you should judge for yourself, the pros and cons are mentioned at: 32 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 33 | # 34 | # vendor/Pods/ 35 | 36 | ## Documentation cache and generated files: 37 | /.yardoc/ 38 | /_yardoc/ 39 | /doc/ 40 | /rdoc/ 41 | 42 | ## Environment normalization: 43 | /.bundle/ 44 | /vendor/bundle 45 | /lib/bundler/man/ 46 | 47 | # for a library or gem, you might want to ignore these files since the code is 48 | # intended to run in multiple environments; otherwise, check them in: 49 | # Gemfile.lock 50 | # .ruby-version 51 | # .ruby-gemset 52 | 53 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 54 | .rvmrc 55 | 56 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 57 | .rubocop-https?--* 58 | 59 | .vscode/ 60 | libexec/.local/ 61 | libexec/.build/ 62 | libexec/.download/ 63 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tools/xccache-proxy"] 2 | path = tools/xccache-proxy 3 | url = git@github.com:trinhngocthuyen/xccache-proxy.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^(VERSION|tools|.*\.xcframework|.*\.lock|.*\.json) 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.5.0 5 | hooks: 6 | - id: check-yaml 7 | - id: check-json 8 | - id: check-toml 9 | - id: check-xml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/rubocop/rubocop 13 | rev: v1.75.2 14 | hooks: 15 | - id: rubocop 16 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: https://raw.githubusercontent.com/trinhngocthuyen/templates/main/config/rubocop/default.yml 2 | 3 | Style/ParallelAssignment: 4 | Enabled: false 5 | Style/TrailingCommaInArguments: 6 | Enabled: false 7 | 8 | Lint/EmptyClass: 9 | Enabled: false 10 | 11 | Layout/EmptyLineAfterGuardClause: 12 | Enabled: false 13 | 14 | Naming/PredicateName: 15 | Enabled: false 16 | Naming/MethodParameterName: 17 | MinNameLength: 1 18 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: trinhngocthuyen 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem "bundler", "> 1.3" 7 | gem "pry-nav" 8 | gem "rspec" 9 | gem "rubocop" 10 | end 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | xccache (1.0.5) 5 | claide 6 | parallel 7 | tty-cursor 8 | tty-screen 9 | xcodeproj (>= 1.26.0) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | CFPropertyList (3.0.7) 15 | base64 16 | nkf 17 | rexml 18 | ast (2.4.3) 19 | atomos (0.1.3) 20 | base64 (0.2.0) 21 | claide (1.1.0) 22 | coderay (1.1.3) 23 | colored2 (3.1.2) 24 | diff-lcs (1.6.1) 25 | json (2.10.2) 26 | language_server-protocol (3.17.0.4) 27 | lint_roller (1.1.0) 28 | method_source (1.1.0) 29 | nanaimo (0.4.0) 30 | nkf (0.2.0) 31 | parallel (1.26.3) 32 | parser (3.3.7.3) 33 | ast (~> 2.4.1) 34 | racc 35 | prism (1.4.0) 36 | pry (0.14.2) 37 | coderay (~> 1.1) 38 | method_source (~> 1.0) 39 | pry-nav (1.0.0) 40 | pry (>= 0.9.10, < 0.15) 41 | racc (1.8.1) 42 | rainbow (3.1.1) 43 | regexp_parser (2.10.0) 44 | rexml (3.4.1) 45 | rspec (3.13.0) 46 | rspec-core (~> 3.13.0) 47 | rspec-expectations (~> 3.13.0) 48 | rspec-mocks (~> 3.13.0) 49 | rspec-core (3.13.3) 50 | rspec-support (~> 3.13.0) 51 | rspec-expectations (3.13.3) 52 | diff-lcs (>= 1.2.0, < 2.0) 53 | rspec-support (~> 3.13.0) 54 | rspec-mocks (3.13.2) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.13.0) 57 | rspec-support (3.13.2) 58 | rubocop (1.75.1) 59 | json (~> 2.3) 60 | language_server-protocol (~> 3.17.0.2) 61 | lint_roller (~> 1.1.0) 62 | parallel (~> 1.10) 63 | parser (>= 3.3.0.2) 64 | rainbow (>= 2.2.2, < 4.0) 65 | regexp_parser (>= 2.9.3, < 3.0) 66 | rubocop-ast (>= 1.43.0, < 2.0) 67 | ruby-progressbar (~> 1.7) 68 | unicode-display_width (>= 2.4.0, < 4.0) 69 | rubocop-ast (1.43.0) 70 | parser (>= 3.3.7.2) 71 | prism (~> 1.4) 72 | ruby-progressbar (1.13.0) 73 | tty-cursor (0.7.1) 74 | tty-screen (0.8.2) 75 | unicode-display_width (3.1.4) 76 | unicode-emoji (~> 4.0, >= 4.0.4) 77 | unicode-emoji (4.0.4) 78 | xcodeproj (1.27.0) 79 | CFPropertyList (>= 2.3.3, < 4.0) 80 | atomos (~> 0.1.3) 81 | claide (>= 1.0.2, < 2.0) 82 | colored2 (~> 3.1) 83 | nanaimo (~> 0.4.0) 84 | rexml (>= 3.3.6, < 4.0) 85 | 86 | PLATFORMS 87 | arm64-darwin-22 88 | ruby 89 | 90 | DEPENDENCIES 91 | bundler (> 1.3) 92 | pry-nav 93 | rspec 94 | rubocop 95 | xccache! 96 | 97 | BUNDLED WITH 98 | 2.5.21 99 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Thuyen Trinh <trinhngocthuyen@gmail.com> 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | which pre-commit &> /dev/null || pip3 install pre-commit 3 | pre-commit install 4 | bundle install 5 | 6 | format: 7 | pre-commit run --all-files 8 | 9 | test: 10 | bundle exec rspec 11 | 12 | ex.%: 13 | cd examples && make $* 14 | 15 | proxy.build: 16 | mkdir -p libexec/.local 17 | cd tools/xccache-proxy && make build CONFIGURATION=release 18 | cp tools/xccache-proxy/.build/release/xccache-proxy libexec/.local/ 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![xccache](docs/res/xccache.png) 2 | 3 | # Yet another caching tool for Xcode projects but with SPM support 4 | 5 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/trinhngocthuyen/xccache/blob/main/LICENSE.txt) 6 | [![Gem](https://img.shields.io/gem/v/xccache.svg)](https://rubygems.org/gems/xccache) 7 | 8 | ## 🎯 Motivation 9 | Caching frameworks is a popular technique to tackle project build time in many large-scale projects. A few outstanding tools in this category are cocoapods-binary-cache, Rugby, and XCRemoteCache. Rugby and cocoapods-binary-cache are particularly tailored for CocoaPods-based projects while XCRemoteCache works for a more general project structure. However, all three of them has the same limitation: **lacking support for SPM/Swift packages**. 10 | 11 | This **xccache** tool attempts to bridge that gap.\ 12 | The long-term vision of this initiative is to make it a unified caching tool for iOS projects, including CocoaPods-based structures. 13 | 14 | ## 🔧 Installation 15 | Via [Bundler](https://bundler.io): Add the gem `xccache` to the Gemfile of your project. 16 | 17 | ```rb 18 | gem "xccache" 19 | ``` 20 | 21 | Via [RubyGems](https://rubygems.org): 22 | ```sh 23 | $ gem install xccache 24 | ``` 25 | 26 | ## 🚀 Getting Started 27 | Check out this doc: [Getting Started](docs/getting-started.md) 28 | 29 | TLDR: Simply run `xccache` to set it up. Run `xccache --help` to explore the usages. 30 | 31 | If caches are in place, you should see this in Xcode build logs. 32 | 33 | <img src="docs/res/xcode_process_xcframeworks.png" width="580px"> 34 | 35 | #### Case Study: For Kickstarter iOS project 36 | 37 | 👉🏻 Check it out: [here](docs/case-study-kickstarter.md) 🎉 38 | 39 | 40 | ## 📑 Documentation 41 | 42 | Check out these docs to understand more about xccache: 43 | 44 | - [🔧 How to Install](docs/how-to-install.md) 45 | - [📝 Overview](docs/overview.md) 46 | - [🚀 Getting Started](docs/getting-started.md) 47 | - [📖 Under the Hood](docs/under-the-hood) 48 | - [Packaging as an xcframework](docs/under-the-hood/packaging-as-xcframework.md) 49 | - [Ensuring `Bundle.module` When Accessing Resources](docs/under-the-hood/ensuring-bundle-module.md) 50 | - [Macro as Binary](docs/under-the-hood/macro-as-binary.md) 51 | - [Proxy Packages](docs/under-the-hood/proxy-packages.md) 52 | - [🩺 Troubleshooting](docs/troubleshooting.md) 53 | - [✍🏼 Case Study: Using XCCache in Kickstarter iOS Project](docs/case-study-kickstarter.md) 54 | 55 | ## 📌 Features and Roadmap 56 | 57 | Check out this doc: [Features and Roadmap](docs/features-roadmap.md) 58 | 59 | ## 🤝 Contribution 60 | Refer to the [contributing guidelines](docs/contributing-guidelines.md) for details. 61 | 62 | ## ⚖️ License 63 | The tool is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 64 | 65 | ## ✍️ Acknowledgement 66 | - [Cytoscape.js](https://github.com/cytoscape/cytoscape.js) for the cachemap visualization 67 | 68 | ## ⭐ Support 69 | If you find this project interesting and useful, keep me going by leaving a star ⭐, sharing the project, or [buying me a coffee](https://buymeacoffee.com/trinhngocthuyen) 🫶. 70 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.5 2 | -------------------------------------------------------------------------------- /bin/xccache: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "xccache" 3 | 4 | XCCache::Command.run(ARGV) 5 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | [< 🏠 Github](https://github.com/trinhngocthuyen/xccache) 2 | 3 | # 📚 XCCache Knowledge Base 4 | ![xccache](res/xccache.png) 5 | Yet another caching tool for Xcode projects but with SPM support 6 | 7 | ## 🎯 Motivation 8 | Caching frameworks is a popular technique to tackle project build time in many large-scale projects. A few outstanding tools in this category are cocoapods-binary-cache, Rugby, and XCRemoteCache. Rugby and cocoapods-binary-cache are particularly tailored for CocoaPods-based projects while XCRemoteCache works for a more general project structure. However, all three of them has the same limitation: **lacking support for SPM/Swift packages**. 9 | 10 | This **xccache** tool attempts to bridge that gap.\ 11 | The long-term vision of this initiative is to make it a unified caching tool for iOS projects, including CocoaPods-based structures. 12 | 13 | ## 📑 Documentation 14 | 15 | Check out these docs to understand more about xccache: 16 | 17 | - [🔧 How to Install](how-to-install.md) 18 | - [📝 Overview](overview.md) 19 | - [🚀 Getting Started](getting-started.md) 20 | - [📖 Under the Hood](under-the-hood) 21 | - [Packaging as an xcframework](under-the-hood/packaging-as-xcframework.md) 22 | - [Ensuring `Bundle.module` When Accessing Resources](under-the-hood/ensuring-bundle-module.md) 23 | - [Macro as Binary](under-the-hood/macro-as-binary.md) 24 | - [Proxy Packages](under-the-hood/proxy-packages.md) 25 | - [🩺 Troubleshooting](troubleshooting.md) 26 | - [✍🏼 Case Study: Using XCCache in Kickstarter iOS Project](case-study-kickstarter.md) 27 | 28 | ## 📌 Features and Roadmap 29 | Check out this doc: [Features and Roadmap](features-roadmap.md) 30 | 31 | ## 🤝 Contribution 32 | Refer to the [contributing guidelines](contributing-guidelines.md) for details. 33 | 34 | ## ⚖️ License 35 | The tool is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 36 | 37 | ## ✍️ Acknowledgement 38 | - [Cytoscape.js](https://github.com/cytoscape/cytoscape.js) for the cachemap visualization 39 | 40 | ## ⭐ Support 41 | If you find this project interesting and useful, keep me going by leaving a star ⭐, sharing the project, or [buying me a coffee](https://buymeacoffee.com/trinhngocthuyen) 🫶. 42 | -------------------------------------------------------------------------------- /docs/case-study-kickstarter.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # ✍🏼 Case Study: Using XCCache in Kickstarter iOS Project 4 | 5 | Let’s try this xccache tool with the [Kickstarter iOS](https://github.com/kickstarter/ios-oss) project. 6 | 7 | I forked the project to [trinhngocthuyen/kickstarter-ios](https://github.com/trinhngocthuyen/kickstarter-ios). Check out the branch [try/xccache](https://github.com/trinhngocthuyen/kickstarter-ios/tree/try/xccache) for the detailed integration. 8 | 9 | Following are the steps needed for the integration. 10 | 11 | #### Step 1. Add `xccache` to Gemfile 12 | 13 | Then, run `bundle install` to have it installed. 14 | 15 | #### Step 2. [Optional] Using existing remote cache 16 | 17 | Specify the git repo for the cache in [`xccache.yml`](https://github.com/trinhngocthuyen/kickstarter-ios/blob/try/xccache/xccache.yml). 18 | 19 | ```yaml 20 | remote: 21 | default: 22 | git: https://github.com/trinhngocthuyen/.cache.git 23 | ``` 24 | 25 | Then, pull the cache from the given repo: 26 | 27 | ```bash 28 | bundle exec xccache remote pull 29 | ``` 30 | 31 | Now, the cache should be available in `~/.xccache/debug`. 32 | ``` 33 | $ tree ~/.xccache/debug -L 2 34 | 35 | /Users/thuyen/.xccache/debug 36 | ├── Alamofire 37 | │   └── Alamofire-513364f8.xcframework 38 | ├── AlamofireImage 39 | │   └── AlamofireImage-1eaf3b6.xcframework 40 | ├── Apollo 41 | │   └── Apollo-5db23797b.xcframework 42 | ├── ApolloAPI 43 | │   └── ApolloAPI-5db23797b.xcframework 44 | ├── ApolloUtils 45 | │   └── ApolloUtils-5db23797b.xcframework 46 | ├── AppboyKit 47 | │   └── AppboyKit-a3511ca.xcframework 48 | ├── AppboySegment 49 | │   └── AppboySegment-dc659b7.xcframework 50 | ├── AppboyUI 51 | │   └── AppboyUI-a3511ca.xcframework 52 | ``` 53 | 54 | #### Step 3. Run `bundle exec xccache` to integrate the cache 55 | 56 | There are some highlighting changes as follows: 57 | 58 | - A new file: [`xccache.lock`](https://github.com/trinhngocthuyen/kickstarter-ios/blob/try/xccache/xccache.lock) that captures the dependencies in the project. 59 | - Changes in xcodeproj files (see: [here](https://github.com/trinhngocthuyen/kickstarter-ios/commit/7520c590e067d08661bc985a035e1a5576ab7208#diff-9cb89939ff9e9815f0bcf171699ed9e3090ae718529ada6e606566b32cdd42adR116)): 60 | - A special package (xccache/packages/umbrella) is added 61 | - Packages and their product dependencies are removed from xcodeproj. 62 | Don’t worry, you can still use those products though. 63 | 64 | #### Step 4. Trigger “Resolve Package Versions” 65 | 66 | Tip: You should trigger this after running xccache command because Xcode doesn’t auto-resolve dependencies upon changes in xccache’s package manifest. 67 | 68 | #### Step 5. Try a clean build 69 | 70 | And observe the build time when having cache. 71 | The observed buid time on my Macbook Air (M1, 2020) is just nearly **2 minutes** 🎉. 72 | 73 | <img src="res/kickstarter_clean_build.png" width="600px"> 74 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # ⚙️ Configuration 4 | 5 | The behavior of xccache can be controlled via a `xccache.yml` configuration file under the root project directory. Example: [xccache.yml](/examples/xccache.yml). 6 | 7 | Following are some available configurations. 8 | 9 | ### `ignore_local` 10 | - Default: `false` 11 | - Whether to ignore local packages. 12 | 13 | ### `ignore` 14 | - Default: `[]` 15 | - Swift package targets to ignore, in patterns. 16 | ```yml 17 | ignore: 18 | - core-utils/* 19 | ``` 20 | > [!NOTE] 21 | > These patterns apply to targets, not products. 22 | 23 | ### `keep_pkgs_in_project` 24 | - Default: `false` 25 | - Whether to keep or remove packages from xcodeproj. By default, packages managed by xccache will be removed from xcodeproj in order to reduce time for package resolution in Xcode. 26 | 27 | ### `ignore_build_errors` 28 | - Default: `false` 29 | - Whether to ignore build errors in `xccache build`. This option might be useful when building multiple targets and one of them fails, with this option as `true`, the tool still continues building other targets. 30 | 31 | ### `default_sdk` 32 | - Default: `iphonesimulator` 33 | - The default sdk to use. Valid values: `iphonesimulator`, `iphoneos`, `macos`, `appletvos`, `appletvsimulator`, `watchos`, `watchsimulator`, `xros`, `xrsimulator`. 34 | 35 | ### `remote` 36 | - The remote cache configuration (using Git, S3, etc.). 37 | 38 | NOTE: This configuration is per build/install configuration (debug/release), as follows. 39 | 40 | **Using Git** 41 | ```yml 42 | remote: 43 | debug: # remote cache config for debug & release 44 | git: git@github.com/org/cache 45 | ``` 46 | 47 | **Using S3** 48 | ```yml 49 | remote: 50 | debug: # remote cache config for debug 51 | git: https://github.com/trinhngocthuyen/.cache.git 52 | release: # remote cache config for release 53 | s3: 54 | uri: "s3://xccache/binaries" 55 | creds: "path/to/aws_creds.json" 56 | ``` 57 | - `s3:uri`: The S3 URI, ex. `s3://xccache/binaries` 58 | - `s3:creds`: The path to the json credentials (default: `~/.xccache/s3.creds.json`). This json contains the access key id and secret access key as follows: 59 | ```json 60 | { 61 | "access_key": "YOUR_KEY_ID", 62 | "secret_access_key": "YOUR_ACCESS_KEY" 63 | } 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/contributing-guidelines.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # 🤝 Contributing 4 | 5 | You are more than welcome to contribute to the project in various ways: 6 | - Implement features 7 | - Fix bugs 8 | - Write tests 9 | - Write documentation 10 | 11 | The following section describes the development workflow when contributing to the project. 12 | 13 | ## Development Workflow 14 | 15 | **Step 1. Clone the project** 16 | 17 | ```sh 18 | git clone https://github.com/trinhngocthuyen/xccache.git && cd xccache 19 | ``` 20 | 21 | **Step 2. Install dependencies** 22 | 23 | ```sh 24 | make install 25 | ``` 26 | 27 | **Step 3. Make changes** 28 | 29 | You can try out your changes with the example project at `examples`. 30 | 31 | **Step 4. Format changes** 32 | 33 | This project is using `pre-commit` (which is installed in step 2) to lint & format changes.\ 34 | By default, pre-commit auto lints and formats your changes. Therefore, make sure step 2 succeeded.\ 35 | In case you want to trigger the format, simply run `make format`. 36 | 37 | **Step 5: Commit changes and create pull requests** 38 | 39 | ### xccache-proxy 40 | 41 | [xccache-proxy](https://github.com/trinhngocthuyen/xccache-proxy) is an internal tool (at: tools/xccache-proxy) used for generating proxy packages (to manipulate cache). 42 | 43 | Its binary is downloaded from remote, to `libexec/.download/<version>/xccache-proxy`. When there's a binary at `libexec/.local/xccache-proxy`, this binary is picked up for the execution. 44 | 45 | Using the local xccache-proxy binary is preferred for local development. If you're making changes in xccache-proxy and want to test against this repo (xccache), simply build the binary by: 46 | ```sh 47 | make proxy.build 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/ex-viz/assets/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #1492A0; 3 | --bg-color: color-mix(in srgb, var(--primary-color), white 80%); 4 | } 5 | body { 6 | font-family: Helvetica, Arial, sans-serif; 7 | font-size: 12px; 8 | margin: 0; 9 | line-height: 1.6; 10 | } 11 | a { color: var(--primary-color) } 12 | a:hover { color: #339966; } 13 | .fa-solid { color: var(--primary-color) } 14 | .container { 15 | display: flex; 16 | height: 100vh; 17 | } 18 | #cy { 19 | flex: 1; 20 | } 21 | #sidebar { 22 | position: relative; 23 | background-color: var(--bg-color); 24 | width: 250px; 25 | transition: all 0.3s ease; 26 | } 27 | .sidebar-content { 28 | width: calc(250px - 32px); 29 | padding: 16px; 30 | transform: translateX(0px); 31 | transition: all 0.3s ease; 32 | } 33 | #sidebar.collapsed { 34 | width: 0; 35 | } 36 | #sidebar.collapsed .sidebar-content{ 37 | transform: translateX(-250px); 38 | } 39 | #sidebar.collapsed .toggle-btn { 40 | right: -36px; 41 | transform: rotate(180deg); 42 | } 43 | .toggle-btn { 44 | position: absolute; 45 | top: 20px; 46 | right: 20px; 47 | z-index: 999; 48 | cursor: pointer; 49 | width: 16px; 50 | height: 16px; 51 | fill: var(--primary-color); 52 | transition: all 0.3s; 53 | } 54 | #sidebar .title { 55 | color: var(--primary-color); 56 | font-size: 16px; 57 | margin-top: 0; 58 | } 59 | #sidebar section { 60 | padding: 16px 0; 61 | } 62 | #sidebar .section-header { 63 | color: color-mix(in srgb, var(--primary-color), grey 20%); 64 | font-weight: bold; 65 | margin-block-end: 4px; 66 | } 67 | .node-info { 68 | display: none; 69 | } 70 | .metadata .info { 71 | font-size: 10px; 72 | } 73 | .info { 74 | color: #888; 75 | } 76 | .info .value { 77 | color: #666; 78 | } 79 | .footnote { 80 | color: #888; 81 | position: absolute; 82 | left: 16px; 83 | bottom: 8px; 84 | } 85 | .node { 86 | border-radius: 3px; 87 | padding: 1px 3px; 88 | color: white; 89 | background-color: var(--color) 90 | } 91 | .desc { color: var(--color) } 92 | .hit { --color: #339966 } 93 | .missed { --color: #ff6f00 } 94 | .ignored { --color: #888 } 95 | -------------------------------------------------------------------------------- /docs/ex-viz/cachemap.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 | <title>Cachemap Visualization 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 47 |
48 |
Powered by xccache
49 |
50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/features-roadmap.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # 📌 Features and Roadmap 4 | 5 | **Live features** 6 | - ✅ Local cache: building/using/rolling back cache 7 | - ✅ Cache visualization 8 | - ✅ Switching between binary cache and source code 9 | - ✅ Support for Swift macros 10 | - ✅ Remote cache: pulling/pushing cache with Git, S3 11 | 12 | **Planned features** 13 | - ☐ Support for CocoaPods-based projects 14 | - ☐ Support for Swift plugins 15 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # 🚀 Getting Started 4 | 5 |
6 | Table of Contents 7 | 39 |
40 | 41 | ## Quick Start 42 | 43 | [✍🏼 Case Study: Using XCCache in Kickstarter iOS Project](case-study-kickstarter.md) ← Read here. 44 | 45 | Simply run `xccache` under the root directory of the project. Then, you should see: 46 | - **`xccache.lock`**: containing the info about packages in the project alongside the products being used. You're recommended to track this file in git. 47 | - The `xccache` directory: containing build intermediates for the integration. This directory is similar to the `Pods` directory (in CocoaPods). Do NOT remove this directory. Instead, please ignore it from git. 48 | 49 | If you see product dependencies of a Swift package being removed from the *Link Binary With Libraries* section, it is expected.\ 50 | In return, this plugin adds another `.xccache` product which includes your product dependencies. 51 | 52 | 53 | 54 | Also, you may notice that all packages turn into local packages. This is perfectly normal as the tool creates special packages called *proxy packages* to manipulate cache of a package. 55 | 56 | 57 | 58 | ## Understanding the Tool 59 | Read the overview: [here](overview.md). 60 | 61 | Following are some docs about what happens under the hood: 62 | - [Packaging as an xcframework](under-the-hood/packaging-as-xcframework.md) 63 | - [Ensuring `Bundle.module` When Accessing Resources](under-the-hood/ensuring-bundle-module.md) 64 | - [Macro as Binary](under-the-hood/macro-as-binary.md) 65 | - [Proxy Packages](under-the-hood/proxy-packages.md) 66 | 67 | ## Working With Cache 68 | 69 | > [!TIP] 70 | > Use the `--help` option in the CLI to explore available (sub)commands and their supported options/flags. 71 | 72 | ### Building Cache 73 | 74 | To build cache of Swift packages, run `xccache build`. 75 | 76 | By default, the tool only builds cache-missed targets. To build specify targets, specify them in the arguments, for example: 77 | ```sh 78 | xccache build SwiftyBeaver SDWebImage 79 | ``` 80 | The prebuilt xcframeworks are available under `xccache/binaries`, following the structure as below: 81 | ``` 82 | xccache /-- binaries /-- SwiftyBeaver /-- SwiftyBeaver-.xcframework 83 | |-- SwiftyBeaver.xcframework 84 | ``` 85 | To build dependencies if cache-missed, use the `--recursive` option. For example, to build cache of `FirebaseCrashlytics` (including its dependencies): 86 | ```sh 87 | xccache build FirebaseCrashlytics --recursive 88 | ``` 89 | 90 | ### Using Cache 91 | Run `xccache use` or simply `xccache` to integrate cache to the project. Note that cache, after being built with `xccache build`, is automatically integrated to the project. You don't need to run `xccache use` in this case. 92 | 93 | In the Package Dependencies section in Xcode, you should notice a special package call `xccache`. 94 | The `binaries` directory of this package reflects the cache being used. For example, in the following image, `SwiftyBeaver` is integrated as binary. 95 | 96 | 97 | 98 | In Xcode build log, you should see xcframeworks of the cache-hit targets being processed by Xcode. 99 | 100 | 101 | ### Viewing Cachemap Visualization 102 | 103 | Whenever cache is integrated into your project (via `xccache`, `xccache use`, or `xccache build`), the tool generates an html (at `xccache/cachemap.html`) that visualizes the cache dependencies.\ 104 | Example: 👉 [ex-viz/cachemap](https://trinhngocthuyen.com/xccache/ex-viz/cachemap). 105 | 106 | Open this html in your browser to better understand the depenencies in your project. 107 | 108 | 109 | ### Switching Between Binary and Source Code 110 | 111 | By default, the tool attemtps to use cache if exists. In case you want to force-switch to source mode for specific targets, there are a few approaches you may consider: 112 | 113 | (1) Run `xccache off ` (ex. `xccache off DebugKit ResourceKit`).\ 114 | Note that the preferences set by this command is not persistent. This means, the next time you run `xccache`, those targets will not be remembered; cache will be integrated if exists. 115 | 116 | (2) If you're looking for a persistent preferences, consider adding them to the [`ignore`](configuration.md#ignore) list in the configuration file. 117 | 118 | (3) Or, you can simply just delete the cache, ex. `rm -rf ~/.xccache/debug/DebugKit`. 119 | 120 | > [!IMPORTANT] 121 | > After running any xccache command, remember to trigger resolving package versions again (File -> Packages -> Resolve Package Versions). Xcode doesn't automatically reload packages upon changes. 122 | 123 | ### Rolling Back Cache 124 | 125 | Run `xccache rollback`. This returns the project to the original state where product dependencies are specified in the *Link Binary With Libraries* section and `.xccache` is removed from this section. 126 | > [!WARNING] 127 | > Well, you're advised not to use this action if not necessary.\ 128 | > If you want to use source code entirely, consider *purging the cache* (`xccache cache clean --all`) instead. 129 | 130 | ### Multiplatform Cache 131 | 132 | An xcframework can include slices for multiple platforms. Use the `--sdk` option to specify the sdk (iphonesimulator, iphoneos, etc.) to use. If not specified, it uses the [`default_sdk`](configuration.md#default_sdk) configuration in the config if exist. Otherwise, it defaults to `iphonesimulator`. 133 | 134 | When building cache, the tool **merges existing slices with the newly created** to reduce unnecessary builds for multiplatform support. This behavior is controlled by the `--merge-slices` flag (default: `true`). To disable it, ie. replacing the existing xcframework if exists, specify `--no-merge-slices`. 135 | 136 | ```sh 137 | xccache build SwiftyBeaver --sdk=iphonesimulator 138 | xccache build SwiftyBeaver --sdk=iphoneos # <-- here, xcframework contains both sdks: iphonesimulator and iphoneos 139 | 140 | xccache build SwiftyBeaver --sdk=macos --no-merge-slices # <-- here, xcframework contains only macos sdk 141 | ``` 142 | 143 | ### Per-Configuration Cache 144 | 145 | Cache of different build configurations (debug/release) is hosted in separate directories `~/.xccache/`. The build configuration is defaulted to `debug`. To specify a different build configuration, use the `--config` argument. 146 | ```sh 147 | xccache build SwiftyBeaver --config=release 148 | xccache --config=release 149 | ``` 150 | 151 | ### Sharing Remote Cache 152 | Cache can be shared among team with remote cache, using Git or S3. 153 | ```sh 154 | xccache remote pull # <-- pull cache 155 | xccache remote push # <-- push cache 156 | ``` 157 | 158 | Refer to the [remote configuration](configuration.md#remote) for the setup. 159 | 160 | ## Working With Swift Packages 161 | ### Building a Swift Package Target 162 | 163 | Packaging a Swift package target as binary is not as easy as it seems, which involves several steps. This tool offers a convenient way to build such a target into an xcframework with just only one step. Check out build options (ex. configuration, sdk, etc.) with `--help`. 164 | ```sh 165 | xccache pkg build 166 | ``` 167 | 168 | ## Managing Dependencies 169 | ### Adding a Dependency 170 | 171 | To add a new package, or new product dependencies (in the *Link Binary With Libraries* section), you can just add it the way you usually do (via Xcode), then just run `xccache` again. 172 | After that, you should see the changes reflected in xccache.lock. 173 | 174 | 175 | 176 | Alternatively, you can directly modify the lockfile with the changes above, and run `xccache`. This way, you can avoid modifying the xcodeproj file. 177 | 178 | ### Removing a Dependency 179 | 180 | Just directly update the lockfile: 181 | - Remove it from the dependencies section 182 | - Remove it from the packages section if not in use 183 | 184 | ``` 185 | "dependencies": { 186 | "EX": [ 187 | "Moya/Moya", 188 | "SwiftyBeaver/SwiftyBeaver", // <-- Remove this if not in use 189 | ] 190 | }, 191 | "packages": [ 192 | { // <-- Remove this if not in use 193 | "repositoryURL": "https://github.com/SwiftyBeaver/SwiftyBeaver", 194 | "requirement": { 195 | "kind": "upToNextMajorVersion", 196 | "minimumVersion": "2.1.1" 197 | } 198 | } 199 | ] 200 | ``` 201 | 202 | ## Configuration 203 | Check out this doc: [Configuration](configuration.md) 204 | -------------------------------------------------------------------------------- /docs/how-to-install.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # 🔧 How to Install 4 | 5 | Via [Bundler](https://bundler.io): Add the gem `xccache` to the Gemfile of your project. 6 | 7 | ```rb 8 | gem "xccache" 9 | ``` 10 | 11 | Via [RubyGems](https://rubygems.org): 12 | ```sh 13 | $ gem install xccache 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # 📝 Overview 4 | ## Cache as xcframeworks 5 | 6 | 7 | The `xccache` CLI provides some functionalities to build a Swift package target into an xcframework (for more details, check out the [Under the Hood](#under-the-hood) section). This xcframework can be used in the project in many ways. See more: [Declare a binary target in the package manifest](https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages#Declare-a-binary-target-in-the-package-manifest). 8 | 9 | The tool manages a special *umbrella package* (at: xccache/packages/umbrella) to manipulate cache dependencies in the project. In case of cache hit, it replaces the original dependency (with source code) with the corresponding prebuilt dependency. 10 | 11 | ### Cache Fallback 12 | In case of cache miss, it automatically uses the original dependency. 13 | 14 | ### Cache Validation Model 15 | (1) **Checksum-based**: An xcframework is associated with a checksum of its package. If the checksum does not match -> cache miss. 16 | 17 | 18 | 19 | ## Under the Hood 20 | - [Packaging as an xcframework](under-the-hood/packaging-as-xcframework.md) 21 | - [Ensuring `Bundle.module` When Accessing Resources](under-the-hood/ensuring-bundle-module.md) 22 | -------------------------------------------------------------------------------- /docs/res/binary_macro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/binary_macro.png -------------------------------------------------------------------------------- /docs/res/cachemap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/cachemap.png -------------------------------------------------------------------------------- /docs/res/kickstarter_clean_build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/kickstarter_clean_build.png -------------------------------------------------------------------------------- /docs/res/lockfile_add_new_dep.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/lockfile_add_new_dep.png -------------------------------------------------------------------------------- /docs/res/proxy_binary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/proxy_binary.png -------------------------------------------------------------------------------- /docs/res/proxy_local.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/proxy_local.png -------------------------------------------------------------------------------- /docs/res/resource_build_structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/resource_build_structure.png -------------------------------------------------------------------------------- /docs/res/resource_bundle_module.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/resource_bundle_module.png -------------------------------------------------------------------------------- /docs/res/resource_bundle_overriding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/resource_bundle_overriding.png -------------------------------------------------------------------------------- /docs/res/switching_binary_to_source.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/switching_binary_to_source.gif -------------------------------------------------------------------------------- /docs/res/switching_binary_to_source.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/switching_binary_to_source.mp4 -------------------------------------------------------------------------------- /docs/res/umbrella_product_dependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/umbrella_product_dependencies.png -------------------------------------------------------------------------------- /docs/res/unknown_deps_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/unknown_deps_err.png -------------------------------------------------------------------------------- /docs/res/xccache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/xccache.png -------------------------------------------------------------------------------- /docs/res/xcode_process_xcframeworks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trinhngocthuyen/xccache/6e3fe288127c529a0b88c653eb02b01ad16af205/docs/res/xcode_process_xcframeworks.png -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](README.md) 2 | 3 | # 🩺 Troubleshooting 4 | 5 | ### Unknown Product Dependencies 6 | 7 | 8 | 9 | If you encountered this issue, it's because there are some product dependencies that the tool failed to infer their packages. This case is likely to happen when having **local packages** that were not properly added to the project. Below is the instructions to resolve this issue (once only). 10 | 11 | After seeing this error, you should see `__unknown__/` (ex. `__unknown__/DebugKit`) in the lockfile (xccache.lock). 12 | ```json 13 | "EX.xcodeproj": { 14 | "packages": [ 15 | ], 16 | "dependencies": { 17 | "EX": [ 18 | "__unknown__/DebugKit" // <-- HERE 19 | ] 20 | } 21 | } 22 | ``` 23 | This mean, the tool cannot infer the package of `DebugKit`. So, what you need to do in this lockfile is: 24 | 25 | (1) Specify the package of that unknown product. 26 | ```json 27 | "EX.xcodeproj": { 28 | "packages": [ 29 | { 30 | "path_from_root": "LocalPackages/core-utils" // <-- HERE 31 | } 32 | ] 33 | } 34 | ``` 35 | 36 | (2) Then, replace the `__unknown__` in the dependencies by the package slug (ex. changing `__unknown__/DebugKit` to `core-utils/DebugKit`). 37 | 38 | ```json 39 | "EX.xcodeproj": { 40 | "packages": [ 41 | { 42 | "path_from_root": "LocalPackages/core-utils" // <-- HERE 43 | } 44 | ], 45 | "dependencies": { 46 | "EX": [ 47 | "core-utils/DebugKit" // <-- HERE 48 | ] 49 | } 50 | } 51 | ``` 52 | 53 | (3) After that, run the xccache workflow again. 54 | -------------------------------------------------------------------------------- /docs/under-the-hood/ensuring-bundle-module.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](../README.md) 2 | 3 | # Under the Hood: Ensuring `Bundle.module` When Accessing Resources 4 | 5 | ### Accessing Resources in Code 6 | If a target includes resources, Xcode creates a resource bundle, which can be accessed using `Bundle.module`. This resource bundle is then copied to the app bundle: 7 | ``` 8 | App.app 9 | |-- App (binary) 10 | |-- _.bundle 11 | ``` 12 | 13 | > [!IMPORTANT] 14 | > You are recommended to always use `Bundle.module` to access resources, and not make assumptions about the exact location of the resource bundle.\ 15 | > See: https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package#Access-a-resource-in-code 16 | 17 | ### How `Bundle.module` is Accessible 18 | 19 | For targets having resources, Xcode or SPM build system generates an internal code for the `Bundle.module` extension. More about SPM implementation for this: [read here](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0271-package-manager-resources.md#runtime-access-to-resources-bundle). 20 | 21 | In Xcode, you can jump-to-definition on `Bundle.module` to see the generated code in a file called `resource_bundle_accessor.swift`. This file is later compiled (into an `.o` file) and merged into the library binary, along with other `.o` files. 22 | 23 | 24 | 25 | With SPM (using `swift build`), you should see the generated source in `.build/debug/.build/DerivedSources` as in the following image: 26 | 27 | 28 | 29 | ### Resource Bundle Inside xcframework 30 | With xccache, we want this resource bundle to reside inside the xcframework for convenience. However, when integrating this xcframework as a binary target. The app bundle structure looks like this: 31 | ``` 32 | App.app 33 | |-- App (binary) 34 | |-- Frameworks 35 | |-- .framework 36 | |-- (binary) 37 | |-- _.bundle 38 | ``` 39 | 40 | `Bundle.module` can no longer detect the resource bundle. Note that the framework binary under `Frameworks/.framework/` is codeless. So, `Bundle(for: BundleFinder.self)` does not work either. 41 | 42 | ### Workaround: Overriding Bundle Lookup Logic 43 | A workaround for this problem is to override the lookup logic. Before combining `.o` files into the framework binary (with `libtool`), we overwrite the object file `resource_bundle_accessor.swift.o` in the build directory. 44 | This can be done by: 45 | - First generating the source with the [additional bundle lookup](/lib/xccache/assets/templates/resource_bundle_accessor.swift.template#L13) 46 | - Compiling this file with `swiftc`, for example: 47 | ```sh 48 | swiftc -emit-library \ 49 | -module-name Foo \ 50 | -target arm64-apple-ios-simulator \ 51 | -sdk \ 52 | -o \ 53 | resource_bundle_accessor.swift 54 | ``` 55 | 56 | 57 | 58 | The same logic applies to Objective-C, ie. generating `resource_bundle_accessor.m` and compiling it into `resource_bundle_accessor.m.o` using `clang`. 59 | -------------------------------------------------------------------------------- /docs/under-the-hood/macro-as-binary.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](../README.md) 2 | 3 | # Under the Hood: Macro as Binary 4 | 5 | This technique was inspired by the approach mentioned in [this blog post](https://www.polpiella.dev/binary-swift-macros). 6 | 7 | Consider the following usage where `#hexColor` is the macro and `Wizard` is its module. 8 | 9 | ```swift 10 | import Wizard 11 | 12 | let color = #hexColor(0xff0000) 13 | ``` 14 | 15 | Such a macro consists of two targets: 16 | - A macro target containing the implementation: `.macro(WizardImpl)` 17 | - A regular target containing the interfaces/declarations `.target(Wizard)`. This target depends on the macro implementation target. 18 | 19 | ``` 20 | .target(Wizard) -> .macro(WizardImpl) 21 | ``` 22 | 23 | Here, we need prebuilt artifacts of both `Wizard` and `WizardImpl`. 24 | 25 | Building `Wizard` is just like building any other regular target. Building `Wizard` is just like building any other regular target. 26 | Meanwhile, obtaining the tool binary from `WizardImpl` is a bit tricky. Building `WizardImpl` itself does not produce the tool binary, only the `.o` files. And combining them into the tool binary is not straightforward given many intermediate dependencies in [swift-syntax](https://github.com/swiftlang/swift-syntax). Luckily 27 | Meanwhile, obtaining the tool binary from `WizardImpl` is a bit tricky. Building `WizardImpl` itself does not produce the tool binary, only the `.o` files. And combining them into the tool binary is not straightforward given many intermediate dependencies in [swift-syntax](https://github.com/swiftlang/swift-syntax). Luckily, building `Wizard`, the associated interfaces target, does produce the tool binary at `.build/arm64-apple-macosx/debug/WizardImpl-tool`. 28 | 29 | After having the tool binary, macro expansion can be done simply by specifying the Swift flags as follows: `-load-plugin-executable path/to/WizardImpl#WizardImpl`. 30 | 31 | 32 | -------------------------------------------------------------------------------- /docs/under-the-hood/packaging-as-xcframework.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](../README.md) 2 | 3 | # Under the Hood: Packaging as an xcframework 4 | 5 | The steps to create an xcframework out of a collection of Swift sources are: 6 | - (1) Creating a framework slice (ex. for iOS iphone simulator - `arm64-apple-ios-simulator`). The result of this step is a framework bundle, ex. `SwiftyBeaver.framework`. 7 | - (2) Creating an xcframework out of framework slices using `xcodebuild -create-xcframework`. 8 | 9 | There are some tricky actions in step (1) so that the framework bundle meets requirements in step (2). For example, in case of Swift frameworks, `xcodebuild -create-xcframework` requires a swiftinterface in the swiftmodule. 10 | 11 | ## Creating a Framework Slice 12 | 13 | By default, building a Swift package target (with `swift build`) does not produce a `.framework` bundle. We have to package it outselve from .o files, headers, swiftmodules, etc. 14 | 15 | ``` 16 | A.framework 17 | |-- A (binary) 18 | |-- Info.plist 19 | | 20 | |-- Headers / 21 | |-- Modules / 22 | |-- module.modulemap 23 | |-- A.swiftmodule / 24 | |-- arm64-apple-ios-simulator.swiftinterface 25 | |-- arm64-apple-ios-simulator.swiftdoc 26 | ... 27 | ``` 28 | Steps to create a framework: 29 | - (1) Run `swift build --target A ...` to build the target. Build artifacts are stored under `.build/debug`. 30 | - (2) Create the framework binary using `libtool` from `.o` files in `.build/debug/A.build`: 31 | ```sh 32 | libtool -static -o A.framework/A .build/debug/A.build/**/*.o 33 | ``` 34 | - (3) Copying swiftmodules & swiftinterfaces in `.build/debug/A.build` and `.build/debug/Modules` to `A.framework/Modules`.\ 35 | Also, creating the modulemap `module.modulemap` under `A.framework/Modules` so that this framework is visible to ObjC code. 36 | - (4) Copying headers (if any) to `A.framework/Headers`. 37 | - (5) Copying the resource bundle (if any) (ex. in `.build/debug/A_A.bundle`) to the framework bundle. 38 | 39 | ## Creating an xcframework from Framework Slices 40 | 41 | ```sh 42 | xcodebuild -create-xcframework \ 43 | -framework arm64-apple-ios-simulator/SwiftyBeaver.framework \ 44 | -framework arm64-apple-ios/SwiftyBeaver.framework \ 45 | -output SwiftyBeaver.xcframework 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/under-the-hood/proxy-packages.md: -------------------------------------------------------------------------------- 1 | [< Knowledge Base](../README.md) 2 | 3 | # Under the Hood: Proxy Packages 4 | 5 | The introduction of proxy packages were highlighted in this discussion: [Cache Re-design (v2)](https://github.com/trinhngocthuyen/xccache/discussions/83#discussion-8346379) 6 | 7 | Each resolved package has an accompanying package called proxy package. This package has a very similar manifest to the resolved package. Both share the same checkout sources. The proxy package manifest is derived from its counterpart. 8 | 9 | ``` 10 | umbrella 11 | ├── .build/checkouts 12 | ├── SwiftyBeaver / -- Package.swift 13 | │ ├── Package.swift 14 | │ ├── Sources 15 | │ 16 | └── Alamofire 17 | 18 | proxy 19 | ├── .proxies 20 | ├── SwiftyBeaver 21 | │ ├── Package.swift (updated) 22 | │ ├── src (symlink to umbrella/.build/checkouts/SwiftyBeaver) 23 | │ 24 | └── Alamofire 25 | ``` 26 | Dependencies of a proxy package are proxy packages 27 | 28 | Take Moya as an example. It depends on Alamofire, a remote git repo as follows. 29 | ```swift 30 | let package = Package( 31 | name: "Moya", 32 | dependencies: [ 33 | .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.0.0")) 34 | ] 35 | ) 36 | ``` 37 | When translating to the proxy model, the manifest should be like this: 38 | ```swift 39 | let package = Package( 40 | name: "Moya", 41 | dependencies: [ 42 | .package(path: "../Alamofire") 43 | ] 44 | ) 45 | ``` 46 | Proxy packages reside adjacent to each other in the directory structure. 47 | ``` 48 | umbrella 49 | 50 | proxy 51 | ├── .proxies 52 | │ ├── Alamofire 53 | │ │ └── Package.swift 54 | │ ├── Moya 55 | │ │ └── Package.swift 56 | │ └── SwiftyBeaver 57 | │ └── Package.swift 58 | │ 59 | └── Package.swift (to be integrated to the project) 60 | ``` 61 | 62 | When having cache, the targets declaration in the manifest is altered to use the xcframework. 63 | ```swift 64 | let package = Package( 65 | name: "Alamofire", 66 | products: [ 67 | .library( 68 | name: "Alamofire", 69 | targets: ["Alamofire"] 70 | ), 71 | ], 72 | targets: [ 73 | .binaryTarget( // <-- HERE 74 | name: "Alamofire", 75 | path: "../../../binaries/Alamofire/Alamofire.xcframework" 76 | ) 77 | ] 78 | ) 79 | ``` 80 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # User settings 2 | xcuserdata/ 3 | 4 | # Obj-C/Swift specific 5 | *.hmap 6 | 7 | # App packaging 8 | *.ipa 9 | *.dSYM.zip 10 | *.dSYM 11 | 12 | # Playgrounds 13 | timeline.xctimeline 14 | playground.xcworkspace 15 | 16 | # Swift Package Manager 17 | Packages/ 18 | Package.pins 19 | Package.resolved 20 | .swiftpm 21 | 22 | DerivedData/ 23 | .build/ 24 | Pods/ 25 | *.xcworkspace 26 | 27 | # fastlane 28 | fastlane/README.md 29 | fastlane/report.xml 30 | fastlane/Preview.html 31 | fastlane/screenshots/**/*.png 32 | fastlane/test_output 33 | 34 | .spm.pods/ 35 | .logs/ 36 | .xcconfigs/ 37 | xccache/ 38 | -------------------------------------------------------------------------------- /examples/EX.xcodeproj/xcshareddata/xcschemes/EX.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 47 | 48 | 49 | 50 | 51 | 61 | 63 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /examples/EX.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "DA9D28D2-8487-4286-9EC0-082D1A2FA0C3", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:EX.xcodeproj", 14 | "identifier" : "F577C62E2D96971200C83C96", 15 | "name" : "EX" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : false, 21 | "target" : { 22 | "containerPath" : "container:EX.xcodeproj", 23 | "identifier" : "F5AF0F9B2DAE09EF00AB812D", 24 | "name" : "EXTests" 25 | } 26 | } 27 | ], 28 | "version" : 1 29 | } 30 | -------------------------------------------------------------------------------- /examples/EX/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/EX/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/EX/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/EX/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import DebugKit 3 | import ResourceKit 4 | 5 | struct ContentView: View { 6 | var body: some View { 7 | VStack { 8 | Text(ResourceKit.greetings() ?? "N/A").font(.title) 9 | Form { 10 | Section("Resources") { 11 | labledContent("ResourceKit.bundle", ResourceKit.bundle.relativePath) 12 | labledContent("DebugKit.bundle", DebugKit.bundle.relativePath) 13 | labledContent("DebugKit.token", DebugKit.loadToken()) 14 | } 15 | .font(.footnote) 16 | } 17 | } 18 | } 19 | 20 | private func labledContent(_ label: String, _ value: String) -> some View { 21 | LabeledContent { 22 | Text(value) 23 | .multilineTextAlignment(.trailing) 24 | .foregroundStyle(value.contains("Frameworks/") ? .green : .gray) 25 | } label: { 26 | Text(label) 27 | } 28 | } 29 | } 30 | 31 | #Preview { 32 | ContentView() 33 | } 34 | -------------------------------------------------------------------------------- /examples/EX/EX-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #include "SPMPlayground_ObjC.h" 2 | -------------------------------------------------------------------------------- /examples/EX/EXApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct EXApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | .onAppear { 9 | playground() 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/EX/Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Bundle { 4 | var relativePath: String { 5 | return bundlePath 6 | .replacing(Bundle.main.bundlePath, with: "") 7 | .replacing(#/^\//#, with: "") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/EX/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/EX/SPMPlayground.swift: -------------------------------------------------------------------------------- 1 | import SwiftyBeaver 2 | import Moya 3 | import Alamofire 4 | import CoreUtils_Wrapper 5 | import DebugKit 6 | import ResourceKit 7 | import Swizzler 8 | import DisplayKit 9 | import GoogleMaps 10 | import SDWebImage 11 | import KingfisherWebP 12 | import FirebaseCrashlytics 13 | import FacebookLogin 14 | import Wizard 15 | 16 | func swift_playground() { 17 | print(SwiftyBeaver.self) // SwiftyBeaver 18 | print(MoyaError.self) // Moya 19 | print(AFError.self) // Alamofire 20 | print(DebugKit.self) // core-utils 21 | print(CoreUtils_Wrapper.self) // core-utils 22 | print(Swizzler.self) // core-utils 23 | print(ResourceKit.self) // core-utils 24 | print(DisplayKit.self) // core-utils 25 | print(GMSAddress.self) // GoogleMaps 26 | print(SDImageCacheOptions.self) // SDWebImage) 27 | print(WebPProcessor.self) // KingfisherWebP 28 | print(CrashlyticsReport.self) // FirebaseCrashlytics 29 | print(LoginResult.self) // FacebookLogin 30 | print(FBLoginButton.self) // FBSDKLoginKit 31 | } 32 | 33 | func macro_playground() { 34 | print(#hexColor(0xff0000)) 35 | } 36 | 37 | func playground() { 38 | swift_playground() 39 | objc_playground() 40 | macro_playground() 41 | } 42 | -------------------------------------------------------------------------------- /examples/EX/SPMPlayground_ObjC.h: -------------------------------------------------------------------------------- 1 | #ifndef SPMPlayground_ObjC_h 2 | #define SPMPlayground_ObjC_h 3 | 4 | #import 5 | 6 | @interface ObjCPlayground: NSObject 7 | 8 | void objc_playground(void); 9 | 10 | @end 11 | 12 | #endif 13 | -------------------------------------------------------------------------------- /examples/EX/SPMPlayground_ObjC.m: -------------------------------------------------------------------------------- 1 | #import "SPMPlayground_ObjC.h" 2 | @import Foundation; 3 | @import CoreUtils_Wrapper; 4 | @import DebugKit; 5 | @import ResourceKit; 6 | 7 | @implementation ObjCPlayground 8 | 9 | void objc_playground(void) { 10 | check(CoreUtils_Wrapper.class); 11 | check(DebugKit.class); 12 | check(ResourceKit.class); 13 | } 14 | 15 | void check(id object) { 16 | NSLog(@"%@", object); 17 | } 18 | 19 | @end 20 | -------------------------------------------------------------------------------- /examples/EXMac/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/EXMac/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /examples/EXMac/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/EXMac/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | var body: some View { 5 | VStack { 6 | Image(systemName: "globe") 7 | .imageScale(.large) 8 | .foregroundStyle(.tint) 9 | Text("Hello, world!") 10 | } 11 | .padding() 12 | } 13 | } 14 | 15 | #Preview { 16 | ContentView() 17 | } 18 | -------------------------------------------------------------------------------- /examples/EXMac/EXMac.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/EXMac/EXMacApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftyBeaver 3 | 4 | @main 5 | struct EXMacApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | ContentView() 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/EXTests/ResourceTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import DebugKit 3 | import ResourceKit 4 | 5 | struct PkgResourceTests { 6 | @Test func checkPkgResources() { 7 | #expect(ResourceKit.greetings() == "Hi from xccache!") 8 | #expect(DebugKit.loadToken() == "12345") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/EXTests/SPMPlayground.swift: -------------------------------------------------------------------------------- 1 | import DebugKit 2 | import ResourceKit 3 | import TestKit 4 | 5 | func playground() { 6 | print(DebugKit.self) // DebugKit 7 | print(ResourceKit.self) // ResourceKit 8 | print(TestKit.self) // TestKit 9 | print(BaseTestCase.self) // TestKit 10 | } 11 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CoreUtils", 7 | platforms: [.iOS(.v17), .macOS(.v13)], 8 | products: [ 9 | .library(name: "Swizzler", targets: ["Swizzler"]), 10 | .library(name: "ResourceKit", targets: ["ResourceKit"]), 11 | .library(name: "DebugKit", targets: ["DebugKit"]), 12 | .library(name: "DisplayKit", targets: ["DisplayKit"]), 13 | .library(name: "TestKit", targets: ["TestKit"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/SwiftyBeaver/SwiftyBeaver.git", .upToNextMajor(from: "2.1.1")), 17 | .package(url: "https://github.com/Moya/Moya", .upToNextMajor(from: "15.0.3")), 18 | .package(path: "../wizard"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Swizzler", 23 | dependencies: [ 24 | "CoreUtils-Wrapper" 25 | ] 26 | ), 27 | .target( 28 | name: "ResourceKit", 29 | dependencies: ["CoreUtils-Wrapper"], 30 | resources: [.copy("greetings.txt")] 31 | ), 32 | .target( 33 | name: "DebugKit", 34 | dependencies: [ 35 | "CoreUtils-Wrapper", 36 | "Swizzler", 37 | .product(name: "SwiftyBeaver", package: "SwiftyBeaver"), 38 | .product(name: "Moya", package: "Moya"), 39 | ], 40 | path: "Sources/DebugKitObjC", 41 | resources: [.copy("token.txt")], 42 | publicHeadersPath: "Headers", 43 | cSettings: [ 44 | .headerSearchPath("PrivateHeaders"), 45 | ] 46 | ), 47 | .target( 48 | name: "DisplayKit", 49 | dependencies: [ 50 | .product(name: "Wizard", package: "wizard"), 51 | ] 52 | ), 53 | .target( 54 | name: "CoreUtils-Wrapper", 55 | path: "Sources/Core" 56 | ), 57 | .target(name: "TestKit"), 58 | ] 59 | ) 60 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/Core/dummy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc public class CoreUtils_Wrapper: NSObject { } 4 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/DebugKitObjc/DebugKit.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import 4 | #import 5 | 6 | @implementation DebugKit 7 | + (NSBundle *)bundle { 8 | return SWIFTPM_MODULE_BUNDLE; 9 | } 10 | + (NSString *)loadToken { 11 | NSBundle *bundle = SWIFTPM_MODULE_BUNDLE; 12 | NSString *tokenPath = [bundle pathForResource:@"token" ofType:@"txt"]; 13 | NSString *content = [NSString stringWithContentsOfFile:tokenPath encoding:NSUTF8StringEncoding error:nil]; 14 | return [content stringByReplacingOccurrencesOfString:@"\n" withString:@""]; 15 | } 16 | + (void)diagnose { 17 | [Swizzler swizzle:@"foo" with:@"bar" forClass:DebugKit.class]; 18 | [Diagnoser diagnoseDevice]; 19 | } 20 | @end 21 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/DebugKitObjc/Diagnose.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @implementation Diagnoser 4 | 5 | + (void)diagnoseDevice { 6 | NSLog(@"Diagnosing device..."); 7 | } 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/DebugKitObjc/Headers/DebugKit.h: -------------------------------------------------------------------------------- 1 | #import 2 | // 👇 expect xccache to convert to nested angle-bracket style `#import ` 3 | #import 4 | 5 | NS_ASSUME_NONNULL_BEGIN 6 | 7 | @interface DebugKit: NSObject 8 | @property (class, nonatomic, readonly, strong) NSBundle *bundle; 9 | + (NSString *)loadToken; 10 | + (void)diagnose; 11 | @end 12 | 13 | NS_ASSUME_NONNULL_END 14 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/DebugKitObjc/PrivateHeaders/Diagnose.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | NS_ASSUME_NONNULL_BEGIN 4 | 5 | @interface Diagnoser: NSObject 6 | + (void)diagnoseDevice; 7 | @end 8 | 9 | NS_ASSUME_NONNULL_END 10 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/DebugKitObjc/token.txt: -------------------------------------------------------------------------------- 1 | 12345 2 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/DisplayKit/DisplayKit.swift: -------------------------------------------------------------------------------- 1 | import Wizard 2 | 3 | public class DisplayKit { 4 | public static let baseColor = #hexColor(0xff0000) 5 | } 6 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/ResourceKit/ResourceKit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc public class ResourceKit: NSObject { 4 | public static let bundle = Bundle.module 5 | public static func greetings() -> String? { 6 | guard let url = Bundle.module.url(forResource: "greetings", withExtension: "txt"), 7 | let content = try? String(contentsOf: url, encoding: .utf8) 8 | else { return nil } 9 | return content.replacing(#/\s*$/#, with: "") // Strip trailing spaces 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/ResourceKit/greetings.txt: -------------------------------------------------------------------------------- 1 | Hi from xccache! 2 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/Swizzler/Swizzler.m: -------------------------------------------------------------------------------- 1 | #import "Swizzler.h" 2 | 3 | @implementation Swizzler 4 | + (void)swizzle:(NSString* )m1 with:(NSString *)m2 forClass:(Class)cls { 5 | NSLog(@"Swizzle %@ with %@", m1, m2); 6 | } 7 | @end 8 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/Swizzler/include/Swizzler.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | NS_ASSUME_NONNULL_BEGIN 4 | 5 | @interface Swizzler: NSObject 6 | + (void)swizzle:(NSString* )m1 with:(NSString *)m2 forClass:(Class)cls; 7 | @end 8 | 9 | NS_ASSUME_NONNULL_END 10 | -------------------------------------------------------------------------------- /examples/LocalPackages/core-utils/Sources/TestKit/TestKit.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import XCTest 3 | 4 | @objc public class TestKit: NSObject { } 5 | 6 | public class BaseTestCase: XCTestCase { } 7 | public protocol BaseTrait: Trait { } 8 | -------------------------------------------------------------------------------- /examples/LocalPackages/wizard/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | import CompilerPluginSupport 5 | 6 | let package = Package( 7 | name: "Wizard", 8 | platforms: [.iOS(.v17), .macOS(.v13)], 9 | products: [ 10 | .library( 11 | name: "Wizard", 12 | targets: ["Wizard"] 13 | ), 14 | .executable( 15 | name: "WizardPlayground", 16 | targets: ["WizardPlayground"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.0"), 21 | ], 22 | targets: [ 23 | .macro( 24 | name: "WizardImpl", 25 | dependencies: [ 26 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 27 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 28 | ] 29 | ), 30 | .target(name: "Wizard", dependencies: ["WizardImpl"]), 31 | .executableTarget(name: "WizardPlayground", dependencies: ["Wizard"]), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /examples/LocalPackages/wizard/Sources/Wizard/Wizard.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import UIKit 3 | public typealias XColor = UIColor 4 | 5 | #elseif canImport(AppKit) 6 | import AppKit 7 | public typealias XColor = NSColor 8 | 9 | #else 10 | #error("Only usable with UIKit or AppKit") 11 | #endif 12 | 13 | @freestanding(expression) 14 | public macro hexColor(_ intLiteral: IntegerLiteralType ) -> XColor = #externalMacro(module: "WizardImpl", type: "HexColorMacro") 15 | -------------------------------------------------------------------------------- /examples/LocalPackages/wizard/Sources/WizardImpl/HexColorMacro.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCompilerPlugin 3 | import SwiftSyntax 4 | import SwiftSyntaxBuilder 5 | import SwiftSyntaxMacros 6 | 7 | enum HexColorMacroError: Error { 8 | case notFoundHex 9 | } 10 | 11 | public struct HexColorMacro: ExpressionMacro { 12 | public static func expansion( 13 | of node: some FreestandingMacroExpansionSyntax, 14 | in context: some MacroExpansionContext 15 | ) throws -> ExprSyntax { 16 | guard let arg = node.arguments.first, 17 | let expr = arg.expression.as(IntegerLiteralExprSyntax.self) 18 | else { 19 | throw HexColorMacroError.notFoundHex 20 | } 21 | let hex = expr.literal.text 22 | return """ 23 | XColor( 24 | red: .init((\(raw: hex) >> 16) & 0xff) / 255, 25 | green: .init((\(raw: hex) >> 8) & 0xff) / 255, 26 | blue: .init((\(raw: hex) >> 0) & 0xff) / 255, 27 | alpha: 1.0 28 | ) 29 | """ 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/LocalPackages/wizard/Sources/WizardImpl/WizardImpl.swift: -------------------------------------------------------------------------------- 1 | import SwiftCompilerPlugin 2 | import SwiftSyntax 3 | import SwiftSyntaxBuilder 4 | import SwiftSyntaxMacros 5 | 6 | @main 7 | struct HexColorMacroPlugin: CompilerPlugin { 8 | let providingMacros: [Macro.Type] = [ 9 | HexColorMacro.self, 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/LocalPackages/wizard/Sources/WizardPlayground/main.swift: -------------------------------------------------------------------------------- 1 | import Wizard 2 | 3 | let color = #hexColor(0xff0000) 4 | print(color) 5 | -------------------------------------------------------------------------------- /examples/Makefile: -------------------------------------------------------------------------------- 1 | XCCACHE_ARGS := --verbose 2 | 3 | format: 4 | cd .. && make format 5 | 6 | cache.use: 7 | bundle exec xccache use $(XCCACHE_ARGS) 8 | 9 | cache.build: 10 | bundle exec xccache build $(TARGET) $(XCCACHE_ARGS) 11 | 12 | cache.init: 13 | bundle exec xccache init $(XCCACHE_ARGS) 14 | 15 | cache.rollback: 16 | bundle exec xccache rollback $(XCCACHE_ARGS) 17 | 18 | cache.viz: 19 | bundle exec xccache viz --out=xccache $(XCCACHE_ARGS) 20 | 21 | build: 22 | cicd ios build 23 | 24 | test: 25 | cicd ios test 26 | 27 | check: 28 | rm -rf xccache/packages/umbrella/.build xccache/cachemap.json 29 | make cache.build build 30 | 31 | ex.%: 32 | make $* 33 | -------------------------------------------------------------------------------- /examples/config.xcconfig: -------------------------------------------------------------------------------- 1 | #include? ".xcconfigs/hook.xcconfig" 2 | -------------------------------------------------------------------------------- /examples/xccache.lock: -------------------------------------------------------------------------------- 1 | { 2 | "EX.xcodeproj": { 3 | "packages": [ 4 | { 5 | "path_from_root": "LocalPackages/core-utils" 6 | }, 7 | { 8 | "path_from_root": "LocalPackages/wizard" 9 | }, 10 | { 11 | "repositoryURL": "https://github.com/facebook/facebook-ios-sdk", 12 | "requirement": { 13 | "kind": "upToNextMajorVersion", 14 | "minimumVersion": "9.0.0" 15 | } 16 | }, 17 | { 18 | "repositoryURL": "https://github.com/firebase/firebase-ios-sdk", 19 | "requirement": { 20 | "kind": "upToNextMajorVersion", 21 | "minimumVersion": "11.4.0" 22 | } 23 | }, 24 | { 25 | "repositoryURL": "https://github.com/googlemaps/ios-maps-sdk", 26 | "requirement": { 27 | "kind": "upToNextMajorVersion", 28 | "minimumVersion": "9.4.0" 29 | } 30 | }, 31 | { 32 | "repositoryURL": "https://github.com/Moya/Moya", 33 | "requirement": { 34 | "kind": "upToNextMajorVersion", 35 | "minimumVersion": "15.0.3" 36 | } 37 | }, 38 | { 39 | "repositoryURL": "https://github.com/SDWebImage/SDWebImage", 40 | "requirement": { 41 | "kind": "upToNextMajorVersion", 42 | "minimumVersion": "5.21.0" 43 | } 44 | }, 45 | { 46 | "repositoryURL": "https://github.com/SnapKit/SnapKit", 47 | "requirement": { 48 | "kind": "upToNextMajorVersion", 49 | "minimumVersion": "5.7.1" 50 | } 51 | }, 52 | { 53 | "repositoryURL": "https://github.com/SwiftyBeaver/SwiftyBeaver.git", 54 | "requirement": { 55 | "kind": "upToNextMajorVersion", 56 | "minimumVersion": "2.1.1" 57 | } 58 | }, 59 | { 60 | "repositoryURL": "https://github.com/yeatse/KingfisherWebP", 61 | "requirement": { 62 | "kind": "upToNextMajorVersion", 63 | "minimumVersion": "1.6.0" 64 | } 65 | } 66 | ], 67 | "dependencies": { 68 | "EX": [ 69 | "core-utils/DebugKit", 70 | "core-utils/DisplayKit", 71 | "core-utils/ResourceKit", 72 | "core-utils/Swizzler", 73 | "facebook-ios-sdk/FacebookLogin", 74 | "firebase-ios-sdk/FirebaseCrashlytics", 75 | "ios-maps-sdk/GoogleMaps", 76 | "KingfisherWebP/KingfisherWebP", 77 | "Moya/Moya", 78 | "SDWebImage/SDWebImage", 79 | "SnapKit/SnapKit-Dynamic", 80 | "SwiftyBeaver/SwiftyBeaver", 81 | "wizard/Wizard" 82 | ], 83 | "EXTests": [ 84 | "core-utils/TestKit" 85 | ], 86 | "EXMac": [ 87 | "SwiftyBeaver/SwiftyBeaver" 88 | ] 89 | }, 90 | "platforms": { 91 | "ios": "17.6", 92 | "osx": "15.4" 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /examples/xccache.yml: -------------------------------------------------------------------------------- 1 | # ignore_local: true 2 | ignore: [] 3 | default_sdk: iphonesimulator 4 | remote: 5 | # debug: 6 | # git: https://github.com/trinhngocthuyen/.cache.git 7 | # release: 8 | # s3: 9 | # uri: "s3://xccache/release" 10 | -------------------------------------------------------------------------------- /lib/xccache.rb: -------------------------------------------------------------------------------- 1 | require "xccache/main" 2 | 3 | module XCCache 4 | ROOT = Pathname(__dir__).parent 5 | LIBEXEC = ROOT / "libexec" 6 | end 7 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/cachemap.html.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cachemap Visualization 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 47 |
48 |
Powered by xccache
49 |
50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/cachemap.js.template: -------------------------------------------------------------------------------- 1 | const graph = JSON.parse(` 2 | <%= json %> 3 | `); 4 | 5 | // ------------------------------------------------ 6 | 7 | const COLORS = { 8 | 'hit': '#339966', 9 | 'missed': '#ff6f00', 10 | 'ignored': '#888', 11 | 'NA': '#888', 12 | } 13 | const cy = cytoscape({ 14 | container: $('#cy'), 15 | elements: ([...graph.nodes, ...graph.edges]).map(x => ({data: x})), 16 | style: [ 17 | { 18 | selector: 'node', 19 | style: { 20 | 'label': (e) => e.id().split("/")[1], 21 | 'color': '#fff', 22 | 'text-valign': 'center', 23 | 'text-halign': 'center', 24 | 'font-size': '14px', 25 | 'shape': 'roundrectangle', 26 | 'width': (e) => Math.max(50, e.id().split('/')[1].length * 8), 27 | 'background-color': (e) => COLORS[e.data('cache') || 'NA'], 28 | } 29 | }, 30 | { 31 | selector: 'node:selected', 32 | style: { 33 | 'font-weight': 'bold', 34 | 'border-width': 3, 35 | 'border-color': '#333', 36 | } 37 | }, 38 | { 39 | selector: 'node[type="agg"]', 40 | style: { 41 | 'background-color': '#333', 42 | } 43 | }, 44 | { 45 | selector: 'edge', 46 | style: { 47 | 'width': 1, 48 | 'target-arrow-shape': 'triangle', 49 | 'curve-style': 'bezier', 50 | 'line-color': '#ccc', 51 | 'target-arrow-color': '#ccc', 52 | } 53 | }, 54 | ], 55 | layout: { 56 | name: 'fcose', 57 | animationDuration: 200, 58 | nodeRepulsion: 10000, 59 | idealEdgeLength: 120, 60 | gravity: 0.25, 61 | } 62 | }); 63 | 64 | cy.on('select', 'node', function(event) { 65 | const node = event.target; 66 | node.displayDetails(); 67 | node.neighborhood().add(node).focus(); 68 | }); 69 | 70 | cy.on('tap', function(event) { 71 | if (event.target == cy) { 72 | $('.node-info').css('display', 'none'); 73 | cy.elements().animateStyle({'opacity': 1, 'line-color': '#ccc', 'target-arrow-color': '#ccc'}); 74 | } 75 | }); 76 | 77 | // ----------------------------------------------------------------- 78 | 79 | cytoscape('collection', 'animateStyle', function(style) { 80 | this.animate({style: style, duration: 200, easing: 'ease-out'}) 81 | }); 82 | cytoscape('collection', 'focus', function() { 83 | this.animateStyle({'opacity': 1, 'line-color': '#666', 'target-arrow-color': '#666'}); 84 | cy.elements().not(this).animateStyle({'opacity': 0.15, 'line-color': '#ccc', 'target-arrow-color': '#ccc'}); 85 | }); 86 | cytoscape('collection', 'displayDetails', function() { 87 | $('.node-info').css('display', 'block'); 88 | const info = $('.node-info .info'); 89 | info.find('.target').html(this.id()); 90 | info.find('.checksum').html(this.data('checksum') || 'NA'); 91 | info.find('.binary') 92 | .html((this.data('binary') || 'NA').split('/').slice(-1)) 93 | .attr({'href': this.data('binary') || ''}); 94 | info.find('.others').html(`Node degree: ${this.degree()} (${this.indegree()} in, ${this.outdegree()} out)`); 95 | }); 96 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/cachemap.style.css.template: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #1492A0; 3 | --bg-color: color-mix(in srgb, var(--primary-color), white 80%); 4 | } 5 | body { 6 | font-family: Helvetica, Arial, sans-serif; 7 | font-size: 12px; 8 | margin: 0; 9 | line-height: 1.6; 10 | } 11 | a { color: var(--primary-color) } 12 | a:hover { color: #339966; } 13 | .fa-solid { color: var(--primary-color) } 14 | .container { 15 | display: flex; 16 | height: 100vh; 17 | } 18 | #cy { 19 | flex: 1; 20 | } 21 | #sidebar { 22 | position: relative; 23 | background-color: var(--bg-color); 24 | width: 250px; 25 | transition: all 0.3s ease; 26 | } 27 | .sidebar-content { 28 | width: calc(250px - 32px); 29 | padding: 16px; 30 | transform: translateX(0px); 31 | transition: all 0.3s ease; 32 | } 33 | #sidebar.collapsed { 34 | width: 0; 35 | } 36 | #sidebar.collapsed .sidebar-content{ 37 | transform: translateX(-250px); 38 | } 39 | #sidebar.collapsed .toggle-btn { 40 | right: -36px; 41 | transform: rotate(180deg); 42 | } 43 | .toggle-btn { 44 | position: absolute; 45 | top: 20px; 46 | right: 20px; 47 | z-index: 999; 48 | cursor: pointer; 49 | width: 16px; 50 | height: 16px; 51 | fill: var(--primary-color); 52 | transition: all 0.3s; 53 | } 54 | #sidebar .title { 55 | color: var(--primary-color); 56 | font-size: 16px; 57 | margin-top: 0; 58 | } 59 | #sidebar section { 60 | padding: 16px 0; 61 | } 62 | #sidebar .section-header { 63 | color: color-mix(in srgb, var(--primary-color), grey 20%); 64 | font-weight: bold; 65 | margin-block-end: 4px; 66 | } 67 | .node-info { 68 | display: none; 69 | } 70 | .metadata .info { 71 | font-size: 10px; 72 | } 73 | .info { 74 | color: #888; 75 | } 76 | .info .value { 77 | color: #666; 78 | } 79 | .footnote { 80 | color: #888; 81 | position: absolute; 82 | left: 16px; 83 | bottom: 8px; 84 | } 85 | .node { 86 | border-radius: 3px; 87 | padding: 1px 3px; 88 | color: white; 89 | background-color: var(--color) 90 | } 91 | .desc { color: var(--color) } 92 | .hit { --color: #339966 } 93 | .missed { --color: #ff6f00 } 94 | .ignored { --color: #888 } 95 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/framework.info.plist.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AvailableLibraries 6 | 7 | 8 | BinaryPath 9 | <%= module_name %>.framework/<%= module_name %> 10 | LibraryPath 11 | <%= module_name %>.framework 12 | 13 | 14 | CFBundleExecutable 15 | <%= module_name %> 16 | CFBundleName 17 | <%= module_name %> 18 | CFBundleIdentifier 19 | com.xccache.<%= module_name %> 20 | CFBundlePackageType 21 | XFWK 22 | XCFrameworkFormatVersion 23 | 1.0 24 | 25 | 26 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/framework.modulemap.template: -------------------------------------------------------------------------------- 1 | framework module <%= module_name %> { 2 | umbrella header "<%= target %>-umbrella.h" 3 | 4 | export * 5 | module * { export * } 6 | } 7 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/resource_bundle_accessor.m.template: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface BundleFinder_<%= module_name %> : NSObject 4 | @end 5 | 6 | @implementation BundleFinder_<%= module_name %> 7 | @end 8 | 9 | NSBundle* <%= module_name %>_SWIFTPM_MODULE_BUNDLE() { 10 | NSString *bundleName = @"<%= pkg %>_<%= target %>"; 11 | NSArray *candidates = @[ 12 | NSBundle.mainBundle.resourceURL, 13 | [NSBundle bundleForClass:[BundleFinder_<%= module_name %> class]].resourceURL, 14 | NSBundle.mainBundle.bundleURL, 15 | [NSBundle.mainBundle.bundleURL URLByAppendingPathComponent:@"Frameworks/<%= target %>.framework"] 16 | ]; 17 | 18 | for (NSURL *candidate in candidates) { 19 | NSURL *bundlePath = [candidate URLByAppendingPathComponent:[bundleName stringByAppendingString:@".bundle"]]; 20 | NSBundle *bundle = [NSBundle bundleWithURL:bundlePath]; 21 | if (bundle) { 22 | return bundle; 23 | } 24 | } 25 | [NSException raise:NSInternalInconsistencyException format:@"Unable to find bundle named %@", bundleName]; 26 | return nil; 27 | } 28 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/resource_bundle_accessor.swift.template: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private class BundleFinder {} 4 | 5 | extension Bundle { 6 | @available(iOS 8.0, *) 7 | static let module: Bundle = { 8 | let bundleName = "<%= pkg %>_<%= target %>" 9 | let candidates = [ 10 | Bundle.main.resourceURL, 11 | Bundle(for: BundleFinder.self).resourceURL, 12 | Bundle.main.bundleURL, 13 | Bundle.main.bundleURL.appendingPathComponent("Frameworks/<%= target %>.framework") 14 | ] 15 | 16 | for candidate in candidates { 17 | let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle") 18 | if let bundle = bundlePath.flatMap(Bundle.init(url:)) { 19 | return bundle 20 | } 21 | } 22 | fatalError("unable to find bundle named \(bundleName)") 23 | }() 24 | } 25 | -------------------------------------------------------------------------------- /lib/xccache/assets/templates/xccache.yml.template: -------------------------------------------------------------------------------- 1 | # Check out this doc for details 2 | # https://github.com/trinhngocthuyen/xccache/blob/main/docs/configuration.md 3 | -------------------------------------------------------------------------------- /lib/xccache/cache/cachemap.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core" 2 | 3 | module XCCache 4 | module Cache 5 | class Cachemap < JSONRepresentable 6 | def depgraph_data 7 | raw["depgraph"] ||= {} 8 | end 9 | 10 | def cache_data 11 | raw["cache"] ||= {} 12 | end 13 | 14 | def missed?(name) 15 | missed.include?(name) 16 | end 17 | 18 | def missed 19 | get_cache_data(:missed) 20 | end 21 | 22 | def stats 23 | %i[hit missed ignored].to_h do |type| 24 | count, total_count = get_cache_data(type).count, cache_data.count 25 | percent = total_count.positive? ? (count.to_f * 100 / total_count).round : 0 26 | [type, "#{percent}% (#{count}/#{total_count})"] 27 | end 28 | end 29 | 30 | def print_stats 31 | verbose = Config.instance.verbose? 32 | colors = { :hit => "green", :missed => "yellow" } 33 | descs = %i[hit missed ignored].to_h do |type| 34 | colorize = proc { |s| colors.key?(type) ? s.send(colors[type]).dark : s.dark } 35 | items = get_cache_data(type) 36 | percent = cache_data.count.positive? ? items.count.to_f / cache_data.count * 100 : 0 37 | desc = "#{type} #{percent.round}% (#{items.count}/#{cache_data.count})" 38 | desc = desc.capitalize if verbose 39 | desc = "#{desc} #{colorize.call(items.to_s)}" if verbose && !items.empty? 40 | [type, desc] 41 | end 42 | if verbose 43 | UI.info <<~DESC 44 | ------------------------------------------------------------------- 45 | Cache stats 46 | #{descs.values.map { |s| "• #{s}" }.join("\n")} 47 | ------------------------------------------------------------------- 48 | DESC 49 | else 50 | UI.info <<~DESC 51 | ------------------------------------------------------------------- 52 | Cache stats: #{descs.values.join(', ')} 53 | To see the full stats, use --verbose in the xccache command 54 | ------------------------------------------------------------------- 55 | DESC 56 | end 57 | end 58 | 59 | def get_cache_data(type) 60 | cache_data.select { |_, v| v == type }.keys 61 | end 62 | 63 | def update_from_graph(graph) 64 | cache_data = 65 | graph["cache"] 66 | .reject { |k, _| k.end_with?(".xccache") } 67 | .to_h do |k, v| 68 | next [k, :hit] if v 69 | next [k, :ignored] if Config.instance.ignore?(k) 70 | [k, :missed] 71 | end 72 | 73 | deps = graph["deps"] 74 | edges = deps.flat_map { |k, xs| xs.map { |v| { :source => k, :target => v } } } 75 | nodes = deps.keys.map do |k| 76 | { 77 | :id => k, 78 | :cache => cache_data[k], 79 | :type => ("agg" if k.end_with?(".xccache")), 80 | :binary => graph["cache"][k], 81 | } 82 | end 83 | self.raw = { 84 | "cache" => cache_data, 85 | "depgraph" => { "nodes" => nodes, "edges" => edges }, 86 | } 87 | save 88 | print_stats 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/xccache/command.rb: -------------------------------------------------------------------------------- 1 | require "claide" 2 | require "xccache/core/config" 3 | require "xccache/swift/sdk" 4 | 5 | module XCCache 6 | class Command < CLAide::Command 7 | include Config::Mixin 8 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 9 | 10 | self.abstract_command = true 11 | self.default_subcommand = "use" 12 | self.summary = "xccache - a build caching tool" 13 | 14 | attr_reader :install_options, :build_options 15 | 16 | def initialize(argv) 17 | super 18 | set_ansi_mode 19 | config.verbose = verbose unless verbose.nil? 20 | config.install_config = argv.option("config", "debug") 21 | @install_options = { 22 | :sdks => str_to_sdks(argv.option("sdk")), 23 | :config => config.install_config, 24 | } 25 | @build_options = { 26 | **@install_options, 27 | :log_dir => argv.option("log-dir"), 28 | :recursive => argv.flag?("recursive"), 29 | :merge_slices => argv.flag?("merge-slices", true), 30 | :library_evolution => argv.flag?("library-evolution"), 31 | } 32 | end 33 | 34 | def str_to_sdks(str) 35 | (str || config.default_sdk).split(",").map { |s| Swift::Sdk.new(s) } 36 | end 37 | 38 | private 39 | 40 | def set_ansi_mode 41 | config.ansi = ansi_output? 42 | return if ansi_output? 43 | Colored2.disable! 44 | String.send(:define_method, :colorize) { |s, _| s } 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/xccache/command/base.rb: -------------------------------------------------------------------------------- 1 | require "xccache/installer" 2 | 3 | module XCCache 4 | class Command 5 | class Options 6 | SDK = ["--sdk=foo,bar", "SDKs (iphonesimulator, iphoneos, macos, etc.)"].freeze 7 | CONFIG = ["--config=foo", "Configuration (debug, release) (default: debug)"].freeze 8 | LOG_DIR = ["--log-dir=foo", "Build log directory"].freeze 9 | MERGE_SLICES = [ 10 | "--merge-slices/--no-merge-slices", 11 | "Whether to merge with existing slices/sdks in the xcframework (default: true)", 12 | ].freeze 13 | LIBRARY_EVOLUTION = [ 14 | "--library-evolution/--no-library-evolution", 15 | "Whether to enable library evolution (build for distribution) (default: false)", 16 | ].freeze 17 | 18 | def self.install_options 19 | [SDK, CONFIG] 20 | end 21 | 22 | def self.build_options 23 | install_options + [LOG_DIR, MERGE_SLICES, LIBRARY_EVOLUTION] 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/xccache/command/build.rb: -------------------------------------------------------------------------------- 1 | require "xccache/installer" 2 | require_relative "base" 3 | 4 | module XCCache 5 | class Command 6 | class Build < Command 7 | self.summary = "Build packages to xcframeworks" 8 | def self.options 9 | [ 10 | *Options.build_options, 11 | ["--recursive", "Whether to build their recursive targets if cache-missed (default: false)"], 12 | ].concat(super) 13 | end 14 | self.arguments = [ 15 | CLAide::Argument.new("TARGET", false, true), 16 | ] 17 | 18 | def initialize(argv) 19 | super 20 | @targets = argv.arguments! 21 | end 22 | 23 | def run 24 | Installer::Build.new(ctx: self, targets: @targets).install! 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/xccache/command/cache.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | require "xccache/command/cache/clean" 3 | require "xccache/command/cache/list" 4 | 5 | module XCCache 6 | class Command 7 | class Cache < Command 8 | self.abstract_command = true 9 | self.summary = "Working with cache (list, clean, etc.)" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/xccache/command/cache/clean.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Command 3 | class Cache < Command 4 | class Clean < Cache 5 | self.summary = "Cleaning/purging cache" 6 | self.arguments = [CLAide::Argument.new("TARGET", false, true)] 7 | def self.options 8 | [ 9 | ["--all", "Whether to remove all cache (default: false)"], 10 | ["--dry", "Dry run - don't remove cache, just show what shall be removed (default: false)"], 11 | ].concat(super) 12 | end 13 | 14 | def initialize(argv) 15 | super 16 | @all = argv.flag?("all") 17 | @dry = argv.flag?("dry") 18 | @targets = argv.arguments! 19 | end 20 | 21 | def run 22 | to_remove = @targets.flat_map { |t| config.spm_cache_dir.glob("#{t}/*") } 23 | to_remove = config.spm_cache_dir.glob("*/*") if @all 24 | to_remove.each do |p| 25 | UI.info("Removing #{p.basename.to_s.yellow}") 26 | p.rmtree unless @dry 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/xccache/command/cache/list.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Command 3 | class Cache < Command 4 | class List < Cache 5 | self.summary = "Listing cache" 6 | 7 | def run 8 | target_paths = config.spm_cache_dir.glob("*") 9 | target_paths.each do |target_path| 10 | next if (paths = target_path.glob("*")).empty? 11 | descs = paths.map { |p| " #{p.basename.to_s.green}" } 12 | UI.info <<~DESC 13 | #{target_path.basename.to_s.cyan}: 14 | #{descs.join('\n')} 15 | DESC 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/xccache/command/off.rb: -------------------------------------------------------------------------------- 1 | require "xccache/installer" 2 | require_relative "base" 3 | 4 | module XCCache 5 | class Command 6 | class Off < Command 7 | self.summary = "Force-switch to source mode for specific targets" 8 | self.arguments = [ 9 | CLAide::Argument.new("TARGET", false, true), 10 | ] 11 | 12 | def initialize(argv) 13 | super 14 | @targets = argv.arguments! 15 | end 16 | 17 | def run 18 | return if @targets.empty? 19 | 20 | UI.info("Will force-use source mode for targets: #{@targets}") 21 | @targets.each { |t| config.ignore_list << "*/#{t}" } 22 | Installer::Use.new(ctx: self).install! 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/xccache/command/pkg.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | require "xccache/command/pkg/build" 3 | 4 | module XCCache 5 | class Command 6 | class Pkg < Command 7 | self.abstract_command = true 8 | self.summary = "Working with Swift packages" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/xccache/command/pkg/build.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm" 2 | 3 | module XCCache 4 | class Command 5 | class Pkg < Command 6 | class Build < Pkg 7 | self.summary = "Build a Swift package into an xcframework" 8 | def self.options 9 | [ 10 | *Options.build_options, 11 | ["--out=foo", "Output directory for the xcframework"], 12 | ["--checksum/no-checksum", "Whether to include checksum to the binary name"], 13 | ].concat(super) 14 | end 15 | self.arguments = [ 16 | CLAide::Argument.new("TARGET", false, true), 17 | ] 18 | 19 | def initialize(argv) 20 | super 21 | @targets = argv.arguments! 22 | @out_dir = argv.option("out") 23 | @include_checksum = argv.flag?("checksum") 24 | end 25 | 26 | def run 27 | pkg = SPM::Package.new 28 | pkg.build( 29 | targets: @targets, 30 | config: @config, 31 | out_dir: @out_dir, 32 | checksum: @include_checksum, 33 | **@build_options, 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/xccache/command/remote.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | require "xccache/storage" 3 | require "xccache/command/remote/pull" 4 | require "xccache/command/remote/push" 5 | 6 | module XCCache 7 | class Command 8 | class Remote < Command 9 | self.abstract_command = true 10 | self.summary = "Working with remote cache" 11 | def self.options 12 | [ 13 | Options::CONFIG, 14 | ["--branch=foo", "Cache branch (if using git) (default: main)"], 15 | ].concat(super) 16 | end 17 | 18 | def initialize(argv) 19 | super 20 | @branch = argv.option("branch", "main") 21 | end 22 | 23 | def storage 24 | @storage ||= create_storage 25 | end 26 | 27 | private 28 | 29 | def create_storage 30 | remote_config = config.remote_config 31 | if (remote = remote_config["git"]) 32 | return GitStorage.new(branch: @branch, remote: remote) 33 | elsif (s3_config = remote_config["s3"]) 34 | return S3Storage.new(uri: s3_config["uri"], creds_path: s3_config["creds"]) 35 | end 36 | Storage.new 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/xccache/command/remote/pull.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm" 2 | 3 | module XCCache 4 | class Command 5 | class Remote < Command 6 | class Pull < Remote 7 | self.summary = "Pulling cache to local" 8 | 9 | def run 10 | storage.pull 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/xccache/command/remote/push.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm" 2 | 3 | module XCCache 4 | class Command 5 | class Remote < Command 6 | class Push < Remote 7 | self.summary = "Pushing cache to remote" 8 | 9 | def run 10 | storage.push 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/xccache/command/rollback.rb: -------------------------------------------------------------------------------- 1 | require "xccache/installer" 2 | require_relative "base" 3 | 4 | module XCCache 5 | class Command 6 | class Rollback < Command 7 | self.summary = "Roll back prebuilt cache for packages" 8 | 9 | def run 10 | Installer::Rollback.new(ctx: self).install! 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/xccache/command/use.rb: -------------------------------------------------------------------------------- 1 | require "xccache/installer" 2 | require_relative "base" 3 | 4 | module XCCache 5 | class Command 6 | class Use < Command 7 | self.summary = "Use prebuilt cache for packages" 8 | def self.options 9 | [ 10 | *Options.install_options, 11 | ].concat(super) 12 | end 13 | 14 | def run 15 | Installer::Use.new(ctx: self).install! 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/xccache/core.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/xccache/core/cacheable.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module Cacheable 3 | def cacheable(*method_names) 4 | method_names.each do |method_name| 5 | const_get(__cacheable_module_name).class_eval do 6 | define_method(method_name) do |*args, **kwargs| 7 | @_cache ||= {} 8 | @_cache[method_name] ||= {} 9 | @_cache[method_name][args.hash | kwargs.hash] ||= 10 | method(method_name).super_method.call(*args, **kwargs) 11 | end 12 | end 13 | end 14 | end 15 | 16 | def __cacheable_module_name 17 | "#{name}_Cacheable".gsub(':', '_') 18 | end 19 | 20 | def self.included(base) 21 | base.extend(self) 22 | 23 | module_name = base.send(:__cacheable_module_name) 24 | remove_const(module_name) if const_defined?(module_name) 25 | base.prepend(const_set(module_name, Module.new)) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/xccache/core/config.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | require "xccache/core/syntax/yml" 3 | 4 | module XCCache 5 | class Config < YAMLRepresentable 6 | module Mixin 7 | def config 8 | Config.instance 9 | end 10 | end 11 | 12 | def self.instance 13 | @instance ||= new(Pathname("xccache.yml").expand_path) 14 | end 15 | 16 | attr_accessor :verbose, :ansi 17 | alias verbose? verbose 18 | alias ansi? ansi 19 | 20 | # To distinguish if it's within an installation, or standalone like `xccache pkg build` 21 | attr_accessor :in_installation 22 | alias in_installation? in_installation 23 | 24 | attr_writer :install_config 25 | 26 | def ensure_file! 27 | Template.new("xccache.yml").render(save_to: path) unless path.exist? 28 | end 29 | 30 | def install_config 31 | @install_config || "debug" 32 | end 33 | 34 | def sandbox 35 | @sandbox = Dir.prepare("xccache").expand_path 36 | end 37 | 38 | def spm_sandbox 39 | @spm_sandbox ||= Dir.prepare(sandbox / "packages").expand_path 40 | end 41 | 42 | def spm_local_pkgs_dir 43 | @spm_local_pkgs_dir ||= Dir.prepare(spm_sandbox / "local") 44 | end 45 | 46 | def spm_xcconfigs_dir 47 | @spm_xcconfigs_dir ||= Dir.prepare(spm_sandbox / "xcconfigs") 48 | end 49 | 50 | def spm_cache_dir 51 | @spm_cache_dir ||= Dir.prepare(Pathname("~/.xccache/#{install_config}").expand_path) 52 | end 53 | 54 | def spm_binaries_dir 55 | @spm_binaries_dir ||= Dir.prepare(spm_sandbox / "binaries") 56 | end 57 | 58 | def spm_build_dir 59 | @spm_build_dir ||= spm_umbrella_sandbox / ".build" 60 | end 61 | 62 | def spm_artifacts_dir 63 | @spm_artifacts_dir ||= spm_build_dir / "artifacts" 64 | end 65 | 66 | def spm_proxy_sandbox 67 | @spm_proxy_sandbox ||= Dir.prepare(spm_sandbox / "proxy") 68 | end 69 | 70 | def spm_umbrella_sandbox 71 | @spm_umbrella_sandbox ||= Dir.prepare(spm_sandbox / "umbrella") 72 | end 73 | 74 | def spm_metadata_dir 75 | @spm_metadata_dir ||= Dir.prepare(spm_sandbox / "metadata") 76 | end 77 | 78 | def lockfile 79 | @lockfile ||= Lockfile.new(Pathname("xccache.lock").expand_path) 80 | end 81 | 82 | def cachemap 83 | require "xccache/cache/cachemap" 84 | @cachemap ||= Cache::Cachemap.new(sandbox / "cachemap.json") 85 | end 86 | 87 | def projects 88 | @projects ||= Pathname(".").glob("*.xcodeproj").map do |p| 89 | Xcodeproj::Project.open(p) 90 | end 91 | end 92 | 93 | def project_targets 94 | projects.flat_map(&:targets) 95 | end 96 | 97 | def remote_config 98 | pick_per_install_config(raw["remote"] || {}) 99 | end 100 | 101 | def ignore_list 102 | raw["ignore"] || [] 103 | end 104 | 105 | def ignore?(item) 106 | return true if ignore_local? && lockfile.local_pkg_slugs.include?(item.split("/").first) 107 | ignore_list.any? { |p| File.fnmatch(p, item) } 108 | end 109 | 110 | def ignore_local? 111 | raw["ignore_local"] 112 | end 113 | 114 | def ignore_build_errors? 115 | raw["ignore_build_errors"] 116 | end 117 | 118 | def keep_pkgs_in_project? 119 | raw["keep_pkgs_in_project"] 120 | end 121 | 122 | def default_sdk 123 | raw["default_sdk"] || "iphonesimulator" 124 | end 125 | 126 | private 127 | 128 | def pick_per_install_config(hash) 129 | hash[install_config] || hash["default"] || {} 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/xccache/core/error.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class BaseError < StandardError 3 | end 4 | 5 | class GeneralError < BaseError 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/xccache/core/git.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Git 3 | attr_reader :root 4 | 5 | def initialize(root) 6 | @root = Pathname(root).expand_path 7 | end 8 | 9 | def run(*args, **kwargs) 10 | Sh.run("git -C #{root}", *args, **kwargs) 11 | end 12 | 13 | def sha 14 | run("rev-parse --short HEAD", capture: true, log_cmd: false)[0].strip 15 | end 16 | 17 | def clean? 18 | status("--porcelain", capture: true, log_cmd: false)[0].empty? 19 | end 20 | 21 | def init? 22 | !root.glob(".git").empty? 23 | end 24 | 25 | %i[init checkout fetch pull push clean add commit branch remote switch status].each do |name| 26 | define_method(name) do |*args, **kwargs| 27 | run(name, *args, **kwargs) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/xccache/core/hash.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | def deep_merge(other, uniq_block: nil, sort_block: nil, &block) 3 | dup.deep_merge!(other, uniq_block: uniq_block, sort_block: sort_block, &block) 4 | end 5 | 6 | def deep_merge!(other, uniq_block: nil, sort_block: nil, &block) 7 | merge!(other) do |key, this_val, other_val| 8 | result = if this_val.is_a?(Hash) && other_val.is_a?(Hash) 9 | this_val.deep_merge(other_val, uniq_block: uniq_block, sort_block: sort_block, &block) 10 | elsif this_val.is_a?(Array) && other_val.is_a?(Array) 11 | this_val + other_val 12 | elsif block_given? 13 | block.call(key, this_val, other_val) 14 | else 15 | other_val 16 | end 17 | 18 | # uniq by block, prefer updates 19 | result = result.reverse.uniq(&uniq_block).reverse if uniq_block && result.is_a?(Array) 20 | result = result.sort_by(&sort_block) if sort_block && result.is_a?(Array) 21 | result 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/xccache/core/live_log.rb: -------------------------------------------------------------------------------- 1 | require "monitor" 2 | require "tty-cursor" 3 | require "tty-screen" 4 | 5 | module XCCache 6 | class LiveLog 7 | include UI::Mixin 8 | CURSOR_LOCK = Monitor.new 9 | 10 | attr_reader :output, :max_lines, :lines, :cursor, :tee 11 | 12 | def initialize(**options) 13 | @output = options[:output] || $stdout 14 | @max_lines = options[:max_lines] || 5 15 | @n_sticky = 0 16 | @lines = [] 17 | @cursor = TTY::Cursor 18 | @screen = TTY::Screen 19 | @tee = options[:tee] 20 | end 21 | 22 | def clear 23 | commit do 24 | output.print(cursor.clear_lines(lines.count + @n_sticky)) 25 | @lines = [] 26 | @n_sticky = 0 27 | end 28 | end 29 | 30 | def puts(line, sticky: false) 31 | commit do 32 | output.print(cursor.clear_lines(lines.count + 1)) 33 | if sticky 34 | @n_sticky += 1 35 | output.puts(truncated(line)) 36 | else 37 | lines.shift if lines.count >= max_lines 38 | lines << truncated(line) 39 | end 40 | output.puts(lines) # print non-sticky content 41 | end 42 | File.open(tee, "a") { |f| f << "#{line}\n" } if tee 43 | end 44 | 45 | def capture(header) 46 | header_start = header.magenta.bold 47 | header_success = "#{header} ✔".green.bold 48 | header_error = "#{header} ✖".red.bold 49 | puts(header_start, sticky: true) 50 | yield if block_given? 51 | clear 52 | update_header(header_success) 53 | rescue StandardError => e 54 | update_header(header_error) 55 | raise e 56 | end 57 | 58 | private 59 | 60 | def update_header(header) 61 | commit do 62 | n = lines.count + @n_sticky 63 | output.print(cursor.up(n) + header + cursor.column(0) + cursor.down(n)) 64 | end 65 | end 66 | 67 | def commit 68 | CURSOR_LOCK.synchronize do 69 | yield 70 | output.flush 71 | end 72 | end 73 | 74 | def truncated(msg) 75 | msg.length > @screen.width ? "#{msg[...@screen.width - 3]}..." : msg 76 | end 77 | 78 | def ui_cls 79 | self 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/xccache/core/lockfile.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core/syntax/json" 2 | 3 | module XCCache 4 | class Lockfile < JSONRepresentable 5 | class Pkg < Hash 6 | def self.from_h(h) 7 | Pkg.new.merge(h) 8 | end 9 | 10 | def key 11 | @key ||= ["repositoryURL", "path_from_root", "relative_path"].find { |x| key?(x) } 12 | end 13 | 14 | def id 15 | self[key] 16 | end 17 | 18 | def local? 19 | key != "repositoryURL" 20 | end 21 | 22 | def slug 23 | @slug ||= File.basename(id, ".*") 24 | end 25 | 26 | def relative_path_from_dir(dir) 27 | return id if key == "relative_path" 28 | (Pathname.pwd / id).relative_path_from(dir) if key == "path_from_root" 29 | end 30 | 31 | def local_absolute_path 32 | Pathname.pwd / self["path_from_root"] if local? 33 | end 34 | end 35 | 36 | def hash_for_project(project) 37 | raw[project.display_name] ||= {} 38 | end 39 | 40 | def product_dependencies_by_targets 41 | @product_dependencies_by_targets ||= raw.values.map { |h| h["dependencies"] }.reduce { |acc, h| acc.merge(h) } 42 | end 43 | 44 | def deep_merge!(hash) 45 | raw.deep_merge!( 46 | hash, 47 | uniq_block: proc { |h| h.is_a?(Hash) ? Pkg.from_h(h).id || h : h }, 48 | sort_block: proc { |x| x.to_s.downcase }, 49 | ) 50 | # After deep_merge, clear property cache 51 | (instance_variables - %i[@path @raw]).each do |ivar| 52 | remove_instance_variable(ivar) 53 | end 54 | end 55 | 56 | def pkgs 57 | @pkgs ||= raw.values.flat_map { |h| h["packages"] || [] }.map { |h| Pkg.from_h(h) } 58 | end 59 | 60 | def local_pkgs 61 | @local_pkgs ||= pkgs.select(&:local?).uniq 62 | end 63 | 64 | def local_pkg_slugs 65 | @local_pkg_slugs ||= local_pkgs.map(&:slug).uniq 66 | end 67 | 68 | def known_product_dependencies 69 | raw.empty? ? [] : product_dependencies.reject { |d| File.dirname(d) == "__unknown__" } 70 | end 71 | 72 | def product_dependencies 73 | @product_dependencies ||= product_dependencies_by_targets.values.flatten.uniq 74 | end 75 | 76 | def targets_data 77 | @targets_data ||= product_dependencies_by_targets.transform_keys { |k| "#{k}.xccache" } 78 | end 79 | 80 | def verify! 81 | known_slugs = pkgs.map(&:slug) 82 | unknown = product_dependencies.reject { |d| known_slugs.include?(File.dirname(d)) } 83 | return if unknown.empty? 84 | 85 | UI.error! <<~DESC 86 | Unknown product dependencies at #{path}: 87 | 88 | #{unknown.sort.map { |d| " • #{d}" }.join("\n")} 89 | 90 | Refer to this doc for how to resolve this issue: 91 | https://github.com/trinhngocthuyen/xccache/blob/main/docs/troubleshooting.md#unknown-product-dependencies 92 | DESC 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/xccache/core/log.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core/config" 2 | require "colored2" 3 | 4 | module XCCache 5 | module UI 6 | @indent = 0 7 | 8 | module Mixin 9 | include Config::Mixin 10 | attr_accessor :indent 11 | 12 | def section(title, timing: false) 13 | start = Time.new if timing 14 | ui_cls.puts(title) 15 | self.indent += 2 16 | res = yield if block_given? 17 | self.indent -= 2 18 | if timing 19 | duration = (Time.new - start).to_i 20 | duration = if duration < 60 then "#{duration}s" 21 | elsif duration < 60 * 60 then "#{duration / 60}m" 22 | else 23 | "#{duration / 3600}h" 24 | end 25 | ui_cls.puts("-> Finished: #{title.dark} (#{duration})") 26 | end 27 | res 28 | end 29 | 30 | def message(message) 31 | ui_cls.puts(message) if config.verbose? 32 | end 33 | 34 | def info(message) 35 | ui_cls.puts(message) 36 | end 37 | 38 | def warn(message) 39 | ui_cls.puts(message.yellow) 40 | end 41 | 42 | def error(message) 43 | ui_cls.puts("[ERROR] #{message}".red) 44 | end 45 | 46 | def error!(message) 47 | error(message) 48 | raise GeneralError, message 49 | end 50 | 51 | def puts(message) 52 | $stdout.puts("#{' ' * self.indent}#{message}") 53 | end 54 | 55 | private 56 | 57 | def ui_cls 58 | UI 59 | end 60 | end 61 | 62 | class << self 63 | include Mixin 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/xccache/core/parallel.rb: -------------------------------------------------------------------------------- 1 | require "parallel" 2 | 3 | class Array 4 | def parallel_map(options = {}) 5 | # By default, use in_threads (IO-bound tasks) 6 | default = {} 7 | default[:in_threads] = Parallel.processor_count unless options.key?(:in_processes) 8 | Parallel.map(self, { **default, **options }) { |x| yield x if block_given? } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/xccache/core/sh.rb: -------------------------------------------------------------------------------- 1 | require "open3" 2 | require "xccache/core/config" 3 | require "xccache/core/log" 4 | require "xccache/core/error" 5 | 6 | module XCCache 7 | class Sh 8 | class ExecError < BaseError 9 | end 10 | 11 | class << self 12 | include Config::Mixin 13 | 14 | def capture_output(cmd) 15 | run(cmd, capture: true, log_cmd: false)[0].strip 16 | end 17 | 18 | def run(*args, env: nil, **options) 19 | cmd = args.join(" ") 20 | 21 | out, err = [], [] 22 | handle_out = options[:handle_out] || proc { |l| out << l } 23 | handle_err = options[:handle_err] || proc { |l| err << l } 24 | if (live_log = options[:live_log]) 25 | handle_out = proc { |l| live_log.puts(l) } 26 | handle_err = proc { |l| live_log.puts(l) } 27 | live_log.puts("$ #{cmd}") if options[:log_cmd] != false 28 | elsif options[:log_cmd] != false 29 | UI.message("$ #{cmd}".cyan.dark) 30 | end 31 | 32 | use_popen = options[:capture] || options[:handle_out] || options[:handle_err] || options[:live_log] 33 | return system(cmd) || (raise GeneralError, "Command '#{cmd}' failed") unless use_popen 34 | 35 | popen3_args = env ? [env, cmd] : [cmd] 36 | Open3.popen3(*popen3_args) do |_stdin, stdout, stderr, wait_thr| 37 | stdout_thread = Thread.new { stdout.each { |l| handle_out.call(l.strip) } } 38 | stderr_thread = Thread.new { stderr.each { |l| handle_err.call(l.strip) } } 39 | [stdout_thread, stderr_thread].each(&:join) 40 | result = wait_thr.value 41 | result.exitstatus 42 | raise ExecError, "Command '#{cmd}' failed with status: #{result.exitstatus}" unless result.success? 43 | end 44 | [out.join("\n"), err.join("\n")] 45 | end 46 | 47 | private 48 | 49 | def log_cmd(cmd, live_log: nil) 50 | return live_log.puts("$ #{cmd}") if live_log 51 | UI.message("$ #{cmd}".cyan.dark) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/xccache/core/syntax.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/xccache/core/syntax/hash.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class HashRepresentable 3 | attr_reader :path 4 | attr_accessor :raw 5 | 6 | def initialize(path, raw: nil) 7 | @path = path 8 | @raw = raw || load || {} 9 | end 10 | 11 | def reload 12 | @raw = load || {} 13 | end 14 | 15 | def load 16 | raise NotImplementedError 17 | end 18 | 19 | def merge!(other) 20 | raw.merge!(other) 21 | end 22 | 23 | def save(to: nil) 24 | raise NotImplementedError 25 | end 26 | 27 | def [](key) 28 | raw[key] 29 | end 30 | 31 | def []=(key, value) 32 | raw[key] = value 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/xccache/core/syntax/json.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require_relative "hash" 3 | 4 | module XCCache 5 | class JSONRepresentable < HashRepresentable 6 | def load 7 | JSON.parse(path.read) if path.exist? 8 | rescue StandardError 9 | {} 10 | end 11 | 12 | def save(to: nil) 13 | (to || path).write(JSON.pretty_generate(raw)) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/xccache/core/syntax/plist.rb: -------------------------------------------------------------------------------- 1 | require "cfpropertylist" 2 | require_relative "hash" 3 | 4 | module XCCache 5 | class PlistRepresentable < HashRepresentable 6 | def load 7 | plist = CFPropertyList::List.new(file: path) 8 | CFPropertyList.native_types(plist.value) 9 | rescue StandardError 10 | {} 11 | end 12 | 13 | def save(to: nil) 14 | raise NotImplementedError 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/xccache/core/syntax/yml.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require_relative "hash" 3 | 4 | module XCCache 5 | class YAMLRepresentable < HashRepresentable 6 | def load 7 | YAML.safe_load(path.read) if path.exist? 8 | rescue StandardError 9 | {} 10 | end 11 | 12 | def save(to: nil) 13 | (to || path).write(raw.to_yaml) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/xccache/core/system.rb: -------------------------------------------------------------------------------- 1 | require "digest" 2 | require "mkmf" 3 | require "tmpdir" 4 | 5 | class String 6 | def c99extidentifier 7 | gsub(/[^a-zA-Z0-9.]/, "_") 8 | end 9 | end 10 | 11 | class File 12 | def self.which(bin) 13 | find_executable0(bin) 14 | end 15 | end 16 | 17 | class Dir 18 | def self.prepare(dir, clean: false, expand: false) 19 | dir = Pathname(dir) 20 | dir = dir.expand_path if expand 21 | dir.rmtree if clean && dir.exist? 22 | dir.mkpath 23 | dir 24 | end 25 | 26 | def self.create_tmpdir 27 | dir = Pathname(Dir.mktmpdir("xccache")) 28 | res = block_given? ? (yield dir) : dir 29 | dir.rmtree if block_given? 30 | res 31 | end 32 | 33 | def self.git?(dir) 34 | XCCache::Sh.capture_output("git -C #{dir} rev-parse --git-dir") == ".git" 35 | end 36 | end 37 | 38 | class Pathname 39 | def symlink_to(dst) 40 | dst = Pathname(dst) 41 | dst.rmtree if dst.symlink? 42 | dst.parent.mkpath 43 | File.symlink(expand_path, dst) 44 | end 45 | 46 | def copy(to: nil, to_dir: nil) 47 | dst = to || (Pathname(to_dir) / basename) 48 | dst.rmtree if dst.exist? || dst.symlink? 49 | dst.parent.mkpath 50 | FileUtils.copy_entry(self, dst) 51 | dst 52 | end 53 | 54 | def checksum 55 | hasher = Digest::SHA256.new 56 | glob("**/*").reject { |p| p.directory? || p.symlink? }.sort.each do |p| 57 | p.open("rb") do |f| 58 | while (chunk = f.read(65_536)) # Read 64KB chunks 59 | hasher.update(chunk) 60 | end 61 | end 62 | end 63 | hasher.hexdigest[...8] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/xccache/installer.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm" 2 | require "xccache/installer/integration" 3 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 4 | 5 | module XCCache 6 | class Installer 7 | include PkgMixin 8 | include IntegrationMixin 9 | 10 | def initialize(options = {}) 11 | ctx = options[:ctx] 12 | raise GeneralError, "Missing context (Command) for #{self.class}" if ctx.nil? 13 | @umbrella_pkg = options[:umbrella_pkg] 14 | @install_options = ctx.install_options 15 | @build_options = ctx.build_options 16 | end 17 | 18 | def perform_install 19 | verify_projects! 20 | recreate_config_dirs 21 | projects.each { |project| migrate_umbrella_to_proxy(project) } 22 | UI.message("Using cache dir: #{config.spm_cache_dir}") 23 | config.ensure_file! 24 | config.in_installation = true 25 | sync_lockfile 26 | proxy_pkg.prepare(@install_options) 27 | 28 | yield if block_given? 29 | 30 | gen_supporting_files 31 | projects.each do |project| 32 | add_xccache_refs_to_project(project) 33 | inject_xcconfig_to_project(project) 34 | end 35 | gen_cachemap_viz 36 | end 37 | 38 | def sync_lockfile 39 | UI.info("Syncing lockfile") 40 | known_dependencies = lockfile.known_product_dependencies 41 | update_projects do |project| 42 | lockfile.deep_merge!( 43 | project.display_name => lockfile_hash_for_project(project, known_dependencies) 44 | ) 45 | end 46 | lockfile.save 47 | lockfile.verify! 48 | end 49 | 50 | def lockfile 51 | config.lockfile 52 | end 53 | 54 | def projects 55 | config.projects 56 | end 57 | 58 | def save_projects 59 | yield if block_given? 60 | projects.each(&:save) 61 | end 62 | 63 | def update_projects 64 | projects.each do |project| 65 | yield project if block_given? 66 | project.save 67 | end 68 | end 69 | 70 | private 71 | 72 | def lockfile_hash_for_project(project, known_dependencies) 73 | deps_by_targets = project.targets.to_h do |target| 74 | deps = target.non_xccache_pkg_product_dependencies.map do |dep| 75 | next dep.full_name unless dep.pkg.nil? 76 | known = known_dependencies.find { |x| File.basename(x) == dep.product_name } 77 | UI.warn("-> Assuming #{known} for #{dep.full_name}".dark) if known 78 | known || dep.full_name 79 | end 80 | [target.name, deps.sort] 81 | end 82 | { 83 | "packages" => project.non_xccache_pkgs.map(&:to_h), 84 | "dependencies" => deps_by_targets, 85 | "platforms" => platforms_for_project(project), 86 | } 87 | end 88 | 89 | def platforms_for_project(project) 90 | project 91 | .targets.select(&:platform_name) 92 | .map { |t| [t.platform_name.to_s, t.deployment_target] } 93 | .sort.reverse.to_h # sort descendingly -> min value is picked for the hash 94 | end 95 | 96 | def verify_projects! 97 | raise "No projects detected. Are you running on the correct project directory?" if projects.empty? 98 | end 99 | 100 | def add_xccache_refs_to_project(project) 101 | group = project.xccache_config_group 102 | add_file = proc { |p| group[p.basename.to_s] || group.new_file(p) } 103 | add_file.call(config.spm_proxy_sandbox / "Package.swift") 104 | add_file.call(config.lockfile.path) 105 | add_file.call(config.path) 106 | group.ensure_synced_group(name: "local-packages", path: config.spm_local_pkgs_dir) 107 | end 108 | 109 | def inject_xcconfig_to_project(project) 110 | group = project.xccache_config_group.ensure_synced_group(name: "xcconfigs", path: config.spm_xcconfigs_dir) 111 | project.targets.each do |target| 112 | xcconfig_path = config.spm_xcconfigs_dir / "#{target.name}.xcconfig" 113 | target.build_configurations.each do |build_config| 114 | if (existing = build_config.base_configuration_xcconfig) 115 | next if existing.path == xcconfig_path 116 | 117 | relative_path = xcconfig_path.relative_path_from(existing.path.parent) 118 | next if existing.includes.include?(relative_path.to_s) 119 | 120 | UI.info("Injecting base configuration for #{target} (#{build_config}) (at: #{existing.path})") 121 | existing.path.write <<~DESC 122 | #include "#{relative_path}" // Injected by xccache, for prebuilt macros support 123 | #{existing.path.read.strip} 124 | DESC 125 | else 126 | UI.info("Setting base configuration #{target} (#{build_config}) as #{xcconfig_path}") 127 | build_config.base_configuration_reference_anchor = group 128 | build_config.base_configuration_reference_relative_path = xcconfig_path.basename.to_s 129 | end 130 | end 131 | end 132 | end 133 | 134 | def migrate_umbrella_to_proxy(project) 135 | return unless project.xccache_pkg&.slug == "umbrella" 136 | 137 | UI.info <<~DESC 138 | Migrating from umbrella to proxy for project #{project.display_name} 139 | You should notice changes in project files from xccache/package/umbrella -> xccache/package/proxy. 140 | Don't worry, this is expected. 141 | DESC 142 | .yellow 143 | 144 | project.xccache_pkg.relative_path = "xccache/packages/proxy" 145 | if (group = project.xccache_config_group) && (ref = group["Package.swift"]) 146 | ref.path = "xccache/packages/proxy/Package.swift" 147 | end 148 | end 149 | 150 | def recreate_config_dirs 151 | [ 152 | config.spm_binaries_dir, 153 | config.spm_local_pkgs_dir, 154 | config.spm_xcconfigs_dir, 155 | config.spm_metadata_dir, 156 | ].each { |p| Dir.prepare(p, clean: true) } 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/xccache/installer/build.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm" 2 | 3 | module XCCache 4 | class Installer 5 | class Build < Installer 6 | def initialize(options = {}) 7 | super 8 | @targets = options[:targets] 9 | end 10 | 11 | def install! 12 | perform_install do 13 | build( 14 | targets: @targets, 15 | out_dir: config.spm_cache_dir, 16 | symlinks_dir: config.spm_binaries_dir, 17 | checksum: true, 18 | **@build_options, 19 | ) 20 | proxy_pkg.gen_proxy # Regenerate proxy to apply new cache after build 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/xccache/installer/integration.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | 3 | module XCCache 4 | class Installer 5 | module IntegrationMixin 6 | include VizIntegrationMixin 7 | include DescsIntegrationMixin 8 | include BuildIntegrationMixin 9 | include SupportingFilesIntegrationMixin 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/xccache/installer/integration/build.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Installer 3 | module BuildIntegrationMixin 4 | def build(options = {}) 5 | to_build = targets_to_build(options) 6 | return UI.warn("Detected no targets to build among cache-missed targets") if to_build.empty? 7 | 8 | UI.info("-> Targets to build: #{to_build.to_s.bold}") 9 | umbrella_pkg.build(**options, targets: to_build) 10 | end 11 | 12 | def targets_to_build(options) 13 | items = (options[:targets] || []).map { |x| File.basename(x) } 14 | items = config.cachemap.missed.map { |x| File.basename(x) } if items.empty? 15 | targets = items.map { |x| umbrella_pkg.get_target(x) } 16 | 17 | if options[:recursive] 18 | UI.message("Will include cache-missed recursive targets") 19 | targets += targets.flat_map do |t| 20 | t.recursive_targets.select { |x| config.cachemap.missed?(x.full_name) } 21 | end 22 | end 23 | # TODO: Sort by number of dependents 24 | targets.map(&:full_name).uniq 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/xccache/installer/integration/descs.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Installer 3 | module DescsIntegrationMixin 4 | def xccache_desc 5 | @xccache_desc ||= desc_of("xccache") 6 | end 7 | 8 | def targets_of_products(products) 9 | products = [products] if products.is_a?(String) 10 | products.flat_map { |x| desc_of(x).targets_of_products(File.basename(x)) } 11 | end 12 | 13 | def dependency_targets_of_products(products) 14 | products = [products] if products.is_a?(String) 15 | products.flat_map { |p| @dependency_targets_by_products[p] || [p] }.uniq 16 | end 17 | 18 | def desc_of(d) 19 | descs_by_name[d.split("/").first] 20 | end 21 | 22 | def binary_targets 23 | descs_by_name.values.flatten.uniq.flat_map(&:binary_targets) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/xccache/installer/integration/supporting_files.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Installer 3 | module SupportingFilesIntegrationMixin 4 | def gen_supporting_files 5 | UI.section("Generating supporting files") do 6 | gen_xcconfigs 7 | end 8 | end 9 | 10 | private 11 | 12 | def gen_xcconfigs 13 | macros_config_by_targets.each do |target, hash| 14 | xcconfig_path = config.spm_xcconfigs_dir / "#{target}.xcconfig" 15 | UI.message("XCConfig of target #{target} at: #{xcconfig_path}") 16 | Xcodeproj::Config.new(hash).save_as(xcconfig_path) 17 | end 18 | end 19 | 20 | def macros_config_by_targets 21 | proxy_pkg.graph["macros"].to_h do |target, paths| 22 | swift_flags = paths.map { |p| "-load-plugin-executable #{p}##{File.basename(p, '.*')}" } 23 | hash = { "OTHER_SWIFT_FLAGS" => "$(inherited) #{swift_flags.join(' ')}" } 24 | [File.basename(target, ".*"), hash] 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/xccache/installer/integration/viz.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Installer 3 | module VizIntegrationMixin 4 | def gen_cachemap_viz 5 | stats = config.cachemap.stats 6 | html_path = config.sandbox / "cachemap.html" 7 | js_path = Dir.prepare(config.sandbox / "assets") / "cachemap.js" 8 | css_path = config.sandbox / "assets" / "style.css" 9 | 10 | root_dir = Pathname(".").expand_path 11 | to_relative = proc do |p| 12 | p.to_s.start_with?(root_dir.to_s) ? p.relative_path_from(root_dir).to_s : p.to_s 13 | end 14 | 15 | UI.info("Cachemap visualization: #{html_path}") 16 | Template.new("cachemap.html").render( 17 | { 18 | :root_dir => root_dir.to_s, 19 | :root_dir_short => root_dir.basename.to_s, 20 | :lockfile_path => config.lockfile.path.to_s, 21 | :lockfile_path_short => to_relative.call(config.lockfile.path), 22 | :binaries_dir => config.spm_binaries_dir.to_s, 23 | :binaries_dir_short => to_relative.call(config.spm_binaries_dir), 24 | :desc_hit => stats[:hit], 25 | :desc_missed => stats[:missed], 26 | :desc_ignored => stats[:ignored], 27 | }, 28 | save_to: html_path 29 | ) 30 | Template.new("cachemap.js").render( 31 | { :json => JSON.pretty_generate(config.cachemap.depgraph_data) }, 32 | save_to: js_path 33 | ) 34 | Template.new("cachemap.style.css").render(save_to: css_path) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/xccache/installer/rollback.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Installer 3 | class Rollback < Installer 4 | def install! 5 | update_projects do |project| 6 | UI.section("Rolling back cache for project #{project.display_name}".bold.green) do 7 | rollback_for_project(project) 8 | end 9 | end 10 | end 11 | 12 | private 13 | 14 | def rollback_for_project(project) 15 | hash = lockfile.hash_for_project(project) 16 | pkgs, deps_by_targets = hash["packages"], hash["dependencies"] 17 | 18 | # Add packages back to the project 19 | pkgs.reject { |h| project.has_pkg?(h) }.each do |h| 20 | project.add_pkg(h) 21 | end 22 | 23 | # Add products back to `Link Binary with Libraries` of targets 24 | deps_by_targets.each do |name, deps| 25 | target = project.get_target(name) 26 | deps.reject { |d| target.has_pkg_product_dependency?(d) }.each do |d| 27 | target.add_pkg_product_dependency(d) 28 | end 29 | end 30 | 31 | # Remove .binary product from the project 32 | project.targets.each(&:remove_xccache_product_dependencies) 33 | project.xccache_pkg&.remove_from_project 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/xccache/installer/use.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm" 2 | 3 | module XCCache 4 | class Installer 5 | class Use < Installer 6 | def install! 7 | update_projects do |project| 8 | perform_install do 9 | UI.section("Using cache for project #{project.display_name}".bold.green) do 10 | replace_binaries_for_project(project) 11 | end 12 | end 13 | end 14 | end 15 | 16 | private 17 | 18 | def replace_binaries_for_project(project) 19 | project.add_xccache_pkg unless project.has_xccache_pkg? 20 | project.targets.each do |target| 21 | target.add_xccache_product_dependency unless target.has_xccache_product_dependency? 22 | target.remove_pkg_product_dependencies { |d| d.pkg.nil? || !d.pkg.xccache_pkg? } 23 | end 24 | project.remove_pkgs(&:non_xccache_pkg?) unless config.keep_pkgs_in_project? 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/xccache/main.rb: -------------------------------------------------------------------------------- 1 | require "pry" if ENV["XCCACHE_IMPORT_PRY"] == "true" 2 | require "pathname" 3 | Dir["#{__dir__}/*.rb"].sort.each { |f| require f unless f == __FILE__ } 4 | -------------------------------------------------------------------------------- /lib/xccache/spm.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/xccache/spm/build.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module SPM 3 | class Buildable 4 | attr_reader :name, :module_name, :pkg_dir, :pkg_desc, :sdk, :sdks, :config, :path, :tmpdir, :library_evolution, 5 | :live_log 6 | alias library_evolution? library_evolution 7 | 8 | def initialize(options = {}) 9 | @name = options[:name] 10 | @module_name = @name.c99extidentifier 11 | @pkg_dir = Pathname(options[:pkg_dir] || ".").expand_path 12 | @pkg_desc = options[:pkg_desc] 13 | @ctx_desc = options[:ctx_desc] # Context desc, could be an umbrella or a standalone pkg 14 | @sdks = options[:sdks] || [] 15 | @sdk = options[:sdk] || @sdks&.first 16 | @config = options[:config] || "debug" 17 | @path = options[:path] 18 | @tmpdir = options[:tmpdir] 19 | @library_evolution = options[:library_evolution] 20 | @sdks.each { |sdk| sdk.version = @ctx_desc.platforms[sdk.platform] } if @ctx_desc 21 | @live_log = options[:live_log] 22 | end 23 | 24 | def build(options = {}) 25 | raise NotImplementedError 26 | end 27 | 28 | def swift_build(target: nil) 29 | cmd = ["swift", "build"] + swift_build_args 30 | cmd << "--package-path" << pkg_dir 31 | cmd << "--target" << (target || name) 32 | cmd << "--sdk" << sdk.sdk_path 33 | sdk.swiftc_args.each { |arg| cmd << "-Xswiftc" << arg } 34 | if library_evolution? 35 | # Workaround for swiftinterface emission 36 | # https://github.com/swiftlang/swift/issues/64669#issuecomment-1535335601 37 | cmd << "-Xswiftc" << "-enable-library-evolution" 38 | cmd << "-Xswiftc" << "-alias-module-names-in-module-interface" 39 | cmd << "-Xswiftc" << "-emit-module-interface" 40 | cmd << "-Xswiftc" << "-no-verify-emitted-module-interface" 41 | end 42 | sh(cmd) 43 | end 44 | 45 | def sh(cmd) 46 | Sh.run(cmd, live_log: live_log) 47 | end 48 | 49 | def swift_build_args 50 | [ 51 | "--configuration", config, 52 | "--triple", sdk.triple(with_version: true), 53 | ] 54 | end 55 | 56 | def pkg_target 57 | @pkg_target ||= pkg_desc.get_target(name) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/base.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core" 2 | 3 | module XCCache 4 | module SPM 5 | class Package 6 | class BaseObject < JSONRepresentable 7 | include Config::Mixin 8 | 9 | ATTRS = %i[root retrieve_pkg_desc].freeze 10 | attr_accessor(*ATTRS) 11 | 12 | def name 13 | raw["name"] 14 | end 15 | 16 | def full_name 17 | is_a?(Description) ? name : "#{pkg_slug}/#{name}" 18 | end 19 | 20 | def inspect 21 | to_s 22 | end 23 | 24 | def display_name 25 | name 26 | end 27 | 28 | def to_s 29 | "<#{self.class} name=#{display_name}>" 30 | end 31 | 32 | def cast_to(cls) 33 | o = cls.new(path, raw: raw) 34 | ATTRS.each { |sym| o.send("#{sym}=", send(sym.to_s)) } 35 | o 36 | end 37 | 38 | def pkg_name 39 | @pkg_name ||= root.name 40 | end 41 | 42 | def pkg_slug 43 | @pkg_slug ||= src_dir.basename.to_s 44 | end 45 | 46 | def fetch(key, dtype) 47 | raw[key].map do |h| 48 | o = dtype.new(nil, raw: h) 49 | o.root = root 50 | o.retrieve_pkg_desc = retrieve_pkg_desc 51 | o 52 | end 53 | end 54 | 55 | def pkg_desc_of(name) 56 | retrieve_pkg_desc.call(name) 57 | end 58 | 59 | def src_dir 60 | @src_dir ||= Pathname(root.raw["path"]).parent 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/dep.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm/desc/base" 2 | 3 | module XCCache 4 | module SPM 5 | class Package 6 | class Dependency < BaseObject 7 | def display_name 8 | slug 9 | end 10 | 11 | def local? 12 | raw.key?("fileSystem") 13 | end 14 | 15 | def hash 16 | @hash ||= local? ? raw["fileSystem"].first : raw["sourceControl"].first 17 | end 18 | 19 | def slug 20 | @slug ||= 21 | if hash.key?("path") 22 | File.basename(hash["path"]) 23 | elsif (location = hash["location"]) && location.key?("remote") 24 | File.basename(location["remote"].flat_map(&:values)[0], ".*") 25 | else 26 | hash["identity"] 27 | end 28 | end 29 | 30 | def path 31 | @path ||= Pathname(hash["path"]).expand_path if local? 32 | end 33 | 34 | def pkg_desc 35 | @pkg_desc ||= pkg_desc_of(slug) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/desc.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm/desc/base" 2 | 3 | module XCCache 4 | module SPM 5 | class Package 6 | class Description < BaseObject 7 | include Cacheable 8 | 9 | def self.descs_in_metadata_dir(dir) 10 | descs = dir.glob("*.json").map { |p| Description.new(p) } 11 | [descs, combine_descs(descs)] 12 | end 13 | 14 | def root 15 | self 16 | end 17 | 18 | def metadata 19 | raw["_metadata"] ||= {} 20 | end 21 | 22 | def platforms 23 | @platforms ||= raw.fetch("platforms", []).to_h { |h| [h["platformName"].to_sym, h["version"]] } 24 | end 25 | 26 | def dependencies 27 | @dependencies ||= fetch("dependencies", Dependency) 28 | end 29 | 30 | def uniform_dependencies 31 | dependencies.filter_map(&:pkg_desc) 32 | end 33 | 34 | def products 35 | @products ||= fetch("products", Product) 36 | end 37 | 38 | def targets 39 | @targets ||= fetch("targets", Target).map(&:downcast) 40 | end 41 | 42 | def binary_targets 43 | @binary_targets ||= targets.select(&:binary?) 44 | end 45 | 46 | def has_target?(name) 47 | targets.any? { |t| t.name == name } 48 | end 49 | 50 | def get_target(name) 51 | targets.find { |t| t.name == name } 52 | end 53 | 54 | def targets_of_products(name) 55 | matched_products = products.select { |p| p.name == name } 56 | matched_products 57 | .flat_map { |p| targets.select { |t| p.target_names.include?(t.name) } } 58 | end 59 | 60 | def local? 61 | # Workaround: If the pkg dir is under the build checkouts dir -> remote 62 | !src_dir.to_s.start_with?((config.spm_build_dir / "checkouts").to_s) 63 | end 64 | 65 | def traverse 66 | nodes, edges, parents = [], [], {} 67 | to_visit = targets.dup 68 | visited = Set.new 69 | until to_visit.empty? 70 | cur = to_visit.pop 71 | next if visited.include?(cur) 72 | 73 | visited << cur 74 | nodes << cur 75 | yield cur if block_given? 76 | 77 | # For macro impl, we don't need their dependencies, just the tool binary 78 | # So, no need to care about swift-syntax dependencies 79 | next if cur.macro? 80 | cur.direct_dependency_targets.each do |t| 81 | to_visit << t 82 | edges << [cur, t] 83 | parents[t] ||= [] 84 | parents[t] << cur 85 | end 86 | end 87 | [nodes, edges, parents] 88 | end 89 | 90 | def git 91 | @git ||= Git.new(src_dir) if Dir.git?(src_dir) 92 | end 93 | 94 | def self.combine_descs(descs) 95 | descs_by_name = descs.flat_map { |d| [[d.name, d], [d.pkg_slug, d]] }.to_h 96 | descs.each { |d| d.retrieve_pkg_desc = proc { |name| descs_by_name[name] } } 97 | descs_by_name 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/product.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm/desc/base" 2 | 3 | module XCCache 4 | module SPM 5 | class Package 6 | class Product < BaseObject 7 | include Cacheable 8 | cacheable :recursive_targets 9 | 10 | def target_names 11 | raw["targets"] 12 | end 13 | 14 | def flatten_as_targets 15 | targets 16 | end 17 | 18 | def targets 19 | @targets ||= root.targets.select { |t| target_names.include?(t.name) } 20 | end 21 | 22 | def library? 23 | type == :library 24 | end 25 | 26 | def type 27 | @type ||= raw["type"].keys.first.to_sym 28 | end 29 | 30 | def recursive_targets(platform: nil) 31 | targets + targets.flat_map { |t| t.recursive_targets(platform: platform) }.uniq 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/target.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm/desc/base" 2 | 3 | module XCCache 4 | module SPM 5 | class Package 6 | class Target < BaseObject 7 | include Cacheable 8 | cacheable :recursive_targets, :direct_dependency_targets, :direct_dependencies 9 | 10 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 11 | 12 | def xccache? 13 | name.end_with?(".xccache") 14 | end 15 | 16 | def xccache_id 17 | macro? ? "#{full_name}.macro" : full_name 18 | end 19 | 20 | def type 21 | @type ||= raw["type"].to_sym 22 | end 23 | 24 | def downcast 25 | cls = { 26 | :binary => BinaryTarget, 27 | :macro => MacroTarget, 28 | }[type] 29 | cls.nil? ? self : cast_to(cls) 30 | end 31 | 32 | def module_name 33 | name.c99extidentifier 34 | end 35 | 36 | def resource_bundle_name 37 | "#{pkg_name}_#{name}.bundle" 38 | end 39 | 40 | def flatten_as_targets 41 | [self] 42 | end 43 | 44 | def sources_path 45 | @sources_path ||= begin 46 | path = raw["path"] || "Sources/#{name}" 47 | root.src_dir / path 48 | end 49 | end 50 | 51 | def use_clang? 52 | !header_paths.empty? 53 | end 54 | 55 | def header_paths(options = {}) 56 | paths = [] 57 | paths += public_header_paths if options.fetch(:public, true) 58 | paths += header_search_paths if options.fetch(:search, false) 59 | paths 60 | .flat_map { |p| p.glob("**/*.h*") } 61 | .map(&:realpath) 62 | .uniq 63 | end 64 | 65 | def settings 66 | raw["settings"] 67 | end 68 | 69 | def header_search_paths 70 | @header_search_paths ||= 71 | settings 72 | .filter_map { |h| h.fetch("kind", {})["headerSearchPath"] } 73 | .flat_map(&:values) 74 | .map { |p| sources_path / p } 75 | end 76 | 77 | def public_header_paths 78 | @public_header_paths ||= begin 79 | res = [] 80 | implicit_path = sources_path / "include" 81 | res << implicit_path unless implicit_path.glob("**/*.h*").empty? 82 | res << (sources_path / raw["publicHeadersPath"]) if raw.key?("publicHeadersPath") 83 | res 84 | end 85 | end 86 | 87 | def resource_paths 88 | @resource_paths ||= begin 89 | res = raw.fetch("resources", []).map { |h| sources_path / h["path"] } 90 | # Refer to the following link for the implicit resources 91 | # https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package#Add-resource-files 92 | implicit = sources_path.glob("*.{xcassets,xib,storyboard,xcdatamodeld,lproj}") 93 | res + implicit 94 | end 95 | end 96 | 97 | def recursive_targets(platform: nil) 98 | children = direct_dependency_targets(platform: platform) 99 | children += children.flat_map { |t| t.macro? ? [t] : t.recursive_targets(platform: platform) } 100 | children.uniq 101 | end 102 | 103 | def direct_dependencies(platform: nil) 104 | raw["dependencies"].flat_map do |hash| 105 | dep_types = ["byName", "target", "product"] 106 | if (dep_type = dep_types.intersection(hash.keys).first).nil? 107 | raise GeneralError, "Unexpected dependency type. Must be one of #{dep_types}. Hash: #{hash}" 108 | end 109 | next [] unless match_platform?(hash[dep_type][-1], platform) 110 | pkg_name = hash[dep_type][1] if dep_type == "product" 111 | find_deps(hash[dep_type][0], pkg_name, dep_type) 112 | end 113 | end 114 | 115 | def direct_dependency_targets(platform: nil) 116 | direct_dependencies(platform: platform).flat_map(&:flatten_as_targets).uniq 117 | end 118 | 119 | def match_platform?(_condition, _platform) 120 | true # FIXME: Handle this 121 | end 122 | 123 | def macro? 124 | type == :macro 125 | end 126 | 127 | def binary? 128 | type == :binary 129 | end 130 | 131 | def binary_path 132 | sources_path if binary? 133 | end 134 | 135 | def local_binary_path 136 | binary_path if binary? && root.local? 137 | end 138 | 139 | def checksum 140 | @checksum ||= root.git&.sha || sources_path.checksum 141 | end 142 | 143 | private 144 | 145 | def find_deps(name, pkg_name, dep_type) 146 | # If `dep_type` is `target` -> constrained within current pkg only 147 | # If `dep_type` is `product` -> `pkg_name` must be present 148 | # If `dep_type` is `byName` -> it's either from this pkg, or its children/dependencies 149 | res = [] 150 | descs = pkg_name.nil? ? [root] + root.uniform_dependencies : [pkg_desc_of(pkg_name)] 151 | descs.each do |desc| 152 | by_target = -> { desc.targets.select { |t| t.name == name } } 153 | by_product = -> { desc.products.select { |t| t.name == name } } 154 | return by_target.call if dep_type == "target" 155 | return by_product.call if dep_type == "product" 156 | return res unless (res = by_target.call).empty? 157 | return res unless (res = by_product.call).empty? 158 | end 159 | [] 160 | end 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/target/binary.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module SPM 3 | class Package 4 | class BinaryTarget < Target 5 | end 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/xccache/spm/desc/target/macro.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module SPM 3 | class Package 4 | class MacroTarget < Target 5 | end 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/xccache/spm/macro.rb: -------------------------------------------------------------------------------- 1 | require_relative "build" 2 | 3 | module XCCache 4 | module SPM 5 | class Macro < Buildable 6 | def initialize(options = {}) 7 | super 8 | @library_evolution = false # swift-syntax is not compatible with library evolution 9 | end 10 | 11 | def build(_options = {}) 12 | # NOTE: Building macro binary is tricky... 13 | # -------------------------------------------------------------------------------- 14 | # Consider this manifest config: .target(Macro) -> .macro(MacroImpl) 15 | # where `.target(Macro)` contains the interfaces 16 | # and `.target(MacroImpl)` contains the implementation 17 | # -------------------------------------------------------------------------------- 18 | # Building `.macro(MacroImpl)` does not produce the tool binary (MacroImpl-tool)... Only `.o` files. 19 | # Yet, linking those files are exhaustive due to many dependencies in swift-syntax 20 | # Luckily, building `.target(Macro)` does produce the tool binary. 21 | # -> WORKAROUND: Find the associated regular target and build it, then collect the tool binary 22 | # --------------------------------------------------------------------------------- 23 | associated_target = pkg_desc.targets.find { |t| t.direct_dependency_targets.include?(pkg_target) } 24 | live_log.info( 25 | "#{name.yellow.dark} is a macro target. " \ 26 | "Will build the associated target #{associated_target.name.dark} to get the tool binary." 27 | ) 28 | swift_build(target: associated_target.name) 29 | binary_path = products_dir / "#{module_name}-tool" 30 | raise GeneralError, "Tool binary not exist at: #{binary_path}" unless binary_path.exist? 31 | binary_path.copy(to: path) 32 | FileUtils.chmod("+x", path) 33 | live_log.info("-> Macro binary: #{path}") 34 | end 35 | 36 | def products_dir 37 | @products_dir ||= pkg_dir / ".build" / "arm64-apple-macosx" / config 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/xccache/spm/mixin.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module PkgMixin 3 | include Config::Mixin 4 | 5 | def umbrella_pkg 6 | proxy_pkg.umbrella 7 | end 8 | 9 | def proxy_pkg 10 | @proxy_pkg ||= SPM::Package::Proxy.new(root_dir: config.spm_proxy_sandbox) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/xccache/spm/pkg.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/xccache/spm/pkg/base.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "xccache/spm/xcframework/slice" 3 | require "xccache/spm/xcframework/xcframework" 4 | require "xccache/spm/xcframework/metadata" 5 | require "xccache/spm/pkg/proxy" 6 | require "xccache/swift/sdk" 7 | 8 | module XCCache 9 | module SPM 10 | class Package 11 | include Config::Mixin 12 | include Proxy::Mixin 13 | 14 | include Cacheable 15 | cacheable :pkg_desc_of_target 16 | 17 | attr_reader :root_dir 18 | 19 | def initialize(options = {}) 20 | @root_dir = Pathname(options[:root_dir] || ".").expand_path 21 | end 22 | 23 | def build(options = {}) 24 | validate! 25 | targets = (options.delete(:targets) || []).map { |t| t.split("/")[-1] } 26 | raise GeneralError, "No targets were specified" if targets.empty? 27 | 28 | Dir.create_tmpdir do |tmpdir| 29 | targets.each_with_index do |t, i| 30 | target_tmpdir = Dir.prepare(tmpdir / t) 31 | log_dir = Dir.prepare(options[:log_dir] || target_tmpdir) 32 | live_log = LiveLog.new(tee: log_dir / "build_#{t}.log") 33 | live_log.capture("[#{i + 1}/#{targets.count}] Building target: #{t}") do 34 | build_target(**options, target: t, live_log: live_log, tmpdir: target_tmpdir) 35 | end 36 | rescue StandardError => e 37 | UI.error("Error: #{e}\n" + "For details, check out: #{live_log.tee}".yellow.bold) 38 | raise e unless Config.instance.ignore_build_errors? 39 | end 40 | end 41 | end 42 | 43 | def build_target(target: nil, sdks: nil, config: nil, out_dir: nil, **options) 44 | target_pkg_desc = pkg_desc_of_target( 45 | target, 46 | ensure_exist: true, 47 | ) 48 | if target_pkg_desc.binary_targets.any? { |t| t.name == target } 49 | return UI.warn("Target #{target} is a binary target -> no need to build") 50 | end 51 | 52 | target = target_pkg_desc.get_target(target) 53 | 54 | out_dir = Pathname(out_dir || ".") 55 | out_dir /= target.name if options[:checksum] 56 | ext = target.macro? ? ".macro" : ".xcframework" 57 | basename = options[:checksum] ? "#{target.name}-#{target.checksum}" : target.name 58 | binary_path = out_dir / "#{basename}#{ext}" 59 | 60 | cls = target.macro? ? Macro : XCFramework 61 | cls.new( 62 | name: target.name, 63 | pkg_dir: root_dir, 64 | config: config, 65 | sdks: sdks, 66 | path: binary_path, 67 | tmpdir: options[:tmpdir], 68 | pkg_desc: target_pkg_desc, 69 | ctx_desc: pkg_desc || target_pkg_desc, 70 | library_evolution: options[:library_evolution], 71 | live_log: options[:live_log], 72 | ).build(**options) 73 | return if (symlinks_dir = options[:symlinks_dir]).nil? 74 | binary_path.symlink_to(symlinks_dir / target.name / "#{target.name}#{ext}") 75 | end 76 | 77 | def resolve 78 | return if @resolved 79 | xccache_proxy.run("resolve --pkg #{root_dir} --metadata #{metadata_dir}") 80 | @resolved = true 81 | end 82 | 83 | def pkg_desc 84 | descs_by_name[root_dir.basename.to_s] 85 | end 86 | 87 | def pkg_desc_of_target(name, **options) 88 | resolve 89 | desc = descs.find { |d| d.has_target?(name) } 90 | raise GeneralError, "Cannot find package with the given target #{name}" if options[:ensure_exist] && desc.nil? 91 | desc 92 | end 93 | 94 | def get_target(name) 95 | pkg_desc_of_target(name)&.get_target(name) 96 | end 97 | 98 | private 99 | 100 | def validate! 101 | return unless root_dir.glob("Package*.swift").empty? 102 | raise GeneralError, "No Package.swift in #{root_dir}. Are you sure you're running on a package dir?" 103 | end 104 | 105 | def metadata_dir 106 | config.in_installation? ? config.spm_metadata_dir : root_dir / ".build/metadata" 107 | end 108 | 109 | def descs 110 | @descs ||= load_descs[0] 111 | end 112 | 113 | def descs_by_name 114 | @descs_by_name ||= load_descs[1] 115 | end 116 | 117 | def load_descs 118 | @descs, @descs_by_name = Description.descs_in_metadata_dir(metadata_dir) 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/xccache/spm/pkg/proxy.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | require_relative "proxy_executable" 3 | 4 | module XCCache 5 | module SPM 6 | class Package 7 | class Proxy < Package 8 | module Mixin 9 | def xccache_proxy 10 | @xccache_proxy ||= Executable.new 11 | end 12 | end 13 | 14 | include Mixin 15 | 16 | def umbrella 17 | @umbrella ||= Package.new(root_dir: config.spm_umbrella_sandbox) 18 | end 19 | 20 | def prepare(options = {}) 21 | xccache_proxy.run("gen-umbrella") 22 | umbrella.resolve 23 | invalidate_cache(sdks: options[:sdks]) 24 | gen_proxy 25 | end 26 | 27 | def gen_proxy 28 | xccache_proxy.run("gen-proxy") 29 | config.cachemap.update_from_graph(graph.reload) 30 | end 31 | 32 | def invalidate_cache(sdks: []) 33 | UI.message("Invalidating cache (sdks: #{sdks.map(&:to_s).join(', ')})") 34 | 35 | config.spm_cache_dir.glob("*/*.{xcframework,macro}").each do |p| 36 | cmps = p.basename(".*").to_s.split("-") 37 | name, checksum = cmps[...-1].join("-"), cmps[-1] 38 | p_without_checksum = config.spm_binaries_dir / name / "#{name}#{p.extname}" 39 | accept_cache = proc { p.symlink_to(p_without_checksum) } 40 | reject_cache = proc { p_without_checksum.rmtree if p_without_checksum.exist? } 41 | next reject_cache.call if (target = umbrella.get_target(name)).nil? 42 | next reject_cache.call if target.checksum != checksum 43 | # For macro, we just need the tool binary to exist 44 | next accept_cache.call if target.macro? 45 | 46 | # For regular targets, the xcframework must satisfy the sdk constraints (ie. containing all the slices) 47 | metadata = XCFramework::Metadata.new(p / "Info.plist") 48 | expected_triples = sdks.map { |sdk| sdk.triple(with_vendor: false) } 49 | missing_triples = expected_triples - metadata.triples 50 | missing_triples.empty? ? accept_cache.call : reject_cache.call 51 | end 52 | end 53 | 54 | def graph 55 | @graph ||= JSONRepresentable.new(root_dir / "graph.json") 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/xccache/spm/pkg/proxy_executable.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module SPM 3 | class Package 4 | class Proxy < Package 5 | class Executable 6 | REPO_URL = "https://github.com/trinhngocthuyen/xccache-proxy".freeze 7 | VERSION_OR_SHA = "1.0.0".freeze 8 | 9 | def run(cmd) 10 | env = { "FORCE_OUTPUT" => "console", "FORCE_COLOR" => "1" } if Config.instance.ansi? 11 | cmd = cmd.is_a?(Array) ? [bin_path.to_s] + cmd : [bin_path.to_s, cmd] 12 | cmd << "--verbose" if Config.instance.verbose? 13 | Sh.run(cmd, env: env) 14 | end 15 | 16 | def bin_path 17 | @bin_path ||= lookup 18 | end 19 | 20 | private 21 | 22 | def lookup 23 | [ 24 | local_bin_path, 25 | default_bin_path, 26 | ].find(&:exist?) || download_or_build_from_source 27 | end 28 | 29 | def default_use_downloaded? 30 | VERSION_OR_SHA.include?(".") 31 | end 32 | 33 | def download_or_build_from_source 34 | default_use_downloaded? ? download : build_from_source 35 | end 36 | 37 | def build_from_source 38 | UI.section("Building xccache-proxy binary from source...".magenta) do 39 | dir = Dir.prepare("~/.xccache/xccache-proxy", expand: true) 40 | git = Git.new(dir) 41 | git.init unless git.init? 42 | git.remote("add", "origin", REPO_URL) unless git.remote(capture: true)[0].strip == "origin" 43 | git.fetch("origin", VERSION_OR_SHA) 44 | git.checkout("-f", "FETCH_HEAD", capture: true) 45 | 46 | Dir.chdir(dir) { Sh.run("make build CONFIGURATION=release") } 47 | (dir / ".build" / "release" / "xccache-proxy").copy(to: default_bin_path) 48 | end 49 | end 50 | 51 | def download 52 | UI.section("Downloading xccache-proxy binary from remote...".magenta) do 53 | Dir.create_tmpdir do |dir| 54 | url = "#{REPO_URL}/releases/download/#{VERSION_OR_SHA}/xccache-proxy.zip" 55 | default_bin_path.parent.mkpath 56 | tmp_path = dir / File.basename(url) 57 | Sh.run("curl -fSL -o #{tmp_path} #{url} && unzip -d #{default_bin_path.parent} #{tmp_path}") 58 | FileUtils.chmod("+x", default_bin_path) 59 | end 60 | end 61 | default_bin_path 62 | end 63 | 64 | def default_bin_path 65 | @default_bin_path ||= begin 66 | dir = LIBEXEC / (default_use_downloaded? ? ".download" : ".build") 67 | dir / "xccache-proxy-#{VERSION_OR_SHA}" / "xccache-proxy" 68 | end 69 | end 70 | 71 | def local_bin_path 72 | @local_bin_path ||= LIBEXEC / ".local" / "xccache-proxy" 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/xccache/spm/xcframework.rb: -------------------------------------------------------------------------------- 1 | require_relative "build" 2 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 3 | -------------------------------------------------------------------------------- /lib/xccache/spm/xcframework/metadata.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core/syntax/plist" 2 | 3 | module XCCache 4 | module SPM 5 | class XCFramework 6 | class Metadata < PlistRepresentable 7 | class Library < Hash 8 | def id 9 | self["LibraryIdentifier"] 10 | end 11 | 12 | def platform 13 | self["SupportedPlatform"] 14 | end 15 | 16 | def archs 17 | self["SupportedArchitectures"] 18 | end 19 | 20 | def simulator? 21 | self["SupportedPlatformVariant"] == "simulator" 22 | end 23 | 24 | def triples 25 | @triples ||= archs.map do |arch| 26 | simulator? ? "#{arch}-#{platform}-simulator" : "#{arch}-#{platform}" 27 | end 28 | end 29 | end 30 | 31 | def available_libraries 32 | @available_libraries ||= raw.fetch("AvailableLibraries", []).map { |h| Library.new.merge(h) } 33 | end 34 | 35 | def triples 36 | @triples ||= available_libraries.flat_map(&:triples) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/xccache/spm/xcframework/slice.rb: -------------------------------------------------------------------------------- 1 | require "xccache/utils/template" 2 | 3 | module XCCache 4 | module SPM 5 | class FrameworkSlice < Buildable 6 | def build(_options = {}) 7 | live_log.puts("Building #{name}.framework (#{config}, #{sdk})".cyan, sticky: true) 8 | swift_build 9 | create_framework 10 | end 11 | 12 | private 13 | 14 | def override_resource_bundle_accessor 15 | # By default, Swift generates resource_bundle_accessor.swift for targets having resources 16 | # (Check .build/.build/DerivedSources/resource_bundle_accessor.swift) 17 | # This enables accessing the resource bundle via `Bundle.module`. 18 | # However, `Bundle.module` expects the resource bundle to be under the app bundle, 19 | # which is not the case for binary targets. Instead the bundle is under `Frameworks/.framework` 20 | # WORKAROUND: 21 | # - Overriding resource_bundle_accessor.swift to add `Frameworks/.framework` to the search list 22 | # - Compiling this file into an `.o` file before using `libtool` to create the framework binary 23 | live_log.info("Override resource_bundle_accessor") 24 | template_name = use_clang? ? "resource_bundle_accessor.m" : "resource_bundle_accessor.swift" 25 | source_path = tmpdir / File.basename(template_name) 26 | obj_path = products_dir / "#{module_name}.build" / "#{source_path.basename}.o" 27 | Template.new(template_name).render( 28 | { :pkg => pkg_target.pkg_name, :target => name, :module_name => module_name }, 29 | save_to: source_path 30 | ) 31 | 32 | if use_clang? 33 | cmd = ["xcrun", "clang"] 34 | cmd << "-x" << "objective-c" 35 | cmd << "-target" << sdk.triple(with_version: true) << "-isysroot" << sdk.sdk_path 36 | cmd << "-o" << obj_path.to_s 37 | cmd << "-c" << source_path 38 | else 39 | cmd = ["xcrun", "swiftc"] 40 | cmd << "-emit-library" << "-emit-object" 41 | cmd << "-module-name" << module_name 42 | cmd << "-target" << sdk.triple(with_version: true) << "-sdk" << sdk.sdk_path 43 | cmd << "-o" << obj_path.to_s 44 | cmd << source_path 45 | end 46 | sh(cmd) 47 | end 48 | 49 | def create_framework 50 | override_resource_bundle_accessor if resource_bundle_product_path.exist? 51 | create_info_plist 52 | create_framework_binary 53 | create_headers 54 | create_modules 55 | copy_resource_bundles if resource_bundle_product_path.exist? 56 | end 57 | 58 | def create_framework_binary 59 | # Write .o file list into a file 60 | obj_paths = products_dir.glob("#{module_name}.build/**/*.o") 61 | raise GeneralError, "Detected no object files for #{name}" if obj_paths.empty? 62 | 63 | objlist_path = tmpdir / "objects.txt" 64 | objlist_path.write(obj_paths.map(&:to_s).join("\n")) 65 | 66 | cmd = ["libtool", "-static"] 67 | cmd << "-o" << "#{path}/#{module_name}" 68 | cmd << "-filelist" << objlist_path.to_s 69 | sh(cmd) 70 | FileUtils.chmod("+x", path / module_name) 71 | end 72 | 73 | def create_info_plist 74 | Template.new("framework.info.plist").render( 75 | { :module_name => module_name }, 76 | save_to: path / "Info.plist", 77 | ) 78 | end 79 | 80 | def create_headers 81 | copy_headers 82 | end 83 | 84 | def create_modules 85 | copy_swiftmodules unless use_clang? 86 | 87 | live_log.info("Creating framework modulemap") 88 | Template.new("framework.modulemap").render( 89 | { :module_name => module_name, :target => name }, 90 | save_to: modules_dir / "module.modulemap" 91 | ) 92 | end 93 | 94 | def copy_headers 95 | live_log.info("Copying headers") 96 | swift_header_paths = products_dir.glob("#{module_name}.build/*-Swift.h") 97 | paths = swift_header_paths + pkg_target.header_paths 98 | paths.each { |p| process_header(p) } 99 | 100 | umbrella_header_content = paths.map { |p| "#include <#{module_name}/#{p.basename}>" }.join("\n") 101 | (headers_dir / "#{name}-umbrella.h").write(umbrella_header_content) 102 | end 103 | 104 | def process_header(path) 105 | handle_angle_bracket_import = proc do |statement, header| 106 | next statement if header.include?("/") 107 | 108 | # NOTE: If importing a header with flat angle-bracket style (ex. `#import `) 109 | # The header `foo.h` may belong to a dependency's headers. 110 | # When packaging into xcframework, `#import ` no longer works because `foo.h` 111 | # coz it's not visible within the framework's headers 112 | # -> We need to explicitly specify the module it belongs to, ex. `#import ` 113 | targets = [pkg_target] + pkg_target.recursive_targets 114 | target = targets.find { |t| t.header_paths.any? { |p| p.basename.to_s == header } } 115 | next statement if target.nil? 116 | 117 | corrected_statement = statement.sub("<#{header}>", "<#{target.module_name}/#{header}>") 118 | <<~CONTENT 119 | // ------------------------------------------------------------------------------------------------- 120 | // NOTE: This import was corrected by xccache, from flat angle-bracket to nested angle-bracket style 121 | // Original: `#{statement}` 122 | #{corrected_statement} 123 | // ------------------------------------------------------------------------------------------------- 124 | CONTENT 125 | end 126 | 127 | content = path.read.gsub(/^ *#import <(.+)>/) { |m| handle_angle_bracket_import.call(m, $1) } 128 | (headers_dir / path.basename).write(content) 129 | end 130 | 131 | def copy_swiftmodules 132 | live_log.info("Copying swiftmodules") 133 | swiftmodule_dir = Dir.prepare("#{modules_dir}/#{module_name}.swiftmodule") 134 | swiftinterfaces = products_dir.glob("#{module_name}.build/#{module_name}.swiftinterface") 135 | to_copy = products_dir.glob("Modules/#{module_name}.*") + swiftinterfaces 136 | to_copy.each do |p| 137 | p.copy(to: swiftmodule_dir / p.basename.sub(module_name, sdk.triple)) 138 | end 139 | end 140 | 141 | def copy_resource_bundles 142 | resolve_resource_symlinks 143 | live_log.info("Copying resource bundle to framework: #{resource_bundle_product_path.basename}") 144 | resource_bundle_product_path.copy(to_dir: path) 145 | end 146 | 147 | def resolve_resource_symlinks 148 | # Well, Xcode seems to well handle symlinks in resources. In xcodebuild log, you would see something like: 149 | # CpResource: builtin-copy ... -resolve-src-symlinks 150 | # But this is not the case if we build with `swift build`. Here, we have to manually handle it 151 | resource_bundle_product_path.glob("**/*").select(&:symlink?).reject(&:exist?).each do |p| 152 | UI.message("Resolve resource symlink: #{p}") 153 | original = pkg_target.resource_paths.find { |rp| rp.symlink? && rp.readlink == p.readlink } 154 | original&.realpath&.copy(to: p) 155 | end 156 | end 157 | 158 | def products_dir 159 | @products_dir ||= pkg_dir / ".build" / sdk.triple / config 160 | end 161 | 162 | def use_clang? 163 | pkg_target.use_clang? 164 | end 165 | 166 | def resource_bundle_product_path 167 | @resource_bundle_product_path ||= products_dir / pkg_target.resource_bundle_name 168 | end 169 | 170 | def headers_dir 171 | @headers_dir ||= Dir.prepare(path / "Headers") 172 | end 173 | 174 | def modules_dir 175 | @modules_dir ||= Dir.prepare(path / "Modules") 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/xccache/spm/xcframework/xcframework.rb: -------------------------------------------------------------------------------- 1 | require "xccache/spm/build" 2 | 3 | module XCCache 4 | module SPM 5 | class XCFramework < Buildable 6 | attr_reader :slices 7 | 8 | def initialize(options = {}) 9 | super 10 | @slices ||= @sdks.map do |sdk| 11 | FrameworkSlice.new( 12 | **options, 13 | sdks: [sdk], 14 | path: Dir.prepare(tmpdir / sdk.triple / "#{module_name}.framework"), 15 | ) 16 | end 17 | end 18 | 19 | def build(merge_slices: false, **_options) 20 | tmp_new_path = tmpdir / "new.xcframework" 21 | tmp_existing_path = tmpdir / "existing.framework" 22 | 23 | slices.each(&:build) 24 | create_xcframework(from: slices.map(&:path), to: tmp_new_path) 25 | 26 | path.copy(to: tmp_existing_path) if path.exist? && merge_slices 27 | path.rmtree if path.exist? 28 | 29 | if merge_slices && tmp_existing_path.exist? 30 | framework_paths = 31 | [tmp_new_path, tmp_existing_path] 32 | .flat_map { |p| p.glob("*/*.framework") } 33 | .uniq { |p| p.parent.basename.to_s } # uniq by id (ex. ios-arm64), preferred new ones 34 | create_xcframework(from: framework_paths, to: path) 35 | else 36 | path.parent.mkpath 37 | tmp_new_path.copy(to: path) 38 | end 39 | live_log.info("-> XCFramework: #{path}") 40 | end 41 | 42 | def create_xcframework(options = {}) 43 | live_log.info("Creating xcframework from slices") 44 | cmd = ["xcodebuild", "-create-xcframework"] 45 | cmd << "-allow-internal-distribution" unless library_evolution? 46 | cmd << "-output" << options[:to] 47 | options[:from].each { |p| cmd << "-framework" << p } 48 | cmd << "> /dev/null" # Only care about errors 49 | sh(cmd) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/xccache/storage.rb: -------------------------------------------------------------------------------- 1 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/xccache/storage/base.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | class Storage 3 | include Config::Mixin 4 | 5 | def initialize(options = {}); end 6 | 7 | def pull 8 | print_warnings 9 | end 10 | 11 | def push 12 | print_warnings 13 | end 14 | 15 | private 16 | 17 | def print_warnings 18 | UI.warn <<~DESC 19 | Do nothing as remote cache is not set up yet. 20 | 21 | To set it up, specify `remote` in `xccache.yml`. 22 | See: https://github.com/trinhngocthuyen/xccache/blob/main/docs/configuration.md#remote 23 | DESC 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/xccache/storage/git.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | 3 | module XCCache 4 | class GitStorage < Storage 5 | attr_reader :branch 6 | 7 | def initialize(options = {}) 8 | super 9 | if (@remote = options[:remote]) 10 | schemes = ["http://", "https://", "git@"] 11 | @remote = File.expand_path(@remote) unless schemes.any? { |x| @remote.start_with?(x) } 12 | ensure_remote 13 | end 14 | @branch = options[:branch] 15 | end 16 | 17 | def pull 18 | git.fetch("--depth 1 origin #{branch}") 19 | git.switch("--detach FETCH_HEAD", capture: true) 20 | git.clean("-dfx", capture: true) 21 | # Re-create local branch so that it has the latest from remote 22 | git.branch("-D #{branch} || true", capture: true) 23 | git.checkout("-b #{branch}", capture: true) 24 | end 25 | 26 | def push 27 | return UI.info("No changes to push, cache repo is clean".magenta) if git.clean? 28 | 29 | git.add(".") 30 | git.commit("-m \"Update cache at #{Time.new}\"") 31 | git.push("-u origin #{branch}") 32 | end 33 | 34 | private 35 | 36 | def git 37 | @git ||= Git.new(config.spm_cache_dir) 38 | end 39 | 40 | def ensure_remote 41 | git.init unless git.init? 42 | existing = git.remote("get-url origin || true", capture: true, log_cmd: false)[0].strip 43 | return if @remote == existing 44 | return git.remote("add origin #{@remote}") if existing.empty? 45 | git.remote("set-url origin #{@remote}") 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/xccache/storage/s3.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | 3 | module XCCache 4 | class S3Storage < Storage 5 | def initialize(options = {}) 6 | super 7 | @uri = options[:uri] 8 | @creds_path = Pathname(options[:creds_path] || "~/.xccache/s3.creds.json").expand_path 9 | creds = JSONRepresentable.new(@creds_path) 10 | @access_key_id = creds["access_key_id"] 11 | @secret_access_key = creds["secret_access_key"] 12 | end 13 | 14 | def pull 15 | s3_sync(src: @uri, dst: config.spm_cache_dir) 16 | end 17 | 18 | def push 19 | s3_sync(src: config.spm_cache_dir, dst: @uri) 20 | end 21 | 22 | private 23 | 24 | def s3_sync(src: nil, dst: nil) 25 | validate! 26 | UI.info("Syncing cache from #{src.to_s.bold} to #{dst.to_s.bold}...") 27 | env = { 28 | "AWS_ACCESS_KEY_ID" => @access_key_id, 29 | "AWS_SECRET_ACCESS_KEY" => @secret_access_key, 30 | } 31 | cmd = ["aws", "s3", "sync"] 32 | cmd << "--exact-timestamps" << "--delete" 33 | cmd << "--include" << "*.xcframework" 34 | cmd << "--include" << "*.macro" 35 | cmd << src << dst 36 | Sh.run(cmd, env: env) 37 | end 38 | 39 | def validate! 40 | if File.which("awsss").nil? 41 | raise GeneralError, "awscli is not installed. Please install it via brew: `brew install awscli`" 42 | end 43 | return unless @access_key_id.nil? || @secret_access_key.nil? 44 | raise GeneralError, <<~DESC 45 | Please ensure the credentials json at #{@creds_path}. Example: 46 | { 47 | "access_key_id": , 48 | "secret_access_key": 49 | } 50 | DESC 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/xccache/swift/sdk.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core/sh" 2 | 3 | module XCCache 4 | module Swift 5 | class Sdk 6 | attr_reader :name, :arch, :vendor, :platform 7 | attr_accessor :version 8 | 9 | NAME_TO_PLATFORM = { 10 | :iphonesimulator => :ios, 11 | :iphoneos => :ios, 12 | :macos => :macos, 13 | :watchos => :watchos, 14 | :watchsimulator => :watchos, 15 | :appletvos => :tvos, 16 | :appletvsimulator => :tvos, 17 | :xros => :xros, 18 | :xrsimulator => :xros, 19 | }.freeze 20 | 21 | def initialize(name, version: nil) 22 | @name = name.to_sym 23 | @vendor = "apple" 24 | @arch = "arm64" 25 | @platform = NAME_TO_PLATFORM.fetch(@name, @name) 26 | @version = version 27 | return if NAME_TO_PLATFORM.key?(@name) 28 | raise GeneralError, "Unknown sdk: #{@name}. Must be one of #{NAME_TO_PLATFORM.keys}" 29 | end 30 | 31 | def to_s 32 | name.to_s 33 | end 34 | 35 | def triple(with_vendor: true, with_version: false) 36 | cmps = [arch] 37 | cmps << vendor if with_vendor 38 | cmps << (with_version && version ? "#{platform}#{version}" : platform.to_s) 39 | cmps << "simulator" if simulator? 40 | cmps.join("-") 41 | end 42 | 43 | def sdk_name 44 | name == :macos ? :macosx : name 45 | end 46 | 47 | def sdk_path 48 | # rubocop:disable Layout/LineLength 49 | # /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk 50 | # rubocop:enable Layout/LineLength 51 | @sdk_path ||= Pathname(Sh.capture_output("xcrun --sdk #{sdk_name} --show-sdk-path")).realpath 52 | end 53 | 54 | def sdk_platform_developer_path 55 | @sdk_platform_developer_path ||= sdk_path.parent.parent # iPhoneSimulator.platform/Developer 56 | end 57 | 58 | def swiftc_args 59 | developer_library_frameworks_path = sdk_platform_developer_path / "Library" / "Frameworks" 60 | developer_usr_lib_path = sdk_platform_developer_path / "usr" / "lib" 61 | [ 62 | "-F#{developer_library_frameworks_path}", 63 | "-I#{developer_usr_lib_path}", 64 | ] 65 | end 66 | 67 | def simulator? 68 | name.to_s.end_with?("simulator") 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/xccache/swift/swiftc.rb: -------------------------------------------------------------------------------- 1 | module XCCache 2 | module Swift 3 | module Swiftc 4 | def self.version 5 | @version ||= begin 6 | m = /Apple Swift version ([\d\.]+)/.match(Sh.capture_output("xcrun swift -version")) 7 | m.nil? ? "6.0" : m[1] 8 | end 9 | end 10 | 11 | def self.version_without_patch 12 | version.split(".")[...2].join(".") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/xccache/utils/template.rb: -------------------------------------------------------------------------------- 1 | require "erb" 2 | require "xccache/core/error" 3 | 4 | module XCCache 5 | class Template 6 | attr_reader :name, :path 7 | 8 | def initialize(name) 9 | @name = name 10 | @path = ROOT / "lib/xccache/assets/templates/#{name}.template" 11 | end 12 | 13 | def render(hash = {}, save_to: nil) 14 | raise GeneralError, "Template not found: #{name}" if path.nil? 15 | 16 | rendered = ERB.new(File.read(@path)).result_with_hash(hash) 17 | Pathname(save_to).write(rendered) unless save_to.nil? 18 | rendered 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj.rb: -------------------------------------------------------------------------------- 1 | require "xccache/core" 2 | Dir["#{__dir__}/#{File.basename(__FILE__, '.rb')}/*.rb"].sort.each { |f| require f } 3 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/build_configuration.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | module Object 6 | class XCBuildConfiguration 7 | def base_configuration_xcconfig 8 | path = base_configuration_xcconfig_path 9 | Config.new(path) if path 10 | end 11 | 12 | def base_configuration_xcconfig_path 13 | return base_configuration_reference.real_path if base_configuration_reference 14 | return unless base_configuration_reference_anchor && base_configuration_reference_relative_path 15 | project.dir / base_configuration_reference_anchor.path / base_configuration_reference_relative_path 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/config.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Config 5 | def path 6 | @filepath 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/file_system_synchronized_root_group.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | module Object 6 | class PBXFileSystemSynchronizedRootGroup 7 | attribute :name, String 8 | 9 | def display_name 10 | return name if name 11 | return File.basename(path) if path 12 | super 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/group.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | module Object 6 | class PBXGroup 7 | def synced_groups 8 | children.grep(PBXFileSystemSynchronizedRootGroup) 9 | end 10 | 11 | def ensure_synced_group(name: nil, path: nil) 12 | synced_groups.find { |g| g.name == name } || new_synced_group(name: name, path: path) 13 | end 14 | 15 | def new_synced_group(name: nil, path: nil) 16 | path = path.relative_path_from(project.dir) unless path.relative? 17 | synced_group = project.new(PBXFileSystemSynchronizedRootGroup) 18 | synced_group.path = path.to_s 19 | synced_group.name = name || path.basename.to_s 20 | self << synced_group 21 | synced_group 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/pkg.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | module Object 6 | module PkgRefMixin 7 | def id 8 | local? ? (relative_path || path) : repositoryURL 9 | end 10 | 11 | def slug 12 | File.basename(id, File.extname(id)) 13 | end 14 | 15 | def local? 16 | is_a?(XCLocalSwiftPackageReference) 17 | end 18 | 19 | def xccache_pkg? 20 | local? && ["xccache/packages/umbrella", "xccache/packages/proxy"].include?(id) 21 | end 22 | 23 | def non_xccache_pkg? 24 | !xccache_pkg? 25 | end 26 | 27 | def create_pkg_product_dependency_ref(product) 28 | ref = project.new(XCSwiftPackageProductDependency) 29 | ref.package = self 30 | ref.product_name = product 31 | ref 32 | end 33 | 34 | def create_target_dependency_ref(product) 35 | ref = project.new(PBXTargetDependency) 36 | ref.name = product 37 | ref.product_ref = create_pkg_product_dependency_ref(product) 38 | ref 39 | end 40 | end 41 | 42 | class XCLocalSwiftPackageReference 43 | include PkgRefMixin 44 | 45 | def ascii_plist_annotation 46 | # Workaround: Xcode is using display_name while Xcodeproj is using File.basename(display_name) 47 | # Here, the plugin forces to use display_name so that updates either by Xcode or Xcodeproj are consistent 48 | " #{isa} \"#{display_name}\" " 49 | end 50 | 51 | def to_h 52 | { 53 | "path_from_root" => absolute_path.relative_path_from(Pathname.pwd).to_s, 54 | } 55 | end 56 | 57 | def absolute_path 58 | path.nil? ? project.dir / relative_path : path 59 | end 60 | end 61 | 62 | class XCRemoteSwiftPackageReference 63 | include PkgRefMixin 64 | 65 | def to_h 66 | { "repositoryURL" => repositoryURL, "requirement" => requirement } 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/pkg_product_dependency.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | module Object 6 | class XCSwiftPackageProductDependency 7 | def full_name 8 | @full_name ||= "#{pkg&.slug || '__unknown__'}/#{product_name}" 9 | end 10 | 11 | def pkg 12 | return package if package 13 | return if @warned_missing_pkg 14 | @warned_missing_pkg = true 15 | Log.warn("Missing pkg of product dependency #{uuid}: #{to_hash}") 16 | end 17 | 18 | def remove_alongside_related 19 | target = referrers.find { |x| x.is_a?(PBXNativeTarget) } 20 | Log.info( 21 | "(-) Remove #{product_name.red} from product dependencies of target #{target.display_name.bold}" 22 | ) 23 | target.dependencies.each { |x| x.remove_from_project if x.product_ref == self } 24 | target.build_phases.each do |phase| 25 | phase.files.select { |f| f.remove_from_project if f.product_ref == self } 26 | end 27 | remove_from_project 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/project.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | Log = XCCache::UI 6 | 7 | def display_name 8 | relative_path.to_s 9 | end 10 | 11 | def relative_path 12 | @relative_path ||= path.relative_path_from(Pathname(".").expand_path) 13 | end 14 | 15 | def dir 16 | path.parent 17 | end 18 | 19 | def pkgs 20 | root_object.package_references 21 | end 22 | 23 | def xccache_pkg 24 | pkgs.find(&:xccache_pkg?) 25 | end 26 | 27 | def non_xccache_pkgs 28 | pkgs.reject(&:xccache_pkg?) 29 | end 30 | 31 | def has_pkg?(hash) 32 | pkg_hash = XCCache::Lockfile::Pkg.from_h(hash) 33 | pkgs.any? { |p| p.id == pkg_hash.id } 34 | end 35 | 36 | def has_xccache_pkg? 37 | pkgs.any?(&:xccache_pkg?) 38 | end 39 | 40 | def add_pkg(hash) 41 | pkg_hash = XCCache::Lockfile::Pkg.from_h(hash) 42 | pkg_hash["relative_path"] = pkg_hash.relative_path_from_dir(dir).to_s if pkg_hash.key == "path_from_root" 43 | 44 | Log.info("Add package #{pkg_hash.id.bold} to project #{display_name.bold}") 45 | cls = pkg_hash.local? ? XCLocalSwiftPackageReference : XCRemoteSwiftPackageReference 46 | ref = new(cls) 47 | custom_keys = ["path_from_root"] 48 | pkg_hash.each { |k, v| ref.send("#{k}=", v) unless custom_keys.include?(k) } 49 | root_object.package_references << ref 50 | ref 51 | end 52 | 53 | def add_xccache_pkg 54 | sandbox_path = XCCache::Config.instance.spm_proxy_sandbox 55 | add_pkg("relative_path" => sandbox_path.relative_path_from(path.parent).to_s) 56 | end 57 | 58 | def remove_pkgs(&block) 59 | pkgs.select(&block).each do |pkg| 60 | Log.info("(-) Remove #{pkg.display_name.red} from package refs of project #{display_name.bold}") 61 | pkg.remove_from_project 62 | end 63 | end 64 | 65 | def get_target(name) 66 | targets.find { |t| t.name == name } 67 | end 68 | 69 | def get_pkg(name) 70 | pkgs.find { |p| p.slug == name } 71 | end 72 | 73 | def xccache_config_group 74 | self["xccache.config"] || new_group("xccache.config") 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/xccache/xcodeproj/target.rb: -------------------------------------------------------------------------------- 1 | require "xcodeproj" 2 | 3 | module Xcodeproj 4 | class Project 5 | module Object 6 | class PBXNativeTarget 7 | alias pkg_product_dependencies package_product_dependencies 8 | 9 | def non_xccache_pkg_product_dependencies 10 | pkg_product_dependencies.reject { |d| d.pkg&.xccache_pkg? } 11 | end 12 | 13 | def has_xccache_product_dependency? 14 | pkg_product_dependencies.any? { |d| d.pkg&.xccache_pkg? } 15 | end 16 | 17 | def has_pkg_product_dependency?(name) 18 | pkg_product_dependencies.any? { |d| d.full_name == name } 19 | end 20 | 21 | def add_pkg_product_dependency(name) 22 | Log.info("(+) Add dependency #{name.blue} to target #{display_name.bold}") 23 | pkg_name, product_name = name.split("/") 24 | pkg = project.get_pkg(pkg_name) 25 | pkg_product_dependencies << pkg.create_target_dependency_ref(product_name).product_ref 26 | end 27 | 28 | def add_xccache_product_dependency 29 | add_pkg_product_dependency("proxy/#{name}.xccache") 30 | end 31 | 32 | def remove_xccache_product_dependencies 33 | remove_pkg_product_dependencies { |d| d.pkg&.xccache_pkg? } 34 | end 35 | 36 | def remove_pkg_product_dependencies(&block) 37 | package_product_dependencies.select(&block).each(&:remove_alongside_related) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /xccache.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | Gem::Specification.new do |spec| 5 | spec.name = "xccache" 6 | spec.version = File.read("VERSION").strip 7 | spec.authors = ["Thuyen Trinh"] 8 | spec.email = ["trinhngocthuyen@gmail.com"] 9 | spec.description = "A Ruby gem" 10 | spec.summary = spec.description 11 | spec.homepage = "https://github.com/trinhngocthuyen/xccache" 12 | spec.license = "MIT" 13 | 14 | spec.files = Dir["{lib,bin}/**/*"] 15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | spec.require_paths = ["lib"] 17 | 18 | spec.add_dependency "claide" 19 | spec.add_dependency "parallel" 20 | spec.add_dependency "tty-cursor" 21 | spec.add_dependency "tty-screen" 22 | spec.add_dependency "xcodeproj", ">= 1.26.0" 23 | end 24 | --------------------------------------------------------------------------------