mirror of
https://github.com/fr33n0w/frup
synced 2026-08-12 18:35:15 -04:00
Added system dependency checks and updated comments for packages requiring audio codecs and desktop environments.
1028 lines
44 KiB
Python
1028 lines
44 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Fast Reticulum Updater v1.0
|
||
Author: F
|
||
Improvements: PyPI-first approach, efficiency, error handling, CLI arguments, colored output, summary, system detection
|
||
"""
|
||
|
||
import requests
|
||
import subprocess
|
||
import sys
|
||
import argparse
|
||
import json
|
||
import os
|
||
import platform
|
||
from typing import Dict, List, Optional
|
||
|
||
# Try to import colorama for colored output
|
||
try:
|
||
from colorama import init, Fore, Style
|
||
init()
|
||
GREEN = Fore.GREEN
|
||
RED = Fore.RED
|
||
YELLOW = Fore.YELLOW
|
||
CYAN = Fore.CYAN
|
||
RESET = Style.RESET_ALL
|
||
BRIGHT = Style.BRIGHT
|
||
except ImportError:
|
||
# Fallback if colorama not installed
|
||
GREEN = RED = YELLOW = CYAN = RESET = BRIGHT = ""
|
||
|
||
class SystemDetector:
|
||
"""Detect system information and capabilities"""
|
||
|
||
def __init__(self):
|
||
self.os_type = platform.system()
|
||
self.os_release = platform.release()
|
||
self.machine = platform.machine()
|
||
self.is_termux = self.detect_termux()
|
||
self.is_raspbian = self.detect_raspbian()
|
||
self.is_pi_zero = self.detect_pi_zero()
|
||
self.has_desktop = self.detect_desktop_environment()
|
||
self.python_version = platform.python_version()
|
||
self.is_slow_system = self.detect_slow_system()
|
||
|
||
def detect_termux(self) -> bool:
|
||
"""Detect if running on Termux (Android)"""
|
||
return os.path.exists('/data/data/com.termux') or 'com.termux' in os.environ.get('PREFIX', '')
|
||
|
||
def detect_raspbian(self) -> bool:
|
||
"""Detect if running on Raspbian/Raspberry Pi OS"""
|
||
try:
|
||
if os.path.exists('/etc/os-release'):
|
||
with open('/etc/os-release', 'r') as f:
|
||
content = f.read().lower()
|
||
return 'raspbian' in content or 'raspberry' in content
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
def detect_pi_zero(self) -> bool:
|
||
"""Detect if running on Raspberry Pi Zero (very slow single-core)"""
|
||
try:
|
||
# Check /proc/cpuinfo for Pi Zero identifiers
|
||
if os.path.exists('/proc/cpuinfo'):
|
||
with open('/proc/cpuinfo', 'r') as f:
|
||
content = f.read().lower()
|
||
# Pi Zero has BCM2835 and single core
|
||
if 'bcm2835' in content or 'bcm2708' in content:
|
||
# Check if single core
|
||
if content.count('processor') == 1:
|
||
return True
|
||
# Check device model
|
||
if os.path.exists('/proc/device-tree/model'):
|
||
with open('/proc/device-tree/model', 'r') as f:
|
||
model = f.read().lower()
|
||
if 'pi zero' in model or 'pi 1' in model:
|
||
return True
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
def detect_slow_system(self) -> bool:
|
||
"""Detect if system is likely to be slow (single-core, low-power)"""
|
||
# Pi Zero is definitely slow
|
||
if self.is_pi_zero:
|
||
return True
|
||
|
||
# Check CPU count
|
||
try:
|
||
import multiprocessing
|
||
cpu_count = multiprocessing.cpu_count()
|
||
if cpu_count == 1:
|
||
return True
|
||
except:
|
||
pass
|
||
|
||
# Termux on older Android devices can be slow
|
||
if self.is_termux:
|
||
return True
|
||
|
||
return False
|
||
|
||
def detect_desktop_environment(self) -> bool:
|
||
"""Detect if a desktop environment is available"""
|
||
# Check for Termux (no desktop)
|
||
if self.is_termux:
|
||
return False
|
||
|
||
# Check common desktop environment variables
|
||
desktop_vars = ['DISPLAY', 'WAYLAND_DISPLAY', 'XDG_CURRENT_DESKTOP', 'DESKTOP_SESSION']
|
||
if any(os.environ.get(var) for var in desktop_vars):
|
||
return True
|
||
|
||
# Check for X11 or Wayland
|
||
if os.path.exists('/tmp/.X11-unix') or os.path.exists('/run/user'):
|
||
return True
|
||
|
||
# For Windows, assume desktop is available
|
||
if self.os_type == 'Windows':
|
||
return True
|
||
|
||
# For macOS, assume desktop is available
|
||
if self.os_type == 'Darwin':
|
||
return True
|
||
|
||
# Check if running in SSH session (likely headless)
|
||
if os.environ.get('SSH_CONNECTION') or os.environ.get('SSH_CLIENT'):
|
||
return False
|
||
|
||
# Default to False for server/headless systems
|
||
return False
|
||
|
||
def get_system_description(self) -> str:
|
||
"""Get a human-readable system description"""
|
||
parts = []
|
||
|
||
# OS Type
|
||
if self.is_termux:
|
||
parts.append("Termux (Android)")
|
||
elif self.is_pi_zero:
|
||
parts.append("Raspberry Pi Zero")
|
||
elif self.is_raspbian:
|
||
parts.append("Raspberry Pi OS")
|
||
elif self.os_type == 'Linux':
|
||
# Try to get distribution name
|
||
try:
|
||
if os.path.exists('/etc/os-release'):
|
||
with open('/etc/os-release', 'r') as f:
|
||
for line in f:
|
||
if line.startswith('PRETTY_NAME='):
|
||
distro = line.split('=')[1].strip().strip('"')
|
||
parts.append(distro)
|
||
break
|
||
else:
|
||
parts.append(f"Linux {self.os_release}")
|
||
else:
|
||
parts.append(f"Linux {self.os_release}")
|
||
except:
|
||
parts.append(f"Linux {self.os_release}")
|
||
else:
|
||
parts.append(f"{self.os_type} {self.os_release}")
|
||
|
||
# Architecture
|
||
parts.append(self.machine)
|
||
|
||
# Desktop status
|
||
if self.has_desktop:
|
||
parts.append("Desktop")
|
||
else:
|
||
parts.append("Headless/Server")
|
||
|
||
# Python version
|
||
parts.append(f"Python {self.python_version}")
|
||
|
||
return " | ".join(parts)
|
||
|
||
def should_skip_desktop_packages(self) -> bool:
|
||
"""Determine if desktop-only packages should be skipped"""
|
||
return not self.has_desktop
|
||
|
||
class ReticulumUpdater:
|
||
def __init__(self, auto_update=False, quiet=False, check_only=False, break_system=False):
|
||
self.auto_update = auto_update
|
||
self.quiet = quiet
|
||
self.check_only = check_only
|
||
self.break_system = break_system
|
||
self.online_versions = {}
|
||
self.local_versions = {}
|
||
self.updated = []
|
||
self.skipped = []
|
||
self.failed = []
|
||
self.already_updated = []
|
||
self.using_custom_config = False
|
||
self.config_path = None
|
||
self.needs_break_system = False
|
||
|
||
# Detect system
|
||
self.system = SystemDetector()
|
||
|
||
# Default packages - can be overridden by config file
|
||
# NOTE: PyPI package names are used for pip installation
|
||
self.packages = [
|
||
# Protocol Stack - PyPI packages
|
||
{'name': 'rns', 'display_name': 'RNS', 'pypi_name': 'rns',
|
||
'url': 'https://github.com/markqvist/Reticulum'},
|
||
{'name': 'lxmf', 'display_name': 'LXMF', 'pypi_name': 'lxmf',
|
||
'url': 'https://github.com/markqvist/lxmf'},
|
||
{'name': 'lxst', 'display_name': 'LXST', 'pypi_name': 'lxst',
|
||
'url': 'https://github.com/markqvist/lxst',
|
||
'system_dependencies': {
|
||
'debian': ['python3-pyaudio', 'codec2'], # For Debian/Ubuntu/Raspbian
|
||
'check_command': 'apt' # How to detect if this system uses these deps
|
||
}},
|
||
|
||
# Software - PyPI packages
|
||
{'name': 'nomadnet', 'display_name': 'NomadNet', 'pypi_name': 'nomadnet',
|
||
'url': 'https://github.com/markqvist/nomadnet'},
|
||
{'name': 'sideband', 'display_name': 'Sideband', 'pypi_name': 'sbapp',
|
||
'url': 'https://github.com/markqvist/Sideband',
|
||
'requires_desktop': True,
|
||
'slow_on_pi_zero': True, # Warn: compilation takes 30+ minutes on Pi Zero
|
||
'system_dependencies': {
|
||
'debian': ['python3-pyaudio', 'codec2', 'xclip', 'xsel'],
|
||
'debian_bookworm': ['python3-pip', 'python3-pyaudio', 'python3-dev',
|
||
'python3-cryptography', 'build-essential', 'libopusfile0',
|
||
'libsdl2-dev', 'libavcodec-dev', 'libavdevice-dev',
|
||
'libavfilter-dev', 'portaudio19-dev', 'codec2',
|
||
'libcodec2-1.0', 'xclip', 'xsel'],
|
||
'check_command': 'apt'
|
||
}},
|
||
|
||
# Software - GitHub only (manual install)
|
||
{'name': 'meshchat', 'display_name': 'MeshChat', 'pypi_name': None,
|
||
'url': 'https://github.com/liamcottle/reticulum-meshchat',
|
||
'manual_install': True, 'skip_local_check': True, 'skip_version_comparison': True,
|
||
'online_only': True},
|
||
{'name': 'columba', 'display_name': 'Columba', 'pypi_name': None,
|
||
'url': 'https://github.com/torlando-tech/columba',
|
||
'manual_install': True, 'skip_local_check': True, 'skip_version_comparison': True,
|
||
'online_only': True},
|
||
|
||
# LoRa Firmware - GitHub only (manual install)
|
||
{'name': 'rnode', 'display_name': 'RNode', 'pypi_name': None,
|
||
'url': 'https://github.com/markqvist/RNode_Firmware',
|
||
'manual_install': True, 'skip_local_check': True, 'skip_version_comparison': True,
|
||
'online_only': True},
|
||
{'name': 'rnode_ce', 'display_name': 'RNode CE', 'pypi_name': None,
|
||
'url': 'https://github.com/liberatedsystems/RNode_Firmware_CE',
|
||
'manual_install': True, 'skip_local_check': True, 'skip_version_comparison': True,
|
||
'online_only': True},
|
||
{'name': 'rnode_tn', 'display_name': 'RNode TN', 'pypi_name': None,
|
||
'url': 'https://github.com/attermann/microReticulum_Firmware',
|
||
'manual_install': True, 'skip_local_check': True, 'skip_version_comparison': True,
|
||
'online_only': True}
|
||
]
|
||
|
||
# Load custom config if available
|
||
self.load_config()
|
||
|
||
def load_config(self):
|
||
"""Load configuration from file if it exists"""
|
||
config_files = ['frup_config.json', '.frup_config.json', '~/.frup_config.json']
|
||
for config_file in config_files:
|
||
config_path = os.path.expanduser(config_file)
|
||
if os.path.exists(config_path):
|
||
try:
|
||
with open(config_path, 'r') as f:
|
||
config = json.load(f)
|
||
if 'packages' in config:
|
||
self.packages = config['packages']
|
||
self.using_custom_config = True
|
||
self.config_path = config_path
|
||
break
|
||
except Exception as e:
|
||
print(f"{RED}Error loading config from {config_path}: {e}{RESET}")
|
||
print(f"{YELLOW}Using default configuration instead{RESET}")
|
||
|
||
def save_example_config(self):
|
||
"""Save an example configuration file"""
|
||
example_config = {
|
||
"comment": "Fast Reticulum Updater Configuration - Customize packages to check/update",
|
||
"packages": [
|
||
{
|
||
"name": "rns",
|
||
"display_name": "RNS",
|
||
"pypi_name": "rns",
|
||
"url": "https://github.com/markqvist/Reticulum"
|
||
},
|
||
{
|
||
"name": "lxmf",
|
||
"display_name": "LXMF",
|
||
"pypi_name": "lxmf",
|
||
"url": "https://github.com/markqvist/lxmf"
|
||
},
|
||
{
|
||
"name": "lxst",
|
||
"display_name": "LXST",
|
||
"pypi_name": "lxst",
|
||
"url": "https://github.com/markqvist/lxst",
|
||
"system_dependencies": {
|
||
"debian": ["python3-pyaudio", "codec2"],
|
||
"check_command": "apt"
|
||
},
|
||
"comment": "Requires audio codec dependencies on Debian/Raspbian"
|
||
},
|
||
{
|
||
"name": "nomadnet",
|
||
"display_name": "NomadNet",
|
||
"pypi_name": "nomadnet",
|
||
"url": "https://github.com/markqvist/nomadnet"
|
||
},
|
||
{
|
||
"name": "sideband",
|
||
"display_name": "Sideband",
|
||
"pypi_name": "sbapp",
|
||
"url": "https://github.com/markqvist/Sideband",
|
||
"requires_desktop": true,
|
||
"slow_on_pi_zero": true,
|
||
"system_dependencies": {
|
||
"debian": ["python3-pyaudio", "codec2", "xclip", "xsel"],
|
||
"debian_bookworm": [
|
||
"python3-pip", "python3-pyaudio", "python3-dev",
|
||
"python3-cryptography", "build-essential", "libopusfile0",
|
||
"libsdl2-dev", "libavcodec-dev", "libavdevice-dev",
|
||
"libavfilter-dev", "portaudio19-dev", "codec2",
|
||
"libcodec2-1.0", "xclip", "xsel"
|
||
],
|
||
"check_command": "apt"
|
||
},
|
||
"comment": "PyPI package name is 'sbapp', requires desktop, compiles slowly on Pi Zero (30+ min), Bookworm needs more build dependencies"
|
||
},
|
||
{
|
||
"name": "meshchat",
|
||
"display_name": "MeshChat",
|
||
"pypi_name": null,
|
||
"url": "https://github.com/liamcottle/reticulum-meshchat",
|
||
"manual_install": true,
|
||
"skip_local_check": true,
|
||
"skip_version_comparison": true,
|
||
"online_only": true
|
||
},
|
||
{
|
||
"name": "columba",
|
||
"display_name": "Columba",
|
||
"pypi_name": null,
|
||
"url": "https://github.com/torlando-tech/columba",
|
||
"manual_install": true,
|
||
"skip_local_check": true,
|
||
"skip_version_comparison": true,
|
||
"online_only": true
|
||
},
|
||
{
|
||
"name": "rnode",
|
||
"display_name": "RNode",
|
||
"pypi_name": null,
|
||
"url": "https://github.com/markqvist/RNode_Firmware",
|
||
"manual_install": true,
|
||
"skip_local_check": true,
|
||
"skip_version_comparison": true,
|
||
"online_only": true
|
||
},
|
||
{
|
||
"name": "rnode_ce",
|
||
"display_name": "RNode CE",
|
||
"pypi_name": null,
|
||
"url": "https://github.com/liberatedsystems/RNode_Firmware_CE",
|
||
"manual_install": true,
|
||
"skip_local_check": true,
|
||
"skip_version_comparison": true,
|
||
"online_only": true
|
||
},
|
||
{
|
||
"name": "rnode_tn",
|
||
"display_name": "RNode TN",
|
||
"pypi_name": null,
|
||
"url": "https://github.com/attermann/microReticulum_Firmware",
|
||
"manual_install": true,
|
||
"skip_local_check": true,
|
||
"skip_version_comparison": true,
|
||
"online_only": true
|
||
}
|
||
],
|
||
"notes": [
|
||
"pypi_name: The package name on PyPI (for pip install)",
|
||
"name: Internal identifier (lowercase, no spaces)",
|
||
"display_name: Human-readable name shown to user",
|
||
"manual_install: true = Cannot be installed via pip",
|
||
"online_only: true = Only check online version, don't show in updates",
|
||
"requires_desktop: true = Only install on systems with desktop environment",
|
||
"slow_on_pi_zero: true = Warn user about long compilation time on Pi Zero",
|
||
"system_dependencies: Object with 'debian' array and optional 'debian_bookworm' for Debian 12 specific deps",
|
||
"Set pypi_name to null for GitHub-only packages"
|
||
]
|
||
}
|
||
with open('frup_config_example.json', 'w') as f:
|
||
json.dump(example_config, f, indent=2)
|
||
print(f"{GREEN}Example config saved to: frup_config_example.json{RESET}")
|
||
print(f"{CYAN}Rename to 'frup_config.json' to use it{RESET}")
|
||
|
||
def normalize_version(self, version: Optional[str]) -> Optional[str]:
|
||
"""Remove common prefixes from version strings for comparison"""
|
||
if version:
|
||
return version.lstrip('v').lstrip('V').strip()
|
||
return version
|
||
|
||
def print_header(self):
|
||
"""Print the application header"""
|
||
if not self.quiet:
|
||
print()
|
||
print(f"{BRIGHT}=============================================={RESET}")
|
||
print(f"{BRIGHT} Fast Reticulum Updater v1.0 by F{RESET}")
|
||
print(f"{BRIGHT} Now with System Detection!{RESET}")
|
||
print(f"{BRIGHT}=============================================={RESET}")
|
||
|
||
# Show system information
|
||
print(f"\n{BRIGHT}** System Information **{RESET}")
|
||
system_desc = self.system.get_system_description()
|
||
print(f" {CYAN}{system_desc}{RESET}")
|
||
|
||
# Warn about slow systems
|
||
if self.system.is_slow_system:
|
||
print(f" {YELLOW}⚠ Slow system detected - package installs may take longer{RESET}")
|
||
if self.system.is_pi_zero:
|
||
print(f" {YELLOW}⚠ Pi Zero detected - using extended timeout (5 minutes){RESET}")
|
||
print(f" {YELLOW}⚠ Network issues? Script will auto-retry up to 5 times{RESET}")
|
||
print(f" {YELLOW}⚠ Sideband compilation may take 30+ minutes (be patient!){RESET}")
|
||
|
||
# Warn about desktop-only packages if no desktop detected
|
||
if self.system.should_skip_desktop_packages():
|
||
print(f" {YELLOW}ℹ No desktop environment detected{RESET}")
|
||
print(f" {YELLOW}ℹ Desktop-only packages (like Sideband) will be skipped{RESET}")
|
||
|
||
# Show config status
|
||
if self.using_custom_config:
|
||
print(f"\n{CYAN}Using custom config: {self.config_path}{RESET}")
|
||
else:
|
||
print(f"\n{CYAN}Using default configuration{RESET}")
|
||
|
||
def fetch_pypi_version(self, package_name: str) -> Optional[str]:
|
||
"""Fetch version from PyPI"""
|
||
try:
|
||
response = requests.get(
|
||
f"https://pypi.org/pypi/{package_name}/json",
|
||
timeout=10
|
||
)
|
||
response.raise_for_status()
|
||
version = response.json()['info']['version']
|
||
return version
|
||
except requests.exceptions.RequestException:
|
||
return None
|
||
|
||
def fetch_github_version(self, repo_url: str) -> Optional[str]:
|
||
"""Fetch version from GitHub releases"""
|
||
try:
|
||
repo_parts = repo_url.split('/')
|
||
repo = f"{repo_parts[-2]}/{repo_parts[-1]}"
|
||
|
||
response = requests.get(
|
||
f"https://api.github.com/repos/{repo}/releases/latest",
|
||
timeout=10,
|
||
headers={'Accept': 'application/vnd.github.v3+json'}
|
||
)
|
||
response.raise_for_status()
|
||
version = response.json().get("tag_name", "Unknown")
|
||
return version
|
||
except requests.exceptions.RequestException:
|
||
return None
|
||
|
||
def should_skip_package(self, package: dict) -> bool:
|
||
"""Determine if a package should be skipped based on system requirements"""
|
||
# Check if package requires desktop
|
||
if package.get('requires_desktop') and self.system.should_skip_desktop_packages():
|
||
return True
|
||
return False
|
||
|
||
def get_install_timeout(self) -> int:
|
||
"""Get appropriate timeout for pip install based on system speed"""
|
||
if self.system.is_pi_zero:
|
||
return 300 # 5 minutes for Pi Zero
|
||
elif self.system.is_slow_system:
|
||
return 240 # 4 minutes for other slow systems
|
||
else:
|
||
return 120 # 2 minutes for normal systems
|
||
|
||
def install_system_dependencies(self, package: dict) -> bool:
|
||
"""Install system dependencies if needed. Returns True if successful or not needed."""
|
||
system_deps = package.get('system_dependencies')
|
||
if not system_deps:
|
||
return True # No dependencies needed
|
||
|
||
display_name = package.get('display_name', package['name'])
|
||
check_cmd = system_deps.get('check_command', 'apt')
|
||
|
||
# Check if the package manager is available
|
||
try:
|
||
result = subprocess.run(
|
||
['which', check_cmd],
|
||
capture_output=True,
|
||
timeout=5
|
||
)
|
||
if result.returncode != 0:
|
||
# Package manager not available, skip dependency check
|
||
return True
|
||
except:
|
||
return True
|
||
|
||
# Get the appropriate dependency list based on Debian version
|
||
deps_list = None
|
||
if check_cmd == 'apt':
|
||
# Check Debian version for Bookworm-specific dependencies
|
||
try:
|
||
if os.path.exists('/etc/os-release'):
|
||
with open('/etc/os-release', 'r') as f:
|
||
content = f.read().lower()
|
||
if 'bookworm' in content or 'debian 12' in content:
|
||
deps_list = system_deps.get('debian_bookworm', system_deps.get('debian', []))
|
||
else:
|
||
# Trixie (Debian 13) or newer - use simpler deps
|
||
deps_list = system_deps.get('debian', [])
|
||
except:
|
||
pass
|
||
|
||
# Fallback to debian deps if version detection failed
|
||
if not deps_list:
|
||
deps_list = system_deps.get('debian', [])
|
||
|
||
if not deps_list:
|
||
return True
|
||
|
||
# Check if we need to install any dependencies
|
||
missing_deps = []
|
||
for dep in deps_list:
|
||
try:
|
||
result = subprocess.run(
|
||
['dpkg', '-s', dep],
|
||
capture_output=True,
|
||
timeout=5
|
||
)
|
||
if result.returncode != 0:
|
||
missing_deps.append(dep)
|
||
except:
|
||
# If we can't check, assume it's missing
|
||
missing_deps.append(dep)
|
||
|
||
if not missing_deps:
|
||
return True # All dependencies already installed
|
||
|
||
# Ask user to install dependencies
|
||
if not self.quiet:
|
||
print(f" {YELLOW}ℹ {display_name} requires system packages: {', '.join(missing_deps)}{RESET}")
|
||
|
||
if self.auto_update:
|
||
print(f" {YELLOW}ℹ Please install manually with: sudo apt install {' '.join(missing_deps)}{RESET}")
|
||
print(f" {YELLOW}Skipping {display_name} (missing system dependencies){RESET}")
|
||
return False
|
||
else:
|
||
response = input(f" Install system dependencies with 'sudo apt install'? (y/n): ").strip().lower()
|
||
|
||
if response != 'y':
|
||
print(f" {YELLOW}Skipping {display_name} (dependencies not installed){RESET}")
|
||
return False
|
||
|
||
# Try to install dependencies
|
||
print(f" {CYAN}Installing system dependencies (this may take a while)...{RESET}")
|
||
try:
|
||
# Use longer timeout for Sideband's many dependencies
|
||
timeout = 300 if len(missing_deps) > 10 else 120
|
||
result = subprocess.run(
|
||
['sudo', 'apt', 'install', '-y'] + missing_deps,
|
||
timeout=timeout
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
print(f" {GREEN}✓ System dependencies installed{RESET}")
|
||
return True
|
||
else:
|
||
print(f" {RED}✗ Failed to install system dependencies{RESET}")
|
||
return False
|
||
except subprocess.TimeoutExpired:
|
||
print(f" {RED}✗ Timeout installing system dependencies{RESET}")
|
||
return False
|
||
except Exception as e:
|
||
print(f" {RED}✗ Error installing system dependencies: {e}{RESET}")
|
||
return False
|
||
|
||
return False
|
||
|
||
def install_package_with_retry(self, pip_cmd: list, display_name: str, max_retries: int = 3) -> tuple:
|
||
"""Install package with retry logic for network failures"""
|
||
install_timeout = self.get_install_timeout()
|
||
|
||
for attempt in range(max_retries):
|
||
try:
|
||
if attempt > 0 and not self.quiet:
|
||
print(f" {YELLOW}Retry attempt {attempt + 1}/{max_retries}...{RESET}")
|
||
|
||
result = subprocess.run(
|
||
pip_cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=install_timeout
|
||
)
|
||
|
||
# Check for network errors that warrant a retry
|
||
if result.returncode != 0:
|
||
stderr_lower = result.stderr.lower()
|
||
# Network-related errors that should trigger retry
|
||
network_errors = [
|
||
'connection aborted',
|
||
'connection broken',
|
||
'remote end closed',
|
||
'connection reset',
|
||
'connection refused',
|
||
'timeout',
|
||
'network is unreachable'
|
||
]
|
||
|
||
if any(err in stderr_lower for err in network_errors) and attempt < max_retries - 1:
|
||
if not self.quiet:
|
||
print(f" {YELLOW}Network error detected, retrying...{RESET}")
|
||
continue
|
||
|
||
# Return result (success or non-network failure)
|
||
return (result.returncode, result.stderr)
|
||
|
||
except subprocess.TimeoutExpired:
|
||
if attempt < max_retries - 1:
|
||
if not self.quiet:
|
||
print(f" {YELLOW}Installation timeout, retrying...{RESET}")
|
||
continue
|
||
return (-1, "Installation timeout")
|
||
|
||
except Exception as e:
|
||
return (-1, str(e))
|
||
|
||
# All retries exhausted
|
||
return (result.returncode, result.stderr)
|
||
|
||
def fetch_online_versions(self):
|
||
"""Fetch all online versions (PyPI first, then GitHub)"""
|
||
if not self.quiet:
|
||
print(f"\n{BRIGHT}** Fetching Latest Versions **{RESET}")
|
||
|
||
for package in self.packages:
|
||
display_name = package.get('display_name', package['name'])
|
||
|
||
# Skip if system requirements not met
|
||
if self.should_skip_package(package):
|
||
if not self.quiet and not package.get('online_only'):
|
||
print(f" {YELLOW}−{RESET} {display_name}: Skipped (requires desktop)")
|
||
continue
|
||
|
||
# Try PyPI first if pypi_name is specified
|
||
if package.get('pypi_name'):
|
||
version = self.fetch_pypi_version(package['pypi_name'])
|
||
source = "PyPI"
|
||
else:
|
||
# Fallback to GitHub for packages not on PyPI
|
||
version = self.fetch_github_version(package['url'])
|
||
source = "GitHub"
|
||
|
||
self.online_versions[package['name']] = version
|
||
|
||
if not self.quiet:
|
||
if version:
|
||
print(f" {GREEN}✓{RESET} {display_name}: {CYAN}{version}{RESET} ({source})")
|
||
else:
|
||
print(f" {YELLOW}⚠{RESET} {display_name}: Failed to fetch from {source}")
|
||
|
||
def check_local_versions(self):
|
||
"""Check locally installed versions"""
|
||
if not self.quiet:
|
||
print(f"\n{BRIGHT}** Local Installed Versions **{RESET}")
|
||
|
||
for package in self.packages:
|
||
display_name = package.get('display_name', package['name'])
|
||
|
||
# Skip if system requirements not met
|
||
if self.should_skip_package(package):
|
||
self.local_versions[package['name']] = None
|
||
continue
|
||
|
||
# Skip packages marked as online_only or skip_local_check
|
||
if package.get('skip_local_check') or package.get('online_only'):
|
||
self.local_versions[package['name']] = None
|
||
if not self.quiet and not package.get('online_only'):
|
||
print(f" {YELLOW}−{RESET} {display_name}: Skipped")
|
||
continue
|
||
|
||
# Use pypi_name for pip show if available, otherwise use name
|
||
pip_package_name = package.get('pypi_name') or package['name']
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
["pip", "show", pip_package_name],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
for line in result.stdout.splitlines():
|
||
if "Version:" in line:
|
||
version = line.split(":")[1].strip()
|
||
self.local_versions[package['name']] = version
|
||
if not self.quiet:
|
||
print(f" {GREEN}✓{RESET} {display_name}: {CYAN}{version}{RESET}")
|
||
break
|
||
else:
|
||
self.local_versions[package['name']] = None
|
||
if not self.quiet:
|
||
print(f" {YELLOW}−{RESET} {display_name}: Not installed")
|
||
|
||
except subprocess.TimeoutExpired:
|
||
self.local_versions[package['name']] = None
|
||
if not self.quiet:
|
||
print(f" {YELLOW}⚠{RESET} {display_name}: Check timeout")
|
||
|
||
except Exception as e:
|
||
self.local_versions[package['name']] = None
|
||
if not self.quiet:
|
||
print(f" {RED}✗{RESET} {display_name}: Error checking")
|
||
|
||
def compare_and_update(self):
|
||
"""Compare versions and optionally update packages"""
|
||
if self.check_only:
|
||
print(f"\n{BRIGHT}** Version Comparison (Check Only Mode) **{RESET}")
|
||
else:
|
||
print(f"\n{BRIGHT}** Version Comparison & Update **{RESET}")
|
||
|
||
for package in self.packages:
|
||
# Skip if system requirements not met
|
||
if self.should_skip_package(package):
|
||
continue
|
||
|
||
# Skip certain packages
|
||
if package.get('skip_version_comparison') or package.get('online_only'):
|
||
continue
|
||
|
||
name = package['name']
|
||
display_name = package.get('display_name', name)
|
||
online_version = self.online_versions.get(name)
|
||
local_version = self.local_versions.get(name)
|
||
|
||
# Normalize versions for comparison
|
||
norm_online = self.normalize_version(online_version)
|
||
norm_local = self.normalize_version(local_version)
|
||
|
||
print(f"\n{BRIGHT}{display_name}:{RESET}")
|
||
|
||
# Check if versions could be retrieved
|
||
if online_version is None:
|
||
print(f" {RED}Cannot compare - Online version unavailable{RESET}")
|
||
self.failed.append(display_name)
|
||
continue
|
||
|
||
# Compare versions
|
||
if local_version is None:
|
||
print(f" {YELLOW}Not installed{RESET} (Available: {CYAN}{online_version}{RESET})")
|
||
action = "install"
|
||
should_update = True
|
||
elif norm_local == norm_online:
|
||
print(f" {GREEN}✓ Up to date!{RESET} ({CYAN}{local_version}{RESET})")
|
||
self.already_updated.append(display_name)
|
||
continue
|
||
else:
|
||
print(f" {YELLOW}Update available:{RESET} {local_version} → {CYAN}{online_version}{RESET}")
|
||
action = "update"
|
||
should_update = True
|
||
|
||
# Handle manual install packages
|
||
if package.get('manual_install'):
|
||
print(f" {CYAN}ℹ Please {action} manually from: {package['url']}{RESET}")
|
||
self.skipped.append(display_name)
|
||
continue
|
||
|
||
# Skip if check-only mode
|
||
if self.check_only:
|
||
continue
|
||
|
||
# Handle updates
|
||
if should_update:
|
||
if self.auto_update:
|
||
response = 'y'
|
||
action_verb = "installing" if action == "install" else "updating"
|
||
print(f" {CYAN}Auto-{action_verb}...{RESET}")
|
||
else:
|
||
response = input(f" Do you want to {action} {display_name}? (y/n): ").strip().lower()
|
||
|
||
if response == 'y':
|
||
# Warn about slow compilation on Pi Zero
|
||
if self.system.is_pi_zero and package.get('slow_on_pi_zero'):
|
||
if not self.quiet:
|
||
print(f" {YELLOW}⚠ WARNING: This will take 30+ minutes to compile on Pi Zero!{RESET}")
|
||
if not self.auto_update:
|
||
confirm = input(f" Are you sure you want to continue? (y/n): ").strip().lower()
|
||
if confirm != 'y':
|
||
print(f" {YELLOW}Skipped {display_name}{RESET}")
|
||
self.skipped.append(display_name)
|
||
continue
|
||
|
||
# Check and install system dependencies first
|
||
if not self.install_system_dependencies(package):
|
||
self.skipped.append(display_name)
|
||
continue
|
||
|
||
action_verb = "Installing" if action == "install" else "Updating"
|
||
print(f" {CYAN}{action_verb} {display_name}...{RESET}")
|
||
|
||
# Use pypi_name for pip install if available
|
||
pip_package_name = package.get('pypi_name') or package['name']
|
||
|
||
# Prepare pip command
|
||
pip_cmd = ["pip", "install", "--upgrade", pip_package_name]
|
||
|
||
# Try without --break-system-packages first
|
||
try:
|
||
returncode, stderr = self.install_package_with_retry(pip_cmd, display_name, max_retries=1)
|
||
|
||
# If failed due to externally-managed-environment, retry with flag
|
||
if returncode != 0 and "externally-managed-environment" in stderr:
|
||
if not self.quiet:
|
||
print(f" {YELLOW}System requires --break-system-packages flag, retrying...{RESET}")
|
||
self.needs_break_system = True
|
||
|
||
if self.break_system:
|
||
pip_cmd.append("--break-system-packages")
|
||
else:
|
||
# Ask user for permission if not already given
|
||
if not self.quiet:
|
||
print(f" {YELLOW}This system requires --break-system-packages flag.{RESET}")
|
||
retry = input(f" Retry with --break-system-packages? (y/n): ").strip().lower()
|
||
if retry == 'y':
|
||
pip_cmd.append("--break-system-packages")
|
||
else:
|
||
print(f" {YELLOW}Skipped {display_name} (requires --break-system-packages){RESET}")
|
||
self.skipped.append(display_name)
|
||
continue
|
||
else:
|
||
# In quiet mode with no permission, skip
|
||
self.failed.append(display_name)
|
||
continue
|
||
|
||
# Retry with the flag and network retry support
|
||
if self.system.is_slow_system and not self.quiet:
|
||
print(f" {CYAN}This may take several minutes on your system...{RESET}")
|
||
|
||
# Use more retries for slow/unstable systems
|
||
max_retries = 5 if self.system.is_pi_zero else 3
|
||
returncode, stderr = self.install_package_with_retry(pip_cmd, display_name, max_retries=max_retries)
|
||
|
||
if returncode == 0:
|
||
print(f" {GREEN}✓ {display_name} {action}d successfully!{RESET}")
|
||
self.updated.append(display_name)
|
||
elif returncode == -1 and stderr == "Installation timeout":
|
||
print(f" {RED}✗ {action.capitalize()} timeout for {display_name}{RESET}")
|
||
self.failed.append(display_name)
|
||
else:
|
||
print(f" {RED}✗ Failed to {action} {display_name}{RESET}")
|
||
# Show concise error message
|
||
if stderr:
|
||
# Extract most relevant error line
|
||
error_lines = stderr.strip().split('\n')
|
||
# Look for ERROR: lines
|
||
error_msg = None
|
||
for line in error_lines:
|
||
if 'ERROR:' in line:
|
||
error_msg = line.strip()
|
||
break
|
||
if not error_msg and error_lines:
|
||
# Show last non-empty line
|
||
error_msg = [l for l in error_lines if l.strip()][-1][:150]
|
||
|
||
if error_msg and "externally-managed-environment" not in error_msg:
|
||
print(f" {error_msg}")
|
||
self.failed.append(display_name)
|
||
|
||
except Exception as e:
|
||
print(f" {RED}✗ Error {action}ing {display_name}: {str(e)}{RESET}")
|
||
self.failed.append(display_name)
|
||
else:
|
||
print(f" {YELLOW}Skipped {display_name}{RESET}")
|
||
self.skipped.append(display_name)
|
||
|
||
def print_summary(self):
|
||
"""Print a summary of actions taken"""
|
||
print(f"\n{BRIGHT}=============================================={RESET}")
|
||
print(f"{BRIGHT} SUMMARY{RESET}")
|
||
print(f"{BRIGHT}=============================================={RESET}")
|
||
|
||
if self.already_updated:
|
||
print(f"{GREEN}✓ Up to date:{RESET} {', '.join(self.already_updated)}")
|
||
|
||
if self.updated:
|
||
print(f"{GREEN}✓ Updated:{RESET} {', '.join(self.updated)}")
|
||
|
||
if self.skipped:
|
||
print(f"{YELLOW}− Skipped:{RESET} {', '.join(self.skipped)}")
|
||
|
||
if self.failed:
|
||
print(f"{RED}✗ Failed:{RESET} {', '.join(self.failed)}")
|
||
|
||
if not any([self.updated, self.skipped, self.failed, self.already_updated]):
|
||
print(f"{CYAN}No actions taken.{RESET}")
|
||
|
||
# Suggest --break-system-packages if needed and not used
|
||
if self.needs_break_system and not self.break_system and self.failed:
|
||
print(f"\n{YELLOW}ℹ Tip: Run with --break-system-packages flag to force updates{RESET}")
|
||
print(f" {CYAN}Example: python3 frup.py --break-system-packages --auto{RESET}")
|
||
|
||
# Final status
|
||
print(f"\n{BRIGHT}=============================================={RESET}")
|
||
if self.check_only:
|
||
print(f"{BRIGHT} Check Complete! F.R.U. v1.0 END{RESET}")
|
||
else:
|
||
print(f"{BRIGHT} Update Process Complete! F.R.U. v1.0 END{RESET}")
|
||
print(f"{BRIGHT}=============================================={RESET}")
|
||
|
||
def run(self):
|
||
"""Main execution flow"""
|
||
self.print_header()
|
||
|
||
# Fetch all versions
|
||
self.fetch_online_versions()
|
||
self.check_local_versions()
|
||
|
||
# Compare and potentially update
|
||
self.compare_and_update()
|
||
|
||
# Show summary
|
||
self.print_summary()
|
||
|
||
|
||
def main():
|
||
"""Main entry point with argument parsing"""
|
||
parser = argparse.ArgumentParser(
|
||
description='Fast Reticulum Updater v1.0 - Update Reticulum ecosystem packages',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
frup.py # Interactive mode
|
||
frup.py --auto # Auto-update all packages
|
||
frup.py --check-only # Only check versions without updating
|
||
frup.py --quiet --auto # Silent auto-update
|
||
frup.py --save-config # Save example config file
|
||
frup.py -b --auto # Auto-update with --break-system-packages
|
||
|
||
Changes in v1.0:
|
||
- Added system detection (OS, architecture, desktop environment)
|
||
- Automatically skips desktop-only packages (like Sideband) on headless systems
|
||
- Detects Termux, Raspbian, Pi Zero, and other special environments
|
||
- Shows system information before update checks
|
||
- Extended timeout for slow systems (Pi Zero gets 5 minutes)
|
||
- Automatic retry on network failures (up to 5 times on Pi Zero)
|
||
- System dependency detection and installation (e.g., codec2 for LXST)
|
||
"""
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--auto', '-a',
|
||
action='store_true',
|
||
help='Automatically update all packages without prompting'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--check-only', '-c',
|
||
action='store_true',
|
||
help='Only check versions without updating'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--quiet', '-q',
|
||
action='store_true',
|
||
help='Minimal output (errors and summary only)'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--save-config',
|
||
action='store_true',
|
||
help='Save an example configuration file and exit'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--break-system-packages', '-b',
|
||
action='store_true',
|
||
help='Use --break-system-packages flag for pip (required on some systems)'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--version', '-v',
|
||
action='version',
|
||
version='Fast Reticulum Updater v1.0'
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Create updater instance
|
||
updater = ReticulumUpdater(
|
||
auto_update=args.auto,
|
||
quiet=args.quiet,
|
||
check_only=args.check_only,
|
||
break_system=args.break_system_packages
|
||
)
|
||
|
||
# Handle config save
|
||
if args.save_config:
|
||
updater.save_example_config()
|
||
sys.exit(0)
|
||
|
||
try:
|
||
# Run the updater
|
||
updater.run()
|
||
except KeyboardInterrupt:
|
||
print(f"\n{YELLOW}Interrupted by user{RESET}")
|
||
sys.exit(1)
|
||
except Exception as e:
|
||
print(f"\n{RED}Unexpected error: {e}{RESET}")
|
||
sys.exit(1)
|
||
|
||
# Wait for user input before exiting (unless in quiet mode)
|
||
if not args.quiet:
|
||
print()
|
||
print("------------- Press ENTER to exit... ---------------")
|
||
input()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|