
livecheck currently doesn't support `POST` requests but it wasn't entirely clear how best to handle that. I initially approached it as a `Post` strategy but unfortunately that would have required us to handle response body parsing (e.g., JSON, XML, etc.) in some fashion. We could borrow some of the logic from related strategies but we would still be stuck having to update `Post` whenever we add a strategy for a new format. Instead, this implements `POST` support by borrowing ideas from the `using: :post` and `data` `url` options found in formulae. This uses a `post_form` option to handle form data and `post_json` to handle JSON data, encoding the hash argument for each into the appropriate format. The presence of either option means that curl will use a `POST` request. With this approach, we can make a `POST` request using any strategy that calls `Strategy::page_headers` or `::page_content` (directly or indirectly) and everything else works the same as usual. The only change needed in related strategies was to pass the options through to the `Strategy` methods. For example, if we need to parse a JSON response from a `POST` request, we add a `post_data` or `post_json` hash to the `livecheck` block `url` and use `strategy :json` with a `strategy` block. This leans on existing patterns that we're already familiar with and shouldn't require any notable maintenance burden when adding new strategies, so it seems like a better approach than a `Post` strategy.
253 lines
8.2 KiB
Ruby
253 lines
8.2 KiB
Ruby
# typed: strict
|
|
# 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.
|
|
class Sparkle
|
|
# 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
|
|
|
|
# Common `os` values used in appcasts to refer to macOS.
|
|
APPCAST_MACOS_STRINGS = T.let(["macos", "osx"].freeze, T::Array[String])
|
|
|
|
# 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
|
|
|
|
Item = Struct.new(
|
|
# @api public
|
|
:title,
|
|
# @api public
|
|
:link,
|
|
# @api public
|
|
:channel,
|
|
# @api public
|
|
:release_notes_link,
|
|
# @api public
|
|
:pub_date,
|
|
# @api public
|
|
:os,
|
|
# @api public
|
|
:url,
|
|
# @api private
|
|
:bundle_version,
|
|
# @api public
|
|
:minimum_system_version,
|
|
keyword_init: true,
|
|
) do
|
|
extend Forwardable
|
|
|
|
# @!attribute [r] version
|
|
# @api public
|
|
delegate version: :bundle_version
|
|
|
|
# @!attribute [r] short_version
|
|
# @api public
|
|
delegate short_version: :bundle_version
|
|
|
|
# @!attribute [r] nice_version
|
|
# @api public
|
|
delegate nice_version: :bundle_version
|
|
end
|
|
|
|
# Identifies 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"
|
|
xml = Xml.parse_xml(content)
|
|
return [] if xml.blank?
|
|
|
|
# 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").filter_map do |item|
|
|
enclosure = item.elements["enclosure"]
|
|
|
|
if enclosure
|
|
url = enclosure["url"].presence
|
|
short_version = enclosure["shortVersionString"].presence
|
|
version = enclosure["version"].presence
|
|
os = enclosure["os"].presence
|
|
end
|
|
|
|
title = Xml.element_text(item, "title")
|
|
link = Xml.element_text(item, "link")
|
|
url ||= link
|
|
channel = Xml.element_text(item, "channel")
|
|
release_notes_link = Xml.element_text(item, "releaseNotesLink")
|
|
short_version ||= Xml.element_text(item, "shortVersionString")
|
|
version ||= Xml.element_text(item, "version")
|
|
|
|
minimum_system_version_text =
|
|
Xml.element_text(item, "minimumSystemVersion")&.gsub(/\A\D+|\D+\z/, "")
|
|
if minimum_system_version_text.present?
|
|
minimum_system_version = begin
|
|
MacOSVersion.new(minimum_system_version_text)
|
|
rescue MacOSVersion::Error
|
|
nil
|
|
end
|
|
end
|
|
|
|
pub_date = Xml.element_text(item, "pubDate")&.then do |date_string|
|
|
Time.parse(date_string)
|
|
rescue ArgumentError
|
|
# Omit unparsable 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
|
|
|
|
data = {
|
|
title:,
|
|
link:,
|
|
channel:,
|
|
release_notes_link:,
|
|
pub_date:,
|
|
os:,
|
|
url:,
|
|
bundle_version:,
|
|
minimum_system_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
|
|
end
|
|
|
|
# Filters out items that aren't suitable for Homebrew.
|
|
#
|
|
# @param items [Array] appcast items
|
|
# @return [Array]
|
|
sig { params(items: T::Array[Item]).returns(T::Array[Item]) }
|
|
def self.filter_items(items)
|
|
items.select do |item|
|
|
# Omit items with an explicit `os` value that isn't macOS
|
|
next false if item.os && APPCAST_MACOS_STRINGS.none?(item.os)
|
|
|
|
# Omit items for prerelease macOS versions
|
|
next false if item.minimum_system_version&.strip_patch&.prerelease?
|
|
|
|
true
|
|
end.compact
|
|
end
|
|
|
|
# Sorts items from newest to oldest.
|
|
#
|
|
# @param items [Array] appcast items
|
|
# @return [Array]
|
|
sig { params(items: T::Array[Item]).returns(T::Array[Item]) }
|
|
def self.sort_items(items)
|
|
items.sort_by { |item| [item.pub_date, item.bundle_version] }
|
|
.reverse
|
|
end
|
|
|
|
# Uses `#items_from_content` to identify versions from the Sparkle
|
|
# appcast content or, if a block is provided, passes the content to
|
|
# the block to handle matching.
|
|
#
|
|
# @param content [String] the content to check
|
|
# @param regex [Regexp, nil] a regex for use in a strategy block
|
|
# @return [Array]
|
|
sig {
|
|
params(
|
|
content: String,
|
|
regex: T.nilable(Regexp),
|
|
block: T.nilable(Proc),
|
|
).returns(T::Array[String])
|
|
}
|
|
def self.versions_from_content(content, regex = nil, &block)
|
|
items = sort_items(filter_items(items_from_content(content)))
|
|
return [] if items.blank?
|
|
|
|
item = items.first
|
|
|
|
if block
|
|
block_return_value = case block.parameters[0]
|
|
when [:opt, :item], [:rest], [:req]
|
|
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.
|
|
#
|
|
# @param url [String] the URL of the content to check
|
|
# @param regex [Regexp, nil] a regex for use in a strategy block
|
|
# @return [Hash]
|
|
sig {
|
|
params(
|
|
url: String,
|
|
regex: T.nilable(Regexp),
|
|
unused: T.untyped,
|
|
block: T.nilable(Proc),
|
|
).returns(T::Hash[Symbol, T.untyped])
|
|
}
|
|
def self.find_versions(url:, regex: nil, **unused, &block)
|
|
if regex.present? && block.blank?
|
|
raise ArgumentError,
|
|
"#{Utils.demodulize(T.must(name))} only supports a regex when using a `strategy` block"
|
|
end
|
|
|
|
match_data = { matches: {}, regex:, url: }
|
|
|
|
match_data.merge!(
|
|
Strategy.page_content(
|
|
url,
|
|
url_options: unused.fetch(:url_options, {}),
|
|
),
|
|
)
|
|
content = match_data.delete(:content)
|
|
return match_data if content.blank?
|
|
|
|
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
|