TweetFollow Us on Twitter

RubyCocoa-Part 1

Volume Number: 24 (2008)
Issue Number: 04
Column Tag: Programming

RubyCocoa

A new way to write Cocoa applications-Part 1

by Rich Warren

I admit it. I have a soft spot for Ruby. That probably comes as no surprise, if you've read my earlier articles (particularly Introduction to Ruby on Rails and Ajax on Rails, both available online at www.mactech.com). Needless to say, I'm quite giddy with Leopard's scripting language support. Leopard has elevated Python and Ruby to. . .um. . .not first class citizens. Not quite. But they make a strong second-class showing.

In fact, my biggest complaint comes from the terminology. Apple's own documentation refers to both Ruby and Python as scripting languages. Scripting Languages? Sure, they are both interpreted languages, but the word "scripting" makes them sound like limited, little things. Trust me, you can use these languages to do a lot more than just write scripts. We have two, full-blown, dynamic, object oriented programming languages, and Leopard puts their power at our fingertips.

New Ruby and Python Features

Ruby and Python are not new to OS X. Tiger shipped with both languages installed (though, if you've read my previous articles, you know that the Tiger version of Ruby kinda sucked). Leopard, however, kicks the support up a notch. They've invested a lot of time into getting the details right. While they may not always succeed, I appreciate the effort.

For example, Xcode comes with templates for a variety of Ruby and Python projects. Syntax highlighting and code completion work as expected. Most importantly, Leopard integrates both languages more tightly into the operating system. Both include a bridge to the Objective-C runtime, and both can communicate with scriptable applications.

The Bridge to Objective-C

Leopard ships with the popular RubyCocoa and PyObjC libraries already installed. Developers can use these libraries to write Cocoa applications in either Ruby or Python, respectively. Both languages have access to Leopard's core technologies, including Core Data, Bindings and Document-based applications. These libraries even support the new rock-star frameworks like Core Animation.

But, why would you want to use Ruby or Python? Some might say they're addictive; once you start using them it's hard to go back (trust me, I use Java for my day job). But, you can find other reasons as well. Both Ruby and Python are very expressive languages. You can get a lot of work done with very little code. This makes them ideal choices for rapid development and prototyping.

Additionally, Objective-C, Ruby and Python share many common concepts and design choices. They are all dynamic, object-oriented languages. Ruby and Objective-C in particular, were both heavily influenced by Smalltalk. This common ground helps us coordinate our code across the different languages.

And we can freely mix our code. We can use Ruby subclasses of Objective-C classes, or Python delegates for Objective-C objects. We can transparently call one language from the other. This gives us more power and more flexibility than any one language would have on its own. We have access to each language's libraries. We can exploit their individual strengths, using one language to spackle over the other's weaknesses.

Unfortunately, Cocoa seems to have a one-bridge-at-a-time rule. Mixing either Ruby or Python with Objective-C works just fine. But mixing Ruby and Python quickly becomes problematic. Both frameworks try to load the BridgeSupport dylib, and this can cause errors. Some developers have posted workarounds on the web, but they tend to feel rather hackish to me. Still, I think this issue will smooth itself out with future updates.

The Bridge to OSA

We can also use Ruby and Python to communicate with scriptable applications using the Open Scripting Architecture (OSA). RubyCocoa and PyObjC already give us full access to the native Scripting Bridge, but I think this often becomes unwieldy. We end up writing Ruby (or Python) versions of Objective-C calls on AppleScript APIs.

Fortunately, each language has its own library to simplify scripting: RubyOSA for Ruby and py-applescript for Python. Unfortunately, Leopard does not include these libraries. You need to install them on your own.

Ruby in Leopard

For the rest of this article will dig into the Ruby-specific additions to Leopard. Python has comparable features, but for simplicities sake, I will focus on what I know. Ruby comes ready for serious development. Leopard's installation includes several important libraries: rake, Mongrel, Ferret, Capistrano, sqlite3-ruby, dnssd (aka Bonjour) and Rails. Of course, given the frantic rate of Ruby development, many of these libraries have already grown long in the tooth. Still, that's not a huge concern. Leopard also includes RubyGems.

RubyGems is a command-line package manager for Ruby. It allows us to quickly and easily install and update Ruby libraries. For example, to update the current version of Rails, just type:

gem update –include-dependencies rails

However, if you're like me, the thought of wildly upgrading your system libraries makes your stomach churn. What happens if something goes wrong? Sooner or later, something always goes wrong. Won't this just screw up my system?

Well, put down that bottle of Pepto. Leopard carefully separates its pre-installed libraries from the user-installed libraries and updates. Accidentally updating to an unstable version doesn't change your original system files. Simply uninstall the offending library, and you're good to go. This also makes rolling back to factory defaults quite easy. Simply delete the user-gems folder.

Leopard keeps built-in libraries in the /System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/lib/ruby/gems/1.8/ folder. The gems subfolder contains the actual libraries, while the doc subfolder contains both ri and html documentation.

When you run gems, it saves new libraries to the /Library/Ruby/Gems/1.8/ folder. Again, you can find the libraries in the gems folder, while documentation is. . .wait for it. . .in doc.

Just to be complete, Leopard stashes the RubyCocoa files in a third location: /System/Library/Frameworks/RubyCocoa.framework

I highly recommend poking around in these directories–particularly the RubyCocoa header files. They can give you a good feel for the breadth of options available.

The Limits of RubyCocoa

Of course, there are no magic bullets, and RubyCocoa has its share of downsides.

Slow, slow, slow

As much as I love Ruby, it is a fairly slow, interpreted language. RubyCocoa code will run significantly slower than equivalent Objective-C code. Depending on the application, this may not be a problem. After all, GUI applications spend most of their time waiting on the user anyway.

Besides, if a RubyCocoa program feels slow, you can always profile it and look for bottlenecks. Once you identify likely problems, you can either redesign your code to eliminate the bottleneck, or convert it into faster, Objective-C code.

Finally, the newly released Ruby 1.9 uses a new, faster virtual machine. Unfortunately, as I write this, Ruby 1.9 only comes as a development release–it's not quite ready for prime time.

Not thread safe

Ruby 1.8 is not thread safe. You cannot call Ruby code on multiple native threads. To prevent possible problems, the bridge actually reroutes all Ruby calls from Objective-C to the application's main thread. However, as we will soon see, you can still use Ruby's threads within your Ruby code, which gives us a partial workaround. Again, the production release of Ruby 1.9 should fix this.

Xcode's debugger does not work

You cannot use Xcode's debugger on your ruby code. However, you can use Ruby's debugging tools along with new Leopard tools like DTrace and Instruments. This isn't an ideal solution, but it works.

RubyCocoa does not support Objective-C garbage collection

To me, this was probably the most disappointing limitation. Ruby itself uses garbage collection, but your Objective-C code must continue to manage its own memory. Somehow this just feels wrong.

Finding Documentation and Getting Help

Apple has included a number of documents and examples to help you get started. You can find the following articles linked off the "Introduction to Ruby and Python Programming Topics for Mac OS X" web page (http://developer.apple.com/documentation/Cocoa/Conceptual/RubyPythonCocoa):

Ruby and Python on Mac OS X

Building a RubyCocoa Application: A Tutorial

Using Scripting Bridge in PyObjC and RubyCocoa Code

The Leopard Technology Series for Developers also includes a nice introductory article at hhttp://developer.apple.com/leopard/overview/scriptingcocoa.html.

However, if you want documentation about the frameworks that RubyCocoa supports, prepare for disappointment. You might find a promising folder at /Developer/Documentation/RubyCocoa. Unfortunately, this only contains a few files in Japanese. The actual RubyCocoa documentation is missing. Fortunately, we can fix this. . .more or less.

You need to download the latest RubyCocoa source release from http://rubycocoa.sourceforge.net. Untar the source files, then run the following commands:

ruby install.rb config
ruby install.rb doc

This will create ri and html documentation for most of the Cocoa libraries supported by RubyCocoa. However, the documentation has two small problems.

First, it does not cover all the libraries that RubyCocoa supports.

Second, and more importantly, the installer tends to break whenever Apple updates their reference libraries. The RubyCocoa team tries to keep up with the latest changes, but they are chasing a moving target. The 0.13.0 release will work fine for a fresh install of Xcode 3.0, but if you've updated your reference libraries, it will fail. In that case, try the latest build from the SVN trunk using the following command:

svn co https://rubycocoa.svn.sourceforge.net/svnroot/rubycocoa/trunk\
/src rubycocoa

Don't be surprised when you see errors while parsing Apple's documentation. RubyCocoa should still create documentation for most Cocoa classes.

Alternatively, you can simply look up the Cocoa classes directly from Apple's reference library. As we will see, you can easily translate an Objective-C method into a RubyCocoa call.

Even with all the tutorials, introductory articles and reference libraries, RubyCocoa has a number of dark corners. Fortunately, you can find several other resources to help you master RubyCocoa–or at least help you ask intelligent-sounding questions.

Examples

Leopard's developer tools include 40 sample projects for RubyCocoa. You can find these in the /Developer/Examples/Ruby/RubyCocoa directory. These samples range from old standbys (yet another Currency Converter) to video games. Take some time to browse these projects. They can give you a real feel for using RubyCocoa effectively.

Web Sites

While a quick search on Google brings up 370,000 matches for "RubyCocoa", I highly recommend two sites: the RubyCocoa project pages at SourceForge.net (http://rubycocoa.sourceforge.net/HomePage) and RubyCocoa Resources (http://www.rubycocoa.com). Both provide a range of useful articles. The introductory topics help you get started, while the advanced topics keep you coming back for more.

The Last Resort

The RubyCocoa community has an active mailing list. In my experience, everyone is helpful and kind. But, please: don't waste their time. Try to research the issue on your own. Then, if you're still stuck, check out RubyCocoa Talk.

You can subscribe to RubyCocoa Talk at https://lists.sourceforge.net/lists/listinfo/rubycocoa-talk.

Our Project

To really understand something, sometimes you need to just jump in. Therefore, the rest of this article, will focus on building a simple RSS reader using RubyCocoa.

Why another RSS reader? Leopard already comes with built in RSS features for both Safari and Mail, not to mention many third-party applications. Still, I wanted to try something a bit messier than the typical toy project. By tackling a problem with rough edges, we get a better feel for RubyCocoa's strengths and weaknesses.

Additionally, I wanted a project that would demonstrate the following four points:

The project should use a RubyGem library.

The project should use key Cocoa technologies, like Core Data and Bindings.

The project should use RubyOSA to communicate with an existing, scriptable application.

The project should be implemented entirely in Ruby.

Our RSS reader will read and parse RSS feeds using the FeedTools gem. The application will use both Core Data and Bindings extensively. In part 2, we will send enclosures to an iTunes playlist using RubyOSA. And, except for a single Objective-C class, we will only write Ruby code.

3.859 out of 4 isn't bad.

Installing the Gems

First, a quick word of warning. Don't update RubyGems or any of your libraries just yet. As we will see, this may complicate things. Nothing we can't fix, but you might want to avoid problems when you can.

RubyGems is a powerful package manager for Ruby libraries. It is also a complex, command line tool. A full explanation is beyond the scope of this article, but the table below should get you started. For more information than you could ever possibly want, check out the RubyGem manuals at http://rubygems.org/.


Note: many of these commands (especially install, update and uninstall) require root access. You typically launch them as sudo commands.

Also, I deliberately left one command off the list: gem update –system. This updates the RubyGem system itself. Unfortunately, unlike the other gem updates, this actually changes your system files, and these changes are not easily undone.

I strongly recommend leaving this command alone. Let Apple manage the RubyGems system. As I'm writing this, they just updated RubyGems as part of the 10.5.2 release, so it should stay reasonably current. Modify the gems as much as you want, but leave the system alone.

Most of the time, you will use simple install and update commands; however, the others can come in handy when things go wrong. Updates do not always proceed as smoothly as I would like. Sometimes they leave a gem or two behaving badly. I often find that uninstalling and reinstalling the offending gem (and possibly its dependencies) sorts things out.

Now that we understand the basics of RubyGems, our first step should be the simplest. We just need to install our project's RubyGem libraries. In theory, this should only require typing the following command, entering your password when prompted.

sudo gem install feedtools

Unfortunately, life is never this easy. The FeedTools library contains the deprecated ruby-gem command. As long as you're still running the version of RubyGems that came with Leopard, you shouldn't have any problems. The library just logs a few warnings to the console. However, newer versions of RubyGems no longer recognize this command. Bottom line, if you've updated to 10.5.2, you have the new version of RubyGems, and the FeedTools library will crash.

To fix this, you simply need to edit feed_tools.rb. You can find this file at /Library/Ruby/Gems/1.8/gems/feedtools-0.2.26/lib/feed_tools.rb. Globally replace "require-gem" with "gem".

Creating the Project

Now, we can create our project. Open Xcode, and from the File menu select New Project.... In the Assistant window, scroll down and select Cocoa-Ruby Core Data Application. Click Next.


Creating our Cocoa-Ruby Core Data Application

Enter RubyRSS for the project name. Set the project directory to whatever you wish. Click Next again. Abracadabra. . .project created!

But, lets take a quick look at what Xcode has done.

MainMenu.nib and RubyRSS_DataModel.xcdatamodel are standard files for any Core Data application. The first defines our user interface. The second defines our data model. We will take a closer look at both in just a second.

Open main.m. This is the starting point for our application. As you can see, a RubyCocoa application's main simply imports the RubyCocoa runtime, then launches rb_main.rb using the RBApplicationMain() function.

main.m

#import <Cocoa/Cocoa.h>
#import <RubyCocoa/RBRuntime.h>
int main(int argc, const char *argv[])
{
   return RBApplicationMain("rb_main.rb", argc, argv);
}

Our Ruby code really starts with rb_main.rb. The default implementation loads the RubyCocoa library, locates the application's resource path, then loads any files ending with .rb using Ruby's require() method. This creates all our Ruby classes. Once finished, rb_main.rb calls NSApplicationMain(), which initializes and runs the Cocoa application.

rb_main.rb

require 'osx/cocoa'
def rb_main_init
   path = OSX::NSBundle.mainBundle.resourcePath.fileSystemRepresentation
   rbfiles = Dir.entries(path).select {|x| /\.rb\z/ =~ x}
   rbfiles -= [ File.basename(__FILE__) ]
   rbfiles.each do |path|
      require( File.basename(path) )
   end
end
if $0 == __FILE__ then
   rb_main_init
   OSX.NSApplicationMain(0, nil)
end

With our Ruby classes now defined, we can access them from Objective-C. Unfortunately, we cannot directly import Ruby classes into Objective-C files; however, we can indirectly access the classes by name. While we won't do this in our application, the following code snippet shows the basic technique. It creates a MyRubyClass object defined in a MyRubyClass.rb file. It then calls the object's mySampleMethodCall().

Accessing Ruby from Objective-C

Class myRubyClass = NSClassFromString(@"MyRubyClass");
id ruby = [[myRubyClass alloc] init];
[ruby mySampleMethodCall]

Notice how RubyCocoa seamlessly translates objects between Ruby and Objective-C. Usually, you won't need to worry, things just work.

Sooner or later, however, you will rub up against one of the rougher edges. For example, RubyCocoa converts Ruby objects into Objective-C equivalents when possible. This means you can pass Ruby Strings to Objective-C methods. RubyCocoa will automatically convert them into NSStrings.

However, the reverse is not true. RubyCocoa will place a Ruby wrapper around Objective-C classes, and will sometimes add convenience methods (like adding each() to NSString, NSArray and NSDictionary), but it does not convert the classes.

So, if RubyCocoa calls an Objective-C method that returns a string, the Ruby code will get an NSString, not a Ruby String. A quick call to to_s() fixes this, but it can cause bugs if you're not careful. Also, Ruby and Objective-C sometimes have very different ideas about booleans. We'll take a closer look at that little wrinkle later.

My advice, ignore object conversions until they cause problems. This is best dealt with on a case-by-case basis.

Finally RubyRSSAppDelegate.rb acts as a Ruby-implemented delegate for our application. Feel free to poke around this file. However, you'll find the most interesting bits at the very beginning. This class not only imports the Core Data framework, it also subclasses NSObject. This just demonstrates how easily Ruby and Objective-C code can mix.

RubyRSSAppDelegate.rb

This Ruby code imports a Cocoa framework, then subclasses an Objective-C object.

require 'osx/cocoa'

OSX.require_framework 'CoreData'

class AppDelegate < OSX::NSObject

...

Defining the Model

Building a Core Data model is beyond the scope of this article. For more information, take a look at the Core Data Tutorial video (http://developer.apple.com/cocoa/coredatatutorial,/a>) or Apple's article on creating managed object models with Xcode (http://developer.apple.com/documentation/Cocoa/Conceptual/CreatingMOMWithXcode).

For simplicity's sake, lets import our model from the online source code for this article. First, download the source code from ftp://ftp.mactech.com/src/. Delete RubyRSS.xcdatamodel from your project. Select Also Move to Trash when prompted. Then, select Project... Add to Project... In the file dialog, select RubyRSS.xcdatamodel from the source code's folder. Press Add. In the next dialog, make sure Copy items into designation group's folder (if needed) is selected. Click Add again.

Now, open RubyRSS.xcdatamodel, and let's poke around inside. RubyRSS uses a simple model with only three Entities: Feed, Post and Enclosure.


RubyRSS's Data Model

The Feed entities represent our RSS subscriptions. Feed has three attributes: name, url and count. It also has a too-many relationship with Post.

Post has two attributes: title and text. Post also has two relationships: one points back to Feed, while a to-many relationship points to Enclosure. So far, so good–this isn't exactly rocket science.

Finally, Enclosure has two attributes: url and isAudio. It also has a single relationship with Post.

The attributes have straightforward data types. I've listed the details below, but nothing should come as a surprise. Also, if you look carefully at the model, you will see that I've placed some restrictions on the data. In general, I recommend making your data as restrictive as possible; however, we don't need data validation for this tutorial, so I'll let you explore it on your own.


Enclosure and Post are both NSManagedObjects. However, Feed's count attribute needs a bit of special attention. Count represents the number of posts associated with this feed. To get this behavior, we will need to subclass NSManagedObjects and override the count() accessor.

Now, as I said earlier, I hoped to implement everything using Ruby. This will be the one exception. Trying to write this in Ruby just creates problems; the default AppDelegate implementation automatically creates Key Value Coding (KVC) wrappers for any attributes declared in the NSManagedObjectModel. Since this occurs after our classes have loaded, our custom count() method gets clobbered.

We could fix this, but it's easier to write ManagedFeed in Objective-C, and I'm all about the pragmatic.

ManagedFeed.h

This is the header file for our ManagedFeed class.
#import <Cocoa/Cocoa.h>
@interface ManagedFeed : NSManagedObject {
}
-(int)count;
@end

ManagedFeed.m

This is the implementation of our ManagedFeed class.

#import "ManagedFeed.h"
@implementation ManagedFeed
+(NSSet*) keyPathsForValuesAffectingCount {
   
   NSSet *set = [super keyPathsForValuesAffectingValueForKey:@"posts"];
   
   return [set setByAddingObject:@"posts"];
}
-(int)count {
   id posts = [self valueForKey:@"posts"];
   NSArray * all = [posts allObjects];
   
   return [all count];
}
@end

You can find a detailed description of the keyPathsForValuesAffecting<key> method in the NSKeyValueObserving protocol reference. Essentially, this method describes the dependencies for a given key. KVO uses this to determine if and when the key may have changed. In our code, count could change whenever the value of the post key changes. We could specify this by just returning a set that contains @"post".

However, our implementation is a little more complicated. Apple recommends requesting an initial set of keys from the super class, then appending your own key paths to that set. In this tutorial, the call to the super class will always returns an empty set. However, this implementation protects us from future changes.

In the count method, we return the number of posts associated with this feed. We get a copy of the posts relationship using KVC . Then we extract an NSArray containing these posts. Finally, we return the number of objects in our NSArray.

Building the Controllers

Apple now recommends building your controllers before designing your interface. You can still create controller objects within Interface Builder and then export them back to Xcode. You can even export your controllers in Ruby and Python; however, I could not get the resulting code to run. Best to follow their advice and just write the controllers yourself.

Just like the standard Objective-C versions, our Ruby controllers combine outlets, actions and possibly a few helper functions. Outlets represent the UI elements that we will need to programmatically interact with. Actions represent UI-driven events.

Fortunately, RubyCocoa provides an attr_accessor-like method for defining outlets. For those not familiar with attr_accessor, it takes any number of symbols, and creates an instance variable for each one. Attr_accessor also creates the getter method <symbol>() and the setter method <symbol>=(). For example, attr_accessor :name creates @name, name() and name=().

Similarly, ib_outlet takes a comma-separated list of symbols. It converts each symbol into an instance variable with the same name. A corresponding outlet will also appear in Interface Builder.

Note: you should avoid using attr_accessor in your RubyCocoa code. Unfortunately, attr_accessor does not create KVC compliant variables, so we cannot connect to them using Bindings. The getter works fine, but Cocoa expects a set<Symbol>() setter (setName() in our example).

Fortunately, RubyCocoa provides kvc_accessor. Kvc_accessor works identically to attr_accessor, but creates KVC compliant methods.

RubyCocoa also simplifies declaring KVC dependencies. The kvc_depends_on() method takes two parameters: an array of symbols representing the dependencies, and a single symbol representing the calculated attribute.

Basically, this method replaces Objective-C's keyPathsForValuesAffecting<key>(). Take a look at our ManagedFeed.m file again. The keyPathsForValuesAffectingCount method defines count's dependency upon posts. In Ruby, we could replace that method with a single line:

kvc_depends_on([:posts], :count)

Finally, RubyCocoa elegantly handles actions. Simply define a method with a single parameter, usually named sender. After the method, add a call to ib_action() passing in the method's name as a symbol.

Sample RubyCocoa Action

def myAction
   ...
end
ib_action :myAction

Now, the Rubyists out there have undoubtedly noticed that the RubyCocoa formatting looks a bit odd. Most of this creeps in when we translate Objective-C syntax into Ruby.

Objective-C's syntax uses both named arguments and colons–neither of which translates nicely. Therefore, when referring to an Objective-C method, concatenate all the pieces of its signature, and replace the colons with underscores.

[canvas print: text withFontColor: red];

becomes

canvas.print_withFontColor_(text, red)

As a bit of syntactic sugar, RubyCocoa allows you to drop the final underscore. So, print_withFontColor_() becomes print_withFontColor(). Note: the Ruby and Python Programming Topics for Mac OS X article claims that this option is disabled by default. This is not true. In most cases, you can use the two variants interchangeably. The exceptions, however, can cause real pain.

When Objective-C calls a Ruby method that overrides an Objective-C method (Ah, yes. She knows that I know that she knows that I know. . . .), RubyCocoa looks for the method signature without the trailing underscore. So, just to prevent possible problems, I recommend universally dropping the last underscore.

For consistency, I've tried to use camel case for actions (likeThis). Pure-ruby helper functions have the more-traditional underscore names (like_this).

OK, enough babbling. Let's look at the code. We will have two windows in our UI, the main window, and a dialog for adding new feeds Let's create a controller for each: MainController.rb and AddFeedController.rb respectively.

MainController.rb

This class acts as the controller for our main window. It responds to all the main window's actions, and makes changes to the data model. It will also coordinate with both the FeedTools and RubyOSA libraries when necessary.

require 'osx/cocoa'
# Controller for the Main window.
class MainController < OSX::NSObject
   ib_outlet :feeds, :posts, :enclosures, :web_view, :posts_table, 
      :enclosures_table, :progress, :app_delegate
   
   # accessor for the current Feed collection.
   def feeds
      return @feeds.arrangedObjects
   end
   # Add remaining methods here
end

Here, we're building a subclass of NSObject. We start by declaring a slew of outlets for Interface Builder. The feeds() accessor returns an array containing all our Feed entities. This represents all currently subscribed feeds.

The next two methods override NSObject methods. The Cocoa framework will automatically call these.

NSObject Methods

# Initializes the Main Window after it is loaded from the NIB.
def awakeFromNib
   @progress.setDisplayedWhenStopped(false)
   @posts.addObserver_forKeyPath_options_context(self, "selection", 
      0, nil)
end
   
# This listener method will be called whenever the Post Table's selection 
# changes. It updates the HTML in the web view.
def observeValueForKeyPath_ofObject_change_context( key_path, object, 
   change, context)
   set_html if @posts.isEqual(object)
end

The framework calls our awakeFromNib() method after all objects have been loaded from the nib file, and once all outlets are set. We can use this method to perform any additional initialization. In our case, we make the NSProgressIndicator invisible when not in use. We also force our controller to listen for any changes to the @posts selection.

The framework now calls observeValueForKeyPath_ofObject_change_context() whenever @posts's selection changes. We simply verify that we're receiving an update from @posts, then call the set_html() helper method.

Note: As I mentioned earlier, you must drop the final underscore from this method's name. Otherwise, Key Value Observing (KVO) cannot find our implementation.

Next, we declare two actions: sendToItunesAction() and refreshFeedsAction(). Currently, they just print a message to the console.

Actions

# This action sends the currently selected Enclosure to iTunes.

def sendToItunesAction(sender)
   puts "Send to iTunes"
end
ib_action :sendToItunesAction
   
# This action refreshes all the feeds.   
def refreshFeedsAction(sender)
      puts "Refresh Feeds"
end
   
ib_action :refreshFeedsAction

Add_feed() adds a new Feed entity to the managed object context. It then fills in the feeds attributes. Notice that it leaves the posts relationship blank. We don't have any posts yet.

add_feed()

# Adds a new feed to the Managed Object Context
def add_feed(name, url) 
   
   moc =  @app_delegate.managedObjectContext
   
   new_feed = OSX::NSEntityDescription\
      .insertNewObjectForEntityForName_inManagedObjectContext("Feed", 
         moc);
   new_feed.setValue_forKey(name, "name")
   new_feed.setValue_forKey(url, "url")
end

Finally, set_html() gets the text from our currently selected post. We then display this text in our web view.

set_html()

private
      
# Helper Function: Updates the HTML displayed by the Web View to the text 
# of the currently selected post.
def set_html
      
   index = @posts_table.selectedRow
   frame = @web_view.mainFrame
      
   # If nothing is selected, just return.
   if index < 0 then
      frame.loadHTMLString_baseURL("", nil)
   else
      post = @posts.arrangedObjects[index]
      frame.loadHTMLString_baseURL(post.text, nil)   
   end
end

Our second controller is even simpler. This controller has only two outlets, plus two KVC-compliant properties, and a third virtual property.

The sheet outlet provides access to the Add Feed dialog sheet, while the window_controller provides a link back to our main controller.

The name and url properties hold (not surprisingly) the name and URL of the new feed.

Finally, the virtual property, valid_feed, returns true if the feed has a valid name and URL. Obviously, valid_feed depends upon the name and url properties. Key Value Observing will call our valid-feed accessor whenever either of the dependent variables changes.

AddFeedController.rb

require 'osx/cocoa'
# AddFeedController acts as the controller for the Add Feed sheet.
class AddFeedController < OSX::NSObject
   ib_outlet :sheet, :window_controller
   kvc_accessor :name, :url
   kvc_depends_on([:name, :url], :valid_feed)
   
   # Add methods here
end

The open_dialog() method opens the Add Feed dialog. We declare this as an action, so that we can link it to a button on the main window.

open_dialog()

# This action opens the Add Feed sheet.
def open_dialog(sender)
      
   OSX::NSApp.beginSheet_modalForWindow_modalDelegate_\
      didEndSelector_contextInfo(   @sheet, 
                                       @main_window, 
                                       self, 
                                       nil, 
                                       nil)
end
   
ib_action :open_dialog

Next, we add our add_feed() action. We will link this action to the Add button on the Add Feed dialog sheet. This method simply converts the feed name and URL into Ruby Strings, then delegates back to the main window controller's add_feed() method. Finally, it closes the Add Feed sheet.

add_feed()

# The add_feed action grabs the name and url from the Add Feed sheet, 
# adds the new feed to the Managed Object Context, then closes the sheet.
def add_feed(sender)
   feed_name = @name.to_s
   feed_url = @url.to_s
      
   @window_controller.add_feed(feed_name, feed_url)
      
   close_dialog
end
   
ib_action :add_feed

The cancel() action simply closes the Add Feed dialog sheet. We will link this action to the Cancel button on the Add Feed sheet.

cancel()

# The cancel action closes the sheet without adding a new feed.
def cancel(sender)      
      close_dialog
end
   
ib_action :cancel

The valid_feed() method uses Ruby's regular expressions to filter out invalid entries. Basically, the feed name must contain at least one non-whitespace character, while the URL must start with "feed://", then contain one or more characters, a period, and end with one or more characters. The URL cannot have any white space.

Note: While Ruby has explicit true and false values, it also treats all nil values as false, and all non-nil values as true. This means, the result of ANDing together two regular expressions is either nil or the String matched by the second regular expression. While Ruby will correctly interpret this as a boolean value, when we pass it to the Cocoa framework, we get the following exception:

AddFeedController#rb

SetValue_forKey: OSX::OCException: 
NSInternalInconsistencyException - 
  Cannot create BOOL from object <RBObject: 0x1437bdd0> 
  of class RBObject

To prevent this, we explicitly convert our result to a boolean value.

valid_feed()

# valid_feed returns true if the Add Feed sheet's current name and url 
# values are valid. This method can be monitored using KVO. 
def valid_feed
   name = @name.to_s
   url = @url.to_s
      
   result = name.match('\S+') && url.match('^feed://\S+\.\S+$')
      
   # explicitly convert to booleans.
   return ! result.nil?
end

Finally, the private helper function, close_dialog() clears the Text Fields and closes the dialog.

close_dialog()

private
# Helper Function: close_dialog clears the Add Feed's 
# text boxes, then closes the sheet.
def close_dialog
   setName("")
   setUrl("")
   
   @sheet.orderOut(self)
   OSX::NSApp.endSheet(@sheet)
end

Building the User Interface

Building the user interface is also beyond the scope of this article. Simply copy MainMenu.nib from the online source code.

Our user interface consists of the main window and the Add Feed panel. We have three array controllers. The first contains all of our Feed entities. The second contains all Post entities associated with the currently selected Feed. The third contains all Enclosure entities associated with our currently selected Post. Bindings automatically maintain these relationships, requiring no code on our part.

Finally, we have the Add Feed and the Main controllers defined in the previous section.


RubyRSS's Arrays and Controllers

The Main window consists of three Table Views. The first contains the names and post counts from the Feeds array. The second displays titles from the Posts array. The final table contains URLs from the Enclosures array. Again, we set all of these values using Bindings. Of these, only the feed names are editable.

The Main window also has a Web View. This contains the text for the currently selected post; however, unlike the Table Views, we cannot set the Web View's content using Bindings. Instead, we actually have to write code.

The good news is, you've already written this code. Look back at our main controller. Remember, how it receives notifications whenever the posts' selection changes? It then fires the set_html() helper function. That's the code we need. We use KVO to automatically synchronize our web view with the current selection. Basically, we're recreating the code that Bindings normally gives us for free.

Finally, our main window has four Buttons: one adds a new feed, one deletes the selected feed, one sends the selected enclosure to iTunes, and the last one refreshes all our feeds. Since some of these operations can take a long time, we also have a Progress Indicator.


RubyRSS's Main Window

Even simpler, the Add Feed panel has two Text Fields: one for the Feed's name and one for the URL. Each Text Field has a corresponding Label. Finally, we have an Add Button and a Cancel Button.


Add Feed Window

The connections between our UI elements and the controllers' outlets and actions should seem straightforward enough. I won't go into the details here, but I encourage you to open up the nib in Interface Builder and get a feel for the wiring.

One last quick step. Since our user interface uses a Web View, we need to add the WebKit.framework to our project. Right click on the Frameworks folder in the Groups & Files tree. Select Add... Existing Frameworks.... In the File dialog, find and select the WebKit.framework and select Add. In the next panel, just select Add again.

You can now compile and launch the application. Of course, it won't do much yet. We can add new feeds, but we cannot actually read or parse them. All the basic RubyCocoa code works, but we still need to add support for FeedTools and RubyOSA.

Parsing the Feeds

The FeedTools library provides code for parsing, generating and auto discovery of RSS, atom and cdf feeds. We're only using a fraction of its abilities. If you want to know more, check out the web page for FeedTools and its sister project FeedUpdater (http://sporkmonger.com/projects/feedtools/).

Let's make a new class to handle the interactions between FeedTools and our data model. Create a new Ruby class named FeedReader.rb.

FeedReader.rb

require 'rubygems'
require 'feed_tools'
require 'osx/cocoa'
OSX.require_framework 'CoreData'
# FeedReader class uses feed_tools to download and parse all the feeds, 
# then adds new posts and enclosures to the Managed Object Context.
class FeedReader
   # insert methods here
end

FeedReader starts by loading the required libraries. Obviously we need FeedTools and RubyCocoa. The RubyGems library lets us access any gem-installed libraries; therefore, we need to load RubyGems before loading FeedTools. Most interestingly, the OSX.require_framework method lets us load Cocoa frameworks. In this case, FeedReader needs Core Data.

initialize()

# Default Constructor
def initialize(main_controller, moc)
   @main_controller = main_controller
   @moc = moc
end

Initialize() allows us to construct new FeedReader objects. It takes two arguments: a reference to the main window controller, and a reference to our managed object context.

refresh()

# Gets the current list of feeds. Downloads all Feeds and adds new Posts 
# and Enclosures to the Managed Object Context.
def refresh
   feed_entries = @main_controller.feeds
   feed_entries.each {|data_feed| update(data_feed)}
end

FeedReader only exposes a single method to the outside. Refresh() iterates over all the feeds, passing each one to the update() helper function.

update()

private
   
# Helper Function: updates a single Feed.
def update(data_feed)
   feed = FeedTools::Feed.open(data_feed.url)
   posts = feed.entries
   posts.each{|post| add_post(post, data_feed)}
end

Update() extracts the list of available posts for each feed. It iterates over the list of posts, calling add_post() for each one.

add_post()

# Helper Function: adds new posts to the given feed.
def add_post(post, data_feed) 
   title = post.title
   text = post.summary
      
   # check to see if this already exists...
   return if post_exists?(title, text)
            
   # now make a new entity
   data_post = OSX::NSEntityDescription\
      .insertNewObjectForEntityForName_inManagedObjectContext("Post",
         @moc);
      
   data_post.setValue_forKey(title, "title")
   data_post.setValue_forKey(text, "text")
   data_post.setValue_forKey(data_feed, "feed")
      
   enclosures = post.enclosures
   enclosures.each do |enclosure| 
      add_enclosure(enclosure, data_post)}
   end
end

Add_post() extracts the post's title and text. It then calls post_exists?(), checking if any posts in the managed object context already have a matching title and text. If the post doesn't already exist, add_post() adds a new Post Entity. It then fills in the entity's attributes and sets the feed relationship.

Since we're using bi-directional relationships, Core Data automatically adds this post to its Feed. Finally, add_post() iterates over the post's list of enclosures, calling add_enclosure() for each one.

post_exists?()

# Helper Function: determine if a post exists with the given title and 
# text.
def post_exists?(title, text)
   request = OSX::NSFetchRequest.alloc.init
      
   description = OSX::NSEntityDescription\
      .entityForName_inManagedObjectContext('Post', @moc)
      
   request.setEntity(description)
      
   predicate = OSX::NSPredicate.predicateWithFormat(
      "title like %@ AND text like %@", title, text)
      
   request.setPredicate(predicate)
      
   error = nil
      
   count = @moc.countForFetchRequest_error(request, error)
      
   # if we have an error, assume the post doesn't exist.
   if !error.nil?
      puts "*** Error ***"
      puts error
      return false
   end
      
   count > 0
end

Post_exists?() builds an NSFetchRequest for all Post entities whose title and text mach the given arguments. We then use countForFetchRequest_error_() to count the number of matching Posts. For any number greater than zero, we return true. Otherwise, we return false. Note: we log any errors, but simply assume no matches are found.

add_enclosure()

   
# Helper Function: adds a single Enclosure to the given Post.
def add_enclosure(enclosure, data_post)
   
   url = enclosure.url
   isAudio = enclosure.audio?
      
   new_enclosure = OSX::NSEntityDescription.\
      insertNewObjectForEntityForName_inManagedObjectContext(
         "Enclosure", @moc);
   new_enclosure.setValue_forKey(url, "url")
   new_enclosure.setValue_forKey(isAudio, "isAudio")
   new_enclosure.setValue_forKey(data_post, "post")
end

Finally, add_enclosure() adds a new Enclosure entity to the managed object context. It then fills the attributes, and sets the post relationship. Again, we have a bi-directional relationship, so Core Data automatically adds this Enclosure to the Post's enclosures relationship.

Now we just need to make our MainController aware of the FeedReader. Add the following line to MainController's awakeFromNib() method:

@feed_reader = FeedReader.new(self, @app_delegate.managedObjectContext)

This creates a new FeedReader object. We just need to call @feed_reader.refresh() whenever the user presses the Refresh Feeds button. However, this operation can take a while, especially when you subscribe to a lot of feeds. We don't want our UI to freeze up. Also, we would like to let the user know that something is actually happening. So, let's have FeedReader refresh the feeds in a second thread, and turn on the progress bar.

Unfortunately, Ruby 1.8 is not thread safe. We cannot call Ruby code from a second (or third, or fourth...) Objective-C thread. However, we can use Ruby's internal threads. Ruby uses a green threading model. Basically, as far as the hardware knows, Ruby runs in a single thread, but the Ruby interpreter can time slice between several green threads.

Ruby threads have some advantages and some disadvantages over processor threads. A full discussion is beyond the scope of this article, but — bottom line — they work fine for our purposes. The user interface will not freeze up while we refresh the feeds.

Simply replace refreshFeedsAction() with the following code.

new refreshFeedsAction()

# This action refreshes all the feeds.
def refreshFeedsAction(sender)
   Thread.new do
      begin
         @progress.startAnimation(self)
         @feed_reader.refresh
         @progress.stopAnimation(self)
      rescue Exception => e
         puts e.message
         OSX::NSApp.stop(self)
      end
   end
end
ib_action :refreshFeedsAction

The begin. . .rescue. . .end blocks handle any exceptions thrown in our worker thread. If an error occurs, it executes the rescue block, which logs the error and quits the application.

I'm a firm believer in failing fast. We don't want our code to lumber ahead in an unknown state. At least during development, go ahead and force the application to stop as soon as an error occurs.

That's it. Open up the RSS reader and add a few feeds. Click the Refresh Feeds button, and watch the posts roll in. Quit the application, and then launch it again. Core Data automatically saves all our data.


The Complete RubyRSS

This almost looks like a real application. Almost. It still needs a lot of work. For example, the tables remain completely unsorted. Ideally, users will want to filter them as well. By default we should probably filter out any posts that the user has already read. Searching would also be nice.

From a software engineering standpoint, we're playing fast and loose with our threads here. The user can perform any number of bad actions (like quitting the application) while the worker thread is still running. We should probably address that.

But, you have to admit, we squeezed a ton of functionality out of a few hundred lines of code. Ruby + Core Data + Bindings makes a high-octane combination.

Next time, we will look at using RubyOSA to send enclosures to iTunes. We will also take a look at RubyCocoa's debugging options, and look at a few other cool tricks as well.


Rich Warren lives in Honolulu, Hawaii with his wife, Mika, daughter, Haruko, and his son, Kai. He is a software engineer, freelance writer and part time graduate student. When not playing on the beach, he is probably writing, coding or doing research on his MacBook Pro. You can reach Rich at rikiwarren@mac.com, or check out his blog at http://freelancemadscience.blogspot.com/

 
AAPL
$99.18
Apple Inc.
-1.57
MSFT
$45.90
Microsoft Corpora
-0.46
GOOG
$568.27
Google Inc.
-9.09

MacTech Search:
Community Search:

Software Updates via MacUpdate

Cocktail Family License (5 Macs) 7.6.1 -...
Cocktail is a general purpose utility for OS X that lets you clean, repair and optimize your Mac. It is a powerful digital toolset that helps hundreds of thousands of Mac users around the world get... Read more
Cocktail 8.0 Beta 2 - General maintenanc...
Cocktail is a general purpose utility for OS X that lets you clean, repair and optimize your Mac. It is a powerful digital toolset that helps hundreds of thousands of Mac users around the world get... Read more
QuickBooks 2015 16.0.0.1352 R1 - Financi...
QuickBooks 2015 helps you manage your business easily and efficiently. Organize your finances all in one place, track money going in and out of your business, and spot areas where you can save.... Read more
Mac DVDRipper Pro 5.0.1 - Copy, backup,...
Mac DVDRipper Pro is the DVD backup solution that lets you protect your DVDs from scratches, save your batteries by reading your movies from your hard disk, manage your collection with just a few... Read more
Apple OS X bash Update 1.0 - Fix for sec...
The OS X bash Update fixes a security flaw in the bash UNIX shell on OS X 10.9.5 (also on OS X 10.8 and 10.7 [see Related Links below]). OS X 10.9.5 or later Downloads for OS X 10.8 and OS X 10.7 in... Read more
SyncTwoFolders 2.0.5 - Syncs two user-sp...
SyncTwoFolders simply synchronizes two folders. It supports synchronization across mounted network drives and it is a possibility to run a simulation showing in a log what will be done. Please visit... Read more
FinderPop 2.5.7 - Classic Mac utility, n...
FinderPop is a Universal preference pane that extends OS X's contextual menus using a FinderPop Items folder much as the Apple Menu Items folder used to do for the Apple menu. It has other features... Read more
VueScan 9.4.45 - Scanner software with a...
VueScan is a scanning program that works with most high-quality flatbed and film scanners to produce scans that have excellent color fidelity and color balance. VueScan is easy to use, and has... Read more
LibreOffice 4.3.2.2 - Free Open Source o...
LibreOffice is an office suite (word processor, spreadsheet, presentations, drawing tool) compatible with other major office suites. The Document Foundation is coordinating development and... Read more
calibre 2.4 - Complete e-library managem...
Calibre is a complete e-book library manager. Organize your collection, convert your books to multiple formats, and sync with all of your devices. Let Calibre be your multi-tasking digital... Read more

Latest Forum Discussions

See All

iKeywi - Customizable 5-Row Keyboard (U...
iKeywi - Customizable 5-Row Keyboard 1.0 Device: iOS Universal Category: Utilities Price: $.99, Version: 1.0 (iTunes) Description: Want to add an extra row to your iPhone/iPad? One of the most popular keyboard extension in iOS... | Read more »
Manage Your Cloud – Wunderlist Now Suppo...
Manage Your Cloud – Wunderlist Now Supports Dropbox Posted by Jessica Fisher on October 1st, 2014 [ permalink ] Universal App - Designed for iPhone and iPad | Read more »
Nexticy Review
Nexticy Review By Jennifer Allen on October 1st, 2014 Our Rating: :: IDEAL FORM CREATIONiPad Only App - Designed for the iPad Nexticy allows you to make your own forms for research purposes or to organize your business better. It’s... | Read more »
Tiny Troopers: Alliance Marches onto the...
Tiny Troopers: Alliance Marches onto the App Store Tomorrow Posted by Jessica Fisher on October 1st, 2014 [ permalink ] Tiny Troopers: Alliance, by Kukouri, is a | Read more »
HeroCraft Introduces Unlimited Sequel to...
HeroCraft Introduces Unlimited Sequel to WW2: Sandbox. Strategy & Tactics Posted by Jessica Fisher on October 1st, 2014 [ permalink ] | Read more »
RGB Express Review
RGB Express Review By Jennifer Allen on October 1st, 2014 Our Rating: :: DELIGHTFUL PUZZLINGUniversal App - Designed for iPhone and iPad Guide trucks along their delivery routes in RGB Express, a testing but charming puzzle game... | Read more »
The Sagas of Fire*Wolf (Games)
The Sagas of Fire*Wolf 1.0 Device: iOS Universal Category: Games Price: $9.99, Version: 1.0 (iTunes) Description: | Read more »
BuggyFun Review
BuggyFun Review By Amy Solomon on October 1st, 2014 Our Rating: iPad Only App - Designed for the iPad BuggyFun allows children to create their own tracks for bugs to interact with for a unique open-ended experience.   | Read more »
Fold the Adventure Review
Fold the Adventure Review By Jennifer Allen on October 1st, 2014 Our Rating: :: AWKWARD FOLDSUniversal App - Designed for iPhone and iPad Fold pieces of paper to create platforms for a princely rabbit in this puzzle game; something... | Read more »
WW2: Sandbox. Strategy & Tactics (G...
WW2: Sandbox. Strategy & Tactics 1.0.0 Device: iOS Universal Category: Games Price: $9.99, Version: 1.0.0 (iTunes) Description: ***NOTE: Compatible with iPhone 4s and up, iPad 2 and up - may not work properly on earlier devices... | Read more »

Price Scanner via MacPrices.net

Amazon offers 13-inch MacBook Air for $899, $...
Amazon.com has the 13″ 1.4GHz 128GB MacBook Air on sale for $100 off MSRP including free shipping: - 13″ 1.4GHz 128GB MacBook Air: $899.99 Read more
Apple resting On Its iPhone Laurels? – The ‘B...
Apple calls its new iPhone 6 and 6 Plus “The Biggest Advancements in iPhone History,” but does reality live up to the hype? “Seldom have so many waited so breathlessly for so little,” tweeted veteran... Read more
Roundup of Apple Mac and iPad Education disco...
Purchase a new Mac or iPad at The Apple Store for Education and take up to $300 off MSRP. All teachers, students, and staff of any educational institution qualify for the discount. Shipping is free,... Read more
Apple Boycotts German Magazine Computer Bild...
Apple has revoked its PR accreditation of Germany’s Computer Bild, Europe’s best-selling PC magazine, in reaction to Bild’s posting of a “#Bentgate” YouTube video. Axel Telzerow, editor in chief of... Read more
iPhone 6 & iPhone 6 Plus Available in Chi...
Apple has announced that iPhone 6 and iPhone 6 Plus will be available in China beginning Friday, October 17 from the Apple Online Store (http://www.apple.com), Apple’s retail stores, and an expansive... Read more
MacBook Airs on sale for $100 off MSRP, start...
Best Buy has the new 2014 MacBook Airs on sale for $100 off MSRP on their online store. Choose free home shipping or free local store pickup (if available). Prices valid for online orders only, in-... Read more
Apple Releases OS X Mavericks bash Update 1.0...
Apple has released a patch update for OS X Mavericks users to address the recently-detected “Shellshock” security bug in BSD UNIX’s bash shell. Apple says only a few Mac users who had manually... Read more
Pivotal Payments Ready for Apple Pay – FlexPo...
Pivotal Payments, a provider of merchant services and global payment processing solutions, has announced its proprietary FlexPoint platform will support credit and debit transactions through Apple’s... Read more
iStabilizer Announces Tabarm — First Friction...
iStabilizer, a specialist in universal lightweight compact tripods, steady cams, dollies, mounts, and remotes for smartphones, tablets, and cameras, announced today the iStabilizer tabArm, the first... Read more
IStabilizer Flex Smartphone Tripod Wins Usa T...
iStabilizer, a specialist in universal lightweight compact tripods, steady cams, and other products for smartphones, tablets, and cameras, has announced today that its iStabilizer Flex smartphone... Read more

Jobs Board

*Apple* Retail - Multiple Positions (US) - A...
Sales Specialist - Retail Customer Service and Sales Transform Apple Store visitors into loyal Apple customers. When customers enter the store, you're also the Read more
*Apple* Retail - Multiple Positions (US) - A...
Job Description: Sales Specialist - Retail Customer Service and Sales Transform Apple Store visitors into loyal Apple customers. When customers enter the store, Read more
Senior Event Manager, *Apple* Retail Market...
…This senior level position is responsible for leading and imagining the Apple Retail Team's global event strategy. Delivering an overarching brand story; in-store, Read more
*Apple* Solutions Consultant (ASC) - Apple (...
**Job Summary** The ASC is an Apple employee who serves as an Apple brand ambassador and influencer in a Reseller's store. The ASC's role is to grow Apple Read more
Project Manager / Business Analyst, WW *Appl...
…a senior project manager / business analyst to work within our Worldwide Apple Fulfillment Operations and the Business Process Re-engineering team. This role will work Read more
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.