Last active
August 8, 2020 06:19
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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