Files
neon-mod-manager/mod_manager.py
T
arch_agent 96f24a8b25 Neon Mod Manager v0.1 — Cyberpunk 2077 mod manager for Linux
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
2026-07-13 10:24:16 +02:00

1005 lines
38 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
- 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()