2026-10-08 09:00:56 -04:00
#!/bin/zsh
2026-10-13 16:00:41 -04:00
# 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.
2026-10-08 09:00:56 -04:00
#
2026-10-13 16:00:41 -04:00
# 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
2026-10-15 15:02:17 -04:00
# zsh install.sh --replace-config replace the live config even when it came from an Apply on the settings page
2026-10-14 14:53:08 -04:00
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
2026-10-13 16:00:41 -04:00
# 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.
2026-10-08 09:00:56 -04:00
set -e
SRC = ${ 0 : A : h }
LABEL = org.waterschurch.status-panel
2026-10-13 16:23:59 -04:00
SETTINGS_LABEL = org.waterschurch.status-settings
2026-10-08 09:00:56 -04:00
# 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) "
2026-10-14 20:28:43 -04:00
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)
2026-10-13 16:00:41 -04:00
KEEP_CONFIG = 0
2026-10-14 14:53:08 -04:00
SITE = ""
SITE_NEXT = 0
2026-10-13 16:00:41 -04:00
for arg in " $@ " ; do
2026-10-14 14:53:08 -04:00
if [[ $SITE_NEXT == 1 ]] ; then SITE = " $arg " ; SITE_NEXT = 0; continue ; fi
2026-10-13 16:00:41 -04:00
case " $arg " in
--keep-config) KEEP_CONFIG = 1 ;;
2026-10-15 15:02:17 -04:00
--replace-config) REPLACE_CONFIG = 1 ;;
2026-10-14 14:53:08 -04:00
--site) SITE_NEXT = 1 ;;
2026-10-14 12:18:36 -04:00
--remove| --rollback| --now) ;;
2026-10-13 16:00:41 -04:00
*) echo "unknown option $arg " >& 2; exit 2 ;;
esac
done
2026-10-14 14:53:08 -04:00
# 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
2026-10-08 09:00:56 -04:00
2026-10-13 16:23:59 -04:00
restart_agent() { # $1 = label, $2 = plist
local label = ${ 1 :- $LABEL } plist = ${ 2 :- $PLIST }
launchctl bootout " $DOMAIN / $label " 2>/dev/null || true
2026-10-13 16:00:41 -04:00
# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error".
2026-10-13 16:23:59 -04:00
for i in { 1..50} ; do launchctl print " $DOMAIN / $label " >/dev/null 2>& 1 || break; sleep 0.2; done
2026-10-13 16:00:41 -04:00
for attempt in 1 2 3; do
2026-10-13 16:23:59 -04:00
launchctl bootstrap " $DOMAIN " " $plist " && break
[[ $attempt == 3 ]] && { echo "launchctl bootstrap failed for $label " >& 2; exit 1; }
2026-10-13 16:00:41 -04:00
sleep 1
done
}
2026-10-08 09:00:56 -04:00
2026-10-13 16:23:59 -04:00
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
}
2026-10-13 16:00:41 -04:00
write_plist() { # $1 = exit timeout in seconds
cat > " $PLIST " <<EOF
2026-10-08 09:00:56 -04:00
<?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>
2026-10-13 16:00:41 -04:00
<key>ExitTimeOut</key><integer>$1</integer>
2026-10-08 09:00:56 -04:00
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel.err.log</string>
</dict>
</plist>
EOF
2026-10-13 16:00:41 -04:00
}
2026-10-08 09:00:56 -04:00
2026-10-13 16:00:41 -04:00
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
2026-10-13 16:23:59 -04:00
launchctl bootout " $DOMAIN / $SETTINGS_LABEL " 2>/dev/null || true
rm -f " $PLIST " " $SETTINGS_PLIST "
echo "removed $LABEL and $SETTINGS_LABEL (files left in $DEST )"
2026-10-13 16:00:41 -04:00
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
2026-10-14 12:18:36 -04:00
# 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
2026-10-15 15:02:17 -04:00
# 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
2026-10-13 16:00:41 -04:00
# 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
2026-10-14 14:53:08 -04:00
CFG = " $SRC_CONFIG "
2026-10-13 16:00:41 -04:00
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
2026-10-14 14:53:08 -04:00
if [[ -f " $DEST /config.json" ]] && ! cmp -s " $SRC_CONFIG " " $DEST /config.json" ; then
2026-10-13 16:00:41 -04:00
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
2026-10-14 14:53:08 -04:00
cp " $SRC_CONFIG " " $DEST /config.json"
2026-10-13 16:00:41 -04:00
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 /"
2026-10-14 14:53:08 -04:00
# 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 /"
2026-10-13 16:23:59 -04:00
# 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 /"
2026-10-13 16:00:41 -04:00
if [[ -d " $SRC /companion-objects" ]] ; then
rm -rf " $DEST /companion-objects" && cp -R " $SRC /companion-objects" " $DEST /"
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)"
2026-10-08 09:00:56 -04:00
echo "log: ~/Library/Logs/status-panel.log"
2026-10-13 16:00:41 -04:00
wait_for_start " $T " || true
2026-10-13 16:23:59 -04:00
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