brew/Library/Homebrew/cask/artifact/abstract_uninstall.rb
Ruoyu Zhong e9ce4a5a4e
Update path to system settings on Ventura
"System Preferences" has been renamed to "System Settings" on Ventura.
Privacy and security settings have moved, too. This commit makes sure
these changes are reflected. (Some adjustments were already made in
#14092.)

Signed-off-by: Ruoyu Zhong <zhongruoyu@outlook.com>
2023-03-17 19:24:07 +08:00

481 lines
16 KiB
Ruby

# typed: true
# frozen_string_literal: true
require "timeout"
require "utils/user"
require "cask/artifact/abstract_artifact"
require "cask/pkg"
require "extend/hash_validator"
using HashValidator
module Cask
module Artifact
# Abstract superclass for uninstall artifacts.
#
# @api private
class AbstractUninstall < AbstractArtifact
extend T::Sig
ORDERED_DIRECTIVES = [
:early_script,
:launchctl,
:quit,
:signal,
:login_item,
:kext,
:script,
:pkgutil,
:delete,
:trash,
:rmdir,
].freeze
def self.from_args(cask, **directives)
new(cask, directives)
end
attr_reader :directives
def initialize(cask, directives)
directives.assert_valid_keys!(*ORDERED_DIRECTIVES)
super(cask, **directives)
directives[:signal] = Array(directives[:signal]).flatten.each_slice(2).to_a
@directives = directives
# This is already included when loading from the API.
return if cask.loaded_from_api?
return unless directives.key?(:kext)
cask.caveats do
T.bind(self, ::Cask::DSL::Caveats)
kext
end
end
def to_h
directives.to_h
end
sig { override.returns(String) }
def summarize
to_h.flat_map { |key, val| Array(val).map { |v| "#{key.inspect} => #{v.inspect}" } }.join(", ")
end
private
def dispatch_uninstall_directives(**options)
ORDERED_DIRECTIVES.each do |directive_sym|
dispatch_uninstall_directive(directive_sym, **options)
end
end
def dispatch_uninstall_directive(directive_sym, **options)
return unless directives.key?(directive_sym)
args = directives[directive_sym]
send("uninstall_#{directive_sym}", *(args.is_a?(Hash) ? [args] : args), **options)
end
def stanza
self.class.dsl_key
end
# Preserve prior functionality of script which runs first. Should rarely be needed.
# :early_script should not delete files, better defer that to :script.
# If cask writers never need :early_script it may be removed in the future.
def uninstall_early_script(directives, **options)
uninstall_script(directives, directive_name: :early_script, **options)
end
# :launchctl must come before :quit/:signal for cases where app would instantly re-launch
def uninstall_launchctl(*services, command: nil, **_)
booleans = [false, true]
all_services = []
# if launchctl item contains a wildcard, find matching process(es)
services.each do |service|
all_services << service unless service.include?("*")
next unless service.include?("*")
found_services = find_launchctl_with_wildcard(service)
next if found_services.blank?
found_services.each { |found_service| all_services << found_service }
end
all_services.each do |service|
ohai "Removing launchctl service #{service}"
booleans.each do |with_sudo|
plist_status = command.run(
"/bin/launchctl",
args: ["list", service],
sudo: with_sudo, print_stderr: false
).stdout
if plist_status.start_with?("{")
command.run!("/bin/launchctl", args: ["remove", service], sudo: with_sudo)
sleep 1
end
paths = [
+"/Library/LaunchAgents/#{service}.plist",
+"/Library/LaunchDaemons/#{service}.plist",
]
paths.each { |elt| elt.prepend(Dir.home).freeze } unless with_sudo
paths = paths.map { |elt| Pathname(elt) }.select(&:exist?)
paths.each do |path|
command.run!("/bin/rm", args: ["-f", "--", path], sudo: with_sudo)
end
# undocumented and untested: pass a path to uninstall :launchctl
next unless Pathname(service).exist?
command.run!("/bin/launchctl", args: ["unload", "-w", "--", service], sudo: with_sudo)
command.run!("/bin/rm", args: ["-f", "--", service], sudo: with_sudo)
sleep 1
end
end
end
def running_processes(bundle_id)
system_command!("/bin/launchctl", args: ["list"])
.stdout.lines.drop(1)
.map { |line| line.chomp.split("\t") }
.map { |pid, state, id| [pid.to_i, state.to_i, id] }
.select do |(pid, _, id)|
pid.nonzero? && /\A(?:application\.)?#{Regexp.escape(bundle_id)}(?:\.\d+){0,2}\Z/.match?(id)
end
end
def find_launchctl_with_wildcard(search)
regex = Regexp.escape(search).gsub("\\*", ".*")
system_command!("/bin/launchctl", args: ["list"])
.stdout.lines.drop(1) # skip stdout column headers
.map do |line|
pid, _state, id = line.chomp.split(/\s+/)
id if pid.to_i.nonzero? && id.match?(regex)
end.compact
end
sig { returns(String) }
def automation_access_instructions
navigation_path = if MacOS.version >= :ventura
"System Settings → Privacy & Security"
else
"System Preferences → Security & Privacy → Privacy"
end
<<~EOS
Enable Automation access for "Terminal → System Events" in:
#{navigation_path} → Automation
if you haven't already.
EOS
end
# :quit/:signal must come before :kext so the kext will not be in use by a running process
def uninstall_quit(*bundle_ids, command: nil, **_)
bundle_ids.each do |bundle_id|
next unless running?(bundle_id)
unless T.must(User.current).gui?
opoo "Not logged into a GUI; skipping quitting application ID '#{bundle_id}'."
next
end
ohai "Quitting application '#{bundle_id}'..."
begin
Timeout.timeout(10) do
Kernel.loop do
next unless quit(bundle_id).success?
next if running?(bundle_id)
puts "Application '#{bundle_id}' quit successfully."
break
end
end
rescue Timeout::Error
opoo "Application '#{bundle_id}' did not quit. #{automation_access_instructions}"
end
end
end
def running?(bundle_id)
script = <<~JAVASCRIPT
'use strict';
ObjC.import('stdlib')
function run(argv) {
try {
var app = Application(argv[0])
if (app.running()) {
$.exit(0)
}
} catch (err) { }
$.exit(1)
}
JAVASCRIPT
system_command("osascript", args: ["-l", "JavaScript", "-e", script, bundle_id],
print_stderr: true).status.success?
end
def quit(bundle_id)
script = <<~JAVASCRIPT
'use strict';
ObjC.import('stdlib')
function run(argv) {
var app = Application(argv[0])
try {
app.quit()
} catch (err) {
if (app.running()) {
$.exit(1)
}
}
$.exit(0)
}
JAVASCRIPT
system_command "osascript", args: ["-l", "JavaScript", "-e", script, bundle_id],
print_stderr: false
end
private :quit
# :signal should come after :quit so it can be used as a backup when :quit fails
def uninstall_signal(*signals, command: nil, **_)
signals.each do |pair|
raise CaskInvalidError.new(cask, "Each #{stanza} :signal must consist of 2 elements.") unless pair.size == 2
signal, bundle_id = pair
ohai "Signalling '#{signal}' to application ID '#{bundle_id}'"
pids = running_processes(bundle_id).map(&:first)
next unless pids.any?
# Note that unlike :quit, signals are sent from the current user (not
# upgraded to the superuser). This is a todo item for the future, but
# there should be some additional thought/safety checks about that, as a
# misapplied "kill" by root could bring down the system. The fact that we
# learned the pid from AppleScript is already some degree of protection,
# though indirect.
odebug "Unix ids are #{pids.inspect} for processes with bundle identifier #{bundle_id}"
Process.kill(signal, *pids)
sleep 3
end
end
def uninstall_login_item(*login_items, command: nil, upgrade: false, **_)
return if upgrade
apps = cask.artifacts.select { |a| a.class.dsl_key == :app }
derived_login_items = apps.map { |a| { path: a.target } }
[*derived_login_items, *login_items].each do |item|
type, id = if item.respond_to?(:key) && item.key?(:path)
["path", item[:path]]
else
["name", item]
end
ohai "Removing login item #{id}"
result = system_command(
"osascript",
args: [
"-e",
%Q(tell application "System Events" to delete every login item whose #{type} is #{id.to_s.inspect}),
],
)
opoo "Removal of login item #{id} failed. #{automation_access_instructions}" unless result.success?
sleep 1
end
end
# :kext should be unloaded before attempting to delete the relevant file
def uninstall_kext(*kexts, command: nil, **_)
kexts.each do |kext|
ohai "Unloading kernel extension #{kext}"
is_loaded = system_command!("/usr/sbin/kextstat", args: ["-l", "-b", kext], sudo: true).stdout
if is_loaded.length > 1
system_command!("/sbin/kextunload", args: ["-b", kext], sudo: true)
sleep 1
end
system_command!("/usr/sbin/kextfind", args: ["-b", kext], sudo: true).stdout.chomp.lines.each do |kext_path|
ohai "Removing kernel extension #{kext_path}"
system_command!("/bin/rm", args: ["-rf", kext_path], sudo: true)
end
end
end
# :script must come before :pkgutil, :delete, or :trash so that the script file is not already deleted
def uninstall_script(directives, directive_name: :script, force: false, command: nil, **_)
# TODO: Create a common `Script` class to run this and Artifact::Installer.
executable, script_arguments = self.class.read_script_arguments(directives,
"uninstall",
{ must_succeed: true, sudo: false },
{ print_stdout: true },
directive_name)
ohai "Running uninstall script #{executable}"
raise CaskInvalidError.new(cask, "#{stanza} :#{directive_name} without :executable.") if executable.nil?
executable_path = staged_path_join_executable(executable)
if (executable_path.absolute? && !executable_path.exist?) ||
(!executable_path.absolute? && (which executable_path).nil?)
message = "uninstall script #{executable} does not exist"
raise CaskError, "#{message}." unless force
opoo "#{message}; skipping."
return
end
command.run(executable_path, **script_arguments)
sleep 1
end
def uninstall_pkgutil(*pkgs, command: nil, **_)
ohai "Uninstalling packages; your password may be necessary:"
pkgs.each do |regex|
::Cask::Pkg.all_matching(regex, command).each do |pkg|
puts pkg.package_id
pkg.uninstall
end
end
end
def each_resolved_path(action, paths)
return enum_for(:each_resolved_path, action, paths) unless block_given?
paths.each do |path|
resolved_path = Pathname.new(path)
resolved_path = resolved_path.expand_path if path.to_s.start_with?("~")
if resolved_path.relative? || resolved_path.split.any? { |part| part.to_s == ".." }
opoo "Skipping #{Formatter.identifier(action)} for relative path '#{path}'."
next
end
if MacOS.undeletable?(resolved_path)
opoo "Skipping #{Formatter.identifier(action)} for undeletable path '#{path}'."
next
end
begin
yield path, Pathname.glob(resolved_path)
rescue Errno::EPERM
raise if File.readable?(File.expand_path("~/Library/Application Support/com.apple.TCC"))
navigation_path = if MacOS.version >= :ventura
"System Settings → Privacy & Security"
else
"System Preferences → Security & Privacy → Privacy"
end
odie "Unable to remove some files. Please enable Full Disk Access for your terminal under " \
"#{navigation_path} → Full Disk Access."
end
end
end
def uninstall_delete(*paths, command: nil, **_)
return if paths.empty?
ohai "Removing files:"
each_resolved_path(:delete, paths) do |path, resolved_paths|
puts path
command.run!(
"/usr/bin/xargs",
args: ["-0", "--", "/bin/rm", "-r", "-f", "--"],
input: resolved_paths.join("\0"),
sudo: true,
)
end
end
def uninstall_trash(*paths, **options)
return if paths.empty?
resolved_paths = each_resolved_path(:trash, paths).to_a
ohai "Trashing files:", resolved_paths.map(&:first)
trash_paths(*resolved_paths.flat_map(&:last), **options)
end
def trash_paths(*paths, command: nil, **_)
return if paths.empty?
stdout, stderr, = system_command HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift",
args: paths,
print_stderr: false
trashed = stdout.split(":").sort
untrashable = stderr.split(":").sort
return trashed, untrashable if untrashable.empty?
untrashable.delete_if do |path|
Utils.gain_permissions(path, ["-R"], SystemCommand) do
system_command! HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift",
args: [path],
print_stderr: false
end
true
rescue
false
end
opoo "The following files could not be trashed, please do so manually:"
$stderr.puts untrashable
[trashed, untrashable]
end
def all_dirs?(*directories)
directories.all?(&:directory?)
end
def recursive_rmdir(*directories, command: nil, **_)
success = T.let(true, T::Boolean)
each_resolved_path(:rmdir, directories) do |_path, resolved_paths|
resolved_paths.select(&method(:all_dirs?)).each do |resolved_path|
puts resolved_path.sub(Dir.home, "~")
if (ds_store = resolved_path.join(".DS_Store")).exist?
command.run!("/bin/rm", args: ["-f", "--", ds_store], sudo: true, print_stderr: false)
end
unless recursive_rmdir(*resolved_path.children, command: command)
success = false
next
end
status = command.run("/bin/rmdir", args: ["--", resolved_path], sudo: true, print_stderr: false).success?
success &= status
end
end
success
end
def uninstall_rmdir(*args, **kwargs)
return if args.empty?
ohai "Removing directories if empty:"
recursive_rmdir(*args, **kwargs)
end
end
end
end