Hello there! 👋🏻 If you're short on time and can't delve into the entire process,
consider exploring the autosparkle command-line tool.
It provides benefits like DMG customization and automatic app versioning.
Introduction
I’ve always wondered how popular apps deliver their apps without using the App Store. 🤔
- Do they build everything from scratch?
- What’s the difference between a dmg and a pkg file?
- How do they notify users about new updates?
Alright, let’s dive into the nuts and bolts of how we can get our app out there. We’ll break down some key terms, compare a few things, and walk through the process step by step. Ready? Let’s get started! 🚀
These questions will be answered in detail in this article, along with many other concerns that come to mind. I’ll also share my usual process for automating app delivery to make life easier without repeating the same steps over and over again.
But first, why would someone want to deliver their app outside of the App Store? 🤯
There are several reasons, including:
- Full System Access: Use system extensions and daemons to access macOS’s full potential without sandboxing.
- Instant Updates: Release updates whenever you want without waiting for reviews or fearing arbitrary rejections.
- Maximize Revenue: Avoid paying Apple Store commission fees.
These are enough reasons to let anyone reconsider having their apps delivered through the App Store.
For me, the main reason is the desire for full access to the Mac operating system. As someone who enjoys diving deep into the OS and understanding how it works, delivering my apps through the App Store wasn’t an option.
PKG vs DMG
There are two formats for distributing your app on the macOS, it’s either a pkg or dmg, it’s up to you to pick between them depending on your needs. Let’s see what’s the difference between them:
- PKG: This is a bundle of files that have been assembled together into a single package. When you double-click on this package, an installer is launched. There’s also an option to run scripts either before or after the installation process. This setup is particularly beneficial for applications that need to be installed in multiple locations, not just confined to the Applications folder.
- DMG: A DMG is a disk image, functioning similarly to a remote drive or CD. It exists as a single file, making it an efficient method for distributing an application that doesn’t have dependencies. This format is particularly advantageous for straightforward application distribution.
Signing
Installing an unsigned application will result in warnings from macOS, causing users to doubt the app’s safety and trustworthiness. Therefore, signing your application is a crucial step for ensuring its acceptance and reliability.
Xcode provides a simple way to sign your application with just a few clicks. However, since our aim is to automate the entire process and distribute the app outside the App Store, we need to handle this in the terminal.
To sign the app (or the dmg) and all the inner components you need to use the Developer ID Application Certificate.
If you are willing to use pkg you should sign it with a different certificate which is the Developer ID Installer Certificate.
These certificates should be created by the account holder.
Notarization
Apple also requires anyone who wants to deliver an application to notarize it first.
Notarization gives users more confidence that the software you share has been checked by Apple for harmful stuff. It’s an automatic system that examines your software for any bad content and confirms it’s signed off correctly. If your Developer ID signing key is exposed, notarization helps protect your users.
Ignoring this part will leave an impression that your app isn’t safe and the below alert will be shown to the user
Imagine we’ve signed and notarized our app, and it’s available for download on our website. Now, how do we update it? Do we tell users to delete the app and download it again?
No need for that, because we have Sparkle! Let’s explore what Sparkle is 🔍
Sparkle
Sparkle is a widely used open-source framework, often considered the standard for updating macOS apps distributed outside the App Store. This framework employs a method known as Appcasting to distribute updates. It’s similar to using an RSS feed to deliver updates and release notes to users.
While you could develop a custom method to push updates to your users, Sparkle is usually sufficient for most scenarios. It simplifies many steps for you.
To use Sparkle more efficiently, it’s beneficial to utilize a remote storage solution. Options such as GitHub Releases, Amazon S3, or a dedicated section on your website can facilitate efficient hosting and management of various versions of your app.
Add Sparkle to your project
Here’s how to add Sparkle to your project. The steps are easy and are also in the guide here. But I’ll go over them quickly.
- First, we have to put the Sparkle package in our project. Use https://github.com/sparkle-project/Sparkle as a package link and ensure it’s added to your app target.
- To keep it easy, let’s put a menu item in our main menu. When you click it, Sparkle will look for updates to our app.
import AppKit
import Sparkle
final class MainMenu: NSMenu {
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
init() {
super.init(title: "")
let menuItem = NSMenuItem()
addItem(menuItem)
let appMenu = NSMenu()
menuItem.submenu = appMenu
let checkForUpdatesMenuItem = NSMenuItem()
checkForUpdatesMenuItem.title = "Check For Updates"
checkForUpdatesMenuItem.target = updaterController
checkForUpdatesMenuItem.action = #selector(updaterController.checkForUpdates)
appMenu.addItem(checkForUpdatesMenuItem)
// Add other menu items if needed
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
3. Signing your updates with EdDSA signatures is a crucial step to authenticate that the updates delivered to the users originate from you. To generate these keys, we must utilize the generate_keys
executable file. Unfortunately, this file is not accessible from the Sparkle Swift package, so we need to download the latest release from here. You’ll find it in the bin
folder, alongside other executables that we will use later.
Execute the generate_keys
file in your terminal. This process will yield a key and a value, which should be added to your application’s info.plist
file.
You may see the following alert saying that Apple could not verify generate_keys
is free of malware… So make sure to press the Allow Anyway button in Privacy & Security.
4. The final step involves specifying the URL of your appcast.xml
. This file holds all the information about the latest update. Sparkle uses it to check if the installed app is up-to-date or requires an update, and what content to display to the user when an update is available. The URL should be public and accessible to all.
In your app’s Info.plist
file, add a new property with the key SUFeedURL. The value should be the URL that points to your appcast.xml
file (even if it’s not exist yet).
And that’s it! We’ve successfully integrated Sparkle into our app 🎉.
The Automated Process
Delivering an app outside of the store involves numerous steps. Repeating these steps each time you release a new version can be quite time-consuming. Fortunately, Apple and Sparkle provide command-line tools that allow us to automate everything in a single script.
Our script will incorporate the necessary steps from Apple, like notarizing and signing the application, along with some custom scripts and logic needed to display the update to the user. I’ll be using Ruby for this script, but you can replicate the same steps in different languages, such as shell scripting if you prefer.
For simplicity, I will use dmg to distribute the app.
Throughout this article, I will be referencing my open-source project, sparkle-test-app. The project is already integrated with Sparkle. All you need to do is add values for the SUFeedURL
and SUPublicEDKey
in your Info.plist
file, alongside adding your values inside the environment file.
Here are the steps we’ll automate:
- Archive and sign the app
- Export your app
- Create and sign the dmg
- Sign the Sparkle framework
- Notarize and staple the DMG
- Sign the update
- Generate the appcast.xml
- Upload the DMG and appcast.xml to the storage
Ensure that Ruby is installed on your machine, along with the Xcode command line tool xcode-select
. Once these are set up, we can start writing the script 👨🏻💻.
Set up the environment
It’s a best practice to support multiple environments for your project; maybe you need to deliver your app with a Snapshot
configuration where some items and debug settings are only available for the QA team, or probably you want to add this script in a CD workflow, therefore we should take this into consideration. I’ll be using dotenv for handling these environments.
Make sure to add gem 'dotenv'
in your Gemfile.
Inside the project where you have your macOS application, create a new file called .env.autosparkle.local
add fill your own values that we will use in our script:
CONFIGURATION=
SCHEME=
# Used for signing
DEVELOPER_ID_APPLICATION_BASE64=
DEVELOPER_ID_APPLICATION_PASSWORD=
# Used for notarizing the DMG
APPLE_ID=
APP_SPECIFIC_PASSWORD=
To obtain the base64 versions of the certificates, export the p12
files for each certificate from your Keychain. You should select both the certificate and the private key.
After exporting the p12
file (let’s say on your Desktop) you can obtain the base64 by executing this command:
base64 -i ~/Desktop/Developer-ID-App.p12 | pbcopy
Now after we set our environment, let’s try to read these values in our Ruby script:
require 'dotenv'
require 'optparse'
options = {}
OptionParser.new do |opts|
opts.banner = 'Usage: automate-sparkle.rb [options]'
opts.on('--project-path PATH', 'Xcode project path to the project') do |path|
options[:project_path] = path
end
opts.on('--env-file-path ENVIRONMENT', 'Environment file to load') do |env_file_path|
options[:env_file_path] = env_file_path
end
opts.on('--app-display-name NAME', 'Display name of the app') do |name|
options[:app_display_name] = name
end
opts.on('--marketing-version VERSION', 'Marketing version of the app') do |version|
options[:marketing_version] = version
end
opts.on('--current-project-version VERSION', 'Current project version of the app') do |version|
options[:current_project_version] = version
end
end.parse!
# Check if both project path and environment are specified
unless options[:project_path] &&
options[:env_file_path] &&
options[:app_display_name] &&
options[:marketing_version] &&
options[:current_project_version]
raise 'Error: Project path, environment file path, app display name, marketing version and current project version must be specified.'
end
env_file_path = options[:env_file_path]
raise "Error: #{env_file_path} does not exist." unless File.exist?(env_file_path)
Dotenv.load(env_file_path)
puts "Running the script with the #{options[:env_file_path]} environment..."
You can now run the Ruby script and pass the needed options and you will see that the script can detect your environment and all the values from the .env.autosparkle.local
file. To run the script you can execute this command:
ruby main.rb --project-path ~/Desktop/SparkleTestApp/SparkleTestApp.xcodeproj \\
--env-file-path .env.autosparkle.local \\
--app-display-name SparkleTestApp \\
--marketing-version 1.0.0 \\
--current-project-version 1
For simplicity, I am passing both the marketing version and the current project version to this command. It’s important to note that while you can handle version bumping in various ways, Sparkle primarily uses the current project version, which should be incremented. This means that for any build you want to release, you should increase the current project version by one, regardless of what you specify for the marketing version.
Archive and Sign the app
It’s always a good idea to create a dedicated keychain for signing any product. This ensures clarity and security. Additionally, we need to include the base64 of the Developer ID application certificate. To use it, we have to write the actual content into a file and then import it to a keychain. I’ll share the custom code I created to generate a temporary keychain that gets automatically deleted when our code is complete.
# frozen_string_literal: true
require 'tempfile'
require 'securerandom'
require 'open3'
require 'base64'
require_relative 'constants'
require_relative 'helpers'
#
# Execute the given block with a temporary keychain,
# The block will receive the application certificate name and team id as arguments.
# The temporary keychain will be deleted after the block has been executed.
#
def with_temporary_keychain
original_keychain_list = `security list-keychains`.strip.split("\n").map(&:strip)
default_keychain = execute_command('security default-keychain')
default_keychain_path = default_keychain.gsub(/"(.*)"/, '\1').strip
delete_temporary_keychain_if_needed
begin
# Create a temporary keychain
create_temporary_keychain(original_keychain_list)
import_certificates_in_temporary_keychain
execute_command("security set-key-partition-list -S apple-tool:,apple:,codesign: \\
-s -k \"#{Constants::KEYCHAIN_PASSWORD}\" #{Constants::KEYCHAIN_PATH}")
# Fetch the certificate names and team ids from the temporary keychain
application_cert_name, application_team_id = fetch_application_certificate_info
store_notarization_credentials(application_team_id)
keychain_info = {
application_cert_name: application_cert_name,
application_team_id: application_team_id
}
yield(keychain_info) if block_given?
ensure
puts 'Ensuring cleanup of temporary keychain...'
delete_temporary_keychain_if_needed
# Reset the keychain to the default
execute_command("security list-keychains -s #{original_keychain_list.join(' ')}")
execute_command("security default-keychain -s \"#{default_keychain_path}\"")
end
end
private
def create_temporary_keychain(original_keychain_list)
execute_command("security create-keychain -p \"#{Constants::KEYCHAIN_PASSWORD}\" #{Constants::KEYCHAIN_PATH}")
execute_command("security unlock-keychain -p \"#{Constants::KEYCHAIN_PASSWORD}\" #{Constants::KEYCHAIN_PATH}")
execute_command("security list-keychains -d user -s #{(original_keychain_list + [Constants::KEYCHAIN_PATH]).join(' ')}")
execute_command("security default-keychain -s #{Constants::KEYCHAIN_PATH}")
end
def store_notarization_credentials(application_team_id)
command = "xcrun notarytool store-credentials #{Constants::NOTARIZE_KEYCHAIN_PROFILE} \\
--keychain #{Constants::KEYCHAIN_PATH} \\
--apple-id #{ENV['APPLE_ID']} \\
--team-id #{application_team_id} \\
--password #{ENV['APP_SPECIFIC_PASSWORD']}"
execute_command(command)
end
def import_certificates_in_temporary_keychain
developer_id_application_p12 = Base64.decode64(ENV['DEVELOPER_ID_APPLICATION_BASE64'] || '')
# Create temporary files for the .p12 certificates
application_cert_file = Tempfile.new(['application_cert', '.p12'])
# Write the decoded .p12 data to the temporary files
application_cert_file.write(developer_id_application_p12)
application_cert_file.close
# Import the certificates into the temporary keychain
import_file_to_keychain(application_cert_file.path, ENV['DEVELOPER_ID_APPLICATION_PASSWORD'])
end
def import_file_to_keychain(file_path, password)
command = "security import #{file_path} -k #{Constants::KEYCHAIN_PATH} -P #{password}"
command += ' -T /usr/bin/codesign'
command += ' -T /usr/bin/security'
command += ' -T /usr/bin/productbuild'
command += ' -T /usr/bin/productsign'
execute_command(command)
end
def fetch_certificate_info(certificate_type)
command = "security find-certificate -c \"#{certificate_type}\" #{Constants::KEYCHAIN_PATH}"
command += " | grep \"labl\" | sed -E 's/^.*\"labl\"<blob>=\"(.*)\".*/\\1/'"
name = `#{command}`.strip
team_id = name[/\(([^)]+)\)$/, 1]
[name, team_id]
end
def fetch_application_certificate_info
fetch_certificate_info('Developer ID Application')
end
def delete_temporary_keychain_if_needed
return unless File.exist?(File.expand_path(Constants::KEYCHAIN_PATH.to_s))
execute_command("security delete-keychain #{Constants::KEYCHAIN_PATH}")
execute_command("rm -rf #{Constants::KEYCHAIN_PATH}")
end
In addition to the code above, I created two files, one called constants.rb
and the other one is helpers.rb
. The constants.rb
contains all the constants of the app, like the keychain name and path…
# frozen_string_literal: true
# Constants module to store the constants used in the application
module Constants
NOTARIZE_KEYCHAIN_PROFILE = 'autosparkle.keychain.notarize.profile'
KEYCHAIN_NAME = 'temporary.autosparkle.keychain'
KEYCHAIN_PATH = "~/Library/Keychains/#{KEYCHAIN_NAME}-db"
KEYCHAIN_PASSWORD = 'autosparkle'
end
The other file helpers.rb
contains a single function that executes our shell commands, it raises an error if something goes wrong and it returns the output of the command in case we want to use it in our script.
#
# This method executes a command and returns the output
# It raises an error if the command fails
#
def execute_command(command)
stdout, stderr, status = Open3.capture3(command)
unless status.success?
puts "\nCommand failed: #{command}\n"
puts "\nError: #{stderr}\n"
raise
end
stdout
end
I will use this function after loading the environment in my main script to create a build directory to store any generated files.
# Clean the build directory
execute_command('rm -rf ./build')
execute_command('mkdir ./build')
Now we are ready to archive and sign our app, let’s create a separate function for it:
def archive(application_cert_name, application_team_id, app_display_name, project_path)
app_archive_path = "./build/#{app_display_name}.xcarchive"
archive_command = 'xcodebuild clean analyze archive'
archive_command += " -scheme #{ENV['SCHEME']}"
archive_command += " -archivePath '#{app_archive_path}'"
archive_command += " CODE_SIGN_IDENTITY='#{application_cert_name}'"
archive_command += " DEVELOPMENT_TEAM='#{application_team_id}'"
archive_command += " -configuration #{ENV['CONFIGURATION']}"
archive_command += " OTHER_CODE_SIGN_FLAGS='--timestamp --options=runtime'"
archive_command += " -project #{project_path}"
execute_command(archive_command)
app_archive_path
end
The application_cert_name
and the application_team_id
are returned from the use_temporary_keychain
the function we created earlier, so at the top of the file make sure you import the keychain_helpers.rb
where this function exists.
require_relative 'keychain_helpers'
Now let’s call our archive function
with_temporary_keychain do |keychain_info|
application_cert_name = keychain_info[:application_cert_name]
application_team_id = keychain_info[:application_team_id]
app_display_name = options[:app_display_name]
project_path = options[:project_path]
marketing_version = options[:marketing_version]
current_project_version = options[:current_project_version]
options[:project_path] = project_path
# 1. Archive and export the app
puts '1. Archiving and exporting the app...'
app_archive_path = archive(application_cert_name, application_team_id, app_display_name, project_path)
end
Export the app
Similar to what we did in the archive step, now we will create a new function that exports our .app
from the archive, we need to pass the export options plist to this script, so we will create a new file in the build directory and then pass it.
def export(app_archive_path, application_cert_name, application_team_id, app_display_name)
export_options = {
signingStyle: 'automatic',
method: 'developer-id',
teamID: application_team_id,
signingCertificate: application_cert_name,
destination: 'export'
}
export_options_file_path = './build/exportOptions.plist'
export_options_file = File.new(export_options_file_path, 'w')
export_options_file.write(export_options.to_plist)
export_options_file.close
# A directory for the exported app
export_app_dir_path = './exported_app'
# Construct the export command
export_command = "xcodebuild -exportArchive -archivePath \"#{app_archive_path}\""
export_command += " -exportPath \"#{export_app_dir_path}\""
export_command += " -exportOptionsPlist \"#{export_options_file.path}\""
execute_command(export_command)
"#{export_app_dir_path}/#{app_display_name}.app"
end
Now let’s call this function inside our with_temporary_keychain
function
# 2. Export the app
puts '2. Exporting the app...'
exported_app_path = export(app_archive_path, application_cert_name, application_team_id, app_display_name)
Sign the Sparkle framework
Depending on the xcodebuild
command to automatically sign everything might not always work. According to the Sparkle documentation, it’s important to also sign the Sparkle framework if you’re not exporting the app from Xcode. Skipping this step can cause the notarization process to fail.
def sign_sparkle_framework(exported_app_path, application_cert_name)
sparkle_framework_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework"
codesign_command = "codesign -f -o runtime --timestamp -s \"#{application_cert_name}\""
sparkle_auto_update_path = "#{sparkle_framework_path}/AutoUpdate"
execute_command("#{codesign_command} \"#{sparkle_auto_update_path}\"")
sparkle_updater_path = "#{sparkle_framework_path}/Updater.app"
execute_command("#{codesign_command} \"#{sparkle_updater_path}\"")
sparkle_installer_xpc_path = "#{sparkle_framework_path}/XPCServices/Installer.xpc/Contents/MacOS/Installer"
execute_command("#{codesign_command} \"#{sparkle_installer_xpc_path}\"")
sparkle_downloader_xpc_path = "#{sparkle_framework_path}/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
execute_command("#{codesign_command} \"#{sparkle_downloader_xpc_path}\"")
execute_command("#{codesign_command} \"#{sparkle_framework_path}\"")
end
Again, we need to call this function inside the with_temporary_keychain
# 3. Sign the Sparkle framework
puts '3. Signing the Sparkle framework...'
sign_sparkle_framework(exported_app_path, application_cert_name)
Create and sign the DMG
To simplify the process, I’ll create a basic DMG container for our app. If you’re interested in customization, you should definitely explore the methods used in the autosparkle project here.
def create_and_sign_dmg(exported_app_path, app_display_name, application_cert_name)
dmg_path = "./build/#{app_display_name}.dmg"
dmg_command = "hdiutil create -volname #{app_display_name} -srcfolder #{exported_app_path} -ov -format UDZO #{dmg_path}"
execute_command(dmg_command)
# sign the DMG
signing_dmg_command = "codesign --force --sign \"#{application_cert_name}\""
signing_dmg_command += " --timestamp --options runtime \"#{dmg_path}\""
execute_command(signing_dmg_command)
dmg_path
end
# 4. Create and sign the DMG
puts '4. Creating and signing the DMG...'
dmg_path = create_and_sign_dmg(exported_app_path, app_display_name, application_cert_name)
Notarize and staple the DMG
By utilizing our magical use_temporary_keychain
function, we’ve already set up everything necessary for the notarization step. We have a saved keychain profile in our custom keychain, which we will use in the script.
def notarize_and_staple_dmg(dmg_path)
notarize_command = "xcrun notarytool submit \"#{dmg_path}\""
notarize_command += " --keychain-profile \"#{Constants::NOTARIZE_KEYCHAIN_PROFILE}\""
notarize_command += " --keychain \"#{Constants::KEYCHAIN_PATH}\""
notarize_command += ' --wait'
execute_command(notarize_command)
execute_command("xcrun stapler staple \"#{dmg_path}\"")
end
# 5. Notarize and staple the DMG
puts '5. Notarizing and stapling the DMG...'
notarize_and_staple_dmg(dmg_path)
Sign the update
As mentioned earlier, Sparkle requires all new updates (our DMG) to be signed with EdDSA signatures. We used the generate_keys
tool to create our private and public keys for Sparkle. This tool stores the private key in the Keychain.
To retrieve the Sparkle private key from the Keychain, we’ll use the generate_keys
tool again, specifying a file location to which it will write the key.
bin/generate_keys -x ~/Desktop/sparkle_private_key.txt
Copy the key from the file and add it to our .env.autosparkle.local
file:
SPARKLE_PRIVATE_KEY=
Having set our sparkle private key as an environment variable, we’re prepared to sign the DMG. First, we’ll integrate the sign_update
tool into our project by creating a new directory named sparkle. Into this directory, we’ll place the sign_update
executable found in the previously mentioned downloaded bin
directory.
With that in place, we’re ready to implement our signing function:
def sign_update(pkg_path)
sign_update_path = File.join(__dir__, 'sparkle', 'sign_update')
sign_command = "echo \"#{ENV['SPARKLE_PRIVATE_KEY']}\" | "
sign_command += "#{sign_update_path} \"#{pkg_path}\" --ed-key-file -"
execute_command(sign_command)
end
This sign_update
tool generates an XML fragment that will be used later to create our appcast.xml
file.
# 6. Sign the update
puts '6. Signing the update...'
ed_signature_fragment = sign_update(dmg_path)
Generate the appcast.xml file
To generate the appcast.xml file, we will:
- Access our remote storage to retrieve the
appcast.xml
file. - If the
appcast.xml
file exists, this indicates that we need to append a new version item to the file. - If the
appcast.xml
file does not exist, this signifies that it is our first version release, and we will need to create a new file from scratch.
To manage these operations, I will utilize AWS S3 as a storage for reading and writing both the appcast.xml
and the DMG files. You can use whatever is convenient, like Azure Blob Storage, or Google Drive…
To effectively interact with AWS S3, we must first configure the necessary values in the environment variables.
AWS_S3_ACCESS_KEY=
AWS_S3_SECRET_ACCESS_KEY=
AWS_S3_REGION=
AWS_S3_BUCKET_NAME=
Also, we need to include the AWS S3 gem in our project, so let’s add it in our Gemfile:
gem 'aws-sdk-s3'
To make things cleaner, I’ll create a separate class to handle the reading and writing of the S3. In a new file called storage.rb
let’s add our AwsS3Storage
class:
# frozen_string_literal: true
require 'aws-sdk-s3'
# AwsS3Storage class to handle the storage of the files in the AWS S3 bucket
class AwsS3Storage
def initialize
variables = {
access_key: ENV['AWS_S3_ACCESS_KEY'],
secret_access_key: ENV['AWS_S3_SECRET_ACCESS_KEY'],
region: ENV['AWS_S3_REGION'],
bucket_name: ENV['AWS_S3_BUCKET_NAME']
}
credentials = Aws::Credentials.new(variables[:access_key], variables[:secret_access_key])
Aws.config.update({
region: variables[:region],
credentials: credentials
})
s3 = Aws::S3::Resource.new
@bucket = s3.bucket(variables[:bucket_name])
end
def upload(dmg_path, appcast_path, app_display_name, marketing_version)
appcast_object = @bucket.object('appcast.xml')
appcast_object.upload_file(appcast_path)
destination_path = "#{marketing_version}/#{app_display_name}.dmg"
version_object = @bucket.object(destination_path)
version_object.upload_file(dmg_path)
rescue StandardError => e
raise "Failed to upload file: #{e.message}"
end
def deployed_appcast_xml
appcast_object = @bucket.object('appcast.xml')
appcast_object.get.body.read
rescue StandardError
nil
end
end
This class primarily serves two functions. The first, upload
, handles the uploading of our DMG file along with the updated appcast.xml
file. The second, deployed_appcast_xml
, retrieves the content of the existing appcast.xml
file from the storage.
Within the initialize
method, I configure the connection to our AWS S3 bucket.
Let’s create a new file called appcast.rb
, it will be responsible of generating a new appcast XML content:
require 'nokogiri'
def generate_appcast_xml(
ed_signature_fragment,
deployed_appcast_xml,
app_display_name,
marketing_version,
current_project_version
)
if deployed_appcast_xml
append_item_to_existing_appcast(
ed_signature_fragment,
deployed_appcast_xml,
app_display_name,
marketing_version,
current_project_version
)
else
<<~XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Changelog</title>
<description>Most recent changes with links to updates.</description>
<language>en</language>
#{generate_appcast_item(
ed_signature_fragment,
app_display_name,
marketing_version,
current_project_version
)}
</channel>
</rss>
XML
end
end
def append_item_to_existing_appcast(
ed_signature_fragment,
deployed_appcast_xml,
app_display_name,
marketing_version,
current_project_version
)
doc = Nokogiri::XML(deployed_appcast_xml)
new_item_xml = generate_appcast_item(ed_signature_fragment, app_display_name, marketing_version,
current_project_version)
new_item_doc = Nokogiri::XML(new_item_xml)
new_item = new_item_doc.root
channel = doc.at_xpath('//channel')
channel.add_child(new_item)
doc.to_xml
end
def self.generate_appcast_item(
ed_signature_fragment,
app_display_name,
marketing_version,
current_project_version
)
date = Time.now.strftime('%a %b %d %H:%M:%S %Z %Y')
pkg_url = "#{marketing_version}/#{app_display_name}.dmg"
<<~XML
<item>
<title>#{ENV['SPARKLE_UPDATE_TITLE']}</title>
<link>#{ENV['WEBSITE_URL']}</link>
<sparkle:version>#{current_project_version}</sparkle:version>
<sparkle:shortVersionString>#{marketing_version}</sparkle:shortVersionString>
<description>
<![CDATA[
#{ENV['SPARKLE_RELEASE_NOTES']}
]]>
</description>
<pubDate>#{date}</pubDate>
<enclosure url="#{pkg_url}" type="application/octet-stream" #{ed_signature_fragment} />
<sparkle:minimumSystemVersion>#{ENV['MINIMUM_MACOS_VERSION']}</sparkle:minimumSystemVersion>
</item>
XML
end
Nothing complicated here. We are creating an XML string based on certain values. It’s evident that we are utilizing more environment variables, so let’s incorporate them into our environment file.
SPARKLE_UPDATE_TITLE=New Update Whohoo!
SPARKLE_RELEASE_NOTES="<h1>What's New</h1><p>Here's what's new in this version:</p><ul><li>Added new feature</li><li>Fixed some bugs</li></ul>"
MINIMUM_MACOS_VERSION=14.0
SPARKLE_UPDATE_TITLE: The title of your new update
SPARKLE_RELEASE_NOTES: An HTML code (without the body and header) that describes what you have changed in your new version
MINIMUM_MACOS_VERSION: The minimum macOS operating system version required for this version of your app.
You can notice also in the above code that I am using a new gem called nokogiri
it’s a well-known gem in Ruby that facilitates reading and writing from/to an HTML/XML code.
Make sure to add it to your Gemfile
.
gem 'nokogiri'
Everything is set now, let’s call our new functions to generate the appcast.xml
file
# 7. Generate the appcast XML
puts '7. Generating the appcast XML...'
storage = AwsS3Storage.new
appcast_xml = generate_appcast_xml(
ed_signature_fragment,
storage.deployed_appcast_xml,
app_display_name,
marketing_version,
current_project_version
)
Upload the DMG and appcast.xml to the storage
We’re nearly done; all that remains is to invoke the upload
function from our custom storage, passing in dmg_path
and appcast_file_path
as arguments:
# 8. Upload the DMG and appcast XML to the storage
puts '8. Uploading the DMG and appcast XML to the storage...'
appcast_file_path = './build/appcast.xml'
appcast_file = File.new(appcast_file_path, 'w')
appcast_file.write(appcast_xml)
appcast_file.close
storage.upload(dmg_path, appcast_file_path, app_display_name, marketing_version)
Woohoo! 🥳🥳 You’ve just unlocked a treasure trove of extra time! Now you can focus on the really important stuff, like finally mastering the art of making the perfect sandwich or a coffee.
Enjoy your newfound freedom!
If you’re planning to integrate this script into a Continuous Deployment (CD) workflow rather than executing it locally, it’s crucial to create a new file named
.env.autosparkle.production
. Ensure that this file does not contain any secrets; these should be passed through the CD process instead. Additionally,.env.autosparkle.local
should be added to.gitignore
to prevent it from being accidentally pushed to the git repository.
The autosparkle Command-line tool
I’m excited to share that I’ve developed a comprehensive script to fully automate the update process, all within an open-source project named autosparkle. This tool not only allows for customization of your DMG but also automatically calculates app versions, eliminating any manual effort on your part. Additionally, Autosparkle seamlessly updates your Xcode build settings to incorporate new versions, ensuring the app display name and the minimum macOS version specified in Xcode are accurately reflected.
Exciting new features are on the horizon, including support for pkg
s with pre and post scripts, enhancing the distribution of updates by leveraging binary deltas. This means you’ll only need to download the changes, not the entire version, each time an update is released. We’re also planning to expand support to more storage options, providing you with flexibility in file management.
I eagerly anticipate seeing how you utilize autosparkle and look forward to your contributions to its growth!