mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 03:47:44 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
166
jackify/backend/services/mo2_setup_service.py
Normal file
166
jackify/backend/services/mo2_setup_service.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
MO2 Setup Service
|
||||
|
||||
Downloads and configures a standalone Mod Organizer 2 instance:
|
||||
- Fetches latest release from GitHub
|
||||
- Extracts with 7z
|
||||
- Creates a Steam shortcut and Proton prefix via AutomatedPrefixService
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_dangerous_path(path: Path) -> bool:
|
||||
home = Path.home().resolve()
|
||||
dangerous = [Path('/'), Path('/home'), Path('/root'), home]
|
||||
return any(path.resolve() == d for d in dangerous)
|
||||
|
||||
|
||||
class MO2SetupService:
|
||||
"""Download, extract, and configure a standalone MO2 instance."""
|
||||
|
||||
GITHUB_API = "https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest"
|
||||
ASSET_PATTERN = re.compile(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$")
|
||||
|
||||
def setup_mo2(
|
||||
self,
|
||||
install_dir: Path,
|
||||
shortcut_name: str = "Mod Organizer 2",
|
||||
progress_callback: Optional[Callable[[str], None]] = None,
|
||||
should_cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Tuple[bool, Optional[int], Optional[str]]:
|
||||
"""
|
||||
Download, extract, and configure MO2.
|
||||
|
||||
Returns (success, app_id, error_message).
|
||||
"""
|
||||
|
||||
def _progress(msg: str):
|
||||
logger.info(msg)
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
|
||||
def _cancel_requested() -> bool:
|
||||
try:
|
||||
return bool(should_cancel and should_cancel())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not shutil.which('7z'):
|
||||
return False, None, "7z not found. Install p7zip-full (or equivalent) first."
|
||||
|
||||
if _is_dangerous_path(install_dir):
|
||||
return False, None, f"Refusing to install to dangerous path: {install_dir}"
|
||||
|
||||
# Create directory
|
||||
try:
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return False, None, f"Could not create directory: {e}"
|
||||
|
||||
# Fetch release info
|
||||
_progress("Fetching latest MO2 release info...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
resp = requests.get(self.GITHUB_API, timeout=15, verify=True)
|
||||
resp.raise_for_status()
|
||||
release = resp.json()
|
||||
except Exception as e:
|
||||
return False, None, f"Failed to fetch MO2 release info: {e}"
|
||||
|
||||
# Find asset
|
||||
asset = None
|
||||
for a in release.get('assets', []):
|
||||
if self.ASSET_PATTERN.match(a['name']):
|
||||
asset = a
|
||||
break
|
||||
if not asset:
|
||||
return False, None, "Could not find main MO2 .7z asset in latest release."
|
||||
|
||||
# Download
|
||||
archive_path = install_dir / asset['name']
|
||||
_progress(f"Downloading {asset['name']}...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
with requests.get(asset['browser_download_url'], stream=True, timeout=120, verify=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
return False, None, f"Failed to download MO2 archive: {e}"
|
||||
|
||||
# Extract
|
||||
_progress(f"Extracting to {install_dir}...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['7z', 'x', str(archive_path), f'-o{install_dir}'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=1200,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.decode(errors='ignore')
|
||||
return False, None, f"Extraction failed: {err}"
|
||||
except Exception as e:
|
||||
return False, None, f"Extraction failed: {e}"
|
||||
|
||||
# Validate
|
||||
mo2_exe = install_dir / "ModOrganizer.exe"
|
||||
if not mo2_exe.exists():
|
||||
# MO2 release archives usually extract into a single top-level folder.
|
||||
# Limit search depth to direct children to avoid expensive recursive scans.
|
||||
mo2_exe = None
|
||||
for child in install_dir.iterdir():
|
||||
candidate = child / "ModOrganizer.exe"
|
||||
if candidate.exists():
|
||||
mo2_exe = candidate
|
||||
break
|
||||
if not mo2_exe:
|
||||
return False, None, "ModOrganizer.exe not found after extraction."
|
||||
|
||||
# Cleanup archive
|
||||
try:
|
||||
archive_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_progress(f"MO2 installed at: {mo2_exe.parent}")
|
||||
|
||||
# Set up Steam shortcut and Proton prefix
|
||||
_progress("Creating Steam shortcut and Proton prefix...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
from .automated_prefix_service import AutomatedPrefixService
|
||||
svc = AutomatedPrefixService()
|
||||
success, prefix_path, app_id, _last_ts = svc.run_working_workflow(
|
||||
shortcut_name=shortcut_name,
|
||||
modlist_install_dir=str(install_dir),
|
||||
final_exe_path=str(mo2_exe),
|
||||
progress_callback=_progress,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"AutomatedPrefixService failed: {e}")
|
||||
return False, None, f"Prefix setup failed: {e}"
|
||||
|
||||
if not success:
|
||||
return False, None, "Failed to create Steam shortcut or Proton prefix."
|
||||
|
||||
_progress("MO2 setup complete.")
|
||||
return True, app_id, None
|
||||
Reference in New Issue
Block a user