Companion keeper: after a restart the panel opens the Companion that ran last if it is not running

- only in the watchdog's first 10 minutes, never another version, never during an upgrade
- SC verified end to end; laptop SSH to the SC Macs; configs synced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 14:26:18 -04:00
co-authored by Claude Opus 5.5
parent 268ac21715
commit 5db1d93732
3 changed files with 95 additions and 0 deletions
+53
View File
@@ -3355,6 +3355,54 @@ def start_artnet_listener(snap, stop, port=6454):
return t
def companion_app_running():
"""The .app of the Companion running on this Mac (from ps), or None."""
try:
out = subprocess.run(["/bin/ps", "-axo", "command"], capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return None
for line in out.splitlines():
m = re.match(r"^(/Applications/Companion[^/]*\.app)/Contents/MacOS/Companion$", line.strip())
if m:
return m.group(1)
return None
class CompanionKeeper:
"""After this Mac restarts, Companion is open: the deck is Companion, so a Companion that is not a login
item would leave the whole panel dark (Southcoast, 2026-10-21: its login items cannot be read without a
prompt on its screen). Remembers which copy runs (state "companion_app"; Woonsocket keeps four versions,
so never "Companion.app" by guess) and, only in the watchdog's first 10 minutes after it starts (that is,
after a login), opens that copy if no Companion is running. Never while an upgrade is going on."""
WINDOW = 600
def __init__(self, engine, data_dir, started=None, running=companion_app_running, opener=None):
self.engine, self.data_dir = engine, data_dir
self.started = started if started is not None else time.time()
self.running, self.opener = running, opener or (lambda app: subprocess.run(["/usr/bin/open", "-g", app], timeout=30))
self.opened_at = 0.0
def check(self, now=None):
now = now or time.time()
app = self.running()
known = self.engine.state.get("companion_app")
if app:
if app != known:
if known:
log.info("COMPANION now runs from %s (was %s)", app, known)
self.engine.state.set("companion_app", app)
return None
if not known or now - self.started > self.WINDOW or now - self.opened_at < 120 or not os.path.isdir(known):
return None
flag = os.path.join(self.data_dir or "", "companion-upgrading")
if self.data_dir and os.path.exists(flag) and now - os.path.getmtime(flag) < 1800:
return None
self.opened_at = now
log.info("COMPANION was not running after this Mac started: opening %s", known)
self.opener(known)
return known
class Periodic(threading.Thread):
def __init__(self, name, interval, fn, stop):
super().__init__(name=name, daemon=True)
@@ -3703,6 +3751,11 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
t = Periodic("dante-newcomers", 60.0, lambda: watch_dante_newcomers(snap, cfg, c5, engine), stop)
t.start()
threads.append(t)
if (cfg.get("companion") or {}).get("host", "127.0.0.1") in ("127.0.0.1", "localhost") and sys.platform == "darwin":
keeper = CompanionKeeper(engine, data_dir)
t = Periodic("companion-keeper", 30.0, keeper.check, stop)
t.start()
threads.append(t)
if cfg.get("ready_page") and not (cfg.get("ready") or {}).get("disabled"):
import readiness
poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0])