Files
Jackify/jackify/backend/services/update_service.py
2026-04-20 20:57:23 +01:00

566 lines
22 KiB
Python

"""
Update service for checking and applying Jackify updates.
This service handles checking for updates via GitHub releases API
and coordinating the update process.
"""
import logging
import os
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Callable
import requests
from ...shared.appimage_utils import get_appimage_path, is_appimage, can_self_update
logger = logging.getLogger(__name__)
@dataclass
class UpdateInfo:
"""Information about an available update."""
version: str
tag_name: str
release_date: str
changelog: str
download_url: str
source: str = "github"
file_size: Optional[int] = None
is_critical: bool = False
is_delta_update: bool = False
github_download_url: Optional[str] = None
class UpdateService:
"""Service for checking and applying Jackify updates."""
def __init__(self, current_version: str):
"""
Initialize the update service.
Args:
current_version: Current version of Jackify (e.g. "0.1.1")
"""
self.current_version = current_version
self.github_repo = "Omni-guides/Jackify"
self.github_api_base = "https://api.github.com"
self.update_check_timeout = 10 # seconds
def check_for_updates(self) -> Optional[UpdateInfo]:
"""
Check for available updates via GitHub releases API.
Returns:
UpdateInfo if update available, None otherwise
"""
try:
url = f"{self.github_api_base}/repos/{self.github_repo}/releases/latest"
headers = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': f'Jackify/{self.current_version}'
}
logger.debug(f"Checking for updates at {url}")
response = requests.get(url, headers=headers, timeout=self.update_check_timeout)
response.raise_for_status()
release_data = response.json()
latest_version = release_data['tag_name'].lstrip('v')
if self._is_newer_version(latest_version):
# Check if this version was skipped
if self._is_version_skipped(latest_version):
logger.debug(f"Version {latest_version} was skipped by user")
return None
# Find AppImage asset (prefer delta update if available)
download_url = None
file_size = None
# Look for delta update first (smaller download)
for asset in release_data.get('assets', []):
if asset['name'].endswith('.AppImage.delta') or 'delta' in asset['name'].lower():
download_url = asset['browser_download_url']
file_size = asset['size']
logger.debug(f"Found delta update: {asset['name']} ({file_size} bytes)")
break
# Fallback to full AppImage if no delta available
if not download_url:
for asset in release_data.get('assets', []):
if asset['name'].endswith('.AppImage'):
download_url = asset['browser_download_url']
file_size = asset['size']
logger.debug(f"Found full AppImage: {asset['name']} ({file_size} bytes)")
break
if download_url:
github_url = download_url
nexus_url = self._try_nexus_download_url(latest_version)
update_source = "github"
if nexus_url:
download_url = nexus_url
update_source = "nexus"
logger.info("Update source: Nexus CDN (version %s)", latest_version)
else:
logger.info("Update source: GitHub Releases (version %s)", latest_version)
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
try:
safe_version = str(latest_version) if latest_version else ""
safe_tag = str(release_data.get('tag_name', ''))
safe_date = str(release_data.get('published_at', ''))
safe_changelog = str(release_data.get('body', ''))[:1000]
safe_url = str(download_url)
logger.debug(f"Creating UpdateInfo for version {safe_version}")
update_info = UpdateInfo(
version=safe_version,
tag_name=safe_tag,
release_date=safe_date,
changelog=safe_changelog,
download_url=safe_url,
file_size=file_size,
is_delta_update=is_delta,
source=update_source,
github_download_url=str(github_url),
)
logger.debug(f"UpdateInfo created successfully")
return update_info
except Exception as e:
logger.error(f"Failed to create UpdateInfo: {e}")
return None
else:
logger.warning(f"No AppImage found in release {latest_version}")
return None
except requests.RequestException as e:
logger.error(f"Failed to check for updates: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error checking for updates: {e}")
return None
_NEXUS_MOD_ID = 1427
def _try_nexus_download_url(self, target_version: str) -> Optional[str]:
"""
If the user is Nexus Premium, query the Nexus files list for the mod
and return a CDN download URL for the file matching target_version.
Returns None on any failure or if the version is not yet on Nexus.
"""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
if ConfigHandler().get('force_github_updates', False):
logger.info("Nexus update source bypassed: force_github_updates is enabled")
return None
except Exception:
pass
try:
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
token = auth_service.get_auth_token()
if not token:
logger.info("Nexus update lookup skipped: no auth token")
return None
auth_method = auth_service.get_auth_method()
is_oauth = auth_method == "oauth"
from jackify.backend.services.nexus_premium_service import NexusPremiumService
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
if not is_premium:
logger.info("Nexus update lookup skipped: not Premium")
return None
auth_headers = {"Accept": "application/json"}
if is_oauth:
auth_headers["Authorization"] = f"Bearer {token}"
else:
auth_headers["apikey"] = token
files_url = f"https://api.nexusmods.com/v1/games/site/mods/{self._NEXUS_MOD_ID}/files.json"
resp = requests.get(files_url, headers=auth_headers, timeout=8)
resp.raise_for_status()
files = resp.json().get("files", [])
# Prefer MAIN category; accept any non-archived/removed file matching the version.
match = None
for f in files:
if f.get("version") != target_version:
continue
if f.get("category_name") == "MAIN":
match = f
break
if f.get("category_name") not in ("ARCHIVED", "REMOVED"):
match = match or f
if match is None:
logger.info("Nexus update lookup: version %s not found on Nexus", target_version)
return None
nexus_file_id = match["file_id"]
dl_url = f"https://api.nexusmods.com/v1/games/site/mods/{self._NEXUS_MOD_ID}/files/{nexus_file_id}/download_link.json"
resp = requests.get(dl_url, headers=auth_headers, timeout=8)
resp.raise_for_status()
links = resp.json()
if isinstance(links, list) and links:
cdn_url = links[0].get("URI")
if cdn_url:
logger.info("Nexus update CDN link obtained for version %s (file_id=%s)", target_version, nexus_file_id)
return cdn_url
logger.info("Nexus update lookup: empty download links for version %s", target_version)
except Exception as e:
logger.info("Nexus update lookup failed, falling back to GitHub: %s", e)
return None
def _is_newer_version(self, version: str) -> bool:
"""
Compare versions to determine if update is newer.
Args:
version: Version to compare against current
Returns:
bool: True if version is newer than current
"""
try:
# Simple version comparison for semantic versioning
def version_tuple(v):
return tuple(map(int, v.split('.')))
return version_tuple(version) > version_tuple(self.current_version)
except ValueError:
logger.warning(f"Could not parse version: {version}")
return False
def _is_version_skipped(self, version: str) -> bool:
"""
Check if a version was skipped by the user.
Args:
version: Version to check
Returns:
bool: True if version was skipped, False otherwise
"""
try:
from ...backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
skipped_versions = config_handler.get('skipped_versions', [])
return version in skipped_versions
except Exception as e:
logger.warning(f"Error checking skipped versions: {e}")
return False
def check_for_updates_async(self, callback: Callable[[Optional[UpdateInfo]], None]) -> None:
"""
Check for updates in background thread.
Args:
callback: Function to call with update info (or None)
"""
def check_worker():
try:
update_info = self.check_for_updates()
logger.debug(f"check_worker: Received update_info: {update_info}")
logger.debug(f"check_worker: About to call callback...")
callback(update_info)
logger.debug(f"check_worker: Callback completed")
except Exception as e:
logger.error(f"Error in background update check: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
callback(None)
thread = threading.Thread(target=check_worker, daemon=True)
thread.start()
def can_update(self) -> bool:
"""
Check if updating is possible in current environment.
Returns:
bool: True if updating is possible
"""
if not is_appimage():
logger.debug("Not running as AppImage - updates not supported")
return False
appimage_path = get_appimage_path()
if not appimage_path:
logger.debug("AppImage path validation failed - updates not supported")
return False
if not can_self_update():
logger.debug("Cannot write to AppImage - updates not possible")
return False
logger.debug(f"Self-updating enabled for AppImage: {appimage_path}")
return True
def download_update(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Download update AppImage. Falls back to GitHub if the primary source fails.
"""
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
result = self._download_update_manual(update_info, progress_callback)
if result:
logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
return result
# Primary source failed - fall back to GitHub if we came from Nexus
if update_info.source == "nexus" and update_info.github_download_url:
logger.warning("Nexus download failed, falling back to GitHub")
fallback = UpdateInfo(
version=update_info.version,
tag_name=update_info.tag_name,
release_date=update_info.release_date,
changelog=update_info.changelog,
download_url=update_info.github_download_url,
source="github",
file_size=update_info.file_size,
is_delta_update=False,
github_download_url=update_info.github_download_url,
)
result = self._download_update_manual(fallback, progress_callback)
if result:
logger.info("Update download complete via GitHub fallback: %s -> %s", update_info.version, result)
return result
logger.error("Update download failed: %s", update_info.version)
return None
def _download_update_manual(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Fallback manual download method.
Args:
update_info: Information about the update to download
progress_callback: Optional callback for download progress
Returns:
Path to downloaded file, or None if download failed
"""
try:
logger.info("Downloading update %s from %s (%s)", update_info.version, update_info.source, update_info.download_url)
response = requests.get(update_info.download_url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
# Create update directory in user's data directory
from jackify.shared.paths import get_jackify_data_dir
update_dir = get_jackify_data_dir() / "updates"
update_dir.mkdir(parents=True, exist_ok=True)
# Nexus delivers a .7z archive; GitHub delivers the AppImage directly.
# Detect which we have after download, then handle accordingly.
# Saving as .7z avoids any path collision with the final .AppImage name.
archive_file = update_dir / f"Jackify-{update_info.version}.7z"
temp_file = update_dir / f"Jackify-{update_info.version}.AppImage"
with open(archive_file, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if progress_callback:
progress_callback(downloaded_size, total_size)
if self._is_7z_archive(archive_file):
logger.info("Downloaded file is a 7z archive, extracting AppImage")
extracted = self._extract_appimage_from_7z(archive_file, update_dir, update_info.version)
archive_file.unlink(missing_ok=True)
if not extracted:
logger.error("Failed to extract AppImage from 7z archive")
return None
temp_file = extracted
else:
archive_file.rename(temp_file)
# Make executable
temp_file.chmod(0o755)
logger.info("Update downloaded successfully: %s from %s -> %s", update_info.version, update_info.source, temp_file)
return temp_file
except Exception as e:
logger.error(f"Failed to download update manually: {e}")
return None
def _is_7z_archive(self, path: Path) -> bool:
"""Detect 7z archive by magic bytes (37 7A BC AF 27 1C)."""
try:
with open(path, 'rb') as f:
magic = f.read(6)
return magic == b'7z\xbc\xaf\x27\x1c'
except Exception:
return False
def _get_bundled_7z_path(self) -> Optional[Path]:
"""Return path to bundled 7z binary (AppImage or dev)."""
import os
candidates = []
appdir = os.environ.get('APPDIR')
if appdir:
candidates.append(Path(appdir) / 'opt' / 'jackify' / 'tools' / '7z')
candidates.append(Path(__file__).parent.parent.parent / 'tools' / '7z')
for p in candidates:
if p.exists() and os.access(p, os.X_OK):
return p
return None
def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]:
"""Extract AppImage from a 7z archive into dest_dir."""
seven_z = self._get_bundled_7z_path()
if not seven_z:
logger.error("Bundled 7z not found, cannot extract update archive")
return None
out_path = dest_dir / f"Jackify-{version}.AppImage"
if out_path.exists():
out_path.unlink()
tmp_dir = Path(tempfile.mkdtemp(dir=dest_dir))
try:
result = subprocess.run(
[str(seven_z), 'e', str(archive), f'-o{tmp_dir}', '-y'],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip())
return None
candidates = list(tmp_dir.glob('*.AppImage'))
if not candidates:
logger.error("No .AppImage found in archive contents: %s",
[p.name for p in tmp_dir.iterdir()])
return None
extracted = candidates[0]
logger.debug("Found %s in archive (%d bytes)", extracted.name, extracted.stat().st_size)
shutil.move(str(extracted), str(out_path))
if not out_path.exists():
logger.error("AppImage missing after move to %s", out_path)
return None
logger.info("Extracted AppImage to %s (%d bytes)", out_path, out_path.stat().st_size)
return out_path
except Exception as e:
logger.error("Exception during 7z extraction: %s", e)
return None
finally:
shutil.rmtree(str(tmp_dir), ignore_errors=True)
def apply_update(self, new_appimage_path: Path) -> bool:
"""
Apply update by replacing current AppImage.
Creates a helper script that waits for Jackify to exit,
then replaces the AppImage and restarts it.
"""
current_appimage = get_appimage_path()
if not current_appimage:
logger.error("Cannot determine current AppImage path")
return False
try:
helper_script = self._create_update_helper(current_appimage, new_appimage_path)
if helper_script:
logger.info("Applying update: replacing %s with %s", current_appimage, new_appimage_path)
subprocess.Popen(['nohup', 'bash', str(helper_script)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return True
return False
except Exception as e:
logger.error(f"Failed to apply update: {e}")
return False
def _create_update_helper(self, current_appimage: Path, new_appimage: Path) -> Optional[Path]:
"""Create helper script that replaces the AppImage after Jackify exits."""
try:
from jackify.shared.paths import get_jackify_data_dir
update_dir = get_jackify_data_dir() / "updates"
update_dir.mkdir(parents=True, exist_ok=True)
helper_script = update_dir / "update_helper.sh"
script_content = f'''#!/bin/bash
# Jackify Update Helper Script
CURRENT_APPIMAGE="{current_appimage}"
NEW_APPIMAGE="{new_appimage}"
TEMP_NAME="$CURRENT_APPIMAGE.updating"
echo "Jackify Update Helper"
echo "Waiting for Jackify to exit..."
sleep 5
echo "Validating new AppImage..."
if [ ! -f "$NEW_APPIMAGE" ]; then
echo "ERROR: New AppImage not found: $NEW_APPIMAGE"
exit 1
fi
if ! timeout 10 "$NEW_APPIMAGE" --version >/dev/null 2>&1; then
echo "ERROR: New AppImage failed validation test"
exit 1
fi
echo "New AppImage validated successfully"
echo "Performing safe replacement..."
if [ -f "$CURRENT_APPIMAGE" ]; then
cp "$CURRENT_APPIMAGE" "$CURRENT_APPIMAGE.backup"
fi
if cp "$NEW_APPIMAGE" "$TEMP_NAME"; then
chmod +x "$TEMP_NAME"
if mv "$TEMP_NAME" "$CURRENT_APPIMAGE"; then
echo "Update completed successfully!"
rm -f "$NEW_APPIMAGE"
rm -f "$CURRENT_APPIMAGE.backup"
echo "Restarting Jackify..."
sleep 1
exec "$CURRENT_APPIMAGE"
else
echo "ERROR: Failed to move updated AppImage"
rm -f "$TEMP_NAME"
if [ -f "$CURRENT_APPIMAGE.backup" ]; then
mv "$CURRENT_APPIMAGE.backup" "$CURRENT_APPIMAGE"
fi
exit 1
fi
else
echo "ERROR: Failed to copy new AppImage"
exit 1
fi
rm -f "{helper_script}"
'''
with open(helper_script, 'w') as f:
f.write(script_content)
helper_script.chmod(0o755)
logger.debug(f"Created update helper script: {helper_script}")
return helper_script
except Exception as e:
logger.error(f"Failed to create update helper script: {e}")
return None