Files
brew/Library/Homebrew/cask/cask.rb
T
Mike McQuaid 233377e66a Treat cask OS support as explicit data
- Align casks with formulae: platform support comes from
  `depends_on :macos`/`:linux`/`macos:` data rather than generation
  heuristics guessing intent from `os` stanzas, Linux checksums or
  `on_linux` blocks.
- `Cask#to_hash_with_variations` now emits variations for every
  valid OS/arch tag whenever `on_system` blocks exist, matching
  `Formula#to_hash_with_variations`; the Linux-specific gate and its
  `sha256_set_for_linux?` and `on_linux_blocks_exist?` tracking are
  removed. macOS-only casks publish truthful Linux variations, e.g.
  a `null` `sha256`, instead of omitting them.
- `Cask::Installer` gains a first-class unsupported-system error:
  API-loaded casks with no activatable artifact for the running
  system fail with "This cask is not available on macOS/Linux."
  instead of installing nothing. Audited casks always declare an
  activatable artifact for the systems they support, so missing
  artifacts in API data mean the system is unsupported. Source
  loads keep working for unaudited casks, e.g. naked containers.
- A sweep of the full homebrew/cask generation pipeline (all casks,
  both Linux tags, including internal per-tag payloads) confirmed
  no cask needs new `depends_on` annotations and nothing regresses.
2026-08-05 16:23:33 +01:00

842 lines
28 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "bundle_version"
require "cask/cask_loader"
require "cask/config"
require "cask/dsl"
require "cask/metadata"
require "cask/tab"
require "utils/output"
require "api_hashable"
require "trust"
module Cask
# An instance of a cask.
class Cask
extend Forwardable
extend APIHashable
extend ::Utils::Output::Mixin
include Metadata
# The unique identifier for this {Cask}, used to refer to it in commands
# and tap paths.
# e.g. `firefox`
#
# @api public
sig { returns(String) }
attr_reader :token
# The configuration of this {Cask}.
#
# @api internal
sig { returns(Config) }
attr_reader :config
sig { returns(T.nilable(Pathname)) }
attr_reader :sourcefile_path
sig { returns(T.nilable(String)) }
attr_reader :source
sig { returns(Config) }
attr_reader :default_config
sig { returns(T.nilable(CaskLoader::ILoader)) }
attr_reader :loader
sig { returns(T.nilable(Pathname)) }
attr_accessor :download
sig { returns(T::Boolean) }
attr_accessor :allow_reassignment
sig { params(eval_all: T::Boolean).returns(T::Array[Cask]) }
def self.all(eval_all: false)
if !eval_all && !Homebrew::EnvConfig.tap_trust_configured?
raise ArgumentError,
"Cask::Cask#all cannot be used without `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \
"`HOMEBREW_NO_REQUIRE_TAP_TRUST=1`"
end
# Load core casks from tokens so they load from the API when the core cask is not tapped.
tokens_and_files = CoreCaskTap.instance.cask_tokens
tokens_and_files += Tap.reject(&:core_cask_tap?).flat_map(&:cask_files)
.then { |files| Homebrew::Trust.trusted_cask_files(files) }
tokens_and_files.filter_map do |token_or_file|
CaskLoader.load(token_or_file)
rescue CaskUnreadableError, CaskInvalidError => e
# Don't let one broken cask break commands. But do complain.
opoo e.message
nil
end
end
# This collides with Kernel#tap, complicating the type signature.
# Overload sigs are not supported by Sorbet, otherwise we would use:
# sig { params(blk: T.proc.params(arg0: Cask).void).returns(T.self_type) }
# sig { params(blk: NilClass).returns(T.nilable(Tap)) }
# Using a union type would require casts or type guards at call sites,
# so T.untyped is used as the return type instead.
sig { params(blk: T.nilable(T.proc.params(arg0: Cask).void)).returns(T.untyped) }
def tap(&blk)
return super if block_given? # Kernel#tap
@tap
end
sig {
params(
token: String,
sourcefile_path: T.nilable(Pathname),
source: T.nilable(String),
tap: T.nilable(Tap),
loaded_from_api: T::Boolean,
loaded_from_internal_api: T::Boolean,
api_source: T.nilable(T::Hash[String, T.untyped]),
config: T.nilable(Config),
allow_reassignment: T::Boolean,
loader: T.nilable(CaskLoader::ILoader),
block: T.nilable(T.proc.bind(DSL).void),
).void
}
def initialize(token, sourcefile_path: nil, source: nil, tap: nil, loaded_from_api: false,
loaded_from_internal_api: false, api_source: nil, config: nil, allow_reassignment: false,
loader: nil, &block)
@token = token
@sourcefile_path = sourcefile_path
@source = source
@tap = tap
@allow_reassignment = allow_reassignment
@loaded_from_api = loaded_from_api
@loaded_from_internal_api = loaded_from_internal_api
@api_source = api_source
@language_variations_available = T.let(false, T::Boolean)
@language_evaluator = T.let(nil, T.nilable(T.proc.params(languages: T::Array[String]).returns(T.nilable(String))))
@loader = loader
# Sorbet has trouble with bound procs assigned to instance variables:
# https://github.com/sorbet/sorbet/issues/6843
@block = T.let(block, T.untyped)
@default_config = T.let(config || Config.new, Config)
@config = T.let(
if config_path.exist?
Config.from_json(File.read(config_path), ignore_invalid_keys: true)
else
@default_config
end,
Config,
)
refresh
end
sig { returns(T::Boolean) }
def loaded_from_api? = @loaded_from_api
sig { returns(T::Boolean) }
def loaded_from_internal_api? = @loaded_from_internal_api
sig { returns(T.any(String, Pathname)) }
def reloadable_ref
return full_name if loaded_from_api?
sourcefile_path || raise("unexpected nil cask sourcefile_path")
end
sig { returns(T.nilable(T::Hash[String, T.untyped])) }
attr_reader :api_source
# An old name for the cask.
sig { returns(T::Array[String]) }
def old_tokens
@old_tokens ||= T.let(
if (t = tap)
Tap.tap_migration_oldnames(t, token) +
t.cask_reverse_renames.fetch(token, [])
else
[]
end,
T.nilable(T::Array[String]),
)
end
sig { params(config: Config).void }
def config=(config)
@config = config
refresh
end
sig { void }
def refresh
@dsl = T.let(DSL.new(self), T.nilable(DSL))
return unless @block
dsl!.instance_eval(&@block)
dsl!.language_eval
rescue NoMethodError => e
raise CaskInvalidError.new(token, e.message), e.backtrace
end
# Refresh the cask as evaluated on `tag` and yield. Returns `nil` instead of
# raising when the cask has `on_system` blocks that omit the tag.
sig {
type_parameters(:U)
.params(tag: ::Utils::Bottles::Tag, _block: T.proc.returns(T.type_parameter(:U)))
.returns(T.nilable(T.type_parameter(:U)))
}
def refresh_for_tag(tag, &_block)
Homebrew::SimulateSystem.with(os: tag.system, arch: tag.arch) do
refresh
yield
end
rescue CaskInvalidError, CaskUnreadableError
raise unless on_system_blocks_exist?
nil
end
def_delegators :@dsl, *(::Cask::DSL::DSL_METHODS - [:language])
sig {
params(
args: String,
default: T::Boolean,
block: T.nilable(T.proc.returns(String)),
).returns(T.nilable(String))
}
def language(*args, default: false, &block)
return @language_evaluator.call(config.languages) if args.empty? && block.nil? && @language_evaluator
dsl!.language(*args, default:, &block)
end
sig { returns(DSL::Caveats) }
def caveats_object = dsl!.caveats_object
sig { params(caskroom_path: Pathname).returns(T::Array[[String, String]]) }
def timestamped_versions(caskroom_path: self.caskroom_path)
pattern = metadata_timestamped_path(version: "*", timestamp: "*", caskroom_path:).to_s
relative_paths = Pathname.glob(pattern)
.map { |p| p.relative_path_from(p.parent.parent) }
# Sorbet is unaware that Pathname is sortable: https://github.com/sorbet/sorbet/issues/6844
T.unsafe(relative_paths).sort_by(&:basename) # sort by timestamp
.map { |p| p.split.map(&:to_s) }
end
# The fully-qualified token of this {Cask}.
#
# @api internal
sig { returns(String) }
def full_token
return token if (t = tap).nil?
return token if t.core_cask_tap?
"#{t.name}/#{token}"
end
# Alias for {#full_token}.
#
# @api internal
sig { returns(String) }
def full_name = full_token
sig { returns(T::Boolean) }
def installed?
installed_caskfile&.exist? || false
end
sig { returns(T::Boolean) }
def any_version_installed? = installed?
sig { returns(T::Boolean) }
def font?
artifacts.all?(Artifact::Font)
end
sig { returns(T::Boolean) }
def supports_linux?
return true if depends_on.requires_linux?
!depends_on.requires_macos?
end
sig { returns(T::Boolean) }
def supports_macos?
!depends_on.requires_linux?
end
# The caskfile is needed during installation when there are legacy
# `*flight` blocks or language variations missing from API data.
sig { returns(T::Boolean) }
def caskfile_only?
(languages.any? && !@language_variations_available) || artifacts.any?(Artifact::AbstractFlightBlock)
end
sig { returns(T::Boolean) }
def uninstall_flight_blocks?
artifacts.any? do |artifact|
case artifact
when Artifact::PreflightBlock
artifact.directives.key?(:uninstall_preflight)
when Artifact::PostflightBlock
artifact.directives.key?(:uninstall_postflight)
end
end
end
sig { returns(T.nilable(Time)) }
def install_time
# <caskroom_path>/.metadata/<version>/<timestamp>/Casks/<token>.{rb,json} -> <timestamp>
caskfile = installed_caskfile
Time.strptime(caskfile.dirname.dirname.basename.to_s, Metadata::TIMESTAMP_FORMAT) if caskfile
end
sig { returns(T.nilable(Pathname)) }
def installed_caskfile
Caskroom.cask_installed_caskfile(token, old_tokens:)
end
sig { returns(T.nilable(String)) }
def installed_version
# <caskroom_path>/.metadata/<version>/<timestamp>/Casks/<token>.{rb,json} -> <version>
Caskroom.cask_installed_version(token, old_tokens:)
end
sig { void }
def pin
return unless (installed_version = self.installed_version)
versioned_path = caskroom_path/installed_version
return unless versioned_path.exist?
HOMEBREW_PINNED_CASKS.mkpath
return if pinned?
pin_path.unlink if pin_path.file? || pin_path.symlink?
pin_path.make_relative_symlink(versioned_path)
end
sig { void }
def unpin
pin_path.unlink if pin_path.symlink?
HOMEBREW_PINNED_CASKS.rmdir_if_possible
end
sig { returns(T::Boolean) }
def pinned?
pin_path.symlink? && pin_path.exist?
end
sig { returns(T::Boolean) }
def pinnable?
return false unless (installed_version = self.installed_version)
(caskroom_path/installed_version).exist?
end
sig { returns(T.nilable(String)) }
def pinned_version
pin_path.resolved_path.basename.to_s if pinned?
end
sig { returns(Pathname) }
def pin_path
HOMEBREW_PINNED_CASKS/token
end
sig { returns(T.nilable(String)) }
def bundle_short_version
bundle_version&.short_version
end
sig { returns(T.nilable(String)) }
def bundle_long_version
bundle_version&.version
end
sig { returns(Tab) }
def tab
Tab.for_cask(self)
end
sig { returns(Pathname) }
def config_path
metadata_main_container_path/"config.json"
end
sig { returns(T::Boolean) }
def checksumable?
return false if (url = self.url).nil? || url.to_s.blank?
DownloadStrategyDetector.detect(url.to_s, url.using) <= AbstractFileDownloadStrategy || false
end
sig { returns(Pathname) }
def download_sha_path
metadata_main_container_path/"LATEST_DOWNLOAD_SHA256"
end
sig { returns(String) }
def new_download_sha
require "cask/installer"
# Call checksumable? before hashing
@new_download_sha ||= T.let(
Installer.new(self, verify_download_integrity: false)
.download(quiet: true)
.instance_eval { |x| Digest::SHA256.file(x).hexdigest },
T.nilable(String),
)
end
sig { returns(T::Boolean) }
def outdated_download_sha?
return true unless checksumable?
current_download_sha = download_sha_path.read if download_sha_path.exist?
current_download_sha.blank? || current_download_sha != new_download_sha
end
sig { returns(Pathname) }
def caskroom_path
@caskroom_path ||= T.let(Caskroom.path.join(token), T.nilable(Pathname))
end
# Check if the installed cask is outdated.
#
# @api internal
sig {
params(greedy: T::Boolean, greedy_latest: T.nilable(T::Boolean), greedy_auto_updates: T.nilable(T::Boolean))
.returns(T::Boolean)
}
def outdated?(greedy: false, greedy_latest: false, greedy_auto_updates: false)
!outdated_version(greedy:, greedy_latest:,
greedy_auto_updates:).nil?
end
sig {
params(greedy: T::Boolean, greedy_latest: T.nilable(T::Boolean), greedy_auto_updates: T.nilable(T::Boolean))
.returns(T.nilable(String))
}
def outdated_version(greedy: false, greedy_latest: false, greedy_auto_updates: false)
# special case: tap version is not available
return if version.nil?
if version.latest?
return installed_version if (greedy || greedy_latest) && outdated_download_sha?
return
end
return if installed_version == version
if auto_updates && !greedy && !greedy_auto_updates
return unless Homebrew::EnvConfig.upgrade_auto_updates_casks?
return installed_version if auto_updates_bundle_outdated?
return
end
installed_version
end
sig {
params(
greedy: T::Boolean,
verbose: T::Boolean,
json: T::Boolean,
greedy_latest: T::Boolean,
greedy_auto_updates: T::Boolean,
).returns(T.any(String, T::Hash[Symbol, T.untyped]))
}
def outdated_info(greedy, verbose, json, greedy_latest, greedy_auto_updates)
return token if !verbose && !json
installed_version = outdated_version(greedy:, greedy_latest:,
greedy_auto_updates:).to_s
if json
{
name: token,
installed_versions: [installed_version],
current_version: version,
pinned: pinned?,
pinned_version:,
}
else
pinned = " [pinned at #{pinned_version}]" if pinned?
"#{token} (#{installed_version}) != #{version}#{pinned}"
end
end
sig { returns(T.nilable(String)) }
def ruby_source_path
return @ruby_source_path if defined?(@ruby_source_path)
return unless (sfp = sourcefile_path)
return unless (t = tap)
@ruby_source_path = T.let(sfp.relative_path_from(t.path).to_s, T.nilable(String))
end
sig { returns(T::Hash[Symbol, T.nilable(String)]) }
def ruby_source_checksum
@ruby_source_checksum ||= T.let(
begin
sfp = sourcefile_path
{
sha256: sfp ? Digest::SHA256.file(sfp).hexdigest : nil,
}.freeze
end,
T.nilable(T::Hash[Symbol, T.nilable(String)]),
)
end
sig { returns(T::Array[String]) }
def languages
@languages ||= T.let(dsl!.languages, T.nilable(T::Array[String]))
end
sig { returns(T.nilable(String)) }
def tap_git_head
@tap_git_head ||= T.let(tap&.git_head, T.nilable(String))
rescue TapUnavailableError
nil
end
sig { params(cask_struct: Homebrew::API::CaskStruct, tap_git_head: T.nilable(String)).void }
def populate_from_api!(cask_struct, tap_git_head:)
raise ArgumentError, "Expected cask to be loaded from the API" unless loaded_from_api?
@languages = cask_struct.languages
@language_variations_available = cask_struct.language_variations.any?
@language_evaluator = ->(languages) { cask_struct.language(languages) }
@tap_git_head = tap_git_head
@ruby_source_path = cask_struct.ruby_source_path
@ruby_source_checksum = cask_struct.ruby_source_checksum
end
# The string representation of this {Cask}, returning its {#token}.
#
# @api public
sig { returns(String) }
def to_s = token
sig { returns(String) }
def inspect
"#<Cask #{token}#{sourcefile_path&.to_s&.prepend(" ")}>"
end
sig { returns(Integer) }
def hash
token.hash
end
sig { params(other: T.untyped).returns(T::Boolean) }
def eql?(other)
instance_of?(other.class) && token == other.token
end
alias == eql?
sig { returns(T::Hash[String, T.untyped]) }
def to_h
{
"token" => token,
"full_token" => full_name,
"old_tokens" => old_tokens,
"tap" => tap&.name,
"name" => name,
"desc" => desc,
"homepage" => homepage,
"url" => url,
"url_specs" => url_specs,
"version" => version,
"autobump" => autobump?,
"no_autobump_message" => no_autobump_message,
"skip_livecheck" => livecheck.skip?,
"installed" => installed_version,
"installed_time" => install_time&.to_i,
"bundle_version" => bundle_long_version,
"bundle_short_version" => bundle_short_version,
"pinned" => pinned?,
"pinned_version" => pinned_version,
"outdated" => outdated?,
"sha256" => sha256,
"artifacts" => artifacts_list,
"caveats" => caveats_for_api,
"caveats_rosetta" => caveats_object.invoked?(:requires_rosetta) || nil,
"depends_on" => depends_on,
"conflicts_with" => conflicts_with,
"container" => container&.pairs,
"rename" => rename_list,
"auto_updates" => auto_updates,
"deprecated" => deprecated?,
"deprecation_date" => deprecation_date,
"deprecation_reason" => deprecation_reason,
"deprecation_replacement_formula" => deprecation_replacement_formula,
"deprecation_replacement_cask" => deprecation_replacement_cask,
"deprecate_args" => deprecate_args,
"disabled" => disabled?,
"disable_date" => disable_date,
"disable_reason" => disable_reason,
"disable_replacement_formula" => disable_replacement_formula,
"disable_replacement_cask" => disable_replacement_cask,
"disable_args" => disable_args,
"tap_git_head" => tap_git_head,
"languages" => languages,
"ruby_source_path" => ruby_source_path,
"ruby_source_checksum" => ruby_source_checksum,
}
end
HASH_KEYS_TO_SKIP = %w[outdated installed pinned pinned_version versions].freeze
private_constant :HASH_KEYS_TO_SKIP
AUTO_UPDATES_BAD_BUNDLE_VERSIONS = %w[0 0.0].freeze
private_constant :AUTO_UPDATES_BAD_BUNDLE_VERSIONS
sig { returns(T::Hash[String, T.untyped]) }
def to_hash_with_variations
if loaded_from_internal_api?
raise UsageError, "Cannot call #to_hash_with_variations on casks loaded from the internal API"
end
if loaded_from_api? && (json_cask = api_source) && !Homebrew::EnvConfig.no_install_from_api?
return api_to_local_hash(json_cask.dup)
end
hash = to_h_with_language_variations
variations = {}
if dsl!.on_system_blocks_exist?
begin
OnSystem::VALID_OS_ARCH_TAGS.each do |bottle_tag|
macos_requirements = [depends_on.macos, depends_on.maximum_macos].compact
next if bottle_tag.macos? &&
macos_requirements.present? &&
!dsl!.depends_on_set_in_block? &&
macos_requirements.any? { |requirement| !requirement.allows?(bottle_tag.to_macos_version) }
refresh_for_tag(bottle_tag) do
to_h_with_language_variations.each do |key, value|
next if HASH_KEYS_TO_SKIP.include? key
next if value.to_s == hash[key].to_s
variations[bottle_tag.to_sym] ||= {}
variations[bottle_tag.to_sym][key] = value
end
end
end
ensure
refresh
end
end
hash["variations"] = variations
hash
end
sig { returns(T::Hash[String, T.untyped]) }
def to_h_with_language_variations
language_groups = dsl!.language_groups
return to_h if language_groups.empty?
default_language_group = dsl!.default_language_group
raise CaskInvalidError.new(self, "No default language specified.") if default_language_group.nil?
original_config = config
language_hashes = language_groups.to_h do |languages|
localised_config = original_config.dup
localised_config.explicit = original_config.explicit.dup
localised_config.languages = [languages.fetch(0)]
self.config = localised_config
[languages, [to_h, dsl!.language_eval]]
end
hash = language_hashes.fetch(default_language_group).first
hash["language_variations"] = language_hashes.map do |languages, (language_hash, value)|
variation = {
"languages" => languages,
"default" => languages == default_language_group,
"value" => value,
}
language_hash.each do |key, language_value|
next if HASH_KEYS_TO_SKIP.include? key
next if language_value.to_s == hash[key].to_s
variation[key] = language_value
end
variation
end
hash
ensure
self.config = original_config if original_config
end
sig { returns(T::Hash[String, T.untyped]) }
def to_installed_json_hash
cask_url = url
return {} if cask_url.nil? || cask_url.only_path.blank?
{ "url_specs" => { "only_path" => cask_url.only_path } }
end
sig { params(uninstall_only: T::Boolean).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def artifacts_list(uninstall_only: false)
artifacts.filter_map do |artifact|
case artifact
when Artifact::AbstractFlightBlock
uninstall_flight_block = artifact.directives.key?(:uninstall_preflight) ||
artifact.directives.key?(:uninstall_postflight)
next if uninstall_only && !uninstall_flight_block
# Only indicate whether this block is used as we don't load it from the API
{ artifact.summarize.to_sym => nil }
else
zap_artifact = artifact.is_a?(Artifact::Zap)
uninstall_artifact = artifact.respond_to?(:uninstall_phase) || artifact.respond_to?(:post_uninstall_phase)
next if uninstall_only && !zap_artifact && !uninstall_artifact
entry = T.let(
{ artifact.class.dsl_key => artifact.to_args },
T::Hash[Symbol, T.any(String, T::Array[T.anything])],
)
entry[:target] = artifact.target.to_s if !uninstall_only && artifact.is_a?(Artifact::Relocated)
entry
end
end
end
sig { params(uninstall_only: T::Boolean).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def rename_list(uninstall_only: false)
rename.filter_map do |rename|
{ from: rename.from, to: rename.to }
end
end
private
# Returns caveats text for API serialization, excluding conditional
# built-in caveats that depend on the current machine's state.
# These are stored as separate boolean fields (e.g. caveats_rosetta)
# and evaluated at install time instead.
sig { returns(T.nilable(String)) }
def caveats_for_api
Tty.strip_ansi(caveats_object.to_s_without_conditional)
.presence
end
sig { returns(T.nilable(Homebrew::BundleVersion)) }
def bundle_version
@bundle_version ||= T.let(
if (bundle = artifacts.find { |a| a.is_a?(Artifact::App) }&.target) &&
(plist = Pathname("#{bundle}/Contents/Info.plist")) && plist.exist? && plist.readable?
Homebrew::BundleVersion.from_info_plist(plist)
end,
T.nilable(Homebrew::BundleVersion),
)
end
sig { returns(DSL) }
def dsl!
@dsl || raise("unexpected nil @dsl")
end
sig { returns(T.nilable(Artifact::App)) }
def single_app_artifact
app_artifacts = artifacts.grep(Artifact::App)
return unless app_artifacts.one?
app_artifacts.first
end
sig { returns(T.nilable(Pathname)) }
def installed_app_info_plist
return unless (app_artifact = single_app_artifact)
info_plist = app_artifact.target/"Contents/Info.plist"
info_plist if info_plist.exist? && info_plist.readable?
end
sig { params(first: T.nilable(String), second: T.nilable(String)).returns(T.nilable(Integer)) }
def compare_version_strings(first, second)
return if first.blank? || second.blank?
return if first.split(".").size != second.split(".").size
Version.new(first) <=> Version.new(second)
rescue
nil
end
sig { returns(T::Boolean) }
def auto_updates_bundle_outdated?
return false if !auto_updates || version.latest?
return false unless installed_app_info_plist
tap_short_version = version.csv.first.to_s.presence || version.to_s
begin
installed_short_version = bundle_short_version
installed_bundle_version = bundle_long_version
rescue ErrorDuringExecution
return false
end
installed_bundle_version = nil if AUTO_UPDATES_BAD_BUNDLE_VERSIONS.include?(installed_bundle_version)
installed_short_version = nil if AUTO_UPDATES_BAD_BUNDLE_VERSIONS.include?(installed_short_version)
return false if installed_short_version.nil? && installed_bundle_version.nil?
# Some apps split a cask version like 2.61-2057 across the short
# version and bundle version fields.
if installed_short_version && installed_bundle_version
combined_version_comparisons = version.csv.filter_map do |candidate|
compare_version_strings("#{installed_short_version}-#{installed_bundle_version}", candidate.to_s)
end
return false if combined_version_comparisons.include?(0)
return false if combined_version_comparisons.present? && combined_version_comparisons.exclude?(-1)
end
return false if [installed_short_version, installed_bundle_version].any? do |installed_plist_version|
compare_version_strings(installed_plist_version, tap_short_version)&.zero?
end
short_comparison = compare_version_strings(installed_short_version, tap_short_version)
return true if short_comparison == -1
return false if short_comparison == 1
build_comparisons = version.csv.filter_map do |candidate|
compare_version_strings(installed_bundle_version, candidate.to_s)
end
return false if build_comparisons.empty?
return false if build_comparisons.include?(0)
build_comparisons.include?(-1)
end
sig { params(hash: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
def api_to_local_hash(hash)
hash["token"] = token
hash["installed"] = installed_version
hash["pinned"] = pinned?
hash["pinned_version"] = pinned_version
hash["outdated"] = outdated?
hash
end
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def url_specs
url&.specs.dup.tap do |url_specs|
case url_specs&.dig(:user_agent)
when :default
url_specs.delete(:user_agent)
when Symbol
url_specs[:user_agent] = ":#{url_specs[:user_agent]}"
end
end
end
end
end