mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Sync from development - prepare for v0.1.2
This commit is contained in:
315
jackify/backend/services/update_service.py
Normal file
315
jackify/backend/services/update_service.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Update service for checking and applying Jackify updates.
|
||||
|
||||
This service handles checking for updates via GitHub releases API
|
||||
and coordinating the update process.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
file_size: Optional[int] = None
|
||||
is_critical: bool = False
|
||||
|
||||
|
||||
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):
|
||||
# Find AppImage asset
|
||||
download_url = None
|
||||
file_size = None
|
||||
|
||||
for asset in release_data.get('assets', []):
|
||||
if asset['name'].endswith('.AppImage'):
|
||||
download_url = asset['browser_download_url']
|
||||
file_size = asset['size']
|
||||
break
|
||||
|
||||
if download_url:
|
||||
return UpdateInfo(
|
||||
version=latest_version,
|
||||
tag_name=release_data['tag_name'],
|
||||
release_date=release_data['published_at'],
|
||||
changelog=release_data.get('body', ''),
|
||||
download_url=download_url,
|
||||
file_size=file_size
|
||||
)
|
||||
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
|
||||
|
||||
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 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()
|
||||
callback(update_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background update check: {e}")
|
||||
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
|
||||
|
||||
if not can_self_update():
|
||||
logger.debug("Cannot write to AppImage - updates not possible")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def download_update(self, update_info: UpdateInfo,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
|
||||
"""
|
||||
Download update to temporary location.
|
||||
|
||||
Args:
|
||||
update_info: Information about the update to download
|
||||
progress_callback: Optional callback for download progress (bytes_downloaded, total_bytes)
|
||||
|
||||
Returns:
|
||||
Path to downloaded file, or None if download failed
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Downloading update {update_info.version} from {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 temporary file
|
||||
temp_dir = Path(tempfile.gettempdir()) / "jackify_updates"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
temp_file = temp_dir / f"Jackify-{update_info.version}.AppImage"
|
||||
|
||||
with open(temp_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)
|
||||
|
||||
# Make executable
|
||||
temp_file.chmod(0o755)
|
||||
|
||||
logger.info(f"Update downloaded successfully to {temp_file}")
|
||||
return temp_file
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download update: {e}")
|
||||
return None
|
||||
|
||||
def apply_update(self, new_appimage_path: Path) -> bool:
|
||||
"""
|
||||
Apply update by replacing current AppImage.
|
||||
|
||||
This creates a helper script that waits for Jackify to exit,
|
||||
then replaces the AppImage and restarts it.
|
||||
|
||||
Args:
|
||||
new_appimage_path: Path to downloaded update
|
||||
|
||||
Returns:
|
||||
bool: True if update application was initiated successfully
|
||||
"""
|
||||
current_appimage = get_appimage_path()
|
||||
if not current_appimage:
|
||||
logger.error("Cannot determine current AppImage path")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create update helper script
|
||||
helper_script = self._create_update_helper(current_appimage, new_appimage_path)
|
||||
|
||||
if helper_script:
|
||||
# Launch helper script and exit
|
||||
logger.info("Launching update helper and exiting")
|
||||
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 for update replacement.
|
||||
|
||||
Args:
|
||||
current_appimage: Path to current AppImage
|
||||
new_appimage: Path to new AppImage
|
||||
|
||||
Returns:
|
||||
Path to helper script, or None if creation failed
|
||||
"""
|
||||
try:
|
||||
temp_dir = Path(tempfile.gettempdir()) / "jackify_updates"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
helper_script = temp_dir / "update_helper.sh"
|
||||
|
||||
script_content = f'''#!/bin/bash
|
||||
# Jackify Update Helper Script
|
||||
# This script replaces the current AppImage with the new version
|
||||
|
||||
CURRENT_APPIMAGE="{current_appimage}"
|
||||
NEW_APPIMAGE="{new_appimage}"
|
||||
|
||||
echo "Jackify Update Helper"
|
||||
echo "Waiting for Jackify to exit..."
|
||||
|
||||
# Wait for Jackify to exit (give it a few seconds)
|
||||
sleep 3
|
||||
|
||||
echo "Replacing AppImage..."
|
||||
|
||||
# Backup current version (optional)
|
||||
if [ -f "$CURRENT_APPIMAGE" ]; then
|
||||
cp "$CURRENT_APPIMAGE" "$CURRENT_APPIMAGE.backup"
|
||||
fi
|
||||
|
||||
# Replace with new version
|
||||
if cp "$NEW_APPIMAGE" "$CURRENT_APPIMAGE"; then
|
||||
chmod +x "$CURRENT_APPIMAGE"
|
||||
echo "Update completed successfully!"
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f "$NEW_APPIMAGE"
|
||||
|
||||
# Restart Jackify
|
||||
echo "Restarting Jackify..."
|
||||
exec "$CURRENT_APPIMAGE"
|
||||
else
|
||||
echo "Update failed - could not replace AppImage"
|
||||
# Restore backup if replacement failed
|
||||
if [ -f "$CURRENT_APPIMAGE.backup" ]; then
|
||||
mv "$CURRENT_APPIMAGE.backup" "$CURRENT_APPIMAGE"
|
||||
echo "Restored original AppImage"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up this script
|
||||
rm -f "{helper_script}"
|
||||
'''
|
||||
|
||||
with open(helper_script, 'w') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# Make executable
|
||||
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
|
||||
Reference in New Issue
Block a user