diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml new file mode 100644 index 0000000..cc98c11 --- /dev/null +++ b/.github/workflows/build-check.yml @@ -0,0 +1,58 @@ +name: Build Check + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read version + id: version + shell: bash + run: | + VERSION=$(cat VERSION | tr -d '[:space:]') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Build exe with PyInstaller + run: python build/build.py + + - name: Verify exe exists + shell: bash + run: | + if [ -f "dist/FromSoftModManager/FromSoftModManager.exe" ]; then + echo "✅ PyInstaller build successful" + else + echo "❌ FromSoftModManager.exe not found" + exit 1 + fi + + - name: Install Inno Setup + run: choco install innosetup -y --no-progress + + - name: Compile installer + run: iscc "/DAppVersion=${{ steps.version.outputs.version }}" build\installer.iss + + - name: Verify installer exists + shell: bash + run: | + INSTALLER="dist/FromSoftModManager_Setup_v${{ steps.version.outputs.version }}.exe" + if [ -f "$INSTALLER" ]; then + echo "✅ Installer build successful" + else + echo "❌ Installer not found at $INSTALLER" + exit 1 + fi diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 9a13a84..96a1ea6 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -16,6 +16,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Read version id: version @@ -25,6 +27,47 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "tag=v$VERSION" >> $GITHUB_OUTPUT + - name: Generate release notes from commits + id: notes + shell: bash + run: | + # Find the previous tag + PREV_TAG=$(git tag --sort=-v:refname | head -n 1) + if [ -z "$PREV_TAG" ]; then + RANGE="HEAD" + else + RANGE="${PREV_TAG}..HEAD" + fi + echo "Previous tag: $PREV_TAG" + echo "Range: $RANGE" + + # Build release notes from commit titles and descriptions + NOTES="## What's Changed"$'\n\n' + while IFS= read -r line; do + if [ -z "$line" ]; then + continue + fi + # Split at first | to get hash, title, and body + HASH=$(echo "$line" | cut -d'|' -f1) + TITLE=$(echo "$line" | cut -d'|' -f2) + BODY=$(echo "$line" | cut -d'|' -f3-) + + NOTES+="- **${TITLE}** (\`${HASH}\`)"$'\n' + if [ -n "$BODY" ]; then + # Indent each line of the body + while IFS= read -r bline; do + bline=$(echo "$bline" | sed 's/^[[:space:]]*//') + if [ -n "$bline" ]; then + NOTES+=" ${bline}"$'\n' + fi + done <<< "$BODY" + fi + NOTES+=$'\n' + done < <(git log "$RANGE" --pretty=format:"%h|%s|%b" --no-merges) + + # Write to file to avoid escaping issues + echo "$NOTES" > release_notes.md + - name: Set up Python uses: actions/setup-python@v5 with: @@ -55,7 +98,7 @@ jobs: name: Release v${{ steps.version.outputs.version }} draft: false prerelease: false - generate_release_notes: true + body_path: release_notes.md files: | dist/FromSoftModManager_Setup_v${{ steps.version.outputs.version }}.exe dist/FromSoftModManager_v${{ steps.version.outputs.version }}_portable.zip diff --git a/.gitignore b/.gitignore index e8b0097..c557e48 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ mods/ # Build artifacts build/output/ +build/_version.iss build_pyinstaller/ dist/ *.spec diff --git a/VERSION b/VERSION index e9307ca..50ffc5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.2 +2.0.3 diff --git a/app/config/config_manager.py b/app/config/config_manager.py index b8d29c6..60e728c 100644 --- a/app/config/config_manager.py +++ b/app/config/config_manager.py @@ -3,20 +3,47 @@ """ import os +import sys import json from datetime import datetime from pathlib import Path APP_NAME = "FromSoftModManager" -_APP_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# When running as a PyInstaller build, __file__ resolves inside _internal/ +# which is wrong for user data. Use the exe directory instead so that +# config.json and mods/ live next to the exe and persist across updates. +if getattr(sys, "frozen", False): + _APP_DIR = os.path.dirname(sys.executable) +else: + _APP_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + CONFIG_FILE = os.path.join(_APP_DIR, "config.json") _DEFAULT_MODS_DIR = os.path.join(_APP_DIR, "mods") class ConfigManager: def __init__(self): + self._migrate_legacy_config() self._config = self._load() + # ------------------------------------------------------------------ + # Migration + # ------------------------------------------------------------------ + @staticmethod + def _migrate_legacy_config(): + """Move config.json out of _internal/ if it was created there by an + older build. Only relevant for PyInstaller builds.""" + if not getattr(sys, "frozen", False): + return + legacy = os.path.join(os.path.dirname(sys.executable), "_internal", "config.json") + if os.path.isfile(legacy) and not os.path.isfile(CONFIG_FILE): + try: + import shutil + shutil.move(legacy, CONFIG_FILE) + except OSError: + pass + # ------------------------------------------------------------------ # Low-level load / save # ------------------------------------------------------------------ diff --git a/app/services/update_service.py b/app/services/update_service.py new file mode 100644 index 0000000..05a11b3 --- /dev/null +++ b/app/services/update_service.py @@ -0,0 +1,151 @@ +""" +App self-update service — check GitHub releases and download the installer. +""" + +import os +import sys +import subprocess +import tempfile +import urllib.request + +GITHUB_API = "https://api.github.com/repos/spikehockey75/FromSoftModManager/releases/latest" +USER_AGENT = "FromSoftModManager/2.0" + + +def get_current_version() -> str: + """Read the app version from the bundled VERSION file.""" + if getattr(sys, "frozen", False): + base = os.path.join(sys._MEIPASS) + else: + base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + version_file = os.path.join(base, "VERSION") + try: + with open(version_file, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception: + return "0.0.0" + + +def _parse_version(v: str) -> tuple: + """Convert 'X.Y.Z' to a comparable tuple.""" + try: + return tuple(int(x) for x in v.lstrip("v").split(".")) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def get_latest_release() -> dict: + """Fetch the latest release info from GitHub. + + Returns {"version", "download_url", "name"} on success, + or {"error": str} on failure. + """ + try: + import json + req = urllib.request.Request( + GITHUB_API, + headers={ + "User-Agent": USER_AGENT, + "Accept": "application/vnd.github+json", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + + tag = data.get("tag_name", "") + assets = data.get("assets", []) + + # Prefer the Setup installer exe + installer = next( + (a for a in assets if "Setup" in a["name"] and a["name"].endswith(".exe")), + None, + ) + if not installer: + # Fall back to any exe or zip + installer = next( + (a for a in assets if a["name"].endswith((".exe", ".zip"))), + None, + ) + + return { + "version": tag.lstrip("v"), + "download_url": installer["browser_download_url"] if installer else "", + "name": installer["name"] if installer else "", + } + except Exception as e: + return {"error": str(e)} + + +def check_for_update() -> dict: + """Compare current version with latest GitHub release. + + Returns {"has_update", "current", "latest", "download_url"}. + On error returns {"has_update": False, "error": str}. + """ + current = get_current_version() + release = get_latest_release() + + if "error" in release: + return {"has_update": False, "current": current, "error": release["error"]} + + latest = release.get("version", "") + has_update = _parse_version(latest) > _parse_version(current) + + return { + "has_update": has_update, + "current": current, + "latest": latest, + "download_url": release.get("download_url", ""), + "name": release.get("name", ""), + } + + +def download_and_run_installer(download_url: str, progress_callback=None) -> dict: + """Download the installer exe and launch it. + + progress_callback(message: str, percent: int) + Returns {"success": bool, "message": str}. + """ + if not download_url: + return {"success": False, "message": "No download URL available"} + + if progress_callback: + progress_callback("Downloading update…", 5) + + tmp_dir = tempfile.mkdtemp(prefix="fsmm_update_") + filename = download_url.rsplit("/", 1)[-1] or "FromSoftModManager_Setup.exe" + installer_path = os.path.join(tmp_dir, filename) + + try: + req = urllib.request.Request(download_url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=120) as resp, \ + open(installer_path, "wb") as f: + total = int(resp.headers.get("Content-Length", 0)) + downloaded = 0 + while True: + chunk = resp.read(65536) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total and progress_callback: + pct = 5 + int((downloaded / total) * 85) + progress_callback( + f"Downloading… {downloaded // 1024}KB / {total // 1024}KB", pct + ) + except Exception as e: + return {"success": False, "message": f"Download failed: {e}"} + + if progress_callback: + progress_callback("Launching installer…", 95) + + try: + # Launch the installer detached — it will close this app via CloseApplications + subprocess.Popen( + [installer_path], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + if sys.platform == "win32" else 0, + ) + return {"success": True, "message": "Installer launched"} + except Exception as e: + return {"success": False, "message": f"Could not launch installer: {e}"} diff --git a/app/ui/dialogs/add_mod_dialog.py b/app/ui/dialogs/add_mod_dialog.py index 879ab2e..a3562b2 100644 --- a/app/ui/dialogs/add_mod_dialog.py +++ b/app/ui/dialogs/add_mod_dialog.py @@ -267,9 +267,15 @@ def _on_premium_fallback(self, mod_name: str, nexus_url: str): self._progress_panel.setVisible(False) webbrowser.open(nexus_url) self._error_lbl.setText( - "Nexus Premium is required for direct API downloads.\n" - "The mod page has been opened in your browser — download " - "the file manually, then select it below." + "Free Nexus account — direct downloads require Premium.\n" + "The mod page has been opened in your browser.\n\n" + "Steps:\n" + " 1. Click the FILES tab on the Nexus page\n" + " 2. Click \"Manual Download\" on the file you want\n" + " 3. Wait for the download to finish\n" + " 4. Use the \"Install from ZIP\" section below to\n" + " browse to the downloaded .zip / .7z / .rar file\n" + " (usually in your Downloads folder)" ) self._error_lbl.setStyleSheet("font-size:11px;color:#ff9800;") self._error_lbl.setVisible(True) diff --git a/app/ui/dialogs/me3_setup_dialog.py b/app/ui/dialogs/me3_setup_dialog.py index c6a2de7..3ca6a6b 100644 --- a/app/ui/dialogs/me3_setup_dialog.py +++ b/app/ui/dialogs/me3_setup_dialog.py @@ -39,8 +39,9 @@ def _build(self): # Icon + title row title_row = QHBoxLayout() - icon_lbl = QLabel("⚙") - icon_lbl.setStyleSheet("font-size:32px;") + icon_lbl = QLabel("\uE713") + icon_lbl.setFont(QFont("Segoe MDL2 Assets", 24)) + icon_lbl.setStyleSheet("color:#e0e0ec;") title_row.addWidget(icon_lbl) title = QLabel("Mod Engine 3 Required") diff --git a/app/ui/dialogs/settings_dialog.py b/app/ui/dialogs/settings_dialog.py index 1ce0285..d39f863 100644 --- a/app/ui/dialogs/settings_dialog.py +++ b/app/ui/dialogs/settings_dialog.py @@ -10,6 +10,7 @@ class SettingsDialog(QDialog): settings_saved = Signal() + _update_checked = Signal(object) # internal: update check result from bg thread def __init__(self, config: ConfigManager, parent=None): super().__init__(parent) @@ -120,6 +121,30 @@ def _build(self): mods_layout.addRow("", mods_help) layout.addWidget(mods_group) + # ── App Updates ──────────────────────────────────────── + update_group = QGroupBox("App Updates") + update_layout = QFormLayout(update_group) + update_layout.setSpacing(10) + + from app.services.update_service import get_current_version + version_lbl = QLabel(f"v{get_current_version()}") + version_lbl.setStyleSheet("font-size:12px;color:#e0e0ec;font-weight:600;") + update_layout.addRow("Current version:", version_lbl) + + check_row = QHBoxLayout() + self._check_update_btn = QPushButton("Check for Updates") + self._check_update_btn.setObjectName("btn_blue") + self._check_update_btn.setFixedWidth(160) + self._check_update_btn.clicked.connect(self._check_for_updates) + check_row.addWidget(self._check_update_btn) + self._update_status_lbl = QLabel("") + self._update_status_lbl.setStyleSheet("font-size:11px;color:#8888aa;") + check_row.addWidget(self._update_status_lbl) + check_row.addStretch() + update_layout.addRow("", check_row) + + layout.addWidget(update_group) + # ── Buttons ─────────────────────────────────────────── btn_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) btn_box.accepted.connect(self._save) @@ -195,6 +220,35 @@ def _import_me3_profiles(self): dlg = ME2MigrationDialog(merged, me3_path, self._config, parent=self) dlg.exec() + def _check_for_updates(self): + self._check_update_btn.setEnabled(False) + self._update_status_lbl.setText("Checking…") + self._update_status_lbl.setStyleSheet("font-size:11px;color:#8888aa;") + self._update_checked.connect(self._on_update_check_done) + + import threading + + def _work(): + from app.services.update_service import check_for_update + result = check_for_update() + self._update_checked.emit(result) + + threading.Thread(target=_work, daemon=True).start() + + def _on_update_check_done(self, result): + self._check_update_btn.setEnabled(True) + self._update_checked.disconnect(self._on_update_check_done) + if result.get("error"): + self._update_status_lbl.setText(f"Error: {result['error']}") + self._update_status_lbl.setStyleSheet("font-size:11px;color:#e74c3c;") + elif result.get("has_update"): + latest = result.get("latest", "?") + self._update_status_lbl.setText(f"Update available: v{latest}") + self._update_status_lbl.setStyleSheet("font-size:11px;color:#b0d880;font-weight:600;") + else: + self._update_status_lbl.setText("Up to date") + self._update_status_lbl.setStyleSheet("font-size:11px;color:#4a6a2a;font-weight:600;") + def _browse_mods_dir(self): path = QFileDialog.getExistingDirectory( self, "Select Mod Storage Directory", self._mods_dir.text() or "" diff --git a/app/ui/game_page.py b/app/ui/game_page.py index 7b70787..f658351 100644 --- a/app/ui/game_page.py +++ b/app/ui/game_page.py @@ -5,7 +5,21 @@ from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTabWidget, QFrame) from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QIcon, QPixmap, QPainter, QColor from app.config.config_manager import ConfigManager + +_MDL2 = "Segoe MDL2 Assets" + + +def _mdl2_icon(char: str, size: int = 16, color: str = "#c0c0d8") -> QIcon: + px = QPixmap(size, size) + px.fill(QColor("transparent")) + p = QPainter(px) + p.setFont(QFont(_MDL2, int(size * 0.75))) + p.setPen(QColor(color)) + p.drawText(px.rect(), Qt.AlignCenter, char) + p.end() + return QIcon(px) from app.core.me3_service import ME3_GAME_MAP from app.ui.tabs.settings_tab import ME3ProfileTab from app.ui.tabs.saves_tab import SavesTab @@ -36,16 +50,16 @@ def _build(self): self._mods_tab = ModsTab(self._game_id, self._game_info, self._config) self._saves_tab = SavesTab(self._game_id, self._game_info, self._config) - self._tabs.addTab(self._mods_tab, "📦 Mods") + self._tabs.addTab(self._mods_tab, _mdl2_icon("\uE7B8", 16), "Mods") # ME3 Profile tab — only for ME3-supported games self._profile_tab = None if self._game_id in ME3_GAME_MAP: self._profile_tab = ME3ProfileTab(self._game_id, self._game_info, self._config) - self._tabs.addTab(self._profile_tab, "⚙ ME3 Profile") + self._tabs.addTab(self._profile_tab, _mdl2_icon("\uE713", 16), "ME3 Profile") self._profile_tab.log_message.connect(self.log_message) - self._tabs.addTab(self._saves_tab, "💾 Saves") + self._tabs.addTab(self._saves_tab, _mdl2_icon("\uE74E", 16), "Saves") self._tabs.currentChanged.connect(self._on_tab_changed) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index edafb36..37c67ad 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -12,7 +12,23 @@ from PySide6.QtCore import Qt, Signal, QThread, QObject, QTimer, QSize from PySide6.QtGui import QPixmap, QShortcut, QKeySequence +from PySide6.QtGui import QFont as _QFont, QIcon as _QIcon, QPixmap as _QPixmap, QPainter as _QPainter, QColor as _QColor from app.config.config_manager import ConfigManager + +# Windows 11 native icon font — used for all UI icons +_MDL2 = "Segoe MDL2 Assets" + + +def _mdl2_icon(char: str, size: int = 16, color: str = "#c0c0d8") -> _QIcon: + """Render a Segoe MDL2 Assets glyph to a QIcon.""" + px = _QPixmap(size, size) + px.fill(_QColor("transparent")) + p = _QPainter(px) + p.setFont(_QFont(_MDL2, int(size * 0.75))) + p.setPen(_QColor(color)) + p.drawText(px.rect(), Qt.AlignCenter, char) + p.end() + return _QIcon(px) from app.core.game_scanner import scan_for_games from app.ui.sidebar import Sidebar from app.ui.game_page import GamePage @@ -69,8 +85,8 @@ def _build(self): tb_layout = QHBoxLayout(title_bar) tb_layout.setContentsMargins(14, 0, 14, 0) - logo = QLabel("🎮") - logo.setStyleSheet("font-size:18px;") + logo = QLabel() + logo.setPixmap(_mdl2_icon("\uE7FC", 20, "#e0e0ec").pixmap(20, 20)) tb_layout.addWidget(logo) app_name = QLabel("FromSoft Mod Manager") @@ -84,6 +100,42 @@ def _build(self): root.addWidget(title_bar) + # ── Update banner (hidden until update detected) ──────── + self._update_banner = QFrame() + self._update_banner.setVisible(False) + self._update_banner.setFixedHeight(36) + self._update_banner.setStyleSheet( + "QFrame{background:#2d3a1e;border-bottom:1px solid #4a6a2a;}" + ) + ub_layout = QHBoxLayout(self._update_banner) + ub_layout.setContentsMargins(14, 0, 14, 0) + self._update_lbl = QLabel("") + self._update_lbl.setStyleSheet("font-size:12px;color:#b0d880;font-weight:600;") + ub_layout.addWidget(self._update_lbl) + ub_layout.addStretch() + self._update_now_btn = QPushButton("Update Now") + self._update_now_btn.setFixedHeight(24) + self._update_now_btn.setStyleSheet( + "QPushButton{font-size:11px;color:#fff;background:#4a6a2a;" + "border:none;border-radius:4px;padding:2px 12px;font-weight:600;}" + "QPushButton:hover{background:#5a8a3a;}" + ) + self._update_now_btn.clicked.connect(self._on_update_now) + ub_layout.addWidget(self._update_now_btn) + dismiss_btn = QPushButton("\uE711") + dismiss_btn.setFont(_QFont(_MDL2, 10)) + dismiss_btn.setFixedSize(24, 24) + dismiss_btn.setStyleSheet( + "QPushButton{color:#8a8a6a;background:transparent;border:none;}" + "QPushButton:hover{color:#b0d880;}" + ) + dismiss_btn.setToolTip("Dismiss") + dismiss_btn.clicked.connect(lambda: self._update_banner.setVisible(False)) + ub_layout.addWidget(dismiss_btn) + root.addWidget(self._update_banner) + + self._update_download_url = "" + # ── Main content area (splitter) ─────────────────────── self._splitter = QSplitter(Qt.Horizontal) self._splitter.setHandleWidth(1) @@ -129,8 +181,9 @@ def _build_landing(self) -> QWidget: layout.setAlignment(Qt.AlignCenter) layout.setSpacing(16) - icon = QLabel("🎮") - icon.setStyleSheet("font-size:56px;") + icon = QLabel("\uE7FC") + icon.setFont(_QFont(_MDL2, 48)) + icon.setStyleSheet("color:#3a3a5a;") icon.setAlignment(Qt.AlignCenter) layout.addWidget(icon) @@ -144,7 +197,8 @@ def _build_landing(self) -> QWidget: subtitle.setAlignment(Qt.AlignCenter) layout.addWidget(subtitle) - scan_btn = QPushButton("🔍 Scan for Games") + scan_btn = QPushButton("Scan for Games") + scan_btn.setIcon(_mdl2_icon("\uE721", 20, "#ffffff")) scan_btn.setObjectName("btn_accent") scan_btn.setFixedWidth(200) scan_btn.setFixedHeight(44) @@ -410,6 +464,27 @@ def _launch(): threading.Thread(target=_launch, daemon=True).start() + def _find_coop_ini(self, game_id: str, game_info: dict, gdef: dict) -> str | None: + """Locate the co-op settings INI, checking the mod's stored path + first (ME3 mod dir), then falling back to the game directory.""" + # Check ME3 mod directory first (where Manage dialog edits) + coop_id = f"{game_id}-coop" + for m in self._config.get_game_mods(game_id): + if m["id"] == coop_id and m.get("path") and os.path.isdir(m["path"]): + for root, _dirs, files in os.walk(m["path"]): + for f in files: + if f.endswith(".ini"): + return os.path.join(root, f) + break + # Fall back to game directory + config_rel = gdef.get("config_relative", "") + install_path = game_info.get("install_path", "") + if config_rel and install_path: + candidate = os.path.join(install_path, config_rel) + if os.path.isfile(candidate): + return candidate + return None + def _check_coop_password(self, game_id: str, game_info: dict) -> bool: """Check if the co-op INI has an empty cooppassword. Prompt if so. @@ -420,13 +495,8 @@ def _check_coop_password(self, game_id: str, game_info: dict) -> bool: if "cooppassword" not in gdef.get("defaults", {}): return True - config_rel = gdef.get("config_relative", "") - install_path = game_info.get("install_path", "") - if not config_rel or not install_path: - return True - - ini_path = os.path.join(install_path, config_rel) - if not os.path.isfile(ini_path): + ini_path = self._find_coop_ini(game_id, game_info, gdef) + if not ini_path: return True from app.core.ini_parser import read_ini_value @@ -465,6 +535,22 @@ def _poll_updates(self): elif tag == "update_check": _, game_id, game_name, result = item self._on_update_checked(game_id, game_name, result) + elif tag == "app_update": + _, result = item + latest = result.get("latest", "") + self._update_download_url = result.get("download_url", "") + self._update_lbl.setText(f"Update available: v{latest}") + self._update_banner.setVisible(True) + self._terminal.log(f"App update available: v{latest}", "warn") + elif tag == "app_update_done": + _, result = item + if result.get("success"): + self._terminal.log("Installer launched — closing app…", "success") + QApplication.quit() + else: + self._terminal.log(f"Update failed: {result.get('message', 'unknown error')}", "error") + self._update_now_btn.setEnabled(True) + self._update_now_btn.setText("Update Now") elif tag == "launch_result": _, name, success, method = item if success: @@ -487,6 +573,33 @@ def _on_update_checked(self, game_id: str, game_name: str, result: dict): self._terminal.log(f"{game_name}: update available → v{latest}", "warning") self._sidebar.set_update_badge(game_id, True) + # ------------------------------------------------------------------ + # App self-update + # ------------------------------------------------------------------ + def _on_update_now(self): + """Download the latest installer and launch it.""" + url = self._update_download_url + if not url: + self._terminal.log("No download URL available", "error") + return + + self._update_now_btn.setEnabled(False) + self._update_now_btn.setText("Downloading…") + self._terminal.setVisible(True) + self._terminal.log("Downloading app update…", "info") + pending = self._pending + + def _download(): + from app.services.update_service import download_and_run_installer + + def _progress(msg, pct): + pending.put(("log", msg, "info")) + + result = download_and_run_installer(url, progress_callback=_progress) + pending.put(("app_update_done", result)) + + threading.Thread(target=_download, daemon=True).start() + def _check_all_mod_updates(self): """Fire background update checks for all installed mods across all games.""" api_key = self._config.get_nexus_api_key() diff --git a/app/ui/nexus_widget.py b/app/ui/nexus_widget.py index 3a34874..fb2db21 100644 --- a/app/ui/nexus_widget.py +++ b/app/ui/nexus_widget.py @@ -5,7 +5,7 @@ QPushButton, QDialog, QLineEdit, QDialogButtonBox, QFrame) from PySide6.QtCore import Qt, Signal, QTimer, QThread, QObject -from PySide6.QtGui import QPixmap, QCursor +from PySide6.QtGui import QPixmap, QCursor, QFont, QIcon, QPainter, QColor from app.config.config_manager import ConfigManager from app.services.nexus_service import NexusService from app.services.nexus_sso import NexusSSOClient @@ -199,6 +199,7 @@ def closeEvent(self, event): class NexusWidget(QWidget): """Top of sidebar — shows login button or logged-in user.""" auth_changed = Signal(str) # emits api_key on change + _avatar_ready = Signal(bytes) # internal: avatar image data from bg thread def __init__(self, config: ConfigManager, parent=None): super().__init__(parent) @@ -210,6 +211,7 @@ def __init__(self, config: ConfigManager, parent=None): QTimer.singleShot(500, self._revalidate_key) def _build(self): + self._avatar_ready.connect(self._on_avatar_ready) self._layout = QVBoxLayout(self) self._layout.setContentsMargins(10, 10, 10, 10) self._layout.setSpacing(6) @@ -240,9 +242,12 @@ def _build(self): self._avatar_lbl = QLabel() self._avatar_lbl.setFixedSize(32, 32) + self._avatar_lbl.setAlignment(Qt.AlignCenter) self._avatar_lbl.setStyleSheet( "background:#2a2a4a;border-radius:16px;border:1px solid #3a3a5a;" ) + # Default person icon + self._set_default_avatar() ul.addWidget(self._avatar_lbl) user_info = QVBoxLayout() @@ -258,6 +263,17 @@ def _build(self): self._layout.addWidget(self._user_widget) + def _set_default_avatar(self): + """Render a person icon as the default avatar.""" + px = QPixmap(32, 32) + px.fill(QColor("transparent")) + p = QPainter(px) + p.setFont(QFont("Segoe MDL2 Assets", 16)) + p.setPen(QColor("#8888aa")) + p.drawText(px.rect(), Qt.AlignCenter, "\uE77B") # Contact icon + p.end() + self._avatar_lbl.setPixmap(px) + def _refresh(self): key = self._config.get_nexus_api_key() user = self._config.get_nexus_user_info() @@ -273,6 +289,49 @@ def _refresh(self): self._status_lbl.setStyleSheet( "font-size:10px;color:#4ecca3;" if is_premium else "font-size:10px;color:#8888aa;" ) + # Fetch profile photo in background + profile_url = user.get("profile_url", "") + if profile_url: + self._fetch_avatar(profile_url) + else: + self._set_default_avatar() + + def _fetch_avatar(self, url: str): + """Download the Nexus profile image in the background.""" + import urllib.request + + def _work(): + try: + req = urllib.request.Request(url, headers={"User-Agent": "FromSoftModManager/2.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + self._avatar_ready.emit(resp.read()) + except Exception: + pass + + threading.Thread(target=_work, daemon=True).start() + + def _on_avatar_ready(self, data: bytes): + px = QPixmap() + px.loadFromData(data) + if not px.isNull(): + scaled = px.scaled(32, 32, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + # Crop to center 32x32 if needed + if scaled.width() > 32 or scaled.height() > 32: + x = (scaled.width() - 32) // 2 + y = (scaled.height() - 32) // 2 + scaled = scaled.copy(x, y, 32, 32) + # Apply circular mask + from PySide6.QtGui import QPainterPath, QBrush + circle = QPixmap(32, 32) + circle.fill(QColor("transparent")) + p = QPainter(circle) + p.setRenderHint(QPainter.Antialiasing) + path = QPainterPath() + path.addEllipse(0, 0, 32, 32) + p.setClipPath(path) + p.drawPixmap(0, 0, scaled) + p.end() + self._avatar_lbl.setPixmap(circle) def _revalidate_key(self): """Background check that the stored API key is still valid.""" diff --git a/app/ui/sidebar.py b/app/ui/sidebar.py index 5a29d48..7a9d30c 100644 --- a/app/ui/sidebar.py +++ b/app/ui/sidebar.py @@ -9,9 +9,23 @@ QPushButton, QFrame, QScrollArea, QSizePolicy, QSpacerItem) from PySide6.QtCore import Qt, Signal, QSize, QTimer -from PySide6.QtGui import QPixmap, QIcon, QFont +from PySide6.QtGui import QPixmap, QIcon, QFont, QPainter, QColor from app.config.config_manager import ConfigManager + +# Windows 11 native icon font +_MDL2 = "Segoe MDL2 Assets" + + +def _mdl2_icon(char: str, size: int = 16, color: str = "#c0c0d8") -> QIcon: + px = QPixmap(size, size) + px.fill(QColor("transparent")) + p = QPainter(px) + p.setFont(QFont(_MDL2, int(size * 0.75))) + p.setPen(QColor(color)) + p.drawText(px.rect(), Qt.AlignCenter, char) + p.end() + return QIcon(px) from app.ui.nexus_widget import NexusWidget @@ -175,13 +189,15 @@ def _build(self): mgmt_layout.setContentsMargins(6, 6, 6, 6) mgmt_layout.setSpacing(2) - scan_btn = QPushButton("🔍 Scan Games") + scan_btn = QPushButton("Scan Games") + scan_btn.setIcon(_mdl2_icon("\uE721", 18)) scan_btn.setObjectName("sidebar_mgmt_btn") scan_btn.setFixedHeight(36) scan_btn.clicked.connect(self.scan_requested) mgmt_layout.addWidget(scan_btn) - settings_btn = QPushButton("⚙ Settings") + settings_btn = QPushButton("Settings") + settings_btn.setIcon(_mdl2_icon("\uE713", 18)) settings_btn.setObjectName("sidebar_mgmt_btn") settings_btn.setFixedHeight(36) settings_btn.clicked.connect(self.settings_requested) diff --git a/app/ui/tabs/launch_tab.py b/app/ui/tabs/launch_tab.py index df9b656..c9776e5 100644 --- a/app/ui/tabs/launch_tab.py +++ b/app/ui/tabs/launch_tab.py @@ -91,7 +91,8 @@ def _build(self): "background:#181830;border:1px solid #2a2a4a;border-radius:8px;" "font-size:40px;color:#3a3a5a;" ) - self._cover.setText("🎮") + self._cover.setFont(QFont("Segoe MDL2 Assets", 32)) + self._cover.setText("\uE7FC") center_layout.addWidget(self._cover, alignment=Qt.AlignHCenter) # Game name @@ -195,19 +196,32 @@ def _cb(msg): self._launch_btn.setText("▶ Launch Co-op") )) + def _find_coop_ini(self) -> str | None: + """Locate the co-op settings INI, checking the mod's stored path + first (ME3 mod dir), then falling back to the game directory.""" + coop_id = f"{self._game_id}-coop" + for m in self._config.get_game_mods(self._game_id): + if m["id"] == coop_id and m.get("path") and os.path.isdir(m["path"]): + for root, _dirs, files in os.walk(m["path"]): + for f in files: + if f.endswith(".ini"): + return os.path.join(root, f) + break + config_rel = self._gdef.get("config_relative", "") + install_path = self._game_info.get("install_path", "") + if config_rel and install_path: + candidate = os.path.join(install_path, config_rel) + if os.path.isfile(candidate): + return candidate + return None + def _check_coop_password(self) -> bool: """Check if the co-op INI has an empty cooppassword. Prompt if so.""" if "cooppassword" not in self._gdef.get("defaults", {}): return True - config_rel = self._gdef.get("config_relative", "") - install_path = self._game_info.get("install_path", "") - if not config_rel or not install_path: - return True - - import os - ini_path = os.path.join(install_path, config_rel) - if not os.path.isfile(ini_path): + ini_path = self._find_coop_ini() + if not ini_path: return True from app.core.ini_parser import read_ini_value, save_ini_settings diff --git a/app/ui/tabs/mods_tab.py b/app/ui/tabs/mods_tab.py index ed6f8c0..ca27082 100644 --- a/app/ui/tabs/mods_tab.py +++ b/app/ui/tabs/mods_tab.py @@ -10,8 +10,22 @@ QPushButton, QScrollArea, QFrame, QProgressBar, QFileDialog, QDialog, QSizePolicy, QInputDialog, QLineEdit) -from PySide6.QtCore import Qt, Signal, QTimer +from PySide6.QtCore import Qt, Signal, QTimer, QSize +from PySide6.QtGui import QIcon, QFont, QPixmap, QPainter, QColor from app.config.config_manager import ConfigManager + +_MDL2 = "Segoe MDL2 Assets" + + +def _mdl2_icon(char: str, size: int = 16, color: str = "#c0c0d8") -> QIcon: + px = QPixmap(size, size) + px.fill(QColor("transparent")) + p = QPainter(px) + p.setFont(QFont(_MDL2, int(size * 0.75))) + p.setPen(QColor(color)) + p.drawText(px.rect(), Qt.AlignCenter, char) + p.end() + return QIcon(px) from app.config.game_definitions import GAME_DEFINITIONS from app.core.mod_installer import install_mod_from_zip from app.core.me3_service import write_me3_profile, find_me3_executable, ME3_GAME_MAP, slugify @@ -105,14 +119,62 @@ def _build(self): top.addWidget(self._ver_lbl) top.addStretch() - # Primary action button - self._primary_btn = QPushButton() - self._primary_btn.setFixedHeight(30) - self._primary_btn.setMinimumWidth(90) - self._primary_btn.clicked.connect( - lambda: self._pending.put(("action", self._mod["id"])) + # Manage button (cog wheel) — installed mods with INI settings + self._manage_btn = QPushButton() + self._manage_btn.setIcon(_mdl2_icon("\uE713", 16, "#c0c0d8")) + self._manage_btn.setIconSize(QSize(16, 16)) + self._manage_btn.setFixedSize(30, 30) + self._manage_btn.setToolTip("Manage Settings") + self._manage_btn.setStyleSheet( + "QPushButton{background:#1e1e3a;" + "border:1px solid #3a3a5a;border-radius:6px;}" + "QPushButton:hover{background:#2a2a4a;border-color:#7b8cde;}" + ) + self._manage_btn.clicked.connect( + lambda: self._pending.put(("manage", self._mod["id"])) + ) + top.addWidget(self._manage_btn) + + # Uninstall button (delete icon) — installed mods without INI + self._uninstall_btn = QPushButton() + self._uninstall_btn.setIcon(_mdl2_icon("\uE74D", 16, "#e74c3c")) + self._uninstall_btn.setIconSize(QSize(16, 16)) + self._uninstall_btn.setFixedSize(30, 30) + self._uninstall_btn.setToolTip("Uninstall") + self._uninstall_btn.setStyleSheet( + "QPushButton{background:transparent;" + "border:1px solid #e74c3c;border-radius:6px;}" + "QPushButton:hover{background:#e74c3c;}" + ) + self._uninstall_btn.clicked.connect( + lambda: self._pending.put(("uninstall", self._mod["id"])) + ) + top.addWidget(self._uninstall_btn) + + # Update button (sync icon) — visible only when update available + self._update_btn = QPushButton() + self._update_btn.setIcon(_mdl2_icon("\uE72C", 16, "#ff9800")) + self._update_btn.setIconSize(QSize(16, 16)) + self._update_btn.setFixedSize(30, 30) + self._update_btn.setStyleSheet( + "QPushButton{background:transparent;" + "border:1px solid #ff9800;border-radius:6px;}" + "QPushButton:hover{background:#ff9800;}" + ) + self._update_btn.clicked.connect( + lambda: self._pending.put(("update", self._mod["id"])) ) - top.addWidget(self._primary_btn) + top.addWidget(self._update_btn) + + # Install button (text) — virtual/uninstalled mods only + self._install_btn = QPushButton("Install") + self._install_btn.setFixedHeight(30) + self._install_btn.setMinimumWidth(90) + self._install_btn.setObjectName("btn_accent") + self._install_btn.clicked.connect( + lambda: self._pending.put(("install", self._mod["id"])) + ) + top.addWidget(self._install_btn) # Activate / Deactivate toggle (ME3 games, installed mods only) self._toggle_sw = ToggleSwitch(checked=self._mod.get("enabled", True)) @@ -153,7 +215,7 @@ def _build(self): layout.addWidget(self._progress) # Initial button state - self._refresh_primary_btn() + self._refresh_buttons() self._refresh_toggle_btn() # Show checking status if installed and has nexus info @@ -163,31 +225,23 @@ def _build(self): # ------------------------------------------------------------------ # Button state helpers # ------------------------------------------------------------------ - def _refresh_primary_btn(self): + def _refresh_buttons(self): if self._virtual: - self._primary_btn.setText("Install") - self._primary_btn.setObjectName("btn_accent") - elif self._has_update: - self._primary_btn.setText(f"Update → v{self._latest_version}" if self._latest_version else "Update") - self._primary_btn.setStyleSheet( - "QPushButton{color:#ff9800;border:1px solid #ff9800;border-radius:4px;" - "padding:2px 8px;background:transparent;}" - "QPushButton:hover{background:#ff9800;color:#fff;}" - ) - elif self._has_ini(): - self._primary_btn.setText("Manage") - self._primary_btn.setObjectName("") - self._primary_btn.setStyleSheet("") + self._install_btn.setVisible(True) + self._manage_btn.setVisible(False) + self._uninstall_btn.setVisible(False) + self._update_btn.setVisible(False) else: - self._primary_btn.setText("Uninstall") - self._primary_btn.setStyleSheet( - "QPushButton{color:#e74c3c;border:1px solid #e74c3c;border-radius:4px;" - "padding:2px 8px;background:transparent;}" - "QPushButton:hover{background:#e74c3c;color:#fff;}" - ) - # Force style re-evaluation - self._primary_btn.style().unpolish(self._primary_btn) - self._primary_btn.style().polish(self._primary_btn) + self._install_btn.setVisible(False) + has_ini = self._has_ini() + self._manage_btn.setVisible(has_ini) + self._uninstall_btn.setVisible(not has_ini) + if self._has_update: + tip = f"Update to v{self._latest_version}" if self._latest_version else "Update" + self._update_btn.setToolTip(tip) + self._update_btn.setVisible(True) + else: + self._update_btn.setVisible(False) def _refresh_toggle_btn(self): show = self._is_me3_game and not self._virtual @@ -272,7 +326,7 @@ def set_update_status(self, result: dict): has_update = result.get("has_update", False) self._has_update = has_update self._latest_version = latest - self._refresh_primary_btn() + self._refresh_buttons() if has_update: self._status_lbl.setText(f"🔔 Update available: v{latest}") self._status_lbl.setStyleSheet("font-size:11px;color:#ff9800;font-weight:600;") @@ -287,7 +341,11 @@ def set_installing(self, visible: bool, pct: int = 0, msg: str = ""): if msg: self._status_lbl.setText(msg) self._status_lbl.setStyleSheet("font-size:11px;color:#8888aa;") - self._primary_btn.setEnabled(not visible) + enabled = not visible + self._install_btn.setEnabled(enabled) + self._manage_btn.setEnabled(enabled) + self._uninstall_btn.setEnabled(enabled) + self._update_btn.setEnabled(enabled) def on_install_done(self, result: dict, new_version: str = ""): self.set_installing(False) @@ -298,7 +356,7 @@ def on_install_done(self, result: dict, new_version: str = ""): self._mod["version"] = ver self._ver_lbl.setText(f"v{ver}") self._has_update = False - self._refresh_primary_btn() + self._refresh_buttons() self._refresh_toggle_btn() self._status_lbl.setText("✓ Installed successfully") self._status_lbl.setStyleSheet("font-size:11px;color:#4ecca3;") @@ -413,6 +471,18 @@ def _poll_updates(self): tag = item[0] if tag == "action": self._route_action(item[1]) + elif tag == "manage": + ini = self._get_mod_ini_path(item[1]) + if ini: + self._do_manage(item[1]) + elif tag == "update": + if self._ensure_me3_available(): + self._do_update(item[1]) + elif tag == "uninstall": + self._do_uninstall(item[1]) + elif tag == "install": + if self._ensure_me3_available(): + self._do_install(item[1]) elif tag == "toggle": _, mod_id, enabled = item self._config.set_mod_enabled(self._game_id, mod_id, enabled) @@ -998,10 +1068,29 @@ def _handle_premium_fallback(self, mod_id: str): if nexus_url: webbrowser.open(nexus_url) self.log_message.emit( - "Nexus Premium required for direct downloads. " - "Opening mod page — download the file, then select it.", + "Free Nexus account — opening mod page in your browser.", "warning" ) + from PySide6.QtWidgets import QMessageBox + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Information) + msg.setWindowTitle("Download Mod Manually") + msg.setText( + f"Download \"{mod.get('name', mod_id)}\" from Nexus Mods" + ) + msg.setInformativeText( + "The mod page has been opened in your browser.\n\n" + "1. Click the FILES tab on the Nexus page\n" + "2. Click \"Manual Download\" on the file you want\n" + "3. Wait for the download to finish\n" + "4. Click OK below, then browse to the downloaded\n" + " .zip / .7z / .rar file (usually in your Downloads folder)\n\n" + "Tip: Nexus Premium members get one-click installs\n" + "directly from the app." + ) + msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + if msg.exec() != QMessageBox.Ok: + return downloads = os.path.join(os.path.expanduser("~"), "Downloads") path, _ = QFileDialog.getOpenFileName( self, f"Select downloaded archive for {mod.get('name', mod_id)}", downloads, diff --git a/build/build.py b/build/build.py index 006aa07..ded0423 100644 --- a/build/build.py +++ b/build/build.py @@ -43,6 +43,7 @@ "--hidden-import", "app.services.nexus_service", "--hidden-import", "app.services.nexus_sso", "--hidden-import", "app.services.steam_service", + "--hidden-import", "app.services.update_service", "--hidden-import", "py7zr", "--hidden-import", "rarfile", ] @@ -52,6 +53,15 @@ cmd.append(ENTRY_POINT) +# Generate _version.iss for Inno Setup (avoids ISPP compatibility issues) +version_file = os.path.join(BASE_DIR, "VERSION") +with open(version_file, "r", encoding="utf-8") as f: + app_version = f.read().strip() +version_iss = os.path.join(BASE_DIR, "build", "_version.iss") +with open(version_iss, "w", encoding="utf-8") as f: + f.write(f'#define AppVersion "{app_version}"\n') +print(f"Generated {version_iss} (v{app_version})") + print(f"Building {APP_NAME}...") print(f"Entry: {ENTRY_POINT}") result = subprocess.run(cmd, cwd=BASE_DIR) @@ -65,6 +75,11 @@ os.remove(path) print(f"[CLEAN] Removed {path} (user config must not be shipped)") + # Copy VERSION to dist root so the installer and future upgrades can read it + dist_version = os.path.join(DIST_DIR, APP_NAME, "VERSION") + shutil.copy2(version_file, dist_version) + print(f"[COPY] VERSION -> {dist_version}") + exe_path = os.path.join(DIST_DIR, APP_NAME, f"{APP_NAME}.exe") if os.path.isfile(exe_path): print(f"\n[OK] Build successful: {exe_path}") diff --git a/build/installer.iss b/build/installer.iss index 9467147..c866d7d 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -4,7 +4,7 @@ #define AppName "FromSoft Mod Manager" #ifndef AppVersion - #define AppVersion Trim(StringChange(StringChange(FileRead(AddBackslash(SourcePath) + "..\VERSION"), Chr(13), ""), Chr(10), "")) + #include "_version.iss" #endif #define AppPublisher "FromSoftModManager" #define AppURL "https://github.com/spikehockey75/FromSoftModManager" @@ -50,6 +50,28 @@ Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: deskto Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent [Code] +// Detect existing install and show upgrade prompt +function InitializeSetup(): Boolean; +var + PrevVersion: AnsiString; + VersionFile: String; +begin + Result := True; + VersionFile := ExpandConstant('{localappdata}\FromSoftModManager\VERSION'); + if FileExists(VersionFile) then + begin + LoadStringFromFile(VersionFile, PrevVersion); + PrevVersion := Trim(PrevVersion); + if PrevVersion <> '' then + begin + Result := (MsgBox( + '{#AppName} v' + PrevVersion + ' is already installed.' + #13#10 + + 'Update to v{#AppVersion}?', + mbConfirmation, MB_YESNO) = IDYES); + end; + end; +end; + // Download and install ME3 if not already present procedure DownloadME3(); var @@ -74,3 +96,23 @@ begin DownloadME3(); end; end; + +// Prompt to remove user data (config, mods) on uninstall +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + AppDir: String; +begin + if CurUninstallStep = usPostUninstall then + begin + AppDir := ExpandConstant('{app}'); + if DirExists(AppDir) then + begin + if MsgBox('Delete your settings and downloaded mods?' + #13#10 + + 'This will remove everything in:' + #13#10 + + AppDir, mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then + begin + DelTree(AppDir, True, True, True); + end; + end; + end; +end; diff --git a/main.py b/main.py index 2a9a811..9b35536 100644 --- a/main.py +++ b/main.py @@ -49,7 +49,10 @@ def main(): QCoreApplication.setApplicationName("FromSoft Mod Manager") QCoreApplication.setOrganizationName("FromSoftModManager") - QCoreApplication.setApplicationVersion("2.0.0") + + # Read version from VERSION file instead of hardcoding + from app.services.update_service import get_current_version + QCoreApplication.setApplicationVersion(get_current_version()) app = QApplication(sys.argv) app.setStyle("Fusion") # base style; overridden by QSS @@ -120,6 +123,17 @@ def main(): window = MainWindow(config) window.show() + # Background app update check + import threading + from app.services.update_service import check_for_update + + def _check_app_update(): + result = check_for_update() + if result.get("has_update"): + window._pending.put(("app_update", result)) + + threading.Thread(target=_check_app_update, daemon=True).start() + sys.exit(app.exec())