lib/motion/project/appcast.rb in motion-sparkle-sandbox-2.0.0 vs lib/motion/project/appcast.rb in motion-sparkle-sandbox-2.0.1

- old
+ new

@@ -1,127 +1,200 @@ -module Motion::Project - class Sparkle +# frozen_string_literal: true - def create_release_notes - if File.exist?(release_notes_template_path) - File.open("#{release_notes_path}", "w") do |f| - template = File.read(release_notes_template_path) - f << ERB.new(template).result(binding) +module Motion + module Project + class Sparkle + # Generate the appcast. + # Note: We do not support the old DSA keys, only the newer EdDSA keys. + # See https://sparkle-project.org/documentation/eddsa-migration + # rubocop:disable Metrics/CyclomaticComplexity + def generate_appcast + generate_appcast_app = "#{vendored_sparkle_path}/bin/generate_appcast" + path = (project_path + archive_folder).realpath + appcast_filename = (path + appcast.feed_filename) + + args = [] + + FileUtils.mkdir_p(path) unless File.exist?(path) + + App.info('Sparkle', "Generating appcast using `#{generate_appcast_app}`") + puts "from files in `#{path}`...".indent(11) + + if appcast.use_exported_private_key && File.exist?(private_key_path) + # -s <private-EdDSA-key> The private EdDSA string (128 characters). If not + # specified, the private EdDSA key will be read from + # the Keychain instead. + private_key = File.read(private_key_path) + args << "-s=#{private_key}" end - App.info 'Create', "./#{release_notes_path}" - else - App.fail "Release notes template not found as expected at ./#{release_notes_template_path}" - end - end - def create_appcast - create_release_folder - appcast_file = File.open("#{sparkle_release_path}/#{appcast.feed_filename}", 'w') do |f| - xml_string = '' - doc = REXML::Formatters::Pretty.new - doc.write(appcast_xml, xml_string) - f << "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" - f << xml_string - f << "\n" - end - if appcast_file - App.info "Create", "./#{sparkle_release_path}/#{appcast.feed_filename}" - else - App.info "Fail", "./#{sparkle_release_path}/#{appcast.feed_filename} not created" - end - end + # --download-url-prefix <url> A URL that will be used as prefix for the URL from + # where updates will be downloaded. + args << "--download-url-prefix=#{appcast.package_base_url}" if appcast.package_base_url.present? - def appcast_xml - rss = REXML::Element.new 'rss' - rss.attributes['xmlns:atom'] = "http://www.w3.org/2005/Atom" - rss.attributes['xmlns:sparkle'] = "http://www.andymatuschak.org/xml-namespaces/sparkle" - rss.attributes['xmlns:version'] = "2.0" - rss.attributes['xmlns:dc'] = "http://purl.org/dc/elements/1.1/" - channel = rss.add_element 'channel' - channel.add_element('title').text = @config.name - channel.add_element('description').text = "#{@config.name} updates" - channel.add_element('link').text = @config.info_plist["SUFeedURL"] - channel.add_element('language').text = 'en' - channel.add_element('pubDate').text = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z") - atom_link = channel.add_element('atom:link') - atom_link.attributes['href'] = @config.info_plist["SUFeedURL"] - atom_link.attributes['rel'] = 'self' - atom_link.attributes['type'] = "application/rss+xml" - item = channel.add_element 'item' - item.add_element('title').text = "#{@config.name} #{@config.short_version}" - item.add_element('pubDate').text = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z") - guid = item.add_element('guid') - guid.text = "#{@config.name}-#{@config.short_version}" - guid.attributes['isPermaLink'] = false - item.add_element('sparkle:releaseNotesLink').text = "#{appcast.notes_url}" - enclosure = item.add_element('enclosure') - enclosure.attributes['url'] = "#{appcast.package_url}" - enclosure.attributes['length'] = "#{@package_size}" - enclosure.attributes['type'] = "application/octet-stream" - enclosure.attributes['sparkle:version'] = @config.version - enclosure.attributes['sparkle:shortVersionString'] = @config.short_version - enclosure.attributes['sparkle:dsaSignature'] = @package_signature - rss - end + # --release-notes-url-prefix <url> A URL that will be used as prefix for constructing + # URLs for release notes. + args << "--release-notes-url-prefix=#{appcast.notes_base_url}" if appcast.notes_base_url.present? - def release_notes_template_path - sparkle_config_path + "release_notes.template.erb" - end + # --link <link> A URL to the application's website which Sparkle may + # use for directing users to if they cannot download a + # new update from within the application. This will be + # used for new generated update items. By default, no + # product link is used. - def release_notes_content_path - sparkle_config_path + "release_notes.content.html" - end + # --versions <versions> An optional comma delimited list of application + # versions (specified by CFBundleVersion) to generate + # new update items for. By default, new update items + # are inferred from the available archives and are only + # generated if they are in the latest 5 updates in the + # appcast. - def release_notes_path - sparkle_release_path + appcast.notes_filename.to_s - end + # --maximum-deltas <maximum-deltas> + # The maximum number of delta items to create for the + # latest update for each minimum required operating + # system. (default: 5) - def release_notes_content - if File.exist?(release_notes_content_path) - File.read(release_notes_content_path) - else - App.fail "Missing #{release_notes_content_path}" + # --channel <channel-name> + # The Sparkle channel name that will be used for + # generating new updates. By default, no channel is + # used. Old applications need to be using Sparkle 2 to + # use this feature. + + # --major-version <major-version> + # The last major or minimum autoupdate sparkle:version + # that will be used for generating new updates. By + # default, no last major version is used. + + # --phased-rollout-interval <phased-rollout-interval> + # The phased rollout interval in seconds that will be + # used for generating new updates. By default, no + # phased rollout interval is used. + + # --critical-update-version <critical-update-version> + # The last critical update sparkle:version that will be + # used for generating new updates. An empty string + # argument will treat this update as critical coming + # from any application version. By default, no last + # critical update version is used. Old applications + # need to be using Sparkle 2 to use this feature. + + # --informational-update-versions <informational-update-versions> + # A comma delimited list of application + # sparkle:version's that will see newly generated + # updates as being informational only. An empty string + # argument will treat this update as informational + # coming from any application version. By default, + # updates are not informational only. --link must also + # be provided. Old applications need to be using + # Sparkle 2 to use this feature. + + # -o <output-path> Path to filename for the generated appcast (allowed + # when only one will be created). + + # -f <private-dsa-key-file> Path to the private DSA key file. Only use this + # option for transitioning to EdDSA from older updates. + # Note: only for supporting a legacy app that used DSA keys. Check if the + # default DSA key exists in `sparkle/config/dsa_priv.pem` and if it does, + # add it to the command. + if File.exist?(legacy_private_key_path) + App.info 'Sparkle', "Also signing with legacy DSA key at #{legacy_private_key_path}" + args << "-f=#{legacy_private_key_path}" + end + + args << "-o=#{appcast_filename}" if appcast_filename.present? + + App.info 'Executing', [generate_appcast_app, *args, path.to_s].join(' ') + + results, status = Open3.capture2e(generate_appcast_app, *args, path.to_s) + + App.info('Sparkle', "Saved appcast to `#{appcast_filename}`") if status.success? + puts results.indent(11) + + return unless status.success? + + puts + puts "SUFeedURL : #{feed_url}".indent(11) + puts "SUPublicEDKey : #{public_EdDSA_key}".indent(11) end - end + # rubocop:enable Metrics/CyclomaticComplexity - def release_notes_html - release_notes_content - end + def generate_appcast_help + generate_appcast_app = "#{vendored_sparkle_path}/bin/generate_appcast" + results, _status = Open3.capture2e(generate_appcast_app, '--help') + puts results + end + def create_release_notes + App.fail "Release notes template not found as expected at ./#{release_notes_template_path}" unless File.exist?(release_notes_template_path) - class Appcast - attr_accessor :base_url, - :feed_base_url, - :feed_filename, - :notes_base_url, - :notes_filename, - :package_base_url, - :package_filename, - :archive_folder + create_release_folder - def initialize - @feed_base_url = nil - @feed_filename = 'releases.xml' - @notes_base_url = nil - @notes_filename = 'release_notes.html' - @package_base_url = nil - @package_filename = nil - @base_url = nil - @archive_folder = nil + File.open(release_notes_path.to_s, 'w') do |f| + template = File.read(release_notes_template_path) + f << ERB.new(template).result(binding) + end + + App.info 'Create', "./#{release_notes_path}" end - def feed_url - "#{feed_base_url || base_url}/#{feed_filename}" + def release_notes_template_path + sparkle_config_path.join('release_notes.template.erb') end - def notes_url - "#{notes_base_url || base_url}/#{notes_filename}" + def release_notes_content_path + sparkle_config_path.join('release_notes.content.html') end - def package_url - "#{package_base_url || base_url}/#{package_filename}" + def release_notes_path + sparkle_release_path + (appcast.notes_filename || "#{app_name}.#{@config.short_version}.html") end - end + def release_notes_content + if File.exist?(release_notes_content_path) + File.read(release_notes_content_path) + else + App.fail "Missing #{release_notes_content_path}" + end + end + def release_notes_html + release_notes_content + end + + class Appcast + attr_accessor :base_url, + :feed_base_url, + :feed_filename, + :notes_filename, + :package_filename, + :archive_folder, + :use_exported_private_key + attr_writer :notes_base_url, + :package_base_url + + def initialize + @feed_base_url = nil + @feed_filename = 'releases.xml' + @notes_base_url = nil + @notes_filename = nil + @package_base_url = nil + @package_filename = nil + @base_url = nil + @archive_folder = nil + @use_exported_private_key = false + end + + def feed_url + "#{feed_base_url || base_url}#{feed_filename}" + end + + def notes_base_url + @notes_base_url || base_url + end + + def package_base_url + @package_base_url || base_url + end + end + end end end