"""Panel self-update from the EvilFox Gitea repository.""" from __future__ import annotations import logging import os import re import subprocess import sys import threading import time from typing import Optional logger = logging.getLogger(__name__) DEFAULT_REPO_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main' DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git' DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest' UPDATE_REMOTE = 'panel-update' def _env(name: str, default: str) -> str: return (os.environ.get(name) or default).strip() def repo_url() -> str: return _env('PANEL_UPDATE_REPO_URL', DEFAULT_REPO_URL).rstrip('/') def git_url() -> str: return _env('PANEL_UPDATE_GIT_URL', DEFAULT_GIT_URL) def api_latest_url() -> str: return _env('PANEL_UPDATE_API_LATEST', DEFAULT_API_LATEST) def parse_version(value: str) -> tuple: """Parse semver-ish tags like v2.3.0 / 2.3.0-beta into a comparable tuple.""" raw = (value or '').strip() if not raw: return (0, 0, 0, 1, '') raw = raw.lstrip('vV') main, _, pre = raw.partition('-') parts = [] for piece in main.split('.'): if piece.isdigit(): parts.append(int(piece)) else: m = re.match(r'(\d+)', piece or '') parts.append(int(m.group(1)) if m else 0) while len(parts) < 3: parts.append(0) # release > prerelease: empty pre sorts higher pre_rank = 0 if not pre else 1 return (parts[0], parts[1], parts[2], pre_rank, pre) def version_gt(a: str, b: str) -> bool: return parse_version(a) > parse_version(b) def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]: try: proc = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, encoding='utf-8', errors='replace', ) return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip() except FileNotFoundError: return 127, '', f'Command not found: {cmd[0]}' except subprocess.TimeoutExpired: return 124, '', f'Timeout running: {" ".join(cmd)}' class UpdateManager: def __init__(self, app_root: str, current_version: str): self.app_root = os.path.abspath(app_root) self.current_version = current_version def detect_mode(self) -> dict: frozen = bool(getattr(sys, 'frozen', False)) in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1' git_dir = os.path.join(self.app_root, '.git') is_git = os.path.isdir(git_dir) git_ok = False if is_git and not frozen: code, _, _ = _run(['git', '--version'], self.app_root, timeout=10) git_ok = code == 0 can_auto = bool(git_ok and not frozen) mode = 'git' if can_auto else ('docker' if in_docker else ('binary' if frozen else 'manual')) return { 'mode': mode, 'can_auto_update': can_auto, 'is_git': is_git, 'frozen': frozen, 'in_docker': in_docker, 'app_root': self.app_root, 'repo_url': repo_url(), 'git_url': git_url(), } def _ensure_update_remote(self) -> Optional[str]: code, out, err = _run(['git', 'remote'], self.app_root, timeout=15) if code != 0: return err or out or 'git remote failed' remotes = set((out or '').split()) target = git_url() if UPDATE_REMOTE in remotes: code, _, err = _run( ['git', 'remote', 'set-url', UPDATE_REMOTE, target], self.app_root, timeout=15, ) else: code, _, err = _run( ['git', 'remote', 'add', UPDATE_REMOTE, target], self.app_root, timeout=15, ) if code != 0: return err or 'Failed to configure update remote' return None def working_tree_dirty(self) -> bool: code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30) return code == 0 and bool(out.strip()) def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict: mode = self.detect_mode() if not mode['can_auto_update']: return { 'status': 'error', 'message': ( 'Auto-update requires a git checkout of the panel sources. ' 'Docker/binary installs: pull a new image or download a release manually.' ), 'mode': mode, } target = (target_version or '').strip() if not re.match(r'^v?\d+(\.\d+){0,3}([.-][\w.]+)?$', target): return {'status': 'error', 'message': f'Invalid target version: {target_version}'} if not target.startswith('v'): target = 'v' + target if not allow_dirty and self.working_tree_dirty(): return { 'status': 'error', 'message': ( 'Working tree has local changes. Commit/stash them or set allow_dirty, ' 'then retry auto-update.' ), } err = self._ensure_update_remote() if err: return {'status': 'error', 'message': err} code, out, stderr = _run( ['git', 'fetch', '--tags', '--force', UPDATE_REMOTE], self.app_root, timeout=180, ) if code != 0: return { 'status': 'error', 'message': f'git fetch failed: {stderr or out or code}', } # Prefer annotated/lightweight tag; fall back to remote main tip if tag missing code, _, _ = _run( ['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'], self.app_root, timeout=15, ) if code == 0: checkout_ref = target else: # tag might only exist as FETCH_HEAD / remote tag code2, _, _ = _run( ['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}^{{}}'], self.app_root, timeout=15, ) checkout_ref = target if code2 == 0 else f'tags/{target}' code, out, stderr = _run( ['git', 'checkout', '--force', checkout_ref], self.app_root, timeout=60, ) if code != 0: # last resort: checkout remote main code_m, out_m, err_m = _run( ['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'], self.app_root, timeout=60, ) if code_m != 0: return { 'status': 'error', 'message': f'git checkout {target} failed: {stderr or out or err_m or out_m}', } checkout_ref = f'{UPDATE_REMOTE}/main' req = os.path.join(self.app_root, 'requirements.txt') pip_log = '' if os.path.isfile(req): code_p, out_p, err_p = _run( [sys.executable, '-m', 'pip', 'install', '-r', req], self.app_root, timeout=300, ) pip_log = (out_p or err_p or '')[:1500] if code_p != 0: return { 'status': 'error', 'message': f'pip install failed after checkout: {err_p or out_p or code_p}', 'checkout': checkout_ref, } return { 'status': 'success', 'message': f'Updated to {target}. Panel will restart.', 'target_version': target, 'checkout': checkout_ref, 'pip': pip_log[:500], 'restart': True, } @staticmethod def schedule_restart(delay_sec: float = 1.5) -> None: def _restart(): time.sleep(max(0.5, float(delay_sec))) try: argv = [sys.executable] + sys.argv logger.info('Restarting panel after update: %s', argv) os.execv(sys.executable, argv) except Exception: logger.exception('Failed to restart after update; exiting') os._exit(0) threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()