From 42b325e92722a25b8f7d2c4354a52148cee01a04 Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 21 Oct 2026 14:33:39 -0400 Subject: [PATCH] ProPresenter updates are safe: any installed copy counts as running, the newest version opens - newest_of picks by CFBundleShortVersionString, not file name (ProPresenter.app sorted after ProPresenter 20.1.app) - mac_start takes newest_of; auto-setup uses it for programs on the Companion Mac - WS and SC switched over; NA's ProP restart re-rendered (same pick today) Co-Authored-By: Claude Opus 5.5 --- config.json | 4 ++-- settings/autoconf.py | 10 ++++++++-- settings/builders.py | 24 +++++++++++++++++++++--- sites/sc/config.json | 13 +++++++------ sites/ws/config.json | 19 ++++++++++--------- tests/test_campus.py | 9 +++++++-- tests/test_settings.py | 27 +++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/config.json b/config.json index 3bc9ec5..3048b17 100644 --- a/config.json +++ b/config.json @@ -328,9 +328,9 @@ "process": "ProPresenter", "wait_seconds": 10 }, - "run": "APP=$(ls -d /Applications/ProPresenter*.app | sort -V | tail -1); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\"", "short": "PROPRES", - "template": "mac_restart" + "template": "mac_restart", + "run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\"" }, "ready_copy_tracks": { "action": "copy_tracks", diff --git a/settings/autoconf.py b/settings/autoconf.py index 2e68917..4d8ed15 100644 --- a/settings/autoconf.py +++ b/settings/autoconf.py @@ -230,9 +230,15 @@ def items(inv, nets): continue fid = slug("%s_ingest" % a["app"]) pretty = a["label"].title() if a["app"] not in ("propresenter", "ableton") else {"propresenter": "ProPresenter", "ableton": "Ableton"}[a["app"]] - start = {"template": "mac_start", "params": {"host": "ingest", "process": a["process"], "app": app_path}, + # Any installed copy counts, and the newest opens: an update that installs beside the old one + # ("ProPresenter 20.0.1.app" and "ProPresenter.app" at Woonsocket) never reads as stopped. + newest = a["open"] if "*" in a.get("open", "") else "" + if newest: + exe = b.app_match(newest, a["process"]) + opens = {"app": "$APP", "newest_of": newest} if newest else {"app": app_path} + start = {"template": "mac_start", "params": dict({"host": "ingest", "process": a["process"]}, **opens), "label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]} - again = {"template": "mac_restart", "params": {"host": "ingest", "process": a["process"], "app": app_path, "wait_seconds": 10, "force": True}, + again = {"template": "mac_restart", "params": dict({"host": "ingest", "process": a["process"], "wait_seconds": 10, "force": True}, **opens), "label": "restart %s on this Mac (closes it first)" % pretty, "short": a["label"]} item = {"ident": "app:ingest|%s" % a["process"], "group": a.get("group") or ("VIDEO" if a["app"] == "arena" else "STREAM" if a["app"] == "obs" else "SHOW"), "label": a["label"], "checks": [{"type": "process", "match": exe, "name": a["label"], "fix": fid}], diff --git a/settings/builders.py b/settings/builders.py index 012fb26..60ad338 100644 --- a/settings/builders.py +++ b/settings/builders.py @@ -64,6 +64,22 @@ def mac_quit(process, wait_seconds, force, stop_children=False): return s +def newest_app(pattern): + """APP=... the newest installed copy by its own version number (CFBundleShortVersionString), not by + file name: "ProPresenter.app" sorts after "ProPresenter 20.1.app" by name (2026-10-21, before the + Southcoast/Woonsocket ProPresenter update).""" + return ("APP=$(for a in %s; do [ -d \"$a\" ] && printf '%%s|%%s\\n' \"$(defaults read \"$a/Contents/Info\" " + "CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); " + % str(pattern).strip()) + + +def app_match(pattern, process): + """A pgrep -f pattern for the program from any copy matching an /Applications glob (never its helpers).""" + import re as _re + parts = [_re.escape(x) for x in str(pattern).split("*")] + return "[^/]*".join(parts).replace("\\ ", " ") + "/Contents/MacOS/" + _re.escape(process).replace("\\ ", " ") + "$" + + def mac_open(app, background=False, arg=None): """open -a Name / open /path/App.app (+ -g to stay in the background).""" app = str(app) @@ -109,8 +125,10 @@ TEMPLATES = { "words": "Start a Mac program if it is not running", "os": "mac", "start_only": True, "params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None), - ("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)], - "render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])), + ("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None), + ("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")], + "render": lambda p, cfg: ("pgrep -xq %s || { %s%s; }" % (q(p["process"]), newest_app(p["newest_of"]), mac_open("$APP")) + if p.get("newest_of") else "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"]))), "suggest": lambda p, cfg: "start %s if it is not running" % p["app"], }, "mac_restart": { @@ -123,7 +141,7 @@ TEMPLATES = { ("background", "Open it in the background", "No window comes to the front.", "bool", False), ("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False), ("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")], - "render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "") + "render": lambda p, cfg: (newest_app(p["newest_of"]) if p.get("newest_of") else "") + mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False)) + mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)), "suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]), diff --git a/sites/sc/config.json b/sites/sc/config.json index 0985c14..9928607 100644 --- a/sites/sc/config.json +++ b/sites/sc/config.json @@ -155,7 +155,7 @@ }, { "type": "process", - "match": "/Applications/ProPresenter 20.0.1.app/Contents/MacOS/ProPresenter", + "match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$", "name": "PROP", "fix": "propresenter_ingest" } @@ -388,29 +388,30 @@ "params": { "host": "ingest", "process": "ProPresenter", - "app": "/Applications/ProPresenter 20.0.1.app" + "app": "$APP", + "newest_of": "/Applications/ProPresenter*.app" }, "label": "start ProPresenter on this Mac if it is not running", "short": "PROP", "kind": "local", - "run": "pgrep -xq ProPresenter || open '/Applications/ProPresenter 20.0.1.app'" + "run": "pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); open \"$APP\"; }" }, "restart_propresenter_ingest": { "template": "mac_restart", "params": { "host": "ingest", "process": "ProPresenter", - "app": "/Applications/ProPresenter 20.0.1.app", + "app": "$APP", "wait_seconds": 10, "force": true, "background": false, "stop_children": false, - "newest_of": "" + "newest_of": "/Applications/ProPresenter*.app" }, "label": "restart ProPresenter on this Mac (closes it first)", "short": "PROP", "kind": "local", - "run": "pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open '/Applications/ProPresenter 20.0.1.app'" + "run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\"" }, "prop_port_propresenter_api": { "kind": "propresenter_port", diff --git a/sites/ws/config.json b/sites/ws/config.json index ec37864..c2fb05c 100644 --- a/sites/ws/config.json +++ b/sites/ws/config.json @@ -71,13 +71,14 @@ "kind": "local", "label": "start ProPresenter on this Mac if it is not running", "params": { - "app": "/Applications/ProPresenter.app", + "app": "$APP", "host": "ingest", - "process": "ProPresenter" + "process": "ProPresenter", + "newest_of": "/Applications/ProPresenter*.app" }, - "run": "pgrep -xq ProPresenter || open /Applications/ProPresenter.app", "short": "PROP", - "template": "mac_start" + "template": "mac_start", + "run": "pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); open \"$APP\"; }" }, "restart_ableton_ws_tracks": { "host": "ws_tracks", @@ -97,18 +98,18 @@ "kind": "local", "label": "restart ProPresenter on this Mac (closes it first)", "params": { - "app": "/Applications/ProPresenter.app", + "app": "$APP", "background": false, "force": true, "host": "ingest", - "newest_of": "", + "newest_of": "/Applications/ProPresenter*.app", "process": "ProPresenter", "stop_children": false, "wait_seconds": 10 }, - "run": "pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open /Applications/ProPresenter.app", "short": "PROP", - "template": "mac_restart" + "template": "mac_restart", + "run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\"" }, "restart_superrack_ws_foh": { "host": "ws_foh", @@ -489,7 +490,7 @@ }, { "fix": "propresenter_ingest", - "match": "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter", + "match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$", "name": "PROP", "type": "process" } diff --git a/tests/test_campus.py b/tests/test_campus.py index 20bfcc8..85353cf 100644 --- a/tests/test_campus.py +++ b/tests/test_campus.py @@ -162,9 +162,14 @@ class TestBuild(unittest.TestCase): cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234") self.assertEqual(schema.validate(cfg)[0], []) prop = next(t for t in cfg["tiles"] if t["label"] == "PROP") - self.assertEqual([(c["type"], c.get("match") or c.get("conn")) for c in prop["checks"]], [("conn", "ProP"), ("process", exe)]) + import re + self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "process"]) + match = prop["checks"][1]["match"] # any installed copy counts (an update beside the old) + self.assertTrue(re.search(match, exe) and re.search(match, "/Applications/ProPresenter 21.4.app/Contents/MacOS/ProPresenter")) fix = cfg["fixes"][prop["checks"][1]["fix"]] - self.assertEqual((fix["kind"], fix["run"]), ("local", "pgrep -xq ProPresenter || open /Applications/ProPresenter.app")) + self.assertEqual(fix["kind"], "local") + self.assertTrue(fix["run"].startswith("pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app"), fix["run"]) + self.assertTrue(fix["run"].endswith('open "$APP"; }'), fix["run"]) # the newest version opens self.assertIn(prop["checks"][1]["fix"], cfg["auto_fix"]["fixes"]) self.assertEqual([k["label"] for k in cfg["restart_page"]["keys"]], ["PROP"]) self.assertEqual(cfg["fixes"][cfg["restart_page"]["keys"][0]["fix"]]["kind"], "local") diff --git a/tests/test_settings.py b/tests/test_settings.py index ced220f..e022676 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -376,3 +376,30 @@ class TestBindingAndProbes(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestNewestCopy(unittest.TestCase): + """An update installed beside the old copy: the newest version opens, whatever the file names.""" + + @unittest.skipUnless(sys.platform == "darwin", "reads app versions with macOS's defaults") + def test_the_newest_version_wins_not_the_last_name(self): + import subprocess + from settings import builders as b + for names in ((("Show 20.0.1.app", "20.0.1"), ("Show.app", "20.1.0")), (("Show 20.0.1.app", "20.0.1"), ("Show.app", "19.4"))): + root = tempfile.mkdtemp(prefix="sp-apps-") + for name, ver in names: + os.makedirs(os.path.join(root, name, "Contents")) + subprocess.run(["/usr/bin/defaults", "write", os.path.join(root, name, "Contents", "Info"), + "CFBundleShortVersionString", ver], check=True) + out = subprocess.run(["/bin/sh", "-c", b.newest_app(root + "/Show*.app") + 'echo "$APP"'], + capture_output=True, text=True).stdout.strip() + want = max(names, key=lambda n: tuple(int(x) for x in n[1].split(".")))[0] + self.assertEqual(os.path.basename(out), want) + + def test_any_copy_counts_as_running_but_never_a_helper(self): + import re + from settings import builders as b + m = b.app_match("/Applications/ProPresenter*.app", "ProPresenter") + self.assertTrue(re.search(m, "/Applications/ProPresenter 20.0.1.app/Contents/MacOS/ProPresenter")) + self.assertTrue(re.search(m, "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter")) + self.assertFalse(re.search(m, "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter Helper (Network)"))