Add one-click panel auto-update from git.evilfox.cc (v2.4.0).

Check Gitea releases in Settings and install the latest tag via git fetch/checkout with pip sync and restart when running from a git checkout.
This commit is contained in:
orohi
2026-07-28 09:28:15 +03:00
parent 6206697e3b
commit fd257a59f7
9 changed files with 451 additions and 17 deletions
+249
View File
@@ -0,0 +1,249 @@
"""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()