Sync from development - prepare for v0.1.2

This commit is contained in:
Omni
2025-09-14 21:54:18 +01:00
parent c20a27dd90
commit 0b6e32beac
11 changed files with 833 additions and 4874 deletions

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.1.0.1"
__version__ = "0.1.2"

View File

@@ -37,8 +37,8 @@ def get_latest_release_info():
def get_current_version():
# This should match however Jackify stores its version
try:
from src import version
return version.__version__
from jackify import __version__
return __version__
except ImportError:
return None

View 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

View File

@@ -174,13 +174,103 @@ class JackifyCLI:
Returns:
Dictionary of backend service instances
"""
# For now, create a basic modlist service
# TODO: Add other services as needed
# Initialize update service
from jackify.backend.services.update_service import UpdateService
update_service = UpdateService(jackify_version)
services = {
'modlist_service': ModlistService(self.system_info)
'modlist_service': ModlistService(self.system_info),
'update_service': update_service
}
return services
def _check_for_updates_on_startup(self):
"""Check for updates on startup in background thread"""
try:
self._debug_print("Checking for updates on startup...")
def update_check_callback(update_info):
"""Handle update check results"""
try:
if update_info:
print(f"\n{COLOR_INFO}Update available: v{update_info.version}{COLOR_RESET}")
print(f"Current version: v{jackify_version}")
print(f"Release date: {update_info.release_date}")
if update_info.changelog:
print(f"Changelog: {update_info.changelog[:200]}...")
print(f"Download size: {update_info.file_size / (1024*1024):.1f} MB" if update_info.file_size else "Download size: Unknown")
print(f"\nTo update, run: jackify --update")
print("Or visit: https://github.com/Omni-guides/Jackify/releases")
else:
self._debug_print("No updates available")
except Exception as e:
self._debug_print(f"Error showing update info: {e}")
# Check for updates in background
self.backend_services['update_service'].check_for_updates_async(update_check_callback)
except Exception as e:
self._debug_print(f"Error checking for updates on startup: {e}")
# Continue anyway - don't block startup on update check errors
def _handle_update(self):
"""Handle manual update check and installation"""
try:
print("Checking for updates...")
update_service = self.backend_services['update_service']
# Check if updating is possible
if not update_service.can_update():
print(f"{COLOR_ERROR}Update not possible: not running as AppImage or insufficient permissions{COLOR_RESET}")
return 1
# Check for updates
update_info = update_service.check_for_updates()
if update_info:
print(f"{COLOR_INFO}Update available: v{update_info.version}{COLOR_RESET}")
print(f"Current version: v{jackify_version}")
print(f"Release date: {update_info.release_date}")
if update_info.changelog:
print(f"Changelog: {update_info.changelog}")
print(f"Download size: {update_info.file_size / (1024*1024):.1f} MB" if update_info.file_size else "Download size: Unknown")
# Ask for confirmation
response = input("\nDo you want to download and install this update? (y/N): ").strip().lower()
if response in ['y', 'yes']:
print("Downloading update...")
def progress_callback(downloaded, total):
if total > 0:
percentage = int((downloaded / total) * 100)
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
print(f"\rDownloaded {downloaded_mb:.1f} MB of {total_mb:.1f} MB ({percentage}%)", end='', flush=True)
downloaded_path = update_service.download_update(update_info, progress_callback)
if downloaded_path:
print(f"\nDownload completed. Installing update...")
if update_service.apply_update(downloaded_path):
print(f"{COLOR_INFO}Update applied successfully! Jackify will restart...{COLOR_RESET}")
return 0
else:
print(f"{COLOR_ERROR}Failed to apply update{COLOR_RESET}")
return 1
else:
print(f"\n{COLOR_ERROR}Failed to download update{COLOR_RESET}")
return 1
else:
print("Update cancelled.")
return 0
else:
print(f"{COLOR_INFO}You are already running the latest version (v{jackify_version}){COLOR_RESET}")
return 0
except Exception as e:
print(f"{COLOR_ERROR}Update failed: {e}{COLOR_RESET}")
return 1
def _initialize_command_handlers(self):
"""Initialize command handler instances.
@@ -271,6 +361,11 @@ class JackifyCLI:
self._debug_print('JackifyCLI.run() called')
self._debug_print(f'Parsed args: {self.args}')
# Handle update functionality
if getattr(self.args, 'update', False):
self._debug_print('Entering update workflow')
return self._handle_update()
# Handle legacy restart-steam functionality (temporary)
if getattr(self.args, 'restart_steam', False):
self._debug_print('Entering restart_steam workflow')
@@ -290,6 +385,9 @@ class JackifyCLI:
if getattr(self.args, 'command', None):
return self._run_command(self.args.command, self.args)
# Check for updates on startup (non-blocking)
self._check_for_updates_on_startup()
# Run interactive mode (legacy for now)
self._run_interactive()
@@ -303,6 +401,7 @@ class JackifyCLI:
parser.add_argument("--resolution", type=str, help="Resolution to set (optional)")
parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)')
parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)')
parser.add_argument('--update', action='store_true', help='Check for and install updates')
# Add command-specific arguments
self.commands['tuxborn'].add_args(parser)

View File

@@ -0,0 +1,293 @@
"""
Update notification and download dialog for Jackify.
This dialog handles notifying users about available updates and
managing the download/installation process.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QProgressBar, QGroupBox, QCheckBox
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QPixmap, QFont
from ....backend.services.update_service import UpdateService, UpdateInfo
logger = logging.getLogger(__name__)
class UpdateDownloadThread(QThread):
"""Background thread for downloading updates."""
progress_updated = Signal(int, int) # downloaded, total
download_finished = Signal(object) # Path or None
def __init__(self, update_service: UpdateService, update_info: UpdateInfo):
super().__init__()
self.update_service = update_service
self.update_info = update_info
self.downloaded_path = None
def run(self):
"""Download the update in background."""
try:
def progress_callback(downloaded: int, total: int):
self.progress_updated.emit(downloaded, total)
self.downloaded_path = self.update_service.download_update(
self.update_info, progress_callback
)
self.download_finished.emit(self.downloaded_path)
except Exception as e:
logger.error(f"Error in download thread: {e}")
self.download_finished.emit(None)
class UpdateDialog(QDialog):
"""Dialog for notifying users about updates and handling downloads."""
def __init__(self, update_info: UpdateInfo, update_service: UpdateService, parent=None):
super().__init__(parent)
self.update_info = update_info
self.update_service = update_service
self.downloaded_path = None
self.download_thread = None
self.setup_ui()
self.setup_connections()
def setup_ui(self):
"""Set up the dialog UI."""
self.setWindowTitle("Jackify Update Available")
self.setModal(True)
self.setMinimumSize(500, 400)
self.setMaximumSize(600, 600)
layout = QVBoxLayout(self)
# Header
header_layout = QHBoxLayout()
# Update icon (if available)
icon_label = QLabel()
icon_label.setText("🔄") # Simple emoji for now
icon_label.setStyleSheet("font-size: 32px;")
header_layout.addWidget(icon_label)
# Update title
title_layout = QVBoxLayout()
title_label = QLabel(f"Update Available: v{self.update_info.version}")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
title_layout.addWidget(title_label)
subtitle_label = QLabel(f"Current version: v{self.update_service.current_version}")
subtitle_label.setStyleSheet("color: #666;")
title_layout.addWidget(subtitle_label)
header_layout.addLayout(title_layout)
header_layout.addStretch()
layout.addLayout(header_layout)
# File size info
if self.update_info.file_size:
size_mb = self.update_info.file_size / (1024 * 1024)
size_label = QLabel(f"Download size: {size_mb:.1f} MB")
size_label.setStyleSheet("color: #666; margin-bottom: 10px;")
layout.addWidget(size_label)
# Changelog group
changelog_group = QGroupBox("What's New")
changelog_layout = QVBoxLayout(changelog_group)
self.changelog_text = QTextEdit()
self.changelog_text.setPlainText(self.update_info.changelog or "No changelog available.")
self.changelog_text.setMaximumHeight(150)
self.changelog_text.setReadOnly(True)
changelog_layout.addWidget(self.changelog_text)
layout.addWidget(changelog_group)
# Progress section (initially hidden)
self.progress_group = QGroupBox("Download Progress")
progress_layout = QVBoxLayout(self.progress_group)
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
progress_layout.addWidget(self.progress_bar)
self.progress_label = QLabel("Preparing download...")
self.progress_label.setVisible(False)
progress_layout.addWidget(self.progress_label)
layout.addWidget(self.progress_group)
self.progress_group.setVisible(False)
# Options
options_group = QGroupBox("Update Options")
options_layout = QVBoxLayout(options_group)
self.auto_restart_checkbox = QCheckBox("Automatically restart Jackify after update")
self.auto_restart_checkbox.setChecked(True)
options_layout.addWidget(self.auto_restart_checkbox)
layout.addWidget(options_group)
# Buttons
button_layout = QHBoxLayout()
self.later_button = QPushButton("Remind Me Later")
self.later_button.clicked.connect(self.remind_later)
button_layout.addWidget(self.later_button)
self.skip_button = QPushButton("Skip This Version")
self.skip_button.clicked.connect(self.skip_version)
button_layout.addWidget(self.skip_button)
button_layout.addStretch()
self.download_button = QPushButton("Download & Install Update")
self.download_button.setDefault(True)
self.download_button.clicked.connect(self.start_download)
button_layout.addWidget(self.download_button)
self.install_button = QPushButton("Install & Restart")
self.install_button.setVisible(False)
self.install_button.clicked.connect(self.install_update)
button_layout.addWidget(self.install_button)
layout.addLayout(button_layout)
# Style the download button
self.download_button.setStyleSheet("""
QPushButton {
background-color: #0d7377;
color: white;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #14a085;
}
""")
def setup_connections(self):
"""Set up signal connections."""
pass
def start_download(self):
"""Start downloading the update."""
if not self.update_service.can_update():
self.show_error("Update not possible",
"Cannot update: not running as AppImage or insufficient permissions.")
return
# Show progress UI
self.progress_group.setVisible(True)
self.progress_bar.setVisible(True)
self.progress_label.setVisible(True)
self.progress_label.setText("Starting download...")
# Disable buttons during download
self.download_button.setEnabled(False)
self.later_button.setEnabled(False)
self.skip_button.setEnabled(False)
# Start download thread
self.download_thread = UpdateDownloadThread(self.update_service, self.update_info)
self.download_thread.progress_updated.connect(self.update_progress)
self.download_thread.download_finished.connect(self.download_completed)
self.download_thread.start()
def update_progress(self, downloaded: int, total: int):
"""Update download progress."""
if total > 0:
percentage = int((downloaded / total) * 100)
self.progress_bar.setValue(percentage)
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
self.progress_label.setText(f"Downloaded {downloaded_mb:.1f} MB of {total_mb:.1f} MB ({percentage}%)")
else:
self.progress_label.setText(f"Downloaded {downloaded / (1024 * 1024):.1f} MB...")
def download_completed(self, downloaded_path: Optional[Path]):
"""Handle download completion."""
if downloaded_path:
self.downloaded_path = downloaded_path
self.progress_label.setText("Download completed successfully!")
self.progress_bar.setValue(100)
# Show install button
self.download_button.setVisible(False)
self.install_button.setVisible(True)
# Re-enable other buttons
self.later_button.setEnabled(True)
self.skip_button.setEnabled(True)
else:
self.show_error("Download Failed", "Failed to download the update. Please try again later.")
# Reset UI
self.progress_group.setVisible(False)
self.download_button.setEnabled(True)
self.later_button.setEnabled(True)
self.skip_button.setEnabled(True)
def install_update(self):
"""Install the downloaded update."""
if not self.downloaded_path:
self.show_error("No Download", "No update has been downloaded.")
return
self.progress_label.setText("Installing update...")
if self.update_service.apply_update(self.downloaded_path):
self.progress_label.setText("Update applied successfully! Jackify will restart...")
# Close dialog and exit application (update helper will restart)
self.accept()
# The update helper script will handle the restart
import sys
sys.exit(0)
else:
self.show_error("Installation Failed", "Failed to apply the update. Please try again.")
def remind_later(self):
"""Close dialog and remind later."""
self.reject()
def skip_version(self):
"""Skip this version (could save preference)."""
# TODO: Save preference to skip this version
self.reject()
def show_error(self, title: str, message: str):
"""Show error message to user."""
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(self, title, message)
def closeEvent(self, event):
"""Handle dialog close event."""
if self.download_thread and self.download_thread.isRunning():
# Cancel download if in progress
self.download_thread.terminate()
self.download_thread.wait()
event.accept()

View File

@@ -528,6 +528,10 @@ class JackifyMainWindow(QMainWindow):
from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService
self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck)
# Initialize update service
from jackify.backend.services.update_service import UpdateService
self.update_service = UpdateService(__version__)
debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
def _is_steamdeck(self):
@@ -735,6 +739,32 @@ class JackifyMainWindow(QMainWindow):
print(f"Error checking protontricks: {e}")
# Continue anyway - don't block startup on detection errors
def _check_for_updates_on_startup(self):
"""Check for updates on startup in background thread"""
try:
debug_print("Checking for updates on startup...")
def update_check_callback(update_info):
"""Handle update check results"""
try:
if update_info:
debug_print(f"Update available: v{update_info.version}")
# Show update dialog
from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog
dialog = UpdateDialog(update_info, self.update_service, self)
dialog.show() # Non-blocking
else:
debug_print("No updates available")
except Exception as e:
debug_print(f"Error showing update dialog: {e}")
# Check for updates in background
self.update_service.check_for_updates_async(update_check_callback)
except Exception as e:
debug_print(f"Error checking for updates on startup: {e}")
# Continue anyway - don't block startup on update check errors
def cleanup_processes(self):
"""Clean up any running processes before closing"""
try:
@@ -843,6 +873,9 @@ def main():
window = JackifyMainWindow(dev_mode=dev_mode)
window.show()
# Start background update check after window is shown
window._check_for_updates_on_startup()
# Ensure cleanup on exit
import atexit
atexit.register(emergency_cleanup)

View File

@@ -0,0 +1,87 @@
"""
AppImage utilities for self-updating functionality.
This module provides utilities for detecting if Jackify is running as an AppImage
and getting the path to the current AppImage file.
"""
import os
import sys
from pathlib import Path
from typing import Optional
def is_appimage() -> bool:
"""
Check if Jackify is currently running as an AppImage.
Returns:
bool: True if running as AppImage, False otherwise
"""
return 'APPIMAGE' in os.environ
def get_appimage_path() -> Optional[Path]:
"""
Get the path to the current AppImage file.
This uses the APPIMAGE environment variable set by the AppImage runtime.
This is the standard, reliable method for AppImage path detection.
Returns:
Optional[Path]: Path to the AppImage file if running as AppImage, None otherwise
"""
if not is_appimage():
return None
appimage_path = os.environ.get('APPIMAGE')
if appimage_path and os.path.exists(appimage_path):
return Path(appimage_path)
return None
def can_self_update() -> bool:
"""
Check if self-updating is possible.
Returns:
bool: True if self-updating is possible, False otherwise
"""
appimage_path = get_appimage_path()
if not appimage_path:
return False
# Check if we can write to the AppImage file (for replacement)
try:
return os.access(appimage_path, os.W_OK)
except (OSError, PermissionError):
return False
def get_appimage_info() -> dict:
"""
Get information about the current AppImage.
Returns:
dict: Information about the AppImage including path, writability, etc.
"""
appimage_path = get_appimage_path()
info = {
'is_appimage': is_appimage(),
'path': appimage_path,
'can_update': can_self_update(),
'size_mb': None,
'writable': False
}
if appimage_path and appimage_path.exists():
try:
stat = appimage_path.stat()
info['size_mb'] = round(stat.st_size / (1024 * 1024), 1)
info['writable'] = os.access(appimage_path, os.W_OK)
except (OSError, PermissionError):
pass
return info