diff --git a/Library/Homebrew/cask.rb b/Library/Homebrew/cask.rb index d2246dcf9e5168..9bb3b7444a1b80 100644 --- a/Library/Homebrew/cask.rb +++ b/Library/Homebrew/cask.rb @@ -20,5 +20,6 @@ require "cask/pkg" require "cask/quarantine" require "cask/staged" +require "cask/tab" require "cask/url" require "cask/utils" diff --git a/Library/Homebrew/cask/cask.rb b/Library/Homebrew/cask/cask.rb index aa1a62516aaea7..ec68683c47f2bd 100644 --- a/Library/Homebrew/cask/cask.rb +++ b/Library/Homebrew/cask/cask.rb @@ -7,6 +7,7 @@ require "cask/config" require "cask/dsl" require "cask/metadata" +require "cask/tab" require "utils/bottles" require "extend/api_hashable" @@ -209,6 +210,10 @@ def bundle_long_version bundle_version&.version end + def tab + Tab.for_cask(self) + end + def config_path metadata_main_container_path/"config.json" end diff --git a/Library/Homebrew/cask/info.rb b/Library/Homebrew/cask/info.rb index 18ccf66894881b..fbbfcd108b6297 100644 --- a/Library/Homebrew/cask/info.rb +++ b/Library/Homebrew/cask/info.rb @@ -46,7 +46,10 @@ def self.installation_info(cask) Formatter.error("does not exist") end - "Installed\n#{versioned_staged_path} (#{path_details})\n" + tab = Tab.for_cask(cask) + tab_details = "\n #{tab}" if tab.tabfile&.exist? + + "Installed\n#{versioned_staged_path} (#{path_details})#{tab_details}\n" end def self.name_info(cask) diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index 517070f5ba9192..b16ac8709c562b 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -10,6 +10,7 @@ require "cask/download" require "cask/migrator" require "cask/quarantine" +require "cask/tab" require "cgi" @@ -21,8 +22,8 @@ class Installer def initialize(cask, command: SystemCommand, force: false, adopt: false, skip_cask_deps: false, binaries: true, verbose: false, zap: false, require_sha: false, upgrade: false, reinstall: false, - installed_as_dependency: false, quarantine: true, - verify_download_integrity: true, quiet: false) + installed_as_dependency: false, installed_on_request: true, + quarantine: true, verify_download_integrity: true, quiet: false) @cask = cask @command = command @force = force @@ -35,13 +36,14 @@ def initialize(cask, command: SystemCommand, force: false, adopt: false, @reinstall = reinstall @upgrade = upgrade @installed_as_dependency = installed_as_dependency + @installed_on_request = installed_on_request @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?, + :reinstall?, :upgrade?, :verbose?, :zap?, :installed_as_dependency?, :installed_on_request?, :quarantine?, :quiet? def self.caveats(cask) @@ -112,6 +114,11 @@ def install install_artifacts(predecessor:) + tab = Tab.create(@cask) + tab.installed_as_dependency = installed_as_dependency? + tab.installed_on_request = installed_on_request? + tab.write + if (tap = @cask.tap) && tap.should_report_analytics? ::Utils::Analytics.report_package_event(:cask_install, package_name: @cask.token, tap_name: tap.name, on_request: true) @@ -356,6 +363,7 @@ def satisfy_cask_and_formula_dependencies binaries: binaries?, verbose: verbose?, installed_as_dependency: true, + installed_on_request: false, force: false, ).install else @@ -408,6 +416,7 @@ def uninstall(successor: nil) oh1 "Uninstalling Cask #{Formatter.identifier(@cask)}" uninstall_artifacts(clear: true, successor:) if !reinstall? && !upgrade? + remove_tabfile remove_download_sha remove_config_file end @@ -415,6 +424,12 @@ def uninstall(successor: nil) purge_caskroom_path if force? end + def remove_tabfile + tabfile = @cask.tab.tabfile + FileUtils.rm_f tabfile if tabfile.present? && tabfile.exist? + @cask.config_path.parent.rmdir_if_possible + end + def remove_config_file FileUtils.rm_f @cask.config_path @cask.config_path.parent.rmdir_if_possible diff --git a/Library/Homebrew/cask/tab.rb b/Library/Homebrew/cask/tab.rb new file mode 100644 index 00000000000000..25ec926f2ac927 --- /dev/null +++ b/Library/Homebrew/cask/tab.rb @@ -0,0 +1,185 @@ +# typed: true +# frozen_string_literal: true + +module Cask + class Tab + extend Cachable + + FILENAME = "INSTALL_RECEIPT.json" + + attr_accessor :homebrew_version, :tabfile, :loaded_from_api, :installed_as_dependency, :installed_on_request, + :time, :dependencies, :arch, :source, :installed_on, :artifacts + + # Instantiates a {Tab} for a new installation of a cask. + sig { params(cask: Cask).returns(Tab) } + def self.create(cask) + attributes = { + "homebrew_version" => HOMEBREW_VERSION, + "tabfile" => cask.metadata_main_container_path/FILENAME, + "loaded_from_api" => cask.loaded_from_api?, + "installed_as_dependency" => false, + "installed_on_request" => false, + "time" => Time.now.to_i, + "dependencies" => Tab.runtime_deps_hash(cask, cask.depends_on), + "arch" => Hardware::CPU.arch, + "source" => { + "path" => cask.sourcefile_path.to_s, + "tap" => cask.tap&.name, + "tap_git_head" => nil, # Filled in later if possible + "version" => cask.version.to_s, + }, + "installed_on" => DevelopmentTools.build_system_info, + "artifacts" => cask.to_h["artifacts"], + } + + # We can only get `tap_git_head` if the tap is installed locally + attributes["source"]["tap_git_head"] = cask.tap.git_head if cask.tap&.installed? + + new(attributes) + end + + # Returns the {Tab} for an install receipt at `path`. + # + # NOTE: Results are cached. + sig { params(path: Pathname).returns(Tab) } + def self.from_file(path) + cache.fetch(path) do |p| + content = File.read(p) + return empty if content.blank? + + cache[p] = from_file_content(content, p) + end + end + + # Like {from_file}, but bypass the cache. + sig { params(content: String, path: Pathname).returns(Tab) } + def self.from_file_content(content, path) + attributes = begin + JSON.parse(content) + rescue JSON::ParserError => e + raise e, "Cannot parse #{path}: #{e}", e.backtrace + end + attributes["tabfile"] = path + + new(attributes) + end + + sig { params(cask: Cask).returns(Tab) } + def self.for_cask(cask) + path = cask.metadata_main_container_path/FILENAME + + return from_file(path) if path.exist? + + tab = empty + tab.source = { + "path" => cask.sourcefile_path.to_s, + "tap" => cask.tap&.name, + "tap_git_head" => nil, + "version" => cask.version.to_s, + } + tab.artifacts = cask.to_h["artifacts"] + tab.source["tap_git_head"] = cask.tap.git_head if cask.tap&.installed? + + tab + end + + sig { returns(Tab) } + def self.empty + attributes = { + "homebrew_version" => HOMEBREW_VERSION, + "loaded_from_api" => false, + "installed_as_dependency" => false, + "installed_on_request" => false, + "time" => nil, + "dependencies" => nil, + "arch" => nil, + "source" => { + "path" => nil, + "tap" => nil, + "tap_git_head" => nil, + "version" => nil, + }, + "installed_on" => DevelopmentTools.generic_build_system_info, + "artifacts" => [], + } + + new(attributes) + end + + sig { params(cask: Cask, depends_on: DSL::DependsOn).returns(T::Hash[Symbol, T.untyped]) } + def self.runtime_deps_hash(cask, depends_on) + mappable_types = [:cask, :formula] + depends_on.to_h do |type, deps| + next [type, deps] unless mappable_types.include? type + + deps = deps.map do |dep| + if type == :cask + c = CaskLoader.load(dep) + { + "full_name" => c.full_name, + "version" => c.version.to_s, + "declared_directly" => cask.depends_on.cask.include?(dep), + } + elsif type == :formula + f = Formulary.factory(dep, warn: false) + { + "full_name" => f.full_name, + "version" => f.version.to_s, + "revision" => f.revision, + "pkg_version" => f.pkg_version.to_s, + "declared_directly" => cask.depends_on.formula.include?(dep), + } + else + dep + end + end + + [type, deps] + end + end + + def initialize(attributes = {}) + attributes.each { |key, value| instance_variable_set(:"@#{key}", value) } + end + + sig { returns(T.nilable(Tap)) } + def tap + tap_name = source["tap"] + Tap.fetch(tap_name) if tap_name + end + + sig { params(_args: T::Array[T.untyped]).returns(String) } + def to_json(*_args) + attributes = { + "homebrew_version" => homebrew_version, + "loaded_from_api" => loaded_from_api, + "installed_as_dependency" => installed_as_dependency, + "installed_on_request" => installed_on_request, + "time" => time, + "dependencies" => dependencies, + "arch" => arch, + "source" => source, + "installed_on" => installed_on, + "artifacts" => artifacts, + } + + JSON.pretty_generate(attributes) + end + + sig { void } + def write + self.class.cache[tabfile] = self + tabfile.atomic_write(to_json) + end + + sig { returns(String) } + def to_s + s = ["Installed"] + + s << "using the formulae.brew.sh API" if loaded_from_api + s << Time.at(time).strftime("on %Y-%m-%d at %H:%M:%S") if time + + s.join(" ") + end + end +end diff --git a/Library/Homebrew/sorbet/rbi/parlour.rbi b/Library/Homebrew/sorbet/rbi/parlour.rbi index e0df7365995229..ec4254527fc5e5 100644 --- a/Library/Homebrew/sorbet/rbi/parlour.rbi +++ b/Library/Homebrew/sorbet/rbi/parlour.rbi @@ -344,6 +344,9 @@ module Cask sig { returns(T::Boolean) } def installed_as_dependency?; end + sig { returns(T::Boolean) } + def installed_on_request?; end + sig { returns(T::Boolean) } def quarantine?; end