diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index c847a24..fde2288 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -916,6 +916,69 @@ with st.expander("System control", expanded=False): except Exception as e: st.error(f"Failed to update password: {e}") + st.subheader("Software Version") + # Show current version + try: + ver_resp = requests.get(f"{BACKEND_URL}/version", timeout=2) + if ver_resp.ok: + ver_data = ver_resp.json() + current_version = ver_data.get('version', 'unknown') + ver_type = ver_data.get('type', '') + ver_label = current_version if ver_type == 'tag' else f"{current_version} (dev)" + st.write(f"**Current version:** {ver_label}") + else: + st.write("**Current version:** unknown") + current_version = "unknown" + except Exception: + st.write("**Current version:** unknown") + current_version = "unknown" + + # Initialize session state for update check + if 'available_update' not in st.session_state: + st.session_state['available_update'] = None + + col_check, col_status = st.columns([1, 2]) + with col_check: + if st.button("Check for updates"): + try: + check_resp = requests.get(f"{BACKEND_URL}/check_update", timeout=30) + if check_resp.ok: + check_data = check_resp.json() + if check_data.get('error'): + st.session_state['available_update'] = {'error': check_data['error']} + else: + st.session_state['available_update'] = check_data + else: + st.session_state['available_update'] = {'error': f"Failed: {check_resp.status_code}"} + except Exception as e: + st.session_state['available_update'] = {'error': str(e)} + st.rerun() + + with col_status: + if st.session_state['available_update']: + upd = st.session_state['available_update'] + if upd.get('error'): + st.warning(f"Check failed: {upd['error']}") + elif upd.get('update_available'): + st.info(f"Update available: **{upd['available']}**") + else: + st.success("You are on the latest version.") + + # Update button (only show if update is available) + if st.session_state['available_update'] and st.session_state['available_update'].get('update_available'): + if st.button("Update now"): + try: + r = requests.post(f"{BACKEND_URL}/system_update", timeout=120) + if r.ok: + result = r.json() + tag = result.get('tag', 'unknown') + st.success(f"Update to {tag} initiated. The UI will restart shortly.") + st.session_state['available_update'] = None + else: + st.error(f"Failed to update: {r.status_code} {r.text}") + except Exception as e: + st.error(f"Error calling update: {e}") + st.subheader("Reboot") if st.button("Reboot now", type="primary"): try: diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 20a3823..0b97357 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -899,6 +899,176 @@ async def system_reboot(): raise HTTPException(status_code=500, detail=str(e)) +@app.get("/version") +async def get_version(): + """Get the current software version (git tag or commit).""" + try: + # server -> auracast -> src -> bumble-auracast (3 levels up) + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) + + # Try to get the current tag + proc = await asyncio.create_subprocess_exec( + "git", "describe", "--tags", "--exact-match", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return {"version": stdout.decode().strip(), "type": "tag"} + + # Fallback: get the current commit short hash + proc = await asyncio.create_subprocess_exec( + "git", "rev-parse", "--short", "HEAD", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return {"version": stdout.decode().strip(), "type": "commit"} + + return {"version": "unknown", "type": "unknown"} + except Exception as e: + log.error("Exception in /version: %s", e, exc_info=True) + return {"version": "unknown", "type": "error"} + + +@app.get("/check_update") +async def check_update(): + """Check for available updates by comparing current version with latest release tag.""" + try: + # server -> auracast -> src -> bumble-auracast (3 levels up) + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) + + # Fetch all tags and refs from origin + proc = await asyncio.create_subprocess_exec( + "git", "fetch", "--tags", "--force", "origin", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + log.warning("git fetch failed: %s", stderr.decode()) + + # Get the latest tag sorted by version (semver-like sorting) + # This gets all tags, sorts them by version, and takes the last one + proc = await asyncio.create_subprocess_exec( + "git", "tag", "--sort=version:refname", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + return {"available": None, "error": f"Could not list tags: {stderr.decode()}"} + + tags = stdout.decode().strip().split('\n') + tags = [t for t in tags if t] # Filter empty strings + if not tags: + return {"available": None, "error": "No tags found in repository"} + + latest_tag = tags[-1] # Last tag after version sorting + + # Get current version + current_version = (await get_version()).get("version", "unknown") + + update_available = latest_tag != current_version + return { + "current": current_version, + "available": latest_tag, + "update_available": update_available + } + except Exception as e: + log.error("Exception in /check_update: %s", e, exc_info=True) + return {"available": None, "error": str(e)} + + +@app.post("/system_update") +async def system_update(): + """Update application: git pull release branch (latest tag), poetry install, restart services.""" + try: + # Best-effort: stop any active streaming cleanly + try: + await _stop_all() + except Exception: + pass + + # server -> auracast -> src -> bumble-auracast (3 levels up) + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) + + # 1. Fetch and checkout the latest tag from the release branch + # First fetch all tags and the release branch + proc = await asyncio.create_subprocess_exec( + "git", "fetch", "--tags", "origin", "release", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + log.error("git fetch failed: %s", stderr.decode()) + raise HTTPException(status_code=500, detail=f"git fetch failed: {stderr.decode()}") + + # Get the latest tag on the release branch + proc = await asyncio.create_subprocess_exec( + "git", "describe", "--tags", "--abbrev=0", "origin/release", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + log.error("git describe failed: %s", stderr.decode()) + raise HTTPException(status_code=500, detail=f"Failed to get latest tag: {stderr.decode()}") + latest_tag = stdout.decode().strip() + log.info("Updating to tag: %s", latest_tag) + + # Checkout the latest tag + proc = await asyncio.create_subprocess_exec( + "git", "checkout", latest_tag, + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + log.error("git checkout failed: %s", stderr.decode()) + raise HTTPException(status_code=500, detail=f"git checkout failed: {stderr.decode()}") + + # 2. Run poetry install (use full path as poetry is in user's ~/.local/bin) + poetry_path = os.path.expanduser("~/.local/bin/poetry") + proc = await asyncio.create_subprocess_exec( + poetry_path, "install", + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + log.error("poetry install failed: %s", stderr.decode()) + raise HTTPException(status_code=500, detail=f"poetry install failed: {stderr.decode()}") + + # 3. Restart services via the update script + update_script = os.path.join(project_root, 'src', 'service', 'update_and_run_server_and_frontend.sh') + proc = await asyncio.create_subprocess_exec( + "bash", update_script, + cwd=project_root, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + # Don't wait for completion as we'll be restarted + await asyncio.sleep(0.5) + + return {"status": "updating", "tag": latest_tag} + except HTTPException: + raise + except Exception as e: + log.error("Exception in /system_update: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + if __name__ == '__main__': import os os.chdir(os.path.dirname(__file__))