Skip to content

Commit b054d9d

Browse files
committed
[install] Use a Homebrew-installed bundle instead of building over it
A cask and these installers both want to own Tacet.app, and until now each would build over the other: APP_DIR was hardcoded to ~/Applications and the bundle was always rebuilt from the clone. A cask user would end up with two copies, launchd pointed at the one brew does not manage. Worse than untidy. Rebuilding over a cask's bundle re-signs a notarized app with whatever identity is in the environment — usually none — which invalidates the Accessibility and Microphone grants keyed to its designated requirement, and leaves `brew uninstall` looking at something it did not install. Both installers now resolve APP_DIR as $TACET_APP_DIR, then a detected cask install, then ~/Applications. In the cask case they skip the build entirely and the client's uninstall removes only the plist. Machines without the cask are unaffected: brew merely being present is not enough, which is checked, since almost every Mac has it. Solves: cask and installer both claiming the bundle Tests: eleven cases over both scripts — resolution order including an explicit override beating a cask, and that the build skip and the uninstall removal are both guarded by the managed check
1 parent ef1a9e5 commit b054d9d

3 files changed

Lines changed: 204 additions & 26 deletions

File tree

install-client.sh

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,27 @@ set -euo pipefail
4242

4343
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4444
APP_SRC="$REPO_DIR/swift/Packaging/Tacet.app"
45-
APP_DIR="$HOME/Applications"
45+
46+
# Resolution order, most specific first:
47+
# 1. $TACET_APP_DIR — explicit, always wins
48+
# 2. a Homebrew cask install — Homebrew owns that bundle; we point the agent
49+
# at it and never rebuild, overwrite, or remove it
50+
# 3. ~/Applications — built from this clone, the from-source default
51+
#
52+
# Rebuilding over a brew-managed bundle would re-sign it (ad-hoc, with no
53+
# identity in the environment), which invalidates the Accessibility and
54+
# Microphone grants keyed to its designated requirement — the exact failure
55+
# this installer works hardest to avoid — and leaves `brew uninstall` looking
56+
# at something it did not install.
57+
APP_MANAGED=0
58+
if [[ -n "${TACET_APP_DIR:-}" ]]; then
59+
APP_DIR="$TACET_APP_DIR"
60+
elif caskroom="$(brew --caskroom 2>/dev/null)" && [[ -n "$caskroom" && -d "$caskroom/tacet" ]]; then
61+
APP_DIR="/Applications"
62+
APP_MANAGED=1
63+
else
64+
APP_DIR="$HOME/Applications"
65+
fi
4666
APP_DST="$APP_DIR/Tacet.app"
4767

4868
TACET_CONFIG_DIR="$HOME/.config/tacet"
@@ -612,8 +632,16 @@ run_uninstall() {
612632
log "unloaded $AGENT_LABEL"
613633
fi
614634
rm -f "$AGENT_PLIST"
615-
rm -rf "$APP_DST"
616-
log "removed $APP_DST and $AGENT_PLIST"
635+
if [[ "$APP_MANAGED" -eq 1 ]]; then
636+
# Deleting a brew-managed bundle leaves Homebrew believing tacet is still
637+
# installed, so `brew uninstall` then fails and `brew reinstall` is the
638+
# only way back. Removing what we installed means the plist only.
639+
log "removed $AGENT_PLIST"
640+
log "left $APP_DST alone — Homebrew owns it: brew uninstall --cask tacet"
641+
else
642+
rm -rf "$APP_DST"
643+
log "removed $APP_DST and $AGENT_PLIST"
644+
fi
617645
# client.json is deliberately left in place: it holds the shared secret and
618646
# is what a reinstall (or the Hammerspoon client) would want back.
619647
log "left $CLIENT_CONFIG alone — delete it by hand if you meant to."
@@ -663,16 +691,25 @@ case "$MODE" in
663691
uninstall) run_uninstall; exit 0 ;;
664692
esac
665693

666-
log "building the agent"
667-
(cd "$REPO_DIR/swift" && swift build -c release && bash Packaging/build-app.sh)
668-
669-
log "installing to $APP_DST"
670-
mkdir -p "$APP_DIR"
671-
# Replaced wholesale rather than copied over: a stale file left inside the
672-
# bundle invalidates the signature, and the failure surfaces much later as an
673-
# unexplained TCC re-prompt.
674-
rm -rf "$APP_DST"
675-
cp -R "$APP_SRC" "$APP_DST"
694+
if [[ "$APP_MANAGED" -eq 1 ]]; then
695+
# Homebrew already installed the bundle — see the note at APP_DIR.
696+
log "using the Homebrew-installed bundle at $APP_DST (not rebuilding)"
697+
if [[ ! -x "$APP_DST/Contents/MacOS/tacet" ]]; then
698+
err "$APP_DST is missing its binary. Reinstall it: brew reinstall --cask tacet"
699+
exit 1
700+
fi
701+
else
702+
log "building the agent"
703+
(cd "$REPO_DIR/swift" && swift build -c release && bash Packaging/build-app.sh)
704+
705+
log "installing to $APP_DST"
706+
mkdir -p "$APP_DIR"
707+
# Replaced wholesale rather than copied over: a stale file left inside the
708+
# bundle invalidates the signature, and the failure surfaces much later as an
709+
# unexplained TCC re-prompt.
710+
rm -rf "$APP_DST"
711+
cp -R "$APP_SRC" "$APP_DST"
712+
fi
676713

677714
# Must run AFTER the copy (it hashes the installed bundle) and BEFORE the agent
678715
# restarts, so the agent's prompt lands on a cleared entry rather than a stale

install-server.sh

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,27 @@ LABELS=(com.drycodeworks.tacet com.drycodeworks.tacet-whisper)
3434
# Where the running service lives. The clone is
3535
# a place to edit code; a daemon that runs out of it breaks when the checkout
3636
# moves and silently changes behaviour on `git pull`.
37-
APP_DIR="$HOME/Applications"
37+
#
38+
# Resolution order, most specific first:
39+
# 1. $TACET_APP_DIR — explicit, always wins
40+
# 2. a Homebrew cask install — Homebrew owns that bundle; we point launchd
41+
# at it and never rebuild or overwrite it
42+
# 3. ~/Applications — built from this clone, the from-source default
43+
#
44+
# The cask case is not a convenience. Rebuilding over a brew-managed bundle
45+
# would re-sign it (ad-hoc, without a signing identity in the environment),
46+
# which silently invalidates the TCC grants keyed to its designated
47+
# requirement and leaves `brew uninstall` looking at something it did not
48+
# install. Detect it and leave it alone.
49+
APP_MANAGED=0
50+
if [[ -n "${TACET_APP_DIR:-}" ]]; then
51+
APP_DIR="$TACET_APP_DIR"
52+
elif caskroom="$(brew --caskroom 2>/dev/null)" && [[ -n "$caskroom" && -d "$caskroom/tacet" ]]; then
53+
APP_DIR="/Applications"
54+
APP_MANAGED=1
55+
else
56+
APP_DIR="$HOME/Applications"
57+
fi
3858
APP_DST="$APP_DIR/Tacet.app"
3959

4060
# Minimal TOML reader for the three scalars the plists need. Deliberately not a
@@ -404,20 +424,30 @@ fi
404424
# One signed bundle, two roles: `tacet serve` here, `tacet agent` on whatever Mac
405425
# you dictate from. install-client.sh installs the same artifact, so a
406426
# single-machine setup ends up with one copy that plays both parts.
407-
if ! command -v swift >/dev/null 2>&1; then
408-
err "swift not found. Install the Xcode command line tools: xcode-select --install"
409-
exit 1
410-
fi
427+
if [[ "$APP_MANAGED" -eq 1 ]]; then
428+
# Homebrew already installed the bundle. Building here would overwrite a
429+
# notarized bundle with a locally signed one — see the note at APP_DIR.
430+
log "Using the Homebrew-installed bundle at ${APP_DST} (not rebuilding)."
431+
if [[ ! -x "$APP_DST/Contents/MacOS/tacet" ]]; then
432+
err "${APP_DST} is missing its binary. Reinstall it: brew reinstall --cask tacet"
433+
exit 1
434+
fi
435+
else
436+
if ! command -v swift >/dev/null 2>&1; then
437+
err "swift not found. Install the Xcode command line tools: xcode-select --install"
438+
exit 1
439+
fi
411440

412-
log "Building the server..."
413-
(cd "$REPO_DIR/swift" && swift build -c release >/dev/null && bash Packaging/build-app.sh >/dev/null)
441+
log "Building the server..."
442+
(cd "$REPO_DIR/swift" && swift build -c release >/dev/null && bash Packaging/build-app.sh >/dev/null)
414443

415-
log "Installing to ${APP_DST}..."
416-
mkdir -p "$APP_DIR"
417-
# Replaced wholesale: a stale file left inside the bundle invalidates the
418-
# signature, and that surfaces much later as an unexplained TCC re-prompt.
419-
rm -rf "$APP_DST"
420-
cp -R "$REPO_DIR/swift/Packaging/Tacet.app" "$APP_DST"
444+
log "Installing to ${APP_DST}..."
445+
mkdir -p "$APP_DIR"
446+
# Replaced wholesale: a stale file left inside the bundle invalidates the
447+
# signature, and that surfaces much later as an unexplained TCC re-prompt.
448+
rm -rf "$APP_DST"
449+
cp -R "$REPO_DIR/swift/Packaging/Tacet.app" "$APP_DST"
450+
fi
421451

422452
# Prove it runs before a plist points launchd at it. A binary that cannot
423453
# start would otherwise surface as a restart loop with nothing in the log.

tests/test_app_dir_resolution.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Where the installers put the bundle, and when they refuse to touch it.
2+
3+
A Homebrew cask and these installers both want to own Tacet.app. Left alone,
4+
each install would build over the other's copy: the cask's bundle is notarized,
5+
and a rebuild here re-signs it with whatever identity is in the environment —
6+
usually none. That silently invalidates the Accessibility and Microphone grants
7+
keyed to its designated requirement, and leaves `brew uninstall` looking at
8+
something it did not install.
9+
10+
So the installers detect a cask install and step aside. These tests pin the
11+
resolution order and, more importantly, the refusals.
12+
"""
13+
14+
import subprocess
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
ROOT = Path(__file__).resolve().parent.parent
20+
SERVER = ROOT / "install-server.sh"
21+
CLIENT = ROOT / "install-client.sh"
22+
23+
24+
@pytest.fixture
25+
def resolve(tmp_path):
26+
"""Source an installer and report the resolved APP_DIR / APP_MANAGED."""
27+
28+
def run(script: Path, *, caskroom_has_tacet: bool | None, **env: str):
29+
bin_dir = tmp_path / "bin"
30+
bin_dir.mkdir(exist_ok=True)
31+
32+
# caskroom_has_tacet=None means "no brew at all on this machine".
33+
if caskroom_has_tacet is not None:
34+
caskroom = tmp_path / "Caskroom"
35+
caskroom.mkdir(exist_ok=True)
36+
if caskroom_has_tacet:
37+
(caskroom / "tacet").mkdir(exist_ok=True)
38+
brew = bin_dir / "brew"
39+
brew.write_text(
40+
"#!/bin/bash\n"
41+
f"[[ \"$1\" == '--caskroom' ]] && echo '{caskroom}' && exit 0\n"
42+
"exit 1\n"
43+
)
44+
brew.chmod(0o755)
45+
46+
program = f"source {script}\nprintf '%s\\n%s\\n' \"$APP_DIR\" \"$APP_MANAGED\"\n"
47+
r = subprocess.run(
48+
["bash", "-c", program],
49+
capture_output=True,
50+
text=True,
51+
env={"PATH": f"{bin_dir}:/usr/bin:/bin", "HOME": str(tmp_path), **env},
52+
)
53+
assert r.returncode == 0, r.stderr
54+
app_dir, managed = r.stdout.strip().splitlines()[-2:]
55+
return app_dir, managed == "1"
56+
57+
return run
58+
59+
60+
@pytest.mark.parametrize("script", [SERVER, CLIENT], ids=lambda p: p.name)
61+
class TestResolutionOrder:
62+
def test_without_brew_it_builds_into_home(self, resolve, script, tmp_path):
63+
app_dir, managed = resolve(script, caskroom_has_tacet=None)
64+
assert app_dir == f"{tmp_path}/Applications"
65+
assert not managed, "a from-source install owns its bundle"
66+
67+
def test_brew_without_the_cask_is_not_a_cask_install(self, resolve, script, tmp_path):
68+
# Almost every Mac has brew. Its mere presence must not redirect the
69+
# install to /Applications.
70+
app_dir, managed = resolve(script, caskroom_has_tacet=False)
71+
assert app_dir == f"{tmp_path}/Applications"
72+
assert not managed
73+
74+
def test_a_cask_install_is_detected_and_marked_managed(self, resolve, script):
75+
app_dir, managed = resolve(script, caskroom_has_tacet=True)
76+
assert app_dir == "/Applications"
77+
assert managed, "a brew-owned bundle must never be rebuilt over"
78+
79+
def test_an_explicit_override_beats_a_cask_install(self, resolve, script):
80+
# Someone who names a directory means it, even with a cask present.
81+
app_dir, managed = resolve(script, caskroom_has_tacet=True,
82+
TACET_APP_DIR="/opt/custom")
83+
assert app_dir == "/opt/custom"
84+
assert not managed
85+
86+
87+
class TestManagedBundleIsLeftAlone:
88+
"""The refusals, checked at source level.
89+
90+
Running the real install paths would need a signed bundle, a working swift
91+
toolchain and a live launchd — so these assert the guards exist and are
92+
attached to the managed case, which is what the whole mechanism is for.
93+
"""
94+
95+
@pytest.mark.parametrize("script", [SERVER, CLIENT], ids=lambda p: p.name)
96+
def test_the_build_is_skipped_when_homebrew_owns_the_bundle(self, script):
97+
code = script.read_text()
98+
assert 'if [[ "$APP_MANAGED" -eq 1 ]]; then' in code
99+
# The copy-over-the-bundle line must sit inside the else branch.
100+
build_idx = code.index('if [[ "$APP_MANAGED" -eq 1 ]]; then')
101+
assert code.index("swift build -c release", build_idx) > build_idx
102+
103+
def test_uninstall_does_not_delete_a_brew_owned_bundle(self):
104+
# rm -rf on a cask's bundle leaves Homebrew believing tacet is still
105+
# installed: `brew uninstall` then fails and only `brew reinstall`
106+
# recovers. The client is the one with an uninstall path.
107+
code = CLIENT.read_text()
108+
uninstall = code[code.index("run_uninstall()"):]
109+
guard = uninstall.index('"$APP_MANAGED" -eq 1')
110+
removal = uninstall.index('rm -rf "$APP_DST"')
111+
assert guard < removal, "the removal must be guarded by the managed check"

0 commit comments

Comments
 (0)