A few years ago since I started using Macs more often, one annoying thing I dealt with was using my local music library. My usual solution was to just drag files from the file manager to a music player, but this wasn’t as nice on macOS (due to i.e. SMB latency). However, I did have a Subsonic server, which provides a nice music streaming server, complete with an API for clients to use for things like phones. Why not use this on my laptop too?
Of course, if I bought a Mac, I’m not going to put up with bad cross-platform solutions that suck everywhere, when I can instead run bad native software that sucks uniquely for my platform of choice. However, there weren’t too many clients available on Mac. Mostly all of them were unmaintained and had been abandoned in the Snow Leopard era. One of them was Submariner, and it was open-source after the developer (Rafaël Warnault) had stopped working on it. Writing my own seemed a bit daunting with no background, but what if I used the Submariner codebase, and started from there?
Now I’ve been maintaining Submariner for almost two years at this point (it even has a minimal website), adding features and mostly just focusing on modernizing the codebase. It’s been an interesting experience as my first Objective-C/Mac project. A lot of the lessons of modernizing legacy code are universally applicable, but I’ve learned a lot about the specifics of Apple platforms and how they compare. This article aims to be both a retrospective on what I had to learn, what I had to do, and the lessons I took from it, including a comparison of what the development culture is like between platforms.
Sidebar: AppKit and Cocoa often tend to be used in confusingly interchangeable ways. I think Cocoa refers to not just AppKit, but other APIs as well. In the case of developing a Mac application, it doesn’t matter too much, since you have access to the whole animal.
First impressions, first changes
The state of the codebase as it originally was (see on GitHub) shown that it was slightly updated to compile against the 10.9 SDK. Of course, when I did this, I was targeting macOS 12. So, the obvious question is, what are we dealing with?
The answer is a typical Mac application stack of the time, with Objective-C, nibs, and popular frameworks of the time, both part of the OS and popular third-party ones. I did try the old binary build, and while it did run on a modern ARM Mac, which surprised me (meaning, it was an x86_64 application, not stuck in 32-bit jail like so many things of the time), it didn’t work quite right, with completely broken audio playback, and visual issues (UI glitches, not just dated design).
Regardless if it worked out of the box or not, I knew I’d need to hack on it, and this meant getting familiar with the codebase and its subsystems, which provided a snapshot of the times. I’d have to replace what didn’t work, but also replace what was outmoded or just design decisions I didn’t like. Here’s an overview of notable subsystems and changes:
Objective-C and Foundation
While not really user visible. Objective-C was in a transitional period was Submariner was originally written. Objective-C went from manual reference counting to a garbage collector, along with many other changes that made “Objective-C 2.0”, which of course, came after Objective-C 4. This was widely regarded as a bad move, because it had unpredictable latency. Not good for desktop applications, really bad for iPhone applications in 2008. Apple moved over to automated reference counting, which among other things, involved major changes to how Objective-C works to a programmer (no more manual release).
Of course, Submariner targeted the Objective-C GC. What was still surprisingly is that Apple still supports it, even on ARM64. This meant while I could put off the conversion effort off, but I’d still want to do it eventually (ideally after ripping more stuff out). One nice thing is that Xcode still has a tool to convert projects to use ARC, and it does an OK job of what I remember, where it’ll purge the
release calls, try to identify weak pointers, and fix up properties. Of course, it’s not perfect (I had cases where it thought a pointer was weak where it wasn’t), but it’s a lot less tedious than what I thought was needed.
To play back media, Submariner used two different strategies, one for local files, and one for streaming over the network.
For streaming over the network, it used QTKit, which was the Objective-C QuickTime API. QuickTime had a long history on the Mac, so the “native” QuickTime APIs were Carbon (the modernized legacy Mac API, now gone). QTKit was essentially the Cocoa version of the APIs. After 10.6, QuickTime was phased out and replaced with the AVFoundation framework from iOS. It was eventually completely removed, so I’d have to get rid of it regardless.
For local files, it used SFBAudioEngine, a highly flexible and quite nice Swiss army knife of a framework. Submariner only dipped its toes in for audio playback and tag reading. It’s still maintained, but it went a major change from having C++ based APIs to Objective-C, to make it easier to use from Swift. This also had the side effect of having to make any consumers of it Objective-C++, which is an awfully cursed creature only Apple employees should have to deal with. Luckily, with the new Objective-C API wasn’t too much work to adapt to, and let me get rid of the Objective-C.
Initially, I considered using SFBAudioEngine only, since I put the effort into switching to the Objective-C API, and it was already there. However, I ran into issues with streaming over the network, which seemed to be some API design issues with HTTP requests.
So instead, I just decided to use AVFoundation for everything and rip out what was there before. I’d have to rewrite it, but I might as well simplify it, especially since macOS codec support is better than it used to be. This worked out fine, and AVFoundation seems to be less warty than QTKit. But, I’d like to support Opus in container formats people use for music (i.e. not CAF/WebM) and ideally support Vorbis based on user feedback, so I might have to consider something else.
Submariner uses Core Data, Apple’s Objective-C persistent object slash ORM framework. It’s a ton of abstraction and dynamism to wrap your head around if you aren’t used to a framework like it. What did surprise me is this basically didn’t have to change at all – all the Core Data code and models still works, unchanged from the 10.6 era, and it’s still being updated. Xcode will happily handle the old model formats too. Of course, it took me a while to figure out i.e. how to add attributes and do migrations, but what else is new?
What I did have to figure out was more tooling related. I noticed the classes for each kind of “entity” were split in two, with a generated portion from the model, and one where you put your own code. However, the entities were marked as without code generation in Xcode, and the Xcode generated classes had a different naming convention and class hierarchy than what I had. At first, I thought it might have been version differences, but I suspect it was a different generator tool (it seems Xcode didn’t actually have generation back then – that must have sucked!). In the end, I just refactored it to use the Xcode way instead.
Core Data gives you a few choices of how to store the serialized objects; Submariner initially chose an XML format that gives you some easy visibility into the graph, but it is slow. I switched to the SQLite format and it’s notably faster. Note that while it doesn’t abstract the fact it’s using SQLite, it does abstract how it stores things in SQLite – you won’t have fun manually managing the database, nor does it let you. If you do need to peek inside to debug issues though, I can strongly recommend Core Data Lab.
Submariner used a wrapper around
NSURLConnection called Resty. Being from the turn of the decade, it’s based on Objective-C blocks (itself relatively new), and inspired by a Ruby framework. Of course, this framework had some issues with modern macOS (I don’t remember the specifics, but I believe it had something to do with ARC?). Of course, Resty was no longer maintained, and
NSURLConnection itself was deprecated.
The better choice in the end was to adapt to
NSURLSession, which popped up in 10.9. It’s a little verbose due to the configuration system involved, but it supports both blocks and delegates, and in the end, was a lot simpler.
AppKit and Interface Builder
Unless you were a masochist doing things by hand, you almost certainly dealt with nibs (well, in their XML form, XIBs) when it comes to user interface (or many other things – Interface Builder is basically an object graph editor, not just GUI design). Submariner is no exception, but it predates the newer type of nib that came out of iOS and into the Mac, storyboards. While I ended up adopting a lot of the newer alternatives, I didn’t end up adopting storyboards. They seem to be a better fit in iOS, where you generally have stricter navigation patterns, with less custom segues between view controllers, and generally only one visible at a time. Of course, that doesn’t mean you’re stuck with Interface Builder…
Another bit of AppKit in heavy use is Cocoa Bindings. These take full advantage of Objective-C’s dynamic nature, and they’re in use to bind table views to array controllers. The split pane design in the “library” view is done without any code as a result. The tracks table is bound to the contents of the tracks array controller, which is bound to the selected item of the artists array controller, which is bound to the selected item of the albums array controller, and so on. Value transformers are added on top to turn the raw values underneath into something better formatted for users. It’s nice, but this approach isn’t without issues like tons of side effects that can be hard to observe and debug, and of course, means you have to take heavy runtime overhead.
Of course, there’s also new additions to the AppKit toolbox I could take advantage of too. Custom classes to implement common idioms like a popover, such as
MAAttachedWindow, can be replaced with
NSPopover (which appeared in 10.7). Layouts and split views especially have changed a lot. The new constraints system is quite complex, but replaces a lot of manual layout cases. Split view holding priority makes split view resizing more predictable, as opposed to slathering a thick layer of delegates on it.
Changing with the times
While getting rid of the deprecated APIs and replacing them with modern alternatives is good, it’s still not everything. There’s been a lot of new APIs that replaced previously clumsy methods, made something much easier, and the elephant in the room of Swift and SwiftUI. Plus, there’s been changes in taste, both of society in a decade, and my own editorial fiat.
Initially, Submariner looked like this when I picked it up:
While it’s embracing a lot of the Mac app design trends of the time (tons of gradients!), it’s actually doing a lot of custom stuff beyond what Apple provided. The view hierarchy in the nibs is quite complex, there’s a ton of custom drawing in custom classes for scroll bars, linen backgrounds, traffic lights outside the normal title bar, custom images, etc. Some of the new macOS APIs replace the homegrown (and often vendored third-party classes), but I think a lot of the custom logic and design can simply be excised and keeping close to native controls as possible. Doing so let me get rid of a ton of code and images. Some might have an attachment to the linen era of the Mac, but I always thought it was overwrought anyways.
The end result has a lot less code, and looks like a modern macOS application (albeit conserving some of the old stuff, such as in font metrics and some layouts). With less code to look like a specific era, it should also blend in better as the OS design evolves further. I’m taking advantage of the post-macOS 11 design changes like full-height sidebars/content areas, SF Symbols, and unified toolbars. But you can see parts where the original design remains, unchanged from the previous screenshot – just more toned down.
While I mentioned some APIs that replaced specific deprecated alternatives or third-party solutions, there are a few I’ll mention that let me do something previously complex, or replace entire subsystems.
MPRemoteCommandCenter provides system “now playing” controls, analogous to the MPRIS D-Bus interface if you’re familiar with that. By using this, I could replace all lots of things Submariner had that tried to replicate the functionality from before it existed. This included getting of a menu bar applet (managed through
NSStatusBar), global key listening code, and a shortcut recorder (both of these require special permissions nowadays, since they’re capturing keys globally). Now the functionality is handled with a single system-wide interface that handles things like media keys and output device state (i.e. automatically pausing if earbuds are removed) for you, a lot of which would be annoying to reimplement.
NSPageController let me implement page navigation better. A large part of the interface is navigating between different views surrounded by the rest of the UI. Before, these were implemented manually, and there was no history stack to go back. With the page controller, it provides animations, history, and state management with the help of some delegate methods and record types.
NSURLComponents is essentially a mutable version of
NSURL for easily building URLs in parts, including structured manipulation of query string arguments and properly escaping characters for you. It avoids error-prone manually building URLs from strings, or using confusing Core Foundation URL escaping.
The big elephant in the room is that Objective-C is…. polarizing. While the syntax is not appealing at first, you can come to appreciate the Objective-C worldview. However, the world of PLT has moved on, and the mix of dynamic runtime behaviour and C’s memory model doesn’t really make for an appealing language for the performance and security problems of the 2020s. The features and history of Swift have been better covered elsewhere, but the benefits of adopting it beyond Apple’s current “strategic vision” are obvious.
But, what’s adopting Swift like in a real-world application? The answer is actually pretty smooth, once you set up bridging headers and remember limitations such as needing to forward declare Swift classes in Objective-C headers and not being able to inherit from Swift classes in Objective-C (so, conversion starts with children). Swift can deeply integrate into the Objective-C view of the world (i.e. registering classes with the right names) when you opt in.
The interesting part is what happens as you convert or write new code in Swift. Swift presenting interfaces to Objective-C will have to present normal Objective-C resources, and some of these can use the newer Swift features (i.e. the
lazy keyword). For the newer Swift features that can’t be done in Objective-C (i.e. discriminated unions/sum types), you can write wrappers around it. As you have more Swift modules interacting with each other, these wrappers become unnecessary except for involving the OS APIs (places where Objective-C assumptions are deeply held, like in AppKit).
Of course, it’s not all perfect. Xcode is a lot slower at parsing Swift and dealing with errors, as is the debugging experience. Swift is a richer language, but the tooling isn’t as mature, plus, Objective-C’s dynamism might help in terms of having aspects of a Smalltalk-ish debugging experience.
You know the story, Interface Builder and XIBs have problems (i.e. the huge sensitive diffs), the industry is hooked on reactive UI frameworks like React, SwiftUI brings this for cross-OS Apple UI development, but it’s a young framework with its own teething issues. What’s it like to add it to a Mac-specific app, with no need of the cross-device benefits?
The answer is you could still get the workflow benefits, and that it is easy to incrementally add to an application. What I’ve done is take a SwiftUI
View, wrap that in an
NSHostingView, and make that a subview of the API surface view controller. It’s easy to isolate the SwiftUI view to only a specific part of a view, as opposed to a total rewrite.
SwiftUI encourages you to use modern things like
Observable over Objective-C world things like KVO, so converting models over to Swift helps. In the cases where the same models are mixed with things like Cocoa Bindings and SwiftUI, it can end up creating things like
@objc @Published var dynamic. Other unfortunate interactions include the SwiftUI tables not tending to like nullable properties. Unfortunately, I inherited that in the Core Data schema, and am going to have to sort that out.
What is annoying is some of the shiny SwiftUI features (i.e. the
Settings view) depend on totally switching over to the SwiftUI application lifecycle as opposed to integrating it into your existing one. Doing that partially seems harder, or at least not well travelled. I want to convert most of the view controllers over to being all SwiftUI to make such a conversion to a SwiftUI main window easier. I could use
NSViewRepresentable, but that’d be wrapping something already wrapping SwiftUI.
The philosophical difference between AppKit and Win32
So I studied and eventually refurbished a Mac application for the first time, but it’s not my first time with desktop applications. I’ve got quite a bit of familiarity with quite a few Microsoft stacks (complex projects like a different music player, Git porcelain, shell extension, etc.), although I never figured out the freedesktop.org world myself. Now having done both, I can fairly make a comparison between the two approaches.
AppKit is very maximalist. You get a lot, and in each component, you tend to get a ton of functionality. I’m quite used to raw Win32 where you get a minimum set of controls (though the fact you get controls means you’re doing better than going raw on the window system), and that’s it outside of involving external things like Common Controls. Even the later frameworks like Windows Forms don’t tend to provide too much.
Some of this is due to the dynamic nature of Objective-C; arbitrary views can receive things like
copy: messages (due to the responder mechanism, analogous to focus in Win32), so the Edit menu often works without intervention. But sometimes, it can lead to surprises, like views being able to receive
print:… and the default implementation in
NSView actually trying its best to offer a printable version of the view. One does have to wonder how things would look had Objective-C never taken hold on the Mac.
There’s rich controls like
NSRuleEditor available that are used in system applications, usable from your application. Microsoft rarely shares the toys that they use in Office, leading to people to keep reimplementing them in subtly different ways, if adopted at all. In AppKit, complex functionality is generally available in a way to keep applications consistent with other applications and the system in general. Fitting in is the happy path, providing obvious benefits.
Even outside of AppKit and in the rest of Cocoa, you tend to get other rich frameworks, out of the box, from computer vision to natural language processing to the ORM of Core Data. What exists in the Microsoft world tends to be quite raw, if it even exists at all. External dependencies often feel more of a necessity as a result.
In the Windows world, if you took a programmer from 2003, and dropped them into 2023, they could probably still be able to develop applications today. But would they be developing applications from 2003, or 2023? Microsoft has been through several frameworks, development environments and languages. Sometimes, it feels like you’re witnessing Conway’s Law in action, with warring departments wanting to push their preferred solution, and you’re unsure what’s recommended by Microsoft (UWP/WinRT or .NET cross-platform? Or do what they do and not what they say, and just use Electron?).
For UI toolkits/frameworks alone, there’s been raw Win32, MFC, ATL, Windows Forms, WPF, whatever UWP has, and WinUI 1 through 3. I’m almost certainly missing a few; how many can you count? Some of these have backwards compatibility or integration with each other, but many became dead ends, yet Microsoft and users of these frameworks still have to maintain them. It’s easy to just pick one and be stuck with it, even if it’s not getting improvements. For all the flack the free software world gets over CADT, it feels Microsoft is more afflicted with it.
By contrast, the Cocoa of 2023 feels like an evolved version of 2003’s. There’s more of it and more to it, and some of the older ideas are outmoded, but it can be done with and learnt from the existing skillset you have from then. It’s actually pretty easy to take an old application and make it feel like a modern one. In the Windows world, it’d often be superficial – your app might try to adopt the latest UI trends, but it’s just lipstick on a dead-end framework without a rewrite that has dubious benefits.
Even more disruptive changes like Swift and SwiftUI are done incrementally. I previously mentioned that this is possible before, but it tends to be pretty simple – i.e. just annotating with
@objc or creating the wrapper classes. In comparison, the XAML Islands example from Microsoft to bridge UWP and Win32 is quite baroque. I tried to add the UWP
SystemMediaTransportControls to a Windows Forms application and had to deal with not just a wrapper (such as the Contracts package), but also differences in the .NET BCL memory stream and UWP memory stream interface. Not quite the same experience as
But not without problems
Although I’ve had a lot of positive things to say, not everything is great. Many of these reasons held me (and probably others) back early on, and the frustrating part is a lot of it has been like this for a long time.
The maximalist nature of the APIs and the previously dynamic nature of the APIs tends to make them quite daunting to approach. It’s not quite to Ruby levels, but if your frame of reference is Windows development, then it’s a lot of things being done for you that feels out of your control. Constraints and bindings tend to be the worst for feeling like “magic” in the bad sense.
Not helping the daunting nature is the spotty quality of the documentation. The reference documentation can be OK. Goal oriented tutorials is thin on the ground, but also surprisingly OK when they exist. However, the documentation introducing and explaining concepts tends to not exist. If it does, it’s often in the documentation archive for things with a long history (which is not a good sign, even if the documentation is good), or stuck in WWDC presentations for newer things. This makes an already sprawling set of APIs seem less manageable, especially considering their complexity relative to the competition. Without this, trying to figure out what to do is cargo culting.
Some of the more ambitious APIs can have annoying oversights. For example, as much as
NSPageController can save you a ton of effort with managing navigation patterns, it has surprising omissions like ways to manage history. The array it manages is immutable, and changing it out from under it will confuse the controller. Of course, this could also just be due to lack of knowledge about the APIs to do something not immediately obvious – not helped by the poor documentation.
Another issue is sometimes old APIs don’t compose as well with the newer ones. For example, in AppKit, the full-height content area (for things like full-height sidebars and vibrancy as content goes under the toolbar/title bar) in macOS 11 brought the UIKit concept of the safe area with it. However, you can get confusing interactions between things that don’t seem aware of having to consider the safe area for i.e. sizing, and have to intervene manually. This includes split views that automatically save their position on restart, but fail to account for the safe area when restoring.
Xcode has historically not been the best IDE. It’s getting better from what it used to be, but it’s still polarizing. Unfortunately, fans of alternate IDEs might not enjoy the available options, with JetBrains’ AppCode disappearing from the market recently. You can probably ignore Xcode and use your own editor of choice, but for editing nibs and Core Data models, you’ll probably want to pop into Xcode every so often. SwiftUI and SwiftData provide UI and schema as code – presumably, part of the motivation is to avoid Xcode.
While the App Store isn’t a requirement like on Apple’s other platforms, the review process it requires is still controversial, and part of why many Mac developers decide it’s not worth the effort. I decided to ship on there anyways, since I didn’t feel like maintaining Sparkle for auto-updates myself. The whole process feels oriented for… commercial operations, as opposed to open source ones, for lack of better phrasing.
For example, I have a button to add the Subsonic demo server to provide users an example if they don’t have their own server. The reviewers seemed confused by this and asked if I had the rights to do this – the music provided in it is CC licensed, but I assume they didn’t know what that meant. The only thing that satisfied them was an email stating it was OK from the maintainer of Subsonic, who was understanding.
That said, I’ve had surprisingly interesting interactions before. Once, they managed to find a (somewhat obvious in retrospect) crash bug, with a stack trace and steps to reproduce, and rejected the submission based on that. Considering the reputation (and my own experience) of the review process as pedantic and arbitrary, I didn’t expect something reasonable and actionable.
Hopes and expectations, and how they change
I think it’s interesting when projects get adopted years later. Not just the novelty of someone doing it at all, but the intersection of what a different maintainer sees in a project and how the rest of the ecosystem how moved around it. I’m sure I took the project in a different direction than Rafaël would have (though he seemed happy someone took it up). I removed some vestiges of features probably planned (i.e. video playback), after all. But there’s also the question of both how ecosystems evolved – both the Mac and Subsonic ones, and how that impacted Submariner.
While I’ve already gone over the evolution of the Mac at least from a programmer’s perspective, the Subsonic one is a bit more subtle. A plethora of alternative and simpler music-focused servers like Navidrome exploded onto the scene, and changed the emphasis on Subsonic-related things trying to be a generic media server instead back to a focus on music. Submariner has support for some of these like podcasts and the “now playing” feed, but I’m unlikely to add more here.
Another huge difference is their data model. Subsonic presents the filesystem hierarchy and later added a tag metadata based view. Most alternative servers use tags for their model of the world and present a fake filesystem hierarchy based on that. Submariner predated that tag based API, but its data model assumed directories would be laid out in an artist-album-track manner (common, but not always), combining the worst of both worlds for Subsonic users. Amusingly, because of the data model’s assumptions, converting to use the tag based APIs were pretty easy. The hardest part was making invalidating the old IDs work well. I managed to make something that worked out pretty well in my own testing, without many complaints.
I myself use Submariner a lot, but interestingly, I have quite a few users other than myself. I haven’t advertised it at all, so it’s seemingly people discovering it on their own, or perhaps other projects linking to it. The feedback I’ve gotten has been very reasonable, and people have even submitted patches, which surprised me considering it’s an end-user application. I plan to keep working on it as long as I (or maybe even others) find it useful.