
Set metadata when we load casks from the source API. Set `@tap_git_head` manually. Also, allow `auto_update` to receive false. This is only done for consistency. Stop double printing the `kext` caveat. Handle Array elements correctly when substituting path elements in artifacts. This affected how the Uninstall Signal keys were evaluated (they are represented as arrays of arrays).
573 lines
17 KiB
Ruby
573 lines
17 KiB
Ruby
# typed: false
|
|
# frozen_string_literal: true
|
|
|
|
require "formula_installer"
|
|
require "unpack_strategy"
|
|
require "utils/topological_hash"
|
|
|
|
require "cask/config"
|
|
require "cask/download"
|
|
require "cask/quarantine"
|
|
|
|
require "cgi"
|
|
|
|
module Cask
|
|
# Installer for a {Cask}.
|
|
#
|
|
# @api private
|
|
class Installer
|
|
extend T::Sig
|
|
|
|
extend Predicable
|
|
|
|
def initialize(cask, command: SystemCommand, force: false, adopt: false,
|
|
skip_cask_deps: false, binaries: true, verbose: false,
|
|
zap: false, require_sha: false, upgrade: false,
|
|
installed_as_dependency: false, quarantine: true,
|
|
verify_download_integrity: true, quiet: false)
|
|
@cask = cask
|
|
@command = command
|
|
@force = force
|
|
@adopt = adopt
|
|
@skip_cask_deps = skip_cask_deps
|
|
@binaries = binaries
|
|
@verbose = verbose
|
|
@zap = zap
|
|
@require_sha = require_sha
|
|
@reinstall = false
|
|
@upgrade = upgrade
|
|
@installed_as_dependency = installed_as_dependency
|
|
@quarantine = quarantine
|
|
@verify_download_integrity = verify_download_integrity
|
|
@quiet = quiet
|
|
end
|
|
|
|
attr_predicate :binaries?, :force?, :adopt?, :skip_cask_deps?, :require_sha?,
|
|
:reinstall?, :upgrade?, :verbose?, :zap?, :installed_as_dependency?,
|
|
:quarantine?, :quiet?
|
|
|
|
def self.caveats(cask)
|
|
odebug "Printing caveats"
|
|
|
|
caveats = cask.caveats
|
|
return if caveats.empty?
|
|
|
|
Homebrew.messages.record_caveats(cask.token, caveats)
|
|
|
|
<<~EOS
|
|
#{ohai_title "Caveats"}
|
|
#{caveats}
|
|
EOS
|
|
end
|
|
|
|
sig { params(quiet: T.nilable(T::Boolean), timeout: T.nilable(T.any(Integer, Float))).void }
|
|
def fetch(quiet: nil, timeout: nil)
|
|
odebug "Cask::Installer#fetch"
|
|
|
|
load_cask_from_source_api! if @cask.loaded_from_api? && @cask.caskfile_only?
|
|
|
|
verify_has_sha if require_sha? && !force?
|
|
|
|
download(quiet: quiet, timeout: timeout)
|
|
|
|
satisfy_dependencies
|
|
end
|
|
|
|
def stage
|
|
odebug "Cask::Installer#stage"
|
|
|
|
Caskroom.ensure_caskroom_exists
|
|
|
|
extract_primary_container
|
|
save_caskfile
|
|
rescue => e
|
|
purge_versioned_files
|
|
raise e
|
|
end
|
|
|
|
def install
|
|
start_time = Time.now
|
|
odebug "Cask::Installer#install"
|
|
|
|
old_config = @cask.config
|
|
if @cask.installed? && !force? && !reinstall? && !upgrade?
|
|
return if quiet?
|
|
|
|
raise CaskAlreadyInstalledError, @cask
|
|
end
|
|
|
|
check_conflicts
|
|
|
|
print caveats
|
|
fetch
|
|
uninstall_existing_cask if reinstall?
|
|
|
|
backup if force? && @cask.staged_path.exist? && @cask.metadata_versioned_path.exist?
|
|
|
|
oh1 "Installing Cask #{Formatter.identifier(@cask)}"
|
|
opoo "macOS's Gatekeeper has been disabled for this Cask" unless quarantine?
|
|
stage
|
|
|
|
@cask.config = @cask.default_config.merge(old_config)
|
|
|
|
install_artifacts
|
|
|
|
if @cask.tap&.should_report_analytics?
|
|
::Utils::Analytics.report_event(:cask_install, @cask.token, on_request: true)
|
|
end
|
|
|
|
purge_backed_up_versioned_files
|
|
|
|
puts summary
|
|
end_time = Time.now
|
|
Homebrew.messages.package_installed(@cask.token, end_time - start_time)
|
|
rescue
|
|
restore_backup
|
|
raise
|
|
end
|
|
|
|
def check_conflicts
|
|
return unless @cask.conflicts_with
|
|
|
|
@cask.conflicts_with[:cask].each do |conflicting_cask|
|
|
if (match = conflicting_cask.match(HOMEBREW_TAP_CASK_REGEX))
|
|
conflicting_cask_tap = Tap.fetch(match[1], match[2])
|
|
next unless conflicting_cask_tap.installed?
|
|
end
|
|
|
|
conflicting_cask = CaskLoader.load(conflicting_cask)
|
|
raise CaskConflictError.new(@cask, conflicting_cask) if conflicting_cask.installed?
|
|
rescue CaskUnavailableError
|
|
next # Ignore conflicting Casks that do not exist.
|
|
end
|
|
end
|
|
|
|
def reinstall
|
|
odebug "Cask::Installer#reinstall"
|
|
@reinstall = true
|
|
install
|
|
end
|
|
|
|
def uninstall_existing_cask
|
|
return unless @cask.installed?
|
|
|
|
# Always force uninstallation, ignore method parameter
|
|
cask_installer = Installer.new(@cask, verbose: verbose?, force: true, upgrade: upgrade?)
|
|
zap? ? cask_installer.zap : cask_installer.uninstall
|
|
end
|
|
|
|
sig { returns(String) }
|
|
def summary
|
|
s = +""
|
|
s << "#{Homebrew::EnvConfig.install_badge} " unless Homebrew::EnvConfig.no_emoji?
|
|
s << "#{@cask} was successfully #{upgrade? ? "upgraded" : "installed"}!"
|
|
s.freeze
|
|
end
|
|
|
|
sig { returns(Download) }
|
|
def downloader
|
|
@downloader ||= Download.new(@cask, quarantine: quarantine?)
|
|
end
|
|
|
|
sig { params(quiet: T.nilable(T::Boolean), timeout: T.nilable(T.any(Integer, Float))).returns(Pathname) }
|
|
def download(quiet: nil, timeout: nil)
|
|
# Store cask download path in cask to prevent multiple downloads in a row when checking if it's outdated
|
|
@cask.download ||= downloader.fetch(quiet: quiet, verify_download_integrity: @verify_download_integrity,
|
|
timeout: timeout)
|
|
end
|
|
|
|
def verify_has_sha
|
|
odebug "Checking cask has checksum"
|
|
return unless @cask.sha256 == :no_check
|
|
|
|
raise CaskError, <<~EOS
|
|
Cask '#{@cask}' does not have a sha256 checksum defined and was not installed.
|
|
This means you have the #{Formatter.identifier("--require-sha")} option set, perhaps in your HOMEBREW_CASK_OPTS.
|
|
EOS
|
|
end
|
|
|
|
def primary_container
|
|
@primary_container ||= begin
|
|
downloaded_path = download(quiet: true)
|
|
UnpackStrategy.detect(downloaded_path, type: @cask.container&.type, merge_xattrs: true)
|
|
end
|
|
end
|
|
|
|
def extract_primary_container(to: @cask.staged_path)
|
|
odebug "Extracting primary container"
|
|
|
|
odebug "Using container class #{primary_container.class} for #{primary_container.path}"
|
|
|
|
basename = downloader.basename
|
|
|
|
if (nested_container = @cask.container&.nested)
|
|
Dir.mktmpdir do |tmpdir|
|
|
tmpdir = Pathname(tmpdir)
|
|
primary_container.extract(to: tmpdir, basename: basename, verbose: verbose?)
|
|
|
|
FileUtils.chmod_R "+rw", tmpdir/nested_container, force: true, verbose: verbose?
|
|
|
|
UnpackStrategy.detect(tmpdir/nested_container, merge_xattrs: true)
|
|
.extract_nestedly(to: to, verbose: verbose?)
|
|
end
|
|
else
|
|
primary_container.extract_nestedly(to: to, basename: basename, verbose: verbose?)
|
|
end
|
|
|
|
return unless quarantine?
|
|
return unless Quarantine.available?
|
|
|
|
Quarantine.propagate(from: primary_container.path, to: to)
|
|
end
|
|
|
|
def install_artifacts
|
|
artifacts = @cask.artifacts
|
|
already_installed_artifacts = []
|
|
|
|
odebug "Installing artifacts"
|
|
odebug "#{artifacts.length} #{::Utils.pluralize("artifact", artifacts.length)} defined", artifacts
|
|
|
|
artifacts.each do |artifact|
|
|
next unless artifact.respond_to?(:install_phase)
|
|
|
|
odebug "Installing artifact of class #{artifact.class}"
|
|
|
|
next if artifact.is_a?(Artifact::Binary) && !binaries?
|
|
|
|
artifact.install_phase(command: @command, verbose: verbose?, adopt: adopt?, force: force?)
|
|
already_installed_artifacts.unshift(artifact)
|
|
end
|
|
|
|
save_config_file
|
|
save_download_sha if @cask.version.latest?
|
|
rescue => e
|
|
begin
|
|
already_installed_artifacts.each do |artifact|
|
|
if artifact.respond_to?(:uninstall_phase)
|
|
odebug "Reverting installation of artifact of class #{artifact.class}"
|
|
artifact.uninstall_phase(command: @command, verbose: verbose?, force: force?)
|
|
end
|
|
|
|
next unless artifact.respond_to?(:post_uninstall_phase)
|
|
|
|
odebug "Reverting installation of artifact of class #{artifact.class}"
|
|
artifact.post_uninstall_phase(command: @command, verbose: verbose?, force: force?)
|
|
end
|
|
ensure
|
|
purge_versioned_files
|
|
raise e
|
|
end
|
|
end
|
|
|
|
# TODO: move dependencies to a separate class,
|
|
# dependencies should also apply for `brew cask stage`,
|
|
# override dependencies with `--force` or perhaps `--force-deps`
|
|
def satisfy_dependencies
|
|
return unless @cask.depends_on
|
|
|
|
macos_dependencies
|
|
arch_dependencies
|
|
cask_and_formula_dependencies
|
|
end
|
|
|
|
def macos_dependencies
|
|
return unless @cask.depends_on.macos
|
|
return if @cask.depends_on.macos.satisfied?
|
|
|
|
raise CaskError, @cask.depends_on.macos.message(type: :cask)
|
|
end
|
|
|
|
def arch_dependencies
|
|
return if @cask.depends_on.arch.nil?
|
|
|
|
@current_arch ||= { type: Hardware::CPU.type, bits: Hardware::CPU.bits }
|
|
return if @cask.depends_on.arch.any? do |arch|
|
|
arch[:type] == @current_arch[:type] &&
|
|
Array(arch[:bits]).include?(@current_arch[:bits])
|
|
end
|
|
|
|
raise CaskError,
|
|
"Cask #{@cask} depends on hardware architecture being one of " \
|
|
"[#{@cask.depends_on.arch.map(&:to_s).join(", ")}], " \
|
|
"but you are running #{@current_arch}."
|
|
end
|
|
|
|
def collect_cask_and_formula_dependencies
|
|
return @cask_and_formula_dependencies if @cask_and_formula_dependencies
|
|
|
|
graph = ::Utils::TopologicalHash.graph_package_dependencies(@cask)
|
|
|
|
raise CaskSelfReferencingDependencyError, cask.token if graph[@cask].include?(@cask)
|
|
|
|
::Utils::TopologicalHash.graph_package_dependencies(primary_container.dependencies, graph)
|
|
|
|
begin
|
|
@cask_and_formula_dependencies = graph.tsort - [@cask]
|
|
rescue TSort::Cyclic
|
|
strongly_connected_components = graph.strongly_connected_components.sort_by(&:count)
|
|
cyclic_dependencies = strongly_connected_components.last - [@cask]
|
|
raise CaskCyclicDependencyError.new(@cask.token, cyclic_dependencies.to_sentence)
|
|
end
|
|
end
|
|
|
|
def missing_cask_and_formula_dependencies
|
|
collect_cask_and_formula_dependencies.reject do |cask_or_formula|
|
|
installed = if cask_or_formula.respond_to?(:any_version_installed?)
|
|
cask_or_formula.any_version_installed?
|
|
else
|
|
cask_or_formula.try(:installed?)
|
|
end
|
|
installed && (cask_or_formula.respond_to?(:optlinked?) ? cask_or_formula.optlinked? : true)
|
|
end
|
|
end
|
|
|
|
def cask_and_formula_dependencies
|
|
return if installed_as_dependency?
|
|
|
|
formulae_and_casks = collect_cask_and_formula_dependencies
|
|
|
|
return if formulae_and_casks.empty?
|
|
|
|
missing_formulae_and_casks = missing_cask_and_formula_dependencies
|
|
|
|
if missing_formulae_and_casks.empty?
|
|
puts "All formula dependencies satisfied."
|
|
return
|
|
end
|
|
|
|
ohai "Installing dependencies: #{missing_formulae_and_casks.map(&:to_s).join(", ")}"
|
|
missing_formulae_and_casks.each do |cask_or_formula|
|
|
if cask_or_formula.is_a?(Cask)
|
|
if skip_cask_deps?
|
|
opoo "`--skip-cask-deps` is set; skipping installation of #{cask_or_formula}."
|
|
next
|
|
end
|
|
|
|
Installer.new(
|
|
cask_or_formula,
|
|
adopt: adopt?,
|
|
binaries: binaries?,
|
|
verbose: verbose?,
|
|
installed_as_dependency: true,
|
|
force: false,
|
|
).install
|
|
else
|
|
fi = FormulaInstaller.new(
|
|
cask_or_formula,
|
|
**{
|
|
show_header: true,
|
|
installed_as_dependency: true,
|
|
installed_on_request: false,
|
|
verbose: verbose?,
|
|
}.compact,
|
|
)
|
|
fi.prelude
|
|
fi.fetch
|
|
fi.install
|
|
fi.finish
|
|
end
|
|
end
|
|
end
|
|
|
|
def caveats
|
|
self.class.caveats(@cask)
|
|
end
|
|
|
|
def metadata_subdir
|
|
@metadata_subdir ||= @cask.metadata_subdir("Casks", timestamp: :now, create: true)
|
|
end
|
|
|
|
def save_caskfile
|
|
old_savedir = @cask.metadata_timestamped_path
|
|
|
|
return if @cask.source.blank?
|
|
|
|
extension = @cask.loaded_from_api? ? "json" : "rb"
|
|
(metadata_subdir/"#{@cask.token}.#{extension}").write @cask.source
|
|
old_savedir&.rmtree
|
|
end
|
|
|
|
def save_config_file
|
|
metadata_subdir
|
|
@cask.config_path.atomic_write(@cask.config.to_json)
|
|
end
|
|
|
|
def save_download_sha
|
|
@cask.download_sha_path.atomic_write(@cask.new_download_sha) if @cask.checksumable?
|
|
end
|
|
|
|
def uninstall
|
|
load_installed_caskfile!
|
|
oh1 "Uninstalling Cask #{Formatter.identifier(@cask)}"
|
|
uninstall_artifacts(clear: true)
|
|
if !reinstall? && !upgrade?
|
|
remove_download_sha
|
|
remove_config_file
|
|
end
|
|
purge_versioned_files
|
|
purge_caskroom_path if force?
|
|
end
|
|
|
|
def remove_config_file
|
|
FileUtils.rm_f @cask.config_path
|
|
@cask.config_path.parent.rmdir_if_possible
|
|
end
|
|
|
|
def remove_download_sha
|
|
FileUtils.rm_f @cask.download_sha_path if @cask.download_sha_path.exist?
|
|
end
|
|
|
|
def start_upgrade
|
|
uninstall_artifacts
|
|
backup
|
|
end
|
|
|
|
def backup
|
|
@cask.staged_path.rename backup_path
|
|
@cask.metadata_versioned_path.rename backup_metadata_path
|
|
end
|
|
|
|
def restore_backup
|
|
return if !backup_path.directory? || !backup_metadata_path.directory?
|
|
|
|
Pathname.new(@cask.staged_path).rmtree if @cask.staged_path.exist?
|
|
Pathname.new(@cask.metadata_versioned_path).rmtree if @cask.metadata_versioned_path.exist?
|
|
|
|
backup_path.rename @cask.staged_path
|
|
backup_metadata_path.rename @cask.metadata_versioned_path
|
|
end
|
|
|
|
def revert_upgrade
|
|
opoo "Reverting upgrade for Cask #{@cask}"
|
|
restore_backup
|
|
install_artifacts
|
|
end
|
|
|
|
def finalize_upgrade
|
|
ohai "Purging files for version #{@cask.version} of Cask #{@cask}"
|
|
|
|
purge_backed_up_versioned_files
|
|
|
|
puts summary
|
|
end
|
|
|
|
def uninstall_artifacts(clear: false)
|
|
artifacts = @cask.artifacts
|
|
|
|
odebug "Uninstalling artifacts"
|
|
odebug "#{artifacts.length} #{::Utils.pluralize("artifact", artifacts.length)} defined", artifacts
|
|
|
|
artifacts.each do |artifact|
|
|
if artifact.respond_to?(:uninstall_phase)
|
|
odebug "Uninstalling artifact of class #{artifact.class}"
|
|
artifact.uninstall_phase(
|
|
command: @command, verbose: verbose?, skip: clear, force: force?, upgrade: upgrade?,
|
|
)
|
|
end
|
|
|
|
next unless artifact.respond_to?(:post_uninstall_phase)
|
|
|
|
odebug "Post-uninstalling artifact of class #{artifact.class}"
|
|
artifact.post_uninstall_phase(
|
|
command: @command, verbose: verbose?, skip: clear, force: force?, upgrade: upgrade?,
|
|
)
|
|
end
|
|
end
|
|
|
|
def zap
|
|
load_installed_caskfile!
|
|
ohai "Implied `brew uninstall --cask #{@cask}`"
|
|
uninstall_artifacts
|
|
if (zap_stanzas = @cask.artifacts.select { |a| a.is_a?(Artifact::Zap) }).empty?
|
|
opoo "No zap stanza present for Cask '#{@cask}'"
|
|
else
|
|
ohai "Dispatching zap stanza"
|
|
zap_stanzas.each do |stanza|
|
|
stanza.zap_phase(command: @command, verbose: verbose?, force: force?)
|
|
end
|
|
end
|
|
ohai "Removing all staged versions of Cask '#{@cask}'"
|
|
purge_caskroom_path
|
|
end
|
|
|
|
def backup_path
|
|
return if @cask.staged_path.nil?
|
|
|
|
Pathname("#{@cask.staged_path}.upgrading")
|
|
end
|
|
|
|
def backup_metadata_path
|
|
return if @cask.metadata_versioned_path.nil?
|
|
|
|
Pathname("#{@cask.metadata_versioned_path}.upgrading")
|
|
end
|
|
|
|
def gain_permissions_remove(path)
|
|
Utils.gain_permissions_remove(path, command: @command)
|
|
end
|
|
|
|
def purge_backed_up_versioned_files
|
|
# versioned staged distribution
|
|
gain_permissions_remove(backup_path) if backup_path&.exist?
|
|
|
|
# Homebrew Cask metadata
|
|
return unless backup_metadata_path.directory?
|
|
|
|
backup_metadata_path.children.each do |subdir|
|
|
gain_permissions_remove(subdir)
|
|
end
|
|
backup_metadata_path.rmdir_if_possible
|
|
end
|
|
|
|
def purge_versioned_files
|
|
ohai "Purging files for version #{@cask.version} of Cask #{@cask}"
|
|
|
|
# versioned staged distribution
|
|
gain_permissions_remove(@cask.staged_path) if @cask.staged_path&.exist?
|
|
|
|
# Homebrew Cask metadata
|
|
if @cask.metadata_versioned_path.directory?
|
|
@cask.metadata_versioned_path.children.each do |subdir|
|
|
gain_permissions_remove(subdir)
|
|
end
|
|
|
|
@cask.metadata_versioned_path.rmdir_if_possible
|
|
end
|
|
@cask.metadata_main_container_path.rmdir_if_possible unless upgrade?
|
|
|
|
# toplevel staged distribution
|
|
@cask.caskroom_path.rmdir_if_possible unless upgrade?
|
|
end
|
|
|
|
def purge_caskroom_path
|
|
odebug "Purging all staged versions of Cask #{@cask}"
|
|
gain_permissions_remove(@cask.caskroom_path)
|
|
end
|
|
|
|
private
|
|
|
|
# load the same cask file that was used for installation, if possible
|
|
def load_installed_caskfile!
|
|
installed_caskfile = @cask.installed_caskfile
|
|
|
|
if installed_caskfile.exist?
|
|
begin
|
|
@cask = CaskLoader.load(installed_caskfile)
|
|
return
|
|
rescue CaskInvalidError
|
|
# could be caused by trying to load outdated caskfile
|
|
end
|
|
end
|
|
|
|
load_cask_from_source_api! if @cask.loaded_from_api? && @cask.caskfile_only?
|
|
# otherwise we default to the current cask
|
|
end
|
|
|
|
def load_cask_from_source_api!
|
|
options = { git_head: @cask.tap_git_head, sha256: @cask.ruby_source_checksum["sha256"] }
|
|
cask_source = Homebrew::API::Cask.fetch_source(@cask.token, **options)
|
|
@cask = CaskLoader::FromContentLoader.new(cask_source, tap: @cask.tap).load(config: @cask.config)
|
|
end
|
|
end
|
|
end
|