From 0597d61b27306a5ef8113c6f3621b50b45578b50 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 11:55:13 -0700 Subject: [PATCH 01/10] Fix co-op password check to search ME3 mod dir first, fix Inno Setup version - _find_coop_ini checks ME3 mod directory before game directory in both main_window.py and launch_tab.py (fixes password prompt appearing despite password being set in the mod manager) - build.py generates _version.iss for Inno Setup (avoids ISPP compatibility issues with Chr/Trim/FileRead) - installer.iss uses #include "_version.iss" instead of inline ISPP - Added build/_version.iss to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + app/ui/main_window.py | 30 +++++++++++++++++++++++------- app/ui/tabs/launch_tab.py | 29 +++++++++++++++++++++-------- build/build.py | 9 +++++++++ build/installer.iss | 2 +- 5 files changed, 55 insertions(+), 16 deletions(-) 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/app/ui/main_window.py b/app/ui/main_window.py index edafb36..c8d3855 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -410,6 +410,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 +441,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 diff --git a/app/ui/tabs/launch_tab.py b/app/ui/tabs/launch_tab.py index df9b656..82ab767 100644 --- a/app/ui/tabs/launch_tab.py +++ b/app/ui/tabs/launch_tab.py @@ -195,19 +195,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/build/build.py b/build/build.py index 006aa07..b7957d6 100644 --- a/build/build.py +++ b/build/build.py @@ -52,6 +52,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) diff --git a/build/installer.iss b/build/installer.iss index 9467147..a94cbcd 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" From 0c428f61b08a0e8e801b804d87d7ae57052df196 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 11:56:49 -0700 Subject: [PATCH 02/10] Bump version to 2.0.3 Co-Authored-By: Claude Opus 4.6 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index e9307ca..50ffc5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.2 +2.0.3 From 5989c1b3b3db700443543fafc1324d5c8fa3b845 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 12:17:01 -0700 Subject: [PATCH 03/10] Fix config_manager _APP_DIR for PyInstaller builds In PyInstaller builds, __file__ resolves inside _internal/ causing config.json and mods/ to be created in the wrong directory. Now uses sys.executable parent directory when frozen, so user data lives next to the exe and persists across updates. Also migrates legacy _internal/config.json to the app root on first run after the fix. Co-Authored-By: Claude Opus 4.6 --- app/config/config_manager.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 # ------------------------------------------------------------------ From 143a7bee8992c7f6d72bb855eb63eac29a20f089 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 12:26:59 -0700 Subject: [PATCH 04/10] Replace single mod button with icon buttons and tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Manage: gear icon (⚙) with "Manage Settings" tooltip - Update: up arrow (⬆) with "Update to vX.Y.Z" tooltip, orange themed - Uninstall: X icon (✕) with "Uninstall" tooltip, red themed - Install: keeps red accent text button for virtual/uninstalled mods - Each action has its own queue tag for direct dispatch Co-Authored-By: Claude Opus 4.6 --- app/ui/tabs/mods_tab.py | 118 ++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/app/ui/tabs/mods_tab.py b/app/ui/tabs/mods_tab.py index ed6f8c0..ebfd47f 100644 --- a/app/ui/tabs/mods_tab.py +++ b/app/ui/tabs/mods_tab.py @@ -105,14 +105,56 @@ 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 (gear icon) — installed mods with INI settings + self._manage_btn = QPushButton("\u2699") + self._manage_btn.setFixedSize(30, 30) + self._manage_btn.setToolTip("Manage Settings") + self._manage_btn.setStyleSheet( + "QPushButton{font-size:14px;background:#1e1e3a;color:#c0c0d8;" + "border:1px solid #3a3a5a;border-radius:6px;}" + "QPushButton:hover{background:#2a2a4a;border-color:#7b8cde;color:#7b8cde;}" ) - top.addWidget(self._primary_btn) + self._manage_btn.clicked.connect( + lambda: self._pending.put(("manage", self._mod["id"])) + ) + top.addWidget(self._manage_btn) + + # Uninstall button (X icon) — installed mods without INI + self._uninstall_btn = QPushButton("\u2715") + self._uninstall_btn.setFixedSize(30, 30) + self._uninstall_btn.setToolTip("Uninstall") + self._uninstall_btn.setStyleSheet( + "QPushButton{font-size:14px;background:transparent;color:#e74c3c;" + "border:1px solid #e74c3c;border-radius:6px;}" + "QPushButton:hover{background:#e74c3c;color:#fff;}" + ) + self._uninstall_btn.clicked.connect( + lambda: self._pending.put(("uninstall", self._mod["id"])) + ) + top.addWidget(self._uninstall_btn) + + # Update button (up arrow) — visible only when update available + self._update_btn = QPushButton("\u2b06") + self._update_btn.setFixedSize(30, 30) + self._update_btn.setStyleSheet( + "QPushButton{font-size:14px;background:transparent;color:#ff9800;" + "border:1px solid #ff9800;border-radius:6px;}" + "QPushButton:hover{background:#ff9800;color:#fff;}" + ) + self._update_btn.clicked.connect( + lambda: self._pending.put(("update", self._mod["id"])) + ) + 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 +195,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 +205,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 +306,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 +321,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 +336,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 +451,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) From baee3a2c4b64e22a64fc8667f4d44f27abb59236 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 12:29:39 -0700 Subject: [PATCH 05/10] Prompt to delete user data on uninstall After the standard uninstall completes, asks the user if they want to remove settings and downloaded mods. Defaults to No so nothing is lost accidentally. Co-Authored-By: Claude Opus 4.6 --- build/installer.iss | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/build/installer.iss b/build/installer.iss index a94cbcd..5d8ec37 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -74,3 +74,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; From bc76b65304d88669d2f30bb5ad122034e81c9782 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 12:42:27 -0700 Subject: [PATCH 06/10] Add in-app auto-update and installer upgrade detection - New update_service.py: checks GitHub releases API on startup, downloads installer exe to temp dir, and launches it detached - Main window shows dismissible green banner when update available with "Update Now" button that downloads and runs the installer - Settings dialog adds "App Updates" section with current version and manual "Check for Updates" button - Installer detects existing install via VERSION file and prompts to upgrade with version info - build.py copies VERSION to dist root for installer detection and adds update_service to hidden imports - main.py reads version from VERSION file instead of hardcoding Co-Authored-By: Claude Opus 4.6 --- app/services/update_service.py | 151 ++++++++++++++++++++++++++++++ app/ui/dialogs/settings_dialog.py | 54 +++++++++++ app/ui/main_window.py | 78 +++++++++++++++ build/build.py | 6 ++ build/installer.iss | 22 +++++ main.py | 16 +++- 6 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 app/services/update_service.py 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/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/main_window.py b/app/ui/main_window.py index c8d3855..748448c 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -84,6 +84,41 @@ 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("✕") + dismiss_btn.setFixedSize(24, 24) + dismiss_btn.setStyleSheet( + "QPushButton{font-size:12px;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) @@ -481,6 +516,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: @@ -503,6 +554,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/build/build.py b/build/build.py index b7957d6..975c397 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", ] @@ -74,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 5d8ec37..e0b88d0 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -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: String; + 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 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()) From f019c0098b67572bc5a7942815378975614e3f22 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 13:49:18 -0700 Subject: [PATCH 07/10] Replace emoji icons with Windows 11 MDL2 glyphs, add Nexus avatar, improve free user UX - Migrate all UI icons to Segoe MDL2 Assets for native Windows 11 look - Add Nexus profile avatar with circular masking and background fetch - Add detailed step-by-step instructions for free Nexus users downloading mods manually - Fix Inno Setup installer AnsiString type mismatch Co-Authored-By: Claude Opus 4.6 --- app/ui/dialogs/add_mod_dialog.py | 12 ++++-- app/ui/dialogs/me3_setup_dialog.py | 5 ++- app/ui/game_page.py | 20 +++++++-- app/ui/main_window.py | 33 +++++++++++--- app/ui/nexus_widget.py | 61 +++++++++++++++++++++++++- app/ui/sidebar.py | 22 ++++++++-- app/ui/tabs/launch_tab.py | 3 +- app/ui/tabs/mods_tab.py | 69 +++++++++++++++++++++++------- build/installer.iss | 2 +- 9 files changed, 191 insertions(+), 36 deletions(-) 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/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 748448c..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") @@ -106,10 +122,11 @@ def _build(self): ) self._update_now_btn.clicked.connect(self._on_update_now) ub_layout.addWidget(self._update_now_btn) - dismiss_btn = QPushButton("✕") + dismiss_btn = QPushButton("\uE711") + dismiss_btn.setFont(_QFont(_MDL2, 10)) dismiss_btn.setFixedSize(24, 24) dismiss_btn.setStyleSheet( - "QPushButton{font-size:12px;color:#8a8a6a;background:transparent;border:none;}" + "QPushButton{color:#8a8a6a;background:transparent;border:none;}" "QPushButton:hover{color:#b0d880;}" ) dismiss_btn.setToolTip("Dismiss") @@ -164,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) @@ -179,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) 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 82ab767..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 diff --git a/app/ui/tabs/mods_tab.py b/app/ui/tabs/mods_tab.py index ebfd47f..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,41 +119,47 @@ def _build(self): top.addWidget(self._ver_lbl) top.addStretch() - # Manage button (gear icon) — installed mods with INI settings - self._manage_btn = QPushButton("\u2699") + # 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{font-size:14px;background:#1e1e3a;color:#c0c0d8;" + "QPushButton{background:#1e1e3a;" "border:1px solid #3a3a5a;border-radius:6px;}" - "QPushButton:hover{background:#2a2a4a;border-color:#7b8cde;color:#7b8cde;}" + "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 (X icon) — installed mods without INI - self._uninstall_btn = QPushButton("\u2715") + # 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{font-size:14px;background:transparent;color:#e74c3c;" + "QPushButton{background:transparent;" "border:1px solid #e74c3c;border-radius:6px;}" - "QPushButton:hover{background:#e74c3c;color:#fff;}" + "QPushButton:hover{background:#e74c3c;}" ) self._uninstall_btn.clicked.connect( lambda: self._pending.put(("uninstall", self._mod["id"])) ) top.addWidget(self._uninstall_btn) - # Update button (up arrow) — visible only when update available - self._update_btn = QPushButton("\u2b06") + # 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{font-size:14px;background:transparent;color:#ff9800;" + "QPushButton{background:transparent;" "border:1px solid #ff9800;border-radius:6px;}" - "QPushButton:hover{background:#ff9800;color:#fff;}" + "QPushButton:hover{background:#ff9800;}" ) self._update_btn.clicked.connect( lambda: self._pending.put(("update", self._mod["id"])) @@ -1048,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/installer.iss b/build/installer.iss index e0b88d0..c866d7d 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -53,7 +53,7 @@ Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#StringChange( // Detect existing install and show upgrade prompt function InitializeSetup(): Boolean; var - PrevVersion: String; + PrevVersion: AnsiString; VersionFile: String; begin Result := True; From a2ddf866db3c8c54f3d88ac955274f278157c79d Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 13:52:09 -0700 Subject: [PATCH 08/10] Auto-generate release notes from commit titles and descriptions - Fetch full git history (fetch-depth: 0) to find previous tag - Generate markdown release notes from git log between tags - Each commit shows bold title, short hash, and indented body - Replace generate_release_notes with body_path for custom notes Co-Authored-By: Claude Opus 4.6 --- .github/workflows/create-release.yml | 45 +++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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 From 0b226e1900f74cd1aac2b42a5a2ecbc01f6a09c7 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 13:53:39 -0700 Subject: [PATCH 09/10] Add build check workflow for PRs Runs PyInstaller build and Inno Setup compile on every PR to main, verifying both the exe and installer are produced successfully. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-check.yml | 58 +++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/build-check.yml 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 From dd53191abb62f0aeef01506f164c5fc8bd3c5448 Mon Sep 17 00:00:00 2001 From: Brandon Arndt Date: Fri, 27 Feb 2026 13:57:37 -0700 Subject: [PATCH 10/10] Fix Unicode arrow in build.py for Windows cp1252 encoding Co-Authored-By: Claude Opus 4.6 --- build/build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/build.py b/build/build.py index 975c397..ded0423 100644 --- a/build/build.py +++ b/build/build.py @@ -78,7 +78,7 @@ # 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}") + 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):