From 86b4ad22ea3df9392e14959dd70bfd69d727b907 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 17:48:58 -0400 Subject: [PATCH] feat(macos): add per-user .pkg installer script Packages the already-built, signed, notarized .plugin bundle into a double-click .pkg using pkgbuild + productbuild, targeting the installing user's ~/Library/Application Support/obs-studio/plugins with no admin password (Distribution plus a relative pkgbuild --install-location). Validates the input bundle is present, signed, and carries its engine before packaging; refuses --notarize without --sign at argument time. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/make-macos-installer.sh | 261 ++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100755 scripts/make-macos-installer.sh diff --git a/scripts/make-macos-installer.sh b/scripts/make-macos-installer.sh new file mode 100755 index 0000000..79aef69 --- /dev/null +++ b/scripts/make-macos-installer.sh @@ -0,0 +1,261 @@ +#!/bin/bash +# Build the macOS .pkg installer for CoreVideo, from an already-built .plugin +# bundle (scripts/make-macos-bundle.sh produces that bundle; this script does +# not build or rebuild it). +# +# Usage: +# scripts/make-macos-installer.sh --bundle PATH --version X.Y.Z [options] +# +# --bundle PATH The signed, notarized .plugin bundle to package +# (required). Not modified; read-only input. +# --version X.Y.Z Package version (required). Must match the bundle's own +# CFBundleShortVersionString -- see the mismatch check +# below for why that is a hard error, not a warning. +# --out DIR Where to write the .pkg (default: directory containing +# --bundle) +# --sign IDENTITY productbuild/pkgbuild signing identity. Omit for an +# unsigned local-test pkg (installable and runnable +# through `installer`, just not distributable or +# notarizable). A DISTRIBUTABLE build needs a +# "Developer ID Installer: ..." identity -- note that +# this is a DIFFERENT certificate from the +# "Developer ID Application: ..." one the bundle itself +# was signed with; `security find-identity -v` lists +# both kinds side by side and it is easy to paste the +# wrong one. +# --notarize PROF After signing, submit the .pkg to Apple with notarytool +# using this stored credential profile, then staple the +# ticket to the .pkg itself. Requires --sign; refuses +# without it (see below). +# +# THE INSTALL LOCATION IS PER-USER BY DESIGN, NOT A FLAG. +# +# The product owner chose ~/Library/Application Support/obs-studio/plugins +# specifically to avoid the admin-password prompt a system-wide /Library +# install would force. A .pkg's payload is normally written by a root-owned +# installer process, so "just write to $HOME" is not available the ordinary +# way -- $HOME inside a postinstall script IS ROOT'S HOME, and that is a +# classic way to silently install into /var/root instead of the real user's +# home. The mechanism that actually gets root's installer process to write +# into an unprivileged user's home is a Distribution package that declares: +# +# combined with a pkgbuild --install-location that is RELATIVE (no leading +# slash) -- an absolute path bypasses domain resolution entirely and pins the +# write to whatever that absolute path names. Both pieces below +# (INSTALL_LOCATION and the line in the generated distribution.xml) +# are load-bearing together; changing one without the other breaks this +# silently, so neither is exposed as a flag. Verified empirically on this +# machine (not merely assumed -- Apple has tightened per-user installs over +# the years): `installer -pkg out.pkg -target CurrentUserHomeDirectory` runs +# with NO password prompt, "Installing at base path" reports the real user's +# home, the payload lands owned by that user (not root), and +# `pkgutil --volume ~ --pkgs` shows the receipt registered against that +# user's home volume -- `pkgutil --pkgs` (no --volume) shows nothing, because +# a per-user install never touches the system-wide receipt database at all. + +set -euo pipefail + +IDENTIFIER="us.iamfatness.corevideo.obs-zoom-plugin" +INSTALL_LOCATION="Library/Application Support/obs-studio/plugins" +BUNDLE="" +OUT_DIR="" +VERSION="" +SIGN_ID="" +NOTARIZE_PROFILE="" + +while [ $# -gt 0 ]; do + case "$1" in + --bundle) BUNDLE="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --sign) SIGN_ID="$2"; shift 2 ;; + --notarize) NOTARIZE_PROFILE="$2"; shift 2 ;; + -h|--help) sed -n '2,54p' "$0"; exit 0 ;; + *) echo "error: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +# Validated HERE, before anything is built: this combination can only fail +# (Apple's notary service rejects an unsigned .pkg outright), and a 613 MB +# bundle takes long enough to package and upload that finding this out +# afterwards wastes minutes to say something knowable at argument time. Same +# pattern as make-macos-bundle.sh's --notarize/--sign check. +if [ -n "$NOTARIZE_PROFILE" ]; then + [ -n "$SIGN_ID" ] || { + echo "error: --notarize needs a --sign identity; Apple rejects an" >&2 + echo " unsigned installer package." >&2; exit 2; } +fi + +# Not fatal -- codesign/productbuild will refuse the wrong certificate TYPE on +# their own -- but this exact mistake is one line away: this project's two +# Developer ID identities differ only in the word "Application" vs +# "Installer", and only the latter is valid here. +if [ -n "$SIGN_ID" ]; then + case "$SIGN_ID" in + *Installer*) ;; + *) echo "warning: --sign identity '$SIGN_ID' does not look like a" >&2 + echo " \"Developer ID Installer\" certificate; pkgbuild" >&2 + echo " and productbuild need the Installer identity, not" >&2 + echo " the Application one used to sign the bundle." >&2 ;; + esac +fi + +[ -n "$BUNDLE" ] || { echo "error: --bundle is required" >&2; exit 2; } +[ -n "$VERSION" ] || { echo "error: --version is required" >&2; exit 2; } + +# ── Validate the input bundle BEFORE doing any packaging work ────────────── +# +# An installer that cheerfully packages a broken bundle is worse than no +# installer: the person running it sees "install successful" and only finds +# out the plugin can't join a meeting (or doesn't load at all) much later, +# with no signal pointing back at "the .pkg was built from a bad bundle." +# Two bugs of exactly that shape (an engine-less bundle, a bundle missing its +# effects) were fixed in make-macos-bundle.sh the same day this script was +# written, both silent until someone noticed the symptom downstream. This +# script is not in the business of re-deriving every one of that script's +# checks, but the three failure modes explicitly in scope here -- missing, +# unsigned, engine-less -- are checked up front and hard. +[ -d "$BUNDLE" ] || { + echo "error: bundle '$BUNDLE' does not exist" >&2; exit 1; } + +BUNDLE_NAME="$(basename "$BUNDLE")" +INFO_PLIST="$BUNDLE/Contents/Info.plist" +[ -f "$INFO_PLIST" ] || { + echo "error: '$BUNDLE' has no Contents/Info.plist; not a bundle" >&2 + exit 1; } + +BUNDLE_EXEC="$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" \ + "$INFO_PLIST" 2>/dev/null)" +[ -n "$BUNDLE_EXEC" ] && [ -f "$BUNDLE/Contents/MacOS/$BUNDLE_EXEC" ] || { + echo "error: '$BUNDLE' has no Contents/MacOS/\$CFBundleExecutable;" >&2 + echo " the module binary itself is missing" >&2; exit 1; } + +# codesign --verify --strict, not just `codesign -d`: the latter happily +# describes a signature that no longer matches the bundle's actual contents +# (e.g. touched after signing). --strict is what catches that, and what an +# ad-hoc ("-") signature also fails, which is exactly the "unsigned" case this +# check exists for -- pkgbuild does not itself refuse an ad-hoc-signed +# component, and shipping one would silently hand out a build Gatekeeper +# rejects on a tester's Mac. +codesign --verify --strict "$BUNDLE" 2>&1 || { + echo "error: '$BUNDLE' failed codesign --verify --strict; it is" >&2 + echo " unsigned, ad-hoc signed, or its signature no longer" >&2 + echo " matches its contents. Build with make-macos-bundle.sh's" >&2 + echo " --sign \"Developer ID Application: ...\" first." >&2 + exit 1; } + +# The engine is not optional -- see make-macos-bundle.sh's own hard stop for +# the identical check and the live incident that motivated it (a bundle +# without ZoomObsEngine.app loads into OBS and LOOKS fine; it just can't join +# a meeting). This script trusts that stop for "was the bundle built right" +# and re-checks it anyway for "is the SPECIFIC bundle passed to --bundle, +# right now, the one that has it" -- the two scripts run at different times, +# often days apart, and nothing stops someone pointing --bundle at a stale or +# hand-edited copy. +ENGINE_APP="$BUNDLE/Contents/MacOS/ZoomObsEngine.app" +[ -d "$ENGINE_APP" ] && [ -f "$ENGINE_APP/Contents/MacOS/ZoomObsEngine" ] || { + echo "error: '$BUNDLE' has no Contents/MacOS/ZoomObsEngine.app with its" >&2 + echo " own executable; this bundle cannot join a meeting and must" >&2 + echo " not be packaged. Rebuild with make-macos-bundle.sh (no" >&2 + echo " --no-engine)." >&2 + exit 1; } + +# The version stamped into the .pkg has to match what is actually inside it. +# pkgutil, Software Update-style upgrade detection, and a support ticket that +# quotes "Installer said 0.1.45 but Help > About says 0.1.44" all key off +# this number, and a --version typo here is invisible until someone hits one +# of those. Comparing against the bundle's own baked-in version turns that +# into a build-time error instead of a support ticket. +BUNDLE_VERSION="$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" \ + "$INFO_PLIST" 2>/dev/null)" +if [ -n "$BUNDLE_VERSION" ] && [ "$BUNDLE_VERSION" != "$VERSION" ]; then + echo "error: --version '$VERSION' does not match the bundle's own" >&2 + echo " CFBundleShortVersionString '$BUNDLE_VERSION'" >&2 + exit 2 +fi + +OUT_DIR="${OUT_DIR:-$(cd "$(dirname "$BUNDLE")" && pwd)}" +mkdir -p "$OUT_DIR" +OUT_PKG="$OUT_DIR/CoreVideo-Setup-$VERSION.pkg" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +COMPONENT_PKG="$WORKDIR/component.pkg" + +# --component, not --root: the bundle already IS the single item to install +# (it carries its own Info.plist, which is what makes pkgbuild recognize it +# as a bundle-style component rather than a loose directory tree), so there is +# no need to stage a synthetic root just to get the same payload path. +# --install-location is RELATIVE (see the top-of-file comment for why that +# specific property is what makes the per-user domain resolution below work +# at all) and matches nothing on this machine's absolute filesystem until +# productbuild's directive resolves it against a target volume. +COMPONENT_SIGN_ARGS=() +if [ -n "$SIGN_ID" ]; then + COMPONENT_SIGN_ARGS=(--sign "$SIGN_ID") +fi +pkgbuild --component "$BUNDLE" \ + --install-location "$INSTALL_LOCATION" \ + --identifier "$IDENTIFIER" \ + --version "$VERSION" \ + ${COMPONENT_SIGN_ARGS[@]+"${COMPONENT_SIGN_ARGS[@]}"} \ + "$COMPONENT_PKG" + +# The Distribution wrapper is what actually carries the per-user domain +# declaration -- a bare component .pkg (what pkgbuild alone produces) has no +# way to express "resolve this against the current user's home, not root's." +# `customize="never"` keeps this a silent double-click install with no +# choices screen, matching the Windows installer's one-click experience this +# script exists to match. `enable_anywhere="false"` is deliberate, not +# redundant with `enable_localSystem="false"`: "anywhere" is a THIRD domain +# (an external volume picker) distinct from local-system, and leaving it on +# offers a volume-choice UI this installer has no reason to show. +DIST_XML="$WORKDIR/distribution.xml" +cat > "$DIST_XML" < + + CoreVideo for OBS + + + + + + + + + + + + component.pkg + +XML + +DIST_SIGN_ARGS=() +if [ -n "$SIGN_ID" ]; then + DIST_SIGN_ARGS=(--sign "$SIGN_ID") +fi +productbuild --distribution "$DIST_XML" --package-path "$WORKDIR" \ + ${DIST_SIGN_ARGS[@]+"${DIST_SIGN_ARGS[@]}"} \ + "$OUT_PKG" + +echo "built: $OUT_PKG (version $VERSION)" + +# ── Notarization ────────────────────────────────────────────────────────── +# +# Unlike a .plugin bundle, a .pkg is directly submittable and directly +# staplable -- no zip-for-submission dance, because notarytool accepts a bare +# .pkg and the staple target is the same file either way. +if [ -n "$NOTARIZE_PROFILE" ]; then + echo "submitting to Apple (613 MB — expect several minutes)…" + xcrun notarytool submit "$OUT_PKG" \ + --keychain-profile "$NOTARIZE_PROFILE" --wait + + xcrun stapler staple "$OUT_PKG" + xcrun stapler validate "$OUT_PKG" + # The question a tester's Mac actually asks before running the + # installer. `-t install` is the assessment type for a .pkg, matching + # make-macos-bundle.sh's own `-t install` use for the bundle it produces. + spctl --assess -vv -t install "$OUT_PKG" + echo "notarized and stapled: $OUT_PKG" +fi