Welcome to Session 709, "Cross Platform Nearby Networking".
My name is Demijan, and I'm a Software Engineer in the Real-time Networking Team at Apple.
Last year we introduced a new framework called Multipeer Connectivity in iOS, which makes it really, really easy to discover and communicate with nearby devices.
Building a network with nearby devices can be accomplished with only a few lines of code.
Many, many apps have decided to adopt Multipeer Connectivity for their nearby networking needs.
And some of the use cases we've seen have really made us smile, so I'd like to mention some of them to you today.
First, iTranslate Voice: iTranslate Voice is an app that brings real-time translation to iOS users.
They use Multipeer Connectivity to connect multiple devices together and enable people who don't share a common language to communicate with each other.
One person speaks a sentence into their device in their language, and the other person hears the translation of that sentence on the other device.
It's really, really cool.
Second example is an app called Metronome Touch.
Metronome Touch synchronizes multiple metronomes, and a metronome is a tool that musicians use to play to the same beat or follow the same tempo.
Now Metronome Touch uses Multipeer Connectivity to accurately synchronize multiple iOS devices so that the metronome on each device ticks in perfect sync.
And, third, FireChat, FireChat brings nearby chatting to our customers.
People who are nearby can now communicate with each other even when there is no internet connection available.
This type of application can be particularly useful in environments, like subway stations or airplanes, for example, but also in countries with limited internet access.
We've seen many other use cases for Multipeer Connectivity and some of the prevalent ones have been to exchange data, such as files, and to to-do lists for example, and remote control functionality.
Now throughout the year we've heard a lot of good feedback from you guys and we've heard a lot of good ideas, but one request that has come up over and over again has been to bring Multipeer Connectivity to the Mac.
So this year I'm really happy to announce that we're bringing Multipeer Connectivity to OS X starting with Yosemite.
From now on you will be able to do cross-platform nearby networking between iOS and OS X devices just as easily as you have been so far on iOS.
And the API is exactly the same, so you should be ready in no time.
All right, so let's talk about the agenda for today.
First, I want to talk about some basics so we set the stage for the rest of the talk.
Then I will talk about Multipeer Connectivity on OS X, where I'll focus on some of the specifics that are true for development on OS X and for the OS X experience.
Next, I will talk about a few best practices.
And, finally, I'd like to cover a few more advanced topics, namely custom discovery and authentication.
So let's start with the basics.
Multipeer Connectivity supports three wireless technologies on iOS-Bluetooth, Infrastructure Wi-Fi, and Peer-to-Peer Wi-Fi.
On OS X we will support Ethernet, Infrastructure Wi-Fi, and Peer-to-Peer Wi-Fi, as well.
So I'd like to talk about Peer-to-Peer Wi-Fi for a moment.
Peer-to-Peer Wi-Fi enables you to communicate with other nearby devices even if they're not connected to the same access point, or if they're not connected to an access point at all.
So, many of you have wondered which devices support Peer-to-Peer networking.
So I'd like to talk about that a bit.
Well, on iOS it's pretty simple.
If your iOS device has the new Lightning Connector then it supports Peer-to-Peer Wi-Fi.
If it doesn't, it won't support Peer-to-Peer Wi-Fi, but on those devices you can still use Bluetooth and Infrastructure Wi-Fi.
For Macs, the story is also pretty simple.
If you have a Mac that was released in 2012 or later then it will have support for Peer-to-Peer Wi-Fi.
Okay, so let's establish some terminology that we will use throughout the rest of the talk.
First, nearby, by nearby I will mean anything that is within the range of supported wireless technologies.
A peer, a peer will be a device, either our own or a nearby device.
An advertiser will be a device that makes itself discoverable to other devices nearby.
And the browser will be a device that is searching or discovering other nearby devices.
Multipeer Connectivity happens in two phases.
First, the discovery phase, where the devices discover each other and establish a communication session by sending invitations to each other.
Then when they're connected into a session, the second phase, called the session phase, begins, where they can exchange data with each other.
So let's start with the discovery phase.
The first approach to discovery phase that we support is UI-based and it's the most simple one.
We have a browser and an advertiser.
An advertiser has to instantiate a peer ID object, a session object, and an advertiser assistant object.
It then starts by calling the start method.
The browser similarly instantiates a peer ID, a session, and the browser view controller.
It then presents the browser view controller to the user to start browsing.
The rest of the process will be entirely user-driven, and you will be notified when the peers connect into a session with the session delegate method, peer:didChangeState, where the state will be specified as connected.
So that was the UI-based approach.
The programmatic approach requires you to do a little bit more work, but it gives you much more flexibility.
So, again, we have a browser and an advertiser.
The advertiser instantiates a nearby service object instead of an advertiser assistant object.
And the browser instantiates a nearby service browser object instead of the browser view controller.
The browser starts by calling startBrowsingForPeers method, and the advertiser starts by calling startAdvertisingPeer.
So now both of them are-the browser is browsing, and the advertiser is advertising.
When the browser discovers the advertiser you will be notified with a delegate method, foundPeer.
At that time the browser can send an invitation to the advertiser by calling the invitePeer method.
When the browser calls the invitePeer method an invitation will be sent out to the advertiser, and when the advertiser receives the invitation you will be notified with the did receive invitation from peer method.
At that time, the advertiser has to decide whether it wants to accept or reject the invitation.
And let's say it accepts the invitation, then a message will be sent back to the browser, and they will start connecting into a session.
When they connect with each other, again, you will be notified with the delegate method, peer:DidChangeState, where the state will be specified as connected.
So this was the discovery phase.
Let's now cover the session phase.
So in the session phase we assume that the nearby peers are already connected with each other, and now what they want to do is they want to exchange data with each other.
Well, Multipeer Connectivity supports three sets of APIs for exchanging that data-messages, streaming, and resources.
Let's start with messages.
A message is a chunk of data with well-defined boundaries.
If you want to send a message you can use the sendData method, where you will pass the message as the first parameter, encapsulated in an NSData object.
You will also have to specify an array of peers that you want to receive the message.
When you receive a message you will be notified with a delegate method, didReceiveData, which will pass to you the message and the sender.
Now if you want to send really large amounts of data or data without well-defined boundaries, such as a live audio stream, for example, then you might be better served using our streaming APIs.
And to start a stream you can call the method, startStreamWithName, which will give you an NSOutputStream object that you can use to stream data to the recipient.
The recipient will be notified where the delegate method didReceiveStream, and that method will give it an NSInputStream object that the recipient can use to receive streaming data.
And, third, resources, we support files and web URLs to send as resources, and you can send a resource by using sendResourceAtURL method, where you specify that the URL method of the resource you want to send and you specify which peer you want to send it to.
You will also have to pass to the framework a completion handler, and that completion handler will be called when the resource has finished transmitting or if something went wrong during the transmission.
Now the receiver, when it starts receiving a resource, will be notified with a delegate method, didStartReceiving ResourceWithName, and when the resource finishes being received it will be notified with a delegate method, didFinishResourceWithName.
Okay, so in summary, we've covered the discovery phase and the session phase.
You can do UI-based discovery or programmatic discovery, which gives you a bit more flexibility, and in the session phase you can use APIs to send data where we support messages, streaming and resources.
Much more in-depth information about these topics can be seen at our last year's WWDC presentation, which you can see online.
All right, let's proceed with Multipeer Connectivity on OS X.
The good news is that the APIs on OS X are exactly the same as APIs on iOS, nevertheless, there are some differences that are different to the OS X experience and I would like to talk about those now.
Let's start with UI-based discovery.
Imagine I have an app.
I have a Mac, which is running an app that uses Multipeer Connectivity.
I want to see if somebody is around, so I bring up the browser view controller.
The browser's view controller is presented as a modal sheet, and in the lower left corner you can see an activity indicator, which indicates to us that we're browsing.
Currently there is no one nearby.
Let's then assume that Gabe comes nearby and Gabe is also running an app that uses Multipeer Connectivity.
Moments later, we'll see in our UI that Gabe is nearby, and if I want to invite Gabe into a session I have to press the Invite button in the table view.
So I go ahead and do that.
When I do that, an invitation will be sent out to Gabe.
And when Gabe receives the invitation, we will present, the framework will present an alert that will notify Gabe that I want to connect to him.
At this time Gabe needs to decide whether he wants to accept or decline the invitation.
So let's assume Gabe is game and wants to accept, and accepts the invitation, so he clicks on Accept.
And an accept message is sent back to me.
At that moment we will start connecting, and when we're finished connecting it will say so in the UI next to Gabe, and I will be able to click on the Done button, which will dismiss the browser view controller, and I am connected to Gabe and start exchanging data with him.
So this was the flow for UI discovery on Mac OS X.
Let's now see how you can implement this in code.
Now, first, you have to instantiate an advertiser, and this is done much the same, like on iOS.
First, you instantiate the advertiser assistant object and you start it.
Now for the MCBrowserViewController on OS X, subclass is NSViewController, unlike UIViewController in iOS.
Note that NSViewController has seen substantial changes in Yosemite, and you can see or you can hear much more about those changes in the session, "Storyboards and View Controllers".
So to set up the browser view controller on OS X, I have to instantiate it, and I have to set the delegate.
Then I have to-then I present the browser view controller by using one of the new NSViewController APIs, presentViewControllerAsSheet, and I pass the browser view controller object.
Note that "self" here is a subclass of an NSViewController.
We realize that you might be, that your architecture might not be based on NSViewControllers, and in that case you might want to present the view controller using the NSAppBeginSheet method.
Well, if you want to do that you can do that, but first you will have to get an NSWindow object for the browser view controller.
And you can do so by using one of the new methods on NS window called windowWithContentViewController.
That method will give you back an NS window for the browser view controller, and once you have that NSWindow object you can use the beginSheet API to present the browser view controller to the user.
When the user is done using the browser, it will click on either Done or Cancel button, and when that happens you will be notified via the delegate methods, browserViewControllerDidFinish and browserView ControllerWasCancelled, respectively.
In those methods you'll have the opportunity to react to whatever action the user has taken and you will have to dismiss the browser view controller by using the dismissViewController method.
Next, I want to talk about entitlements.
If you are sandboxed, either voluntarily or because you ship on the app store you will have to set entitlements appropriately.
Multipeer Connectivity you need support for both incoming and outgoing connections, so you'll have to enable entitlements for these operations.
If you don't do that, then Multipeer Connectivity on OS X just won't work, so make sure you do that.
And that's really everything that is different.
Everything else, like programmatic discovery and sending data, for instance, is exactly the same as on iOS, so you should be ready in no time.
Okay, at this point I would like to invite Eric on stage, who will show you a demo.
Thanks, good morning, everyone.
My name is Eric, and today I'd like to show you a quick demo of Multipeer Connectivity, so let's switch over.
Great, so let's say we're at a party and everyone is taking photos.
It would be really cool if we could collect all those photos onto a map and display them on a really large screen for everyone to see.
To simulate this sort of application, today we have two iOS devices.
Here I have a white iPhone and a pink iPhone, and we also have a Mac, and we'll be using Multipeer Connectivity to connect them together.
And whenever the iOS devices take photos they'll transmit them over to the Mac.
So let's take a look at this in more detail.
Over here we have the Mac app, on the left-hand side we'll have a photo roll, where the new photos will pop in, and over here on the right-hand side we'll have a larger view of the latest photo that we got.
Down here in the corner we have a little Browse button.
So let's get started.
I'll go ahead and click on the Browse button.
So here's the browser that we saw earlier.
Right now it's currently empty.
When I launch the iPhone app it will start advertising, and the browser will be able to discover it, and it'll pop up in the list.
So I'll go ahead and do that here, so there it is.
And I can go ahead and do the same on the other iPhone.
Great, so now we have both of the devices.
We can go ahead and invite the white iPhone.
Over here I received the invitation, so I'll go ahead and tap Accept, and we can see that it connected.
I'll do the same for the pink iPhone, so invite it, and over here I'll accept.
Great, so now both of the devices are connected, we can go ahead and click on the Done button to dismiss the browser, and we can start taking some photos.
So I'll grab the white iPhone, and let's see if we can get a shot of this camera right here.
Great, so it's sort of an antique camera, I guess.
Let's see that with the phone, we can compare them.
So you can see how far we've come with the cameras.
Here's let me switch over to the pink iPhone phone, and let me take a picture of this little rabbit thing.
Great, let's see, maybe I can take one of myself, and then, let's see, we'll switch over.
Here's a little thing of bismuth.
Great, okay, so let's see if we can take a quick look at what the iOS side looks like.
Okay, great, now that was a quick demonstration of cross-platform nearby networking with Multipeer.
So let's talk a little bit about how I built the demo.
On the iOS side, I took a piece of sample code called AB Cam that teaches you how to use the camera.
Whenever we save a new still image, all we do is we take the URL for the new file and we use the sendResource API we just saw, and that sends it over to the Mac side.
Over here on the Mac side I took a piece of sample code called Image Browser and it just teaches you how to display a grid of images, like this.
I added in Multipeer Connectivity to bring up the browser, and whenever we receive a new resource we just add that into the image list's data source array.
So if you'd like to learn more about how to use Multipeer Connectivity in your own apps we hope you will check out last year's iOS sample code, it's called Multipeer Group Chat.
And we're really looking forward to seeing what sort of new apps you guys can come up with.
With that, I'd like to hand it back to Demijan.
Thank you, Eric.
So Multipeer Connectivity on OS X is much like Multipeer Connectivity on iOS.
In this section we've shown you the UI-based discovery on OS X, and we've told you which entitlements you need to enable to make Multipeer Connectivity work in OS X apps.
Next I'd like to talk about a few best practices that we thought you guys should be aware of.
So let's assume we have two devices, a Mac and a phone, and let's say the Mac is advertising and the iPhone is browsing.
Moments later the iPhone will discover the Mac and it will have a reference or it will have its peerID object.
Then let's assume that for some reason the Mac goes away.
For instance, the user could have closed the lid for some reason or the user could have reset the system because of a software update.
When the Mac comes back it will instantiate a new peerID object and a new advertiser, and the iPhone will discover it, but it will see a new object for the peer, which actually corresponds to the same Mac.
So this can lead to many issues because iPhone doesn't know that these two objects actually correspond to the same device.
So in order to circumvent that problem, we recommend that you reuse peerID objects.
After you've created a peerID object for the first time you can store it in the user defaults, so the next time you need it you don't have to create a new one.
So if the Mac, when it comes back, reuses the first peerID object, the iPhone won't be confused and it will only have one object that represents the Mac.
Now let's see how you guys can do this in code.
Once you've instantiated the peerID object, you'll need to store it in the user defaults.
And to do so, you'll first have to serialize the peerID object using the archivedDataWithRootObject method.
Once the peer is serialized into an NSData object you can save it in the user defaults.
Later when you need to de-serialize it and retrieve it from the defaults you'll first have to de-serialize it using unarchiveObjectWithData method, and then when you have the original peerID object you can use it in your application.
Next auto inviting, many of you have made applications that both advertise and browse at the same time, and when a browser sees an advertiser it immediately sends an invitation.
Basically, what you want to achieve with this is, if you want to abstract away the connection process from the user.
As soon as another user is seen you want the devices to connect to each other.
So let's assume we have a Mac and a phone, and both are browsing and advertising.
So soon they will discover each other, and they will have a peerID object of the other peer.
But now the question is who will be the one to send an invitation?
And here's where many of you get confused.
So in order to solve this problem you can use a deterministic algorithm that will on both sides come to the same result so that only one peer will be the one to send an invitation.
Now there are many ways you can do that, and one of them is to use peer ID hash values or hash value of the peer ID object.
Since both sides have access to the same two peer ID objects, they will come to the same determination as to who has the higher hash value, and only one invitation will be sent out.
Next I want to talk about discovery info a little bit.
Multipeer Connectivity uses Bonjour underneath for discovery, and you have the option to set additional data for advertisers when you instantiate them.
Now this additional data is passed to the framework in the form of an NSDictionary that we call discoveryInfo, and discoveryInfo is very useful because it is made available to the browsers when they discover an advertiser.
So first thing I want to advise is to keep discoveryInfo small, this will make the discovery experience much better for your users.
Next both keys and values in discoveryInfo must be of type NSString.
If any key or any value in the discoveryInfo dictionary is not of type NSString, the framework will complain and throw an exception.
Also, you should know that each key value pair in the discoveryInfo dictionary underneath gets formatted in a Bonjour text record entry, and each Bonjour text record entry has a specific format, which is shown in this slide.
First the key, followed by an equal sign, and then the value.
Note that each text record entry is limited to 256 bytes, and if any key-value pair when formatted as a text record exceeds 256 bytes, the framework will complain again and throw an exception.
For more details on Discovery Info and Bonjour text records I'd like to refer you to the Bonjour RFC, which can be obtained at the link quoted at the bottom of this slide.
Okay, so now we're ready to tackle on some advanced topics, and I'd like to start with custom discovery.
We've covered two approaches to discovery so far.
The first one was UI-based, and it's really simple.
All you have to do is instantiate an advertiser, instantiate a browser, and everything else is entirely user-driven.
Now this approach is very simple to implement, but the framework gives you the UI, so you don't have much flexibility there.
If you want to design your own framework, you can use the programmatic approach.
Now the programmatic approach requires you to do a little bit more work, but you have much more flexibility in terms of how you define the user experience.
So even given that we have these two approaches, we realize that there may be some of you who have the need to have or to define the discovery experience even more or to customize it even more.
And for those, we offer a third way, which we call the custom approach.
Now the custom approach might be useful for those users that operate in environments that is not Bonjour friendly, for instance, or you may have the need to exchange large amounts of data during the discovery phase and that data cannot fit in the discoveryInfo dictionary.
So if you fit into one of those categories you might find custom discovery useful.
So let's go over custom discovery in this section.
First, let me say that for custom discovery you are in full control of the discovery process, so we will assume that you will implement a mechanism to discover who is nearby and you will also establish a one-to-one data link between nearby peers.
So this will be your job.
Now the goal for nearby peers will be to connect into a Multipeer session where they can exchange data with other peers on a many-to-many basis.
So let's see how they can do that.
Assuming that we've discovered nearby peers and we've established a one-to-one data link between them, let's see what you have to do in order to connect them into a session.
First, each peer will need to instantiate a peerID object and a session object, and then it will have to-they will both have to complete a two-step process.
First, they will have to exchange their peerID objects over the one-to-one data link.
So, in order to do that, they will first have to serialize their peerID object and pass them over to the other peer.
Once the serialized ID object is available they'll have to de-serialize it, and at that moment they'll have the peerID object of the other peer.
Once the peerID object is available they'll have to generate nearby connection data by calling nearbyConnectionDataForPeer method.
When nearby connection data becomes available they will have to exchange that object much like before with the other peer, so this is the second step of the process, and when both of these objects are available on the other side you can connect them into a session using connectPeer:withConnectionData method.
When they are done connecting, much like before, you will be notified with a delegate method, peer:didChangeState, and the state will be specified as connected.
Okay, so let's see how we can do this in code.
Now serializing and de-serializing can be accomplished much like we've described before in the best practices section.
You can use, for serializing you can use NSKeyedArchiver APIs, and for de-serializing you can use NSKeyedUnarchiver APIs.
So now that we've exchanged the peerID objects we have to generate connection data, and to generate nearby connection data you can use nearbyConnectionData withCompletionHandler method.
The framework, when it's done, will call the completion handler, where it will pass to you the object that contains nearby connection data.
Then you will have to send that object over to the other side and when all peers have both objects for the peer they want to connect with they can do so by calling connectPeer withNearbyConnectionData.
In this method you also have the opportunity to specify a timeout in seconds, and this timeout will let the framework know how long you're willing to wait until the peers successfully connect in a session.
If for some reason you change your mind during the connection process you can cancel it by calling cancelConnectPeer method.
So in summary we've described a fully customized discovery, which you can use if you can't use the UI-based or the programmatic approach for your needs.
It consists of a two-step process.
First, you need to exchange the peerID object, then you need to exchange nearby connection object, and when both of these objects are available for the other peer you can connect it into a session.
Next up is authentication.
So some apps, for instance, those that deal with money transactions think an app that enables people to split a taxi cab bill will have to rely on properly implemented security to provide safe and trustworthy experience to their users.
Multipeer Connectivity gives you the option of enabling encryption and authentication for providing security to your users.
Now enabling encryption is pretty easy, all you have to do is set the flag when you instantiate the session, but in authentication it is a little bit more involved.
So let's take a look at how you can deal with authentication in this section.
If you want to provide authentication to your users you will have to make sure that each one of your users has a digital identity, and a digital identity consists of a private key and a certificate.
The users will use the private key to sign their messages and they will make their certificate available to other users so they can verify if the signature is valid and if they can trust the sender.
In code, a digital identity is represented by a SecIdentityRef object.
The private key is represented by a SecKeyRef object.
And the certificate is represented by a SecCertificateRef object.
There are multiple ways how you can distribute digital identities to your users.
Perhaps the best way is to make identities available on a trusted web server so that the users can download them from that trusted web server from within the app.
Other ways include e-mail attachments or via a mobile device management server.
For more detailed information on this topic, I'd like to refer you to the Tech QA 1745, and the link for this is also provided at the bottom of this slide.
Now let's see how you can import a digital identity in your code.
Usually the digital identity is stored in a PKCS#12 data file, and these files are password-protected.
So the first thing you'll need to do is get the password for the file and store it in a dictionary.
Next you will have to get access to bytes in that file by using dataWithContentsOfURL method, and once bytes are available you can import the digital identity by using SecPKCS12Import method.
This method will import the digital identity from the bytes that we have from the file and store it in an array.
So now you know what a digital identity is, how to distribute it to your users, and how to import it in your apps.
Note that only certificate part of your digital identity is made available to other users, and the other users can use that certificate to verify if you are really who you claim you are, and they can make a decision if they want to trust you.
So let's look at how they can verify this.
An end-user certificate can be issued by a trusted root certificate authority or it can be issued by an untrusted intermediate certificate authority whose certificate was issued by a trusted certificate authority.
This hierarchy of certificates that starts with the end-user certificate on the left and ends with the trusted root certificate on the right is often referred to as the chain of trust.
Roughly speaking, when we evaluate the chain of trust, the following step-wise procedure happens.
First, we inspect the end user's certificate and check if it's valid.
Let's assume it is, next, we inspect the intermediate certificate and check if it's valid.
We also check if the end user certificate was, indeed, issued by the intermediate certificate authority.
Say that checks out, as well, finally, we have to inspect the root certificate, and we check if it's valid.
We also check if the intermediate certificate was, indeed, issued by the trusted certificate authority, and if that checks out, as well, then the chain of trust is valid.
So in code when you evaluate the chain of trust, the first thing you'll have to do is create a policy object.
This policy object will govern how the chain of trust is evaluated.
Then you will have to create the chain of trust, and once you have the chain of trust you will have to evaluate it.
So let's see how you can do this in code.
First, we need to create the policy, and for our purposes we can use SecPolicyCreateBasicX509 API, which will create a policy, an X509 policy because we're dealing with X509-type certificates.
Next, we have to create the trust object by using SecTrustCreateWithCertificates API, and we also have to set anchor certificates.
By anchor certificates we mean the root certificates that can be trusted by the system, so we have to let the system know which certificates can be at the end of the chain of trust.
Once that is established, we can evaluate the chain of trust by calling the SecTrustEvaluate method, and the result will be stored in the second parameter.
Now if the result equals kSecTrustUnspecified, that means that the chain of trust is valid.
So, with that, let's see how you can set up authentication in a Multipeer Connectivity session.
You'll have to use the session initializer initWithPeer securityIdentity encryption preference method.
And for the second parameter securityIdentity you'll have to pass an array, which contains your digital identity as the first parameter and a chain of certificates that validate your identity as next elements.
When somebody tries to connect to you, you will be notified with the delegate method, didReceiveCertificate, where you will be passed the chain of certificates that represent the other peer.
At that time you will have to decide whether you want to trust that peer or whether you trust that peer and whether you want to proceed connecting to it.
At this moment you can use the method for evaluating the chain of trust that we discussed a couple of slides ago.
And if you decide that the chain of trust can be trusted, is valid and can be trusted, you can let the framework know by calling the certificate handler and passing it a Boolean, so in this case a yes.
If you don't trust it you can just pass it a no and the framework will reject the connection.
In summary, we've looked at a bit more detail about how you can set up authentication in your Multipeer Connectivity apps.
We've looked at digital identities and we've described how you can make them available to your users and how you can import them in your apps.
We've also looked at chains of trust and how you can go about evaluating them.
For more information, I would like to refer you to our Evangelist, Paul Danbold, and you can check out our documentation, "Multipeer Connectivity Framework Reference" guide.
And, as Eric mentioned, we have some sample code that you guys should check out called MultipeerGroupChat.
We'll be available to answer questions and hear about your comments at devforums.apple.com, as well.
There are a few related sessions that I would like to call out.
One is "What's New in Foundation Networking", and the other one is "Storyboards and Controllers" on OS X, where you can hear more about what's new with NSViewControllers in Yosemite.
Thank you very much, everyone.
[ Applause ]