#!/bin/sh
# Uncork installer for Apple Silicon Macs. Page: https://uncorkmac.com/en/install.html
#
#   curl -fsSL https://uncorkmac.com/install.sh | sh
#   curl -fsSL https://uncorkmac.com/install.sh | sh -s -- --no-open   install, do not open the app
#   curl -fsSL https://uncorkmac.com/install.sh | sh -s -- --check     show what would be installed, change nothing
#   curl -fsSL https://uncorkmac.com/install.sh | sh -s -- --reinstall replace an installed copy with the latest one
#   curl -fsSL https://uncorkmac.com/install.sh | sh -s -- --game 730  also tell Uncork which Steam game it is for
#                                                                       (730 is the Steam number of Counter-Strike 2)
#
# What it does: reads https://uncorkmac.com/latest.json, downloads that disk image, compares its
# SHA-256, checks that the app inside is signed with Developer ID team PJC7K6A3U5 and accepted by
# Gatekeeper (notarized by Apple), copies Uncork.app to /Applications and opens it.
# No sudo, no account. The script itself makes two requests, both to uncorkmac.com: latest.json
# and the image. (macOS may ask Apple about the signature while checking it, as it does for any app.)
#
# UNCORK_BASE=https://other-host replaces the host for both downloads (a mirror, if a network
# blocks uncorkmac.com). The signature check stays the same whatever the host.
# UNCORK_INSTALL_DIR=/some/folder installs there instead of /Applications.
# An installed Uncork is left alone unless you pass --reinstall: it updates itself.
set -eu

TEAM="PJC7K6A3U5"
BUNDLE_ID="app.uncork"
BASE="${UNCORK_BASE:-https://uncorkmac.com}"
# Developer ID requirement pinned to the app and the team: the image is refused unless Apple's
# Developer ID chain signed this bundle id for exactly this team.
REQUIREMENT="identifier \"$BUNDLE_ID\" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$TEAM\""

GAME=""
TMP=""
MOUNT=""
APP=""
NEW=""
OLD=""

say() { printf '%s\n' "$*"; }
fail() { printf 'uncork install: %s\n' "$*" >&2; exit 1; }

# Runs on every exit, including Ctrl-C: nothing half-installed and nothing hidden may stay behind.
cleanup() {
    if [ -n "$NEW" ] && [ -e "$NEW" ]; then rm -rf "$NEW" 2>/dev/null || true; fi
    if [ -n "$OLD" ] && [ -e "$OLD" ]; then
        if [ -n "$APP" ] && [ ! -e "$APP" ]; then
            # Interrupted between the two moves: put the previous copy back.
            mv "$OLD" "$APP" 2>/dev/null || printf 'uncork install: the previous copy is in %s\n' "$OLD" >&2
        else
            rm -rf "$OLD" 2>/dev/null || printf 'uncork install: could not remove the old copy %s; you can delete it.\n' "$OLD" >&2
        fi
    fi
    if [ -n "$MOUNT" ] && [ -d "$MOUNT" ]; then
        hdiutil detach "$MOUNT" -quiet 2>/dev/null || hdiutil detach "$MOUNT" -force -quiet 2>/dev/null || true
    fi
    if [ -n "$TMP" ] && [ -d "$TMP" ]; then rm -rf "$TMP" 2>/dev/null || true; fi
}

field() { plutil -extract "$1" raw -o - "$TMP/latest.json" 2>/dev/null || true; }

installed_version() {
    plutil -extract CFBundleShortVersionString raw -o - "$1/Contents/Info.plist" 2>/dev/null || true
}

# Opens Uncork. With --game the app gets a link that only names the game: Uncork starts nothing by
# itself, it shows which game it will offer to install after the Steam sign-in.
open_app() {
    if [ -n "$GAME" ]; then
        open -a "$APP" "uncork://setup?game=$GAME" 2>/dev/null
    else
        open "$APP" 2>/dev/null
    fi
}

next_steps() {
    say ""
    say "Next, in the Uncork window (a person does this, it cannot be scripted):"
    say "  1. Click \"Prepare everything · 7–9 minutes\" and accept Apple's licence when it opens."
    say "  2. Sign in to Steam in the window that appears. Uncork does not see the password."
    if [ -n "$GAME" ]; then
        say "  3. Uncork offers to install the game you named (Steam number $GAME). Install it and press Play."
    else
        say "  3. Install the game in Steam and press Play."
    fi
    say "Games with kernel-level anti-cheat do not run: https://uncorkmac.com/en/anti-cheat.html"
}

main() {
    OPEN=1
    CHECK=0
    REINSTALL=0
    GAME=""
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --no-open) OPEN=0 ;;
            --check) CHECK=1 ;;
            --reinstall) REINSTALL=1 ;;
            --game)
                [ "$#" -ge 2 ] || fail "--game needs the Steam number of the game, for example --game 730"
                GAME="$2"; shift ;;
            --game=*) GAME="${1#--game=}" ;;
            -h|--help) say "Options: --no-open, --check, --reinstall, --game <Steam number>. Details: https://uncorkmac.com/en/install.html"; exit 0 ;;
            *) fail "unknown option $1 (known: --no-open, --check, --reinstall, --game <Steam number>)" ;;
        esac
        shift
    done
    if [ -n "$GAME" ]; then
        # The number from the game's Steam store address: store.steampowered.com/app/730/ -> 730.
        case "$GAME" in
            0*|*[!0-9]*) fail "--game takes the Steam number of the game (digits only, for example 730), got $GAME" ;;
        esac
        [ "${#GAME}" -le 9 ] || fail "--game: $GAME is not a Steam number"
    fi

    [ "$(uname -s)" = "Darwin" ] || fail "Uncork is a Mac app; this is $(uname -s)."
    # uname -m says x86_64 in a Terminal opened under Rosetta, so ask the hardware.
    [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = "1" ] \
        || fail "Uncork needs a Mac with Apple Silicon (M1 or later). This Mac has an Intel processor."
    OS="$(sw_vers -productVersion)"
    [ "${OS%%.*}" -ge 14 ] 2>/dev/null \
        || fail "Uncork needs macOS 14 or later. This Mac runs macOS $OS."
    case "$BASE" in
        https://*) ;;
        *) fail "UNCORK_BASE must start with https:// (got $BASE)" ;;
    esac
    BASE="${BASE%/}"

    # Where the app goes, and whether a copy is already there. Without UNCORK_INSTALL_DIR both
    # usual folders are checked, so a second copy never appears next to an existing one.
    USER_APPS="${HOME:-}/Applications"
    if [ -n "${UNCORK_INSTALL_DIR:-}" ]; then
        DEST="${UNCORK_INSTALL_DIR%/}"
        case "$DEST" in
            /?*) ;;
            *) fail "UNCORK_INSTALL_DIR must be a full path to a folder (got ${UNCORK_INSTALL_DIR})" ;;
        esac
    elif [ -d "/Applications/Uncork.app" ]; then DEST="/Applications"
    elif [ -n "${HOME:-}" ] && [ -d "$USER_APPS/Uncork.app" ]; then DEST="$USER_APPS"
    elif [ -w /Applications ]; then DEST="/Applications"
    elif [ -n "${HOME:-}" ]; then DEST="$USER_APPS"
    else fail "cannot write to /Applications and HOME is not set. Set UNCORK_INSTALL_DIR to a folder."
    fi
    APP="$DEST/Uncork.app"
    HAVE=""
    if [ -d "$APP" ]; then HAVE="$(installed_version "$APP")"; fi

    TMP="$(mktemp -d "${TMPDIR:-/tmp}/uncork-install.XXXXXX")" || fail "could not create a temporary folder."
    trap cleanup EXIT
    trap 'exit 130' HUP INT TERM

    curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 --connect-timeout 20 --max-time 60 \
        "$BASE/latest.json" -o "$TMP/latest.json" \
        || fail "could not read $BASE/latest.json. Check the network; if it blocks uncorkmac.com, try another network."
    VERSION="$(field version)"
    FILE="$(field file)"
    SHA="$(field sha256)"
    case "$VERSION" in
        ""|*[!0-9.]*) fail "$BASE/latest.json does not name a version." ;;
    esac
    [ "$FILE" = "Uncork-$VERSION.dmg" ] || fail "$BASE/latest.json names an unexpected file."
    case "$SHA" in
        *[!0-9a-f]*) fail "$BASE/latest.json has a malformed SHA-256." ;;
    esac
    [ "${#SHA}" -eq 64 ] || fail "$BASE/latest.json has no SHA-256."

    if [ "$CHECK" -eq 1 ]; then
        say "Latest Uncork: $VERSION ($BASE/updates/$FILE)"
        say "SHA-256: $SHA"
        say "Installed in $DEST: ${HAVE:-nothing}"
        exit 0
    fi

    if [ -d "$APP" ] && [ "$REINSTALL" -eq 0 ]; then
        say "Uncork ${HAVE:-(unknown version)} is already installed in $APP. Nothing was changed."
        if [ -n "$HAVE" ] && [ "$HAVE" != "$VERSION" ]; then
            say "The latest is $VERSION: Uncork installs updates by itself; to replace the copy now, run this again with --reinstall."
        fi
        if [ "$OPEN" -eq 1 ]; then open_app || true; fi
        next_steps
        exit 0
    fi
    if [ -d "$APP" ]; then
        # Replacing the app under a running game breaks the game, so both must be closed first.
        if pgrep -x Uncork >/dev/null 2>&1 || pgrep -f "Application Support/[^/]*/Runtimes/" >/dev/null 2>&1; then
            fail "Uncork or a game started from it is running. Quit both, then run this again."
        fi
    fi

    say "Downloading Uncork $VERSION…"
    # A stalled connection must end with a message, not hang: under 1 KB/s for 30 s is a failure.
    curl -fL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 2 -sS \
        --connect-timeout 20 --speed-limit 1000 --speed-time 30 --max-time 1800 \
        "$BASE/updates/$FILE" -o "$TMP/$FILE" \
        || fail "the download of $BASE/updates/$FILE failed."
    GOT="$(shasum -a 256 "$TMP/$FILE" | awk '{print $1}')"
    [ "$GOT" = "$SHA" ] || fail "SHA-256 mismatch: expected $SHA, got $GOT. Nothing was installed."

    MOUNT="$TMP/mount"
    mkdir "$MOUNT"
    # stdin is the pipe this script arrives through: hdiutil must not read from it.
    hdiutil attach "$TMP/$FILE" -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" -quiet </dev/null \
        || fail "could not open the disk image."
    SRC="$MOUNT/Uncork.app"
    if [ -L "$SRC" ] || [ ! -d "$SRC" ]; then fail "the disk image has no Uncork.app."; fi

    say "Checking the signature (Developer ID team $TEAM, notarization)…"
    codesign --verify --deep --strict -R="$REQUIREMENT" "$SRC" 2>/dev/null \
        || fail "the app in the image is not $BUNDLE_ID signed by Developer ID team $TEAM. Nothing was installed."
    spctl --assess --type execute "$SRC" 2>/dev/null \
        || fail "Gatekeeper did not accept the app (not notarized, or macOS could not check it). Nothing was installed."

    mkdir -p "$DEST" || fail "could not create $DEST."
    NEW="$DEST/.Uncork.app.installing.$$"
    rm -rf "$NEW"
    ditto "$SRC" "$NEW" || fail "could not copy the app to $DEST."
    if [ -d "$APP" ]; then
        OLD="$DEST/.Uncork.app.previous.$$"
        mv "$APP" "$OLD" || { OLD=""; fail "could not replace $APP."; }
    fi
    # If this move fails, cleanup puts the previous copy back and removes the new one.
    mv "$NEW" "$APP" || fail "could not move the app into $DEST."
    NEW=""

    say "Installed Uncork $VERSION: $APP"
    if [ "$OPEN" -eq 1 ]; then
        open_app || say "Could not open the app from here (no desktop session?). Open $APP by hand."
    fi
    next_steps
}

# The whole script is one function called on the last line: a download cut off halfway runs nothing.
main "$@"
