96f24a8b25
Features: - Enable/disable mods (.archive <-> .archive.disabled) - Load order via filename prefixes (000_, 010_, 020_) - Conflict detection - Profile save/load - Nexus Mods nxm:// protocol handler - PyQt6 GUI, Wayland compatible - No REDmod, no FUSE, no Wine prefix
1005 lines
38 KiB
Python
1005 lines
38 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Neon Mod Manager — Cyberpunk 2077 Mod Manager
|
||
==============================================
|
||
Simple, no-nonsense mod manager that:
|
||
- Copies mods directly to archive/pc/mod/ (no VFS, no FUSE, no REDmod)
|
||
- Manages load order via filename prefixes (000_, 010_, 020_)
|
||
- Enable/disable mods by renaming .archive <-> .archive.disabled
|
||
- Detects file conflicts between .archive files
|
||
- Catches nxm:// downloads from Nexus Mods
|
||
- Supports profiles (save/load mod sets)
|
||
|
||
Usage:
|
||
python3 mod_manager.py
|
||
→ GUI opens
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import shutil
|
||
import struct
|
||
import hashlib
|
||
import subprocess
|
||
import urllib.request
|
||
import urllib.parse
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||
QListWidget, QListWidgetItem, QLabel, QPushButton, QFileDialog,
|
||
QTabWidget, QTreeWidget, QTreeWidgetItem, QComboBox, QLineEdit,
|
||
QMessageBox, QProgressBar, QGroupBox, QCheckBox, QSpinBox, QSlider,
|
||
QMenu, QStatusBar, QSplitter, QHeaderView
|
||
)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
|
||
from PyQt6.QtGui import QIcon, QAction, QColor, QFont, QDragEnterEvent, QDropEvent
|
||
|
||
# --- Config ---
|
||
APP_NAME = "Neon Mod Manager"
|
||
APP_VERSION = "0.1.0"
|
||
CONFIG_DIR = Path.home() / ".config" / "neon-mod-manager"
|
||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||
PROFILES_DIR = CONFIG_DIR / "profiles"
|
||
|
||
# Default game path — can be overridden in settings
|
||
DEFAULT_GAME_PATH = "/mnt/Spiele/Heroic/Cyberpunk 2077"
|
||
DEFAULT_MOD_DIR = "archive/pc/mod"
|
||
|
||
# Nexus Mods
|
||
NEXUS_API_KEY_FILE = Path.home() / ".nexus_api_key"
|
||
NEXUS_GAME_ID = "cyberpunk2077"
|
||
NEXUS_API_BASE = "https://api.nexusmods.com/v1"
|
||
|
||
# --- Archive parsing ---
|
||
# CP2077 .archive files are REDengine RAD archives
|
||
# Header: "RDAR" magic, version, file count, file table offset
|
||
|
||
def read_archive_info(path):
|
||
"""Read basic info from a .archive file: file count, size, list of internal file paths."""
|
||
try:
|
||
with open(path, "rb") as f:
|
||
magic = f.read(4)
|
||
if magic != b"RDAR":
|
||
return {"valid": False, "error": "Not a RDAR archive", "files": []}
|
||
|
||
version = struct.unpack("<I", f.read(4))[0]
|
||
file_table_offset = struct.unpack("<Q", f.read(8))[0]
|
||
file_count = struct.unpack("<I", f.read(4))[0]
|
||
|
||
# Try to read file table — format varies by version
|
||
# For now just return basic info
|
||
return {
|
||
"valid": True,
|
||
"version": version,
|
||
"file_count": file_count,
|
||
"table_offset": file_table_offset,
|
||
"files": [], # Would need full parser for file list
|
||
"size": os.path.getsize(path),
|
||
}
|
||
except Exception as e:
|
||
return {"valid": False, "error": str(e), "files": []}
|
||
|
||
|
||
def get_file_hash(path, chunk_size=65536):
|
||
"""Get SHA256 hash of a file for duplicate detection."""
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
while True:
|
||
chunk = f.read(chunk_size)
|
||
if not chunk:
|
||
break
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
# --- Config management ---
|
||
|
||
def load_config():
|
||
"""Load configuration from file."""
|
||
if CONFIG_FILE.exists():
|
||
with open(CONFIG_FILE, "r") as f:
|
||
return json.load(f)
|
||
return {
|
||
"game_path": DEFAULT_GAME_PATH,
|
||
"mod_dir": DEFAULT_MOD_DIR,
|
||
"last_profile": "Default",
|
||
"nexus_api_key": "",
|
||
"auto_prefix": True, # Auto-assign load order prefixes
|
||
}
|
||
|
||
|
||
def save_config(config):
|
||
"""Save configuration to file."""
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
with open(CONFIG_FILE, "w") as f:
|
||
json.dump(config, f, indent=2)
|
||
|
||
|
||
# --- Mod management ---
|
||
|
||
class Mod:
|
||
"""Represents a single mod file."""
|
||
def __init__(self, path, enabled=True, prefix=""):
|
||
self.path = Path(path)
|
||
self.enabled = enabled
|
||
self.prefix = prefix
|
||
self.info = {}
|
||
self.conflicts = []
|
||
|
||
@property
|
||
def name(self):
|
||
"""Display name without prefix and extension."""
|
||
name = self.path.name
|
||
if name.endswith(".disabled"):
|
||
name = name[:-9]
|
||
if name.endswith(".archive"):
|
||
name = name[:-8]
|
||
# Remove numeric prefix like "000_"
|
||
if len(name) > 4 and name[:4].isdigit() and name[3] == "_":
|
||
name = name[4:]
|
||
return name
|
||
|
||
@property
|
||
def filename(self):
|
||
return self.path.name
|
||
|
||
@property
|
||
def size_mb(self):
|
||
return self.path.stat().st_size / (1024 * 1024)
|
||
|
||
@property
|
||
def sort_key(self):
|
||
"""Sort key based on prefix."""
|
||
name = self.path.name
|
||
if name[:3].isdigit():
|
||
return name[:3]
|
||
return "999" # Unprefixed mods go last
|
||
|
||
|
||
def scan_mods(mod_dir):
|
||
"""Scan the mod directory and return list of Mod objects."""
|
||
mods = []
|
||
mod_path = Path(mod_dir)
|
||
if not mod_path.exists():
|
||
return mods
|
||
|
||
for f in mod_path.iterdir():
|
||
if f.is_file() and (f.name.endswith(".archive") or f.name.endswith(".archive.disabled")):
|
||
enabled = f.name.endswith(".archive")
|
||
mod = Mod(f, enabled=enabled)
|
||
mod.info = read_archive_info(f)
|
||
mods.append(mod)
|
||
|
||
mods.sort(key=lambda m: m.sort_key)
|
||
return mods
|
||
|
||
|
||
def toggle_mod(mod):
|
||
"""Enable or disable a mod by renaming .archive <-> .archive.disabled."""
|
||
if mod.enabled:
|
||
# Disable
|
||
new_path = mod.path.with_suffix(mod.path.suffix + ".disabled")
|
||
mod.path.rename(new_path)
|
||
mod.path = new_path
|
||
mod.enabled = False
|
||
else:
|
||
# Enable
|
||
new_path = mod.path.with_suffix("")
|
||
mod.path.rename(new_path)
|
||
mod.path = new_path
|
||
mod.enabled = True
|
||
|
||
|
||
def set_load_order(mods, order_list):
|
||
"""Rename mods to set load order via numeric prefixes.
|
||
order_list is a list of mod names in the desired order.
|
||
"""
|
||
for i, mod_name in enumerate(order_list):
|
||
prefix = f"{i * 10:03d}_"
|
||
for mod in mods:
|
||
if mod.name == mod_name:
|
||
old_name = mod.path.name
|
||
# Remove existing prefix
|
||
clean_name = old_name
|
||
if clean_name[:4].isdigit() and clean_name[3] == "_":
|
||
clean_name = clean_name[4:]
|
||
new_name = prefix + clean_name
|
||
if old_name != new_name:
|
||
new_path = mod.path.parent / new_name
|
||
mod.path.rename(new_path)
|
||
mod.path = new_path
|
||
break
|
||
|
||
|
||
# --- Nexus Mods integration ---
|
||
|
||
def parse_nxm_url(url):
|
||
"""Parse an nxm:// URL to extract mod info.
|
||
Format: nxm://game/mods/mod_id/files/file_id?key=...&expires=...
|
||
"""
|
||
parsed = urllib.parse.urlparse(url)
|
||
parts = parsed.path.strip("/").split("/")
|
||
if len(parts) >= 4 and parts[0] == "mods" and parts[2] == "files":
|
||
return {
|
||
"game": parsed.netloc,
|
||
"mod_id": parts[1],
|
||
"file_id": parts[3],
|
||
"params": dict(urllib.parse.parse_qsl(parsed.query)),
|
||
}
|
||
return None
|
||
|
||
|
||
def download_nxm_mod(nxm_url, api_key, download_dir):
|
||
"""Download a mod from Nexus Mods using an nxm:// URL."""
|
||
info = parse_nxm_url(nxm_url)
|
||
if not info:
|
||
return None, "Invalid NXM URL"
|
||
|
||
# Generate download URL via Nexus API
|
||
headers = {"apikey": api_key}
|
||
api_url = f"{NEXUS_API_BASE}/games/{info['game']}/mods/{info['mod_id']}/files/{info['file_id']}/download_link.json"
|
||
|
||
try:
|
||
req = urllib.request.Request(api_url, headers=headers)
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
data = json.loads(resp.read())
|
||
download_url = data[0]["URI"]
|
||
|
||
# Download the file
|
||
filename = f"mod_{info['mod_id']}_{info['file_id']}.7z"
|
||
filepath = Path(download_dir) / filename
|
||
|
||
urllib.request.urlretrieve(download_url, filepath)
|
||
return filepath, None
|
||
except Exception as e:
|
||
return None, str(e)
|
||
|
||
|
||
# --- GUI ---
|
||
|
||
class ModManagerWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.config = load_config()
|
||
self.mods = []
|
||
self.current_profile = self.config.get("last_profile", "Default")
|
||
|
||
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
|
||
self.setMinimumSize(900, 600)
|
||
self.setStyleSheet(self._get_style())
|
||
|
||
# Create central widget with tabs
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
layout = QVBoxLayout(central)
|
||
|
||
# Top bar: game path + profile selector
|
||
top_bar = self._create_top_bar()
|
||
layout.addLayout(top_bar)
|
||
|
||
# Tab widget
|
||
self.tabs = QTabWidget()
|
||
self.tabs.addTab(self._create_mods_tab(), "📦 Mods")
|
||
self.tabs.addTab(self._create_conflicts_tab(), "⚠️ Konflikte")
|
||
self.tabs.addTab(self._create_profiles_tab(), "💾 Profile")
|
||
self.tabs.addTab(self._create_nexus_tab(), "🌐 Nexus Mods")
|
||
self.tabs.addTab(self._create_settings_tab(), "⚙️ Settings")
|
||
layout.addWidget(self.tabs)
|
||
|
||
# Status bar
|
||
self.status = QStatusBar()
|
||
self.setStatusBar(self.status)
|
||
self.status.showMessage("Ready")
|
||
|
||
# Load mods on startup
|
||
QTimer.singleShot(100, self.refresh_mods)
|
||
|
||
def _get_style(self):
|
||
return """
|
||
QMainWindow { background: #0a0a0f; }
|
||
QWidget { background: #12121a; color: #e0e0e0; font-size: 14px; }
|
||
QTabWidget::pane { border: 1px solid #2a2a3a; border-radius: 8px; }
|
||
QTabBar::tab { background: #12121a; border: 1px solid #2a2a3a; padding: 8px 20px; border-radius: 8px 8px 0 0; color: #a0a0b0; }
|
||
QTabBar::tab:selected { background: #1a1a2a; border-bottom: 2px solid #00ff9f; color: #00ff9f; }
|
||
QPushButton { background: #1a1a2a; border: 1px solid #2a2a3a; border-radius: 6px; padding: 8px 16px; color: #e0e0e0; }
|
||
QPushButton:hover { background: #2a2a3a; border-color: #00ff9f; }
|
||
QPushButton:pressed { background: #00ff9f; color: #0a0a0f; }
|
||
QListWidget { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; }
|
||
QListWidgetItem { padding: 4px; }
|
||
QTreeWidget { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; }
|
||
QLineEdit, QComboBox { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 4px; padding: 6px; }
|
||
QGroupBox { border: 1px solid #2a2a3a; border-radius: 8px; margin-top: 12px; padding-top: 12px; }
|
||
QGroupBox::title { color: #00ff9f; subcontrol-origin: margin; left: 12px; padding: 0 4px; }
|
||
QProgressBar { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 4px; text-align: center; }
|
||
QProgressBar::chunk { background: #00ff9f; border-radius: 4px; }
|
||
QCheckBox { color: #e0e0e0; }
|
||
QLabel { color: #e0e0e0; }
|
||
QStatusBar { background: #12121a; border-top: 1px solid #2a2a3a; }
|
||
"""
|
||
|
||
def _create_top_bar(self):
|
||
layout = QHBoxLayout()
|
||
|
||
# Game path
|
||
layout.addWidget(QLabel("Game:"))
|
||
self.game_path_label = QLabel(self.config.get("game_path", DEFAULT_GAME_PATH))
|
||
self.game_path_label.setStyleSheet("color: #a0a0b0; padding: 0 10px;")
|
||
layout.addWidget(self.game_path_label, 1)
|
||
|
||
browse_btn = QPushButton("📂 Browse")
|
||
browse_btn.clicked.connect(self.browse_game_path)
|
||
layout.addWidget(browse_btn)
|
||
|
||
# Separator
|
||
layout.addWidget(QLabel(" | "))
|
||
|
||
# Profile selector
|
||
layout.addWidget(QLabel("Profile:"))
|
||
self.profile_combo = QComboBox()
|
||
self.profile_combo.setMinimumWidth(150)
|
||
self._refresh_profiles()
|
||
self.profile_combo.currentTextChanged.connect(self.load_profile)
|
||
layout.addWidget(self.profile_combo)
|
||
|
||
# Refresh button
|
||
refresh_btn = QPushButton("🔄 Refresh")
|
||
refresh_btn.clicked.connect(self.refresh_mods)
|
||
layout.addWidget(refresh_btn)
|
||
|
||
return layout
|
||
|
||
def _create_mods_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
# Mod list
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
|
||
# Left: mod list
|
||
self.mod_list = QTreeWidget()
|
||
self.mod_list.setHeaderLabels(["", "Mod Name", "Size", "Order", "Status"])
|
||
self.mod_list.setRootIsDecorated(False)
|
||
self.mod_list.setAlternatingRowColors(True)
|
||
self.mod_list.setColumnWidth(0, 40) # Checkbox
|
||
self.mod_list.setColumnWidth(1, 300) # Name
|
||
self.mod_list.setColumnWidth(2, 80) # Size
|
||
self.mod_list.setColumnWidth(3, 60) # Order
|
||
self.mod_list.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
self.mod_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.mod_list.customContextMenuRequested.connect(self._mod_context_menu)
|
||
self.mod_list.itemChanged.connect(self._on_mod_checkbox_changed)
|
||
|
||
splitter.addWidget(self.mod_list)
|
||
|
||
# Right: mod details
|
||
details_group = QGroupBox("Details")
|
||
details_layout = QVBoxLayout(details_group)
|
||
self.details_label = QLabel("Select a mod to see details.")
|
||
self.details_label.setWordWrap(True)
|
||
self.details_label.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||
details_layout.addWidget(self.details_label)
|
||
splitter.addWidget(details_group)
|
||
|
||
splitter.setSizes([600, 300])
|
||
layout.addWidget(splitter, 1)
|
||
|
||
# Bottom buttons
|
||
btn_layout = QHBoxLayout()
|
||
|
||
add_btn = QPushButton("➕ Add Mod")
|
||
add_btn.clicked.connect(self.add_mod)
|
||
btn_layout.addWidget(add_btn)
|
||
|
||
enable_all_btn = QPushButton("✅ Enable All")
|
||
enable_all_btn.clicked.connect(self.enable_all_mods)
|
||
btn_layout.addWidget(enable_all_btn)
|
||
|
||
disable_all_btn = QPushButton("❌ Disable All")
|
||
disable_all_btn.clicked.connect(self.disable_all_mods)
|
||
btn_layout.addWidget(disable_all_btn)
|
||
|
||
reorder_btn = QPushButton("🔢 Auto-Sort (Load Order)")
|
||
reorder_btn.clicked.connect(self.auto_sort_mods)
|
||
btn_layout.addWidget(reorder_btn)
|
||
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
return widget
|
||
|
||
def _create_conflicts_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
layout.addWidget(QLabel("⚠️ Detected file conflicts between mods:"))
|
||
|
||
self.conflict_tree = QTreeWidget()
|
||
self.conflict_tree.setHeaderLabels(["File", "Mod 1", "Mod 2", "Resolution"])
|
||
self.conflict_tree.setAlternatingRowColors(True)
|
||
layout.addWidget(self.conflict_tree, 1)
|
||
|
||
scan_btn = QPushButton("🔍 Scan for Conflicts")
|
||
scan_btn.clicked.connect(self.scan_conflicts)
|
||
layout.addWidget(scan_btn)
|
||
|
||
return widget
|
||
|
||
def _create_profiles_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
layout.addWidget(QLabel("Save and load mod configurations as profiles:"))
|
||
|
||
# Profile name input
|
||
name_layout = QHBoxLayout()
|
||
name_layout.addWidget(QLabel("Name:"))
|
||
self.profile_name_input = QLineEdit()
|
||
self.profile_name_input.setPlaceholderText("Profile name (e.g. NSFW, Vanilla+, Minimal)")
|
||
name_layout.addWidget(self.profile_name_input, 1)
|
||
save_btn = QPushButton("💾 Save Profile")
|
||
save_btn.clicked.connect(self.save_profile)
|
||
name_layout.addWidget(save_btn)
|
||
layout.addLayout(name_layout)
|
||
|
||
# Profile list
|
||
self.profile_list = QListWidget()
|
||
self.profile_list.itemDoubleClicked.connect(lambda item: self.load_profile(item.text()))
|
||
layout.addWidget(self.profile_list, 1)
|
||
|
||
# Buttons
|
||
btn_layout = QHBoxLayout()
|
||
load_btn = QPushButton("📂 Load")
|
||
load_btn.clicked.connect(lambda: self.load_profile(self.profile_list.currentItem().text() if self.profile_list.currentItem() else ""))
|
||
btn_layout.addWidget(load_btn)
|
||
|
||
delete_btn = QPushButton("🗑️ Delete")
|
||
delete_btn.clicked.connect(self.delete_profile)
|
||
btn_layout.addWidget(delete_btn)
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
return widget
|
||
|
||
def _create_nexus_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
# API key
|
||
key_group = QGroupBox("Nexus Mods API Key")
|
||
key_layout = QVBoxLayout(key_group)
|
||
key_layout.addWidget(QLabel("Get your API key from: https://www.nexusmods.com/users/myaccount?tab=api%20keys"))
|
||
key_input_layout = QHBoxLayout()
|
||
self.nexus_key_input = QLineEdit()
|
||
self.nexus_key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self.nexus_key_input.setPlaceholderText("Paste your Nexus API key here...")
|
||
self.nexus_key_input.setText(self.config.get("nexus_api_key", ""))
|
||
key_input_layout.addWidget(self.nexus_key_input, 1)
|
||
save_key_btn = QPushButton("Save")
|
||
save_key_btn.clicked.connect(self.save_nexus_key)
|
||
key_input_layout.addWidget(save_key_btn)
|
||
key_layout.addLayout(key_input_layout)
|
||
layout.addWidget(key_group)
|
||
|
||
# NXM URL download
|
||
dl_group = QGroupBox("Manual NXM Download")
|
||
dl_layout = QVBoxLayout(dl_group)
|
||
dl_layout.addWidget(QLabel("Paste an nxm:// URL from Nexus Mods:"))
|
||
url_layout = QHBoxLayout()
|
||
self.nxm_url_input = QLineEdit()
|
||
self.nxm_url_input.setPlaceholderText("nxm://cyberpunk2077/mods/123/files/456?key=...")
|
||
url_layout.addWidget(self.nxm_url_input, 1)
|
||
dl_btn = QPushButton("⬇️ Download")
|
||
dl_btn.clicked.connect(self.download_nxm)
|
||
url_layout.addWidget(dl_btn)
|
||
dl_layout.addLayout(url_layout)
|
||
|
||
self.nexus_progress = QProgressBar()
|
||
self.nexus_progress.setVisible(False)
|
||
dl_layout.addWidget(self.nexus_progress)
|
||
|
||
self.nexus_status = QLabel("")
|
||
dl_layout.addWidget(self.nexus_status)
|
||
layout.addWidget(dl_group)
|
||
|
||
# nxm:// protocol handler info
|
||
info_group = QGroupBox("Browser Integration")
|
||
info_layout = QVBoxLayout(info_group)
|
||
info_layout.addWidget(QLabel("To auto-catch nxm:// links from your browser:"))
|
||
register_btn = QPushButton("🔗 Register nxm:// Protocol Handler")
|
||
register_btn.clicked.connect(self.register_nxm_protocol)
|
||
info_layout.addWidget(register_btn)
|
||
info_layout.addWidget(QLabel("This lets clicking 'Download' on Nexus Mods\nautomatically download the mod here."))
|
||
layout.addWidget(info_group)
|
||
layout.addStretch()
|
||
|
||
return widget
|
||
|
||
def _create_settings_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
# Game path
|
||
path_group = QGroupBox("Game Path")
|
||
path_layout = QVBoxLayout(path_group)
|
||
path_layout.addWidget(QLabel("Cyberpunk 2077 installation directory:"))
|
||
path_input_layout = QHBoxLayout()
|
||
self.game_path_input = QLineEdit()
|
||
self.game_path_input.setText(self.config.get("game_path", DEFAULT_GAME_PATH))
|
||
path_input_layout.addWidget(self.game_path_input, 1)
|
||
path_browse_btn = QPushButton("📂")
|
||
path_browse_btn.clicked.connect(self.browse_game_path_settings)
|
||
path_input_layout.addWidget(path_browse_btn)
|
||
path_layout.addLayout(path_input_layout)
|
||
|
||
save_path_btn = QPushButton("Save")
|
||
save_path_btn.clicked.connect(self.save_game_path)
|
||
path_layout.addWidget(save_path_btn)
|
||
layout.addWidget(path_group)
|
||
|
||
# Load order settings
|
||
order_group = QGroupBox("Load Order")
|
||
order_layout = QVBoxLayout(order_group)
|
||
self.auto_prefix_check = QCheckBox("Auto-assign numeric prefixes (000_, 010_, 020_)")
|
||
self.auto_prefix_check.setChecked(self.config.get("auto_prefix", True))
|
||
order_layout.addWidget(self.auto_prefix_check)
|
||
order_layout.addWidget(QLabel("Higher numbers = loaded later = overrides earlier mods"))
|
||
layout.addWidget(order_group)
|
||
|
||
layout.addStretch()
|
||
return widget
|
||
|
||
# --- Actions ---
|
||
|
||
def get_mod_dir(self):
|
||
return Path(self.config["game_path"]) / self.config["mod_dir"]
|
||
|
||
def refresh_mods(self):
|
||
"""Scan mod directory and update the list."""
|
||
mod_dir = self.get_mod_dir()
|
||
if not mod_dir.exists():
|
||
self.status.showMessage(f"Mod directory not found: {mod_dir}")
|
||
return
|
||
|
||
self.mods = scan_mods(mod_dir)
|
||
self._update_mod_list()
|
||
self.status.showMessage(f"Found {len(self.mods)} mods in {mod_dir}")
|
||
|
||
def _update_mod_list(self):
|
||
"""Update the mod list tree widget."""
|
||
self.mod_list.blockSignals(True)
|
||
self.mod_list.clear()
|
||
|
||
for mod in self.mods:
|
||
item = QTreeWidgetItem()
|
||
item.setCheckState(0, Qt.CheckState.Checked if mod.enabled else Qt.CheckState.Unchecked)
|
||
item.setText(1, mod.name)
|
||
item.setText(2, f"{mod.size_mb:.1f} MB")
|
||
item.setText(3, mod.sort_key)
|
||
item.setText(4, "✅ Enabled" if mod.enabled else "❌ Disabled")
|
||
|
||
if not mod.enabled:
|
||
item.setForeground(4, QColor("#ff0080"))
|
||
else:
|
||
item.setForeground(4, QColor("#00ff9f"))
|
||
|
||
# Store mod reference
|
||
item.setData(0, Qt.ItemDataRole.UserRole, mod)
|
||
self.mod_list.addTopLevelItem(item)
|
||
|
||
self.mod_list.blockSignals(False)
|
||
|
||
def _on_mod_checkbox_changed(self, item, column):
|
||
"""Handle checkbox toggle on mod list."""
|
||
if column != 0:
|
||
return
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if not mod:
|
||
return
|
||
|
||
new_state = item.checkState(0) == Qt.CheckState.Checked
|
||
if new_state != mod.enabled:
|
||
toggle_mod(mod)
|
||
item.setText(4, "✅ Enabled" if mod.enabled else "❌ Disabled")
|
||
if mod.enabled:
|
||
item.setForeground(4, QColor("#00ff9f"))
|
||
else:
|
||
item.setForeground(4, QColor("#ff0080"))
|
||
self.status.showMessage(f"{'Enabled' if mod.enabled else 'Disabled'}: {mod.name}")
|
||
|
||
def _mod_context_menu(self, pos):
|
||
"""Right-click context menu on mod list."""
|
||
item = self.mod_list.itemAt(pos)
|
||
if not item:
|
||
return
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
|
||
menu = QMenu(self)
|
||
|
||
toggle_action = QAction("Disable" if mod.enabled else "Enable", self)
|
||
toggle_action.triggered.connect(lambda: self._on_mod_checkbox_changed(item, 0))
|
||
menu.addAction(toggle_action)
|
||
|
||
menu.addSeparator()
|
||
|
||
move_up = QAction("⬆️ Move Up (earlier)", self)
|
||
move_up.triggered.connect(lambda: self._move_mod(item, up=True))
|
||
menu.addAction(move_up)
|
||
|
||
move_down = QAction("⬇️ Move Down (later)", self)
|
||
move_down.triggered.connect(lambda: self._move_mod(item, up=False))
|
||
menu.addAction(move_down)
|
||
|
||
menu.addSeparator()
|
||
|
||
delete_action = QAction("🗑️ Delete Mod", self)
|
||
delete_action.triggered.connect(lambda: self._delete_mod(item))
|
||
menu.addAction(delete_action)
|
||
|
||
menu.exec(self.mod_list.mapToGlobal(pos))
|
||
|
||
def _move_mod(self, item, up=True):
|
||
"""Move a mod up or down in load order."""
|
||
idx = self.mod_list.indexOfTopLevelItem(item)
|
||
if up and idx > 0:
|
||
self.mod_list.takeTopLevelItem(idx)
|
||
self.mod_list.insertTopLevelItem(idx - 1, item)
|
||
self.mod_list.setCurrentItem(item)
|
||
elif not up and idx < self.mod_list.topLevelItemCount() - 1:
|
||
self.mod_list.takeTopLevelItem(idx)
|
||
self.mod_list.insertTopLevelItem(idx + 1, item)
|
||
self.mod_list.setCurrentItem(item)
|
||
|
||
# Re-apply prefixes
|
||
self._apply_order_from_list()
|
||
|
||
def _apply_order_from_list(self):
|
||
"""Rename mods based on current list order."""
|
||
order = []
|
||
for i in range(self.mod_list.topLevelItemCount()):
|
||
item = self.mod_list.topLevelItem(i)
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if mod:
|
||
order.append(mod.name)
|
||
set_load_order(self.mods, order)
|
||
self.refresh_mods()
|
||
|
||
def _delete_mod(self, item):
|
||
"""Delete a mod file."""
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
reply = QMessageBox.question(self, "Delete Mod",
|
||
f"Delete '{mod.name}' ({mod.size_mb:.1f} MB)?\nThis cannot be undone!",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
mod.path.unlink()
|
||
self.refresh_mods()
|
||
self.status.showMessage(f"Deleted: {mod.name}")
|
||
|
||
def add_mod(self):
|
||
"""Add a mod file from file dialog."""
|
||
files, _ = QFileDialog.getOpenFileNames(self, "Select mod files",
|
||
"", "Archive files (*.archive *.7z *.zip);;All files (*.*)")
|
||
if not files:
|
||
return
|
||
|
||
mod_dir = self.get_mod_dir()
|
||
mod_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
for f in files:
|
||
src = Path(f)
|
||
dst = mod_dir / src.name
|
||
if dst.exists():
|
||
reply = QMessageBox.question(self, "Overwrite?",
|
||
f"'{src.name}' already exists. Overwrite?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
continue
|
||
shutil.copy2(src, dst)
|
||
self.status.showMessage(f"Added: {src.name}")
|
||
|
||
self.refresh_mods()
|
||
|
||
def enable_all_mods(self):
|
||
"""Enable all disabled mods."""
|
||
for mod in self.mods:
|
||
if not mod.enabled:
|
||
toggle_mod(mod)
|
||
self.refresh_mods()
|
||
self.status.showMessage("All mods enabled")
|
||
|
||
def disable_all_mods(self):
|
||
"""Disable all enabled mods."""
|
||
for mod in self.mods:
|
||
if mod.enabled:
|
||
toggle_mod(mod)
|
||
self.refresh_mods()
|
||
self.status.showMessage("All mods disabled")
|
||
|
||
def auto_sort_mods(self):
|
||
"""Auto-sort mods by current list order and apply numeric prefixes."""
|
||
self._apply_order_from_list()
|
||
self.status.showMessage("Load order applied")
|
||
|
||
def browse_game_path(self):
|
||
"""Browse for game directory."""
|
||
path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory",
|
||
self.config.get("game_path", DEFAULT_GAME_PATH))
|
||
if path:
|
||
self.config["game_path"] = path
|
||
save_config(self.config)
|
||
self.game_path_label.setText(path)
|
||
self.refresh_mods()
|
||
|
||
def browse_game_path_settings(self):
|
||
path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory",
|
||
self.game_path_input.text())
|
||
if path:
|
||
self.game_path_input.setText(path)
|
||
|
||
def save_game_path(self):
|
||
self.config["game_path"] = self.game_path_input.text()
|
||
self.config["auto_prefix"] = self.auto_prefix_check.isChecked()
|
||
save_config(self.config)
|
||
self.game_path_label.setText(self.config["game_path"])
|
||
self.status.showMessage("Settings saved")
|
||
|
||
# --- Conflicts ---
|
||
|
||
def scan_conflicts(self):
|
||
"""Scan for file conflicts between mods."""
|
||
self.conflict_tree.clear()
|
||
|
||
# Group mods by name pattern (similar names might conflict)
|
||
# For now, just check if multiple mods have similar file patterns
|
||
# A real implementation would parse .archive files and compare internal file paths
|
||
|
||
# Simple check: mods with same base name but different prefix
|
||
name_map = {}
|
||
for mod in self.mods:
|
||
if not mod.enabled:
|
||
continue
|
||
base = mod.name.lower()
|
||
if base not in name_map:
|
||
name_map[base] = []
|
||
name_map[base].append(mod)
|
||
|
||
conflicts_found = False
|
||
for base, mod_list in name_map.items():
|
||
if len(mod_list) > 1:
|
||
conflicts_found = True
|
||
for i in range(len(mod_list)):
|
||
for j in range(i + 1, len(mod_list)):
|
||
item = QTreeWidgetItem()
|
||
item.setText(0, base)
|
||
item.setText(1, mod_list[i].filename)
|
||
item.setText(2, mod_list[j].filename)
|
||
item.setText(3, f"{mod_list[i].sort_key} > {mod_list[j].sort_key}")
|
||
item.setForeground(3, QColor("#ff9500"))
|
||
self.conflict_tree.addTopLevelItem(item)
|
||
|
||
if not conflicts_found:
|
||
item = QTreeWidgetItem()
|
||
item.setText(0, "No conflicts detected ✅")
|
||
self.conflict_tree.addTopLevelItem(item)
|
||
|
||
self.status.showMessage(f"Conflict scan complete: {self.conflict_tree.topLevelItemCount()} items")
|
||
|
||
# --- Profiles ---
|
||
|
||
def _refresh_profiles(self):
|
||
"""Refresh the profile combo box and list."""
|
||
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
profiles = [f.stem for f in PROFILES_DIR.glob("*.json")]
|
||
if not profiles:
|
||
profiles = ["Default"]
|
||
self._save_profile_data("Default")
|
||
|
||
self.profile_combo.blockSignals(True)
|
||
self.profile_combo.clear()
|
||
self.profile_combo.addItems(profiles)
|
||
if self.current_profile in profiles:
|
||
self.profile_combo.setCurrentText(self.current_profile)
|
||
self.profile_combo.blockSignals(False)
|
||
|
||
# Also update profile list
|
||
if hasattr(self, "profile_list"):
|
||
self.profile_list.clear()
|
||
self.profile_list.addItems(profiles)
|
||
|
||
def _save_profile_data(self, name):
|
||
"""Save current mod state as a profile."""
|
||
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
data = {
|
||
"mods": [{"name": m.name, "enabled": m.enabled, "order": m.sort_key} for m in self.mods],
|
||
"saved_at": datetime.now().isoformat(),
|
||
}
|
||
with open(PROFILES_DIR / f"{name}.json", "w") as f:
|
||
json.dump(data, f, indent=2)
|
||
|
||
def save_profile(self):
|
||
"""Save current configuration as a profile."""
|
||
name = self.profile_name_input.text().strip()
|
||
if not name:
|
||
QMessageBox.warning(self, "No name", "Please enter a profile name.")
|
||
return
|
||
self._save_profile_data(name)
|
||
self._refresh_profiles()
|
||
self.status.showMessage(f"Profile saved: {name}")
|
||
|
||
def load_profile(self, name):
|
||
"""Load a profile — enable/disable mods accordingly."""
|
||
if not name:
|
||
return
|
||
path = PROFILES_DIR / f"{name}.json"
|
||
if not path.exists():
|
||
return
|
||
|
||
with open(path, "r") as f:
|
||
data = json.load(f)
|
||
|
||
# Build a map of desired states
|
||
desired = {m["name"]: m["enabled"] for m in data.get("mods", [])}
|
||
desired_order = [m["name"] for m in data.get("mods", [])]
|
||
|
||
# Apply enable/disable
|
||
for mod in self.mods:
|
||
should_enable = desired.get(mod.name, None)
|
||
if should_enable is True and not mod.enabled:
|
||
toggle_mod(mod)
|
||
elif should_enable is False and mod.enabled:
|
||
toggle_mod(mod)
|
||
|
||
# Apply load order
|
||
if desired_order:
|
||
set_load_order(self.mods, desired_order)
|
||
|
||
self.current_profile = name
|
||
self.config["last_profile"] = name
|
||
save_config(self.config)
|
||
self.refresh_mods()
|
||
self.status.showMessage(f"Profile loaded: {name}")
|
||
|
||
def delete_profile(self):
|
||
item = self.profile_list.currentItem()
|
||
if not item:
|
||
return
|
||
name = item.text()
|
||
if name == "Default":
|
||
QMessageBox.warning(self, "Cannot delete", "Cannot delete Default profile.")
|
||
return
|
||
reply = QMessageBox.question(self, "Delete Profile",
|
||
f"Delete profile '{name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
(PROFILES_DIR / f"{name}.json").unlink()
|
||
self._refresh_profiles()
|
||
self.status.showMessage(f"Profile deleted: {name}")
|
||
|
||
# --- Nexus Mods ---
|
||
|
||
def save_nexus_key(self):
|
||
key = self.nexus_key_input.text().strip()
|
||
self.config["nexus_api_key"] = key
|
||
save_config(self.config)
|
||
# Also save to file for protocol handler
|
||
NEXUS_API_KEY_FILE.write_text(key)
|
||
NEXUS_API_KEY_FILE.chmod(0o600)
|
||
self.status.showMessage("Nexus API key saved")
|
||
|
||
def download_nxm(self):
|
||
"""Download a mod from an nxm:// URL."""
|
||
url = self.nxm_url_input.text().strip()
|
||
if not url:
|
||
return
|
||
|
||
api_key = self.config.get("nexus_api_key", "")
|
||
if not api_key:
|
||
QMessageBox.warning(self, "No API key", "Please set your Nexus Mods API key first.")
|
||
self.tabs.setCurrentIndex(3) # Switch to Nexus tab
|
||
return
|
||
|
||
self.nexus_progress.setVisible(True)
|
||
self.nexus_progress.setRange(0, 0) # Indeterminate
|
||
self.nexus_status.setText("Downloading...")
|
||
|
||
# Download in a separate thread
|
||
import threading
|
||
def do_download():
|
||
download_dir = Path.home() / "Downloads" / "nexus-mods"
|
||
download_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
filepath, error = download_nxm_mod(url, api_key, download_dir)
|
||
|
||
# Update UI from main thread
|
||
def on_done():
|
||
self.nexus_progress.setVisible(False)
|
||
if error:
|
||
self.nexus_status.setText(f"❌ {error}")
|
||
else:
|
||
self.nexus_status.setText(f"✅ Downloaded: {filepath}")
|
||
# Offer to install
|
||
reply = QMessageBox.question(self, "Install Mod?",
|
||
f"Downloaded {filepath.name}\n\nInstall to mod directory?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
mod_dir = self.get_mod_dir()
|
||
shutil.copy2(filepath, mod_dir / filepath.name)
|
||
self.refresh_mods()
|
||
self.status.showMessage(f"Installed: {filepath.name}")
|
||
|
||
QTimer.singleShot(0, on_done)
|
||
|
||
threading.Thread(target=do_download, daemon=True).start()
|
||
|
||
def register_nxm_protocol(self):
|
||
"""Register nxm:// protocol handler for this app."""
|
||
# Create .desktop file for nxm:// protocol
|
||
desktop_dir = Path.home() / ".local" / "share" / "applications"
|
||
desktop_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
script_path = Path(__file__).resolve()
|
||
desktop_file = desktop_dir / "neon-mod-manager-nxm.desktop"
|
||
|
||
desktop_content = f"""[Desktop Entry]
|
||
Type=Application
|
||
Name=Neon Mod Manager (NXM Handler)
|
||
Exec=python3 {script_path} --nxm %u
|
||
Terminal=false
|
||
MimeType=x-scheme-handler/nxm;
|
||
NoDisplay=true
|
||
"""
|
||
desktop_file.write_text(desktop_content)
|
||
|
||
# Update MIME database
|
||
subprocess.run(["update-desktop-database", str(desktop_dir)], capture_output=True)
|
||
|
||
self.status.showMessage("nxm:// protocol registered! Browser will now redirect downloads here.")
|
||
QMessageBox.information(self, "Protocol Registered",
|
||
"nxm:// protocol handler registered!\n\n"
|
||
"When you click 'Download' on Nexus Mods,\n"
|
||
"the mod will be sent to Neon Mod Manager.\n\n"
|
||
"You may need to set it as default in your browser settings.")
|
||
|
||
|
||
def main():
|
||
app = QApplication(sys.argv)
|
||
|
||
# Check for --nxm argument (protocol handler invocation)
|
||
if len(sys.argv) > 2 and sys.argv[1] == "--nxm":
|
||
nxm_url = sys.argv[2]
|
||
# Quick download mode — no GUI
|
||
config = load_config()
|
||
api_key = config.get("nexus_api_key", "")
|
||
if not api_key and NEXUS_API_KEY_FILE.exists():
|
||
api_key = NEXUS_API_KEY_FILE.read_text().strip()
|
||
|
||
if not api_key:
|
||
print("No API key set. Run Neon Mod Manager GUI to set your key.")
|
||
sys.exit(1)
|
||
|
||
download_dir = Path.home() / "Downloads" / "nexus-mods"
|
||
download_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
filepath, error = download_nxm_mod(nxm_url, api_key, download_dir)
|
||
if error:
|
||
print(f"Error: {error}")
|
||
sys.exit(1)
|
||
else:
|
||
print(f"Downloaded: {filepath}")
|
||
|
||
# Auto-install to mod directory
|
||
mod_dir = Path(config["game_path"]) / config["mod_dir"]
|
||
if mod_dir.exists():
|
||
shutil.copy2(filepath, mod_dir / filepath.name)
|
||
print(f"Installed to: {mod_dir}")
|
||
sys.exit(0)
|
||
|
||
# Normal GUI mode
|
||
window = ModManagerWindow()
|
||
window.show()
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |