Configuring App Updates for Mac Catalyst Apps With Sparkle | by Eskil Gjerde Sviggum | Nov, 2022

Let’s release apps outside the App Store

Sparkle is an open-source framework for integrating app updates into your Mac apps when they are not distributed through the App Store. It is highly flexible and allows you to push automatic updates to your app in a simple way. It’s likely that you’ve already seen Sparkle in many of the apps you use – it’s used for example postal worker, arc, hex fan, rectangle, and countless others.

Screenshot of the Sparkle Update window.

One of the key features of Sparkle is its ease of implementation. In a regular Cocoa app, you can support app updates without a single line of code.

Fortunately, Sparkle works in Catalyst apps too, but requires some extra work to get up and running.

The Sparkle framework needs to be able to talk to AppKit, so it won’t work properly if installed in our main app. So, we need to create a bundle that can access the AppKit API.

Create and select a new target for macOS in Xcode Bundle below Framework & Library, I’ll call it the “SparklePlugin”.

Next, go to your project settings and add the bundle to your main app Frameworks, Libraries, and Embedded Content,

make sure to Embed & Sign Make the bundle available only on macOS by pressing and selecting the Filter drop-down Mac Catalyst Only.

2. Add Sparkle Framework

Now, we can include Sparkle as a dependency in the newly created bundle. to select File , Add packages… and use as the package URL.

When prompted to choose to package products, remember to select the bundle you created as the target.

After checking Sparkle with SPM choose the Package Products menu in Xcode.  Bundle just created

Because we are adding sparkle to our bundle and loading the bundle from our main app the app will be looking for the sparkle framework in our app /Frameworks directory and not of our bundle /Frameworks where it is actually located – resulting in a NSExecutableLinkError (3588) with message Library not loaded: @rpath/Sparkle.framework/Versions/B/Sparkle,

To solve this, go to the bundle’s build settings, search for “runpath search path” and add @loader_path/../Frameworks,

Next, we can start implementing calls for updates to create the sparkle look.

Before that, we needed a way for our main app to talk with our Sparkle bundle. This can be done using a protocol.

Create a new Swift file in the group for your bundle and be sure to add the file to both your app target and the bundle target. I’ll name the protocol SparklePluginProtocol,

With both the app target and the sparkleplugin target selected

populate it with a init() and a method to initialize the updater, startUpdater(),

Note that bundlers can only load classes exposed to Objective-C, so we need to conform to our protocol NSObjectProtocol to declare the class as NSObject, and annotate it with @objc(SparklePluginProtocol) To declare the name of a protocol in an Objective-C-friendly way for the runtime.

/* SparklePluginProtocol.swift */

@objc(SparklePluginProtocol)
protocol SparklePluginProtocol: NSObjectProtocol
init()

func startUpdater()

Next, create a new file available only to the Sparkle plugin target.

This would be for the class that implements the protocol and is the class responsible for making API calls to Sparkle.

Remember that the class needs to be exposed to Objective-C and therefore also derives from NSObject.

In the starter, create a new SPUStandardUpdaterControllermaking sure startingUpdater Is false To prevent the controller from starting when we load the bundle in our main app.

Instead, we’ll create a method to start the updater from our AppDelegate right on time:

/* SparklePlugin.swift */

import Sparkle

@objc
final class SparklePlugin: NSObject, SparklePluginProtocol

private let updaterController: SPUStandardUpdaterController

override init()

updaterController = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: nil,
userDriverDelegate: nil
)

func startUpdater()
updaterController.startUpdater()

Back in our main app, just create a new file for your app target that will contain the code to load the bundle and call the Sparkle API via the protocol we just created.

Starting with loading the bundle, create a new structure and make sure it’s only available in Mac Catalyst by wrapping it in #if targetEnvironment(macCatalyst),

Since bundle loading may fail, we create an optional initializer and set sparklePlugin for stable working results loadSparklePlugin() if it succeeds.

/* SparklePluginInterface.swift */

#if targetEnvironment(macCatalyst)
struct SparklePluginInterface

private let sparklePlugin: SparklePluginProtocol

init?()
guard let sparklePlugin = Self.loadSparklePlugin() else
return nil

self.sparklePlugin = sparklePlugin


#endif

Loading the plugin is done as follows. If loading the bundle should fail, the program will halt DEBUG only because of assertionFailure(),

/* SparklePluginInterface.swift */

private static func loadSparklePlugin() -> SparklePluginProtocol?
guard
let bundleUrl = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SparklePlugin.bundle"),
let bundle = Bundle(url: bundleUrl)
else
return nil

do
try bundle.loadAndReturnError()
catch
print(error)
assertionFailure()
return nil

guard
let SparklePlugin = bundle.classNamed("SparklePlugin.SparklePlugin") as? SparklePluginProtocol.Type
else
return nil

return SparklePlugin.init()

Finally, we can add a method to start the updater:

/* SparklePluginInterface.swift */

func startUpdater()
sparklePlugin.startUpdater()

Results in the full code for the interface:

/* SparklePluginInterface.swift */

#if targetEnvironment(macCatalyst)
struct SparklePluginInterface

private let sparklePlugin: SparklePluginProtocol

init?()
guard let sparklePlugin = Self.loadSparklePlugin() else
return nil

self.sparklePlugin = sparklePlugin

func startUpdater()
sparklePlugin.startUpdater()

extension SparklePluginInterface
private static func loadSparklePlugin() -> SparklePluginProtocol?
guard
let bundleUrl = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SparklePlugin.bundle"),
let bundle = Bundle(url: bundleUrl)
else
return nil

do
try bundle.loadAndReturnError()
catch
print(error)
assertionFailure()
return nil

guard
let SparklePlugin = bundle.classNamed("SparklePlugin.SparklePlugin") as? SparklePluginProtocol.Type
else
return nil

return SparklePlugin.init()

#endif

After this, you can start Sparkle and check for updates.

Initialize and run Sparkle interface in AppDelegate startUpdater() in your AppDelegate didFinishLaunchingWithOptions way.

/* AppDelegate.swift */

#if targetEnvironment(macCatalyst)
let sparkleInterface = SparklePluginInterface()
#endif

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool

// Setup sparkle
#if targetEnvironment(macCatalyst)
sparkleInterface?.startUpdater()
#endif

return true

Finally, Sparkle can be configured in your main app Info.plist, You will need to provide a URL for the Epcast stream where your updates reside under SUFeedURL key.

Sparkle also requires a public key to verify the signature of updates. SUPublicEDKey key. For more information see Sparkle’s DocumentsSpecifically steps 3-6.

You can customize Sparkle to your liking and add more functionality.

A common addition is creating a menu item for the user to manually check for updates.

Starting in our AppDelegate, add a New Application menu item to buildMenu way.

/* AppDelegate.swift */

override func buildMenu(with builder: UIMenuBuilder)
super.buildMenu(with: builder)

guard builder.system == UIMenuSystem.main else return

// Add `Check for updates`
#if targetEnvironment(macCatalyst)
if let appMenu = builder.menu(for: .application)
let checkForUpdatesItem = UICommand(title: "Check for updates", action: #selector(didPressCheckForUpdates(sender:)))
var children = appMenu.children
children.insert(checkForUpdatesItem, at: 1)
let menu = UIMenu(title: appMenu.title, image: appMenu.image, identifier: appMenu.identifier, options: appMenu.options, children: children)
builder.replace(menu: .application, with: menu)

#endif

#if targetEnvironment(macCatalyst)
@objc
func didPressCheckForUpdates(sender: Any?)
sparkleInterface?.checkForUpdates(sender: sender)

#endif

Next, add a method to the SparklePluginInterface that forwards messages to the plugin.

/* SparklePluginInterface.swift */

struct SparklePluginInterface

/* ... */

func checkForUpdates(sender: Any?)
sparklePlugin.checkForUpdates(sender: sender)

add one to bundle checkForUpdates(sender:) method and apply in your SparklePlugin Class.

/* SparklePluginProtocol.swift */ 

@objc(SparklePluginProtocol)
protocol SparklePluginProtocol: NSObjectProtocol

/* ... */

func checkForUpdates(sender: Any?)

/* SparklePlugin.swift */

@objc
final class SparklePlugin: NSObject, SparklePluginProtocol

private let updaterController: SPUStandardUpdaterController

/* ... */

func checkForUpdates(sender: Any?)
updaterController.checkForUpdates(sender)

This is the general way when implementing a new call to Sparkle:

  • Implement calls to Sparkle in your SparklePlugin class in the bundle.
  • Add method description to your SparklePluginProtocol.
  • Add a method to your SparklePluginInterface that forwards the call to your bundle.

Leave a Reply