1# Copyright (c) 2007, The RubyCocoa Project.
2# Copyright (c) 2005-2006, Jonathan Paisley.
3# All Rights Reserved.
4#
5# RubyCocoa is free software, covered under either the Ruby's license or the 
6# LGPL. See the COPYRIGHT file for more information.
7
8# Takes a built RubyCocoa app bundle (as produced by the 
9# Xcode/ProjectBuilder template) and copies it into a new
10# app bundle that has all dependencies resolved.
11#
12# usage:
13#   ruby standaloneify.rb -d mystandaloneprog.app mybuiltprog.app
14#
15# This creates a new application that should have dependencies resolved.
16#
17# The script attempts to identify dependencies by running the program
18# without OSX.NSApplicationMain, then grabbing the list of loaded
19# ruby scripts and extensions. This means that only the libraries that
20# you 'require' are bundled.
21#
22# NOTES:
23#
24#  Your ruby installation MUST NOT be the standard Panther install - 
25#  the script depends on ruby libraries being in non-standard paths to
26#  work.
27#
28#  I've only tested it with a DarwinPorts install of ruby 1.8.2.
29#
30#  Extension modules should be copied over correctly.
31#
32#  Ruby gems that are used are copied over in their entirety (thanks to some
33#  ideas borrowed from rubyscript2exe)
34#
35#  install_name_tool is used to rewrite dyld load paths - this may not work
36#  depending on how your libraries have been compiled. I've not had any 
37#  issues with it yet though.
38#
39# Use ENV['RUBYCOCOA_STANDALONEIFYING?'] in your application to check if it's being standaloneified.
40
41# FIXME: Using evaluation is "evil", should use RubyNode instead. Eloy Duran.
42
43module Standaloneify
44  MAGIC_ARGUMENT = '--standaloneify'
45
46  def self.find_file_in_load_path(filename)
47    return filename if filename[0] == ?/
48      paths = $LOAD_PATH.select do |p|
49      path = File.join(p,filename)
50      return path if File.exist?(path)
51      end
52      return nil
53  end
54
55end
56
57if __FILE__ == $0 and ARGV[0] == Standaloneify::MAGIC_ARGUMENT then
58  # Got magic argument
59  ARGV.shift
60
61  module Standaloneify
62    LOADED_FILES = []
63    def self.notify_loaded(filename)
64      LOADED_FILES << filename unless LOADED_FILES.include?(filename)
65    end
66  end
67
68  module Kernel
69    alias :pre_standaloneify_load :load
70    def load(*args)
71      if self.is_a?(OSX::OCObjWrapper) then
72        return self.method_missing(:load,*args)
73      end
74
75      filename = args[0]
76      result = pre_standaloneify_load(*args)
77      Standaloneify.notify_loaded(filename) if filename and result
78      return result
79    end
80  end
81
82  module Standaloneify
83    def self.find_files(loaded_features,loaded_files)
84
85      loaded_features.delete("rubycocoa.bundle")
86
87      files_and_paths = (loaded_features + loaded_files).map do |file|
88        [file,find_file_in_load_path(file)]
89      end
90
91      files_and_paths.reject! { |f,p| p.nil? }
92
93      if defined?(Gem) then
94        resources_d = OSX::NSBundle.mainBundle.resourcePath.fileSystemRepresentation
95        gems_home_d = File.join(resources_d,"RubyGems")
96        gems_gem_d = File.join(gems_home_d,"gems")
97        gems_spec_d = File.join(gems_home_d,"specifications")
98
99        FileUtils.mkdir_p(gems_spec_d)
100        FileUtils.mkdir_p(gems_gem_d)
101
102        Gem::Specification.list.each do |gem|
103          next unless gem.loaded?
104          $stderr.puts "Found gem #{gem.name}"
105
106          FileUtils.cp_r(gem.full_gem_path,gems_gem_d)
107          FileUtils.cp(File.join(gem.installation_path,"specifications",gem.full_name + ".gemspec"),gems_spec_d)
108          # Remove any files that come from the GEM
109          files_and_paths.reject! { |f,p| p.index(gem.full_gem_path) == 0 }
110        end
111
112        # Add basis RubyGems dependencies that are not detected since 
113        # require is overwritten and doesn't modify $LOADED_FEATURES.
114        %w{fileutils.rb etc.bundle}.each { |f| 
115          files_and_paths << [f, find_file_in_load_path(f)] 
116        }
117      end
118
119      return files_and_paths
120    end
121  end
122
123  require 'osx/cocoa'
124
125  module OSX
126    def self.NSApplicationMain(*args)
127      # Prevent application main loop from starting
128    end
129  end
130
131  $LOADED_FEATURES << "rubycocoa.bundle"
132
133  $0 = ARGV[0]
134  require ARGV[0]
135
136  loaded_features = $LOADED_FEATURES.uniq.dup
137  loaded_files = Standaloneify::LOADED_FILES.dup
138
139  require 'fileutils'
140
141  result = Standaloneify.find_files(loaded_features, loaded_files)
142  File.open(ENV["STANDALONEIFY_DUMP_FILE"],"w") {|fp| fp.write(result.inspect) }
143
144  exit 0
145end
146
147
148module Standaloneify
149
150  RB_MAIN_PREFIX = <<-EOT.gsub(/^ */,'')
151  ################################################################################
152  # #{File.basename(__FILE__)} patch
153  ################################################################################
154  # Remove all entries that aren't in the application bundle
155
156  COCOA_APP_RESOURCES_DIR = File.dirname(__FILE__)
157
158  $LOAD_PATH.reject! { |d| d.index(File.dirname(COCOA_APP_RESOURCES_DIR))!=0 }
159  $LOAD_PATH << File.join(COCOA_APP_RESOURCES_DIR,"ThirdParty")
160  $LOAD_PATH << File.join(File.dirname(COCOA_APP_RESOURCES_DIR),"lib")
161
162  $LOADED_FEATURES << "rubycocoa.bundle"
163
164  ENV['GEM_HOME'] = ENV['GEM_PATH'] = File.join(COCOA_APP_RESOURCES_DIR,"RubyGems")
165
166  ################################################################################
167  EOT
168
169  def self.patch_main_rb(resources_d)
170    rb_main = File.join(resources_d,"rb_main.rb")
171    main_script = RB_MAIN_PREFIX + File.read(rb_main)
172    File.open(rb_main,"w") do |fp|
173      fp.write(main_script)
174    end
175  end
176
177  def self.get_dependencies(macos_d,resources_d)
178    # Set an environment variable that can be checked inside the application.
179    # This is useful because standaloneify uses evaluation, so it might be possible
180    # that the application does something which leads to problems while standaloneifying.
181    ENV['RUBYCOCOA_STANDALONEIFYING?'] = 'true'
182    
183    dump_file = File.join(resources_d,"__require_dump")
184    # Run the main Mac program
185    mainprog = Dir[File.join(macos_d,"*")][0]
186    ENV['STANDALONEIFY_DUMP_FILE'] = dump_file
187    system(mainprog,__FILE__,MAGIC_ARGUMENT)
188
189    begin
190      result = eval(File.read(dump_file))
191    rescue
192      $stderr.puts "Couldn't read dependency list"
193      exit 1
194    end
195    File.unlink(dump_file)        
196    result
197  end
198
199  class LibraryFixer
200    def initialize
201      @done = {}
202    end
203
204    def self.needs_to_be_bundled(path)
205      case path
206      when %r:^/usr/lib/:
207        return false
208      when %r:^/lib/:
209        return false
210      when %r:^/Library/Frameworks:
211        $stderr.puts "WARNING: don't know how to deal with frameworks (%s)" % path.inspect
212        return false
213      when %r:^/System/Library/Frameworks:
214        return false
215      when %r:^@executable_path:
216        $stderr.puts "WARNING: can't handle library with existing @executable_path reference (%s)" % path.inspect
217        return false
218      end
219      return true
220    end
221
222    ## For the given library, copy into the lib dir (if copy_self),
223    ## iterate through dependent libraries and copy them if necessary,
224    ## updating the name in self
225
226    def fixup_library(relative_path,full_path,dest_root,copy_self=true)
227      prefix = "@executable_path/../lib"
228
229      lines = %x[otool -L '#{full_path}'].split("\n")
230      paths = lines.map { |x| x.split[0] }
231      paths.shift # argument name
232
233      return if @done[full_path]
234
235      if copy_self then
236        @done[full_path] = true
237        new_path = File.join(dest_root,relative_path)
238        internal_path = File.join(prefix,relative_path)
239        FileUtils.mkdir_p(File.dirname(new_path))
240        FileUtils.cp(full_path,new_path)
241        File.chmod(0700,new_path)
242        full_path = new_path
243        system("install_name_tool","-id",internal_path,new_path)
244      end
245
246      paths.each do |path|
247        next if File.basename(path) == File.basename(full_path)
248
249        if self.class.needs_to_be_bundled(path) then
250          puts "Fixing %s in %s" % [path.inspect,full_path.inspect]
251          fixup_library(File.basename(path),path,dest_root)
252
253          lib_name = File.basename(path)
254          new_path = File.join(dest_root,lib_name)
255          internal_path = File.join(prefix,lib_name)
256
257          system("install_name_tool","-change",path,internal_path,full_path)
258        end
259      end
260    end
261  end
262
263  def self.make_standalone_application(source,dest,extra_libs)
264    FileUtils.cp_r(source,dest)
265    dest_d = Pathname.new(dest).realpath.to_s
266
267    # Calculate various paths in new app bundle
268    contents_d = File.join(dest_d,"Contents")
269    frameworks_d = File.join(contents_d,"Frameworks")
270    resources_d = File.join(contents_d,"Resources")
271    lib_d = File.join(contents_d,"lib")
272    macos_d = File.join(contents_d,"MacOS")
273
274    # Calculate paths to the to-be copied RubyCocoa framework
275    ruby_cocoa_d = File.join(frameworks_d,"RubyCocoa.framework")
276    ruby_cocoa_inc = File.join(ruby_cocoa_d,"Resources","ruby")
277    ruby_cocoa_lib = File.join(ruby_cocoa_d,"RubyCocoa")
278    
279    # First check if the developer might already have added the RubyCocoa framework (in a copy phase)
280    unless File.exist? ruby_cocoa_d
281      # Create Frameworks dir and copy RubyCocoa in there
282      FileUtils.mkdir_p(frameworks_d)
283      FileUtils.mkdir_p(lib_d)
284      rc_path = [
285        "/System/Library/Frameworks/RubyCocoa.framework",
286        "/Library/Frameworks/RubyCocoa.framework"
287      ].find { |p| File.exist?(p) }
288      raise "Cannot locate RubyCocoa.framework" unless rc_path  
289      # FileUtils.cp_r(rc_path,frameworks_d)
290      # Do not use FileUtils.cp_r because it tries to follow symlinks.
291      unless system("cp -R \"#{rc_path}\" \"#{frameworks_d}\"")
292        raise "cannot copy #{rc_path} to #{frameworks_d}"
293      end
294    end
295
296    # Copy in and update library references for RubyCocoa
297    fixer = LibraryFixer.new
298    fixer.fixup_library(File.basename(ruby_cocoa_lib),ruby_cocoa_lib,lib_d,false)
299
300    third_party_d = File.join(resources_d,"ThirdParty")
301    FileUtils.mkdir_p(third_party_d)
302
303    # Calculate bundles and Ruby modules needed
304    dependencies = get_dependencies(macos_d,resources_d)
305
306    patch_main_rb(resources_d)
307
308    extra_libs.each do |lib|
309      dependencies << [lib,find_file_in_load_path(lib)]
310    end
311
312    dependencies.each do |feature,path|
313
314      case feature
315      when /\.rb$/
316        next if feature[0] == ?/
317        if File.exist?(File.join(ruby_cocoa_inc,feature)) then
318        puts "Skipping RubyCocoa file " + feature.inspect
319        next
320        end
321        if path[0..(resources_d.length - 1)] == resources_d
322          puts "Skipping existing Resource file " + feature.inspect
323          next
324        end
325        dir = File.join(third_party_d,File.dirname(feature))
326        FileUtils.mkdir_p(dir)
327        puts "Copying " + feature.inspect
328        FileUtils.cp(path,File.join(dir,File.basename(feature)))
329
330      when /\/rubycocoa.bundle$/
331        next
332
333      when /\.bundle$/
334        puts "Copying bundle " + feature.inspect
335
336        base = File.basename(path)
337        if path then
338          if feature[0] == ?/ then
339            relative_path = File.basename(feature)
340          else
341            relative_path = feature
342          end
343          fixer.fixup_library(relative_path,path,lib_d)
344        else
345          puts "WARNING: Bundle #{extra} not found"
346        end
347
348      else
349        $stderr.puts "WARNING: unknown feature %s loaded" % feature.inspect
350      end
351    end
352  end
353
354end
355
356if $0 == __FILE__ then
357
358  require 'ostruct'
359  require 'optparse'
360  require 'pathname'
361  require 'fileutils'
362
363  config = OpenStruct.new
364  config.force = false
365  config.extra_libs = []
366  config.dest = nil
367
368  ARGV.options do |opts|
369    opts.banner = "usage: #{File.basename(__FILE__)} -d DEST [options] APPLICATION\n\nUse ENV['RUBYCOCOA_STANDALONEIFYING?'] in your application to check if it's being standaloneified.\n"
370    opts.on("-f","--force","Delete target app if it exists already") { |config.force| }
371    opts.on("-d DEST","--dest","Place result at DEST (required)") {|config.dest|}
372    opts.on("-l LIBRARY","--lib","Extra library to bundle") { |lib| config.extra_libs << lib }
373
374    opts.parse!
375  end
376
377  if not config.dest or ARGV.length!=1 then
378    $stderr.puts ARGV.options
379    exit 1
380  end
381
382  source_app_d = ARGV.shift
383
384  if config.dest !~ /\.app$/ then
385    $stderr.puts "Target must have '.app' extension"
386    exit 1
387  end
388
389  if File.exist?(config.dest) then
390    if config.force then
391      FileUtils.rm_rf(config.dest)
392    else
393      $stderr.puts "Target exists already (#{config.dest.inspect})"
394      exit 1
395    end
396  end
397
398  Standaloneify.make_standalone_application(source_app_d,config.dest,config.extra_libs)
399
400end
401
402