#!/usr/bin/env bash
# mac-updater.sh — Hanson OS self-updater, run by launchd (every 4h + at login).
#
# Lives at ~/.hanson-os/mac-updater.sh — UpdaterBootstrap copies it out of the
# app bundle on every launch, so the updater script itself stays current with
# the app (no chicken-and-egg).
#
# Flow: GET updates.hanson911.me/mac/version.json → compare build number →
# if newer: curl the zip → verify sha256 AND that the new app is codesigned by
# OUR team (KQZF9JQ9SK) → graceful-quit if running → ditto-swap the bundle →
# relaunch only if it was running.
#
# Why no notarization needed: curl-downloaded files carry NO com.apple.quarantine
# xattr, so the swapped app launches without Gatekeeper prompts even though it
# is only Developer-ID-signed, not notarized. (First install via the AirDrop'd
# dmg IS notarized; the dmg path is the only quarantined one.)
#
# Security: the codesign TeamIdentifier check is the real control. Even if
# updates.hanson911.me were compromised, an attacker cannot push an app that
# isn't signed by Hanson's Apple Developer cert. The sha256 guards integrity.
set -uo pipefail
umask 077   # logs + temp dirs not world-readable

HOST="https://updates.hanson911.me/mac"
TEAM_ID="KQZF9JQ9SK"
APP_NAME="Hanson OS"
APP_PATH="${HANSON_OS_APP_PATH:-/Applications/${APP_NAME}.app}"
PROC_MATCH="${APP_NAME}.app/Contents/MacOS"   # stable substring for pgrep
MY_UID="$(id -u)"
BACKUP="${APP_PATH}.update-bak"
LOG="$HOME/Library/Logs/hansonos-updater.log"
PLIST_BIN="/usr/libexec/PlistBuddy"
LOCKDIR="$HOME/.hanson-os/updater.lock.d"
TMP=""

mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG" 2>/dev/null; }
cleanup() { [ -n "$TMP" ] && rm -rf "$TMP"; rm -rf "$LOCKDIR"; }

# ── single-instance lock: launchd fires at login + RunAtLoad + every 4h, so a
#    slow download must not overlap the next run and race on the bundle swap.
#    macOS has no flock(1) — use an atomic mkdir lock with PID staleness. ──
mkdir -p "$HOME/.hanson-os" 2>/dev/null || true
if ! mkdir "$LOCKDIR" 2>/dev/null; then
  oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "")
  if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then
    log "another updater instance (pid=$oldpid) running — skip"; exit 0
  fi
  log "stale lock (pid='${oldpid:-?}' dead) — reclaiming"
  rm -rf "$LOCKDIR"
  mkdir "$LOCKDIR" 2>/dev/null || { log "could not reclaim lock — skip"; exit 0; }
fi
echo $$ > "$LOCKDIR/pid"
trap cleanup EXIT   # we own the lock now

# ── crash recovery: a prior run killed mid-swap leaves the app moved to $BACKUP
#    and $APP_PATH missing. Restore before anything else, else all future runs
#    skip on the sanity check below and the app stays gone. ──
if [ ! -d "$APP_PATH" ] && [ -d "$BACKUP" ]; then
  log "RECOVERY: stale backup found — restoring $BACKUP → $APP_PATH"
  mv "$BACKUP" "$APP_PATH" 2>>"$LOG" || log "RECOVERY mv failed"
fi

# ── 0. sanity ──
[ -d "$APP_PATH" ] || { log "app not found at $APP_PATH — skip"; exit 0; }

TMP="$(mktemp -d)"

# ── 1. fetch manifest ──
curl -fsSL -m 30 "$HOST/version.json" -o "$TMP/version.json" || { log "manifest fetch failed"; exit 0; }
REMOTE_BUILD=$(plutil -extract build  raw -o - "$TMP/version.json" 2>/dev/null || echo "")
REMOTE_VER=$(plutil   -extract version raw -o - "$TMP/version.json" 2>/dev/null || echo "")
REMOTE_URL=$(plutil   -extract url     raw -o - "$TMP/version.json" 2>/dev/null || echo "")
REMOTE_SHA=$(plutil   -extract sha256  raw -o - "$TMP/version.json" 2>/dev/null || echo "")

# ── 2. compare build numbers (integer, monotonic) ──
LOCAL_BUILD=$("$PLIST_BIN" -c "Print :CFBundleVersion" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "0")
case "$REMOTE_BUILD" in ''|*[!0-9]*) log "bad/empty remote build '$REMOTE_BUILD' — skip"; exit 0;; esac
case "$LOCAL_BUILD"  in ''|*[!0-9]*) LOCAL_BUILD=0;; esac

if [ "$REMOTE_BUILD" -le "$LOCAL_BUILD" ]; then
  log "up to date (local=$LOCAL_BUILD remote=$REMOTE_BUILD)"
  exit 0
fi
log "update available: build $LOCAL_BUILD → $REMOTE_BUILD ($REMOTE_VER)"
[ -n "$REMOTE_URL" ] || { log "no url in manifest — skip"; exit 0; }

# ── 3. download zip ──
curl -fsSL -m 300 "$REMOTE_URL" -o "$TMP/update.zip" || { log "zip download failed"; exit 0; }

# ── 4. verify sha256 (download-integrity only; NOT the trust anchor — the
#    codesign Team ID check in step 6 is what defeats a compromised host, since
#    an attacker who controls version.json also controls its sha256) ──
if [ -n "$REMOTE_SHA" ]; then
  ACTUAL_SHA=$(shasum -a 256 "$TMP/update.zip" | awk '{print $1}')
  if [ "$ACTUAL_SHA" != "$REMOTE_SHA" ]; then
    log "SHA256 MISMATCH expected=$REMOTE_SHA got=$ACTUAL_SHA — ABORT"
    exit 0
  fi
else
  log "WARNING: manifest has no sha256 — ABORT (refuse unverified update)"
  exit 0
fi

# ── 5. extract ──
ditto -x -k "$TMP/update.zip" "$TMP/extracted" || { log "unzip failed"; exit 0; }
NEW_APP=$(find "$TMP/extracted" -maxdepth 2 -name "${APP_NAME}.app" -type d | head -1)
[ -d "$NEW_APP" ] || { log "no .app in zip — skip"; exit 0; }

# ── 6. SECURITY: codesign valid + signed by OUR team ──
if ! codesign --verify --deep --strict "$NEW_APP" >>"$LOG" 2>&1; then
  log "codesign --verify FAILED — ABORT (possible tampering)"
  exit 0
fi
NEW_TEAM=$(codesign -dvv "$NEW_APP" 2>&1 | sed -n 's/^TeamIdentifier=//p')
if [ "$NEW_TEAM" != "$TEAM_ID" ]; then
  log "TeamIdentifier '$NEW_TEAM' != '$TEAM_ID' — ABORT (not our build)"
  exit 0
fi

# ── 7. extracted build must match manifest (no downgrade/mismatch trick) ──
NEW_BUILD=$("$PLIST_BIN" -c "Print :CFBundleVersion" "$NEW_APP/Contents/Info.plist" 2>/dev/null || echo "0")
if [ "$NEW_BUILD" != "$REMOTE_BUILD" ]; then
  log "extracted build $NEW_BUILD != manifest $REMOTE_BUILD — ABORT"
  exit 0
fi

# ── 8. quit if running (graceful, so chat flushes to JSONL) ──
WAS_RUNNING=0
if pgrep -U "$MY_UID" -f "$PROC_MATCH" >/dev/null 2>&1; then
  WAS_RUNNING=1
  log "app running — graceful quit"
  osascript -e "tell application \"${APP_NAME}\" to quit" >/dev/null 2>&1 || true
  for _ in $(seq 1 20); do
    pgrep -U "$MY_UID" -f "$PROC_MATCH" >/dev/null 2>&1 || break
    sleep 0.5
  done
  if pgrep -U "$MY_UID" -f "$PROC_MATCH" >/dev/null 2>&1; then
    log "app did not quit in 10s — sending TERM"
    pkill -TERM -U "$MY_UID" -f "$PROC_MATCH" >/dev/null 2>&1 || true
    for _ in $(seq 1 10); do
      pgrep -U "$MY_UID" -f "$PROC_MATCH" >/dev/null 2>&1 || break
      sleep 0.5
    done
  fi
  if pgrep -U "$MY_UID" -f "$PROC_MATCH" >/dev/null 2>&1; then
    log "app still running after TERM — ABORT (retry next cycle)"
    exit 0
  fi
fi

# ── 9. swap (backup adjacent → same-volume fast mv + rollback) ──
rm -rf "$BACKUP"
if ! mv "$APP_PATH" "$BACKUP" 2>>"$LOG"; then
  log "could not move old app (perms?) — ABORT"
  exit 0
fi
if ! ditto "$NEW_APP" "$APP_PATH" >>"$LOG" 2>&1; then
  log "ditto swap FAILED — attempting rollback"
  rm -rf "$APP_PATH" 2>/dev/null
  if mv "$BACKUP" "$APP_PATH" 2>>"$LOG"; then
    log "rollback OK — old app restored"
  else
    log "CRITICAL: rollback failed — $APP_PATH missing, manual reinstall needed"
  fi
  exit 0
fi
rm -rf "$BACKUP"
log "swapped to build $NEW_BUILD"

# ── 10. relaunch only if it was running ──
if [ "$WAS_RUNNING" -eq 1 ]; then
  open "$APP_PATH" && log "relaunched"
fi
log "update complete: now build $NEW_BUILD ($REMOTE_VER)"
