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/

 

Community Search:
MacTech Search:

Software Updates via MacUpdate

Latest Forum Discussions

See All

Tokkun Studio unveils alpha trailer for...
We are back on the MMORPG news train, and this time it comes from the sort of international developers Tokkun Studio. They are based in France and Japan, so it counts. Anyway, semantics aside, they have released an alpha trailer for the upcoming... | Read more »
Win a host of exclusive in-game Honor of...
To celebrate its latest Jujutsu Kaisen crossover event, Honor of Kings is offering a bounty of login and achievement rewards kicking off the holiday season early. [Read more] | Read more »
Miraibo GO comes out swinging hard as it...
Having just launched what feels like yesterday, Dreamcube Studio is wasting no time adding events to their open-world survival Miraibo GO. Abyssal Souls arrives relatively in time for the spooky season and brings with it horrifying new partners to... | Read more »
Ditch the heavy binders and high price t...
As fun as the real-world equivalent and the very old Game Boy version are, the Pokemon Trading Card games have historically been received poorly on mobile. It is a very strange and confusing trend, but one that The Pokemon Company is determined to... | Read more »
Peace amongst mobile gamers is now shatt...
Some of the crazy folk tales from gaming have undoubtedly come from the EVE universe. Stories of spying, betrayal, and epic battles have entered history, and now the franchise expands as CCP Games launches EVE Galaxy Conquest, a free-to-play 4x... | Read more »
Lord of Nazarick, the turn-based RPG bas...
Crunchyroll and A PLUS JAPAN have just confirmed that Lord of Nazarick, their turn-based RPG based on the popular OVERLORD anime, is now available for iOS and Android. Starting today at 2PM CET, fans can download the game from Google Play and the... | Read more »
Digital Extremes' recent Devstream...
If you are anything like me you are impatiently waiting for Warframe: 1999 whilst simultaneously cursing the fact Excalibur Prime is permanently Vault locked. To keep us fed during our wait, Digital Extremes hosted a Double Devstream to dish out a... | Read more »
The Frozen Canvas adds a splash of colou...
It is time to grab your gloves and layer up, as Torchlight: Infinite is diving into the frozen tundra in its sixth season. The Frozen Canvas is a colourful new update that brings a stylish flair to the Netherrealm and puts creativity in the... | Read more »
Back When AOL WAS the Internet – The Tou...
In Episode 606 of The TouchArcade Show we kick things off talking about my plans for this weekend, which has resulted in this week’s show being a bit shorter than normal. We also go over some more updates on our Patreon situation, which has been... | Read more »
Creative Assembly's latest mobile p...
The Total War series has been slowly trickling onto mobile, which is a fantastic thing because most, if not all, of them are incredibly great fun. Creative Assembly's latest to get the Feral Interactive treatment into portable form is Total War:... | Read more »

Price Scanner via MacPrices.net

Early Black Friday Deal: Apple’s newly upgrad...
Amazon has Apple 13″ MacBook Airs with M2 CPUs and 16GB of RAM on early Black Friday sale for $200 off MSRP, only $799. Their prices are the lowest currently available for these newly upgraded 13″ M2... Read more
13-inch 8GB M2 MacBook Airs for $749, $250 of...
Best Buy has Apple 13″ MacBook Airs with M2 CPUs and 8GB of RAM in stock and on sale on their online store for $250 off MSRP. Prices start at $749. Their prices are the lowest currently available for... Read more
Amazon is offering an early Black Friday $100...
Amazon is offering early Black Friday discounts on Apple’s new 2024 WiFi iPad minis ranging up to $100 off MSRP, each with free shipping. These are the lowest prices available for new minis anywhere... Read more
Price Drop! Clearance 14-inch M3 MacBook Pros...
Best Buy is offering a $500 discount on clearance 14″ M3 MacBook Pros on their online store this week with prices available starting at only $1099. Prices valid for online orders only, in-store... Read more
Apple AirPods Pro with USB-C on early Black F...
A couple of Apple retailers are offering $70 (28%) discounts on Apple’s AirPods Pro with USB-C (and hearing aid capabilities) this weekend. These are early AirPods Black Friday discounts if you’re... Read more
Price drop! 13-inch M3 MacBook Airs now avail...
With yesterday’s across-the-board MacBook Air upgrade to 16GB of RAM standard, Apple has dropped prices on clearance 13″ 8GB M3 MacBook Airs, Certified Refurbished, to a new low starting at only $829... Read more
Price drop! Apple 15-inch M3 MacBook Airs now...
With yesterday’s release of 15-inch M3 MacBook Airs with 16GB of RAM standard, Apple has dropped prices on clearance Certified Refurbished 15″ 8GB M3 MacBook Airs to a new low starting at only $999.... Read more
Apple has clearance 15-inch M2 MacBook Airs a...
Apple has clearance, Certified Refurbished, 15″ M2 MacBook Airs now available starting at $929 and ranging up to $410 off original MSRP. These are the cheapest 15″ MacBook Airs for sale today at... Read more
Apple drops prices on 13-inch M2 MacBook Airs...
Apple has dropped prices on 13″ M2 MacBook Airs to a new low of only $749 in their Certified Refurbished store. These are the cheapest M2-powered MacBooks for sale at Apple. Apple’s one-year warranty... Read more
Clearance 13-inch M1 MacBook Airs available a...
Apple has clearance 13″ M1 MacBook Airs, Certified Refurbished, now available for $679 for 8-Core CPU/7-Core GPU/256GB models. Apple’s one-year warranty is included, shipping is free, and each... Read more

Jobs Board

Seasonal Cashier - *Apple* Blossom Mall - J...
Seasonal Cashier - Apple Blossom Mall Location:Winchester, VA, United States (https://jobs.jcp.com/jobs/location/191170/winchester-va-united-states) - Apple Read more
Seasonal Fine Jewelry Commission Associate -...
…Fine Jewelry Commission Associate - Apple Blossom Mall Location:Winchester, VA, United States (https://jobs.jcp.com/jobs/location/191170/winchester-va-united-states) Read more
Seasonal Operations Associate - *Apple* Blo...
Seasonal Operations Associate - Apple Blossom Mall Location:Winchester, VA, United States (https://jobs.jcp.com/jobs/location/191170/winchester-va-united-states) - Read more
Hair Stylist - *Apple* Blossom Mall - JCPen...
Hair Stylist - Apple Blossom Mall Location:Winchester, VA, United States (https://jobs.jcp.com/jobs/location/191170/winchester-va-united-states) - Apple Blossom Read more
Cashier - *Apple* Blossom Mall - JCPenney (...
Cashier - Apple Blossom Mall Location:Winchester, VA, United States (https://jobs.jcp.com/jobs/location/191170/winchester-va-united-states) - Apple Blossom Mall Read more
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.