mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 22:47:45 +02:00
224 lines
8.1 KiB
Python
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
|