diff --git a/Library/Homebrew/cask/download.rb b/Library/Homebrew/cask/download.rb index 5d577eeb2e..e2f43bf526 100644 --- a/Library/Homebrew/cask/download.rb +++ b/Library/Homebrew/cask/download.rb @@ -40,6 +40,10 @@ module Cask end end + def time_file_size + downloader.resolved_time_file_size + end + def clear_cache downloader.clear_cache end diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index c93530edec..a3cf058dec 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -30,7 +30,8 @@ module Cask def initialize(cask, command: SystemCommand, force: false, skip_cask_deps: false, binaries: true, verbose: false, require_sha: false, upgrade: false, - installed_as_dependency: false, quarantine: true) + installed_as_dependency: false, quarantine: true, + verify_download_integrity: true) @cask = cask @command = command @force = force @@ -42,6 +43,7 @@ module Cask @upgrade = upgrade @installed_as_dependency = installed_as_dependency @quarantine = quarantine + @verify_download_integrity = verify_download_integrity end attr_predicate :binaries?, :force?, :skip_cask_deps?, :require_sha?, @@ -150,13 +152,10 @@ module Cask s.freeze end + sig { returns(Pathname) } def download - return @downloaded_path if @downloaded_path - - odebug "Downloading" - @downloaded_path = Download.new(@cask, quarantine: quarantine?).fetch - odebug "Downloaded to -> #{@downloaded_path}" - @downloaded_path + @download ||= Download.new(@cask, quarantine: quarantine?) + .fetch(verify_download_integrity: @verify_download_integrity) end def verify_has_sha @@ -171,15 +170,15 @@ module Cask def primary_container @primary_container ||= begin - download - UnpackStrategy.detect(@downloaded_path, type: @cask.container&.type, merge_xattrs: true) + downloaded_path = download + UnpackStrategy.detect(downloaded_path, type: @cask.container&.type, merge_xattrs: true) end end - def extract_primary_container + def extract_primary_container(to: @cask.staged_path) odebug "Extracting primary container" - odebug "Using container class #{primary_container.class} for #{@downloaded_path}" + odebug "Using container class #{primary_container.class} for #{primary_container.path}" basename = CGI.unescape(File.basename(@cask.url.path)) @@ -191,16 +190,16 @@ module Cask FileUtils.chmod_R "+rw", tmpdir/nested_container, force: true, verbose: verbose? UnpackStrategy.detect(tmpdir/nested_container, merge_xattrs: true) - .extract_nestedly(to: @cask.staged_path, verbose: verbose?) + .extract_nestedly(to: to, verbose: verbose?) end else - primary_container.extract_nestedly(to: @cask.staged_path, basename: basename, verbose: verbose?) + primary_container.extract_nestedly(to: to, basename: basename, verbose: verbose?) end return unless quarantine? return unless Quarantine.available? - Quarantine.propagate(from: @downloaded_path, to: @cask.staged_path) + Quarantine.propagate(from: primary_container.path, to: to) end def install_artifacts diff --git a/Library/Homebrew/dev-cmd/bump-cask-pr.rb b/Library/Homebrew/dev-cmd/bump-cask-pr.rb index 7a4b74e6bb..b5752c6fa1 100644 --- a/Library/Homebrew/dev-cmd/bump-cask-pr.rb +++ b/Library/Homebrew/dev-cmd/bump-cask-pr.rb @@ -72,9 +72,10 @@ module Homebrew new_version = Cask::DSL::Version.new(new_version) new_base_url = args.url new_hash = args.sha256 + new_hash = :no_check if new_hash == ":no_check" old_version = cask.version - old_hash = cask.sha256.to_s + old_hash = cask.sha256 tap_full_name = cask.tap&.full_name default_remote_branch = cask.tap.path.git_origin_branch if cask.tap @@ -95,7 +96,7 @@ module Homebrew elsif old_version.latest? opoo "No --url= argument specified!" unless new_base_url elsif new_version.latest? - opoo "Ignoring specified --sha256= argument." if new_hash + opoo "Ignoring specified --sha256= argument." if new_hash && new_check != :no_check elsif Version.new(new_version) < Version.new(old_version) odie <<~EOS You need to bump this cask manually since changing the @@ -136,7 +137,9 @@ module Homebrew ] end - if !new_version.latest? && (new_hash.nil? || cask.languages.present?) + if new_version.latest? + new_hash = :no_check + elsif new_hash.nil? || cask.languages.present? tmp_contents = Utils::Inreplace.inreplace_pairs(cask.sourcefile_path, replacement_pairs.uniq.compact, read_only_run: true, @@ -172,15 +175,14 @@ module Homebrew end end - replacement_pairs << if old_version.latest? + p old_hash + + replacement_pairs << if old_version.latest? || new_version.latest? || new_hash == :no_check + hash_regex = old_hash == :no_check ? ":no_check" : "[\"']#{Regexp.escape(old_hash.to_s)}[\"']" + [ - "sha256 :no_check", - "sha256 \"#{new_hash}\"", - ] - elsif new_version.latest? - [ - "sha256 \"#{old_hash}\"", - "sha256 :no_check", + /sha256\s+#{hash_regex}/m, + "sha256 #{new_hash == :no_check ? ":no_check" : "\"#{new_hash}\""}", ] else [ diff --git a/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb b/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb new file mode 100644 index 0000000000..435bcc08d8 --- /dev/null +++ b/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb @@ -0,0 +1,201 @@ +# typed: false +# frozen_string_literal: true + +require "cask/download" +require "cask/installer" +require "cask/cask_loader" +require "cli/parser" +require "tap" + +module Homebrew + extend T::Sig + + extend SystemCommand::Mixin + + sig { returns(CLI::Parser) } + def self.bump_unversioned_casks_args + Homebrew::CLI::Parser.new do + usage_banner <<~EOS + `bump-unversioned-casks` [] [] + + Check all casks with unversioned URLs in a given for updates. + EOS + switch "-n", "--dry-run", + description: "List what would be done, but do not actually do anything." + flag "--limit=", + description: "Maximum number of casks to update." + flag "--state-file=", + description: "File for keeping track of state." + + named 1 + end + end + + sig { void } + def self.bump_unversioned_casks + args = bump_unversioned_casks_args.parse + + state_file = if args.state_file.present? + Pathname(args.state_file).expand_path + else + HOMEBREW_CACHE/"bump_unversioned_casks.json" + end + state_file.dirname.mkpath + + tap = Tap.fetch(args.named.first) + + old_state = state_file.exist? ? JSON.parse(state_file.read) : {} + + new_state = {} + + cask_files = tap.cask_files + unversioned_cask_files = cask_files.select do |cask_file| + url = cask_file.each_line do |line| + url = line[/\s*url\s+"([^"]+)"\s*/, 1] + break url if url + end + + url.present? && url.exclude?('#{') + end.sort + + unversioned_casks = unversioned_cask_files.map { |path| Cask::CaskLoader.load(path) } + + ohai "Static Casks:" + puts "Total: #{unversioned_casks.count}" + puts "Single-App: #{unversioned_casks.count { |c| single_app_cask?(c) }}" + puts "Single-Pkg: #{unversioned_casks.count { |c| single_pkg_cask?(c) }}" + + limit = args.limit.presence&.to_i || unversioned_casks.count + + unversioned_casks.shuffle.each do |cask| + ohai "Checking #{cask.full_name}" + + unless single_app_cask?(cask) + opoo "Skipping, not a single-app cask." + next + end + + download = Cask::Download.new(cask) + time, file_size = begin + download.time_file_size + rescue + opoo "Skipping, cannot get time and file size." + next + end + + odebug "Time: #{time.inspect}" + odebug "Size: #{file_size.inspect}" + + last_state = old_state.fetch(cask.full_name, {}) + last_check_time = last_state["check_time"]&.yield_self { |t| Time.parse(t) } + + check_time = Time.now + if last_check_time && check_time < (last_check_time + 1.day) + opoo "Skipping, already checked within the last 24 hours." + next + end + + last_sha256 = last_state["sha256"] + last_time = last_state["time"]&.yield_self { |t| Time.parse(t) } + last_file_size = last_state["file_size"] + + next if last_time == time && last_file_size == file_size + + installer = Cask::Installer.new(cask, verify_download_integrity: false) + + begin + cached_download = installer.download + rescue => e + onoe e + next + end + + sha256 = cached_download.sha256 + + if last_sha256 != sha256 && (version = guess_cask_version(cask, installer)) + odebug "Version: #{version.inspect}" + + if cask.version == version + oh1 "Cask #{cask} is up-to-date at #{version}" + else + bump_cask_pr_args = [ + "bump-cask-pr", + "--version", version.to_s, + "--sha256", ":no_check", + "--message", "Automatic update via `brew bump-unversioned-casks`.", + cask.sourcefile_path + ] + + if args.dry_run? + bump_cask_pr_args << "--dry-run" + oh1 "Would bump #{cask} from #{cask.version} to #{version}" + else + oh1 "Bumping #{cask} from #{cask.version} to #{version}" + end + + begin + system_command! HOMEBREW_BREW_FILE, args: bump_cask_pr_args + rescue ErrorDuringExecution => e + onoe e + Homebrew.failed = true + end + end + end + + unless args.dry_run? + new_state[cask.full_name] = { + "sha256" => sha256, + "check_time" => check_time.iso8601, + "time" => time&.iso8601, + "file_size" => file_size, + } + end + + break if (limit -= 1).zero? + end + + state_file.atomic_write JSON.pretty_generate(old_state.merge(new_state)) + end + + sig { params(cask: Cask::Cask, installer: Cask::Installer).returns(T.nilable(String)) } + def self.guess_cask_version(cask, installer) + apps = cask.artifacts.select { |a| a.is_a?(Cask::Artifact::App) } + return if apps.count != 1 + + Dir.mktmpdir do |dir| + dir = Pathname(dir) + + installer.extract_primary_container(to: dir) + + plists = apps.flat_map do |app| + Pathname.glob(dir/"**"/app.source.basename/"Contents"/"Info.plist") + end + next if plists.empty? + + plist = plists.first + + system_command! "plutil", args: ["-convert", "xml1", plist] + plist = Plist.parse_xml(plist.read) + + short_version = plist["CFBundleShortVersionString"] + version = plist["CFBundleVersion"] + + return "#{short_version},#{version}" if cask.version.include?(",") + + return cask.version.to_s if [short_version, version].include?(cask.version.to_s) + + return short_version if short_version&.match(/\A\d+(\.\d+)+\Z/) + return version if version&.match(/\A\d+(\.\d+)+\Z/) + + short_version || version + end + end + + def self.single_app_cask?(cask) + cask.artifacts.count { |a| a.is_a?(Cask::Artifact::App) } == 1 + end + + def self.single_pkg_cask?(cask) + cask.artifacts.count { |a| a.is_a?(Cask::Artifact::Pkg) } == 1 + end +end diff --git a/Library/Homebrew/download_strategy.rb b/Library/Homebrew/download_strategy.rb index a2deeed57f..b33ec75036 100644 --- a/Library/Homebrew/download_strategy.rb +++ b/Library/Homebrew/download_strategy.rb @@ -356,7 +356,7 @@ class CurlDownloadStrategy < AbstractFileDownloadStrategy ohai "Downloading #{url}" - resolved_url, _, url_time = resolve_url_basename_time(url) + resolved_url, _, url_time, = resolve_url_basename_time_file_size(url) fresh = if cached_location.exist? && url_time url_time <= cached_location.mtime @@ -398,14 +398,19 @@ class CurlDownloadStrategy < AbstractFileDownloadStrategy rm_rf(temporary_path) end + def resolved_time_file_size + _, _, time, file_size = resolve_url_basename_time_file_size(url) + [time, file_size] + end + private def resolved_url_and_basename - resolved_url, basename, = resolve_url_basename_time(url) + resolved_url, basename, = resolve_url_basename_time_file_size(url) [resolved_url, basename] end - def resolve_url_basename_time(url) + def resolve_url_basename_time_file_size(url) @resolved_info_cache ||= {} return @resolved_info_cache[url] if @resolved_info_cache.include?(url) @@ -458,9 +463,15 @@ class CurlDownloadStrategy < AbstractFileDownloadStrategy .map { |t| t.match?(/^\d+$/) ? Time.at(t.to_i) : Time.parse(t) } .last + file_size = + lines.map { |line| line[/^Content-Length:\s*(\d+)/i, 1] } + .compact + .map(&:to_i) + .last + basename = filenames.last || parse_basename(redirect_url) - @resolved_info_cache[url] = [redirect_url, basename, time] + @resolved_info_cache[url] = [redirect_url, basename, time, file_size] end def _fetch(url:, resolved_url:) @@ -526,7 +537,7 @@ class CurlApacheMirrorDownloadStrategy < CurlDownloadStrategy @combined_mirrors = [*@mirrors, *backup_mirrors] end - def resolve_url_basename_time(url) + def resolve_url_basename_time_file_size(url) if url == self.url super("#{apache_mirrors["preferred"]}#{apache_mirrors["path_info"]}") else diff --git a/Library/Homebrew/utils/github.rb b/Library/Homebrew/utils/github.rb index c589748168..c3c493231a 100644 --- a/Library/Homebrew/utils/github.rb +++ b/Library/Homebrew/utils/github.rb @@ -736,11 +736,12 @@ module GitHub EOS user_message = args.message if user_message - pr_message += <<~EOS + pr_message = <<~EOS + #{user_message} --- - #{user_message} + #{pr_message} EOS end diff --git a/completions/internal_commands_list.txt b/completions/internal_commands_list.txt index f2c3de6ba2..c9c4fbf6aa 100644 --- a/completions/internal_commands_list.txt +++ b/completions/internal_commands_list.txt @@ -18,6 +18,7 @@ bump bump-cask-pr bump-formula-pr bump-revision +bump-unversioned-casks cask cat cleanup diff --git a/docs/Manpage.md b/docs/Manpage.md index 27525453db..78c55fbbdc 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -899,6 +899,17 @@ present, "revision 1" will be added. * `--message`: Append *`message`* to the default commit message. +### `bump-unversioned-casks` [*`options`*] [*`tap`*] + +Check all casks with unversioned URLs in a given *`tap`* for updates. + +* `-n`, `--dry-run`: + List what would be done, but do not actually do anything. +* `--limit`: + Maximum number of casks to update. +* `--state-file`: + File for keeping track of state. + ### `cat` *`formula`*|*`cask`* Display the source of a *`formula`* or *`cask`*. diff --git a/manpages/brew.1 b/manpages/brew.1 index 2e6304a461..6da1f9c037 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -1245,6 +1245,21 @@ Print what would be done rather than doing it\. \fB\-\-message\fR Append \fImessage\fR to the default commit message\. . +.SS "\fBbump\-unversioned\-casks\fR [\fIoptions\fR] [\fItap\fR]" +Check all casks with unversioned URLs in a given \fItap\fR for updates\. +. +.TP +\fB\-n\fR, \fB\-\-dry\-run\fR +List what would be done, but do not actually do anything\. +. +.TP +\fB\-\-limit\fR +Maximum number of casks to update\. +. +.TP +\fB\-\-state\-file\fR +File for keeping track of state\. +. .SS "\fBcat\fR \fIformula\fR|\fIcask\fR" Display the source of a \fIformula\fR or \fIcask\fR\. .