#!/bin/bash
# VPN Manager — macOS hands-free self-updater.
#
# This runs DETACHED from the Electron app. The app spawns it, emits
# update:applying to the renderer, then quits. Because we're detached
# (own process group, stdio ignored, parent already exiting) we survive the
# app's death, swap the .app bundle in place, and relaunch — no manual drag.
#
# Why this exists: the app is ad-hoc signed (no Apple Developer ID), so
# Squirrel.Mac / electron-updater quitAndInstall hangs forever. This script is
# the only flow that completes an update on an ad-hoc Mac with zero user steps.
#
# Args:
#   $1  DMG_PATH    — verified .dmg already downloaded + sha256-checked by the app
#   $2  TARGET_APP  — absolute path to the installed bundle, e.g.
#                     "/Applications/VPN Manager.app"
#   $3  APP_PID     — pid of the running app; we wait for it to exit first
#
# Robustness contract:
#   - Everything is logged to ~/Library/Logs/VPN Manager/selfupdate.log
#   - Every fatal step routes through fail(), which falls back to `open "$DMG"`
#     (the proven manual installer flow) so the user is NEVER left with no app.
#   - Timeouts on the wait-for-quit loop and on hdiutil so we can't hang forever.
#
# H-5: Ed25519 detached-signature verification of the DMG (added 2026-05).
#   Before mounting we look for a sidecar signature file at "${DMG_PATH}.sig"
#   (base64-encoded Ed25519 signature over the DMG's raw SHA-512 — same
#   convention publish.js emits next to the artifact). The pinned public key
#   lives next to this script at ./selfupdate-pub.pem. If BOTH the .sig file
#   and the pinned key are present we hard-fail on a bad signature. If EITHER
#   is missing we log a loud warning and continue ("legacy unsigned manifest"
#   path) — this covers clients/servers that shipped before the cutover. New
#   clients will get the .sig downloaded alongside the DMG by main.js once the
#   updater plumbing lands; until then existing 0.2.x users keep auto-updating.
#   To make verification mandatory, set REQUIRE_UPDATE_SIG=1 in the env.

set -u

# Anchor cwd somewhere we never delete. The default cwd inherited from Electron
# is typically inside the .app bundle, which we are about to `rm -rf`; if that
# happens while it's our cwd, later steps (and the relaunch) can misbehave. "/"
# always exists and is never the swap target.
cd / 2>/dev/null || true

DMG_PATH="${1:-}"
TARGET_APP="${2:-}"
APP_PID="${3:-}"

APP_NAME="VPN Manager.app"
LOG_DIR="${HOME}/Library/Logs/VPN Manager"
LOG_FILE="${LOG_DIR}/selfupdate.log"
MOUNT_POINT="$(mktemp -d "${TMPDIR:-/tmp}/vpnmgr-mnt.XXXXXX")"
MOUNTED=0

mkdir -p "$LOG_DIR" 2>/dev/null || true

log() {
  printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$*" >> "$LOG_FILE" 2>/dev/null || true
}

cleanup_mount() {
  # Detach the DMG if we mounted it. Try graceful, then force.
  if [ "$MOUNTED" = "1" ]; then
    log "detaching ${MOUNT_POINT}"
    hdiutil detach "$MOUNT_POINT" >> "$LOG_FILE" 2>&1 \
      || hdiutil detach "$MOUNT_POINT" -force >> "$LOG_FILE" 2>&1 \
      || log "WARN: detach failed for ${MOUNT_POINT}"
  fi
  rmdir "$MOUNT_POINT" 2>/dev/null || true
}

# Fatal: log the reason, clean up the mount, then fall back to opening the DMG
# so the user can still install manually. Never leaves the user stranded.
# Use this for failures where the DMG itself is known-good (mount/copy/relaunch
# problems) — opening it lets the user drag the app across by hand.
fail() {
  log "FAIL: $*"
  cleanup_mount
  if [ -n "$DMG_PATH" ] && [ -f "$DMG_PATH" ]; then
    log "fallback: open \"$DMG_PATH\" (manual installer)"
    open "$DMG_PATH" >> "$LOG_FILE" 2>&1 || log "WARN: fallback open failed"
  fi
  exit 1
}

# Fatal WITHOUT the open-DMG fallback. Use this when the downloaded file is NOT a
# valid disk image: `open`ing it would just hand junk (e.g. an HTML error page)
# to Archive Utility — the exact symptom we're hardening against. Log loudly +
# exit non-zero so the failure is visible in the log instead of a silent no-op.
fail_no_open() {
  log "FAIL (no-open): $*"
  cleanup_mount
  exit 1
}

log "=== self-update start ==="
log "dmg=${DMG_PATH} target=${TARGET_APP} pid=${APP_PID}"

[ -n "$DMG_PATH" ] || fail "no DMG path argument"
[ -f "$DMG_PATH" ] || fail "DMG not found at ${DMG_PATH}"
[ -n "$TARGET_APP" ] || fail "no target app path argument"

# 1) Wait for the running app to fully quit so we can replace its bundle.
#    Cap at ~60s (120 * 0.5s). We LOG EVERY ITERATION: if launchd ever reaps this
#    script (the old bug), the log simply STOPS mid wait-loop — that's the
#    smoking gun. A healthy run shows the ticks counting up then "done", then the
#    swap + "relaunching" lines.
if [ -n "$APP_PID" ]; then
  log "waiting for app pid ${APP_PID} to exit (heartbeat every tick, cap ~60s)"
  waited=0
  while kill -0 "$APP_PID" 2>/dev/null; do
    sleep 0.5
    waited=$((waited + 1))
    log "wait-for-pid ${APP_PID}: tick ${waited} (~$((waited / 2))s) still alive"
    if [ "$waited" -ge 120 ]; then
      log "WARN: app pid ${APP_PID} still alive after ~60s — continuing anyway"
      break
    fi
  done
  log "app pid ${APP_PID} gone (waited ~$((waited / 2))s, ${waited} ticks)"
fi

# 1b) H-5: verify the Ed25519 signature on the DMG before mounting it. We sign
#     the raw SHA-512 of the DMG (matching publish.js), and the public key is
#     pinned next to this script. openssl on macOS supports Ed25519 since
#     LibreSSL 3.x (default in macOS 12+), so no extra deps needed.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PUB_KEY="${UPDATE_PUBKEY:-${SCRIPT_DIR}/selfupdate-pub.pem}"
SIG_FILE="${DMG_PATH}.sig"
REQUIRE_SIG="${REQUIRE_UPDATE_SIG:-0}"

if [ -f "$SIG_FILE" ] && [ -f "$PUB_KEY" ]; then
  log "ed25519 verify: SIG=${SIG_FILE} PUB=${PUB_KEY}"
  # Decode the base64 signature to a raw 64-byte file openssl can consume.
  SIG_RAW="$(mktemp "${TMPDIR:-/tmp}/vpnmgr-sig.XXXXXX")"
  SHA_RAW="$(mktemp "${TMPDIR:-/tmp}/vpnmgr-sha.XXXXXX")"
  # Trim whitespace/newlines from the sig file before decoding.
  tr -d '[:space:]' < "$SIG_FILE" | base64 --decode > "$SIG_RAW" 2>>"$LOG_FILE" \
    || { rm -f "$SIG_RAW" "$SHA_RAW"; fail_no_open "could not decode signature file ${SIG_FILE}"; }
  # Produce the 64-byte raw SHA-512 of the DMG (same input publish.js signed).
  shasum -a 512 -b "$DMG_PATH" 2>>"$LOG_FILE" | awk '{print $1}' | xxd -r -p > "$SHA_RAW" \
    || { rm -f "$SIG_RAW" "$SHA_RAW"; fail_no_open "could not compute SHA-512 of ${DMG_PATH}"; }
  if openssl pkeyutl -verify -pubin -inkey "$PUB_KEY" -rawin -in "$SHA_RAW" -sigfile "$SIG_RAW" >> "$LOG_FILE" 2>&1; then
    log "ed25519 verify: OK"
    rm -f "$SIG_RAW" "$SHA_RAW"
  else
    rm -f "$SIG_RAW" "$SHA_RAW"
    fail_no_open "ed25519 signature verification FAILED for ${DMG_PATH} — refusing to install possibly tampered update"
  fi
else
  # Cutover path: legacy unsigned manifest OR no pinned key yet.
  if [ "$REQUIRE_SIG" = "1" ]; then
    fail_no_open "missing signature (${SIG_FILE}) or pubkey (${PUB_KEY}) and REQUIRE_UPDATE_SIG=1 is set — refusing to install"
  fi
  log "WARN: skipping ed25519 verify (sig=${SIG_FILE} exists? $([ -f "$SIG_FILE" ] && echo yes || echo no), pub=${PUB_KEY} exists? $([ -f "$PUB_KEY" ] && echo yes || echo no)) — legacy unsigned-update path"
fi

# 2) Strip quarantine from the DOWNLOADED DMG first. A DMG fetched over HTTP gets
#    the com.apple.quarantine xattr; on an ad-hoc build Gatekeeper can then
#    TRANSLOCATE or refuse the mounted contents, which is one way an "update"
#    silently degrades into Finder just showing the file. Best-effort.
log "stripping quarantine from DMG ${DMG_PATH}"
xattr -dr com.apple.quarantine "$DMG_PATH" >> "$LOG_FILE" 2>&1 || log "WARN: xattr strip on DMG failed (non-fatal)"

# 3) Resolve the source .app from the downloaded artifact. We support BOTH a
#    .dmg (mount) AND a .zip (extract): our Linux/WSL build host cannot produce
#    DMGs (no hdiutil), so releases ship as .zip. Trying to `hdiutil attach` a
#    zip is exactly what produced "the disk image couldn't be opened / is
#    corrupted". Detect the kind by extension, falling back to a content probe.
SRC_APP=""
case "$DMG_PATH" in
  *.zip) ARTIFACT_KIND=zip ;;
  *.dmg) ARTIFACT_KIND=dmg ;;
  *) if hdiutil imageinfo "$DMG_PATH" >> "$LOG_FILE" 2>&1; then ARTIFACT_KIND=dmg; else ARTIFACT_KIND=zip; fi ;;
esac
log "artifact kind: ${ARTIFACT_KIND}"

if [ "$ARTIFACT_KIND" = "zip" ]; then
  # 4z) Extract the zip (ditto preserves the ad-hoc signature + xattrs; unzip is
  #     the fallback). The .app sits at the archive root for our electron-builder
  #     mac zips, but we also search a few levels deep just in case.
  EXTRACT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vpnmgr-zip.XXXXXX")"
  log "extracting zip ${DMG_PATH} -> ${EXTRACT_DIR}"
  if ! ditto -x -k "$DMG_PATH" "$EXTRACT_DIR" >> "$LOG_FILE" 2>&1; then
    log "WARN: ditto extract failed, trying unzip"
    unzip -oq "$DMG_PATH" -d "$EXTRACT_DIR" >> "$LOG_FILE" 2>&1 || fail_no_open "could not extract zip ${DMG_PATH}"
  fi
  SRC_APP="${EXTRACT_DIR}/${APP_NAME}"
  if [ ! -d "$SRC_APP" ]; then
    FOUND="$(find "$EXTRACT_DIR" -maxdepth 3 -name "$APP_NAME" -type d -prune 2>/dev/null | head -n1)"
    [ -n "$FOUND" ] && [ -d "$FOUND" ] && SRC_APP="$FOUND"
  fi
  [ -d "$SRC_APP" ] || fail_no_open "source app not found in zip (looked for ${APP_NAME})"
  log "source app (zip) at ${SRC_APP}"
else
  # 4d) DMG path: VERIFY it's a real disk image, then mount read-only.
  log "validating disk image via hdiutil imageinfo ${DMG_PATH}"
  if ! hdiutil imageinfo "$DMG_PATH" >> "$LOG_FILE" 2>&1; then
    fail_no_open "hdiutil imageinfo failed — ${DMG_PATH} is not a valid disk image; refusing to open it as an installer"
  fi
  log "attaching ${DMG_PATH} -> ${MOUNT_POINT}"
  if ! hdiutil attach "$DMG_PATH" -nobrowse -readonly -noverify -mountpoint "$MOUNT_POINT" >> "$LOG_FILE" 2>&1; then
    fail "hdiutil attach failed"
  fi
  MOUNTED=1
  SRC_APP="${MOUNT_POINT}/${APP_NAME}"
  if [ ! -d "$SRC_APP" ]; then
    log "app not at mount root; searching ${MOUNT_POINT} for ${APP_NAME}"
    FOUND="$(find "$MOUNT_POINT" -maxdepth 2 -name "$APP_NAME" -type d -prune 2>/dev/null | head -n1)"
    if [ -n "$FOUND" ] && [ -d "$FOUND" ]; then
      SRC_APP="$FOUND"
      log "found source app at ${SRC_APP}"
    else
      fail "source app not found in DMG (looked for ${APP_NAME} under ${MOUNT_POINT})"
    fi
  fi
fi

# 5) Replace the installed bundle. ditto preserves resource forks / xattrs /
#    the ad-hoc signature better than cp -R.
log "removing old bundle ${TARGET_APP}"
rm -rf "$TARGET_APP" || fail "could not remove old bundle (permissions?)"

log "copying new bundle -> ${TARGET_APP}"
if ! ditto "$SRC_APP" "$TARGET_APP" >> "$LOG_FILE" 2>&1; then
  # ditto failed — try cp -R as a secondary path before giving up.
  log "WARN: ditto failed, trying cp -R"
  TARGET_PARENT="$(dirname "$TARGET_APP")"
  cp -R "$SRC_APP" "$TARGET_PARENT/" >> "$LOG_FILE" 2>&1 || fail "copy of new bundle failed"
fi

# 6) Re-strip quarantine so Gatekeeper doesn't say "damaged" on an ad-hoc build,
#    then VERIFY it's actually gone. ditto/cp can re-apply quarantine when copying
#    from a freshly-mounted (and thus quarantined) DMG, so the strip MUST come
#    AFTER the copy AND we must confirm it took — a lingering quarantine xattr is
#    exactly what makes `open` reveal Finder / translocate instead of launching.
log "stripping quarantine from ${TARGET_APP}"
xattr -dr com.apple.quarantine "$TARGET_APP" >> "$LOG_FILE" 2>&1 || log "WARN: xattr strip failed (non-fatal)"
# Verify: `xattr -p com.apple.quarantine` should print NOTHING once it's cleared.
QUARANTINE_LEFT="$(xattr -p com.apple.quarantine "$TARGET_APP" 2>/dev/null || true)"
if [ -z "$QUARANTINE_LEFT" ]; then
  log "verified: com.apple.quarantine cleared on ${TARGET_APP}"
else
  log "WARN: com.apple.quarantine STILL present on ${TARGET_APP} -> '${QUARANTINE_LEFT}' (relaunch may be translocated by Gatekeeper)"
  # One more aggressive attempt: clear it on the bundle root explicitly.
  xattr -d com.apple.quarantine "$TARGET_APP" >> "$LOG_FILE" 2>&1 || true
fi
chmod -R +x "${TARGET_APP}/Contents/MacOS/" >> "$LOG_FILE" 2>&1 || true
chmod -R +x "${TARGET_APP}/Contents/Resources/bin/" >> "$LOG_FILE" 2>&1 || true

# 7) Detach the DMG (the bundle is already copied to disk).
cleanup_mount
MOUNTED=0

# 8) Relaunch the freshly installed app.
#    Sanity-check the swapped bundle has a runnable executable before we relaunch
#    so a half-copied bundle surfaces as a clear failure (-> DMG fallback) rather
#    than a silent no-op.
TARGET_BIN="${TARGET_APP}/Contents/MacOS/VPN Manager"
if [ ! -x "$TARGET_BIN" ] && ! ls "${TARGET_APP}/Contents/MacOS/"* >/dev/null 2>&1; then
  fail "swapped bundle has no executable in Contents/MacOS"
fi

# Relaunch strategy, in order of reliability for an AD-HOC bundle on macOS 15.1+:
#   1) DIRECT BINARY EXEC — run Contents/MacOS/VPN Manager straight, backgrounded
#      in a subshell so it's not our child. This is the ONLY method that reliably
#      DODGES App Translocation / Gatekeeper, which on recent macOS can hijack an
#      `open` of a freshly-written ad-hoc bundle into a read-only translocated
#      mount or a Finder reveal (the "app closed + a window appears" symptom).
#   2) open -n — request a brand-new instance via LaunchServices.
#   3) open    — last resort.
# We only fall back to opening the DMG (fail()) if ALL of these fail.
RELAUNCHED=0

if [ -x "$TARGET_BIN" ]; then
  log "relaunch attempt 1: direct binary exec \"$TARGET_BIN\""
  # Subshell + & so the new app is reparented away from this updater script and
  # survives even when launchd tears the updater job down after we exit.
  ( "$TARGET_BIN" >/dev/null 2>&1 & )
  if [ $? -eq 0 ]; then
    RELAUNCHED=1
    log "relaunch: direct binary exec dispatched"
  else
    log "WARN: direct binary exec dispatch failed"
  fi
else
  log "WARN: ${TARGET_BIN} not executable — skipping direct exec, trying open"
fi

if [ "$RELAUNCHED" != "1" ]; then
  log "relaunch attempt 2: open -n \"$TARGET_APP\""
  if open -n "$TARGET_APP" >> "$LOG_FILE" 2>&1; then
    RELAUNCHED=1
  else
    log "WARN: 'open -n' failed"
  fi
fi

if [ "$RELAUNCHED" != "1" ]; then
  log "relaunch attempt 3: open \"$TARGET_APP\""
  if open "$TARGET_APP" >> "$LOG_FILE" 2>&1; then
    RELAUNCHED=1
  else
    log "WARN: plain 'open' failed"
  fi
fi

if [ "$RELAUNCHED" != "1" ]; then
  fail "all relaunch attempts failed (direct exec + open -n + open)"
fi

log "=== self-update done ok (relaunched) ==="
exit 0
