For my Mac-based iCatalog Editor app I am developing a preview mode that allows for on-device previewing of iCatalogs. This is modeled after the Preview mode in iBooks Author with the tiny difference that there Apple restricts the preview feature to iPads connected via USB, which we will be using the full power of Bonjour to use any app that is running our specialized iCatalog Viewer app on the local WiFi network.
Apple has done a great job making service publishing and discovery a breeze. However they are severely lacking in the object-oriented Sockets department. in this blog post I’ll be developing an Objective-C library that will greatly simplify the process of finding, connecting and communicating with other devices.
And since we are developing for iOS and OS X in parallel the resulting code will work on Mac and iOS devices just the same.
I got most of my knowledge from the WWDC 2012 talk “Simplify Networking with Bonjour” coupled with some pointers from the CocoaEcho sample code. As with most APIs by Apple you would expect to be able to work on one of three levels: Posix Sockets, CoreFoundation Sockets or something at the NS* Foundation level. But apparently Apple doesn’t want to make it TOO easy for us, so the only two viable options for what we are trying to achieve are the first two.
When communicating over the network you will usually have a client that connects to a port on a server, both referring to software. Client and Server might be having the same abilities when communicating in the end, but the main difference is that a server app will establish a port on which it is listening for an incoming connection. Imagine if you will a web server that is listening for HTTP traffic on port 80. A client app would be a web browser connecting to this well-known port.
You might be tempted to use a well-known low number port for your server, but the problem with this is that your static port number might clash with another process having been bound to this very port before your app gets a chance to. Instead we will let the OS assign us a random free high port number. Then we will employ Bonjour to announce the availability of our service on this port.
On a connection oriented socket (TCP) you would do something like this to receive connections (“Server”):
- Create socket
- Bind same socket to an address
- Listen for incoming connections on the socket
- Accept incoming connections, this returns another socket
… and this to connect (“Client”):
- Create socket
- Connect socket
On the very lowest level those ports are so-called file handles. To Unix it does not make a difference whether a file handle is actually an identifier of a local file or a network stream. Those file handles are also referred to as “Native Socket Handle”.
Server
Let’s first look at the simplest possible way to achieve the server-ing part on the lowest level. To future-proof the thing we’ll be getting an IPv4 as well as an IPv6 port.
// create IPv4 socket int fd4 = socket(AF_INET, SOCK_STREAM, 0); // allow for reuse of local address static const int yes = 1; int err = setsockopt(fd4, SOL_SOCKET, SO_REUSEADDR, (const void *) &yes, sizeof(yes)); // a structure for the socket address struct sockaddr_in sin; memset(&sin, 0, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_len = sizeof(sin); sin.sin_port = htons(0); // asks kernel for arbitrary port number err = bind(fd4, (const struct sockaddr *)&sin, sin.sin_len); socklen_t addrLen = sizeof(sin); err = getsockname(fd4, (struct sockaddr *)&sin, &addrLen); err = listen(fd4, 5); |
If you step through this code with the debugger you will find that the sin_port field will get a value assigned by the bind command. Since we want the connection for IPv6 to be on the same port the code for creating this is almost identical except for passing in the IPv4 port.
// create IPv6 socket int fd6 = socket(AF_INET6, SOCK_STREAM, 0); int one = 1; err = setsockopt(fd6, IPPROTO_IPV6, IPV6_V6ONLY, &one, sizeof(one)); err = setsockopt(fd6, SOL_SOCKET, SO_REUSEADDR, (const void *) &yes, sizeof(yes)); struct sockaddr_in sin6; memset(&sin6, 0, sizeof(sin6)); sin6.sin_family = AF_INET6; sin6.sin_len = sizeof(sin6); sin6.sin_port = sin.sin_port; // uses same port as IPv4 err = bind(fd6, (const struct sockaddr *) &sin6, sin6.sin_len); err = listen(fd6, 5); |
Note that we are not setting any value for sin_addr.s_addr because we don’t care about what IP address the service will be accepting connections for. We might also pass in INADDR_ANY (which is defined as 0) to make this clearer to somebody reading our code.
Until here it was all C and Posix, but for tying this into app flow we need to schedule the ports are so-called “Run Loop Source”. As I understand it our app’s run loop will check for now data on these sockets at each iteration.
The CocoaEcho sample creates the CFSocket directly without going down to the Posix level. It is a matter of preference which of the both approaches you chose, both have the same result.
In order to put a socket (which we now have in the form of a file handle) into a run loop source we first need to wrap it into a CFSocket, providing a C-style callback function that gets called as soon as somebody is connecting to the it.
void ListeningSocketCallback(CFSocketRef s, CFSocketCallBackType type, CFDataRef address, const void *data, void *info) { DTBonjourServer *server = (__bridge DTBonjourServer *)info; // For an accept callback, the data parameter is a pointer to a CFSocketNativeHandle. [server _acceptConnection:*(CFSocketNativeHandle *)data]; } - (void)_addRunLoopSourceForFileDescriptor:(int)fd { CFSocketContext context = {0, (__bridge void *)self, NULL, NULL, NULL}; CFSocketRef sock; CFRunLoopSourceRef rls; sock = CFSocketCreateWithNative(NULL, fd, kCFSocketAcceptCallBack, ListeningSocketCallback, &context); rls = CFSocketCreateRunLoopSource(NULL, sock, 0); CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, kCFRunLoopCommonModes); CFRelease(rls); CFRelease(sock); } - (void)startListening { // setup done here // schedule run loop sources [self _addRunLoopSourceForFileDescriptor:fd4]; [self _addRunLoopSourceForFileDescriptor:fd6]; // publish via Bonjour _service = [[NSNetService alloc] initWithDomain:@"" // use all available domains type:@"_icatalogpreview._tcp." name:@"" // uses default name of system port:ntohs(sin.sin_port)]; [_service scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; _service.delegate = self; [_service publish]; } |
CFSocketCreateWithNative takes our native file handle and makes it into a CFSocket. The CFSocketContext is used to pass a bridged pointer to self to the ListeningSocketCallback function whenever there is a new connection. All this callback function does is to call the acceptConnection method back in self, we’ll get to that shortly.
Finally we publish the service via Bonjour using the sin_port that the system found for us. Just like the runloop source the NSNetService also needs to be scheduled on the run loop. Note that we are always using the “Common Modes” option because this does not get paused if we scroll a UIScrollView on iOS.
Now, let’s accept connections. A new connection will be identified by the CFSocketNativeHandle. For this handle we can create a pair of read and write streams. The read stream receives data, the write stream sends data to the connected client.
- (void)_acceptConnection:(CFSocketNativeHandle)nativeSocketHandle { DTBonjourDataConnection *newConnection = [[DTBonjourDataConnection alloc] initWithNativeSocketHandle:nativeSocketHandle]; newConnection.delegate = self; [newConnection open]; [_connections addObject:newConnection]; } |
You can see that I have create a DTBonjourDataConnection class that owns the input and output streams and also the code to deal with the various kinds of communication events. As all my open source, this will be available in my DTFoundation project on GitHub.
The essential init function for the file handle looks like this:
- (id)initWithNativeSocketHandle:(CFSocketNativeHandle)nativeSocketHandle { self = [super init]; if (self) { CFReadStreamRef readStream = NULL; CFWriteStreamRef writeStream = NULL; CFStreamCreatePairWithSocket(kCFAllocatorDefault, nativeSocketHandle, &readStream, &writeStream); if (readStream && writeStream) { CFReadStreamSetProperty(readStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanTrue); CFWriteStreamSetProperty(writeStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanTrue); _inputStream = (__bridge_transfer NSInputStream *)readStream; _outputStream = (__bridge_transfer NSOutputStream *)writeStream; _inputBuffer = [[NSMutableData alloc] init]; _outputBuffer = [[NSMutableData alloc] init]; } else { close(nativeSocketHandle); return nil; } } return self; } |
Not much to it, CFStreamCreatePairWithSocket creates the pair of streams which due to toll-free bridging between CFStream and NSStream will be saved in two instance variables. CFReadStreamRef is the same as NSInputStream, CFWriteStreamRef also goes as NSOutputStream. The input and output buffers are simply NSMutableData instances so that we can append new data to them.
Opening and closing of the connection is done like so:
- (BOOL)open { [_inputStream setDelegate:self]; [_outputStream setDelegate:self]; [_inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [_outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [_inputStream open]; [_outputStream open]; return YES; } - (void)close { [_inputStream setDelegate:nil]; [_outputStream setDelegate:nil]; [_inputStream close]; [_outputStream close]; [_inputStream removeFromRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [_outputStream removeFromRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [_delegate connectionDidClose:self]; } |
You can see that we set self (the connection instance) as the delegate for the streams. This means that we get callbacks for state changes of both streams via stream:handleEvent: where event can be any of these states:
- NSStreamEventNone
- NSStreamEventOpenCompleted – the connection was opened
- NSStreamEventHasBytesAvailable – received data is available
- NSStreamEventHasSpaceAvailable – the stream will accept writing to
- NSStreamEventErrorOccurred – an error happened
- NSStreamEventEndEncountered – the connection was closed
Essentially all the code for these events calls read or write on the streams in the matching events, if there is an error or the end of the stream then the connection is closed.
#pragma mark - NSStream Delegate - (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)streamEvent { switch(streamEvent) { case NSStreamEventOpenCompleted: { break; } case NSStreamEventHasBytesAvailable: { uint8_t buffer[2048]; NSInteger actuallyRead = [_inputStream read:(uint8_t *)buffer maxLength:sizeof(buffer)]; if (actuallyRead > 0) { [_inputBuffer appendBytes:buffer length:actuallyRead]; [self _startDecoding]; // empty buffer } else { // A non-positive value from -read:maxLength: indicates either end of file (0) or // an error (-1). In either case we just wait for the corresponding stream event // to come through. } break; } case NSStreamEventErrorOccurred: { NSLog(@"Error occurred: %@", [aStream.streamError localizedDescription]); } case NSStreamEventEndEncountered: { [self close]; break; } case NSStreamEventHasSpaceAvailable: { if ([_outputBuffer length] != 0) { [self _startOutput]; } break; } default: { // do nothing } break; } } |
DTBonjourDataConnection also has code to encode and decode discrete messages with a header. Since binary data will have no recognizable end, I am constructing headers that include the length of the payload so that the recipient is able to know exactly where a data packet (= one object) ends.
This is the part where you might already have a defined protocol that you want to implement. My class is able to send every object that implements the NSCoding protocol. Alternatively you can switch to JSON but that restricts the messages to JSON-compatible objects. For example you cannot send a simple NSString as JSON, because there you have to have an NSDictionary or NSArray as root object.
Client
The things you need to do for the client is orders of magnitudes simpler. You use NSNetServiceBrowser to find services of your type that you can connect to. Once the user selected one, NSNetService resolves the address and establishes the connection for us, with a single line of code we get the input and output streams. This is why I also created the DTBonjourDataConnection to be used on the client-side.
I modeled the preview device selection screen after the one to be found in iBooks Author. The NSTableView is data-bound to an NSArrayController and this in turn is bound to a foundServices array property.
- (void)windowDidLoad { [super windowDidLoad]; serviceBrowser = [[NSNetServiceBrowser alloc] init]; serviceBrowser.delegate = self; [serviceBrowser searchForServicesOfType:@"_icatalogpreview._tcp." inDomain:@""]; } #pragma mark - NetServiceBrowser Delegate - (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser didFindService:(NSNetService *)aNetService moreComing:(BOOL)moreComing { [self willChangeValueForKey:@"foundServices"]; [_foundServices addObject:aNetService]; [self didChangeValueForKey:@"foundServices"]; NSLog(@"found: %@", aNetService); } - (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser didRemoveService:(NSNetService *)aNetService moreComing:(BOOL)moreComing { [self willChangeValueForKey:@"foundServices"]; [_foundServices removeObject:aNetService]; [self didChangeValueForKey:@"foundServices"]; NSLog(@"removed: %@", aNetService); } |
Using an array controller allows us to bund the Enabled property of the Preview NSButton to the canRemove property of the array controller. This way the button is only enabled if a service is selected. Nice trick on Mac.
On iOS you would want to restrict the table view update to when the moreComing flag is NO. Bonjour is incredibly efficient and fast in discovering available services on your network.
Now once the user has selected an NSNetService to connect do, all you need to do is to create another DTBonjourDataConnection via the initializer that takes the service as parameter.
- (id)initWithService:(NSNetService *)service { self = [super init]; if (self) { [service getInputStream:&_inputStream outputStream:&_outputStream]; _inputBuffer = [[NSMutableData alloc] init]; _outputBuffer = [[NSMutableData alloc] init]; } return self; } |
I found this approach to working fine with OS X 10.8, but apparently if you are supporting earlier OS X versions and are using ARC then there were 3 bugs in getInputStream:outputStream: that prompted Apple to provide a work-around in Technical Q&A QA1546. This was even mentioned in the above referenced WWDC talk, so I am not sure under which circumstances you have to use the workaround.
The same connection class can be used on both OS X and iOS. So far I’ve been using the server part in an iOS app and the connection class in both the iOS and the Mac-based Editor app.
Conclusion
The client-side portion necessary of establishing a connection via Bonjour is straightforward and functional at the Objective-C level. The server-side not so much, there you have to creep around on the Posix or CoreFoundation level. Which makes us wonder why Apple does not want to make creating apps that vend certain services TOO easy.
The wrappers that we have created simplify the process tremendously. With the networking foundation and methods in place for encoding and decoding objects we can move to a higher level and actually think about a protocol that we want to be the mode of communication between our Editor and the preview app.
Categories: Recipes