Sam Ford b4cb47815f
Sparkle: Pass all items into strategy block
It's sometimes necessary to work with all the items in a Sparkle feed
to be able to correctly identify the newest version but livecheck's
`Sparkle` strategy only passes the `item` it views as newest into a
`strategy` block.

This updates the `Sparkle` strategy to optionally pass all items into
a `strategy` block, so we can manipulate them (e.g., filtering,
sorting). This is enabled by naming the first argument of the
strategy block `items` instead of `item`. `Sparkle` `strategy` blocks
where the first argument is `item` will continue to work as expected.

This necessarily updates `#item_from_content` (now
`items_from_content`) to return all items. I've decided to move the
sorting out of `#items_from_content`, so it simply returns the items
in the order they appear. If there is ever an exceptional situation
where we need the original order, this will technically allow for it.

The sorting has instead been moved into the `#versions_from_content`
method, to maintain the existing behavior. I thought about passing
the items into the `strategy` block in their original order but it
feels like sorting by default is the better approach for now (partly
from the perspective of maintaining existing behavior) and we can
always revisit this in the future if a cask ever requires the
original order.

Lastly, this expands the `Sparkle` tests to increase coverage. The
only untested parts are `#find_versions` (which currently
requires a network request) and a couple safeguard `raise` calls
when there's a `REXML::UndefinedNamespaceException` (which shouldn't
be encountered unless something is broken).
2022-06-01 18:29:37 -04:00

213 lines
6.9 KiB
Ruby

# typed: true
# frozen_string_literal: true
require "bundle_version"
module Homebrew
module Livecheck
module Strategy
# The {Sparkle} strategy fetches content at a URL and parses
# it as a Sparkle appcast in XML format.
#
# This strategy is not applied automatically and it's necessary to use
# `strategy :sparkle` in a `livecheck` block to apply it.
#
# @api private
class Sparkle
extend T::Sig
# A priority of zero causes livecheck to skip the strategy. We do this
# for {Sparkle} so we can selectively apply it when appropriate.
PRIORITY = 0
# The `Regexp` used to determine if the strategy applies to the URL.
URL_MATCH_REGEX = %r{^https?://}i.freeze
# Whether the strategy can be applied to the provided URL.
#
# @param url [String] the URL to match against
# @return [Boolean]
sig { params(url: String).returns(T::Boolean) }
def self.match?(url)
URL_MATCH_REGEX.match?(url)
end
# @api private
Item = Struct.new(
# @api public
:title,
# @api private
:pub_date,
# @api public
:url,
# @api private
:bundle_version,
keyword_init: true,
) do
extend T::Sig
extend Forwardable
# @api public
delegate version: :bundle_version
# @api public
delegate short_version: :bundle_version
# @api public
delegate nice_version: :bundle_version
end
# Identify version information from a Sparkle appcast.
#
# @param content [String] the text of the Sparkle appcast
# @return [Item, nil]
sig { params(content: String).returns(T::Array[Item]) }
def self.items_from_content(content)
require "rexml/document"
parsing_tries = 0
xml = begin
REXML::Document.new(content)
rescue REXML::UndefinedNamespaceException => e
undefined_prefix = e.to_s[/Undefined prefix ([^ ]+) found/i, 1]
raise if undefined_prefix.blank?
# Only retry parsing once after removing prefix from content
parsing_tries += 1
raise if parsing_tries > 1
# When an XML document contains a prefix without a corresponding
# namespace, it's necessary to remove the prefix from the content
# to be able to successfully parse it using REXML
content = content.gsub(%r{(</?| )#{Regexp.escape(undefined_prefix)}:}, '\1')
retry
end
# Remove prefixes, so we can reliably identify elements and attributes
xml.root&.each_recursive do |node|
node.prefix = ""
node.attributes.each_attribute do |attribute|
attribute.prefix = ""
end
end
xml.get_elements("//rss//channel//item").map do |item|
enclosure = item.elements["enclosure"]
if enclosure
url = enclosure["url"]
short_version = enclosure["shortVersionString"]
version = enclosure["version"]
os = enclosure["os"]
end
url ||= item.elements["link"]&.text
short_version ||= item.elements["shortVersionString"]&.text&.strip
version ||= item.elements["version"]&.text&.strip
title = item.elements["title"]&.text&.strip
pub_date = item.elements["pubDate"]&.text&.strip&.presence&.yield_self do |date_string|
Time.parse(date_string)
rescue ArgumentError
# Omit unparseable strings (e.g. non-English dates)
nil
end
if (match = title&.match(/(\d+(?:\.\d+)*)\s*(\([^)]+\))?\Z/))
short_version ||= match[1]
version ||= match[2]
end
bundle_version = BundleVersion.new(short_version, version) if short_version || version
next if os && os != "osx"
if (minimum_system_version = item.elements["minimumSystemVersion"]&.text&.gsub(/\A\D+|\D+\z/, ""))
macos_minimum_system_version = begin
OS::Mac::Version.new(minimum_system_version).strip_patch
rescue MacOSVersionError
nil
end
next if macos_minimum_system_version&.prerelease?
end
data = {
title: title,
pub_date: pub_date,
url: url,
bundle_version: bundle_version,
}.compact
next if data.empty?
# Set a default `pub_date` (for sorting) if one isn't provided
data[:pub_date] ||= Time.new(0)
Item.new(**data)
end.compact
end
# Identify versions from content
#
# @param content [String] the content to pull version information from
# @return [Array]
sig {
params(
content: String,
regex: T.nilable(Regexp),
block: T.untyped,
).returns(T::Array[String])
}
def self.versions_from_content(content, regex = nil, &block)
items = items_from_content(content).sort_by { |item| [item.pub_date, item.bundle_version] }.reverse
return [] if items.blank?
item = items.first
if block
block_return_value = case block.parameters[0]
when [:opt, :item], [:rest]
regex.present? ? yield(item, regex) : yield(item)
when [:opt, :items]
regex.present? ? yield(items, regex) : yield(items)
else
raise "First argument of Sparkle `strategy` block must be `item` or `items`"
end
return Strategy.handle_block_return(block_return_value)
end
version = T.must(item).bundle_version&.nice_version
version.present? ? [version] : []
end
# Checks the content at the URL for new versions.
sig {
params(
url: String,
regex: T.nilable(Regexp),
_unused: T.nilable(T::Hash[Symbol, T.untyped]),
block: T.untyped,
).returns(T::Hash[Symbol, T.untyped])
}
def self.find_versions(url:, regex: nil, **_unused, &block)
if regex.present? && block.blank?
raise ArgumentError, "#{T.must(name).demodulize} only supports a regex when using a `strategy` block"
end
match_data = { matches: {}, url: url }
match_data.merge!(Strategy.page_content(url))
content = match_data.delete(:content)
versions_from_content(content, regex, &block).each do |version_text|
match_data[:matches][version_text] = Version.new(version_text)
end
match_data
end
end
end
end
end