"""In-app update checks via Homebrew."""
from coseda.logging_utils import log_print
import json
import shutil
import subprocess
import sys
from typing import Optional
STABLE_FORMULA = "kristallorakel/coseda/coseda"
NIGHTLY_FORMULA = "kristallorakel/coseda/coseda-nightly"
def _create_progress_dialog(title: str, parent=None):
"""
Try to create a small Qt dialog to show update progress.
Returns None if Qt is unavailable or no application instance exists.
"""
try:
from PyQt6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QPlainTextEdit, QVBoxLayout
except Exception:
return None
app = QApplication.instance()
if app is None:
return None
class _ProgressDialog(QDialog):
def __init__(self) -> None:
dlg_parent = parent if parent is not None else app.activeWindow()
super().__init__(dlg_parent)
self.setWindowTitle(title)
self.setModal(False)
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
self.log = QPlainTextEdit(self)
self.log.setReadOnly(True)
layout.addWidget(self.log)
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self)
self.button_box.rejected.connect(self.close)
close_btn = self.button_box.button(QDialogButtonBox.StandardButton.Close)
if close_btn:
close_btn.setEnabled(False)
layout.addWidget(self.button_box)
self.show()
self.raise_()
def add_message(self, message: str) -> None:
self.log.appendPlainText(message)
self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())
app.processEvents()
def allow_close(self) -> None:
close_btn = self.button_box.button(QDialogButtonBox.StandardButton.Close)
if close_btn:
close_btn.setEnabled(True)
app.processEvents()
return _ProgressDialog()
def _find_brew() -> Optional[str]:
"""
Locate the Homebrew executable. Tries PATH and common install prefixes.
"""
candidates = [
shutil.which("brew"),
"/opt/homebrew/bin/brew", # Apple Silicon macOS default
"/usr/local/bin/brew", # Intel macOS default
"/home/linuxbrew/.linuxbrew/bin/brew", # Linuxbrew default
]
for path in candidates:
if path and shutil.which(path):
return path
return None
def _notify(title: str, message: str, error: bool = False) -> None:
"""
Show a Qt message box when available, otherwise print to stdout/stderr.
"""
try:
from PyQt6.QtWidgets import QApplication, QMessageBox
if QApplication.instance() is not None:
icon = QMessageBox.Icon.Critical if error else QMessageBox.Icon.Information
box = QMessageBox(icon, title, message)
box.exec()
return
except Exception:
pass
stream = sys.stderr if error else sys.stdout
log_print(f"{title}: {message}", file=stream)
def _arch_prefix() -> list[str]:
"""Return ['arch', '-arm64'] when the process is running under Rosetta 2."""
if sys.platform != "darwin":
return []
try:
result = subprocess.run(
["sysctl", "-n", "sysctl.proc_translated"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
)
if result.stdout.strip() == "1":
return ["arch", "-arm64"]
except Exception:
pass
return []
def _run_brew(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(
_arch_prefix() + args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
def _brew_cellar_path(brew_path: str, formula: str):
"""
Return the Cellar path for this formula if Homebrew reports it installed; otherwise None.
"""
from pathlib import Path
versions = _run_brew([brew_path, "list", "--versions", formula])
if versions.returncode != 0 or not versions.stdout.strip():
return None
cellar = _run_brew([brew_path, "--cellar", formula])
if cellar.returncode != 0 or not cellar.stdout.strip():
return None
return Path(cellar.stdout.strip()).resolve()
def _path_is_relative_to(path, parent) -> bool:
try:
return path.is_relative_to(parent)
except AttributeError:
# Python <3.9 fallback
return str(path).startswith(str(parent))
def _installed_formula_for_current_app(brew_path: str, formula: Optional[str]):
"""
Return the Homebrew formula/cellar that owns the running code.
When no formula is explicitly requested, try both the stable and nightly
formulae so nightly installs update themselves instead of checking stable.
"""
from pathlib import Path
current_path = Path(__file__).resolve()
candidates = [formula] if formula else [STABLE_FORMULA, NIGHTLY_FORMULA]
for candidate in candidates:
cellar_path = _brew_cellar_path(brew_path, candidate)
if cellar_path is not None and _path_is_relative_to(current_path, cellar_path):
return candidate, cellar_path, current_path
return formula or STABLE_FORMULA, None, current_path
[docs]
def check_for_updates(
_checked: bool = False,
formula: Optional[str] = None,
parent=None,
silent: bool = False,
notify_only_on_update: bool = False,
) -> None:
"""
Refresh Homebrew metadata, then check if the COSEDA formula is outdated.
Prompts the user with the result and suggests upgrading via Homebrew.
"""
dialog = None if silent else _create_progress_dialog("Checking for COSEDA Updates", parent=parent)
if silent and notify_only_on_update:
# Fully silent background check: only surface user-facing notifications when an update is found
log = lambda _msg: None
else:
log = dialog.add_message if dialog else (lambda msg: log_print(msg))
brew_path: Optional[str] = _find_brew()
if not brew_path:
log("Homebrew executable not found on PATH.")
if not (silent and notify_only_on_update):
_notify("Homebrew not found", "Homebrew is required to check for updates.", error=True)
if dialog:
dialog.allow_close()
return
formula, cellar_path, current_path = _installed_formula_for_current_app(brew_path, formula)
if cellar_path is None:
log("Homebrew does not report this COSEDA formula as the running install.")
if not (silent and notify_only_on_update):
_notify(
"Homebrew upgrade unavailable",
"COSEDA is not running from a Homebrew stable or nightly install, "
"so the in-app upgrade is unavailable.",
error=True,
)
if dialog:
dialog.allow_close()
return
log(f"Using Homebrew formula: {formula}")
log("Refreshing coseda tap metadata...")
tap = formula.rsplit("/", 1)[0] # "kristallorakel/coseda"
repo_result = _run_brew([brew_path, "--repo", tap])
if repo_result.returncode == 0:
tap_path = repo_result.stdout.strip()
fetch_result = subprocess.run(
["git", "-C", tap_path, "fetch", "--quiet", "--prune"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
)
reset_result = subprocess.run(
["git", "-C", tap_path, "reset", "--hard", "origin/HEAD"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
)
if fetch_result.returncode != 0 or reset_result.returncode != 0:
detail = (fetch_result.stderr or reset_result.stderr).strip()
log(f"Tap refresh failed; continuing with cached metadata. {detail}")
if not (silent and notify_only_on_update):
_notify(
"Tap refresh failed",
f"Could not refresh coseda tap metadata (using stale data).\n{detail}",
error=True,
)
else:
log("Could not locate coseda tap; skipping metadata refresh.")
log(f"Checking if {formula} is outdated...")
outdated_result = _run_brew([brew_path, "outdated", "--json=v2", formula])
stdout_text = outdated_result.stdout.strip()
stderr_text = outdated_result.stderr.strip()
try:
data = json.loads(stdout_text or "{}")
except json.JSONDecodeError:
data = None
if data is None:
detail = stderr_text or stdout_text or "Unknown error from Homebrew."
log(f"Homebrew outdated check failed: {detail}")
if not (silent and notify_only_on_update):
_notify(
"Update check failed",
f"Homebrew could not check for updates.\n{detail}",
error=True,
)
if dialog:
dialog.allow_close()
dialog.close()
return
entries = data.get("formulae", []) or []
if not entries:
log("You are on the latest Homebrew version of COSEDA.")
if not notify_only_on_update:
_notify("Up to date", "You are already on the latest Homebrew version of COSEDA.")
if dialog:
dialog.allow_close()
return
entry = entries[0]
latest = entry.get("latest_version") or entry.get("current_version") or "unknown"
installed_versions = entry.get("installed_versions") or entry.get("installed") or []
installed = ", ".join(installed_versions) if installed_versions else "unknown"
log(f"Update available. Installed: {installed} | Latest: {latest}")
# Offer to upgrade immediately when running under Qt; otherwise use stdout/stderr.
try:
from PyQt6.QtWidgets import QApplication, QMessageBox, QDialogButtonBox
app = QApplication.instance()
except Exception:
app = None
if app is None:
_notify(
"Update available",
f"A newer version is available via Homebrew.\n"
f"Installed: {installed}\n"
f"Latest: {latest}\n\n"
f"Run `brew upgrade {formula}` to install the update.",
)
else:
box = QMessageBox(parent if parent is not None else app.activeWindow())
box.setIcon(QMessageBox.Icon.Question)
box.setWindowTitle("COSEDA Update Available")
box.setText(
"A newer version is available via Homebrew.\n"
f"Installed: {installed}\n"
f"Latest: {latest}"
)
box.setInformativeText("Do you want to run `brew upgrade` now?")
upgrade_btn = box.addButton("Upgrade now", QMessageBox.ButtonRole.AcceptRole)
box.addButton("Later", QMessageBox.ButtonRole.RejectRole)
box.exec()
if box.clickedButton() is upgrade_btn:
if dialog:
dialog.allow_close()
dialog.close()
upgrade_dialog = _create_progress_dialog("Upgrading COSEDA", parent=parent)
upgrade_log = upgrade_dialog.add_message if upgrade_dialog else log
upgrade_log(f"Running: brew upgrade {formula}")
proc = subprocess.Popen(
_arch_prefix() + [brew_path, "upgrade", formula],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
for line in proc.stdout:
upgrade_log(line.rstrip())
proc.wait()
if proc.returncode == 0:
upgrade_log("Upgrade completed successfully.")
_notify(
"Upgrade complete",
"COSEDA was upgraded successfully.\n"
"Restart the application to use the new version.",
)
else:
upgrade_log(f"Upgrade failed (exit code {proc.returncode}).")
_notify("Upgrade failed", f"brew upgrade exited with code {proc.returncode}.", error=True)
if upgrade_dialog:
upgrade_dialog.allow_close()
if dialog:
dialog.allow_close()
dialog.close()
if __name__ == "__main__":
check_for_updates()