diff --git a/homebrew/README.md b/homebrew/README.md index 4f2331e..ece078d 100644 --- a/homebrew/README.md +++ b/homebrew/README.md @@ -31,6 +31,7 @@ - Homebrew - Bash - `curl` +- macOS 自带的 `/usr/bin/script` 和 `/usr/bin/perl`(用于兼顾实时进度与自定义配色) - macOS Keychain 工具 `/usr/bin/security`,仅启动器需要 可先检查: @@ -197,7 +198,7 @@ BREWUP_CONNECT_TIMEOUT_SECONDS=20 BREWUP_LOW_SPEED_TIME_SECONDS=45 brewup 为了兼顾性能,Formula 和 Cask 的批量阶段都只执行一次,只有仍然过期的失败项进入单项重试。单项升级保持串行,因为并行运行多个 Homebrew 写操作会争用 Homebrew 锁和 `/Applications`。用于错误分类的临时日志会在命令结束或收到信号后删除。 -在交互式终端中,主脚本会让 Homebrew 直接连接 TTY,以便 Homebrew 6 实时重绘并行下载进度。需要记录错误以供重试判断时,macOS 自带的 `script(1)` 会创建伪终端并同步保存日志。如果将 `brewup` 输出重定向到文件或 CI,则自动切换为稳定的逐行输出,不绘制动态进度条。 +在交互式终端中,主脚本会通过 macOS 自带的 `script(1)` 为 Homebrew 创建伪终端,以便 Homebrew 6 实时重绘并行下载进度。后续的流式着色器会立即透传进度条的回车刷新,并只对完整文本行应用包名、版本和状态配色。需要记录错误以供重试判断时,`script(1)` 也会同步保存日志。如果将 `brewup` 输出重定向到文件或 CI,则自动切换为稳定的逐行输出,不绘制动态进度条。 ## Cask 与清理策略 diff --git a/homebrew/brew-upgrade-manager.sh b/homebrew/brew-upgrade-manager.sh index 20d97c6..6521168 100644 --- a/homebrew/brew-upgrade-manager.sh +++ b/homebrew/brew-upgrade-manager.sh @@ -570,13 +570,158 @@ colorize_brew_output() { done } +can_stream_colorize_tty_output() { + [[ -t 1 && "$(uname -s 2>/dev/null || true)" == "Darwin" && \ + -x /usr/bin/script && -x /usr/bin/perl ]] +} + +colorize_brew_tty_stream() { + # `script` 将 brew 的输出传递给本函数时,普通行以 CRLF 结尾,进度条则以 + # CR 重绘。按行的 Bash read 无法同时处理两者,会等到下一个换行才刷新进度。 + # 这个小型 Perl 流式过滤器会立即透传 CR 更新,仅对完整的普通行重复使用 + # 现有的语义配色规则。 + HB_STREAM_RED="$RED" \ + HB_STREAM_GREEN="$GREEN" \ + HB_STREAM_YELLOW="$YELLOW" \ + HB_STREAM_BLUE="$BLUE" \ + HB_STREAM_MAGENTA="$MAGENTA" \ + HB_STREAM_CYAN="$CYAN" \ + HB_STREAM_BOLD="$BOLD" \ + HB_STREAM_NC="$NC" \ + /usr/bin/perl -e ' +use strict; +use warnings; +use utf8; + +$| = 1; + +my %color = map { $_ => ($ENV{"HB_STREAM_" . uc($_)} // "") } + qw(red green yellow blue magenta cyan bold nc); +for my $value (values %color) { + $value =~ s/\\033/\e/g; + $value =~ s/\\e/\e/g; +} + +sub print_version_transition { + my ($line) = @_; + return 0 unless $line =~ /^(\s*)(.*\S)\s+(-\>|=\>)\s+(\S+)(.*)$/; + + my ($indent, $left, $arrow, $new_version, $suffix) = ($1, $2, $3, $4, $5); + my ($old_version) = $left =~ /(\S+)$/; + my $package = substr($left, 0, length($left) - length($old_version)); + + print $indent; + print $color{bold}, $color{cyan}, $package, $color{nc} if length $package; + print $color{yellow}, $old_version, $color{nc}, " "; + print $color{blue}, $arrow, $color{nc}, " "; + print $color{bold}, $color{green}, $new_version, $color{nc}, $suffix, "\n"; + return 1; +} + +sub print_heading { + my ($line) = @_; + my $text = substr($line, 4); + + if ($text =~ /^(Upgrading|Installing|Reinstalling|Fetching|Downloading|Pouring|Would)\s+(.+)$/) { + print $color{blue}, "==>", $color{nc}, " "; + print $color{cyan}, $1, $color{nc}, " "; + print $color{bold}, $color{green}, $2, $color{nc}, "\n"; + } elsif ($text =~ /^(?:Upgraded\s|Installed\s|Successfully\s)/) { + print $color{blue}, "==>", $color{nc}, " ", $color{bold}, $color{green}, $text, $color{nc}, "\n"; + } elsif ($text =~ /^(?:Outdated\s|Disabled\s|Warning)/) { + print $color{blue}, "==>", $color{nc}, " ", $color{yellow}, $text, $color{nc}, "\n"; + } elsif ($text =~ /^(?:Formulae|Casks|New Formulae|New Casks|Caveats|Summary)$/) { + print $color{blue}, "==>", $color{nc}, " ", $color{bold}, $color{magenta}, $text, $color{nc}, "\n"; + } else { + print $color{blue}, "==>", $color{nc}, " ", $color{cyan}, $text, $color{nc}, "\n"; + } +} + +sub colorize_line { + my ($line) = @_; + return if print_version_transition($line); + + if ($line =~ /^==> /) { + print_heading($line); + } elsif ($line =~ /^(?:Warning:|warning:)/) { + print $color{yellow}, $line, $color{nc}, "\n"; + } elsif ($line =~ /^(?:Error:|error:|Failed:|failed:)/) { + print $color{red}, $line, $color{nc}, "\n"; + } elsif ($line =~ /^Removing:/) { + print $color{yellow}, $line, $color{nc}, "\n"; + } elsif ($line =~ /^(?:✔︎ |✔ |🍺 )/ || + $line =~ /was successfully upgraded!|successfully installed!|All dependencies satisfied\./) { + print $color{green}, $line, $color{nc}, "\n"; + } elsif ($line =~ /^(?:Already up-to-date|Updated Homebrew |Updated .*tap|Successfully rebased |Pruned |This operation has freed )/) { + print $color{green}, $line, $color{nc}, "\n"; + } elsif ($line =~ /^ (?:Downloaded|Verified) /) { + print $color{green}, $line, $color{nc}, "\n"; + } else { + print $line, "\n"; + } +} + +my $buffer = ""; +while (sysread(STDIN, my $chunk, 8192)) { + $buffer .= $chunk; + + while (length $buffer) { + my $newline = index($buffer, "\n"); + my $carriage = index($buffer, "\r"); + my $position; + if ($newline < 0) { + $position = $carriage; + } elsif ($carriage < 0) { + $position = $newline; + } else { + $position = $newline < $carriage ? $newline : $carriage; + } + last if $position < 0; + + my $separator = substr($buffer, $position, 1); + # 分块时 CR 可能正好在 CRLF 的边界,等待下一块再决定它是换行还是进度重绘。 + last if $separator eq "\r" && $position + 1 == length($buffer); + + my $record = substr($buffer, 0, $position); + if ($separator eq "\r" && substr($buffer, $position + 1, 1) eq "\n") { + substr($buffer, 0, $position + 2, ""); + colorize_line($record); + } elsif ($separator eq "\n") { + substr($buffer, 0, $position + 1, ""); + colorize_line($record); + } else { + substr($buffer, 0, $position + 1, ""); + print $record, "\r"; + } + } +} + +if (length $buffer) { + if ($buffer =~ s/\r\z//) { + print $buffer, "\r"; + } else { + colorize_line($buffer); + } +} +' +} + run_brew_colored() { local pipeline_status=() - # Homebrew 6 的并行下载队列只有在 stdout 是 TTY 时才会绘制 - # 实时进度。交互式终端中直接运行 brew,保留它的原生进度条与 - # 动态下载状态;重定向到文件或 CI 时再使用按行语义着色。 - if [[ -t 1 ]]; then + # 伪终端保留 Homebrew 的实时进度,流式着色器只处理完整文本行, + # 不会缓冲或改写用 CR 刷新的进度输出。 + if can_stream_colorize_tty_output; then + if /usr/bin/script -q /dev/null env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" | colorize_brew_tty_stream; then + return 0 + fi + pipeline_status=("${PIPESTATUS[@]}") + if ((${pipeline_status[1]:-0} != 0)); then + print_error "failed to colorize Homebrew output." + return "${pipeline_status[1]}" + fi + return "${pipeline_status[0]}" + elif [[ -t 1 ]]; then env -u HOMEBREW_COLOR brew "$@" return $? fi @@ -704,14 +849,17 @@ run_brew_with_retries() { fi : > "$ACTIVE_LOG_FILE" - if [[ -t 1 && "$(uname -s 2>/dev/null || true)" == "Darwin" && -x /usr/bin/script ]]; then - # tee 会把 brew 的 stdout 变成管道,导致 Homebrew 关闭实时进度。 - # macOS script(1) 为 brew 分配伪终端,同时把完整输出记录到 - # 日志,供后续错误分类与重试决策使用。-F 确保记录实时刷新。 - if /usr/bin/script -qF "$ACTIVE_LOG_FILE" env -u HOMEBREW_COLOR brew "$@"; then + if can_stream_colorize_tty_output; then + # 为错误分类保留转录,同时通过伪终端和流式着色兼顾进度条与版本配色。 + if /usr/bin/script -qF "$ACTIVE_LOG_FILE" env -u HOMEBREW_COLOR HOMEBREW_NO_COLOR=1 brew "$@" | colorize_brew_tty_stream; then status=0 else - status=$? + pipeline_status=("${PIPESTATUS[@]}") + status="${pipeline_status[0]}" + if ((${pipeline_status[1]:-0} != 0)); then + print_error "failed to colorize Homebrew output." + status="${pipeline_status[1]}" + fi fi elif env -u HOMEBREW_COLOR brew "$@" 2>&1 | tee "$ACTIVE_LOG_FILE" | colorize_brew_output; then status=0