Skip to content

Instantly share code, notes, and snippets.

@peteranny
Last active August 8, 2020 06:19
Show Gist options
  • Save peteranny/8014be5135c9829ca3b5570a1d9420af to your computer and use it in GitHub Desktop.
Save peteranny/8014be5135c9829ca3b5570a1d9420af to your computer and use it in GitHub Desktop.
An example Podfile-like script which applies patches between Carthage checkout and build. https://medium.com/@tingyishih/ios-migrate-from-cocoapods-to-carthage-7be237e4cac0
#!/usr/bin/ruby -W0
=begin
This file is to replace Cartfile to achieve patches between Carthage checkout and build
Please run `./Cartfile.patch` to browse its usage
=end
require_relative "Cartfile.patch.utils"
carthage do
github "mxcl/PromiseKit", "~> 6.8" do
patch_build_settings xcodeproj_path: "Carthage/Checkouts/PromiseKit/PromiseKit.xcodeproj", target_names: ["PromiseKit"], static: true
end
end
require "xcodeproj"
require "pathname"
require "FileUtils"
require "json"
## Argument parsing
class Arguments
attr_accessor :action, :args
def initialize(arglist)
@action = arglist[0]
@args = arglist[1..-1]
end
end
$args = Arguments.new(ARGV)
## Public functions
def carthage
END {
Logger.log "Loading #{File.basename($0)}..." do
$cartfile = Cartfile.new
yield
end
case $args.action
when "bootstrap"
raise "❌ Please specify available dependency names\n\nUsage:\n#{bootstrap_usage}\n" unless bootstrap_valid_dep_names?($args.args)
bootstrap($args.args) do yield end
when "update"
raise "❌ Please specify available dependency names\n\nUsage:\n#{update_usage}\n" unless update_valid_dep_names?($args.args)
update($args.args) do yield end
when "export"
# Export Cartfile for debugging
Logger.log "Exporting Cartfile..." do
$cartfile.export(Path.path(fromRoot: "Cartfile"))
end
else
raise "❌ Unknown action. Available actions: bootstrap, update\n\nUsage:\n#{bootstrap_usage}#{update_usage}\n"
end
}
end
def binary(repo, constraint = nil, &patch)
$cartfile.addConstraint(type: "binary", repo: repo, constraint: constraint, patch: patch)
end
def github(repo, constraint = nil, &patch)
$cartfile.addConstraint(type: "github", repo: repo, constraint: constraint, patch: patch)
end
def git(repo, constraint = nil, &patch)
$cartfile.addConstraint(type: "git", repo: repo, constraint: constraint, patch: patch)
end
private
## Private functions
class Path
def self.root
Dir.pwd
end
def self.path(fromRoot:)
"#{root}/#{fromRoot}"
end
end
class Cartfile
attr_accessor :deps
def initialize
@deps = {}
end
def addConstraint(type:, repo:, constraint:, patch:)
name = Dependency.name(forRepo: repo)
@deps[name] = Dependency.new(type: type, repo: repo, patch: patch) unless @deps.has_key?(name)
dep = @deps[name]
raise "❌ Dependency `#{name}` has conflicting types `#{type}` and `#{dep.type}`" unless type == dep.type
raise "❌ Dependency `#{name}` has conflicting repos `#{repo}` and `#{dep.repo}`" unless repo == dep.repo
raise "❌ Dependency `#{name}` should not have more than one patch blocks" unless patch.nil? || patch == dep.patch
dep.constraints.push(constraint) unless constraint.nil?
end
def compatible?
@deps.each { |name, dep| dep.compatible? }
end
def load_resolved(path)
return unless File.exist?(path)
File.open(path).readlines.each_with_index { |line, index|
matched = line.match(/(\w+) "(.+)" "(.+)"/)
raise "❌ Unable to parse L#{index + 1} in Cartfile.resolved" if matched.nil?
type, repo, resolved = matched.captures
name = Dependency.name(forRepo: repo)
@deps[name] = Dependency.new(type: type, repo: repo, patch: nil) unless @deps.has_key?(name)
@deps[name].resolved = resolved
}
end
def export(path)
File.open(path, "w") { |file|
for name, dep in @deps do
constraint = dep.constraints.first
next if constraint.nil?
constraint = "\"#{constraint}\"" if constraint.match(/(>=|~>|==) \s*(.*)/).nil? # Non-semantic constraint
file.write "#{dep.type} \"#{dep.repo}\" #{constraint}\n"
end
file
}
end
def to_s
"#{@deps}"
end
end
class Dependency
attr_accessor :type, :repo, :constraints, :patch # From Cartfile.patch
attr_accessor :resolved # From Cartfile.resolved
attr_accessor :version, :builds # From *.version
def initialize(type:, repo:, patch:)
@type = type
@repo = repo
@constraints = []
@patch = patch
end
def compatible?
name = self.class.name(forRepo: @repo)
raise "❌ Dependency `#{name}` is not resolved. #{fix_usage(name: name, action: "update")}`" if @resolved.nil?
@constraints.each { |constraint|
raise "❌ Dependency `#{name}` has resolved version `#{@resolved}` incompatible with constraints #{@constraints}. #{fix_usage(name: name, action: "update")}\nConstraints found in:\n#{`grep -l #{name} #{Path.path(fromRoot: "Carthage/Checkouts/*/Cartfile")}`}" unless Version.compatible?(version: @resolved, constraint: constraint)
}
end
def load_version(path)
raise "❌ Unable to open `#{path}`" unless File.exist?(path)
json = JSON.parse(File.read(path))
@version = json["commitish"]
@builds = []
for info in json["iOS"] do
path = nil
case info["linking"]
when "dynamic"
path = Path.path(fromRoot: "Carthage/Build/iOS/#{info["name"]}.framework")
when "static"
path = Path.path(fromRoot: "Carthage/Build/iOS/Static/#{info["name"]}.framework")
end
raise "❌ Framework `#{path}` is missing" unless File.exists?(path)
@builds.push(path)
end
end
def to_s
"<type:#{@type} repo:#{@repo} constraints:#{@constraints} patch:#{@patch} resolved:#{@resolved} version:#{@version} builds:#{@builds}>"
end
def self.name(forRepo: repo)
forRepo.split("/").last.split(".").first
end
end
class Version
def self.semantic(version:)
version.match(/(\d+)\.(\d+)(?:\.(\d+))?/)
end
def self.compatible?(version:, constraint:)
# Version matching
matched = semantic(version: version)
return equal?(version, constraint) if matched.nil? # Non-semantic version
major, minor, patch = matched.captures # Semantic version
# Constraint matching
matched = constraint.match(/(>=|~>|==) \s*(.*)/)
return equal?(version, constraint) if matched.nil? # Non-relational constraint
relation, _version = matched.captures # Relational constraint
matched = semantic(version: _version)
return equal?(@resolved, constraint) if matched.nil? # Non-semantic constraint
_major, _minor, _patch = matched.captures # Semantic constraint
# Compare semantic constraint
case relation
when "=="
major == _major && minor == _minor && patch == _patch
when "~>"
major == _major && (minor > _minor || (minor == _minor && patch >= _patch))
when ">="
major > _major || (major == _major && (minor > _minor || (minor == _minor && patch >= _patch)))
end
end
def self.equal?(v1, v2)
matched = semantic(version: v1)
return v1.sub(/"(.*)"/, '\1')[0..6] == v2.sub(/"(.*)"/, '\1')[0..6] if matched.nil? # Non-semantic version. Commit hash prefix check
major, minor, patch = matched.captures
matched = semantic(version: v2)
return v1.sub(/"(.*)"/, '\1')[0..6] == v2.sub(/"(.*)"/, '\1')[0..6] if matched.nil? # Non-semantic version. Commit hash prefix check
_major, _minor, _patch = matched.captures
return major == _major && minor == _minor && patch == _patch
end
end
class Logger
@@indent = 0
def self.log(s)
puts "#{" " * @@indent}#{s}"
if block_given? then
@@indent += 1
yield
@@indent -= 1
end
end
end
def sh(cmd)
Logger.log "> #{cmd}"
raise "❌ Command failed > #{cmd}" unless system cmd
end
def enabled?(options:, key:, default:)
if default then
options.nil? or options[key].nil? or options[key]
else
not options.nil? and options[key]
end
end
def fix_usage(name:, action:)
"Please fix that by `#{$0} #{action} #{name}` before your `#{$0} #{ARGV.join(" ")}`"
end
# MARK: - bootstrap
def bootstrap_available_dep_names
cartfile = Cartfile.new
cartfile.load_resolved(Path.path(fromRoot: "Cartfile.resolved"))
cartfile.deps.keys
end
def bootstrap_valid_dep_names?(names)
!names.empty? && names.all? { |name| bootstrap_available_dep_names.include?(name) }
end
def bootstrap_usage
return "
$ #{$0} bootstrap [dep1 dep2 ...]
Build without updating versions. I.e., bootstrap dependencies based on `Cartfile.resolved`.
Available deps: #{bootstrap_available_dep_names.join(" ")}
"
end
def bootstrap(names)
Logger.log "Loading Cartfile.resolved..." do
$cartfile.load_resolved(Path.path(fromRoot: "Cartfile.resolved"))
end
$cartfile.compatible?
checked_names = []
Logger.log "Checking out..." do
# XXX: We should checkout all dependencies so as to perfom overall compatibility check
# But it's too time consuming so we checkout only the descendants of the specified dependencies for simplicity
for name in names do
dep = $cartfile.deps[name]
checkout(name, checked_names) if dep.type == "github" || dep.type == "git"
end
end
# XXX: We should have checked here but some buggy version found by us is allowed by `carthage update`
# We skip the check for now until carthage is fixed
# $cartfile.compatible?
Logger.log "Patching..." do
patch(checked_names)
end
Logger.log "Building..." do
build(names)
end
Logger.log "Cleaning..." do
clean()
end
end
def checkout(name, checked_names)
return if checked_names.include?(name)
sh "carthage checkout #{name}"
checked_names.push(name)
# Checkout transitive dependencies
path = Path.path(fromRoot: "Carthage/Checkouts/#{name}/Cartfile")
return unless File.exists?(path)
for line in File.open(path).readlines do
matched = line.match(/(\w+) "(\S*)" (.*)/)
next if matched.nil?
type, repo, constraint = matched.captures
name = Dependency.name(forRepo: repo)
checkout(name, checked_names)
# Collect constraints for later compatibility test
$cartfile.addConstraint(type: type, repo: repo, constraint: constraint, patch: nil)
end
end
def patch(names)
for name in names do
dep = $cartfile.deps[name]
Logger.log "Patching `#{name}`" do
dep.patch.call() unless dep.patch.nil?
end
end
end
def build(names)
sh "carthage build #{names.join(" ")} --platform ios --no-use-binaries"
end
def clean
cartfile = Cartfile.new
cartfile.load_resolved(Path.path(fromRoot: "Cartfile.resolved"))
# Remove unused version files
for path in Dir[Path.path(fromRoot: "Carthage/Build/.*.version")] do
matched = File.basename(path).match(/^\.(?<name>.*)\.version$/)
raise "❌ Unable to parse the name from path `#{path}`" if matched.nil?
unless cartfile.deps.has_key?(matched[:name]) then
Logger.log "Removing #{path}"
FileUtils.rm(path)
end
end
# Find used builds
checked_paths = []
for name, dep in cartfile.deps do
dep.load_version(Path.path(fromRoot: "Carthage/Build/.#{name}.version"))
raise "❌ Dependency `#{name}` has mismatched version `#{dep.version}` with resolved `#{dep.resolved}`. #{fix_usage(name: name, action: "bootstrap")}" unless Version.equal?(dep.version, dep.resolved)
checked_paths.concat(dep.builds)
end
# Remove unused builds
for path in Dir[Path.path(fromRoot: "Carthage/Build/iOS/*.framework")] + Dir[Path.path(fromRoot: "Carthage/Build/iOS/Static/*.framework")] do
unless checked_paths.include?(path) then
Logger.log "Removing #{path}"
FileUtils.rm_r(path)
end
end
end
# MARK: - update
def update_available_dep_names
$cartfile.deps.keys
end
def update_valid_dep_names?(names)
return true if names == ["--"]
!names.empty? && names.all? { |name| update_available_dep_names.include?(name) }
end
def update_usage
return "
$ #{$0} update [dep1 dep2 ...]
Update versions and build. I.e., bootstrap dependencies based on `Cartfile.resolved` updated according to `#{File.basename($0)}`
Available deps: #{update_available_dep_names.join(" ")}
$ #{$0} update --
Update versions for all deps without build. Used when removing deps. I.e., update `Cartfile.resolved` according to `Cartfile.patch`
"
end
def update(names)
update_all_without_build = names == ["--"]
file = nil
Logger.log "Exporting Cartfile..." do
file = $cartfile.export(Path.path(fromRoot: "Cartfile"))
end
begin
Logger.log "Updating Cartfile.resolved..." do
if update_all_without_build then
sh "carthage update --no-checkout"
else
sh "carthage update #{names.join(" ")} --no-checkout"
end
end
ensure
Logger.log "Cleaning Cartfile..." do
FileUtils.rm(file.path)
end
end
bootstrap(names) do yield end unless update_all_without_build
end
public
# πŸ‘‡πŸ‘‡πŸ‘‡
# To facilitate patching required changes to the given target of a project
# Define public utilities here for us to easily call from `Cartfile.patch`
# Please write customized methods here in a readable manner
# Ref: https://github.com/Carthage/Carthage/issues/2575#issuecomment-420971052
def use_xcodeproj(xcodeproj_path:)
raise "❌ Invalid project path #{xcodeproj_path}" unless File.exists?(xcodeproj_path)
Logger.log "Using #{xcodeproj_path}" do
project = Xcodeproj::Project.open(xcodeproj_path)
yield project
project.save
end
end
def create_scheme(xcodeproj_path:, target_name:, scheme_name:)
use_xcodeproj_path(xcodeproj_path) { |project|
foreach_ios_target(project: project, target_names: [target_name]) { |target, config|
scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(target)
scheme.save_as(xcodeproj_path, scheme_name)
Logger.log "βœ… Created shared scheme `#{scheme_name}` for target `#{target.name}` in #{xcodeproj_path}"
}
}
end
def foreach_ios_target(project:, target_names:)
target_names.map { |target_name|
target = project.native_targets.detect { |target| target.name == target_name }
raise "❌ Target `#{target_name}` not found in #{project.path}. Available: #{project.native_targets.map { |target| target.name }.join(" ")}" if target.nil?
raise "❌ Target `#{target.name}` is not a framework in #{project.path}" unless target.product_type == "com.apple.product-type.framework"
config = target.build_configurations.detect { |config| config.name == "Release" }
raise "❌ Target `#{target.name}` has no build configuration for Release in #{project.path}" if config.nil?
raise "❌ Target `#{target.name}` does not support ios in #{project.path}" unless find_ios_supported(config: config)
Logger.log "Checking target `#{target.name}` (#{config.name})" do yield(target, config) end
}
end
def find_ios_supported(config:)
supported_platforms = config.resolve_build_setting("SUPPORTED_PLATFORMS")
return supported_platforms.split(" ").include?("iphoneos") unless supported_platforms.nil?
platform_name = config.resolve_build_setting("PLATFORM_NAME") || config.resolve_build_setting("SDKROOT")
return platform_name == "iphoneos" unless platform_name.nil?
false
end
def patch_build_setting(config:, key:, value:)
config.build_settings[key] = value
Logger.log "βœ… Patched #{key} = #{value}"
end
def find_scheme_path(xcodeproj_path:, scheme_name:)
"#{xcodeproj_path}/xcshareddata/xcschemes/#{scheme_name}.xcscheme"
end
def remove_shared_scheme(xcodeproj_path:, scheme_name:)
scheme_path = find_scheme_path(xcodeproj_path: xcodeproj_path, scheme_name: scheme_name)
raise "❌ Cannot find scheme `#{scheme_name}` in #{xcodeproj_path}" unless File.exists? scheme_path
FileUtils.rm_r scheme_path, force: true, secure: true
Logger.log "βœ… Removed shared scheme `#{scheme_name}` in #{xcodeproj_path}"
end
def create_scheme(xcodeproj_path:, target_name:, scheme_name:)
use_xcodeproj(xcodeproj_path: xcodeproj_path) { |project|
foreach_ios_target(project: project, target_names: [target_name]) { |target, config|
scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(target)
scheme.save_as(xcodeproj_path, scheme_name)
Logger.log "βœ… Created shared scheme `#{scheme_name}` for target `#{target.name}`"
}
}
end
def use_xcscheme(xcscheme_path:)
raise "❌ Invalid xcscheme path #{xcscheme_path}" unless File.exists?(xcscheme_path)
Logger.log "Using #{xcscheme_path}" do
scheme = Xcodeproj::XCScheme.new xcscheme_path
yield scheme
scheme.save!
end
end
def patch_build_settings(options)
xcodeproj_path = options[:xcodeproj_path]
raise "❌ Must provide xcodeproj_path:" if xcodeproj_path.nil?
target_names = options[:target_names]
raise "❌ Must provide target_names:" if target_names.nil?
bitcode = enabled? options: options, key: :bitcode, default: true
dsym = enabled? options: options, key: :dsym, default: true
static = enabled? options: options, key: :static, default: false
swiftmodule = enabled? options: options, key: :swiftmodule, default: false
use_xcodeproj(xcodeproj_path: xcodeproj_path) { |project|
foreach_ios_target(project: project, target_names: target_names) { |target, config|
patch_build_setting config: config, key: "ENABLE_BITCODE", value: "YES" if bitcode
patch_build_setting config: config, key: "DEBUG_INFORMATION_FORMAT", value: "dwarf-with-dsym" if dsym
patch_build_setting config: config, key: "MACH_O_TYPE", value: "staticlib" if static
patch_build_setting config: config, key: "BUILD_LIBRARY_FOR_DISTRIBUTION", value: "YES" if swiftmodule
patch_build_setting config: config, key: "SKIP_INSTALL", value: "NO" if swiftmodule
yield(project, target, config) if block_given? # Append a `do ... end` block to apply more customized patches
}
}
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment