- Auto-setup: programs on the Companion Mac itself (ProPresenter at Woonsocket) get a local process check and local start/restart fixes; every campus gets a Restart page (keys arrive as computers join); NA's auto-fix hours (Tue, Wed, Sun); a program found after its Companion connection joins that key instead of making a second one. settings.campus rebuild remakes a campus from what is found now, keeping its name, PIN and pages; upgrade-companion brings Companion to 5.0.7 from NA's copy (/campus/companion.zip), as Woonsocket was; setup stops with that advice on an older one. - NA's CAMPUSES page has a RESTART key per campus; the CAMPUS page shows that campus's Restart page and a hold there restarts it at that campus. - Backups: backup/backup-campuses.sh, nightly 04:30 on NA (installed everywhere, acts on the hub), into Drive > Waters Creative > Apps > Status Panel Backups/<campus>, 30 kept, alert email via the checklist's alert.js. Campuses report the account to back up with. sites/ws/config.json in the repo. - Woonsocket rebuilt: 9 keys (PROP with ProPresenter on its Mac), Restart page 20 (SUPERRACK, VISTA, ABLETON, PROP), auto-fix on NA's hours; all OK. Both campuses backed up to Drive by the job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
12 KiB
Bash
262 lines
12 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 nightly off-site backup of every campus's settings (does anything only on the hub, NA).
|
|
if [[ -d "$SRC/backup" ]]; then
|
|
rm -rf "$DEST/backup" && cp -R "$SRC/backup" "$DEST/" && chmod +x "$DEST/backup/"*.sh
|
|
BACKUP_LABEL=org.waterschurch.status-backup
|
|
BACKUP_PLIST="$HOME/Library/LaunchAgents/$BACKUP_LABEL.plist"
|
|
cat > "$BACKUP_PLIST" <<EOF2
|
|
<?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>$BACKUP_LABEL</string>
|
|
<key>ProgramArguments</key><array><string>/bin/bash</string><string>$DEST/backup/backup-campuses.sh</string></array>
|
|
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
|
|
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>30</integer></dict>
|
|
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel-backup.err.log</string>
|
|
<key>StandardOutPath</key><string>$HOME/Library/Logs/status-panel-backup.out.log</string>
|
|
</dict>
|
|
</plist>
|
|
EOF2
|
|
launchctl bootout "$DOMAIN/$BACKUP_LABEL" 2>/dev/null || true
|
|
launchctl bootstrap "$DOMAIN" "$BACKUP_PLIST" 2>/dev/null || true
|
|
fi
|
|
# 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
|