Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Library/Homebrew/cask/artifact/abstract_uninstall.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ class AbstractUninstall < AbstractArtifact
:rmdir,
].freeze

# Opt-ins to run certain directives during upgrade/reinstall.
UPGRADE_METADATA_KEYS = [
:quit_on_upgrade,
:signal_on_upgrade,
].freeze

def self.from_args(cask, **directives)
new(cask, **directives)
end

attr_reader :directives

def initialize(cask, **directives)
directives.assert_valid_keys(*ORDERED_DIRECTIVES)
directives.assert_valid_keys(*ORDERED_DIRECTIVES, *UPGRADE_METADATA_KEYS)

super
directives[:signal] = Array(directives[:signal]).flatten.each_slice(2).to_a
Expand Down
11 changes: 10 additions & 1 deletion Library/Homebrew/cask/artifact/uninstall.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ def uninstall_phase(upgrade: false, reinstall: false, **options)
filtered_directives = ORDERED_DIRECTIVES.filter do |directive_sym|
next false if directive_sym == :rmdir

next false if (upgrade || reinstall) && UPGRADE_REINSTALL_SKIP_DIRECTIVES.include?(directive_sym)
if (upgrade || reinstall) && UPGRADE_REINSTALL_SKIP_DIRECTIVES.include?(directive_sym)
case directive_sym
when :quit
next false unless directives.fetch(:quit_on_upgrade, false)
when :signal
next false unless directives.fetch(:signal_on_upgrade, false)
else
next false
end
end

true
end
Expand Down
13 changes: 13 additions & 0 deletions Library/Homebrew/rubocops/cask/uninstall_methods_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ class UninstallMethodsOrder < Base

MSG = T.let("`%<method>s` method out of order", String)

# These keys are ignored when checking method order.
# Mirrors AbstractUninstall::UPGRADE_METADATA_KEYS.
UPGRADE_METADATA_KEYS = T.let(
[:quit_on_upgrade, :signal_on_upgrade].freeze,
T::Array[Symbol],
)

sig { params(node: AST::SendNode).void }
def on_send(node)
return unless [:zap, :uninstall].include?(node.method_name)
Expand All @@ -21,6 +28,12 @@ def on_send(node)
return if hash_node.nil? || (!hash_node.is_a?(AST::Node) && !hash_node.hash_type?)

method_nodes = hash_node.pairs.map(&:key)

method_nodes = method_nodes.reject do |method|
name = method.children.first
UPGRADE_METADATA_KEYS.include?(name)
end

expected_order = method_nodes.sort_by { |method| method_order_index(method) }
comments = processed_source.comments

Expand Down
112 changes: 112 additions & 0 deletions Library/Homebrew/test/cask/artifact/uninstall_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,119 @@

RSpec.describe Cask::Artifact::Uninstall, :cask do
describe "#uninstall_phase" do
let(:fake_system_command) { NeverSudoSystemCommand }

include_examples "#uninstall_phase or #zap_phase"

describe "upgrade/reinstall opt-in uninstall directives" do
context "with-uninstall-quit" do
let(:cask) { Cask::CaskLoader.load(cask_path("with-uninstall-quit")) }
let(:artifact) { cask.artifacts.find { |a| a.is_a?(described_class) } }

it "skips :quit by default during upgrade" do
quit_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
quit_called ||= directive == :quit && options[:command] == fake_system_command
end

artifact.uninstall_phase(upgrade: true, command: fake_system_command)

expect(quit_called).to be false
end

it "skips :quit by default during reinstall" do
quit_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
quit_called ||= directive == :quit && options[:command] == fake_system_command
end

artifact.uninstall_phase(reinstall: true, command: fake_system_command)

expect(quit_called).to be false
end
end

context "with-uninstall-quit-on-upgrade" do
let(:cask) { Cask::CaskLoader.load(cask_path("with-uninstall-quit-on-upgrade")) }
let(:artifact) { cask.artifacts.find { |a| a.is_a?(described_class) } }

it "invokes :quit during upgrade" do
quit_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
quit_called ||= directive == :quit && options[:command] == fake_system_command
end

artifact.uninstall_phase(upgrade: true, command: fake_system_command)

expect(quit_called).to be true
end

it "invokes :quit during reinstall" do
quit_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
quit_called ||= directive == :quit && options[:command] == fake_system_command
end

artifact.uninstall_phase(reinstall: true, command: fake_system_command)

expect(quit_called).to be true
end
end

context "with-uninstall-signal" do
let(:cask) { Cask::CaskLoader.load(cask_path("with-uninstall-signal")) }
let(:artifact) { cask.artifacts.find { |a| a.is_a?(described_class) } }

it "skips :signal by default during upgrade" do
signal_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
signal_called ||= directive == :signal && options[:command] == fake_system_command
end

artifact.uninstall_phase(upgrade: true, command: fake_system_command)

expect(signal_called).to be false
end

it "skips :signal by default during reinstall" do
signal_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
signal_called ||= directive == :signal && options[:command] == fake_system_command
end

artifact.uninstall_phase(reinstall: true, command: fake_system_command)

expect(signal_called).to be false
end
end

context "with-uninstall-signal-on-upgrade" do
let(:cask) { Cask::CaskLoader.load(cask_path("with-uninstall-signal-on-upgrade")) }
let(:artifact) { cask.artifacts.find { |a| a.is_a?(described_class) } }

it "invokes :signal during upgrade" do
signal_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
signal_called ||= directive == :signal && options[:command] == fake_system_command
end

artifact.uninstall_phase(upgrade: true, command: fake_system_command)

expect(signal_called).to be true
end

it "invokes :signal during reinstall" do
signal_called = false
allow(artifact).to receive(:dispatch_uninstall_directive) do |directive, **options|
signal_called ||= directive == :signal && options[:command] == fake_system_command
end

artifact.uninstall_phase(reinstall: true, command: fake_system_command)

expect(signal_called).to be true
end
end
end
end

describe "#post_uninstall_phase" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@
end
CASK
end

it "ignores opt-in boolean keys such as quit_on_upgrade and signal_on_upgrade" do
expect_no_offenses(<<~CASK)
cask "foo" do
url "https://example.com/foo.zip"

uninstall quit: "com.example.foo",
quit_on_upgrade: true,
signal: ["TERM", "com.example.foo"],
signal_on_upgrade: true,
script: {
executable: "/usr/local/bin/foo",
sudo: false,
},
pkgutil: "org.foo.bar",
delete: [
"/usr/local/bin/foo",
"/usr/local/bin/foobar",
]
end
CASK
end
end

context "with a single method" do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
cask "with-uninstall-quit-on-upgrade" do
version "1.2.3"
sha256 "8c62a2b791cf5f0da6066a0a4b6e85f62949cd60975da062df44adf887f4370b"

url "file://#{TEST_FIXTURE_DIR}/cask/MyFancyPkg.zip"
homepage "https://brew.sh/fancy-pkg"

pkg "MyFancyPkg/Fancy.pkg"

uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having to always write true here (and for :signal_on_upgrade) feels redundant, since this should never be anything other than true (because false is inferred when the key is omitted).

I wonder if there's a nicer/DRYer way to do this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @carlocab. I agree but can't figure out a perfect option here. Some ideas:

Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: true
Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", when: :upgrade
Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit_on_upgrade: "my.fancy.package.app"
Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall_and_quit "my.fancy.package.app"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I don't have great suggestions, hence the ✅. I was thinking something like

uninstall quit: ["my.fancy.package.app", :on_upgrade]

but not a huge fan of it. I'm fine with

uninstall quit_on_upgrade: "my.fancy.package.app"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, both – agreed it's a bit wordy to always write *_on_upgrade: true.

The main reason I went with this approach is that it lets the new behavior piggy‑back on the existing “directive value + boolean metadata” pattern in the uninstall DSL (e.g., script: { executable: ..., sudo: true }). quit_on_upgrade: true / signal_on_upgrade: true follow that convention: the directive value remains the single source of truth for the bundle IDs, and the opt-in boolean just says “also run this during upgrade/reinstall” without adding a new directive name.

Keeping them as separate metadata keys lets AbstractUninstall validate them alongside the existing directive keys, and lets the RuboCop cop simply treat them as “keys to ignore for ordering” without introducing a new directive name or a special case path like uninstall_and_quit.

The more generic keys you suggested seem to want either context‑dependent semantics (“what does this mean next to other directives?”) which felt harder to explain in the Cookbook and in code comments, or a new entry point that would be a bigger DSL/implementation change than this PR set out to make. In particular, a form like uninstall quit_on_upgrade: "..." or uninstall_and_quit "..." would work fine on its own, but it’s then either a full replacement for uninstall quit: (a bigger DSL change, since we now have two ways to direct “quit this app on uninstall-like operations”) or it becomes an upgrade-only directive that tends to duplicate the same bundle IDs in a second place. Keeping the bundle IDs only on quit: and using a small flag to control when it also runs during upgrade/reinstall sidesteps that and keeps the change smaller.

If we want to reduce the visual noise a bit, one incremental tweak would be to treat the presence of quit_on_upgrade / signal_on_upgrade as the opt‑in and not require = true everywhere, while keeping the rest of the wiring as is.

If there’s consensus that one of the alternative wordings is worth the extra refactor, I’m happy to take guidance and adjust, but my goal here was to keep the opt‑in clearly as metadata, avoid duplicating the bundle IDs, and keep the changes small and local enough to be easy to maintain.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @toobuntu! Rethinking from your message and the code, how about:

Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: :quit

and

Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: :signal

as the behaviour seems to be mutually exclusive (you cannot both quit and signal at once for the same ID on uninstall/reinstall, right?).

if I'm wrong about being exclusive then I think we're back to:

Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: true

which would affect both quitting and signal if either/both are specified.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Library/Homebrew/cask/artifact/uninstall.rb, the uninstall_phase method runs uninstall directives sequentially. Those directive keys are listed in ORDERED_DIRECTIVES in Library/Homebrew/cask/artifact/abstract_uninstall.rb, and the uninstall logic iterates over those keys in their defined order. Nothing prevents both quit and signal from running for the same bundle ID, and there are at least 9 casks in homebrew/cask which do use both quit and signal on the same bundle ID—where signal essentially serves as a fallback if quit didn't suffice.

Distinguishing quit v. signal in the DSL might be an unnecessary complexity, and a single opt-in for both during upgrade/reinstall is likely sufficient. Intuitively, if a cask should quit on upgrade/reinstall, it seems reasonable that it should also signal.

I had intended the two new keys in this PR to be top-level boolean metadata keys alongside the other uninstall directives (though without enforcing an order on them), to keep the diff minimal.

It seems you envision the opt-in flag nested within the quit/signal directive itself, which is both easier to read and more semantically precise. However, it seems that no existing infrastructure supports such per-directive conditional execution flags (e.g.,on_upgrade: true, when: "default" | "always" | "upgrade" | "reinstall", on_upgrade_reinstall: true, etc.). Implementing this would seemingly require refactoring the uninstall DSL parsing, runtime execution logic, and linting cops. Among those options, an on_upgrade: boolean seems simpler to implement than the more extensible when: strings which might be unnecessary given the current needs.

Given that, I have two key queries for direction:

  • Should we simplify the DSL by assuming opting to quit on upgrade/reinstall also implies opting to signal, and vice versa, via a single shared flag (e.g., on_upgrade: true)?
  • Or is a more substantial refactor to embed conditional flags inside each directive desired?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing something like

Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: :quit
Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: :signal
Suggested change
uninstall quit: "my.fancy.package.app", quit_on_upgrade: true
uninstall quit: "my.fancy.package.app", on_upgrade: [:signal, :quit]

seems ideal here, depending on how much refactoring this really requires.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Should we simplify the DSL by assuming opting to quit on upgrade/reinstall also implies opting to signal, and vice versa, via a single shared flag (e.g., on_upgrade: true)?

Either this or what @carlocab has proposed above work for me. No strong preference either way.

end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
cask "with-uninstall-signal-on-upgrade" do
version "1.2.3"
sha256 "8c62a2b791cf5f0da6066a0a4b6e85f62949cd60975da062df44adf887f4370b"

url "file://#{TEST_FIXTURE_DIR}/cask/MyFancyPkg.zip"
homepage "https://brew.sh/fancy-pkg"

pkg "MyFancyPkg/Fancy.pkg"

uninstall signal: [
["TERM", "my.fancy.package.app"],
["KILL", "my.fancy.package.app"],
], signal_on_upgrade: true
end
18 changes: 16 additions & 2 deletions docs/Cask-Cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,8 +794,8 @@ The easiest and most useful `uninstall` directive is [`pkgutil:`](#uninstall-pkg

* **`early_script:`** (string or hash) - like [`script:`](#uninstall-script), but runs early (for special cases, best avoided)
* [`launchctl:`](#uninstall-launchctl) (string or array) - IDs of `launchd` jobs to remove
* [`quit:`](#uninstall-quit) (string or array) - bundle IDs of running applications to quit (does not run when uninstall is initiated by `brew upgrade` or `brew reinstall`)
* [`signal:`](#uninstall-signal) (array of arrays) - signal numbers and bundle IDs of running applications to send a Unix signal to, for when `quit:` does not work (does not run when uninstall is initiated by `brew upgrade` or `brew reinstall`)
* [`quit:`](#uninstall-quit) (string or array) - bundle IDs of running applications to quit (does not run when uninstall is initiated by `brew upgrade` or `brew reinstall` unless `quit_on_upgrade: true` is present)
* [`signal:`](#uninstall-signal) (array of arrays) - signal numbers and bundle IDs of running applications to send a Unix signal to, for when `quit:` does not work (does not run when uninstall is initiated by `brew upgrade` or `brew reinstall` unless `signal_on_upgrade: true` is present)
* [`login_item:`](#uninstall-login_item) (string or array) - names of login items to remove
* [`kext:`](#uninstall-kext) (string or array) - bundle IDs of kexts to unload from the system
* [`script:`](#uninstall-script) (string or hash) - relative path to an uninstall script to be run via *sudo*; use hash if args are needed
Expand Down Expand Up @@ -852,6 +852,14 @@ IDs for all installed `launchd` jobs can be listed using [`list_installed_launch

#### `uninstall` *quit*

Some applications need to be quit (or explicitly signaled) in order to be safely upgraded in-place. By default, Homebrew does not run `:quit` or `:signal` directives when an uninstall is being performed as part of a `brew upgrade` or `brew reinstall` operation. This avoids unexpectedly terminating user processes during automated upgrades and is designed to avoid data loss; `uninstall quit:` is equivalent to a normal macOS quit and can still discard unsaved in-memory state such as web forms or editor buffers.

Use `quit_on_upgrade: true` only when the application genuinely needs to be closed to upgrade or reinstall correctly and you have verified that it handles a normal macOS quit without losing important user data. In that case, you may opt a cask into running this directive during an upgrade or reinstall by adding `quit_on_upgrade: true` to the `uninstall` stanza:

```ruby
uninstall quit: "com.example.app", quit_on_upgrade: true
```

Bundle IDs for currently running applications can be listed using [`list_running_app_ids`](https://github.com/Homebrew/homebrew-cask/blob/HEAD/developer/bin/list_running_app_ids):

```bash
Expand Down Expand Up @@ -892,6 +900,12 @@ Note that when multiple running processes match the given bundle ID, all matchin

Unlike `quit:` directives, Unix signals originate from the current user, not from the superuser. This is construed as a safety feature, since the superuser is capable of bringing down the system via signals. However, this inconsistency could also be considered a bug, and may be addressed in some fashion in a future version.

To opt a cask into running this directive during an upgrade or reinstall, add `signal_on_upgrade: true` to the `uninstall` stanza:

```ruby
uninstall signal: [["TERM", "com.example.daemon"]], signal_on_upgrade: true
```

#### `uninstall` *login_item*

Login items associated with an application bundle on disk can be listed using [`list_login_items_for_app`](https://github.com/Homebrew/homebrew-cask/blob/HEAD/developer/bin/list_login_items_for_app):
Expand Down
Loading