#!/bin/bash
#
# coffee-radar — macOS installer (DEC-0162)
#
# ⚠️ THIS SCRIPT EXISTS BECAUSE A DOWNLOADED BUILD CANNOT BE OPENED, AND NO
# PACKAGING CHANGE CAN FIX THAT.
#
# The app is ad-hoc signed (DEC-0121's scope call: no Apple Developer ID, no
# notarization). An ad-hoc signature carries NO Team Identifier, so Gatekeeper
# cannot produce the recoverable "unidentified developer" verdict that has a
# right-click → Open escape hatch — it falls through to "is damaged and can't be
# opened. You should move it to the Trash", which has NO override. That verdict
# depends on the com.apple.quarantine attribute a BROWSER attaches to its
# downloads, not on anything inside the bundle. S141 measured it directly on the
# owner's own Mac: the identical bundle is dead when quarantined and alive when
# it is not (DEC-0162).
#
# `curl` does not set com.apple.quarantine (it sets only com.apple.provenance,
# which Gatekeeper does not gate on — RESEARCH-DEC-0148 Report 1). So a build
# fetched by THIS script was never quarantined in the first place: it avoids
# ever acquiring the attribute rather than talking Gatekeeper out of a verdict.
# The one strip — the belt-and-braces `xattr -dr com.apple.quarantine` after
# the install, near the end — removes an attribute nothing above should have
# set, so the result cannot silently reproduce the "damaged" dialog.
#
# ⚠️ WHAT REPLACES GATEKEEPER HERE IS THE SHA256 CHECK BELOW. Gatekeeper was
# never providing integrity for this app anyway — it was refusing to run it. The
# manifest's per-platform sha256 is the real integrity gate, and a mismatch is a
# hard abort, never a warning. Do not weaken it to a warning "just to get
# unblocked": an installer that proceeds on a bad hash is worse than no
# installer, because it looks like it checked.
#
# Published to R2 at a STABLE, no-cache key by scripts/release-desktop.js, which
# substitutes https://releases.coffeeradar.app from RELEASE_R2_PUBLIC_BASE_URL at publish time. The
# committed file carries the placeholder, never a URL: that keeps the origin
# single-sourced with the manifest's own, so a wrong env var cannot produce an
# installer pointing at a different origin than the release it just published.
# The key is stable and version-free ON PURPOSE — the one-liner in the README
# must not change every release, and this script reads the manifest to learn
# what "latest" is, exactly as the in-app updater does.

set -euo pipefail

BASE_URL="${COFFEERADAR_BASE_URL:-https://releases.coffeeradar.app}"
MANIFEST_URL="${BASE_URL%/}/desktop-version.json"
APP_NAME="CoffeeRadar.app"
INSTALL_DIR="${COFFEERADAR_INSTALL_DIR:-/Applications}"

die() { echo "" >&2; echo "install failed: $*" >&2; echo "" >&2; exit 1; }
say() { echo "  $*"; }

echo ""
echo "coffee-radar — macOS installer"
echo ""

# ── refuse anything we cannot actually install ────────────────────────────────
#
# Loud failure, always (DEC-0004). The manifest publishes darwin-arm64 only; on
# an Intel Mac the right outcome is a clear refusal, NOT installing an arm64
# build that would die at exec with a message naming none of this.
[ "$(uname -s)" = "Darwin" ] || die "this installer is macOS-only (found $(uname -s))."
ARCH="$(uname -m)"
[ "$ARCH" = "arm64" ] || die "no build is published for $ARCH — coffee-radar ships darwin-arm64 (Apple Silicon) only.
Nothing was installed."

command -v shasum >/dev/null 2>&1 || die "shasum not found; cannot verify the download's integrity, so nothing was installed."
command -v ditto  >/dev/null 2>&1 || die "ditto not found; it is the only extractor that correctly preserves a signed .app (DEC-0161)."

WORK="$(mktemp -d "${TMPDIR:-/tmp}/coffeeradar-install.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT

# ── the manifest is the source of truth for what "latest" is ──────────────────
# ⚠️ https ONLY, redirects included (S296, L5-14): `-L` follows a 30x wherever it
# points, https→http too, so without --proto-redir the manifest — this script's
# root of trust — and the zip could arrive in cleartext from an https URL.
say "reading $MANIFEST_URL"
curl -fsSL --proto '=https' --proto-redir '=https' "$MANIFEST_URL" -o "$WORK/manifest.json" \
  || die "could not fetch the release manifest from $MANIFEST_URL"

# plutil parses JSON and is present on every stock macOS — no jq, no python3
# (the /usr/bin/python3 shim prompts for Xcode CLT and is not a dependency an
# installer may assume). It exits non-zero on a missing key path, so an absent
# platform entry aborts here rather than producing an empty URL.
VERSION="$(plutil -extract version raw -o - -- "$WORK/manifest.json" 2>/dev/null)" \
  || die "the manifest at $MANIFEST_URL carries no version — it is malformed or this is not the right origin."
URL="$(plutil -extract "platforms.darwin-$ARCH.url" raw -o - -- "$WORK/manifest.json" 2>/dev/null)" \
  || die "the published manifest (version $VERSION) has no darwin-$ARCH build. Nothing was installed."
WANT_SHA="$(plutil -extract "platforms.darwin-$ARCH.sha256" raw -o - -- "$WORK/manifest.json" 2>/dev/null)" \
  || die "the published manifest (version $VERSION) carries no sha256 for darwin-$ARCH.
Refusing to install a build whose integrity cannot be checked."

say "version $VERSION"
say "downloading $(basename "$URL")"
curl -fSL --proto '=https' --proto-redir '=https' --progress-bar "$URL" -o "$WORK/app.zip" \
  || die "download failed from $URL"

# ── ★ THE INTEGRITY GATE ──────────────────────────────────────────────────────
GOT_SHA="$(shasum -a 256 "$WORK/app.zip" | awk '{print $1}')"
if [ "$GOT_SHA" != "$WANT_SHA" ]; then
  die "SHA256 MISMATCH — the download does not match the published manifest.
  expected  $WANT_SHA
  got       $GOT_SHA
Nothing was installed. This means the bytes served are not the bytes released:
a corrupted transfer, a stale CDN edge, or interference. Do not work around it."
fi
say "sha256 verified"

# ── extract, and re-check the signature the packaging pipeline produced ───────
#
# ditto, never unzip: a generic unzip does not correctly reconstruct a signed
# .app's bundle structure, which independently breaks its code signature
# (DEC-0161). This check is not Gatekeeper's — Gatekeeper will still reject an
# ad-hoc signature — it is the same assertion verify-package.js makes at build
# time, re-run on the bytes that actually arrived here.
say "extracting"
ditto -x -k "$WORK/app.zip" "$WORK/x" || die "could not extract the archive."
SRC="$WORK/x/$APP_NAME"
[ -d "$SRC" ] || die "the archive did not contain $APP_NAME."

if ! codesign --verify --deep --strict "$SRC" 2>/dev/null; then
  die "the extracted app FAILS codesign --verify, so macOS would refuse to run it
even after install. This is a broken release, not a problem with your machine.
Nothing was installed."
fi
say "code signature verified"

# ── install ───────────────────────────────────────────────────────────────────
DEST="${INSTALL_DIR%/}/$APP_NAME"
# Suffixed with this script's own pid, same reasoning as installPlan()'s aside
# path (DEC-0171): a crashed earlier run's leftover can never collide with this one.
ASIDE="${DEST}.aside-$$"

# A running instance holds its own binary open; replacing it underneath is how
# an install half-succeeds and the app dies on next launch.
if pgrep -f "$DEST/Contents/MacOS/CoffeeRadar" >/dev/null 2>&1; then
  say "quitting the running copy"
  osascript -e 'quit app "CoffeeRadar"' >/dev/null 2>&1 || true
  for _ in 1 2 3 4 5 6 7 8 9 10; do
    pgrep -f "$DEST/Contents/MacOS/CoffeeRadar" >/dev/null 2>&1 || break
    sleep 1
  done
  pkill -f "$DEST/Contents/MacOS/CoffeeRadar" >/dev/null 2>&1 || true
fi

[ -w "${INSTALL_DIR%/}" ] || die "no write permission on ${INSTALL_DIR%/}.
Re-run with COFFEERADAR_INSTALL_DIR=\"\$HOME/Applications\" to install for this user only."

# ── the swap: two same-volume renames, never rm+copy (DEC-0171 shape) ─────────
#
# The old rm-rf-then-ditto-in was the same defect class DEC-0171 root-caused
# for the in-app updater (docs/RESEARCH-DEC-0171.md): a copy interrupted
# mid-write (killed script, full disk, yanked cable) leaves a fragment bundle
# at $DEST with the old app already gone. `mv` on the same volume is a
# metadata rename, nothing to half-write — the old app stays intact aside, or
# the new app is fully in place, at every instant. ($WORK and $INSTALL_DIR are
# same-volume on a stock single-disk Mac, exactly the assumption the in-app
# updater already makes between Downloads and /Applications.)
say "installing to $DEST"
if [ -e "$DEST" ]; then
  mv "$DEST" "$ASIDE" || die "could not move the existing app aside ($DEST -> $ASIDE). Nothing was changed."
fi

if ! mv "$SRC" "$DEST" 2>/dev/null; then
  if [ -e "$ASIDE" ]; then
    if mv "$ASIDE" "$DEST" 2>/dev/null; then
      die "could not move the new app into place. Your previous version was restored at $DEST."
    fi
    die "could not move the new app into place, AND restoring your previous version also failed.
Your previous version is intact at: $ASIDE — move it back to $DEST by hand, or re-run this installer."
  fi
  die "could not move the new app into place. Nothing was installed."
fi

# Re-verify what actually landed at $DEST — the same S149 husk check
# install-helper.sh runs after its own swap: plist present, executable present
# AND executable, signature seals the whole bundle, and the version is the one
# the manifest promised. $SRC already passed the same shape of check above
# (line ~114) before the move; this re-checks the bytes that arrived.
verify_fail=""
[ -f "$DEST/Contents/Info.plist" ] || verify_fail="Info.plist missing"
if [ -z "$verify_fail" ]; then
  EXECNAME="$(plutil -extract CFBundleExecutable raw -o - -- "$DEST/Contents/Info.plist" 2>/dev/null)" || verify_fail="CFBundleExecutable unreadable"
fi
if [ -z "$verify_fail" ] && [ ! -x "$DEST/Contents/MacOS/$EXECNAME" ]; then verify_fail="executable missing or not executable"; fi
if [ -z "$verify_fail" ] && ! codesign --verify --deep --strict "$DEST" 2>/dev/null; then verify_fail="codesign --verify failed after the move"; fi
if [ -z "$verify_fail" ]; then
  GOT_VERSION="$(plutil -extract CFBundleShortVersionString raw -o - -- "$DEST/Contents/Info.plist" 2>/dev/null)"
  [ "$GOT_VERSION" = "$VERSION" ] || verify_fail="version is '$GOT_VERSION', expected '$VERSION'"
fi

if [ -n "$verify_fail" ]; then
  rm -rf "$DEST"
  if [ -e "$ASIDE" ]; then
    if mv "$ASIDE" "$DEST" 2>/dev/null; then
      die "the newly installed app failed verification ($verify_fail). Your previous version was restored at $DEST."
    fi
    die "the newly installed app failed verification ($verify_fail), AND restoring your previous version also failed.
Your previous version is intact at: $ASIDE — move it back to $DEST by hand, or re-run this installer."
  fi
  die "the newly installed app failed verification ($verify_fail). Nothing usable was installed."
fi
rm -rf "$ASIDE" 2>/dev/null

# Belt and braces. Nothing above should have attached a quarantine attribute —
# curl does not set one — but an install that silently leaves one behind would
# reproduce the exact "damaged" failure this script exists to prevent, and the
# user would have no way to tell the difference.
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true

echo ""
echo "  installed CoffeeRadar $VERSION → $DEST"
echo ""
open "$DEST" || die "installed, but could not launch it. Open it from $INSTALL_DIR."
say "launched — look for the coffee cup in your menu bar."
echo ""
