Files
neon-mod-manager/mod_manager.py
T
arch_agent 3f48ac5fb0 v0.3: Enhanced Script Mods tab — grouped by mod name, file counts, toggle, context menu
- Script mods now grouped by mod name instead of flat file list
- Shows file count per mod and category totals
- Toggle button to enable/disable script mods
- Right-click context menu for script mods
- Info label showing total mods and files
- Support for redscript, TweakXL, CET, red4ext, ArchiveXL
2026-07-19 16:12:50 +02:00

1655 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
- Parses .archive files to detect real file conflicts
- Supports REDmod script mods (r6/tweaks/red4ext)
- Auto-extracts .7z / .zip downloads and installs contents
- 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
import zipfile
import tarfile
import io
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, QTextEdit
)
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.2.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 — Read internal file list from .archive files
# ===========================================================================
def read_archive_info(path):
"""Read info from a .archive file including internal file paths.
REDengine .archive format (RDAR):
Offset 0: magic "RDAR" (4 bytes)
Offset 4: version (4 bytes, uint32 LE)
Offset 8: file table offset (8 bytes, uint64 LE)
Offset 16: file count (4 bytes, uint32 LE)
The file table contains entries with:
- hash (8 bytes)
- offset (8 bytes)
- size (8 bytes)
- path string (variable, null-terminated)
The path strings are at the end of the file table, encoded as UTF-8.
"""
try:
with open(path, "rb") as f:
magic = f.read(4)
if magic != b"RDAR":
return {"valid": False, "error": "Not a RDAR archive", "files": [], "size": os.path.getsize(path)}
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]
file_size = os.path.getsize(path)
files = []
if file_table_offset > 0 and file_table_offset < file_size and file_count > 0 and file_count < 100000:
try:
f.seek(file_table_offset)
# Each table entry: hash(8) + offset(8) + size(8) = 24 bytes + path
# The path strings follow the fixed-size entries
# Try to read entries
entry_size = 24 # hash + offset + size
entries_data = f.read(entry_size * file_count)
if len(entries_data) == entry_size * file_count:
# Try to read path strings after the entries
# Paths are null-terminated UTF-8 strings
paths_data = f.read(min(1024 * 1024, file_size - f.tell())) # Read up to 1MB of paths
# Parse null-terminated strings
all_paths = paths_data.split(b'\x00')
for p in all_paths:
try:
decoded = p.decode('utf-8', errors='ignore').strip()
if decoded and len(decoded) > 3 and ('/' in decoded or '\\' in decoded):
files.append(decoded)
except:
continue
# Limit to file_count
files = files[:file_count]
except Exception:
pass
return {
"valid": True,
"version": version,
"file_count": file_count,
"table_offset": file_table_offset,
"files": files,
"size": file_size,
}
except Exception as e:
return {"valid": False, "error": str(e), "files": [], "size": 0}
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()
# ===========================================================================
# ARCHIVE CONFLICT DETECTION — Compare internal file paths
# ===========================================================================
def scan_archive_conflicts(mods, progress_callback=None):
"""Scan all enabled .archive mods and find files that appear in multiple mods.
Returns a list of conflicts: [{file, mods: [mod1, mod2, ...]}, ...]
"""
file_map = {} # file_path -> [mod_name, ...]
for i, mod in enumerate(mods):
if not mod.enabled:
continue
if progress_callback:
progress_callback(i, len(mods), f"Scanning {mod.name}...")
info = mod.info if mod.info else read_archive_info(mod.path)
mod.info = info
for internal_file in info.get("files", []):
# Normalize path
norm = internal_file.lower().replace("\\", "/")
if norm not in file_map:
file_map[norm] = []
file_map[norm].append(mod.name)
# Find conflicts (files in more than one mod)
conflicts = []
for file_path, mod_names in file_map.items():
if len(mod_names) > 1:
conflicts.append({
"file": file_path,
"mods": list(set(mod_names)),
"count": len(set(mod_names)),
})
return conflicts
# ===========================================================================
# ARCHIVE EXTRACTION — Auto-extract .7z / .zip / .rar downloads
# ===========================================================================
# Mod installation structure:
# .archive files → archive/pc/mod/
# .reds files → r6/scripts/
# .yaml files (TweakXL) → r6/tweaks/
# .dll files (red4ext plugins) → red4ext/plugins/<modname>/
# .ini files (engine config) → engine/config/
# .xl files (ArchiveXL) → same dir as .archive
ARCHIVE_EXTS = {".archive"}
REDS_DIRS = {"r6", "scripts", "red4ext", "engine", "bin"}
TWEAK_EXTS = {".yaml", ".yml"}
DLL_EXTS = {".dll"}
def extract_archive(filepath, extract_to):
"""Extract a .7z/.zip/.rar file to a temporary directory.
Returns the path to the extracted directory, or None on failure.
"""
filepath = Path(filepath)
extract_dir = extract_to / filepath.stem
extract_dir.mkdir(parents=True, exist_ok=True)
ext = filepath.suffix.lower()
if ext == ".zip":
try:
with zipfile.ZipFile(filepath, 'r') as zf:
zf.extractall(extract_dir)
return extract_dir
except Exception as e:
print(f"ZIP extraction failed: {e}")
return None
elif ext == ".7z":
# Try 7z command
try:
result = subprocess.run(
["7z", "x", str(filepath), f"-o{extract_dir}", "-y"],
capture_output=True, text=True, timeout=120
)
if result.returncode == 0:
return extract_dir
print(f"7z failed: {result.stderr}")
except FileNotFoundError:
pass
# Try 7za (p7zip-full)
try:
result = subprocess.run(
["7za", "x", str(filepath), f"-o{extract_dir}", "-y"],
capture_output=True, text=True, timeout=120
)
if result.returncode == 0:
return extract_dir
print(f"7za failed: {result.stderr}")
except FileNotFoundError:
pass
print("7z not found. Install p7zip: sudo pacman -S p7zip")
return None
elif ext in (".rar", ".r00"):
try:
result = subprocess.run(
["unrar", "x", str(filepath), str(extract_dir) + "/", "-y"],
capture_output=True, text=True, timeout=120
)
if result.returncode == 0:
return extract_dir
print(f"unrar failed: {result.stderr}")
except FileNotFoundError:
pass
print("unrar not found. Install unrar: sudo pacman -S unrar")
return None
elif ext in (".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz"):
try:
with tarfile.open(filepath, 'r:*') as tf:
tf.extractall(extract_dir)
return extract_dir
except Exception as e:
print(f"tar extraction failed: {e}")
return None
return None
def install_extracted_mod(extract_dir, game_path):
"""Install files from an extracted mod directory to the correct game paths.
Returns a list of installed files.
"""
game_path = Path(game_path)
mod_dir = game_path / "archive/pc/mod"
installed = []
# Walk the extracted directory
for root, dirs, files in os.walk(extract_dir):
root_path = Path(root)
for fname in files:
src = root_path / fname
ext = Path(fname).suffix.lower()
# .archive files → archive/pc/mod/
if ext == ".archive":
dst = mod_dir / fname
mod_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(f"archive/pc/mod/{fname}")
# .xl files (ArchiveXL) → same dir as .archive
elif ext == ".xl":
dst = mod_dir / fname
mod_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(f"archive/pc/mod/{fname}")
# .yaml/.yml (TweakXL) → r6/tweaks/
elif ext in (".yaml", ".yml"):
# Check if it's in a tweaks folder already
rel = root_path.relative_to(extract_dir)
parts = rel.parts
if "tweaks" in parts or "r6" in parts:
# Preserve relative path under r6/
if "r6" in parts:
idx = parts.index("r6")
rel_under_r6 = Path(*parts[idx+1:])
dst = game_path / "r6" / rel_under_r6 / fname
else:
dst = game_path / "r6" / "tweaks" / fname
else:
dst = game_path / "r6" / "tweaks" / fname
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(str(dst.relative_to(game_path)))
# .reds (redscript) → r6/scripts/
elif ext == ".reds":
rel = root_path.relative_to(extract_dir)
parts = rel.parts
if "r6" in parts:
idx = parts.index("r6")
rel_under_r6 = Path(*parts[idx+1:])
dst = game_path / "r6" / rel_under_r6 / fname
elif "scripts" in parts:
dst = game_path / "r6" / "scripts" / fname
else:
dst = game_path / "r6" / "scripts" / fname
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(str(dst.relative_to(game_path)))
# .dll (red4ext plugins) → red4ext/plugins/<modname>/
elif ext == ".dll":
rel = root_path.relative_to(extract_dir)
parts = rel.parts
if "red4ext" in parts and "plugins" in parts:
# Preserve structure
dst = game_path / rel / fname
elif "plugins" in parts:
idx = parts.index("plugins")
plugin_name = parts[idx + 1] if idx + 1 < len(parts) else extract_dir.name
dst = game_path / "red4ext" / "plugins" / plugin_name / fname
else:
plugin_name = extract_dir.name
dst = game_path / "red4ext" / "plugins" / plugin_name / fname
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(str(dst.relative_to(game_path)))
# .ini (engine config) → engine/config/
elif ext == ".ini":
rel = root_path.relative_to(extract_dir)
parts = rel.parts
if "engine" in parts:
dst = game_path / rel / fname
else:
dst = game_path / "engine" / "config" / fname
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(str(dst.relative_to(game_path)))
# CET mods (cyber_engine_tweaks) → bin/x64/plugins/cyber_engine_tweaks/mods/
elif fname.endswith(".lua") or "cyber_engine_tweaks" in str(root_path).lower():
rel = root_path.relative_to(extract_dir)
parts = rel.parts
if "cyber_engine_tweaks" in str(rel).lower() and "mods" in parts:
idx = parts.index("mods")
if idx + 1 < len(parts):
mod_name = parts[idx + 1]
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / mod_name / fname
else:
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / fname
elif ext == ".lua":
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / extract_dir.name / fname
else:
continue # Skip non-lua files in CET context
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
installed.append(str(dst.relative_to(game_path)))
return installed
def install_mod_file(filepath, game_path, status_callback=None):
"""Install a mod file — auto-detect type and handle accordingly.
Supports: .archive, .zip, .7z, .rar, .tar.gz
"""
filepath = Path(filepath)
game_path = Path(game_path)
ext = filepath.suffix.lower()
if ext == ".archive" or ext == ".xl":
# Direct copy to mod directory
mod_dir = game_path / "archive/pc/mod"
mod_dir.mkdir(parents=True, exist_ok=True)
dst = mod_dir / filepath.name
if status_callback:
status_callback(f"Installing {filepath.name}...")
shutil.copy2(filepath, dst)
return [f"archive/pc/mod/{filepath.name}"]
elif ext in (".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".r00"):
# Extract and install
if status_callback:
status_callback(f"Extracting {filepath.name}...")
extract_dir = extract_archive(filepath, Path("/tmp/neon-mod-extract"))
if not extract_dir:
return None
if status_callback:
status_callback(f"Installing files from {filepath.name}...")
installed = install_extracted_mod(extract_dir, game_path)
# Cleanup
shutil.rmtree(extract_dir, ignore_errors=True)
return installed
elif ext in (".yaml", ".yml"):
# TweakXL — copy to r6/tweaks/
dst = game_path / "r6/tweaks" / filepath.name
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(filepath, dst)
return [f"r6/tweaks/{filepath.name}"]
elif ext == ".reds":
# redscript — copy to r6/scripts/
dst = game_path / "r6/scripts" / filepath.name
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(filepath, dst)
return [f"r6/scripts/{filepath.name}"]
else:
return None
# ===========================================================================
# REDMOD SCRIPT SUPPORT — Manage r6/tweaks, r6/scripts, red4ext
# ===========================================================================
def scan_script_mods(game_path):
"""Scan for script-based mods (redscript, TweakXL, CET, red4ext).
Returns dict with categories and their files.
"""
game_path = Path(game_path)
script_mods = {
"redscript": [], # r6/scripts/*.reds
"tweakxl": [], # r6/tweaks/*.yaml
"cet": [], # bin/x64/plugins/cyber_engine_tweaks/mods/*
"red4ext": [], # red4ext/plugins/*
"archivexl": [], # *.xl files in archive/pc/mod/
}
# redscript
rs_dir = game_path / "r6/scripts"
if rs_dir.exists():
for f in rs_dir.rglob("*.reds"):
script_mods["redscript"].append(str(f.relative_to(game_path)))
# TweakXL
tw_dir = game_path / "r6/tweaks"
if tw_dir.exists():
for f in tw_dir.rglob("*.yaml"):
script_mods["tweakxl"].append(str(f.relative_to(game_path)))
for f in tw_dir.rglob("*.yml"):
script_mods["tweakxl"].append(str(f.relative_to(game_path)))
# CET
cet_dir = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods"
if cet_dir.exists():
for d in cet_dir.iterdir():
if d.is_dir():
files = list(d.rglob("*"))
if files:
script_mods["cet"].append(str(d.relative_to(game_path)))
# red4ext
r4e_dir = game_path / "red4ext/plugins"
if r4e_dir.exists():
for d in r4e_dir.iterdir():
if d.is_dir():
files = list(d.glob("*.dll"))
if files:
script_mods["red4ext"].append(str(d.relative_to(game_path)))
# ArchiveXL
mod_dir = game_path / "archive/pc/mod"
if mod_dir.exists():
for f in mod_dir.glob("*.xl"):
script_mods["archivexl"].append(str(f.relative_to(game_path)))
return script_mods
# ===========================================================================
# 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,
}
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 CLASS
# ===========================================================================
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):
name = self.path.name
if name.endswith(".disabled"):
name = name[:-9]
if name.endswith(".archive"):
name = name[:-8]
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):
name = self.path.name
if name[:3].isdigit():
return name[:3]
return "999"
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)
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:
new_path = mod.path.with_suffix(mod.path.suffix + ".disabled")
mod.path.rename(new_path)
mod.path = new_path
mod.enabled = False
else:
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."""
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
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."""
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"
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"]
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.script_mods = {}
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
self.setMinimumSize(900, 600)
self.setStyleSheet(self._get_style())
self.setAcceptDrops(True)
# Central widget
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# Top bar
top_bar = self._create_top_bar()
layout.addLayout(top_bar)
# Tabs
self.tabs = QTabWidget()
self.tabs.addTab(self._create_mods_tab(), "📦 Mods")
self.tabs.addTab(self._create_scripts_tab(), "📜 Script 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 on startup
QTimer.singleShot(100, self.refresh_all)
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; }
QTextEdit { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; }
"""
# --- Drag & Drop ---
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
for url in event.mimeData().urls():
filepath = url.toLocalFile()
if filepath:
self.install_mod(filepath)
# --- Top bar ---
def _create_top_bar(self):
layout = QHBoxLayout()
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)
layout.addWidget(QLabel(" | "))
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_btn = QPushButton("🔄 Refresh")
refresh_btn.clicked.connect(self.refresh_all)
layout.addWidget(refresh_btn)
return layout
# --- Mods tab ---
def _create_mods_tab(self):
widget = QWidget()
layout = QVBoxLayout(widget)
splitter = QSplitter(Qt.Orientation.Horizontal)
self.mod_list = QTreeWidget()
self.mod_list.setHeaderLabels(["", "Mod Name", "Size", "Order", "Status", "Files"])
self.mod_list.setRootIsDecorated(False)
self.mod_list.setAlternatingRowColors(True)
self.mod_list.setColumnWidth(0, 40)
self.mod_list.setColumnWidth(1, 250)
self.mod_list.setColumnWidth(2, 80)
self.mod_list.setColumnWidth(3, 60)
self.mod_list.setColumnWidth(5, 60)
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)
# Details panel
details_group = QGroupBox("Details")
details_layout = QVBoxLayout(details_group)
self.details_label = QTextEdit()
self.details_label.setReadOnly(True)
self.details_label.setMaximumHeight(300)
details_layout.addWidget(self.details_label)
splitter.addWidget(details_group)
splitter.setSizes([600, 300])
layout.addWidget(splitter, 1)
# Buttons
btn_layout = QHBoxLayout()
add_btn = QPushButton(" Add Mod (.archive, .zip, .7z)")
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
# --- Script Mods tab ---
def _create_scripts_tab(self):
widget = QWidget()
layout = QVBoxLayout(widget)
layout.addWidget(QLabel("📜 Script-based mods (redscript, TweakXL, CET, red4ext, ArchiveXL):"))
self.script_tree = QTreeWidget()
self.script_tree.setHeaderLabels(["Type", "Mod Name", "Path", "Files", "Status"])
self.script_tree.setAlternatingRowColors(True)
header = self.script_tree.header()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.script_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.script_tree.customContextMenuRequested.connect(self._script_context_menu)
layout.addWidget(self.script_tree, 1)
# Info label
self.script_info_label = QLabel("Ready. Click Refresh to scan.")
self.script_info_label.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(self.script_info_label)
# Buttons
btn_layout = QHBoxLayout()
add_script_btn = QPushButton(" Add Script Mod (.yaml, .reds, .zip, .7z)")
add_script_btn.clicked.connect(self.add_script_mod)
btn_layout.addWidget(add_script_btn)
refresh_script_btn = QPushButton("🔄 Refresh")
refresh_script_btn.clicked.connect(self.refresh_script_mods)
btn_layout.addWidget(refresh_script_btn)
toggle_script_btn = QPushButton("🔄 Toggle Selected")
toggle_script_btn.clicked.connect(self._toggle_script_mod)
btn_layout.addWidget(toggle_script_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
return widget
# --- Conflicts tab ---
def _create_conflicts_tab(self):
widget = QWidget()
layout = QVBoxLayout(widget)
layout.addWidget(QLabel("⚠️ Real file conflicts — files that appear in multiple .archive mods:"))
self.conflict_tree = QTreeWidget()
self.conflict_tree.setHeaderLabels(["Internal File Path", "Mod 1", "Mod 2", "Mod 3", "Count"])
self.conflict_tree.setAlternatingRowColors(True)
self.conflict_tree.setColumnWidth(0, 350)
layout.addWidget(self.conflict_tree, 1)
# Progress bar for scanning
self.conflict_progress = QProgressBar()
self.conflict_progress.setVisible(False)
layout.addWidget(self.conflict_progress)
btn_layout = QHBoxLayout()
scan_btn = QPushButton("🔍 Deep Scan (Parse All Archives)")
scan_btn.clicked.connect(self.scan_conflicts_deep)
btn_layout.addWidget(scan_btn)
quick_btn = QPushButton("⚡ Quick Scan (Names Only)")
quick_btn.clicked.connect(self.scan_conflicts_quick)
btn_layout.addWidget(quick_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
return widget
# --- Profiles tab ---
def _create_profiles_tab(self):
widget = QWidget()
layout = QVBoxLayout(widget)
layout.addWidget(QLabel("Save and load mod configurations as profiles:"))
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)
self.profile_list = QListWidget()
self.profile_list.itemDoubleClicked.connect(lambda item: self.load_profile(item.text()))
layout.addWidget(self.profile_list, 1)
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
# --- Nexus tab ---
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 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 & Install")
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)
# Browser integration
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
# --- Settings tab ---
def _create_settings_tab(self):
widget = QWidget()
layout = QVBoxLayout(widget)
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)
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)
# Tools check
tools_group = QGroupBox("Extraction Tools")
tools_layout = QVBoxLayout(tools_group)
tools_layout.addWidget(QLabel("Required for auto-extracting .7z / .zip / .rar downloads:"))
for tool, pkg in [("7z", "p7zip"), ("unrar", "unrar"), ("zipinfo", "zip")]:
found = shutil.which(tool) is not None
label = QLabel(f" {'' if found else ''} {tool} ({pkg})")
label.setStyleSheet(f"color: {'#00ff9f' if found else '#ff0080'};")
tools_layout.addWidget(label)
layout.addWidget(tools_group)
layout.addStretch()
return widget
# ===========================================================================
# ACTIONS
# ===========================================================================
def get_mod_dir(self):
return Path(self.config["game_path"]) / self.config["mod_dir"]
def refresh_all(self):
"""Refresh mods, script mods, and profiles."""
self.refresh_mods()
self.refresh_script_mods()
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 refresh_script_mods(self):
"""Scan for script-based mods."""
self.script_mods = scan_script_mods(self.config["game_path"])
self._update_script_list()
def _update_mod_list(self):
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")
# Parse archive for file count (lazy — only if not already parsed)
if not mod.info:
mod.info = read_archive_info(mod.path)
file_count = mod.info.get("file_count", 0)
item.setText(5, str(file_count) if file_count else "?")
if not mod.enabled:
item.setForeground(4, QColor("#ff0080"))
else:
item.setForeground(4, QColor("#00ff9f"))
item.setData(0, Qt.ItemDataRole.UserRole, mod)
self.mod_list.addTopLevelItem(item)
self.mod_list.blockSignals(False)
def _update_script_list(self):
"""Update the script mods tree."""
self.script_tree.clear()
icons = {
"redscript": "📜",
"tweakxl": "⚙️",
"cet": "🔧",
"red4ext": "📦",
"archivexl": "🗂️",
}
total_mods = 0
total_files = 0
for category, files in self.script_mods.items():
if not files:
continue
# Group by mod name (top-level folder or filename)
mod_groups = {}
for f in files:
# Extract mod name from path
path_parts = f.split("/")
if category == "cet" and len(path_parts) >= 7:
# bin/x64/plugins/cyber_engine_tweaks/mods/<modname>/...
mod_name = path_parts[6]
elif category == "red4ext" and len(path_parts) >= 3:
# red4ext/plugins/<modname>/...
mod_name = path_parts[2]
elif category == "redscript" and len(path_parts) >= 3:
# r6/scripts/<modname>/...
mod_name = path_parts[2] if path_parts[2] != "r6" else path_parts[-1]
elif category == "tweakxl" and len(path_parts) >= 3:
# r6/tweaks/<modname>/...
mod_name = path_parts[2] if path_parts[2] != "r6" else path_parts[-1]
else:
mod_name = path_parts[-1]
if mod_name not in mod_groups:
mod_groups[mod_name] = []
mod_groups[mod_name].append(f)
parent = QTreeWidgetItem()
parent.setText(0, f"{icons.get(category, '📋')} {category.upper()}")
parent.setText(1, f"{len(mod_groups)} mods")
parent.setText(3, f"{len(files)} files")
parent.setText(4, "✅ Active")
parent.setForeground(0, QColor("#00ff9f"))
parent.setForeground(4, QColor("#00ff9f"))
parent.setExpanded(True)
for mod_name, mod_files in sorted(mod_groups.items()):
child = QTreeWidgetItem()
child.setText(1, mod_name)
child.setText(3, str(len(mod_files)))
# Check if disabled (.disabled suffix on folder or file)
is_disabled = any(".disabled" in f for f in mod_files)
child.setText(4, "❌ Disabled" if is_disabled else "✅ Active")
if is_disabled:
child.setForeground(4, QColor("#ff0080"))
else:
child.setForeground(4, QColor("#00ff9f"))
child.setData(0, Qt.ItemDataRole.UserRole, {
"category": category,
"mod_name": mod_name,
"files": mod_files,
"disabled": is_disabled
})
# Show first file as path hint
child.setText(2, mod_files[0] if mod_files else "")
parent.addChild(child)
total_mods += 1
total_files += len(mod_files)
self.script_tree.addTopLevelItem(parent)
parent.setExpanded(True)
self.script_info_label.setText(f"Total: {total_mods} mods, {total_files} files")
def _on_mod_checkbox_changed(self, item, column):
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):
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()
# Show internal files
show_files = QAction("📋 Show Internal Files", self)
show_files.triggered.connect(lambda: self._show_internal_files(mod))
menu.addAction(show_files)
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 _show_internal_files(self, mod):
"""Show internal file list of a mod in the details panel."""
if not mod.info:
mod.info = read_archive_info(mod.path)
info = mod.info
text = f"<b>{mod.name}</b><br>"
text += f"Size: {mod.size_mb:.1f} MB<br>"
text += f"Version: {info.get('version', '?')}<br>"
text += f"Files: {info.get('file_count', '?')}<br><br>"
files = info.get("files", [])
if files:
text += "<b>Internal files:</b><br>"
for f in files[:50]:
text += f" {f}<br>"
if len(files) > 50:
text += f" ... and {len(files) - 50} more<br>"
else:
text += "<i>No file list could be parsed</i><br>"
self.details_label.setHtml(text)
def _move_mod(self, item, up=True):
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)
self._apply_order_from_list()
def _apply_order_from_list(self):
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):
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 install_mod(self, filepath):
"""Install a mod file — auto-detect and handle .archive, .zip, .7z, .yaml, .reds"""
game_path = self.config["game_path"]
installed = install_mod_file(filepath, game_path,
status_callback=lambda msg: self.status.showMessage(msg))
if installed:
msg = f"Installed {len(installed)} files:\n" + "\n".join(installed[:20])
if len(installed) > 20:
msg += f"\n... and {len(installed) - 20} more"
self.status.showMessage(f"Installed {len(installed)} files from {Path(filepath).name}")
QMessageBox.information(self, "Mod Installed", msg)
self.refresh_all()
else:
QMessageBox.warning(self, "Install Failed",
f"Could not install {filepath}.\nUnsupported file type or extraction failed.")
def add_mod(self):
"""Add mod files from file dialog — supports .archive, .zip, .7z, .rar, .yaml, .reds"""
files, _ = QFileDialog.getOpenFileNames(self, "Select mod files",
"", "Mod files (*.archive *.zip *.7z *.rar *.yaml *.yml *.reds *.xl);;All files (*.*)")
for f in files:
self.install_mod(f)
def add_script_mod(self):
"""Add script mod files — .yaml, .reds, .zip, .7z"""
files, _ = QFileDialog.getOpenFileNames(self, "Select script mod files",
"", "Script mods (*.yaml *.yml *.reds *.zip *.7z);;All files (*.*)")
for f in files:
self.install_mod(f)
def enable_all_mods(self):
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):
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):
self._apply_order_from_list()
self.status.showMessage("Load order applied")
def browse_game_path(self):
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_all()
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")
# --- Conflict scanning ---
def scan_conflicts_quick(self):
"""Quick conflict scan based on mod names only."""
self.conflict_tree.clear()
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)
for base, mod_list in name_map.items():
if len(mod_list) > 1:
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(4, "2")
item.setForeground(3, QColor("#ff9500"))
self.conflict_tree.addTopLevelItem(item)
if self.conflict_tree.topLevelItemCount() == 0:
item = QTreeWidgetItem()
item.setText(0, "No conflicts detected ✅")
self.conflict_tree.addTopLevelItem(item)
self.status.showMessage(f"Quick scan: {self.conflict_tree.topLevelItemCount()} items")
def scan_conflicts_deep(self):
"""Deep conflict scan — parse all .archive files and compare internal paths."""
self.conflict_tree.clear()
self.conflict_progress.setVisible(True)
self.conflict_progress.setRange(0, len(self.mods))
def progress(current, total, msg):
self.conflict_progress.setValue(current)
self.status.showMessage(msg)
QApplication.processEvents()
conflicts = scan_archive_conflicts(self.mods, progress_callback=progress)
self.conflict_progress.setVisible(False)
if not conflicts:
item = QTreeWidgetItem()
item.setText(0, "No conflicts detected ✅")
self.conflict_tree.addTopLevelItem(item)
else:
for c in sorted(conflicts, key=lambda x: -x["count"]):
item = QTreeWidgetItem()
item.setText(0, c["file"])
for i, mod_name in enumerate(c["mods"][:3]):
item.setText(i + 1, mod_name)
item.setText(4, str(c["count"]))
if c["count"] > 2:
item.setForeground(0, QColor("#ff0080"))
else:
item.setForeground(0, QColor("#ff9500"))
self.conflict_tree.addTopLevelItem(item)
self.status.showMessage(f"Deep scan: {len(conflicts)} conflicting files found")
# --- Profiles ---
def _refresh_profiles(self):
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)
if hasattr(self, "profile_list"):
self.profile_list.clear()
self.profile_list.addItems(profiles)
def _save_profile_data(self, name):
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):
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):
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)
desired = {m["name"]: m["enabled"] for m in data.get("mods", [])}
desired_order = [m["name"] for m in data.get("mods", [])]
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)
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 ---
def save_nexus_key(self):
key = self.nexus_key_input.text().strip()
self.config["nexus_api_key"] = key
save_config(self.config)
NEXUS_API_KEY_FILE.write_text(key)
NEXUS_API_KEY_FILE.chmod(0o600)
self.status.showMessage("Nexus API key saved")
def download_nxm(self):
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(4)
return
self.nexus_progress.setVisible(True)
self.nexus_progress.setRange(0, 0)
self.nexus_status.setText("Downloading...")
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)
def on_done():
self.nexus_progress.setVisible(False)
if error:
self.nexus_status.setText(f"{error}")
elif filepath:
self.nexus_status.setText(f"✅ Downloaded: {filepath.name}")
# Auto-install
installed = install_mod_file(filepath, self.config["game_path"],
status_callback=lambda msg: self.status.showMessage(msg))
if installed:
msg = f"Downloaded and installed {len(installed)} files:\n" + "\n".join(installed[:20])
if len(installed) > 20:
msg += f"\n... and {len(installed) - 20} more"
QMessageBox.information(self, "Mod Installed", msg)
self.refresh_all()
else:
QMessageBox.warning(self, "Install Failed",
f"Downloaded {filepath.name} but could not auto-install.\n"
f"File saved at: {filepath}")
QTimer.singleShot(0, on_done)
threading.Thread(target=do_download, daemon=True).start()
def register_nxm_protocol(self):
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)
subprocess.run(["update-desktop-database", str(desktop_dir)], capture_output=True)
self.status.showMessage("nxm:// protocol registered!")
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.")
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]
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)
print(f"Downloaded: {filepath}")
# Auto-install
installed = install_mod_file(filepath, config["game_path"])
if installed:
print(f"Installed {len(installed)} files to {config['game_path']}")
else:
print(f"File saved at: {filepath} (manual install needed)")
sys.exit(0)
# Normal GUI mode
window = ModManagerWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()