""" GitHub update checking and git-based update mechanism. """ from __future__ import annotations import contextlib import json import logging import os import re import subprocess import sys import time from datetime import datetime from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen import config from utils.database import get_setting, set_setting logger = logging.getLogger('intercept.updater') # Cache keys for settings CACHE_KEY_LAST_CHECK = 'update.last_check' CACHE_KEY_LATEST_VERSION = 'update.latest_version' CACHE_KEY_RELEASE_URL = 'update.release_url' CACHE_KEY_RELEASE_NOTES = 'update.release_notes' CACHE_KEY_DISMISSED_VERSION = 'update.dismissed_version' # Default check interval (6 hours in seconds) DEFAULT_CHECK_INTERVAL = 6 * 60 * 60 def _get_github_repo() -> str: """Get the configured GitHub repository.""" return getattr(config, 'GITHUB_REPO', 'smittix/intercept') def _get_check_interval() -> int: """Get the configured check interval in seconds.""" hours = getattr(config, 'UPDATE_CHECK_INTERVAL_HOURS', 6) return hours * 60 * 60 def _is_update_check_enabled() -> bool: """Check if update checking is enabled.""" return getattr(config, 'UPDATE_CHECK_ENABLED', True) def _compare_versions(current: str, latest: str) -> int: """ Compare two semantic version strings. Returns: -1 if current < latest (update available) 0 if current == latest 1 if current > latest """ def parse_version(v: str) -> tuple: # Strip 'v' prefix if present v = v.lstrip('v') # Split by dots and convert to integers parts = [] for part in v.split('.'): # Handle pre-release suffixes like 2.11.0-beta match = re.match(r'^(\d+)', part) if match: parts.append(int(match.group(1))) else: parts.append(0) # Pad to at least 3 parts while len(parts) < 3: parts.append(0) return tuple(parts) try: current_parts = parse_version(current) latest_parts = parse_version(latest) if current_parts < latest_parts: return -1 elif current_parts > latest_parts: return 1 return 0 except Exception as e: logger.warning(f"Error comparing versions '{current}' and '{latest}': {e}") return 0 def _fetch_github_release() -> dict[str, Any] | None: """ Fetch the latest release from GitHub API. Returns: Dict with release info or None on error """ repo = _get_github_repo() url = f'https://api.github.com/repos/{repo}/releases/latest' try: req = Request(url, headers={ 'User-Agent': 'Intercept-SIGINT', 'Accept': 'application/vnd.github.v3+json' }) with urlopen(req, timeout=10) as response: # Check rate limit headers remaining = response.headers.get('X-RateLimit-Remaining') if remaining and int(remaining) < 10: logger.warning(f"GitHub API rate limit low: {remaining} remaining") data = json.loads(response.read().decode('utf-8')) return { 'tag_name': data.get('tag_name', ''), 'html_url': data.get('html_url', ''), 'body': data.get('body', ''), 'published_at': data.get('published_at', ''), 'name': data.get('name', '') } except HTTPError as e: if e.code == 404: logger.info("No releases found on GitHub") else: logger.warning(f"GitHub API error: {e.code} {e.reason}") return None except URLError as e: logger.warning(f"Failed to fetch GitHub release: {e}") return None except Exception as e: logger.warning(f"Error fetching GitHub release: {e}") return None def check_for_updates(force: bool = False) -> dict[str, Any]: """ Check GitHub for updates. Uses caching to avoid excessive API calls. Only checks GitHub if: - force=True, or - Last check was more than check_interval ago Args: force: If True, bypass cache and check GitHub directly Returns: Dict with update status information """ if not _is_update_check_enabled(): return { 'success': True, 'update_available': False, 'disabled': True, 'message': 'Update checking is disabled' } current_version = config.VERSION # Check cache unless forced if not force: last_check = get_setting(CACHE_KEY_LAST_CHECK) if last_check: try: last_check_time = float(last_check) check_interval = _get_check_interval() if time.time() - last_check_time < check_interval: # Return cached data cached_version = get_setting(CACHE_KEY_LATEST_VERSION) if cached_version: dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION) update_available = _compare_versions(current_version, cached_version) < 0 # Don't show update if user dismissed this version show_notification = update_available and dismissed != cached_version return { 'success': True, 'checked': True, 'update_available': update_available, 'show_notification': show_notification, 'current_version': current_version, 'latest_version': cached_version, 'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '', 'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '', 'cached': True, 'last_check': datetime.fromtimestamp(last_check_time).isoformat() } except (ValueError, TypeError): pass # Fetch from GitHub release = _fetch_github_release() if not release: # Return cached data if available, otherwise error cached_version = get_setting(CACHE_KEY_LATEST_VERSION) if cached_version: update_available = _compare_versions(current_version, cached_version) < 0 return { 'success': True, 'checked': True, 'update_available': update_available, 'current_version': current_version, 'latest_version': cached_version, 'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '', 'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '', 'cached': True, 'network_error': True } return { 'success': False, 'error': 'Failed to check for updates' } latest_version = release['tag_name'].lstrip('v') # Update cache set_setting(CACHE_KEY_LAST_CHECK, str(time.time())) set_setting(CACHE_KEY_LATEST_VERSION, latest_version) set_setting(CACHE_KEY_RELEASE_URL, release['html_url']) set_setting(CACHE_KEY_RELEASE_NOTES, release['body'][:2000] if release['body'] else '') update_available = _compare_versions(current_version, latest_version) < 0 dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION) show_notification = update_available and dismissed != latest_version return { 'success': True, 'checked': True, 'update_available': update_available, 'show_notification': show_notification, 'current_version': current_version, 'latest_version': latest_version, 'release_url': release['html_url'], 'release_notes': release['body'] or '', 'release_name': release['name'] or f'v{latest_version}', 'published_at': release['published_at'], 'cached': False, 'last_check': datetime.now().isoformat() } def get_update_status() -> dict[str, Any]: """ Get current update status from cache without triggering a check. Returns: Dict with cached update status """ current_version = config.VERSION cached_version = get_setting(CACHE_KEY_LATEST_VERSION) last_check = get_setting(CACHE_KEY_LAST_CHECK) dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION) if not cached_version: return { 'success': True, 'checked': False, 'current_version': current_version } update_available = _compare_versions(current_version, cached_version) < 0 show_notification = update_available and dismissed != cached_version last_check_time = None if last_check: with contextlib.suppress(ValueError, TypeError): last_check_time = datetime.fromtimestamp(float(last_check)).isoformat() return { 'success': True, 'checked': True, 'update_available': update_available, 'show_notification': show_notification, 'current_version': current_version, 'latest_version': cached_version, 'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '', 'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '', 'dismissed_version': dismissed, 'last_check': last_check_time } def dismiss_update(version: str) -> dict[str, Any]: """ Dismiss update notification for a specific version. Args: version: The version to dismiss Returns: Status dict """ set_setting(CACHE_KEY_DISMISSED_VERSION, version) return { 'success': True, 'dismissed_version': version } def _is_git_repo() -> bool: """Check if the current directory is a git repository.""" try: result = subprocess.run( ['git', 'rev-parse', '--is-inside-work-tree'], capture_output=True, text=True, timeout=5, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) return result.returncode == 0 and result.stdout.strip() == 'true' except Exception: return False def _get_git_status() -> dict[str, Any]: """Get git repository status.""" repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) try: # Check for uncommitted changes result = subprocess.run( ['git', 'status', '--porcelain'], capture_output=True, text=True, timeout=10, cwd=repo_root ) has_changes = bool(result.stdout.strip()) changed_files = result.stdout.strip().split('\n') if has_changes else [] # Get current branch branch_result = subprocess.run( ['git', 'branch', '--show-current'], capture_output=True, text=True, timeout=5, cwd=repo_root ) current_branch = branch_result.stdout.strip() or 'main' return { 'has_changes': has_changes, 'changed_files': [f for f in changed_files if f], 'current_branch': current_branch } except Exception as e: logger.warning(f"Error getting git status: {e}") return { 'has_changes': False, 'changed_files': [], 'current_branch': 'unknown', 'error': str(e) } def perform_update(stash_changes: bool = False) -> dict[str, Any]: """ Perform a git pull to update the application. Args: stash_changes: If True, stash local changes before pulling Returns: Dict with update result information """ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Check if this is a git repo if not _is_git_repo(): return { 'success': False, 'error': 'Not a git repository', 'manual_update': True, 'message': 'This installation is not using git. Please update manually by downloading the latest release from GitHub.' } git_status = _get_git_status() # Check for local changes if git_status['has_changes'] and not stash_changes: return { 'success': False, 'error': 'local_changes', 'message': 'You have uncommitted local changes. Either commit them, discard them, or enable "stash changes" to temporarily save them.', 'changed_files': git_status['changed_files'] } try: # Stash changes if requested stashed = False if stash_changes and git_status['has_changes']: stash_result = subprocess.run( ['git', 'stash', 'push', '-m', 'INTERCEPT auto-stash before update'], capture_output=True, text=True, timeout=30, cwd=repo_root ) if stash_result.returncode == 0: stashed = True logger.info("Stashed local changes before update") else: return { 'success': False, 'error': 'Failed to stash changes', 'details': stash_result.stderr } # Get current requirements.txt hash to detect changes req_path = os.path.join(repo_root, 'requirements.txt') req_hash_before = None if os.path.exists(req_path): with open(req_path, 'rb') as f: import hashlib req_hash_before = hashlib.md5(f.read()).hexdigest() # Fetch latest changes fetch_result = subprocess.run( ['git', 'fetch', 'origin'], capture_output=True, text=True, timeout=60, cwd=repo_root ) if fetch_result.returncode != 0: # Restore stash if we stashed if stashed: subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30) return { 'success': False, 'error': 'Failed to fetch updates', 'details': fetch_result.stderr } # Get the main branch name branch = git_status.get('current_branch', 'main') # Pull changes pull_result = subprocess.run( ['git', 'pull', 'origin', branch], capture_output=True, text=True, timeout=120, cwd=repo_root ) if pull_result.returncode != 0: # Check for merge conflict if 'CONFLICT' in pull_result.stdout or 'CONFLICT' in pull_result.stderr: # Abort merge subprocess.run(['git', 'merge', '--abort'], cwd=repo_root, timeout=30) # Restore stash if we stashed if stashed: subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30) return { 'success': False, 'error': 'merge_conflict', 'message': 'Merge conflict detected. The update was aborted. Please resolve conflicts manually or reset to a clean state.', 'details': pull_result.stdout + pull_result.stderr } # Restore stash if we stashed if stashed: subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30) return { 'success': False, 'error': 'Failed to pull updates', 'details': pull_result.stderr } # Restore stashed changes if stashed: stash_pop_result = subprocess.run( ['git', 'stash', 'pop'], capture_output=True, text=True, timeout=30, cwd=repo_root ) if stash_pop_result.returncode != 0: logger.warning(f"Failed to restore stashed changes: {stash_pop_result.stderr}") # Check if requirements changed requirements_changed = False if req_hash_before and os.path.exists(req_path): with open(req_path, 'rb') as f: import hashlib req_hash_after = hashlib.md5(f.read()).hexdigest() requirements_changed = req_hash_before != req_hash_after # Determine if update actually happened if 'Already up to date' in pull_result.stdout: return { 'success': True, 'updated': False, 'message': 'Already up to date', 'stashed': stashed } # Clear update cache to reflect new version set_setting(CACHE_KEY_LAST_CHECK, '') set_setting(CACHE_KEY_LATEST_VERSION, '') return { 'success': True, 'updated': True, 'message': 'Update successful! Please restart the application.', 'restart_required': True, 'requirements_changed': requirements_changed, 'stashed': stashed, 'stash_restored': stashed, 'output': pull_result.stdout } except subprocess.TimeoutExpired: return { 'success': False, 'error': 'Operation timed out', 'message': 'The update operation timed out. Please check your network connection and try again.' } except Exception as e: logger.error(f"Update error: {e}") return { 'success': False, 'error': str(e) } def restart_application() -> dict[str, Any]: """ Restart the application using os.execv to replace the current process. This function: 1. Cleans up all running decoder processes 2. Stops the cleanup manager 3. Replaces the current process with a fresh Python interpreter Returns: Dict with status (though this is typically not reached due to execv) """ import app as app_module from utils.cleanup import cleanup_manager from utils.process import cleanup_all_processes logger.info("Application restart requested") try: # Step 1: Kill all decoder processes logger.info("Stopping all decoder processes...") cleanup_all_processes() # Step 2: Clear global process state with app_module.process_lock: app_module.current_process = None with app_module.sensor_lock: app_module.sensor_process = None with app_module.wifi_lock: app_module.wifi_process = None with app_module.adsb_lock: app_module.adsb_process = None with app_module.ais_lock: app_module.ais_process = None with app_module.acars_lock: app_module.acars_process = None with app_module.aprs_lock: app_module.aprs_process = None app_module.aprs_rtl_process = None with app_module.dsc_lock: app_module.dsc_process = None app_module.dsc_rtl_process = None # Step 3: Clear SDR device registry with app_module.sdr_device_registry_lock: app_module.sdr_device_registry.clear() # Step 4: Stop cleanup manager logger.info("Stopping cleanup manager...") cleanup_manager.stop() # Step 5: Prepare for restart using os.execv # Get the Python executable and script path python_executable = sys.executable script_path = os.path.abspath(sys.argv[0]) # Build argument list (preserve original command-line args) args = [python_executable, script_path] + sys.argv[1:] logger.info(f"Restarting with: {' '.join(args)}") # Flush any pending log output logging.shutdown() # Use os.execv to replace the current process # This will not return - the process is replaced entirely os.execv(python_executable, args) # This code is never reached return {'success': True, 'message': 'Restarting...'} except Exception as e: logger.error(f"Restart failed: {e}") return { 'success': False, 'error': str(e), 'message': 'Failed to restart application. Please restart manually.' }