Source code for cosedaUI.update

"""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()