#!/usr/bin/env bash # No `-e` (we tolerate benign non-zero returns); keep -Euo set -Euo pipefail LOG_DIR="/tmp" LOG_FILE="${LOG_DIR}/Install-$(date +%Y%m%d-%H%M%S).log" exec > >(tee -a "$LOG_FILE") 2>&1 echo "Logging to: $LOG_FILE" TARGET_DIR="/Applications" APP_NAME="repertory.app" # Embedded at pack time (from CFBundleIdentifier prefix) LABEL_PREFIX="__LABEL_PREFIX__" UI_LABEL="${LABEL_PREFIX}.ui" staged="" backup="" snapfile="" skip_ui_launch=0 log() { printf "[%(%H:%M:%S)T] %s\n" -1 "$*"; } warn() { log "WARN: $*"; } die() { log "ERROR: $*" exit 2 } here="$(cd "$(dirname "$0")" && pwd)" # Locate source app on the DMG (supports hidden payload dirs) src_app="${here}/${APP_NAME}" if [[ ! -d "$src_app" ]]; then src_app="$(/usr/bin/find "$here" -type d -name "$APP_NAME" -print -quit 2>/dev/null || true)" fi [[ -d "$src_app" ]] || die "Could not find ${APP_NAME} on this disk image." app_basename="$(basename "$src_app")" dest_app="${TARGET_DIR}/${APP_NAME}" bundle_id_of() { /usr/bin/defaults read "$1/Contents/Info" CFBundleIdentifier 2>/dev/null || true; } bundle_exec_of() { /usr/bin/defaults read "$1/Contents/Info" CFBundleExecutable 2>/dev/null || echo "${app_basename%.app}"; } bundle_version_of() { /usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$1/Contents/Info.plist" 2>/dev/null || /usr/bin/defaults read "$1/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo "(unknown)" } bundle_build_of() { /usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$1/Contents/Info.plist" 2>/dev/null || /usr/bin/defaults read "$1/Contents/Info" CFBundleVersion 2>/dev/null || echo "(unknown)" } # Require /Applications; prompt for sudo if needed; abort if cannot elevate USE_SUDO=0 SUDO="" ensure_target_writable() { if mkdir -p "${TARGET_DIR}/.repertory_install_test.$$" 2>/dev/null; then rmdir "${TARGET_DIR}/.repertory_install_test.$$" 2>/dev/null || true USE_SUDO=0 SUDO="" return 0 fi if command -v sudo >/dev/null 2>&1; then log "Elevating privileges to write to ${TARGET_DIR}…" sudo -v || die "Administrator privileges required to install into ${TARGET_DIR}." USE_SUDO=1 SUDO="sudo" return 0 fi die "Cannot write to ${TARGET_DIR} and sudo is unavailable." } # ----- STRICT LABEL PREFIX GATE (fail if invalid) ----- _is_valid_label_prefix() { local p="${1:-}" [[ -n "$p" ]] && [[ "$p" != "__LABEL_PREFIX__" ]] && [[ "$p" =~ ^[A-Za-z0-9._-]+$ ]] && [[ "$p" == *.* ]] } if ! _is_valid_label_prefix "${LABEL_PREFIX:-}"; then die "Invalid LABEL_PREFIX in installer (value: \"${LABEL_PREFIX:-}\"). Rebuild the DMG so the installer contains a valid reverse-DNS prefix." fi # ----- LaunchServices helpers ----- ls_prune_bundle_id() { local bundle_id="$1" keep_path="$2" [[ -z "$bundle_id" ]] && return 0 local search_roots=("/Applications" "$HOME/Applications" "/Volumes") if [[ -n "${here:-}" && "$here" == /Volumes/* ]]; then search_roots+=("$here"); fi local candidates="" for root in "${search_roots[@]}"; do [[ -d "$root" ]] || continue candidates+=$'\n'"$(/usr/bin/mdfind -onlyin "$root" "kMDItemCFBundleIdentifier == '${bundle_id}'" 2>/dev/null || true)" done # Include backups adjacent to keep_path (quote-safe) local parent_dir="${keep_path%/*.app}" candidates+=$'\n'$(/bin/ls -1d "${parent_dir}/"*.bak 2>/dev/null || true) local LSREG="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" printf "%s\n" "$candidates" | /usr/bin/awk 'NF' | /usr/bin/sort -u | while IFS= read -r p; do [[ -z "$p" || ! -d "$p" || "$p" == "$keep_path" ]] && continue log "Unregistering older LS entry for ${bundle_id}: $p" "$LSREG" -u "$p" >/dev/null 2>&1 || true done } # ----- FUSE unmount (no process killing here) ----- is_mounted() { /sbin/mount | /usr/bin/awk '{print $3}' | /usr/bin/grep -Fx "${1:-}" >/dev/null 2>&1; } _list_repertory_fuse_mounts() { /sbin/mount | /usr/bin/grep -Ei 'macfuse|osxfuse' | /usr/bin/awk '{print $3}' | /usr/bin/grep -i "repertory" || true; } _unmount_one() { local mnt="${1:-}" [[ -n "$mnt" ]] || return 0 /usr/sbin/diskutil unmount "$mnt" >/dev/null 2>&1 || /sbin/umount "$mnt" >/dev/null 2>&1 || true if is_mounted "$mnt"; then /usr/sbin/diskutil unmount force "$mnt" >/dev/null 2>&1 || /sbin/umount -f "$mnt" >/dev/null 2>&1 || true fi for _ in {1..20}; do is_mounted "$mnt" || return 0 sleep 0.25 done return 1 } unmount_existing_repertory_volumes() { # Hard-fail on the first unmount problem. while IFS= read -r mnt; do [[ -z "$mnt" ]] && continue log "Unmounting FUSE mount: $mnt" if ! _unmount_one "$mnt"; then warn "Failed to unmount $mnt" return 1 fi done < <(_list_repertory_fuse_mounts) sync || true sleep 0.3 return 0 } # ----- user LaunchAgents (by LABEL_PREFIX only) ----- get_plist_label() { /usr/bin/defaults read "$1" Label 2>/dev/null || /usr/libexec/PlistBuddy -c "Print :Label" "$1" 2>/dev/null || basename "$1" .plist; } # Return the executable path a LaunchAgent runs (ProgramArguments[0] or Program). # Echoes empty string if neither is present. get_plist_exec_path() { local plist="$1" arg0="" # Prefer ProgramArguments[0] arg0="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' "$plist" 2>/dev/null || true)" if [[ -z "$arg0" ]]; then # Fallback to Program (older style) arg0="$(/usr/libexec/PlistBuddy -c 'Print :Program' "$plist" 2>/dev/null || true)" fi printf '%s\n' "$arg0" } snapshot_launchagents_user() { local user_agents="$HOME/Library/LaunchAgents" snapfile="$(/usr/bin/mktemp "/tmp/repertory_launchagents.XXXXXX")" || snapfile="" if [[ -z "$snapfile" ]]; then warn "Could not create temporary snapshot file; skipping LaunchAgent restart snapshot." return 0 fi [[ -d "$user_agents" ]] || return 0 # We collect non-UI first, then UI last, to preserve restart order later. local tmp_nonui tmp_ui tmp_nonui="$(/usr/bin/mktemp "/tmp/repertory_launchagents.nonui.XXXXXX")" || tmp_nonui="" tmp_ui="$(/usr/bin/mktemp "/tmp/repertory_launchagents.ui.XXXXXX")" || tmp_ui="" /usr/bin/find "$user_agents" -maxdepth 1 -type f -name "${LABEL_PREFIX}"'*.plist' -print 2>/dev/null | while IFS= read -r plist; do [[ -z "$plist" ]] && continue # Label must match the prefix local label label="$(get_plist_label "$plist")" [[ -n "$label" && "$label" == "${LABEL_PREFIX}"* ]] || continue # Executable must point into the *installed* app path local exec_path exec_path="$(get_plist_exec_path "$plist")" [[ -n "$exec_path" ]] || continue # Normalize: only accept absolute paths under $dest_app (e.g. .../repertory.app/Contents/...) if [[ "$exec_path" != "$dest_app/"* ]]; then # Not one of ours; skip continue fi # Defer UI label to the end if [[ "$label" == "$UI_LABEL" ]]; then [[ -n "$tmp_ui" ]] && printf "%s\t%s\n" "$plist" "$label" >>"$tmp_ui" else [[ -n "$tmp_nonui" ]] && printf "%s\t%s\n" "$plist" "$label" >>"$tmp_nonui" fi done # Stitch together: non-UI first, then UI [[ -s "$tmp_nonui" ]] && /bin/cat "$tmp_nonui" >>"$snapfile" [[ -s "$tmp_ui" ]] && /bin/cat "$tmp_ui" >>"$snapfile" [[ -n "$tmp_nonui" ]] && /bin/rm -f "$tmp_nonui" 2>/dev/null || true [[ -n "$tmp_ui" ]] && /bin/rm -f "$tmp_ui" 2>/dev/null || true log "Snapshot contains $(/usr/bin/wc -l <"$snapfile" 2>/dev/null || echo 0) LaunchAgent(s)." } unload_launchd_helpers_user() { local uid user_agents uid="$(id -u)" user_agents="$HOME/Library/LaunchAgents" [[ -d "$user_agents" ]] || return 0 while IFS= read -r plist; do [[ -z "$plist" ]] && continue local base label base="$(basename "$plist")" [[ "$base" == "${LABEL_PREFIX}"* ]] || continue label="$(get_plist_label "$plist")" [[ -n "$label" && "$label" == "${LABEL_PREFIX}"* ]] || continue log "Booting out user label ${label} (${plist})" /bin/launchctl bootout "gui/${uid}" "$plist" 2>/dev/null || /bin/launchctl bootout "gui/${uid}" "$label" 2>/dev/null || /bin/launchctl remove "$label" 2>/dev/null || true done < <(/usr/bin/find "$user_agents" -maxdepth 1 -type f -name "${LABEL_PREFIX}"'*.plist' -print 2>/dev/null) /bin/launchctl list 2>/dev/null | /usr/bin/awk -v pre="$LABEL_PREFIX" 'NF>=3 && index($3, pre)==1 {print $3}' | while read -r lbl; do [[ -z "$lbl" ]] && continue log "Booting out leftover user label: $lbl" /bin/launchctl bootout "gui/${uid}" "$lbl" 2>/dev/null || /bin/launchctl remove "$lbl" 2>/dev/null || true done } restart_launchagents_from_snapshot() { [[ -n "${snapfile:-}" && -f "${snapfile}" ]] || return 0 local uid count=0 ui_seen=0 uid="$(id -u)" # Pass 1: restart all non-UI agents first while IFS=$'\t' read -r plist label; do [[ -n "$plist" && -n "$label" ]] || continue [[ -f "$plist" ]] || continue [[ "$label" == "$UI_LABEL" ]] && continue log "Re-starting LaunchAgent: ${label}" /bin/launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true /bin/launchctl kickstart -k "gui/${uid}/${label}" 2>/dev/null || true ((count++)) || true done <"$snapfile" # Give helpers a moment to settle (e.g., automounts) sleep 0.3 # Pass 2: restart the UI agent last (if present in the snapshot) while IFS=$'\t' read -r plist label; do [[ -n "$plist" && -n "$label" ]] || continue [[ -f "$plist" ]] || continue [[ "$label" == "$UI_LABEL" ]] || continue log "Re-starting UI LaunchAgent last: ${label}" /bin/launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true /bin/launchctl kickstart -k "gui/${uid}/${label}" 2>/dev/null || true ui_seen=1 ((count++)) || true done <"$snapfile" log "Re-started ${count} LaunchAgent(s) with prefix ${LABEL_PREFIX}." || true if ((ui_seen)); then # If the UI label is active, skip manual open(1). if /bin/launchctl list | /usr/bin/awk '{print $3}' | /usr/bin/grep -Fxq "$UI_LABEL"; then log "UI LaunchAgent (${UI_LABEL}) active after restart; skipping manual UI launch." skip_ui_launch=1 fi fi } # ----- quarantine helper ----- remove_quarantine() { local path="${1:-}" if [[ "${USE_SUDO:-0}" == "1" ]]; then sudo /usr/bin/xattr -dr com.apple.quarantine "$path" 2>/dev/null || true else /usr/bin/xattr -dr com.apple.quarantine "$path" 2>/dev/null || true fi } # ----- process helpers ----- kill_repertory_processes() { local exec_name="$1" /usr/bin/pkill -TERM -f "$dest_app" >/dev/null 2>&1 || true /usr/bin/pkill -TERM -x "$exec_name" >/dev/null 2>&1 || true for _ in {1..20}; do /usr/bin/pgrep -af "$dest_app" >/dev/null 2>&1 || /usr/bin/pgrep -x "$exec_name" >/dev/null 2>&1 || break sleep 0.1 done /usr/bin/pkill -KILL -f "$dest_app" >/dev/null 2>&1 || true /usr/bin/pkill -KILL -x "$exec_name" >/dev/null 2>&1 || true } # ----- visibility helper ----- unhide_path() { local path="$1" /usr/bin/chflags -R nohidden "$path" 2>/dev/null || true /usr/bin/xattr -d -r com.apple.FinderInfo "$path" 2>/dev/null || true } # ----- stage / validate / activate / post-activate ----- stage_new_app() { staged="${dest_app}.new-$$" log "Staging new app → $staged" $SUDO /usr/bin/ditto "$src_app" "$staged" || die "ditto to stage failed" remove_quarantine "$staged" } validate_staged_app() { [[ -f "$staged/Contents/Info.plist" ]] || { $SUDO /bin/rm -rf "$staged" die "staged app missing Info.plist" } local exe_name_staged exe_name_staged="$(/usr/bin/defaults read "$staged/Contents/Info" CFBundleExecutable 2>/dev/null || echo "${app_basename%.app}")" [[ -x "$staged/Contents/MacOS/$exe_name_staged" ]] || { $SUDO /bin/rm -rf "$staged" die "staged app missing main executable" } } activate_staged_app() { if [[ -d "$dest_app" ]]; then backup="${dest_app}.$(date +%Y%m%d%H%M%S).bak" log "Moving existing app to backup: $backup" $SUDO /bin/mv "$dest_app" "$backup" || { $SUDO /bin/rm -rf "$staged" die "failed to move existing app out of the way" } fi log "Activating new app → $dest_app" if ! $SUDO /bin/mv "$staged" "$dest_app"; then warn "Activation failed; attempting rollback…" [[ -n "$backup" && -d "$backup" ]] && $SUDO /bin/mv "$backup" "$dest_app" || true $SUDO /bin/rm -rf "$staged" || true die "install activation failed" fi } post_activate_cleanup() { log "Clearing quarantine on installed app…" remove_quarantine "$dest_app" log "Clearing hidden flags on installed app…" unhide_path "$dest_app" local LSREG="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" [[ -x "$LSREG" ]] && "$LSREG" -f "$dest_app" >/dev/null 2>&1 || true local BID BID="$(bundle_id_of "$dest_app")" ls_prune_bundle_id "$BID" "$dest_app" log "Installed ${app_basename}: version=$(bundle_version_of "$dest_app") build=$(bundle_build_of "$dest_app")" } launch_ui() { log "Launching the new app…" /usr/bin/open -n "$dest_app" || warn "open -n by path failed; not falling back to -b to avoid launching a stale copy." } remove_backup() { [[ -n "$backup" && -d "$backup" ]] && { log "Removing backup: $backup" $SUDO /bin/rm -rf "$backup" || warn "Could not remove backup (safe to delete manually): $backup" } log "Done." } cleanup_staged() { if [[ -n "${staged:-}" && -d "${staged}" ]]; then log "Cleaning up staged folder: ${staged}" if [[ "${USE_SUDO:-0}" == "1" ]]; then sudo /bin/rm -rf "${staged}" 2>/dev/null || true else /bin/rm -rf "${staged}" 2>/dev/null || true fi fi if [[ -n "${snapfile:-}" && -f "${snapfile}" ]]; then /bin/rm -f "${snapfile}" 2>/dev/null || true fi } main() { ensure_target_writable local exec_name exec_name="$(bundle_exec_of "$src_app")" # 1) Snapshot agents we’ll restart later snapshot_launchagents_user # 2) Hard-fail if any FUSE unmount fails unmount_existing_repertory_volumes || die "One or more FUSE mounts resisted unmount." # 3) Stop user LaunchAgents (do NOT delete plists) unload_launchd_helpers_user # 4) Kill any remaining repertory processes kill_repertory_processes "$exec_name" # 5) Stage → validate → activate → post-activate stage_new_app validate_staged_app activate_staged_app post_activate_cleanup # 6) Re-start previously-running LaunchAgents (so automount helpers come up) restart_launchagents_from_snapshot # 7) If UI LaunchAgent came back, skip manual launch if ((!skip_ui_launch)); then launch_ui fi # 8) Remove backup now that everything is good remove_backup } trap 'rc=$?; cleanup_staged; if (( rc != 0 )); then echo "Installer failed with code $rc. See $LOG_FILE"; fi' EXIT main "$@"