#!/bin/bash # MyMonitor installer. Installs the latest release into ~/Applications, or updates an existing install. # curl -fsSL https://mymonitor.kartikkabadi.com/install.sh | bash # The download is checked against the SHA-256 in the release feed before anything is installed. # Environment, for testing: MYMONITOR_FEED_URL overrides the feed, MM_INSTALL_DIR the folder (default ~/Applications), # and MM_NO_LAUNCH=1 installs without opening the app. set -euo pipefail FEED_URL="${MYMONITOR_FEED_URL:-https://mymonitor.kartikkabadi.com/releases/latest.json}" BUNDLE_ID="com.mymonitor.MyMonitor" APPS="${MM_INSTALL_DIR:-$HOME/Applications}" # Every release is signed with the same self-signed certificate; this is its SHA-1. A download signed by # anything else is not ours, even if its checksum matches a tampered feed. CERT_ROOT="5ea05c22be5f84e91fc12d6679517082b1221934" DEST="$APPS/MyMonitor.app" WORK="" say() { printf '==> %s\n' "$1"; } die() { printf 'error: %s\n' "$1" >&2; exit 1; } cleanup() { if [ -n "$WORK" ]; then rm -rf "$WORK"; fi } trap cleanup EXIT # Reads one key from a JSON or plist file. plutil ships with macOS, so nothing else is needed. read_value() { local value value=$(/usr/bin/plutil -extract "$1" raw -o - "$2" 2>/dev/null) || return 1 printf '%s' "$value" } # True when dotted version $1 is at least $2, such as 14.5.1 and 13.0. Works in the bash 3.2 that macOS ships. version_at_least() { local IFS=. # shellcheck disable=SC2206 local -a have=($1) need=($2) local i h n for ((i = 0; i < ${#have[@]} || i < ${#need[@]}; i++)); do h=${have[i]:-0} n=${need[i]:-0} if ((10#$h > 10#$n)); then return 0; fi if ((10#$h < 10#$n)); then return 1; fi done return 0 } WORK=$(mktemp -d "${TMPDIR:-/tmp}/mymonitor-install.XXXXXX") say "Checking for the latest MyMonitor" MANIFEST="$WORK/latest.json" curl -fsSL --max-time 30 -o "$MANIFEST" "$FEED_URL" || die "could not reach the release feed. Check your connection and try again." VERSION=$(read_value version "$MANIFEST") || die "the release feed has no version" URL=$(read_value url "$MANIFEST") || die "the release feed has no download URL" SHA=$(read_value sha256 "$MANIFEST" | tr '[:upper:]' '[:lower:]') || die "the release feed has no SHA-256" MINIMUM=$(read_value minimumSystemVersion "$MANIFEST") || die "the release feed has no minimum macOS version" [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]] || die "the release feed has an invalid version" [[ "$MINIMUM" =~ ^[0-9]+(\.[0-9]+)*$ ]] || die "the release feed has an invalid minimum macOS version" # file:// downloads are only for testing a local feed, so a remote feed can never point at a local file. case "$URL" in https://*) ;; file://*) case "$FEED_URL" in file://*) ;; *) die "the release feed has an insecure download URL, so nothing was installed" ;; esac ;; *) die "the release feed has an insecure download URL, so nothing was installed" ;; esac if [[ ! "$SHA" =~ ^[0-9a-f]+$ ]] || [ "${#SHA}" -ne 64 ]; then die "the release feed has an invalid SHA-256" fi [ "$(uname -m)" = "arm64" ] || die "MyMonitor requires an Apple silicon Mac." RUNNING=$(sw_vers -productVersion) if ! version_at_least "$RUNNING" "$MINIMUM"; then die "MyMonitor $VERSION needs macOS $MINIMUM or later. This Mac runs macOS $RUNNING." fi say "Downloading MyMonitor $VERSION" ZIP="$WORK/MyMonitor.zip" curl -fsSL --retry 2 --max-time 600 -o "$ZIP" "$URL" || die "the download failed. Nothing was installed." ACTUAL=$(shasum -a 256 "$ZIP" | awk '{print $1}') if [ "$ACTUAL" != "$SHA" ]; then die "the download does not match the published checksum, so nothing was installed" fi say "Verifying the app" STAGE="$WORK/stage" mkdir "$STAGE" # Reject paths outside the bundle and symlinks before extracting untrusted bytes. /usr/bin/zipinfo -1 "$ZIP" > "$WORK/entries" || die "could not read the archive" [ -s "$WORK/entries" ] || die "the archive is empty" while IFS= read -r entry; do case "$entry" in MyMonitor.app/*) ;; *) die "the archive contains a path outside MyMonitor.app" ;; esac case "/${entry%/}/" in *\\* | */../* | */./* | *//*) die "the archive contains an unsafe path" ;; esac done < "$WORK/entries" /usr/bin/zipinfo -l "$ZIP" > "$WORK/types" || die "could not inspect the archive" if grep -q '^l' "$WORK/types"; then die "the archive contains symbolic links"; fi ditto -x -k "$ZIP" "$STAGE" || die "could not unpack the download" NEW_APP="$STAGE/MyMonitor.app" [ -d "$NEW_APP" ] || die "the download does not contain MyMonitor.app" PLIST="$NEW_APP/Contents/Info.plist" [ "$(read_value CFBundleIdentifier "$PLIST")" = "$BUNDLE_ID" ] || die "the download is not MyMonitor" [ "$(read_value CFBundleShortVersionString "$PLIST")" = "$VERSION" ] || die "the app's version does not match the release feed" codesign --verify --deep --strict "$NEW_APP" 2>/dev/null || die "the app's signature is damaged, so nothing was installed" codesign --verify --deep --strict -R="identifier \"$BUNDLE_ID\" and certificate root = H\"$CERT_ROOT\"" "$NEW_APP" 2>/dev/null \ || die "the app is not signed by MyMonitor's release certificate, so nothing was installed" if pgrep -f "$DEST/Contents/MacOS/MyMonitor" >/dev/null 2>&1; then say "Quitting the running MyMonitor" pkill -f "$DEST/Contents/MacOS/MyMonitor" >/dev/null 2>&1 || true sleep 1 fi say "Installing to $DEST" mkdir -p "$APPS" # Copy beside the old app, clear quarantine, then swap so a failed install leaves the working app in place. PENDING="$APPS/.MyMonitor-installing.app" OLD="$APPS/.MyMonitor-previous.app" if [ -e "$OLD" ]; then die "a previous install left $OLD behind. Move it back to $DEST or keep it aside, then rerun." fi rm -rf "$PENDING" if ! ditto "$NEW_APP" "$PENDING"; then rm -rf "$PENDING" die "could not copy the app into $APPS" fi if ! xattr -dr com.apple.quarantine "$PENDING" 2>/dev/null; then rm -rf "$PENDING" die "could not prepare the app to open. Nothing was installed; try again or contact support." fi if [ -e "$DEST" ] && ! mv "$DEST" "$OLD"; then rm -rf "$PENDING" die "could not replace the existing app in $APPS" fi if ! mv "$PENDING" "$DEST"; then # Put the previous app back so the Mac is not left without one. if [ -e "$OLD" ]; then mv "$OLD" "$DEST" || die "could not install and could not restore the previous app; it is at $OLD" fi rm -rf "$PENDING" die "could not install the new app into $APPS" fi if [ -z "${MM_NO_LAUNCH:-}" ]; then say "Opening MyMonitor $VERSION" open "$DEST" --args --show-welcome || true sleep 2 if ! pgrep -f "$DEST/Contents/MacOS/MyMonitor" >/dev/null 2>&1; then if [ -e "$OLD" ]; then rm -rf "$DEST" mv "$OLD" "$DEST" || die "MyMonitor $VERSION did not launch, and the previous app could not be restored; it is at $OLD" say "Reopening the previous version" open "$DEST" || true die "MyMonitor $VERSION did not stay running, so the previous app was restored." fi die "MyMonitor $VERSION did not stay running. Try opening '$DEST' yourself." fi fi rm -rf "$OLD" if [ -n "${MM_NO_LAUNCH:-}" ]; then say "Installed MyMonitor $VERSION" fi printf 'Done. Look for the display icon in your menu bar. On a first install, a short tour opens to get you set up.\n' if [ "$APPS" != "/Applications" ] && [ -d "/Applications/MyMonitor.app" ]; then printf 'Note: an older copy is also in /Applications. Quit it and move it to the Trash so only one runs.\n' fi