Ad

Our DNA is written in Swift
Jump

… and Bonjour to you, too!

In the blog post before this one I began my investigation into TCP connectivity and Bonjour. I set out to create DTBonjour as part of my DTFoundation set of tools to make communicating between Macs and iOS devices extremely easy.

Then I spent a couple of hours on putting together a proof-of-concept app that would show me what’s still missing on the API. Having some classes disconnected from real life use is quite a different ballgame than actually showing it in action.

I asked on Twitter for some suggestions what app to make to show this off, but all where way more involved than the example that I finally decided on: a simple Chat app.

The code for this working sample can be found as part of my Examples GitHub repo.

The first order of business was of course to include DTFoundation as a submodule, set up the user header search path, add the usual Other Linker Flags (-ObjC and -all_load) and to add the xcodeproj (nothing else) to the BonjourChat demo app project.

Next I set up the demo up with a very simple storyboard. You have a root view that shows in two sections available chat rooms and with a Plus Button a screen is modally presented that allows for specifying the name of a new chat room.

DTBonjour consists of two classes: DTBonjourServer and DTBonjourDataConnection.

The difference being that a server is set up on a system-assigned random port and awaits clients that establish connections to it. There is virtually no limit to how many connections a server can maintain. Those connections itself are DTBonjourDataConnection instances which are owned by the server.

Clients use a single DTBonjourDataConnection to connect to a DTBonjourServer. Inspect the demo project to see that BonjourChatClient is a simple subclass of the connection class, it only needs to add a room name. The BonjourChatServer is a subclass of DTBonjourServer and adds a room name, identifier as well as a convenience method to construct the TXTRecord dictionary.

Bonjour services are able to communicate some information about themselves in those TXTRecords. In the demo I am using it to broadcast the server ID and room name, it would also be conceivable to communicate the number of connected chat users and what not. TXTRecords are dictionaries where keys must be NSStrings and the values must be NSDatas. Also you need to be aware that there is a slight delay until the TXTRecord of a service is available.

NSNetService delegates can enable monitoring to be informed when the TXTRecord becomes available or changes. This way you could for example show a constantly updated number of chat users on the chat room overview screen. In the demo I am using the TXTRecord to know which of the found service are actually chat rooms that where created on the device locally. I want to show local servers in a different section of the room overview than remote servers.

Theoretically you could also have a flat peer-to-peer topology but this would still involve opening the socket and publishing the Bonjour Service on one device and then discovering, resolving and connecting on another. Also most use cases would have one app provide a service that other clients should connect to. For example a remote control service would have an app connect as client. Or a Mac app that should sync with iOS clients would take on the role as server.

For my own concrete use case I have a Mac-based editor app as Client that is looking for an instance of my Preview app that offers previewing of documents as a service. It might be somewhat counter-intuitive to have the server on the iOS side because when such an app  gets put into standby then the data connection is interrupted because the service has to be shut down. But this allows for the same user experience as in iBooks Author where you can pick from available devices in a table view. Devices appear as they become available and disappear if iBooks is exited.

The Demo’s ChatRoomTableViewController creates a NSNetServiceBrowser which keeps looking for instances of the chat service. As soon as a new service is reported I am adding it to _unidentifiedServices until I get the delegate method telling me about its TXTRecord.

- (BOOL)_isLocalServiceIdentifier:(NSString *)identifier
{
	for (BonjourChatServer *server in _createdRooms)
	{
		if ([server.identifier isEqualToString:identifier])
		{
			return YES;
		}
	}
 
	return NO;
}
 
- (void)_updateFoundServices
{
	BOOL didUpdate = NO;
 
	for (NSNetService *service in [_unidentifiedServices copy])
	{
		NSDictionary *dict = [NSNetService dictionaryFromTXTRecordData:service.TXTRecordData];
 
		if (!dict)
		{
			continue;
		}
 
		NSString *identifier = [[NSString alloc] initWithData:dict[@"ID"] encoding:NSUTF8StringEncoding];
 
		if (![self _isLocalServiceIdentifier:identifier])
		{
			[_foundServices addObject:service];
			didUpdate = YES;
		}
 
		[_unidentifiedServices removeObject:service];
	}
 
	if (didUpdate)
	{
		[self.tableView reloadData];
	}
}
 
#pragma mark - NetServiceBrowser Delegate
- (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser
			  didFindService:(NSNetService *)aNetService moreComing:(BOOL)moreComing
{
	aNetService.delegate = self;
	[aNetService startMonitoring];
 
	[_unidentifiedServices addObject:aNetService];
 
	NSLog(@"found: %@", aNetService);
 
	if (!moreComing)
	{
		[self _updateFoundServices];
	}
}
 
- (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser
			didRemoveService:(NSNetService *)aNetService moreComing:(BOOL)moreComing
{
	[_foundServices removeObject:aNetService];
	[_unidentifiedServices removeObject:aNetService];
 
	NSLog(@"removed: %@", aNetService);
 
	if (!moreComing)
	{
		[self.tableView reloadData];
	}
}
 
#pragma mark - NSNetService Delegate
- (void)netService:(NSNetService *)sender didUpdateTXTRecordData:(NSData *)data
{
	[self _updateFoundServices];
 
	[sender stopMonitoring];
}

The reason for all this code is that NSNetServiceBrowser will also return a locally created service. The TXTRecord contains a UUID which identifies the server and allows for knowing which servers are local and which have been created by remote apps.

Remote servers get added to the _foundServices array, local servers get ignored because they already got added as BonjourChatServer instances to the _createdRooms array.

If you tap on one of the rows the appropriate object is set as the chatRoom property of the following ChatTableViewController.

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
	if ([[segue identifier] isEqualToString:@"ChatRoom"])
	{
		ChatTableViewController *destination = (ChatTableViewController *)[segue destinationViewController];
 
		NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow];
 
		if (indexPath.section==0)
		{
			// own server
			destination.chatRoom = _createdRooms[indexPath.row];
		}
		else
		{
			// other person's server
			destination.chatRoom = _foundServices[indexPath.row];
		}
	}
}

The Chat view controller determines the mode to use for communication based on the chatRoom.

@implementation ChatTableViewController
{
	BonjourChatServer *_server;
	BonjourChatClient *_client;
 
	NSMutableArray *_messages;
}
 
- (void)viewWillAppear:(BOOL)animated
{
	if ([self.chatRoom isKindOfClass:[BonjourChatServer class]])
	{
		_server = self.chatRoom;
		_server.delegate = self;
		self.navigationItem.title = _server.roomName;
	}
	else if ([self.chatRoom isKindOfClass:[NSNetService class]])
	{
		NSNetService *service = self.chatRoom;
 
		_client = [[BonjourChatClient alloc] initWithService:service];
		_client.delegate = self;
		[_client open];
 
		self.navigationItem.title = _client.roomName;
	}
}

If the chat room is a server then we set the _server IVAR, if it is instead an NSNetService – returned by NSNetServiceBrowser – then we create a client connection instead. Remember that the BonjourChatClient is a subclass of DTBonjourDataConnection i.e. a single pair of input and output streams. Calling open resolves the Bonjour address and establishes the two-way connection.

For the sake of simplicity I opted to have a table view representing the chat. Section 0 has a prototype cell with a text field. Section 1 has the messages with a different prototype cell. New messages are always inserted at the top for a reverse chronological order as we are used to from Twitter. Also because the keyboard covers the bottom half of the screen we would have to worry about hiding the text field.

Whenever the user hits Return (labelled “Send”) the delegate method is called:

#pragma mark - UITextField Delegate
 
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
	if (_server)
	{
		[_server broadcastObject:textField.text];
	}
	else if (_client)
	{
		NSError *error;
		if (![_client sendObject:textField.text error:&error])
		{
			UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message:[error localizedDescription] delegate:nil cancelButtonTitle:@"Ok" otherButtonTitles:nil];
			[alert show];
			return NO;
		}
	}
 
	[_messages insertObject:textField.text atIndex:0];
	[self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:1]] withRowAnimation:UITableViewRowAnimationTop];
 
	textField.text = nil;
 
	return YES;
}

In both cases the DTBonjourDataConnection’s sendObject:error: method is used, for the client the single connection, for the server, for all connections. This method serializes the passed object with the currently set content encoding type and magically deserializes it at the receivers end. The default encoding is to use NSCoding, which means that you can send any object that implements the NSCoding protocol. Alternatively you can switch to JSON encoding should you need to communicate with a different platform, but JSON is severely limited as to which kinds of objects you can send.

The awesome thing about using NSCoding is that with this you can send an entire NSFileWrapper containing a folder with multiple sub-folders and files over the DTBonjourDataConnection.

Messages arriving from other devices will arrive via the corresponding delegate protocols.

#pragma mark - DTBonjourServer Delegate (Server)
 
- (void)bonjourServer:(DTBonjourServer *)server didReceiveObject:(id)object onConnection:(DTBonjourDataConnection *)connection
{
	[_messages insertObject:object atIndex:0];
	[self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:1]] withRowAnimation:UITableViewRowAnimationTop];
}
 
#pragma mark - DTBonjourConnection Delegate (Client)
 
- (void)connection:(DTBonjourDataConnection *)connection didReceiveObject:(id)object
{
	[_messages insertObject:object atIndex:0];
	[self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:1]] withRowAnimation:UITableViewRowAnimationTop];
}

These two delegate methods take care of messages travelling from client to server and vice versa. Whenever the server receives a message it has to forward it to the other connected clients. This is taken care of in BonjourChatServer which – I keep mentioning this because it is important – owns its connections and therefore is delegate to it.

- (void)connection:(DTBonjourDataConnection *)connection didReceiveObject:(id)object
{
	// need to call super because this forwards the object to the server delegate
	[super connection:connection didReceiveObject:object];
 
	// we need to pass the object to all other connections so that they also see the messages
	for (DTBonjourDataConnection *oneConnection in self.connections)
	{
		if (oneConnection!=connection)
		{
			[oneConnection sendObject:object error:NULL];
		}
	}
}

As another sleight of hand I added the connectionDidClose: delegate method which gets called on clients if the server is stopped, e.g. if the app that owns the room goes into background. In this case I’m showing an alert and then pop the controller off the navigation stack.

Conclusion

Long story short, DTBonjour makes it extremely simple to communicate between Mac and iOS devices. On the server side all you need is to create a DTBonjourServer. Clients use NSNetServiceBrowser to select between available servers and then create a DTBonjourDataConnection with a specific NSNetService that the user picked.

I will keep improving on DTBonjour as I am going to add it to a few of my own apps. I also welcome your contributions or suggestions on how to make it better.

Finally, if you have a good use for it then please don’t hesitate to let me know. I am already getting sweaty palms in anticipation of the awesome usage scenarios that people will come up for this.


Categories: Recipes

5 Comments »

  1. I’m experiencing a strange issue, I tweaked a little example code in order to allow the Host to create only a Room, but doing this it seems that the clients are able to connect to the Host but the information are not right.

    Eg. I host a session called First, clients are able to see that one and connect to it.
    I kill all the apps, host a new room called Second, clients are able to see that there is a Bounjour session available but its name is First and not Second. I’m also able to connect to them.
    I kill all the apps again, create a new host called Third, clients are able to see an Host but its name is Second.

    I started thinking that the issue could be related to an unreleased var or vars broadcasted before they can be updated… but I was force killing the app so all the vars has to be new as mint.

    Any idea?

  2. Isn’t the name of the room in the bonjour meta dictionary? it could be that due to your killing the bonjour service does not shut down properly and other clients get the same meta info in the beginning. You have to inspect the meta dictionaries and see what room name arrives there. Also the demo probably doesn’t have a refresh mechanisms for names yet.

    You need to enable the watcher for each NSNetService and update the room names based on the messages.

  3. I just found the possible issue, debugging I placed a breakpoint inside the func:

    – (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser
    didFindService:(NSNetService *)aNetService moreComing:(BOOL)moreComing

    And then I noticed that with a sleep generated via breakpoint was fixing this.

    So I tried adding a usleep on top of the func and it started working fine.