#!/bin/sh
# Udon installer:  curl -fsSL https://udon.sh/install | bash
#
# Checks this Mac, offers to install Apple Container and Homebrew in one question,
# downloads and verifies the latest signed Udon.app, then runs `udond install`, which
# puts it in /Applications and starts it. The menu bar app's Welcome window finishes
# setup. Under `curl | bash` stdin is this script, so the one prompt reads /dev/tty.
# UDON_BACKEND sets the release server (https, or http on this Mac for testing).
# UDON_INSTALL_SOURCE_ONLY=1 only defines functions.
set -eu
# A failed download in a pipeline fails the pipeline. Guarded for shells without it.
# shellcheck disable=SC3040
(set -o pipefail) 2>/dev/null && set -o pipefail

UDON_BACKEND="${UDON_BACKEND:-https://license.udon.sh}"
UDON_TEAM_ID="N3JJK3C7G9"
MAX_ARCHIVE_BYTES=2147483648
APPLICATIONS="/Applications"
STATE="$HOME/Library/Application Support/sh.udon"
AGENTS="$HOME/Library/LaunchAgents"
HAVE_TTY=0
STEP=0

# Colors only when stdout is a terminal, so a piped log stays plain.
if [ -t 1 ]; then
    BOLD=$(printf '\033[1m') DIM=$(printf '\033[2m') RED=$(printf '\033[31m')
    GREEN=$(printf '\033[32m') YELLOW=$(printf '\033[33m') RESET=$(printf '\033[0m')
else
    BOLD='' DIM='' RED='' GREEN='' YELLOW='' RESET=''
fi

step() { STEP=$((STEP + 1)); printf '\n%s[%d/4]%s %s\n' "$BOLD" "$STEP" "$RESET" "$1"; }
info() { printf '   %s\n' "$1"; }
ok() { printf '   %s+%s %s\n' "$GREEN" "$RESET" "$1"; }
warn() { printf '   %s!%s %s\n' "$YELLOW" "$RESET" "$1" >&2; }
die() { DIED=1; printf '\n%sx%s %s\n\n' "$RED" "$RESET" "$1" >&2; exit 1; }

# ask <question>: yes is the default, and the answer when no terminal is attached.
ask() {
    _answer=""
    if [ "$HAVE_TTY" = 1 ]; then
        printf '   %s%s%s [Y/n] ' "$BOLD" "$1" "$RESET"
        read -r _answer <&3 || _answer=""
    else
        printf '   %s%s%s [Y/n] %s(no terminal, using the default)%s\n' "$BOLD" "$1" "$RESET" "$DIM" "$RESET"
    fi
    case "$_answer" in [Nn]*) return 1 ;; esac
}

fetch() {
    /usr/bin/curl --fail --location --show-error --silent \
        --retry 3 --retry-delay 1 --connect-timeout 15 --max-time 900 "$@"
}

json_value() { /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null; }

# poll <message> <command...>: retries every half second for up to two minutes. On a
# terminal the message waits beside a spinner, so a slow start does not look like a hang,
# and the line is cleared once the command succeeds.
poll() {
    _message=$1 _tries=0
    shift
    until "$@"; do
        _tries=$((_tries + 1))
        [ "$_tries" -lt 240 ] || return 1
        for _frame in '|' '/' '-' "\\"; do
            [ ! -t 1 ] || printf '\r   %s %s' "$_frame" "$_message"
            /bin/sleep 0.125
        done
    done
    [ ! -t 1 ] || [ "$_tries" -eq 0 ] || printf '\r\033[K'
}

# 1. Checking this Mac

require_gui_session() {
    /bin/launchctl print "gui/$(/usr/bin/id -u)" >/dev/null 2>&1 ||
        die "Sign in to this Mac's desktop with this account, then run the installer again."
}

check_install_locations() {
    [ -w "$APPLICATIONS" ] ||
        die "Udon installs into /Applications. Sign in as an administrator and run the installer again."
    if [ -e "$STATE" ] || [ -L "$STATE" ]; then
        { [ -d "$STATE" ] && [ ! -L "$STATE" ] && [ "$(/usr/bin/stat -f %u "$STATE")" = "$(/usr/bin/id -u)" ]; } ||
            die "Udon's data folder belongs to another macOS account or is not a folder: $STATE"
    fi
}

check_mac() {
    step "Checking this Mac"
    [ "$(/usr/bin/id -u)" -ne 0 ] || die "Run the installer from your own account, without sudo."
    [ "$(/usr/bin/uname -s)" = Darwin ] || die "Udon runs on macOS only."
    [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ] || die "Udon needs a Mac with Apple Silicon."
    _macos=$(/usr/bin/sw_vers -productVersion)
    [ "${_macos%%.*}" -ge 26 ] 2>/dev/null || die "Udon needs macOS 26 or later. This Mac runs macOS $_macos."
    require_gui_session
    for _tool in curl plutil codesign shasum tar openssl; do
        [ -x "/usr/bin/$_tool" ] || die "This Mac is missing /usr/bin/$_tool, which the installer needs."
    done
    case "$UDON_BACKEND" in
    https://* | http://127.0.0.1:* | http://localhost:*) ;;
    *) die "UDON_BACKEND must use https, or http on this Mac for testing." ;;
    esac
    check_install_locations
    ok "Apple Silicon, macOS $_macos"
}

# 2. Dependencies

container_present() { [ -x /usr/local/bin/container ] || [ -x /opt/homebrew/bin/container ]; }
orbstack_present() {
    [ -d /Applications/OrbStack.app ] &&
        { [ -x "$HOME/.orbstack/bin/docker" ] || [ -x /Applications/OrbStack.app/Contents/MacOS/xbin/docker ]; }
}
brew_present() { [ -x /opt/homebrew/bin/brew ] || [ -x /usr/local/bin/brew ]; }

# The pkg runs as root, so it must carry Apple's own certificate or the Apple Inc.
# Developer ID that Apple Container ships with.
pkg_signed_by_apple() {
    _signature=$(/usr/sbin/pkgutil --check-signature "$1" 2>/dev/null || true)
    case "$_signature" in
    *"signed by a certificate trusted by macOS"*) return 0 ;;
    *"developer certificate issued by Apple for distribution"*)
        case "$_signature" in *"Developer ID Installer: Apple Inc"*) return 0 ;; esac
        ;;
    esac
    return 1
}

install_apple_container() {
    info "Downloading Apple Container from GitHub..."
    _release="$WORK/container-release.json" _pkg="$WORK/container.pkg" _url="" _i=0
    fetch https://api.github.com/repos/apple/container/releases/latest -o "$_release" 2>/dev/null || true
    while [ -z "$_url" ] && _name=$(json_value "$_release" "assets.$_i.name"); do
        case "$_name" in *installer-signed.pkg) _url=$(json_value "$_release" "assets.$_i.browser_download_url" || true) ;; esac
        _i=$((_i + 1))
    done
    if [ -z "$_url" ] || ! fetch "$_url" -o "$_pkg"; then
        warn "Could not download Apple Container. Get it from https://github.com/apple/container/releases."
        return 1
    fi
    if ! pkg_signed_by_apple "$_pkg"; then
        warn "The Apple Container installer is not signed by Apple, so it was not installed."
        return 1
    fi
    info "Installing Apple Container (needs your administrator password)..."
    /usr/bin/sudo /usr/sbin/installer -pkg "$_pkg" -target / || return 1
    ok "Apple Container installed"
}

# stdin is /dev/null so the kernel prompt cannot read this script. The second try
# installs the kernel without asking.
start_container_service() {
    for _bin in /usr/local/bin/container /opt/homebrew/bin/container; do
        [ -x "$_bin" ] || continue
        "$_bin" system status >/dev/null 2>&1 && return 0
        info "Starting the container service..."
        "$_bin" system start </dev/null >/dev/null 2>&1 ||
            "$_bin" system start --enable-kernel-install </dev/null >/dev/null 2>&1 ||
            warn "Could not start the container service. Start it later with: container system start"
        return 0
    done
}

# Homebrew's own installer, told not to wait for RETURN. That also makes its sudo check
# never ask (`sudo -n`), so sudo asks for the password here first and the check finds the
# grant. A later step asks again if the grant has lapsed, as it can while Homebrew
# installs the Command Line Tools. With no terminal nobody can be asked, and it works
# only where sudo needs no password. A failure warns, since Udon runs without Homebrew.
install_homebrew() {
    _brew_script="$WORK/brew-install.sh"
    if ! fetch https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$_brew_script"; then
        warn "Could not download the Homebrew installer. Get it from https://brew.sh."
        return 1
    fi
    info "Installing Homebrew (needs your administrator password)..."
    if { [ "$HAVE_TTY" = 0 ] || /usr/bin/sudo -v; } && NONINTERACTIVE=1 /bin/bash "$_brew_script" </dev/null; then
        ok "Homebrew installed"
    else
        warn "Could not install Homebrew. Get it from https://brew.sh."
        return 1
    fi
}

# One question covers both, asked only when something is missing; then each missing
# one is installed. OrbStack counts as the container engine.
ensure_dependencies() {
    step "Dependencies"
    _missing=""
    container_present || orbstack_present || _missing="Apple Container"
    brew_present || _missing="${_missing:+$_missing, }Homebrew"
    if [ -z "$_missing" ]; then
        ok "A container engine and Homebrew are installed"
        return 0
    fi
    if ! ask "Install dependencies? ($_missing)"; then
        info "Skipped. The Udon menu bar's Welcome window can install them later."
        return 0
    fi
    # Forget the administrator grant when the installer exits, unless one was already live.
    /usr/bin/sudo -n -v 2>/dev/null || FORGET_SUDO=1
    if container_present || orbstack_present; then
        ok "A container engine is installed"
    elif install_apple_container; then
        start_container_service
    fi
    if brew_present; then
        ok "Homebrew is installed"
    else
        install_homebrew || true
    fi
}

# 3. Downloading Udon

# Picks the Apple Silicon archive and checks every manifest field the installer uses.
read_manifest() {
    _i=0
    while FILENAME=$(json_value "$MANIFEST" "artifacts.$_i.filename"); do
        case "$(json_value "$MANIFEST" "artifacts.$_i.arch"):$FILENAME" in arm64:*.tar.gz) break ;; esac
        _i=$((_i + 1))
    done
    VERSION=$(json_value "$MANIFEST" version || true)
    COMMIT=$(json_value "$MANIFEST" commit || true)
    SHA256=$(json_value "$MANIFEST" "artifacts.$_i.sha256" | /usr/bin/tr 'A-F' 'a-f' || true)
    SIZE=$(json_value "$MANIFEST" "artifacts.$_i.size" || true)
    URL=$(json_value "$MANIFEST" "artifacts.$_i.url" || true)
    [ -n "$FILENAME" ] || die "No Udon release is available to download yet. Try again later."
    case "$VERSION" in '' | *[!0-9A-Za-z.+_-]*) die "The release server sent an invalid version." ;; esac
    case "$COMMIT" in '' | *[!0-9A-Za-z._-]*) die "The release server sent an invalid build commit." ;; esac
    case "$FILENAME" in *..* | *[!0-9A-Za-z._+-]*) die "The release server sent an unsafe file name." ;; esac
    case "$SHA256" in *[!0-9a-f]*) SHA256="" ;; esac
    [ "${#SHA256}" -eq 64 ] || die "The release server sent an invalid checksum."
    case "$SIZE" in '' | *[!0-9]*) SIZE=0 ;; esac
    { [ "$SIZE" -gt 0 ] && [ "$SIZE" -le "$MAX_ARCHIVE_BYTES" ]; } 2>/dev/null ||
        die "The release server sent an invalid download size."
    case "$URL" in
    /install/download/*) URL="$UDON_BACKEND$URL" ;;
    https://* | http://127.0.0.1:* | http://localhost:*) ;;
    *) die "The release server sent an unsafe download address." ;;
    esac
    TARGET="${VERSION#v}-$COMMIT"
}

# The daemon reports the build compiled into it, which must be the one the manifest names.
check_build_identity() {
    "$1/Contents/MacOS/udond" build-info --json >"$WORK/build-info.json" 2>/dev/null ||
        die "The downloaded Udon.app did not report its build."
    { [ "$(json_value "$WORK/build-info.json" version)" = "$VERSION" ] &&
        [ "$(json_value "$WORK/build-info.json" commit)" = "$COMMIT" ]; } ||
        die "The downloaded Udon.app is a different build than the release server lists."
}

download_udon() {
    step "Downloading Udon"
    MANIFEST="$WORK/manifest.json"
    fetch "$UDON_BACKEND/install/latest" -o "$MANIFEST" ||
        die "Could not reach the Udon release server at $UDON_BACKEND. Check your network and try again."
    read_manifest
    info "Downloading Udon $VERSION..."
    ARCHIVE="$WORK/$FILENAME"
    fetch --max-filesize "$SIZE" "$URL" -o "$ARCHIVE" || die "Could not download Udon $VERSION. Check your network and try again."
    [ "$(/usr/bin/stat -f %z "$ARCHIVE")" = "$SIZE" ] || die "The Udon download is incomplete. Run the installer again."
    [ "$(/usr/bin/shasum -a 256 "$ARCHIVE" | /usr/bin/awk '{ print $1 }')" = "$SHA256" ] ||
        die "The Udon download does not match its published checksum, so it was not installed."
    # macOS tar strips leading slashes and refuses `..` and paths through symlinks.
    /bin/mkdir "$WORK/release"
    /usr/bin/tar -xzf "$ARCHIVE" -C "$WORK/release" || die "Could not unpack the Udon download."
    PAYLOAD=""
    for _app in "$WORK/release"/*/Udon.app; do
        if [ -d "$_app" ] && [ ! -L "$_app" ]; then
            [ -z "$PAYLOAD" ] || die "The Udon download contains more than one Udon.app."
            PAYLOAD=${_app%/Udon.app}
        fi
    done
    [ -n "$PAYLOAD" ] || die "The Udon download does not contain Udon.app."
    # Check the signature before running anything from the download.
    _app="$PAYLOAD/Udon.app"
    /usr/bin/codesign --verify --deep --strict "$_app" >/dev/null 2>&1 ||
        die "The downloaded Udon.app has an invalid signature, so it was not installed."
    for _code in "$_app" "$_app/Contents/MacOS/udond"; do
        /usr/bin/codesign -dv --verbose=4 "$_code" 2>&1 | /usr/bin/grep -x "TeamIdentifier=$UDON_TEAM_ID" >/dev/null ||
            die "The downloaded Udon.app is not signed by Udon, so it was not installed."
    done
    [ -f "$_app/Contents/Resources/web/index.html" ] || die "The downloaded Udon.app is missing its dashboard."
    check_build_identity "$_app"
    ok "Verified Udon $VERSION"
}

# 4. Installing Udon

# Earlier versions kept the app and dashboard in the data folder. Stop that copy and
# remove its program files and LaunchAgents, so `udond install` starts clean. Data stays.
remove_legacy_install() {
    [ -e "$STATE/Udon.app" ] || [ -e "$STATE/web" ] || return 0
    # An unfinished update needs those files to recover.
    case "$(json_value "$STATE/pending-update.json" phase || true)" in
    succeeded | rolledBack) ;;
    *) [ ! -e "$STATE/pending-update.json" ] ||
        die "An earlier Udon update is being recovered. Wait for Udon to reopen, then run this installer again." ;;
    esac
    info "Removing the earlier Udon from its data folder. Your data stays."
    for _label in sh.udon.menubar sh.udon; do
        /bin/launchctl bootout "gui/$(/usr/bin/id -u)/$_label" >/dev/null 2>&1 || true
    done
    /bin/rm -rf "$STATE/Udon.app" "$STATE/web" "$AGENTS/sh.udon.plist" "$AGENTS/sh.udon.menubar.plist"
}

# `udond install` checks the signed manifest, archive and payload, writes the
# LaunchAgents and hands the swap to Udon's recovery service, so closing Terminal
# cannot interrupt it. It prints the operation and the daemon's address as JSON.
# stdin is /dev/null so nothing it starts can read the rest of this script.
run_native_install() {
    # A new macOS account has no LaunchAgents folder, and Udon 0.62.1 and earlier
    # stop when it is missing.
    /bin/mkdir -p "$AGENTS" || die "Could not create $AGENTS."
    "$PAYLOAD/Udon.app/Contents/MacOS/udond" install \
        --payload "$PAYLOAD" --manifest "$MANIFEST" --archive "$ARCHIVE" </dev/null >"$WORK/install.json" ||
        die "Udon could not be installed. Your data was kept."
    [ "$(json_value "$WORK/install.json" operation.target)" = "$TARGET" ] ||
        die "An earlier Udon update is being recovered. Wait for Udon to reopen, then run this installer again."
    { BASE_URL=$(json_value "$WORK/install.json" connection.baseURL) &&
        LISTEN_HOST=$(json_value "$WORK/install.json" connection.listenHost) &&
        PORT=$(json_value "$WORK/install.json" connection.port) &&
        CERTIFICATE=$(json_value "$WORK/install.json" connection.certificatePath); } ||
        die "Udon did not report its address."
    AUTHORITY=${BASE_URL#https://}
}

# loopback_get <path> <output> [curl options]: pins the daemon's certificate key and
# skips any proxy the shell exports, which cannot reach this Mac's own daemon. A new
# install may still be writing the certificate, and the daemon serves HTTP when TLS is
# unavailable, so use HTTP until the key can be read.
loopback_get() {
    _path=$1 _out=$2
    shift 2
    if [ -r "$CERTIFICATE" ] &&
        /usr/bin/openssl x509 -in "$CERTIFICATE" -pubkey -noout >"$WORK/loopback-key.pem" 2>/dev/null &&
        [ -s "$WORK/loopback-key.pem" ]; then
        SCHEME=https
        set -- --insecure --pinnedpubkey "$WORK/loopback-key.pem" "$@"
    else
        SCHEME=http
    fi
    /usr/bin/curl --noproxy '*' --fail --silent --max-time 2 "$@" -o "$_out" "$SCHEME://$AUTHORITY$_path" 2>/dev/null
}

# A failed or rolled back journal is final: recovery has stopped, so say why now
# and do not wait out the poll.
stop_if_install_failed() {
    _phase=$(json_value "$STATE/pending-update.json" phase || true)
    case "$_phase" in failed | rolledBack)
        _failure=$(json_value "$STATE/pending-update.json" failure || true)
        die "${_failure:-Udon could not finish installing. Run the installer again to retry.}"
        ;;
    esac
}

health_ok() {
    stop_if_install_failed
    loopback_get /health "$WORK/health.json" || return 1
    _version=$(json_value "$WORK/health.json" version || true)
    [ "$(json_value "$WORK/health.json" status)" = ok ] && [ "${_version#v}" = "${VERSION#v}" ]
}

# This build's daemon, dashboard and menu bar must run. Permissions may still wait on
# the user; the Welcome window asks for them.
readiness_ok() {
    case "$(json_value "$1" phase)" in ready | awaiting_permission) ;; *) return 1 ;; esac
    [ "$(json_value "$1" buildID)" = "$TARGET" ] || return 1
    for _id in daemon dashboard menuBar; do
        _n=0
        while _found=$(json_value "$1" "components.$_n.id") && [ "$_found" != "$_id" ]; do _n=$((_n + 1)); done
        [ "$(json_value "$1" "components.$_n.status")" = ok ] || return 1
    done
}

# Done when the recovery journal reports success and the daemon reports ready.
install_ready() {
    stop_if_install_failed
    [ -s "$STATE/menubar.token" ] || return 1
    # A header file keeps the owner token out of the process list.
    printf 'X-Menubar-Token: %s\n' "$(/usr/bin/tr -d '\r\n' <"$STATE/menubar.token")" >"$WORK/token-header"
    loopback_get /api/menubar/readiness "$WORK/readiness.json" -H "@$WORK/token-header" &&
        readiness_ok "$WORK/readiness.json" && [ "$_phase" = succeeded ]
}

install_udon() {
    step "Installing Udon"
    remove_legacy_install
    run_native_install
    poll "Waiting for Udon to start..." health_ok ||
        die "Udon $VERSION did not answer at $BASE_URL within two minutes."
    poll "Waiting for Udon to start..." install_ready ||
        die "Udon did not finish starting within two minutes. Run the installer again to check on it."
    ok "Udon $VERSION is running"
}

finish() {
    case "$LISTEN_HOST" in
    0.0.0.0 | ::) _url="$SCHEME://$(/usr/sbin/scutil --get LocalHostName 2>/dev/null || /bin/hostname -s).local:$PORT" ;;
    *) _url="$SCHEME://$AUTHORITY" ;;
    esac
    printf '\n%s+ Udon is installed.%s\n\n' "$GREEN$BOLD" "$RESET"
    printf '   Open %s%s%s to access the Udon dashboard.\n' "$BOLD" "$_url" "$RESET"
    info "Finish setup in the Welcome window to grant Udon the system permissions it needs."
    info "Reach out to support@udon.sh if you have trouble accessing Udon."
    container_present || orbstack_present ||
        printf '   %sNo container engine yet. The Welcome window installs Apple Container.%s\n' "$DIM" "$RESET"
    printf '\n'
}

# Remove the work folder, and say plainly when a run stopped before it finished.
cleanup() {
    _code=$?
    /bin/rm -rf "$WORK"
    [ "${FORGET_SUDO:-0}" != 1 ] || /usr/bin/sudo -k 2>/dev/null || true
    if [ "$_code" -ne 0 ] && [ "${DIED:-0}" != 1 ]; then
        printf '\n%s! The installer did not finish (exit %s). It is safe to run again.%s\n' "$YELLOW$BOLD" "$_code" "$RESET" >&2
    fi
}

main() {
    [ "$#" -eq 0 ] || die "The installer takes no arguments."
    printf '\n%sUdon%s - Control deck for your Mac server.\n' "$BOLD" "$RESET"
    WORK=$(umask 077 && /usr/bin/mktemp -d "${TMPDIR:-/tmp}/udon-install.XXXXXX") || die "Could not create a temporary folder."
    trap cleanup EXIT
    trap 'exit 130' INT
    trap 'exit 143' TERM
    if { exec 3<>/dev/tty; } 2>/dev/null; then HAVE_TTY=1; fi
    check_mac
    ensure_dependencies
    download_udon
    install_udon
    finish
}

if [ "${UDON_INSTALL_SOURCE_ONLY:-0}" != 1 ]; then
    main "$@"
fi
