From 65fdae15460e7b31a3f735daa4128b9c68e20342 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 01:54:42 +0000 Subject: [PATCH] Make macOS pkg per-user only (~/Applications) System installs land in /Applications where Velopack's UpdateMac may not be able to replace the .app during auto-update. Restricting the distribution domains to currentUserHome skips the Destination Select page and needs no admin rights; the postinstall script's sudo prefixes are dropped since it already runs as the installing user. --- scripts/guibuild.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/guibuild.py b/scripts/guibuild.py index 65aee16..aabbe65 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -381,6 +381,8 @@ def build_velopack(version: str) -> Path: setup = next(iter(sorted(releases_dir.glob(f"*{artifact_ext}"))), None) if setup is None: raise RuntimeError(f"vpk produced no *{artifact_ext} in {releases_dir}") + if sys.platform == "darwin": + force_macos_user_install(setup) artifact = ( _REPO_ROOT / "build" @@ -391,6 +393,46 @@ def build_velopack(version: str) -> Path: return artifact +def force_macos_user_install(pkg: Path) -> None: + """Restrict the Velopack-generated pkg to per-user installs (~/Applications). + + Velopack hardcodes two install domains (currentUserHome + localSystem) in + the distribution XML. System installs land in /Applications, which the + user may not own — Velopack's UpdateMac then cannot replace the .app on + auto-update. With a single domain, macOS Installer skips the Destination + Select page and installs to ~/Applications without admin rights. + + Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall + script: under a per-user install the script already runs as the + installing user, and sudo would fail for lack of a tty. + """ + expanded = pkg.with_name(pkg.stem + "-expanded") + shutil.rmtree(expanded, ignore_errors=True) + # --expand-full also expands the component pkg, exposing its Scripts dir + subprocess.run(["pkgutil", "--expand-full", str(pkg), str(expanded)], check=True) + + dist_xml = expanded / "Distribution" + xml = dist_xml.read_text() + new_xml, count = re.subn( + r"]*/>", + '', + xml, + ) + if count != 1: + raise RuntimeError("Unexpected distribution.xml: not found") + dist_xml.write_text(new_xml) + + postinstalls = list(expanded.glob("*.pkg/Scripts/postinstall")) + if len(postinstalls) != 1: + raise RuntimeError(f"Unexpected pkg layout: postinstalls={postinstalls}") + postinstall = postinstalls[0] + script = postinstall.read_text() + postinstall.write_text(script.replace('sudo -u "$USER" ', "")) + + subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True) + shutil.rmtree(expanded) + + def read_version() -> str: """Read version via setuptools_scm (same logic as hatch-vcs).""" return setuptools_scm.get_version(root=str(_REPO_ROOT))