Files
Jackify/jackify/backend/services/mo2_setup_service.py
2026-03-13 14:43:25 +00:00

224 lines
8.1 KiB
Python

"""
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
import tempfile
import time
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 _extract_archive(
self,
archive_path: Path,
install_dir: Path,
should_cancel: Optional[Callable[[], bool]] = None,
) -> Tuple[bool, Optional[str]]:
"""Extract the MO2 archive without interactive prompts and honor cancellation."""
process = None
try:
process = subprocess.Popen(
['7z', 'x', '-y', '-aoa', str(archive_path), f'-o{install_dir}'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
while True:
if should_cancel and should_cancel():
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
return False, "MO2 setup cancelled."
returncode = process.poll()
if returncode is not None:
stdout, stderr = process.communicate()
if returncode != 0:
err = (stderr or stdout or "").strip()
return False, f"Extraction failed: {err or '7z returned a non-zero exit code.'}"
return True, None
time.sleep(0.1)
except Exception as e:
if process is not None:
try:
process.kill()
except Exception:
pass
return False, f"Extraction failed: {e}"
def setup_mo2(
self,
install_dir: Path,
shortcut_name: str = "Mod Organizer 2",
existing_appid: Optional[int] = None,
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
_progress(f"Downloading {asset['name']}...")
if _cancel_requested():
return False, None, "MO2 setup cancelled."
try:
with tempfile.NamedTemporaryFile(prefix="jackify-mo2-", suffix=".7z", delete=False) as tmp_file:
archive_path = Path(tmp_file.name)
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():
try:
archive_path.unlink(missing_ok=True)
except Exception:
pass
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."
extract_ok, extract_error = self._extract_archive(archive_path, install_dir, should_cancel)
if not extract_ok:
try:
archive_path.unlink(missing_ok=True)
except Exception:
pass
return False, None, extract_error
# 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()
if existing_appid is not None:
app_id = int(existing_appid)
_progress(f"Reusing existing Steam shortcut with AppID: {app_id}")
prefix_path = svc.get_prefix_path(app_id)
if prefix_path is None:
if not svc.create_prefix_with_proton_wrapper(app_id):
return False, None, "Failed to create Proton prefix for existing shortcut."
prefix_path = svc.get_prefix_path(app_id)
success = True
else:
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