Skip to content

Commit 5d27a6c

Browse files
committed
Add EdDSA support and sample macOS app for testing
1 parent 3679918 commit 5d27a6c

28 files changed

+786
-189
lines changed

lib/motion-sparkle-sandbox.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
require 'motion/project/appcast'
1313
require 'motion/project/project'
1414
require 'motion/project/rake_tasks'
15+
require 'motion/project/indent_string'
1516

1617
lib_dir_path = File.dirname(File.expand_path(__FILE__))
1718

lib/motion/project/appcast.rb

Lines changed: 131 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,134 @@
11
module Motion::Project
22
class Sparkle
3-
def create_release_notes
4-
if File.exist?(release_notes_template_path)
5-
File.open(release_notes_path.to_s, 'w') do |f|
6-
template = File.read(release_notes_template_path)
7-
f << ERB.new(template).result(binding)
8-
end
9-
App.info 'Create', "./#{release_notes_path}"
10-
else
11-
App.fail "Release notes template not found as expected at ./#{release_notes_template_path}"
3+
# Generate the appcast.
4+
# Note: We do not support the old DSA keys, only the newer EdDSA keys.
5+
# See https://sparkle-project.org/documentation/eddsa-migration
6+
def generate_appcast
7+
generate_appcast_app = "#{vendored_sparkle_path}/bin/generate_appcast"
8+
path = (project_path + archive_folder).realpath
9+
appcast_filename = (path + appcast.feed_filename)
10+
11+
args = []
12+
13+
FileUtils.mkdir_p(path) unless File.exist?(path)
14+
15+
App.info('Sparkle', "Generating appcast using `#{generate_appcast_app}`")
16+
puts "from files in `#{path}`...".indent(11)
17+
18+
if appcast.use_exported_private_key && File.exist?(private_key_path)
19+
# -s <private-EdDSA-key> The private EdDSA string (128 characters). If not
20+
# specified, the private EdDSA key will be read from
21+
# the Keychain instead.
22+
private_key = File.read(private_key_path)
23+
args << "-s=#{private_key}"
1224
end
13-
end
1425

15-
def create_appcast
16-
create_release_folder
17-
appcast_file = File.open("#{sparkle_release_path}/#{appcast.feed_filename}", 'w') do |f|
18-
xml_string = ''
19-
doc = REXML::Formatters::Pretty.new
20-
doc.write(appcast_xml, xml_string)
21-
f << "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
22-
f << xml_string
23-
f << "\n"
26+
# --download-url-prefix <url> A URL that will be used as prefix for the URL from
27+
# where updates will be downloaded.
28+
args << "--download-url-prefix=#{appcast.package_url}" if appcast.package_url.present?
29+
30+
# --release-notes-url-prefix <url> A URL that will be used as prefix for constructing
31+
# URLs for release notes.
32+
args << "--release-notes-url-prefix=#{appcast.notes_url}" if appcast.notes_url.present?
33+
34+
# --link <link> A URL to the application's website which Sparkle may
35+
# use for directing users to if they cannot download a
36+
# new update from within the application. This will be
37+
# used for new generated update items. By default, no
38+
# product link is used.
39+
40+
# --versions <versions> An optional comma delimited list of application
41+
# versions (specified by CFBundleVersion) to generate
42+
# new update items for. By default, new update items
43+
# are inferred from the available archives and are only
44+
# generated if they are in the latest 5 updates in the
45+
# appcast.
46+
47+
# --maximum-deltas <maximum-deltas>
48+
# The maximum number of delta items to create for the
49+
# latest update for each minimum required operating
50+
# system. (default: 5)
51+
52+
# --channel <channel-name>
53+
# The Sparkle channel name that will be used for
54+
# generating new updates. By default, no channel is
55+
# used. Old applications need to be using Sparkle 2 to
56+
# use this feature.
57+
58+
# --major-version <major-version>
59+
# The last major or minimum autoupdate sparkle:version
60+
# that will be used for generating new updates. By
61+
# default, no last major version is used.
62+
63+
# --phased-rollout-interval <phased-rollout-interval>
64+
# The phased rollout interval in seconds that will be
65+
# used for generating new updates. By default, no
66+
# phased rollout interval is used.
67+
68+
# --critical-update-version <critical-update-version>
69+
# The last critical update sparkle:version that will be
70+
# used for generating new updates. An empty string
71+
# argument will treat this update as critical coming
72+
# from any application version. By default, no last
73+
# critical update version is used. Old applications
74+
# need to be using Sparkle 2 to use this feature.
75+
76+
# --informational-update-versions <informational-update-versions>
77+
# A comma delimited list of application
78+
# sparkle:version's that will see newly generated
79+
# updates as being informational only. An empty string
80+
# argument will treat this update as informational
81+
# coming from any application version. By default,
82+
# updates are not informational only. --link must also
83+
# be provided. Old applications need to be using
84+
# Sparkle 2 to use this feature.
85+
86+
# -o <output-path> Path to filename for the generated appcast (allowed
87+
# when only one will be created).
88+
89+
# -f <private-dsa-key-file> Path to the private DSA key file. Only use this
90+
# option for transitioning to EdDSA from older updates.
91+
# Note: only for supporting a legacy app that used DSA keys. Check if the
92+
# default DSA key exists in `sparkle/config/dsa_priv.pem` and if it does,
93+
# add it to the command.
94+
if File.exist?(legacy_private_key_path)
95+
App.info 'Sparkle', "Also signing with legacy DSA key at #{legacy_private_key_path}"
96+
args << "-f=#{legacy_private_key_path}"
2497
end
25-
if appcast_file
26-
App.info 'Create', "./#{sparkle_release_path}/#{appcast.feed_filename}"
27-
else
28-
App.info 'Fail', "./#{sparkle_release_path}/#{appcast.feed_filename} not created"
98+
99+
args << "-o=#{appcast_filename}" if appcast_filename.present?
100+
101+
App.info 'Executing', [generate_appcast_app, *args, path.to_s].join(' ')
102+
103+
results, status = Open3.capture2e(generate_appcast_app, *args, path.to_s)
104+
105+
App.info('Sparkle', "Saved appcast to `#{appcast_filename}`") if status.success?
106+
puts results.indent(11)
107+
108+
if status.success?
109+
puts
110+
puts "SUFeedURL : #{feed_url}".indent(11)
111+
puts "SUPublicEDKey : #{public_EdDSA_key}".indent(11)
29112
end
30113
end
31114

32-
def appcast_xml
33-
rss = REXML::Element.new 'rss'
34-
rss.attributes['xmlns:atom'] = 'http://www.w3.org/2005/Atom'
35-
rss.attributes['xmlns:sparkle'] = 'http://www.andymatuschak.org/xml-namespaces/sparkle'
36-
rss.attributes['xmlns:version'] = '2.0'
37-
rss.attributes['xmlns:dc'] = 'http://purl.org/dc/elements/1.1/'
38-
channel = rss.add_element 'channel'
39-
channel.add_element('title').text = @config.name
40-
channel.add_element('description').text = "#{@config.name} updates"
41-
channel.add_element('link').text = @config.info_plist['SUFeedURL']
42-
channel.add_element('language').text = 'en'
43-
channel.add_element('pubDate').text = Time.now.strftime('%a, %d %b %Y %H:%M:%S %z')
44-
atom_link = channel.add_element('atom:link')
45-
atom_link.attributes['href'] = @config.info_plist['SUFeedURL']
46-
atom_link.attributes['rel'] = 'self'
47-
atom_link.attributes['type'] = 'application/rss+xml'
48-
item = channel.add_element 'item'
49-
item.add_element('title').text = "#{@config.name} #{@config.short_version}"
50-
item.add_element('pubDate').text = Time.now.strftime('%a, %d %b %Y %H:%M:%S %z')
51-
guid = item.add_element('guid')
52-
guid.text = "#{@config.name}-#{@config.short_version}"
53-
guid.attributes['isPermaLink'] = false
54-
item.add_element('sparkle:releaseNotesLink').text = appcast.notes_url.to_s
55-
enclosure = item.add_element('enclosure')
56-
enclosure.attributes['url'] = appcast.package_url.to_s
57-
enclosure.attributes['length'] = @package_size.to_s
58-
enclosure.attributes['type'] = 'application/octet-stream'
59-
enclosure.attributes['sparkle:version'] = @config.version
60-
enclosure.attributes['sparkle:shortVersionString'] = @config.short_version
61-
enclosure.attributes['sparkle:dsaSignature'] = @package_signature
62-
rss
115+
def generate_appcast_help
116+
generate_appcast_app = "#{vendored_sparkle_path}/bin/generate_appcast"
117+
results, _status = Open3.capture2e(generate_appcast_app, '--help')
118+
puts results
119+
end
120+
121+
def create_release_notes
122+
App.fail "Release notes template not found as expected at ./#{release_notes_template_path}" unless File.exist?(release_notes_template_path)
123+
124+
create_release_folder
125+
126+
File.open(release_notes_path.to_s, 'w') do |f|
127+
template = File.read(release_notes_template_path)
128+
f << ERB.new(template).result(binding)
129+
end
130+
131+
App.info 'Create', "./#{release_notes_path}"
63132
end
64133

65134
def release_notes_template_path
@@ -71,7 +140,7 @@ def release_notes_content_path
71140
end
72141

73142
def release_notes_path
74-
sparkle_release_path + appcast.notes_filename.to_s
143+
sparkle_release_path + (appcast.notes_filename || "#{app_name}.#{@config.short_version}.html")
75144
end
76145

77146
def release_notes_content
@@ -94,29 +163,31 @@ class Appcast
94163
:notes_filename,
95164
:package_base_url,
96165
:package_filename,
97-
:archive_folder
166+
:archive_folder,
167+
:use_exported_private_key
98168

99169
def initialize
100170
@feed_base_url = nil
101171
@feed_filename = 'releases.xml'
102172
@notes_base_url = nil
103-
@notes_filename = 'release_notes.html'
173+
@notes_filename = nil
104174
@package_base_url = nil
105175
@package_filename = nil
106176
@base_url = nil
107177
@archive_folder = nil
178+
@use_exported_private_key = false
108179
end
109180

110181
def feed_url
111-
"#{feed_base_url || base_url}/#{feed_filename}"
182+
"#{feed_base_url || base_url}#{feed_filename}"
112183
end
113184

114185
def notes_url
115-
"#{notes_base_url || base_url}/#{notes_filename}"
186+
notes_base_url || base_url
116187
end
117188

118189
def package_url
119-
"#{package_base_url || base_url}/#{package_filename}"
190+
package_base_url || base_url
120191
end
121192
end
122193
end

lib/motion/project/indent_string.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# https://makandracards.com/makandra/6087-ruby-indent-a-string
2+
String.class_eval do
3+
def indent(count, char = ' ', skip_first_line: false)
4+
gsub(/([^\n]*)(\n|$)/) do |match|
5+
last_iteration = ($1 == "" && $2 == "")
6+
line = ""
7+
line << (char * count) unless last_iteration || skip_first_line
8+
line << $1
9+
line << $2
10+
11+
skip_first_line = false
12+
13+
line
14+
end
15+
end
16+
end

lib/motion/project/package.rb

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ def package
1313
App.info 'Size', @package_size.to_s
1414

1515
sign_package
16-
create_appcast
1716
create_release_notes
17+
1818
`open #{sparkle_release_path}`
1919
end
2020

2121
def create_zip_file
2222
App.fail 'You need to build your app with the Release target to use Sparkle' unless File.exist?(app_bundle_path)
2323

24+
App.info 'Create', "./#{sparkle_release_path}/#{zip_file}"
25+
2426
if File.exist?("#{sparkle_release_path}/#{zip_file}")
2527
App.fail "Release already exists at ./#{sparkle_release_path}/#{zip_file} (remove it manually with `rake sparkle:clean`)"
2628
end
@@ -31,18 +33,24 @@ def create_zip_file
3133

3234
FileUtils.mv "#{app_release_path}/#{zip_file}", "./#{sparkle_release_path}/"
3335

34-
App.info 'Create', "./#{sparkle_release_path}/#{zip_file}"
35-
3636
@package_file = zip_file
3737
@package_size = File.size "./#{sparkle_release_path}/#{zip_file}"
3838
end
3939

4040
def sign_package
4141
package = "./#{sparkle_release_path}/#{zip_file}"
42-
@package_signature = `#{openssl} dgst -sha1 -binary < "#{package}" | #{openssl} dgst -dss1 -sign "#{private_key_path}" | #{openssl} enc -base64`
43-
@package_signature = @package_signature.strip
42+
sign_update_app = "#{vendored_sparkle_path}/bin/sign_update"
43+
args = []
44+
45+
if appcast.use_exported_private_key && File.exist?(private_key_path)
46+
# -s <private-key> The private EdDSA (ed25519) key
47+
private_key = File.read(private_key_path)
48+
args << "-s=#{private_key}"
49+
end
50+
51+
results, _status = Open3.capture2e(sign_update_app, *args, package)
4452

45-
App.info 'Signature', "\"#{@package_signature}\""
53+
App.info 'Signature', results
4654
end
4755
end
4856
end

lib/motion/project/rake_tasks.rb

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
# Rake tasks
1+
# Sparkle specific rake tasks
22
namespace :sparkle do
3-
task :install do
4-
sparkle = App.config.sparkle
5-
sparkle.install
3+
desc 'Sparkle Help'
4+
task :help do
5+
puts <<~HELP
6+
During initial Sparkle setup, run these rake tasks:
7+
8+
1. `rake setup_certificates`
9+
2. `rake setup`
10+
11+
Then after running `rake build:release`, you can run
12+
`rake sparkle:package`
13+
HELP
614
end
715

816
desc 'Setup Sparkle configuration'
@@ -11,7 +19,7 @@
1119
sparkle.setup
1220
end
1321

14-
desc 'Create a ZIP file with you application .app release build'
22+
desc 'Create a ZIP file with your application .app release build'
1523
task :package do
1624
App.config_without_setup.build_mode = :release
1725
sparkle = App.config.sparkle
@@ -23,36 +31,27 @@
2331
sparkle.generate_keys
2432
end
2533

26-
desc 'Sign the ZIP file with appropriate certificates'
34+
desc 'Generate the EdDSA signature for a package'
2735
task :sign do
2836
App.config_without_setup.build_mode = :release
2937
sparkle = App.config.sparkle
3038
sparkle.sign_package
3139
end
3240

33-
task :recreate_public_key do
34-
sparkle = App.config.sparkle
35-
sparkle.generate_public_key
36-
end
37-
38-
task :copy_release_notes_templates do
39-
App.config_without_setup.build_mode = :release
40-
sparkle = App.config.sparkle
41-
sparkle.copy_templates(force = true)
42-
end
43-
44-
desc 'Generate the appcast xml feed'
45-
task :feed do
41+
desc "Generate the appcast xml feed using Sparkle's `generate_appcast`"
42+
task :generate_appcast do
4643
App.config_without_setup.build_mode = :release
4744
sparkle = App.config.sparkle
48-
sparkle.create_appcast
45+
sparkle.generate_appcast
4946
end
5047

51-
desc "Generate the appcast using Sparkle's `generate_appcast`"
52-
task :generate_appcast do
53-
App.config_without_setup.build_mode = :release
54-
sparkle = App.config.sparkle
55-
results = `#{sparkle.vendored_sparkle_path}/generate_appcast -f "#{sparkle.private_key_path}" "#{sparkle.archive_folder}"`
48+
namespace :generate_appcast do
49+
desc "Show help for Sparkle's `generate_appcast`"
50+
task :help do
51+
App.config_without_setup.build_mode = :release
52+
sparkle = App.config.sparkle
53+
sparkle.generate_appcast_help
54+
end
5655
end
5756

5857
desc 'Update the release notes of this build'
@@ -62,10 +61,6 @@
6261
sparkle.create_release_notes
6362
end
6463

65-
# desc "Upload to configured location"
66-
# task :upload do
67-
# end
68-
6964
desc 'Clean the Sparkle release folder'
7065
task :clean do
7166
dir = Motion::Project::Sparkle::RELEASE_PATH

0 commit comments

Comments
 (0)