#!/bin/bash
# SystemCheck companion for macOS. Reads what macOS already exposes about this
# computer's hardware and writes it to a JSON file you can choose on
# systemcheck.live beside a run.
#
# Read-only. Installs nothing, changes no setting, sends nothing anywhere, and
# never asks for sudo.
#
# Never written to the file: serial numbers, the hardware UUID, the provisioning
# UDID, the computer name, the user name, MAC addresses. They are not hashed;
# they are simply left out.
#
# Run:   bash systemcheck-companion.sh [--out path/to/file.json]
#
# Logging mode, for "Confirm this curve":
#        bash systemcheck-companion.sh --log 300 [--out path/to/file.json]
# samples once a second for that many seconds (10 to 3600) and writes the
# timestamped readings to systemcheck-sensorlog.json: the CPU speed limit from
# `pmset -g therm` (Intel Macs report one; Apple silicon usually does not) and
# the power source from `pmset -g batt`. Package power and the thermal
# pressure level come from `powermetrics`, which needs sudo: the script never
# asks for it, and only if you start it with sudo yourself are they read.
# Without it those fields stay null and the file says they need sudo.

set -u

TOOL_VERSION="0.2.0"
PRIVACY="Read-only. Serial numbers, the machine name, the user name, MAC addresses and unique identifiers are never included; they are left out, not hashed. Nothing is sent anywhere; the file stays on this computer until you choose it on the site."

OUT=""
LOG_SECONDS=0
while [ $# -gt 0 ]; do
  case "$1" in
    --out) OUT="${2:-}"; shift; [ $# -gt 0 ] && shift;;
    --log) LOG_SECONDS="${2:-0}"; shift; [ $# -gt 0 ] && shift;;
    *) shift;;
  esac
done
HERE="$(cd "$(dirname "$0")" && pwd)"

NOT_EXPOSED=()

# JSON string or null
js() {
  local v="${1:-}"
  v="$(printf '%s' "$v" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
  if [ -z "$v" ]; then printf 'null'; return; fi
  v="${v//\\/\\\\}"; v="${v//\"/\\\"}"
  printf '"%s"' "$v"
}
# JSON number or null
jn() {
  local v="${1:-}"
  if printf '%s' "$v" | grep -Eq '^-?[0-9]+(\.[0-9]+)?$'; then printf '%s' "$v"; else printf 'null'; fi
}
# value after "Key:" in system_profiler text
field() { printf '%s\n' "$1" | grep -m1 -E "^[[:space:]]*$2:" | sed -E "s/^[[:space:]]*$2:[[:space:]]*//"; }

# ---------- Logging mode: one timestamped sample a second, then exit ----------
if [ "$LOG_SECONDS" != "0" ]; then
  if ! printf '%s' "$LOG_SECONDS" | grep -Eq '^[0-9]+$' || [ "$LOG_SECONDS" -lt 10 ] || [ "$LOG_SECONDS" -gt 3600 ]; then
    echo "--log takes a number of seconds between 10 and 3600." >&2
    exit 2
  fi
  [ -z "$OUT" ] && OUT="$HERE/systemcheck-sensorlog.json"
  ROOT=0
  [ "$(id -u)" = "0" ] && ROOT=1
  LOG_NE=()
  [ "$ROOT" = "0" ] && LOG_NE+=("Package power and thermal pressure (powermetrics needs sudo; start the script with sudo yourself to add them)")
  LOG_NE+=("Processor frequency and temperature (macOS gives them only through the SMC, which this script does not read)")
  # Millisecond clock and timestamp from the python3 that ships with the developer tools, else whole seconds.
  ms_now() { python3 -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo "$(date +%s)000"; }
  iso_now() { python3 -c 'import datetime; print(datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]+"Z")' 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ; }
  echo "SystemCheck companion, logging mode: $LOG_SECONDS seconds, one sample a second. Start the browser test now; nothing is sent anywhere."
  STARTED="$(iso_now)"
  SAMPLES=""
  ANY_LIMIT=0
  ANY_AC=0
  i=0
  while [ "$i" -lt "$LOG_SECONDS" ]; do
    T0="$(ms_now)"
    T="$(iso_now)"
    LIMIT="$(pmset -g therm 2>/dev/null | grep -m1 -E 'CPU_Speed_Limit' | sed -E 's/.*=[[:space:]]*([0-9]+).*/\1/')"
    BATT="$(pmset -g batt 2>/dev/null | head -1)"
    AC="null"
    case "$BATT" in
      *"AC Power"*) AC="true";;
      *"Battery Power"*) AC="false";;
    esac
    PW=""
    PRESSURE=""
    if [ "$ROOT" = "1" ]; then
      PM="$(powermetrics -n 1 -i 200 --samplers cpu_power,thermal 2>/dev/null)"
      MW="$(printf '%s\n' "$PM" | grep -m1 -E '^(Combined Power|Package Power|CPU Power)' | sed -E 's/.*:[[:space:]]*([0-9]+(\.[0-9]+)?)[[:space:]]*mW.*/\1/')"
      [ -n "$MW" ] && PW="$(awk -v m="$MW" 'BEGIN{ printf "%.2f", m/1000 }')"
      PRESSURE="$(printf '%s\n' "$PM" | grep -m1 -i 'pressure level' | sed -E 's/.*:[[:space:]]*//')"
    fi
    [ -n "$LIMIT" ] && ANY_LIMIT=1
    [ "$AC" != "null" ] && ANY_AC=1
    ENTRY="{\"t\":\"$T\",\"freqMhz\":null,\"perfPct\":null,\"limitPct\":$(jn "$LIMIT"),\"tempC\":null,\"packageW\":$(jn "$PW"),\"onAc\":$AC,\"thermalPressure\":$(js "$PRESSURE")}"
    if [ -z "$SAMPLES" ]; then SAMPLES="$ENTRY"; else SAMPLES="$SAMPLES,$ENTRY"; fi
    i=$((i + 1))
    [ $((i % 30)) -eq 0 ] && echo "  $i of $LOG_SECONDS s"
    LEFT=$((1000 - ($(ms_now) - T0)))
    [ "$LEFT" -gt 0 ] && sleep "$(awk -v l="$LEFT" 'BEGIN{ printf "%.3f", l/1000 }')"
  done
  [ "$ANY_LIMIT" = "0" ] && LOG_NE+=("CPU speed limit (pmset -g therm reported none; Apple silicon usually does not)")
  [ "$ANY_AC" = "0" ] && LOG_NE+=("Power source (pmset -g batt reported none)")
  NE=""
  for n in "${LOG_NE[@]}"; do
    if [ -z "$NE" ]; then NE="$(js "$n")"; else NE="$NE,$(js "$n")"; fi
  done
  ELEV="false"
  [ "$ROOT" = "1" ] && ELEV="true"
  printf '{\n  "schema": "systemcheck-sensor-log",\n  "schemaVersion": 1,\n  "tool": {"name": "systemcheck-companion.sh", "version": "%s"},\n  "platform": "macos",\n  "startedAt": "%s",\n  "intervalSeconds": 1,\n  "elevated": %s,\n  "privacy": %s,\n  "samples": [%s],\n  "notExposed": [%s]\n}\n' "$TOOL_VERSION" "$STARTED" "$ELEV" "$(js "$PRIVACY")" "$SAMPLES" "$NE" > "$OUT"
  echo ""
  echo "Not exposed on this run:"
  for n in "${LOG_NE[@]}"; do echo "  - $n"; done
  echo "Written: $OUT"
  echo "Choose this file under \"Confirm this curve\" on the unlocked report of the run you just made."
  exit 0
fi
[ -z "$OUT" ] && OUT="$HERE/systemcheck-companion.json"

# ---------- Battery ----------
POWER="$(system_profiler SPPowerDataType 2>/dev/null || true)"
BATTERIES="[]"
CYCLES="$(field "$POWER" "Cycle Count")"
COND="$(field "$POWER" "Condition")"
FULL="$(field "$POWER" "Full Charge Capacity \(mAh\)")"
DESIGN="$(field "$POWER" "Design Capacity")"
MAXCAP="$(field "$POWER" "Maximum Capacity" | tr -d '%')"
if [ -n "$CYCLES$COND$FULL$MAXCAP" ]; then
  WEAR=""
  if [ -n "$MAXCAP" ] && printf '%s' "$MAXCAP" | grep -Eq '^[0-9]+$'; then WEAR=$((100 - MAXCAP)); fi
  if [ -z "$WEAR" ] && [ -n "$FULL" ] && [ -n "$DESIGN" ]; then
    WEAR="$(awk -v f="$FULL" -v d="$DESIGN" 'BEGIN{ if (d>0) { w=(1-f/d)*100; if (w<0) w=0; printf "%.1f", w } }')"
  fi
  [ -z "$CYCLES" ] && NOT_EXPOSED+=("Battery cycle count")
  [ -z "$DESIGN" ] && NOT_EXPOSED+=("Battery design capacity (macOS shows maximum capacity as a percentage instead)")
  # macOS reports mAh, not mWh; the mWh fields stay null and the wear comes from Maximum Capacity.
  BATTERIES="[{\"designCapacityMwh\":null,\"fullChargeCapacityMwh\":null,\"designCapacityMah\":$(jn "$DESIGN"),\"fullChargeCapacityMah\":$(jn "$FULL"),\"wearPercent\":$(jn "$WEAR"),\"cycleCount\":$(jn "$CYCLES"),\"chemistry\":null,\"condition\":$(js "$COND")}]"
else
  NOT_EXPOSED+=("Battery (none reported; a desktop Mac has none)")
fi

# ---------- Disks ----------
DISKS=""
for d in $(diskutil list physical 2>/dev/null | grep -E '^/dev/disk[0-9]+' | awk '{print $1}'); do
  INFO="$(diskutil info "$d" 2>/dev/null || true)"
  MODEL="$(field "$INFO" "Device / Media Name")"
  SMART="$(field "$INFO" "SMART Status")"
  SSD="$(field "$INFO" "Solid State")"
  PROTO="$(field "$INFO" "Protocol")"
  SIZE="$(field "$INFO" "Disk Size" | sed -E 's/.*\(([0-9]+) Bytes\).*/\1/')"
  MEDIA=""
  [ "$SSD" = "Yes" ] && MEDIA="SSD"
  [ "$SSD" = "No" ] && MEDIA="HDD"
  HEALTH=""
  case "$SMART" in
    Verified) HEALTH="Healthy";;
    Failing) HEALTH="Unhealthy";;
  esac
  ENTRY="{\"model\":$(js "$MODEL"),\"mediaType\":$(js "$MEDIA"),\"busType\":$(js "$PROTO"),\"sizeBytes\":$(jn "$SIZE"),\"healthStatus\":$(js "$HEALTH"),\"operationalStatus\":null,\"wearPercent\":null,\"temperatureC\":null,\"readErrors\":null,\"writeErrors\":null,\"powerOnHours\":null,\"smartStatus\":$(js "$SMART")}"
  if [ -z "$DISKS" ]; then DISKS="$ENTRY"; else DISKS="$DISKS,$ENTRY"; fi
done
NOT_EXPOSED+=("Disk wear, temperature, error counts and power-on hours (macOS shows only the SMART verdict; smartmontools would read the rest)")

# ---------- Hardware (serial number and UUIDs never read into the output) ----------
HW="$(system_profiler SPHardwareDataType 2>/dev/null | grep -v -i -E 'serial|uuid|udid' || true)"
MODELNAME="$(field "$HW" "Model Name")"
MODELID="$(field "$HW" "Model Identifier")"
CHIP="$(field "$HW" "Chip")"
[ -z "$CHIP" ] && CHIP="$(field "$HW" "Processor Name")"
FIRMWARE="$(field "$HW" "System Firmware Version")"
MEMTOTAL="$(sysctl -n hw.memsize 2>/dev/null)"
MEMTYPE="$(field "$HW" "Type")"

BRAND="$(sysctl -n machdep.cpu.brand_string 2>/dev/null)"
[ -z "$BRAND" ] && BRAND="$CHIP"
CORES="$(sysctl -n hw.physicalcpu 2>/dev/null)"
THREADS="$(sysctl -n hw.logicalcpu 2>/dev/null)"
FREQ="$(sysctl -n hw.cpufrequency_max 2>/dev/null)"
MHZ=""
[ -n "$FREQ" ] && MHZ=$((FREQ / 1000000))
[ -z "$MHZ" ] && NOT_EXPOSED+=("Processor clock (Apple silicon does not report one through sysctl)")

# ---------- GPU ----------
GPUNAME="$(system_profiler SPDisplaysDataType 2>/dev/null | grep -m1 -E 'Chipset Model:' | sed -E 's/^[[:space:]]*Chipset Model:[[:space:]]*//')"

# ---------- OS and power ----------
OSNAME="$(sw_vers -productName 2>/dev/null)"
OSVER="$(sw_vers -productVersion 2>/dev/null)"
OSBUILD="$(sw_vers -buildVersion 2>/dev/null)"
ARCH="$(uname -m)"
PM="$(pmset -g batt 2>/dev/null | head -1)"
SOURCE=""; ONAC="null"
case "$PM" in
  *"AC Power"*) SOURCE="AC"; ONAC="true";;
  *"Battery Power"*) SOURCE="Battery"; ONAC="false";;
esac
LOWPOWER="$(pmset -g 2>/dev/null | grep -m1 -E '^[[:space:]]*lowpowermode' | awk '{print $2}')"
PLAN=""
[ "$LOWPOWER" = "1" ] && PLAN="Low Power Mode"
[ "$LOWPOWER" = "0" ] && PLAN="Normal"

NOT_EXPOSED+=("Temperatures (macOS exposes them only through the SMC, which this script does not read)")
NOT_EXPOSED+=("Memory module speed and slots (Macs have soldered memory and do not report them)")

NE=""
for n in "${NOT_EXPOSED[@]}"; do
  if [ -z "$NE" ]; then NE="$(js "$n")"; else NE="$NE,$(js "$n")"; fi
done

NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

cat > "$OUT" <<EOF
{
  "schema": "systemcheck-companion",
  "schemaVersion": 1,
  "tool": {"name": "systemcheck-companion.sh", "version": "$TOOL_VERSION"},
  "platform": "macos",
  "generatedAt": "$NOW",
  "elevated": false,
  "privacy": $(js "$PRIVACY"),
  "batteries": $BATTERIES,
  "disks": [${DISKS}],
  "cpus": [{"name": $(js "$BRAND"), "cores": $(jn "$CORES"), "threads": $(jn "$THREADS"), "maxClockMhz": $(jn "$MHZ"), "currentClockMhz": null}],
  "memory": {"totalBytes": $(jn "$MEMTOTAL"), "slotsTotal": null, "slotsUsed": null, "modules": [], "type": $(js "$MEMTYPE")},
  "gpus": [{"name": $(js "$GPUNAME"), "driverVersion": null, "driverDate": null}],
  "firmware": {"biosVendor": "Apple", "biosVersion": $(js "$FIRMWARE"), "biosDate": null, "boardManufacturer": "Apple", "boardProduct": $(js "$MODELID"), "systemManufacturer": "Apple", "systemModel": $(js "$MODELNAME")},
  "os": {"name": $(js "$OSNAME"), "version": $(js "$OSVER"), "build": $(js "$OSBUILD"), "architecture": $(js "$ARCH")},
  "power": {"plan": $(js "$PLAN"), "onAcPower": $ONAC, "source": $(js "$SOURCE")},
  "thermalZones": [],
  "notExposed": [${NE}],
  "elevatedWouldAdd": []
}
EOF

echo ""
echo "SystemCheck companion"
echo "$PRIVACY"
echo ""
echo "Battery: cycles ${CYCLES:-not exposed}, condition ${COND:-not exposed}, maximum capacity ${MAXCAP:-not exposed}%"
echo "Model: ${MODELNAME:-not exposed} (${MODELID:-not exposed}), ${BRAND:-not exposed}, ${CORES:-?} cores"
echo "macOS: ${OSVER:-?} (${OSBUILD:-?}); power ${SOURCE:-not exposed}"
echo ""
echo "Not exposed on this run:"
for n in "${NOT_EXPOSED[@]}"; do echo "  - $n"; done
echo ""
echo "Written: $OUT"
echo "Choose this file on systemcheck.live/device-test to see it beside your run. It is read in your browser and not uploaded."
