#!/bin/bash # LoginToggle installer. Installs the latest release into ~/Applications, or updates an existing install. # curl -fsSL https://logintoggle.kartikkabadi.com/install.sh | bash # The download is checked against the SHA-256 in the release feed before anything is installed. # Environment, for testing: LOGIN_TOGGLE_FEED_URL overrides the feed, LT_NO_LAUNCH=1 installs without opening the app. set -euo pipefail FEED_URL="${LOGIN_TOGGLE_FEED_URL:-https://logintoggle.kartikkabadi.com/releases/latest.json}" BUNDLE_ID="com.kartikkabadi.login-toggle" APPS="$HOME/Applications" DEST="$APPS/LoginToggle.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}/login-toggle-install.XXXXXX") say "Checking for the latest LoginToggle" 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 RUNNING=$(sw_vers -productVersion) if ! version_at_least "$RUNNING" "$MINIMUM"; then die "LoginToggle $VERSION needs macOS $MINIMUM or later. This Mac runs macOS $RUNNING." fi say "Downloading LoginToggle $VERSION" ZIP="$WORK/LoginToggle.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" ditto -x -k "$ZIP" "$STAGE" || die "could not unpack the download" NEW_APP="$STAGE/LoginToggle.app" [ -d "$NEW_APP" ] || die "the download does not contain LoginToggle.app" PLIST="$NEW_APP/Contents/Info.plist" [ "$(read_value CFBundleIdentifier "$PLIST")" = "$BUNDLE_ID" ] || die "the download is not LoginToggle" [ "$(read_value CFBundleShortVersionString "$PLIST")" = "$VERSION" ] || die "the app's version does not match the release feed" codesign --verify --strict "$NEW_APP" 2>/dev/null || die "the app's signature is damaged, so nothing was installed" if pgrep -f "$DEST/Contents/MacOS/LoginToggle" >/dev/null 2>&1; then say "Quitting the running LoginToggle" pkill -f "$DEST/Contents/MacOS/LoginToggle" >/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/.LoginToggle-installing.app" OLD="$APPS/.LoginToggle-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 "${LT_NO_LAUNCH:-}" ]; then say "Opening LoginToggle $VERSION" open "$DEST" || true sleep 2 if ! pgrep -f "$DEST/Contents/MacOS/LoginToggle" >/dev/null 2>&1; then if [ -e "$OLD" ]; then rm -rf "$DEST" mv "$OLD" "$DEST" || die "LoginToggle $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 "LoginToggle $VERSION did not stay running, so the previous app was restored." fi die "LoginToggle $VERSION did not stay running. Try opening '$DEST' yourself." fi fi rm -rf "$OLD" if [ -n "${LT_NO_LAUNCH:-}" ]; then say "Installed LoginToggle $VERSION" fi printf 'Done. Look for the power icon in your menu bar. On a first install, a short tour opens to get you set up.\n'