Files
status-indicators/install.sh
T
christianandClaude Opus 5.5 3976667bba Other campuses set themselves up: one install line, one join line per computer, keys made from what is found
settings/discover.py reads Companion's connections (and their addresses and channel counts), the
Dante devices, the network MIDI sessions here and on joined Macs, this Mac's wired network, and
what runs on each joined computer. settings/autoconf.py turns that into keys, checks, start and
restart fixes and a page layout the way North Attleboro's were made (group keys only when not
everything fits; receivers fold first), all through the existing builders and rules.
settings/campus.py prepares Companion through its internal API (the panel's custom variables and
the blink trigger), picks free pages, applies the first config, serves the program kit, the
campus install line and the Mac/PC join lines, and looks every ten minutes to add new gear
(discover.auto; never during a service or a recording, never removes, never re-adds what someone
took off). Settings > Auto-setup shows what was found and the lines to paste. install.sh keeps the
kit and makes the panel's login key.

Checked on North Attleboro read-only: discovery finds all 43 connections, 19 Dante devices, four
Macs' MIDI sessions and seven computers, and a from-scratch build reproduces NA's layout. The
Companion calls were tried with a throwaway variable and a disabled trigger, then removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:31:32 -04:00

240 lines
11 KiB
Bash

#!/bin/zsh
# Install or update the status panel watchdog on this Mac (the Ingest Mac) as a LaunchAgent.
# Re-run after changing any file in this folder.
#
# zsh install.sh install / update and (re)start
# zsh install.sh --keep-config update the code but leave the live config.json as it is
# zsh install.sh --replace-config replace the live config even when it came from an Apply on the settings page
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
# zsh install.sh --rollback put the previous code back (kept in prev/) and restart
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
#
# The config is validated with the NEW code before anything is touched, so a stricter check can
# never leave the watchdog crash-looping. The previous code is kept in prev/ for --rollback.
set -e
SRC=${0:A:h}
LABEL=org.waterschurch.status-panel
SETTINGS_LABEL=org.waterschurch.status-settings
# App Support, not ~/Documents: launchd-run python3 cannot read ~/Documents.
DEST="$HOME/Library/Application Support/status-panel"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
CODE=(status_panel.py readiness.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json)
KEEP_CONFIG=0
SITE=""
SITE_NEXT=0
for arg in "$@"; do
if [[ $SITE_NEXT == 1 ]]; then SITE="$arg"; SITE_NEXT=0; continue; fi
case "$arg" in
--keep-config) KEEP_CONFIG=1 ;;
--replace-config) REPLACE_CONFIG=1 ;;
--site) SITE_NEXT=1 ;;
--remove|--rollback|--now) ;;
*) echo "unknown option $arg" >&2; exit 2 ;;
esac
done
# The config to install: the campus folder when asked for, else this folder's config.json.
SRC_CONFIG="$SRC/config.json"
if [[ -n "$SITE" ]]; then
if [[ -f "$SRC/sites/$SITE/config.json" ]]; then SRC_CONFIG="$SRC/sites/$SITE/config.json"
elif [[ -f "$SRC/sites/_template/config.json" ]]; then SRC_CONFIG="$SRC/sites/_template/config.json"; echo "no sites/$SITE/ in the folder: starting from the template"
else echo "no sites/ folder here" >&2; exit 2; fi
[[ -f "$DEST/config.json" ]] && KEEP_CONFIG=1 && echo "a config is already installed: left alone (the page's 'New campus from a bundle' replaces it)"
fi
restart_agent() { # $1 = label, $2 = plist
local label=${1:-$LABEL} plist=${2:-$PLIST}
launchctl bootout "$DOMAIN/$label" 2>/dev/null || true
# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error".
for i in {1..50}; do launchctl print "$DOMAIN/$label" >/dev/null 2>&1 || break; sleep 0.2; done
for attempt in 1 2 3; do
launchctl bootstrap "$DOMAIN" "$plist" && break
[[ $attempt == 3 ]] && { echo "launchctl bootstrap failed for $label" >&2; exit 1; }
sleep 1
done
}
SETTINGS_PLIST="$HOME/Library/LaunchAgents/$SETTINGS_LABEL.plist"
write_settings_plist() {
cat > "$SETTINGS_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$SETTINGS_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>-m</string>
<string>settings.server</string>
<string>--data-dir</string>
<string>$DEST</string>
</array>
<key>WorkingDirectory</key><string>$DEST</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>AbandonProcessGroup</key><true/>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-settings.err.log</string>
</dict>
</plist>
EOF
}
write_plist() { # $1 = exit timeout in seconds
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>$DEST/status_panel.py</string>
<string>--data-dir</string>
<string>$DEST</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>ExitTimeOut</key><integer>$1</integer>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel.err.log</string>
</dict>
</plist>
EOF
}
wait_for_start() { # $1 = install time (unix): wait for a start stamp newer than that
for i in {1..60}; do
if [[ -f "$DEST/started.json" ]] && /usr/bin/python3 - "$DEST/started.json" "$1" <<'PY' 2>/dev/null
import json, sys
d = json.load(open(sys.argv[1]))
sys.exit(0 if d.get("started_at", 0) >= float(sys.argv[2]) else 1)
PY
then
echo "running (code $(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version"))' "$DEST/started.json"))"
return 0
fi
sleep 0.5
done
echo "WARNING: no start stamp after 30 s - check ~/Library/Logs/status-panel.log and status-panel.err.log" >&2
return 1
}
if [[ "$1" == "--remove" ]]; then
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
launchctl bootout "$DOMAIN/$SETTINGS_LABEL" 2>/dev/null || true
rm -f "$PLIST" "$SETTINGS_PLIST"
echo "removed $LABEL and $SETTINGS_LABEL (files left in $DEST)"
exit 0
fi
if [[ "$1" == "--rollback" ]]; then
[[ -d "$DEST/prev" ]] || { echo "nothing to roll back to ($DEST/prev is missing)" >&2; exit 1; }
for f in "$DEST"/prev/*(N); do cp "$f" "$DEST/"; done
/usr/bin/python3 "$DEST/status_panel.py" --check --config "$DEST/config.json"
T=$(date +%s)
write_plist "$(/usr/bin/python3 "$DEST/status_panel.py" --exit-timeout --config "$DEST/config.json")"
restart_agent
echo "rolled back to the previous code (version $(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import status_panel; print(status_panel.VERSION)' "$DEST"))"
wait_for_start "$T" || true
exit 0
fi
# 0. Not while a HyperDeck is recording (the watchdog restarts for a few seconds); --now overrides.
if [[ -f "$DEST/live-now.json" && " $* " != *" --now "* ]] && /usr/bin/python3 - "$DEST/live-now.json" <<'PY'
import json, sys, time
d = json.load(open(sys.argv[1]))
sys.exit(0 if d.get("live") and time.time() - d.get("time", 0) < 60 else 1)
PY
then
echo "a HyperDeck is recording right now ($(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("reason"))' "$DEST/live-now.json")) - not installing; use --now to override" >&2
exit 3
fi
# 0b. Never undo an Apply. Live settings that came from the settings page (their fingerprint is the
# "sha_after" of an entry in history/) and differ from the config this would install were changed on
# the page since this folder was made: replacing them would quietly undo that (2026-10-15: READY hours
# set on the page went back to the folder's defaults). Copy them into the folder first, or say so.
if [[ $KEEP_CONFIG == 0 && ${REPLACE_CONFIG:-0} == 0 && -f "$DEST/config.json" && -d "$DEST/history" ]] \
&& ! cmp -s "${SRC_CONFIG:-$SRC/config.json}" "$DEST/config.json" \
&& /usr/bin/python3 -c '
import glob, hashlib, json, os, sys
d = sys.argv[1]
live = hashlib.sha256(open(os.path.join(d, "config.json"), "rb").read()).hexdigest()
for m in glob.glob(os.path.join(d, "history", "*.meta.json")):
try:
meta = json.load(open(m))
except ValueError:
continue
if meta.get("sha_after") == live:
print("the live settings come from the settings page (%s, %s): not replacing them." % (meta.get("by", "?"), os.path.basename(m)[:15]))
sys.exit(0)
sys.exit(1)' "$DEST"
then
echo "Copy them into this folder first (scp checklist-ingest:'Library/Application Support/status-panel/config.json' config.json)," >&2
echo "or run with --keep-config (code only) or --replace-config (overwrite them)." >&2
exit 4
fi
# 1. The config that will run, checked with the code that will run it.
mkdir -p "$DEST"
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
CFG="$DEST/config.json"
else
CFG="$SRC_CONFIG"
fi
/usr/bin/python3 "$SRC/status_panel.py" --check --config "$CFG"
EXIT_TIMEOUT=$(/usr/bin/python3 "$SRC/status_panel.py" --exit-timeout --config "$CFG")
# 2. Keep the code that runs now, for --rollback.
mkdir -p "$DEST/prev"
for f in $CODE; do [[ -f "$DEST/$f" ]] && cp "$DEST/$f" "$DEST/prev/"; done
# 3. Files. The live config is backed up when it changes; --keep-config leaves it alone.
if [[ $KEEP_CONFIG == 0 ]]; then
if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC_CONFIG" "$DEST/config.json"; then
cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)"
echo "config.json replaced (the old one is kept as config.json.bak-...; use --keep-config to leave it)"
fi
cp "$SRC_CONFIG" "$DEST/config.json"
fi
for f in $CODE; do [[ -f "$SRC/$f" ]] && cp "$SRC/$f" "$DEST/"; done
[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/"
# The set-up script templates the page fills in for each computer.
[[ -d "$SRC/remote-access" ]] && rm -rf "$DEST/remote-access" && cp -R "$SRC/remote-access" "$DEST/"
# The settings page (its own LaunchAgent) and the record of coloured page-number keys (site-owned: never overwritten).
if [[ -d "$SRC/settings" ]]; then
rm -rf "$DEST/settings" && cp -R "$SRC/settings" "$DEST/" && find "$DEST/settings" -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null
fi
[[ -f "$SRC/nav_locations.json" && ! -f "$DEST/nav_locations.json" ]] && cp "$SRC/nav_locations.json" "$DEST/"
if [[ -d "$SRC/companion-objects" ]]; then
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
fi
# The kit another campus installs from (the settings page serves it as /campus/program.tgz).
rm -rf "$DEST/kit" && mkdir -p "$DEST/kit/sites"
cp "$SRC/install.sh" "$DEST/kit/"
[[ -f "$SRC/README.md" ]] && cp "$SRC/README.md" "$DEST/kit/"
[[ -d "$SRC/sites/_template" ]] && cp -R "$SRC/sites/_template" "$DEST/kit/sites/"
# The panel's own login key for the other computers (made once, never replaced).
if [[ ! -f "$HOME/.ssh/id_status_panel" ]]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
/usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName 2>/dev/null || hostname -s)" -f "$HOME/.ssh/id_status_panel"
echo "made the panel's login key ~/.ssh/id_status_panel"
fi
# 4. The LaunchAgent, with the exit timeout this config needs (a running fix finishes before a restart).
T=$(date +%s)
write_plist "$EXIT_TIMEOUT"
restart_agent
echo "installed $LABEL -> $DEST (exit timeout ${EXIT_TIMEOUT}s)"
echo "log: ~/Library/Logs/status-panel.log"
wait_for_start "$T" || true
if [[ -d "$DEST/settings" ]]; then
write_settings_plist
restart_agent "$SETTINGS_LABEL" "$SETTINGS_PLIST"
PORT=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("site",{}).get("settings_port",8020))' "$DEST/config.json")
echo "settings page: http://$(hostname -s).local:$PORT/ (log: ~/Library/Logs/status-settings.log)"
fi