macos

Porting an iPad app to macOS Catalina

Introduction

At the recent WWDC 2019, Apple announced that in the current Xcode 11 betas they are bringing a new way to port a native iOS iPad app to macOS. We’ve been keeping an eye on the progress of Apple’s Project Catalyst (previously Project Marzipan) for a while, but now that the functionality is available to developers we decided to give it a try on one of our previous iPad-optimised projects. This article is intended to show you how to port a typical existing iPad app over to macOS Catalina using UIKitForMac.

In theory, all we need to do is select the “Mac” checkbox in the project settings of our existing iPad app project.

Screenshot 2019-07-20 at 10.45.17.png

For a simple app this may well be the case, but for a typical app with lots of Cocoapods dependencies it’s unfortunately not that simple. This article will describe the process I went through to convert an iPad Pro app into a native macOS app that uses Apple’s new UIKitForMac.

CocoaPods Dependency Migration

After selecting the Mac checkbox above I then tried to build the project but got a number of errors for different pods within my project, mainly because these pods had not yet been ported to UIKitForMac or iOS 13. There are also still outstanding issues related to Project Catalyst support within Cocoapods, which means that some of the more complicated pods aren’t easy to work with.

Out of the pods that were causing errors, I decided which pods I needed and which pods the app could run without. The pods that I decided were unnecessary for the initial port were: Crashlytics, Firebase/Analytics, Fabric and JGProgressHUD. I will now go through the pods that were necessary but had issues, and how I fixed each of these pods so that they would run in a native Mac app.

Realm

Realm wouldn’t build for UIKitForMac due to an error:

in .../Pods/Realm/core/librealmcore-ios.a(bptree.o), building for UIKitForMac, but linking in object file built for iOS Simulator, for architecture x86_64”.

This is quite a typical error when porting from iOS to macOS and refers to the lack of available binaries for the UIKitForMac platform. Surprisingly, the UIKitForMac build is actually considered ‘iOS’, but with an architecture of x86_64. Until xCode 11 an x86_64 architecture for iOS would always have been a binary built for the simulator, but this is no longer the case. This seems to have been some of the motivation behind the new .xcframework bundle type, which is explained in this WWDC 2019 talk.

On the Realm GitHub there was an open branch for Xcode-11-2, which I hoped may fix this issue but the same issue persisted. After reading through a ticket on the Realm GitHub with regards to Xcode 11 fixes, I found there was an open feature branch for Swift Package Manager support.

I commented-out the Realm pod in the podfile and opted to add the pod through the Swift Package Manager route. To do this I followed these simple instructions: 

  • Click File -> Swift Packages -> Add Swift Package Dependency

  • Paste the Realm GitHub url 

  • Click Next

  • Select the project you want to add the dependency to

  • Select the branch radio button and copy the branch from the GitHub repository that you want to add to your project.

The result of adding Realm to my project in this way was that I still had 2 pods in my Podfile that depended on Realm. If I left them in my podfile as-is they would re-download the Realm pod as their dependency, and it’s difficult to share Swift Package Manager dependencies with Cocoapods. Therefore I decided that the best option would be to add these 2 pods as Swift Packages instead, keeping the dependency tree within the same package management system.

Creating a Package.swift File for a Cocoapods Depencency

I found it relatively straightforward to create a package.swift file based on what we see in a dependency’s GitHub repository and existing .podspec. Below is an example of the Package.swift file I made for the RxRealmDataSources dependency.

// swift-tools-version:5.1
import PackageDescription

let package = Package(
	name: "RxRealmDataSources",
    // 1
    platforms: [
        .macOS(.v10_15), .iOS(.v12), .tvOS(.v9), .watchOS(.v3)
    ],
    // 2
    products: [
        .library(name: "RxRealmDataSources", targets: ["RxRealmDataSources"])
    ],
    // 3
	dependencies: [
        .package(url: "https://github.com/foresightmobile/RxSwift", .branch("removing-uiwebkit")),
		.package(url: "https://github.com/foresightmobile/RxRealm.git", .branch("removed-realm")),
        .package(url: "https://github.com/realm/realm-cocoa", .branch("tg/spm"))
	],
    // 4
    targets: [
        .target(
            // 5
            name: "RxRealmDataSources", 
            // 6
            dependencies: ["RxRealm", "RxSwift", "RealmSwift", "Realm", "RxCocoa"], 
            // 7
            path: ".", 
            // 8
            sources: ["RxRealmDataSources"]
        )
    ]
)
I184KL0LDI72.2KRankn/aAgel0whoissourceRank136KMore dataSummary reportDiagnosisDensity00n/a

The first two lines are always necessary, the first line tells the compiler which version of Swift Tools we are using and so decides the syntax of the Package.swift file. The rest of the file is the package definition itself.

  1. These are the platforms that you want the Package to be able to build for. Of note, UIKitForMac is actually the iOS platform.

  2. These are the libraries within the Package that you want to be able to use in your apps in an import statement.

  3. These are the external dependencies that are required for the Package to build successfully. Here I used a couple of projects that I had forked and edited myself to make compatible with UIKitForMac. The difference here from .podspec files is that you can specify the branch of a dependency in a Package.swift file whereas in a .podspec file you cannot.

  4. The targets or target in this case includes the details for the code we want to run.

  5. The name here must match the name in the targets array in the library in 2.

  6. These are equivalent to each target in the dependencies from 3. For example, the package with the url “https://github.com/foresightmobile/RxSwift” will have two targets in “RxSwift” and “RxCocoa” and we need both of these targets for our Package.

  7. This is where within the Package we want to start looking for the code. The path “.” means we want to start looking for the code from the root of the project.

  8. This is the folder within the project that contains all the code we want to run.

When changing Package.swift files and pushing changes to GitHub, you may find that Xcode isn’t picking up your updates from the remote repository. if your Package.swift file doesn’t seem to be updating you will need to alternate between two urls to force updates: i.e. switch between “https://github.com/foresightmobile/RxSwift” and “https://github.com/foresightmobile/RxSwift.git” in the dependency settings. This should force Xcode to update its cached package files.

RxRealmDataSources / RxRealm

For these two pods there was no Package.swift file in their repository, which is required to add a dependency as a Swift Package. So I had to fork these two pods and create Package.swift files for each of them here and here.

RxSwift

This pod was using a class called UIWebKit that is not available for UIKitForMac, so I had to fork the project and remove the UIWebKit references from the code. Then later on in the porting process I realised that another pod was relying on RxSwift 4.5 (rather than the latest 5.x), so I had to create another branch with the UIWebKit removed from RxSwift 4.5.

RxDataSources

The most up to date version of this pod used RxSwift 5, but we had another pod that was relying on RxSwift 4.5. Therefore I had to fork this project and make a branch where the Package.swift file was pointing to the version of RxSwift detailed above.

Nuke

Nuke was using a deprecated version of the URLCache initialiser, which meant I had to switch the name of one of the arguments in the initialisation of URLCache from “diskPath” to “directory” and supply a URL instead of a string. I also made use of an #if targetEnvironment statement here to determine whether to use “diskPath” or “directory”, depending on whether the target build environment is UIKitForMac or not. This means the dependency will still run on iPad as well as Mac.

    public static let sharedUrlCache = { () -> URLCache in
        #if targetEnvironment(UIKitForMac)
            return URLCache(
                memoryCapacity: 0,
                diskCapacity: 150 * 1024 * 1024, // 150 MB
                directory: URL(fileURLWithPath: cachePath))
        #else
            return URLCache(
                memoryCapacity: 0,
                diskCapacity: 150 * 1024 * 1024, // 150 MB
                diskPath: cachePath)
        #endif
    }

More Problems to Fix

…/Pods/Fabric/run: No such file or directory
Command PhaseScriptExecution failed with a nonzero exit code

After fixing issues with the above pods, I was still having an issue with the Fabric pod, which I had commented out of my Podfile earlier. This was because I had a Fabric run script leftover from when Fabric was still in the app. I removed this and the error involving Fabric went away.

Part6-FabricRunScript.png

The final error I was getting was an error that the XCTest framework could not be found. This was because some extra packages had been imported at some point during the Swift Package Manager transition. These extra packages could be found under the “Link with Binary Libraries” section in the “Build Phases” tab of the project settings. Once I removed the packages that I had not added manually (RxBlocking, RxTest), the app ran and I could successfully use it in a window on my Mac and also in the iPad simulator.

macOS Main Window Layout

After getting the app to run in a window on my Mac, it became apparent there was an issue with the main layout and the title bar of the window. This title bar was covering the top part of the screen in the app. To fix this I simply changed the top constraint for the main view element at the top of the page to constrain to the safe area rather than the outside of the window.

Progress Reporting and macOS Interaction

One of the pods I decided to remove was the JGProgressHUD. This was a pod that I could not fix quickly, so an alternative had to be considered. We were using this pod to display the progress of loading and processing data from Contentful. To fit more in-line with macOS interaction paradigms the alternative we decided to pursue was to disable all the buttons on the screen while this data was being downloaded and processed so that a user cannot interrupt this data flow. This was a quick fix and more in-line with macOS interaction paradigms.

Conclusion and Future Work

The intention of this project was to determine the impact of an iPad to macOS port with a real-world example. The porting process was relatively simple, and given a few more weeks I’m sure that a lot of our dependencies would have added Swift Package Manager support and made the transition easier.