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:
@@ -292,6 +292,9 @@ def upgrade_companion(base, say=print, source=None):
|
||||
import urllib.request
|
||||
root = os.path.expanduser("~/Library/Application Support/companion")
|
||||
app = "/Applications/Companion 5.0.7.app"
|
||||
# The watchdog's CompanionKeeper must not reopen the old Companion in the middle of this.
|
||||
with open(os.path.join(PARENT, "companion-upgrading"), "w") as f:
|
||||
f.write(str(time.time()))
|
||||
if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io
|
||||
install_companion_from(source, app, say)
|
||||
if not os.path.exists(app):
|
||||
@@ -323,6 +326,10 @@ def upgrade_companion(base, say=print, source=None):
|
||||
try:
|
||||
discover.companion_get({"companion": {}}, "/api/connections", 2)
|
||||
say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.")
|
||||
try:
|
||||
os.remove(os.path.join(PARENT, "companion-upgrading"))
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -2581,3 +2581,38 @@ class TestSwitches(unittest.TestCase):
|
||||
errors = schema.validate(bad)[0]
|
||||
self.assertTrue(any("bypass.peers" in e for e in errors))
|
||||
self.assertTrue(any("nowhere" in e for e in errors))
|
||||
|
||||
|
||||
class TestCompanionKeeper(unittest.TestCase):
|
||||
"""After a restart the panel opens the Companion that ran last, and only then (Southcoast, 2026-10-21)."""
|
||||
|
||||
def setUp(self):
|
||||
self.engine = sp.Engine(CONFIG, os.path.join(tempfile.mkdtemp(), "state.json"))
|
||||
self.data = tempfile.mkdtemp()
|
||||
self.app = tempfile.mkdtemp(suffix=".app") # stands in for /Applications/Companion 5.0.7.app
|
||||
self.opened = []
|
||||
self.now = 1000.0
|
||||
self.running = self.app
|
||||
|
||||
def keeper(self):
|
||||
return sp.CompanionKeeper(self.engine, self.data, started=self.now, running=lambda: self.running,
|
||||
opener=self.opened.append)
|
||||
|
||||
def test_it_remembers_the_running_copy_and_opens_it_after_a_restart(self):
|
||||
self.keeper().check(self.now) # Companion running: remembered
|
||||
self.assertEqual(self.engine.state.get("companion_app"), self.app)
|
||||
self.running = None # the Mac restarted without Companion
|
||||
k = self.keeper()
|
||||
self.assertEqual(k.check(self.now + 30), self.app)
|
||||
self.assertEqual(k.check(self.now + 60), None) # not again straight away
|
||||
self.assertEqual(self.opened, [self.app])
|
||||
|
||||
def test_it_never_acts_later_on_or_during_an_upgrade_or_without_knowing_the_copy(self):
|
||||
k = self.keeper()
|
||||
self.running = None
|
||||
self.assertIsNone(k.check(self.now + 30)) # never seen one: no guessing
|
||||
self.engine.state.set("companion_app", self.app)
|
||||
self.assertIsNone(k.check(self.now + 700)) # someone quit it later on: left alone
|
||||
open(os.path.join(self.data, "companion-upgrading"), "w").write("x")
|
||||
self.assertIsNone(self.keeper().check(self.now + 30)) # an upgrade is going on
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
Reference in New Issue
Block a user