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>
240 lines
11 KiB
Bash
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
|