├── .rvmrc ├── .gitignore ├── Rakefile ├── lib ├── wox │ ├── version.rb │ ├── task.rb │ ├── helpers │ │ └── number_helper.rb │ ├── target_selector.rb │ ├── builder.rb │ ├── packager.rb │ ├── test_flight.rb │ ├── tasks.rb │ └── build_environment.rb └── wox.rb ├── Gemfile ├── CHANGELOG.md ├── wox.gemspec └── README.md /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm 1.8.7 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | -------------------------------------------------------------------------------- /lib/wox/version.rb: -------------------------------------------------------------------------------- 1 | module Wox 2 | VERSION = "0.0.8" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in wox.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/wox.rb: -------------------------------------------------------------------------------- 1 | require 'wox/target_selector' 2 | require 'wox/build_environment' 3 | require 'wox/task' 4 | require 'wox/builder' 5 | require 'wox/packager' 6 | require 'wox/test_flight' 7 | require 'wox/tasks' 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.0.7 (17 Apr 2011) 2 | TestFlight task now more closely mimics TestFlight API. See README for new format 3 | 4 | # 0.0.6 (16 Apr 2011) 5 | Build now defaults to building the first target in the application. This can be overridden by setting :target -------------------------------------------------------------------------------- /lib/wox/task.rb: -------------------------------------------------------------------------------- 1 | module Wox 2 | class Task 3 | def run_command text, options 4 | result = `#{text}` 5 | 6 | File.open(options[:results], "w") {|f| f.write result } 7 | 8 | if $?.to_i == 0 9 | puts "Success. Results in #{options[:results]}" 10 | puts 11 | else 12 | fail exec "cat #{options[:results]}" 13 | end 14 | end 15 | 16 | end 17 | end -------------------------------------------------------------------------------- /lib/wox/helpers/number_helper.rb: -------------------------------------------------------------------------------- 1 | module Wox 2 | module NumberHelper 3 | def plural count, singular, plural 4 | count == 1 ? singular : plural 5 | end 6 | 7 | def bytes_to_human_size bytes, precision = 1 8 | kb = 1024 9 | mb = 1024 * kb 10 | gb = 1024 * mb 11 | case 12 | when bytes < kb; "%d #{plural(bytes, 'byte', 'bytes')}" % bytes 13 | when bytes < mb; "%.#{precision}f #{plural((bytes / kb), 'kilobyte', 'kilobytes')}" % (bytes / kb) 14 | when bytes < gb; "%.#{precision}f #{plural((bytes / mb), 'megabyte', 'megabytes')}" % (bytes / mb) 15 | when bytes >= gb; "%.#{precision}f #{plural((bytes / gb), 'gigabyte', 'gigabytes')}" % (bytes / gb) 16 | end 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /lib/wox/target_selector.rb: -------------------------------------------------------------------------------- 1 | module Wox 2 | class Selector 3 | def initialize(arg) 4 | @arg = arg 5 | end 6 | 7 | def to_s 8 | "#{prefix} '#{@arg}'" 9 | end 10 | end 11 | 12 | class TargetSelector < Selector 13 | def prefix 14 | "-target" 15 | end 16 | end 17 | 18 | class SchemeSelector < Selector 19 | def prefix 20 | "-scheme" 21 | end 22 | end 23 | 24 | class BuildTypeSelector < Selector 25 | def build_name 26 | File.basename(@arg, File.extname(@arg)) 27 | end 28 | end 29 | 30 | class ProjectBuildSelector < BuildTypeSelector 31 | def prefix 32 | "-project" 33 | end 34 | end 35 | 36 | class WorkspaceBuildSelector < BuildTypeSelector 37 | def prefix 38 | "-workspace" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/wox/builder.rb: -------------------------------------------------------------------------------- 1 | module Wox 2 | class Builder < Task 3 | include Environment 4 | # def initialize(environment); super end 5 | 6 | def build 7 | configuration, sdk, ipa_file, build_dir = environment[:configuration], environment[:sdk], environment[:ipa_file], environment[:build_dir] 8 | profile = environment[:provisioning_profile] 9 | 10 | puts "Building #{environment[:full_name]} configuration:#{configuration}" 11 | 12 | log_file = File.join environment[:build_dir], "build-#{configuration}.log" 13 | 14 | command = "xctool #{environment[:build_selector].to_s} #{environment[:target_selector].to_s} -sdk #{sdk} -configuration #{configuration} PROVISIONING_PROFILE=\"#{profile}\" clean build" 15 | puts command 16 | run_command command, :results => log_file 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /wox.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "wox/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "wox" 7 | s.version = Wox::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Dave Newman"] 10 | s.email = ["dave@snappyco.de"] 11 | s.homepage = "" 12 | s.summary = %q{The Wizard of Xcode} 13 | s.description = %q{Wox is a collection of build tasks that helps you build and publish iOS appicatinos} 14 | 15 | s.rubyforge_project = "wox" 16 | 17 | s.add_dependency "thor" 18 | s.add_dependency "plist" 19 | s.add_dependency "rubyzip" 20 | 21 | s.add_development_dependency "rspec" 22 | 23 | s.files = `git ls-files`.split("\n") 24 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 25 | s.require_paths = ["lib"] 26 | end 27 | -------------------------------------------------------------------------------- /lib/wox/packager.rb: -------------------------------------------------------------------------------- 1 | module Wox 2 | class Packager < Task 3 | include Environment 4 | 5 | def package 6 | configuration, sdk, ipa_file, build_dir = environment[:configuration], environment[:sdk], environment[:ipa_file], environment[:build_dir] 7 | 8 | app_file = File.join build_dir, "#{configuration}-iphoneos", environment[:app_file] 9 | app_file += ".app" unless app_file =~ /\.app^/ 10 | 11 | fail "Couldn't find #{app_file}" unless File.exists? app_file 12 | 13 | provisioning_profile_file = find_matching_mobile_provision environment[:provisioning_profile] 14 | fail "Unable to find matching provisioning profile for '#{environment[:provisioning_profile]}'" if provisioning_profile_file.empty? 15 | 16 | puts "Creating #{ipa_file}" 17 | log_file = File.join build_dir, "ipa.log" 18 | command = "xcrun -sdk #{sdk} PackageApplication -v '#{app_file}' -o '#{File.expand_path ipa_file}' --sign '#{environment[:developer_certificate]}' --embed '#{provisioning_profile_file}'" 19 | puts command 20 | run_command command, :results => log_file 21 | end 22 | 23 | def find_matching_mobile_provision match_text 24 | `grep -rl '#{match_text}' '#{ENV['HOME']}/Library/MobileDevice/Provisioning\ Profiles/'`.strip 25 | end 26 | 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/wox/test_flight.rb: -------------------------------------------------------------------------------- 1 | require 'wox/helpers/number_helper' 2 | require 'zip/zip' 3 | require 'zip/zipfilesystem' 4 | 5 | module Wox 6 | class TestFlight < Task 7 | include Environment 8 | include NumberHelper 9 | 10 | def arg_to_string arg 11 | arg.respond_to?(:join) ? arg.join(",") : arg 12 | end 13 | 14 | def api_args 15 | args = { 16 | :file => "@#{environment[:ipa_file]}", 17 | :dsym => "@#{environment[:dsym_file]}", 18 | :api_token => environment[:api_token], 19 | :team_token => environment[:team_token], 20 | :notes => environment[:notes] 21 | } 22 | 23 | args[:distribution_lists] = environment[:distribution_lists].join(",") if environment.has_entry? :distribution_lists 24 | args[:notify] = environment[:notify] if environment.has_entry? :notify 25 | args 26 | end 27 | 28 | def curl_arg_string 29 | api_args.map {|k,v| "-F #{k}='#{v}'"}.join(" ") 30 | end 31 | 32 | def publish 33 | ipa_file = environment[:ipa_file] 34 | compress_dsym 35 | puts "Publishing to TestFlight" 36 | puts "File: #{ipa_file} (#{bytes_to_human_size File.size?(ipa_file)})" 37 | puts "Accessible To: #{environment[:distribution_lists].join(", ")}" if environment.has_entry? :distribution_lists 38 | puts "After publish will notify team members" if environment.has_entry? :notify 39 | 40 | log_file = File.join environment[:build_dir], "testflight.log" 41 | run_command "curl --progress-bar #{curl_arg_string} http://testflightapp.com/api/builds.json", :results => log_file 42 | end 43 | 44 | def compress_dsym 45 | configuration, sdk, ipa_file, build_dir = environment[:configuration], environment[:sdk], environment[:ipa_file], environment[:build_dir] 46 | dsym_file = File.join build_dir, "#{configuration}-iphoneos", environment[:app_file] 47 | dsym_file += ".app.dSYM" 48 | archive = environment[:dsym_file] 49 | puts "Compressing dSYM: #{archive}" 50 | FileUtils.rm archive, :force=>true 51 | Zip::ZipFile.open(archive, 'w') do |zipfile| 52 | Dir["#{dsym_file}/**/**"].each do |file| 53 | zipfile.add(file.sub(File.dirname(dsym_file)+'/',''),file) 54 | end 55 | end 56 | end 57 | end 58 | end -------------------------------------------------------------------------------- /lib/wox/tasks.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | 3 | module Wox 4 | module TasksScope 5 | attr_reader :environment, :parent_task 6 | def initialize environment, parent_task = nil 7 | @environment = environment 8 | @parent_task = parent_task 9 | end 10 | end 11 | 12 | class Tasks 13 | def self.create options = {}, &block 14 | tasks = self.new(BuildEnvironment.new(options)) 15 | tasks.default_tasks 16 | tasks.instance_eval &block if block_given? 17 | end 18 | 19 | include TasksScope 20 | 21 | def default_tasks 22 | namespace :info do 23 | desc "List available sdks" 24 | task :sdks do 25 | puts environment.sdks.join("\n") 26 | end 27 | 28 | desc "List available configurations" 29 | task :configurations do 30 | puts environment.configurations.join("\n") 31 | end 32 | 33 | desc "List project targets" 34 | task :targets do 35 | puts environment.targets.join("\n") 36 | end 37 | 38 | desc "List project schemes" 39 | task :schemes do 40 | puts environment.targets.join("\n") 41 | end 42 | end 43 | end 44 | 45 | def build name, options, &block 46 | environment.apply options do |e| 47 | t = nil 48 | namespace :build do 49 | desc "Build #{e[:full_name]} with #{e[:configuration]} configuration" 50 | t = task(name) { Builder.new(e).build } 51 | end 52 | tasks = BuildTasks.new(e, t) 53 | tasks.instance_eval &block if block_given? 54 | end 55 | end 56 | end 57 | 58 | class BuildTasks 59 | include TasksScope 60 | 61 | def ipa name, options, &block 62 | environment.apply options.merge({:ipa_name => name}) do |e| 63 | t = nil 64 | namespace :ipa do 65 | desc "Creates #{e[:ipa_file]}" 66 | t = task(name => parent_task) { Packager.new(e).package } 67 | end 68 | 69 | tasks = IpaTasks.new(e, t) 70 | tasks.instance_eval &block if block_given? 71 | end 72 | end 73 | end 74 | 75 | class IpaTasks 76 | include TasksScope 77 | 78 | def testflight name, options 79 | environment.apply options do |e| 80 | namespace :testflight do 81 | desc "Publishes #{e[:ipa_file]} to testflight" 82 | task(name => parent_task) { TestFlight.new(e).publish } 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/wox/build_environment.rb: -------------------------------------------------------------------------------- 1 | require 'plist' 2 | 3 | module Wox 4 | module Environment 5 | attr_reader :environment 6 | def initialize environment 7 | @environment = environment 8 | end 9 | end 10 | 11 | class BuildEnvironment 12 | attr_reader :info_plist, :build_dir, :default_sdk 13 | 14 | def best_selector(selectors) 15 | res = (selectors.drop_while {|sel| sel[:arg] == nil || sel[:arg].empty? }).first 16 | res[:class].new(res[:arg]) unless res == nil 17 | end 18 | 19 | def initialize options 20 | @options = options 21 | 22 | options[:info_plist] ||= 'Resources/Info.plist' 23 | options[:version] ||= Plist::parse_xml(options[:info_plist])['CFBundleVersion'] 24 | options[:build_dir] ||= `pwd`.strip() + '/build' 25 | options[:sdk] ||= 'iphoneos' 26 | options[:configuration] ||= 'Release' 27 | options[:build_selector] = best_selector([{:class => WorkspaceBuildSelector, :arg => options[:workspace_name]}, 28 | {:class => ProjectBuildSelector, :arg => options[:project_name]}, 29 | {:class => WorkspaceBuildSelector, :arg => workspaces.first}, 30 | {:class => ProjectBuildSelector, :arg => projects.first}]) 31 | options[:target_selector] = best_selector([{:class => SchemeSelector, :arg => options[:scheme]}, 32 | {:class => TargetSelector, :arg => options[:target]}, 33 | {:class => SchemeSelector, :arg => schemes.first}, 34 | {:class => TargetSelector, :arg => targets.first}]) 35 | 36 | name = options[:build_selector].build_name 37 | options[:full_name] ||= "#{name} #{self[:version]}" 38 | 39 | options[:app_file] ||= name 40 | 41 | if options[:ipa_name] 42 | options[:ipa_file] ||= File.join self[:build_dir], 43 | [self[:ipa_name], self[:version], self[:configuration]].join("-") + ".ipa" 44 | options[:dsym_file] ||= File.join self[:build_dir], 45 | [self[:ipa_name], self[:version], self[:configuration]].join("-") + ".dSYM.zip" 46 | end 47 | end 48 | 49 | def apply options, &block 50 | yield BuildEnvironment.new @options.merge(options) 51 | end 52 | 53 | def version 54 | self[:version] 55 | end 56 | 57 | def sdks 58 | @sdks ||= `xcodebuild -showsdks`.scan(/-sdk (.*?$)/m).flatten 59 | end 60 | 61 | def configurations 62 | @configurations ||= begin 63 | start_line = xcodebuild_list.find_index{ |l| l =~ /configurations/i } + 1 64 | end_line = xcodebuild_list.find_index{ |l| l =~ /if no/i } - 1 65 | xcodebuild_list.slice start_line...end_line 66 | end 67 | end 68 | 69 | def targets 70 | @targets ||= begin 71 | start_line = xcodebuild_list.find_index{ |l| l =~ /targets/i } + 1 72 | end_line = xcodebuild_list.find_index{ |l| l =~ /configurations/i } - 1 73 | xcodebuild_list.slice(start_line...end_line).map{|l| l.gsub('(Active)','').strip } 74 | end 75 | end 76 | 77 | def workspaces 78 | @workspaces = Dir.glob("*.xcworkspace") 79 | end 80 | 81 | def projects 82 | @projects = Dir.glob("*.xcodeproj") 83 | end 84 | 85 | def schemes 86 | @schemes ||= begin 87 | start_line = xcodebuild_list.find_index{ |l| l =~ /schemes/i } + 1 88 | end_line = xcodebuild_list.find_index{ |l| l =~ /if no/i } - 1 89 | xcodebuild_list.slice start_line...end_line 90 | end 91 | end 92 | 93 | def [](name) 94 | fail "You need to specify :#{name} in Rakefile" unless @options[name] 95 | @options[name].respond_to?(:call) ? @options[name].call : @options[name] 96 | end 97 | 98 | def has_entry? name 99 | @options[name] 100 | end 101 | 102 | private 103 | def xcodebuild_list 104 | @xcodebuild_list ||= `xcodebuild -list`.lines.map{|l| l.strip }.to_a 105 | end 106 | 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Wizard of Xcode 2 | 3 | wox is a ruby gem that adds useful rake tasks in order to have happier iOS devs. 4 | 5 | ## Install 6 | 7 | First set up bundler in your xcode app directory (unless you already have!) 8 | 9 | $ cd ~/code/angry_turds 10 | $ gem install bundler 11 | $ bundle init 12 | 13 | Then edit your Gemfile to look something like this: 14 | 15 | # Gemfile 16 | source :rubygems 17 | gem "wox" 18 | 19 | Then run the bundle command: 20 | 21 | $ bundle 22 | 23 | Now, create a Rakefile (unless you already have one!): 24 | 25 | # Rakefile 26 | include Rake::DSL 27 | require 'bundler' 28 | Bundler.require 29 | 30 | Wox::Tasks.create :info_plist => 'Resources/Info.plist' do 31 | build :debug, :configuration => 'Debug' 32 | end 33 | 34 | Now run rake -T to show you the available rake commands and you should see something like this: 35 | 36 | $ rake -T 37 | rake build:debug # Build angry_turds 0.1 with Debug configuration 38 | rake info:configurations # List available configurations 39 | rake info:sdks # List available sdks 40 | $ rake build:debug 41 | Building angry_turds 0.3 configuration:Debug 42 | Success. Results in build/build-Debug.log 43 | 44 | If you get an error you might need to check the path of the info plist file. We use that to get the version number of the app. 45 | 46 | ## Moar stuff! 47 | 48 | Ok so there's a few more things you can do, like creating ipa files and publishing to TestFlight. That looks like this: 49 | 50 | # Rakefile 51 | include Rake::DSL 52 | require 'bundler' 53 | Bundler.require 54 | 55 | Wox::Tasks.create :info_plist => 'Resources/Info.plist', :sdk => 'iphoneos', :configuration => 'Release' do 56 | build :debug, :configuration => 'Debug' 57 | 58 | build :release, :developer_certificate => 'iPhone Developer: Dangerous Dave (9GZ84DL0DZ)' do 59 | ipa :app_store, :provisioning_profile => 'App Store' 60 | ipa :adhoc, :provisioning_profile => 'Team Provisioning Profile' do 61 | testflight :publish, :api_token => 'nphsZ6nVXMl0brDEsevLY0wRfU6iP0NLaQH3nqoh8jG', 62 | :team_token => 'Qfom2HnGGJnXrUVnOKAxKAmpNO3wdQ9panhtqcA', 63 | :notes => proc { File.read("CHANGELOG") }, 64 | :distribution_lists => %w[Internal QA], 65 | :notify => true 66 | 67 | end 68 | end 69 | end 70 | 71 | There's a few things to notice here. Some tasks need to be nested inside other tasks. This allows them to share environment variables. For example :configuration => 'Release' is on the outer most scope at the top there. That sets the default for all the tasks inside. Any task can override the default like the first build :debug task does. The build :release task sets a developer certificate here which is then shared by the two inner ipa tasks. 72 | 73 | rake -T again: 74 | 75 | $ rake -T 76 | rake build:debug # Build angry_turds 0.1 with Debug configuration 77 | rake build:release # Build angry_turds 0.1 with Release configuration 78 | rake info:configurations # List available configurations 79 | rake info:sdks # List available sdks 80 | rake ipa:adhoc # Creates build/angry_turds-0.1-release-adhoc.ipa 81 | rake ipa:app_store # Creates build/angry_turds-0.1-release-app_store.ipa 82 | rake testflight:publish # Publishes build/angry_turds-0.1-release-adhoc.ipa to testflight 83 | 84 | You'll need to sign up to [TestFlight](http://testflightapp.com) to get your API key and team token which you can plug in here. 85 | 86 | Also check your development certificate and provisioning profile from inside Xcode. 87 | 88 | ## Available options 89 | 90 | The following options can be set at any level and will be inherited by child tasks. 91 | 92 | * :info_plist Default: 'Resources/Info.plist' 93 | * :version Default: 'CFBundleVersion' from info plist file 94 | * :target Default: first target in xcode 95 | * :sdk Default: 'iphoneos' 96 | * :build_dir Default: 'build' 97 | * :configuration Default: 'Release' 98 | * :project_name Default: project name from xcode 99 | * :app_file Default: project_name 100 | * :ipa_name Default: 'project_name-version-configuration-ipa_name' 101 | 102 | ## Wrapping up 103 | 104 | This is very much a WIP! So please log any bugs or feedback you might have and feel free to fork and go nuts! 105 | 106 | 107 | 108 | --------------------------------------------------------------------------------