Building a Command Line OS X app with RubyMotion

I decided to write a command line tool with RubyMotion to visualise CGRects and NSRects from lldb, but I quickly discovered that RubyMotion does not support this out of the box.

You can build an OS X app that doesn’t present a UI by by setting LSUIElement to true and not creating any UI, but when you copy the binary out of the bundle and try to run it, you get an error that looks something like this:

1
drawrect[59471:707] No Info.plist file in application bundle or no NSPrincipalClass in the Info.plist file, exiting

This is due to the NSApplicationMain function not being able to find an Info.plist due to the binary no longer residing inside a bundle. We need to tweak RubyMotion’s compile process to skip this method.

Replacing NSApplicationMain

I tracked down the offending snippet of code within lib/motion/project/template/osx/config.rb.

config.rb link
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
main_txt << <<EOS
int
main(int argc, char **argv)
{
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
EOS
    if ENV['ARR_CYCLES_DISABLE']
      main_txt << <<EOS
    setenv("ARR_CYCLES_DISABLE", "1", true);
EOS
    end
    main_txt << <<EOS
    RubyMotionInit(argc, argv);
    NSApplication *app = [NSApplication sharedApplication];
    [app setDelegate:[NSClassFromString(@"#{delegate_class}") new]];
EOS
    if spec_mode
      main_txt << "SpecLauncher *specLauncher = [[SpecLauncher alloc] init];\n"
      main_txt << "[[NSNotificationCenter defaultCenter] addObserver:specLauncher selector:@selector(appLaunched:) name:NSApplicationDidFinishLaunchingNotification object:nil];\n"
    end
    main_txt << <<EOS
    NSApplicationMain(argc, (const char **)argv);
    [pool release];
    rb_exit(0);
    return 0;
}
EOS

As you can see on line 139, it calls NSApplicationMain which is the source of the error we’re seeing as NSApplicationMain tries to load the NIB as well as construct the application object. See Matt Gallager’s article on NSApplicationMain for more details.

Patching RubyMotion’s compile step

We could do this one of two ways:

  • Create a new OS X command line template
  • Monkey-patch the Config class

Given I’m kinda lazy, monkey-patching the config class was the way to go for me :)

Make a directory called lib in the root of the RubyMotion project, and make a file named osx_cli.rb.

We need to patch the main_cpp_file_txt method of the Motion::Project::OSXConfig class, so your file should look something like this:

osx_cli.rb
1
2
3
4
5
6
7
module Motion::Project
  class OSXConfig < XcodeConfig
    def main_cpp_file_txt(spec_objs)

    end
  end
end

Then you want to paste the original method body in, and then we can start patching!

Let’s remove the NSApplicationMain call as we won’t be needing that (yet).

Command line applications don’t need an application delegate either, so let’s delete the following lines:

1
2
    NSApplication *app = [NSApplication sharedApplication];
    [app setDelegate:[NSClassFromString(@"#{delegate_class}") new]];

However, we still need an entry point into our Ruby code. Let’s use the pre-existing delegate_class variable to define the entry point class.

osx_cli.rb
65
66
67
68
69
70
71
main_txt << <<EOS
    [[NSClassFromString(@"#{delegate_class}") new] main];
    [pool release];
    rb_exit(0);
    return 0;
}
EOS

Now rather than calling NSApplicationMain, we’re now calling the main instance method on the application delegate class (which is AppDelegate by default).

To make RubyMotion actually pick our code up, we need to require it in the Rakefile.

Rakefile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# -*- coding: utf-8 -*-
$:.unshift("/Library/RubyMotion/lib")
require 'motion/project/template/osx'
require_relative 'lib/osx_cli'

begin
  require 'bundler'
  Bundler.require
rescue LoadError
end

Motion::Project::App.setup do |app|
  # Use `rake config' to see complete project settings.
  app.name = 'drawrect'
end

When you run your app, it should now call AppDelegate#main!

What if I still need a run loop?

I thought I was home scot-free at this point, however when I tried to initialize a NSWindow in the daemon component of drawrect, I ran into these errors:

1
2
3
4
5
6
7
8
9
10
drawrect[61361:707] _NXCreateWindowWithStyleMask: error setting window property (1000)
drawrect[61361:707] error [1000] setting colorSpace to DELL 3008WFP colorspace
drawrect[61361:707] PSsetwindowlevel, error setting window level (1000)
drawrect[61361:707] _NSSetWindowTag, error clearing window tags (1000)
drawrect[61361:707] _NSSetWindowTag, error setting window tags (1000)
drawrect[61361:707] error [1000] getting window resolution
drawrect[61361:707] Error [1000] setting resolution to 1
drawrect[61361:707] _NSShapePlainWindowWithOpaqueRect: error setting window shape (1000)
drawrect[61361:707] CGSAddSurface failed - error 1000 (windowID:4017)
drawrect[61361:707] CGSAddSurface failed - error 1000 (windowID:4017)

These errors are occuring because the app no longer has a connection to the window server since we have removed the [NSApplication sharedApplication] line, which connects the app to the window server.

All we need to do is call NSApplication.sharedApplication and run the main event loop:

app_delegate.rb link
66
67
68
69
70
71
72
73
74
75
def bootServer!
    app = NSApplication.sharedApplication
    app.delegate = self

    NSApp.run
  end

  def applicationDidFinishLaunching(notification)
    DrawRect.new.listen
  end

And boom, everything works! drawrect is now a self-contained command line application that has the ability to create windows and doesn’t have to reside in an app bundle.

Check out the drawrect project at https://github.com/chendo/drawrect for a fully functioning example.

Let me know if this has been useful to you!

Comments