#!/bin/bash
# Union dSYM uploader, v2 — https://useunion.dev/docs/dsyms
#
# Extracts each binary's function table from a dSYM on this Mac and uploads it to Union, so crash
# stacks show function names instead of addresses. The raw dSYM is archived too, so file:line can be
# added later without uploading again.
#
#   UNION_SERVER_KEY=sk_… bash v2.sh [path …]
#   UNION_SERVER_KEY=sk_… bash v2.sh --uuid <UUID> [--uuid <UUID> …] [path …]
#
# A path can be a .dSYM, a folder of them, a .zip (fastlane's DSYM_OUTPUT_PATH) or an .xcarchive.
# With no path it uses $DWARF_DSYM_FOLDER_PATH (Xcode build phase) or $CI_ARCHIVE_PATH/dSYMs
# (Xcode Cloud).
#
#   --uuid U    upload only the dSYMs holding these binary UUIDs (Settings → Debug symbols → Missing
#               dSYMs). Without a path they are looked up on this Mac: Spotlight first, then Xcode's
#               Archives and DerivedData. A UUID that is not found is named at the end.
#   --no-raw    upload only the function tables, not the dSYM itself
#   --strict    exit non-zero when an upload fails (default: warn, never fail your build)
#   --dry-run   extract and print what would be uploaded; needs no key
#
# Needs a *server* key (sk_…, Settings → Server keys). Keep it in your CI's secret store; never commit
# it. This file is versioned: v2.sh never changes behaviour, and v2.sh.sha256 lets you pin it.
# Without --uuid it does exactly what v1.sh does.

set -u
VERSION=2
# The symbol-file format, which is not the script's version: v2 writes the same tables v1 did.
FORMAT=1
HOST="${UNION_HOST:-https://in.useunion.dev}"
KEY="${UNION_SERVER_KEY:-}"
RAW=1
STRICT=0
DRY=0
PART_BYTES=$((50 * 1024 * 1024))
FAILED=0
INPUTS=()
WANTED=""

# "6474c08f-92d0-…" → "6474C08F92D0…", or empty when it is not a binary UUID.
norm_uuid() {
  local u
  u=$(echo "$1" | tr -d '-' | tr 'a-f' 'A-F')
  case "$u" in *[!0-9A-F]*) return ;; esac
  [ "${#u}" -eq 32 ] && echo "$u"
}
want() {
  local u
  u=$(norm_uuid "$1")
  [ -n "$u" ] || { echo "union: --uuid $1 is not a binary UUID (32 hex digits)" >&2; exit 2; }
  WANTED="$WANTED $u"
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --no-raw) RAW=0 ;;
    --strict) STRICT=1 ;;
    --dry-run) DRY=1 ;;
    --uuid) [ "$#" -ge 2 ] || { echo "union: --uuid needs a value" >&2; exit 2; }; want "$2"; shift ;;
    --uuid=*) want "${1#--uuid=}" ;;
    -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    -*) echo "union: unknown option $1" >&2; exit 2 ;;
    *) INPUTS+=("$1") ;;
  esac
  shift
done

say() { echo "union: $*"; }
warn() { echo "union: warning: $*" >&2; FAILED=1; }
finish() {
  if [ "$FAILED" -ne 0 ] && [ "$STRICT" -eq 1 ]; then exit 1; fi
  exit 0
}

if [ "$DRY" -eq 0 ] && [ -z "$KEY" ]; then
  warn "UNION_SERVER_KEY is not set; nothing uploaded (Settings → Server keys)"
  finish
fi
case "$KEY" in
  av_*) warn "that is a client key (av_…); dSYM uploads need a server key (sk_…)"; finish ;;
esac
for tool in dwarfdump nm otool xcrun gzip curl ditto split; do
  command -v "$tool" >/dev/null 2>&1 || { warn "$tool not found — run this on a Mac with Xcode"; finish; }
done

# The slices of a bundle, one normalized UUID per line.
bundle_uuids() {
  dwarfdump --uuid "$1" 2>/dev/null | while read -r _ uuid _; do norm_uuid "$uuid"; done
}
wanted() { case " $WANTED " in *" $1 "*) return 0 ;; esac; return 1; }

WORK="$(mktemp -d -t union-dsyms)"
trap 'rm -rf "$WORK"' EXIT

# With --uuid and no path, find the bundles on this Mac. Spotlight indexes every dSYM Xcode writes
# (com_apple_xcode_dsym_uuids, dashed); the folder walk covers a Mac with Spotlight off or excluded.
if [ -n "$WANTED" ] && [ "${#INPUTS[@]}" -eq 0 ]; then
  FOUND="$WORK/found"
  : > "$FOUND"
  for u in $WANTED; do
    dashed="${u:0:8}-${u:8:4}-${u:12:4}-${u:16:4}-${u:20:12}"
    mdfind "com_apple_xcode_dsym_uuids == $dashed" 2>/dev/null | grep '\.dSYM$' >> "$FOUND"
  done
  missing=0
  for u in $WANTED; do
    hit=0
    while IFS= read -r bundle; do
      [ -n "$bundle" ] || continue
      if bundle_uuids "$bundle" | grep -qx "$u"; then hit=1; break; fi
    done < "$FOUND"
    [ "$hit" -eq 1 ] || missing=1
  done
  if [ "$missing" -eq 1 ]; then
    say "not every UUID is in Spotlight; searching Xcode's Archives and DerivedData"
    for dir in "$HOME/Library/Developer/Xcode/Archives" "$HOME/Library/Developer/Xcode/DerivedData"; do
      [ -d "$dir" ] && find "$dir" -name '*.dSYM' -type d -prune 2>/dev/null >> "$FOUND"
    done
  fi
  while IFS= read -r bundle; do [ -n "$bundle" ] && INPUTS+=("$bundle"); done < <(sort -u "$FOUND")
  if [ "${#INPUTS[@]}" -eq 0 ]; then
    warn "none of these dSYMs is on this Mac:$WANTED"
    warn "a dSYM only matches the exact build that shipped — rebuilding the same commit makes new UUIDs."
    warn "look in Xcode → Organizer → Archives on the Mac that archived it, or in the Xcode Cloud build's artifacts, and pass the .xcarchive or .zip as a path"
    finish
  fi
fi

if [ "${#INPUTS[@]}" -eq 0 ]; then
  if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ]; then INPUTS=("$DWARF_DSYM_FOLDER_PATH")
  elif [ -n "${CI_ARCHIVE_PATH:-}" ]; then INPUTS=("$CI_ARCHIVE_PATH/dSYMs")
  else warn "no path given and no Xcode/Xcode Cloud dSYM folder in the environment"; finish
  fi
fi

# Every .dSYM bundle under the inputs, one per line.
BUNDLES="$WORK/bundles"
: > "$BUNDLES"
n=0
for input in "${INPUTS[@]}"; do
  case "$input" in
    *.zip)
      n=$((n + 1)); mkdir -p "$WORK/unzip$n"
      ditto -x -k "$input" "$WORK/unzip$n" 2>/dev/null || { warn "could not unzip $input"; continue; }
      find "$WORK/unzip$n" -name '*.dSYM' -type d -prune >> "$BUNDLES" ;;
    *.xcarchive) find "$input/dSYMs" -name '*.dSYM' -type d -prune >> "$BUNDLES" 2>/dev/null ;;
    *.dSYM) echo "$input" >> "$BUNDLES" ;;
    *)
      if [ -d "$input" ]; then find "$input" -name '*.dSYM' -type d -prune >> "$BUNDLES"
      else warn "$input does not exist"; fi ;;
  esac
done
if [ ! -s "$BUNDLES" ]; then warn "no .dSYM found in ${INPUTS[*]}"; finish; fi

# With --uuid, only the bundles that hold one of them — a folder of a hundred archives uploads the
# one that was asked for. What was found is remembered, so what was not can be named at the end.
SEEN="$WORK/seen"
: > "$SEEN"
if [ -n "$WANTED" ]; then
  sort -u "$BUNDLES" > "$BUNDLES.all"
  : > "$BUNDLES"
  while IFS= read -r bundle; do
    [ -n "$bundle" ] || continue
    keep=0
    for u in $(bundle_uuids "$bundle"); do
      if wanted "$u"; then keep=1; echo "$u" >> "$SEEN"; fi
    done
    [ "$keep" -eq 1 ] && echo "$bundle" >> "$BUNDLES"
  done < "$BUNDLES.all"
fi
report_unfound() {
  [ -n "$WANTED" ] || return 0
  local u
  for u in $WANTED; do
    grep -qx "$u" "$SEEN" || warn "$u — no dSYM with this UUID here. It must come from the exact build that shipped (Xcode → Organizer → Archives, or the Xcode Cloud build's artifacts); rebuilding the same commit makes a new UUID."
  done
}
if [ -n "$WANTED" ] && [ ! -s "$BUNDLES" ]; then report_unfound; finish; fi

api() { # method path [curl args…] — prints the body, returns non-zero on a non-2xx answer
  local method="$1" path="$2"; shift 2
  local out="$WORK/response" code
  code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" "$HOST$path" \
    -H "x-union-key: $KEY" -H "user-agent: union-dsyms/$VERSION" "$@") || return 1
  cat "$out"
  case "$code" in 2*) return 0 ;; *) echo " (HTTP $code)" ; return 1 ;; esac
}
field() { sed -n "s/.*\"$1\":\"\\{0,1\\}\\([^\",}]*\\).*/\\1/p"; }

plist() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null || true; }

# The table for one slice: "<hex offset from __TEXT>\t<demangled name>", sorted by offset.
extract() { # dwarf-file arch out-file
  local dwarf="$1" arch="$2" out="$3" base
  base=$(otool -arch "$arch" -l "$dwarf" | awk '
    $1 == "segname" && $2 == "__TEXT" { text = 1; next }
    text && $1 == "vmaddr" { print $2; exit }')
  [ -n "$base" ] || return 1
  nm -n --defined-only -arch "$arch" "$dwarf" 2>/dev/null | awk -v base="$base" '
    function hex(s,   i, c, v) {
      v = 0; s = tolower(s); sub(/^0x/, "", s)
      for (i = 1; i <= length(s); i++) { c = index("0123456789abcdef", substr(s, i, 1)); v = v * 16 + c - 1 }
      return v
    }
    BEGIN { b = hex(base) }
    ($2 == "t" || $2 == "T") && NF >= 3 {
      name = $3; for (i = 4; i <= NF; i++) name = name " " $i
      sub(/^_/, "", name)          # the Mach-O global prefix, on C and Swift symbols alike
      off = hex($1) - b
      if (off >= 0) printf "%x\t%s\n", off, name
    }' | xcrun swift-demangle --simplified | xcrun c++filt > "$out"
  [ -s "$out" ]
}

say "uploading dSYMs to $HOST"
while IFS= read -r bundle; do
  [ -n "$bundle" ] || continue
  info="$bundle/Contents/Info.plist"
  version=$(plist "$info" CFBundleShortVersionString)
  build=$(plist "$info" CFBundleVersion)
  uuids=""
  for dwarf in "$bundle"/Contents/Resources/DWARF/*; do
    [ -f "$dwarf" ] || continue
    image=$(basename "$dwarf")
    # "UUID: 6474C08F-92D0-3410-BFAA-668957E3F700 (arm64) /path"
    while read -r _ uuid arch _; do
      arch="${arch#(}"; arch="${arch%)}"
      uuid=$(echo "$uuid" | tr -d '-' | tr 'a-f' 'A-F')
      [ "${#uuid}" -eq 32 ] || continue
      uuids="$uuids $uuid"
      if [ "$DRY" -eq 0 ]; then
        state=$(api GET "/v1/symbols/$uuid") || { warn "$image ($arch): $state"; continue; }
        if echo "$state" | grep -q '"symbols":true'; then say "$image ($arch) $uuid — already uploaded"; continue; fi
      fi
      table="$WORK/$uuid.tsv"
      if ! extract "$dwarf" "$arch" "$table.body"; then warn "$image ($arch): no function symbols found"; continue; fi
      printf 'union-symbols\t%s\t%s\t%s\t%s\t%s\t%s\n' "$FORMAT" "$uuid" "$arch" "$image" "$version" "$build" > "$table"
      cat "$table.body" >> "$table"
      count=$(($(wc -l < "$table") - 1))
      if [ "$DRY" -eq 1 ]; then say "$image ($arch) $uuid — $count functions → $table"; cp "$table" "./$uuid.tsv"; continue; fi
      gzip -9 -c "$table" > "$table.gz"
      if out=$(api POST /v1/symbols -H 'content-encoding: gzip' -H 'content-type: text/tab-separated-values' --data-binary "@$table.gz"); then
        say "$image ($arch) $uuid — $count functions uploaded"
      else
        warn "$image ($arch): $out"
      fi
    done < <(dwarfdump --uuid "$dwarf" 2>/dev/null)
  done

  # The dSYM itself, once per bundle, keyed by its first slice. Parts of 50 MB stay under the
  # 100 MB request ceiling and R2's rule that every part but the last is the same size.
  [ "$RAW" -eq 1 ] && [ "$DRY" -eq 0 ] || continue
  set -- $uuids
  [ "$#" -gt 0 ] || continue
  first="$1"
  state=$(api GET "/v1/symbols/$first") || continue
  if echo "$state" | grep -q '"raw":true'; then continue; fi
  zip="$WORK/$first.zip"
  ditto -c -k --keepParent "$bundle" "$zip" || { warn "could not zip $bundle"; continue; }
  upload=$(api POST "/v1/symbols/$first/raw" | field uploadId)
  [ -n "$upload" ] || { warn "$(basename "$bundle"): could not start the raw upload"; continue; }
  rm -f "$WORK"/part.*
  split -b "$PART_BYTES" "$zip" "$WORK/part."
  parts="" i=0 ok=1
  for part in "$WORK"/part.*; do
    i=$((i + 1))
    etag=$(api PUT "/v1/symbols/$first/raw/$upload/$i" --data-binary "@$part" -H 'content-type: application/octet-stream' | field etag)
    [ -n "$etag" ] || { ok=0; break; }
    parts="$parts${parts:+,}{\"part\":$i,\"etag\":\"$etag\"}"
  done
  if [ "$ok" -eq 1 ]; then
    list=""; for u in $uuids; do list="$list${list:+,}\"$u\""; done
    if api POST "/v1/symbols/$first/raw/$upload/complete" -H 'content-type: application/json' \
         --data "{\"parts\":[$parts],\"uuids\":[$list]}" >/dev/null; then
      say "$(basename "$bundle") archived ($(du -h "$zip" | cut -f1 | tr -d ' '))"
    else warn "$(basename "$bundle"): could not finish the raw upload"; fi
  else
    warn "$(basename "$bundle"): a raw part failed; the function tables are uploaded regardless"
  fi
done < "$BUNDLES"

report_unfound
finish
