diff --git a/README.md b/README.md index 7b17518..fb6e084 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # FromSoft Mod Manager -A native Windows desktop app for managing mods across FromSoftware -games. Install mods from Nexus, configure settings, manage saves, -and launch games with Mod Engine 3 — all from a single app. +Skip the manual mod setup headaches — FromSoft Mod Manager automatically finds your Steam games, installs co-op mods from Nexus with one click, and keeps everything up to date. Connect your Nexus account, pick your game, and you're playing co-op in minutes. Saves, settings, and mod loading through Mod Engine 3 are all handled for you. > **Note:** This is a **manager tool only**. The Seamless Co-op mods > are created by [LukeYui](https://github.com/LukeYui). All credit @@ -62,9 +60,9 @@ folders across all drives. ### Nexus Mods Integration -- **SSO authentication** — click "Authorize with Nexus Mods", +- **OAuth 2.0 authentication** — click "Authorize with Nexus Mods", approve in browser, done (no copy-paste needed) -- Manual API key fallback for users who prefer it +- Automatic token refresh — stays connected across sessions - User profile display in sidebar - Trending and recommended mods per game - Direct download with Nexus Premium support @@ -176,8 +174,8 @@ The installer: 1. Click **Connect Account** in the sidebar 2. Click **Authorize with Nexus Mods** — your browser opens -3. Click "Authorize" on the Nexus page — the app receives your - API key automatically +3. Click "Authorize" on the Nexus page — the app connects + automatically 4. Your Nexus username appears in the sidebar ### Managing Mods @@ -223,13 +221,13 @@ fromsoft_coop_manager/ │ │ └── save_manager.py Save file operations │ ├── services/ │ │ ├── nexus_service.py Nexus Mods REST API client -│ │ ├── nexus_sso.py Nexus SSO WebSocket auth flow +│ │ ├── nexus_oauth.py Nexus OAuth 2.0 PKCE auth flow │ │ └── steam_service.py Steam player count and asset APIs │ └── ui/ │ ├── main_window.py Main window with sidebar + content │ ├── sidebar.py Game list, player counts, Nexus │ ├── game_page.py Per-game tab container -│ ├── nexus_widget.py Nexus auth widget (SSO + manual) +│ ├── nexus_widget.py Nexus auth widget (OAuth 2.0) │ ├── terminal_widget.py Log output panel │ ├── tabs/ │ │ ├── launch_tab.py Game launcher with cover art @@ -265,7 +263,6 @@ fromsoft_coop_manager/ | `PySide6` | Qt 6 UI framework | | `requests` | HTTP client for API calls | | `tomlkit` / `tomli-w` | TOML reading/writing for ME3 profiles | -| `websocket-client` | Nexus SSO WebSocket authentication | | `py7zr` | 7z archive extraction | | `rarfile` | RAR archive extraction (requires WinRAR or 7-Zip) | | `pyinstaller` | Build tooling (dev only) | @@ -313,7 +310,7 @@ to `ME3_GAME_MAP` in `app/core/me3_service.py`. | ---------------- | ---------------------------------------------------------- | | **UI Framework** | PySide6 (Qt 6) with Fusion base style | | **Mod Loader** | Mod Engine 3 CLI (`me3 launch -g `) | -| **Nexus Auth** | WebSocket SSO via `wss://sso.nexusmods.com` | +| **Nexus Auth** | OAuth 2.0 PKCE with automatic token refresh | | **Packaging** | PyInstaller (onedir) then Inno Setup installer | | **Config** | JSON config file (`config.json`) | | **Theme** | Custom QSS dark theme (#0e0e18 bg, #e94560 accent) | diff --git a/VERSION b/VERSION index 50ffc5a..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.3 +2.1.0 diff --git a/app/config/config_manager.py b/app/config/config_manager.py index 60e728c..651bb8f 100644 --- a/app/config/config_manager.py +++ b/app/config/config_manager.py @@ -5,6 +5,7 @@ import os import sys import json +import time from datetime import datetime from pathlib import Path @@ -26,6 +27,7 @@ class ConfigManager: def __init__(self): self._migrate_legacy_config() self._config = self._load() + self._migrate_nexus_api_key() # ------------------------------------------------------------------ # Migration @@ -97,15 +99,42 @@ def get_last_scan(self) -> str | None: return self._config.get("last_scan") # ------------------------------------------------------------------ - # Nexus + # Nexus OAuth # ------------------------------------------------------------------ - def get_nexus_api_key(self) -> str: - return self._config.get("nexus_api_key", "") - - def set_nexus_api_key(self, key: str): - self._config["nexus_api_key"] = key + def _migrate_nexus_api_key(self): + """Remove legacy API key auth — users must re-authorize via OAuth.""" + if "nexus_api_key" in self._config: + self._config.pop("nexus_api_key", None) + self._config.pop("nexus_user", None) + self.save() + + def get_nexus_tokens(self) -> dict: + """Return stored OAuth tokens or empty dict. + + Keys: access_token, refresh_token, expires_at + """ + return self._config.get("nexus_tokens", {}) + + def get_nexus_access_token(self) -> str: + """Convenience: return the current access token, or empty string.""" + return self.get_nexus_tokens().get("access_token", "") + + def set_nexus_tokens(self, tokens: dict): + """Store OAuth tokens (access_token, refresh_token, expires_at).""" + self._config["nexus_tokens"] = { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "expires_at": tokens.get("expires_at", 0), + } self.save() + def is_nexus_token_expired(self) -> bool: + """Check if the stored access token has expired.""" + tokens = self.get_nexus_tokens() + if not tokens.get("access_token"): + return True + return time.time() >= tokens.get("expires_at", 0) + def get_nexus_user_info(self) -> dict: return self._config.get("nexus_user", {}) @@ -114,7 +143,7 @@ def set_nexus_user_info(self, info: dict): self.save() def clear_nexus_auth(self): - self._config.pop("nexus_api_key", None) + self._config.pop("nexus_tokens", None) self._config.pop("nexus_user", None) self.save() diff --git a/app/services/nexus_oauth.py b/app/services/nexus_oauth.py new file mode 100644 index 0000000..1843bce --- /dev/null +++ b/app/services/nexus_oauth.py @@ -0,0 +1,360 @@ +""" +Nexus Mods OAuth 2.0 + PKCE authentication for desktop apps. + +Spins up a temporary localhost HTTP server to capture the authorization +callback, exchanges the code for JWT tokens, and provides token refresh. +""" + +import base64 +import hashlib +import json +import os +import sys +import threading +import time +import uuid +import webbrowser +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlencode, urlparse, parse_qs +import urllib.request +import urllib.error + + +def _read_version() -> str: + if getattr(sys, 'frozen', False): + base = sys._MEIPASS + else: + base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + try: + with open(os.path.join(base, "VERSION"), "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + return "2.0.0" + + +USER_AGENT = f"FromSoftModManager/{_read_version()}" + +# ── Nexus OAuth endpoints ──────────────────────────────────────── +NEXUS_AUTH_URL = "https://users.nexusmods.com/oauth/authorize" +NEXUS_TOKEN_URL = "https://users.nexusmods.com/oauth/token" +CLIENT_ID = "fromsoft_mod_manager" +REDIRECT_URI = "http://127.0.0.1:9876/callback" +REDIRECT_PORT = 9876 + +# RSA public key for JWT verification (from Nexus docs) +NEXUS_PUBLIC_KEY = ( + "-----BEGIN PUBLIC KEY-----\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDhKHxCWOeUy38S3UOBOB11SNd/\n" + "wyL9TVvzxePkEsZb4fEVGp0U5MEcDcJgXUo/fZOYTUFMX7ipvCC7sbsyKpJ0xZ/M\n" + "l5zXMBcI03gu6p1TvG+eL0xEk6X8LD+t+GbzH9EY58bZ8kOLEx4lbAX3fNYhMhbh\n" + "HJra9ZVW2QdgHoDV6wIDAQAB\n" + "-----END PUBLIC KEY-----" +) + + +# ── PKCE helpers ───────────────────────────────────────────────── + +def _generate_code_verifier() -> str: + """Generate a cryptographically random code verifier (43+ chars).""" + return base64.urlsafe_b64encode(os.urandom(43)).rstrip(b"=").decode("ascii") + + +def _generate_code_challenge(verifier: str) -> str: + """SHA-256 hash of the verifier, base64url-encoded (no padding).""" + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +# ── JWT decode (minimal, no external dependency) ───────────────── + +def _b64url_decode(s: str) -> bytes: + """Decode base64url without padding.""" + s += "=" * (4 - len(s) % 4) + return base64.urlsafe_b64decode(s) + + +def decode_jwt_payload(token: str) -> dict: + """Decode the JWT payload without verification (user info extraction). + + We trust the token because it came directly from the Nexus token endpoint + over HTTPS. Full RSA verification can be added later if needed. + """ + parts = token.split(".") + if len(parts) != 3: + return {} + try: + payload = _b64url_decode(parts[1]) + return json.loads(payload) + except Exception: + return {} + + +def extract_user_info(access_token: str) -> dict: + """Extract user info from a Nexus OAuth JWT access token.""" + payload = decode_jwt_payload(access_token) + user = payload.get("user", {}) + roles = user.get("membership_roles", []) + return { + "name": user.get("username", ""), + "is_premium": "premium" in roles or "lifetimepremium" in roles, + "is_supporter": "supporter" in roles, + "profile_url": "", # Not available in JWT; fetched separately if needed + } + + +# ── Token exchange & refresh ───────────────────────────────────── + +def exchange_code_for_tokens(code: str, code_verifier: str) -> dict: + """Exchange an authorization code for access + refresh tokens. + + Returns: + {"access_token", "refresh_token", "expires_in", "token_type"} + or {"error": "..."} on failure. + """ + body = urlencode({ + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "code": code, + "code_verifier": code_verifier, + "scope": "", + }).encode("utf-8") + + req = urllib.request.Request( + NEXUS_TOKEN_URL, + data=body, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + data["expires_at"] = int(time.time()) + data.get("expires_in", 3600) + return data + except urllib.error.HTTPError as e: + try: + err_body = e.read().decode() + except Exception: + err_body = "" + return {"error": f"Token exchange failed (HTTP {e.code}): {err_body}"} + except Exception as e: + return {"error": f"Token exchange failed: {e}"} + + +def refresh_access_token(refresh_token: str) -> dict: + """Use a refresh token to obtain a new access token. + + Returns: + {"access_token", "refresh_token", "expires_in", "token_type", "expires_at"} + or {"error": "..."} on failure (e.g. token revoked). + """ + body = urlencode({ + "grant_type": "refresh_token", + "client_id": CLIENT_ID, + "refresh_token": refresh_token, + }).encode("utf-8") + + req = urllib.request.Request( + NEXUS_TOKEN_URL, + data=body, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + data["expires_at"] = int(time.time()) + data.get("expires_in", 3600) + return data + except urllib.error.HTTPError as e: + code = e.code + if 400 <= code < 500: + return {"error": "Token revoked or expired. Please re-authorize."} + return {"error": f"Token refresh failed (HTTP {code})"} + except Exception as e: + return {"error": f"Token refresh failed: {e}"} + + +# ── Localhost callback server ──────────────────────────────────── + +class _CallbackHandler(BaseHTTPRequestHandler): + """Handles the OAuth callback on localhost.""" + + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path != "/callback": + self.send_response(404) + self.end_headers() + return + + params = parse_qs(parsed.query) + code = params.get("code", [None])[0] + state = params.get("state", [None])[0] + error = params.get("error", [None])[0] + + if error: + self.server.oauth_error = error + elif not code: + self.server.oauth_error = "No authorization code received" + elif state != self.server.expected_state: + self.server.oauth_error = "State mismatch — possible CSRF attack" + else: + self.server.oauth_code = code + + # Send a user-friendly response page + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + if self.server.oauth_code: + html = ( + "" + "

Authorization successful!

" + "

You can close this tab and return to FromSoft Mod Manager.

" + "" + ) + else: + html = ( + "" + f"

Authorization failed

" + f"

{self.server.oauth_error or 'Unknown error'}

" + "" + ) + self.wfile.write(html.encode("utf-8")) + + def log_message(self, format, *args): + """Suppress default HTTP server logging.""" + pass + + +class _OAuthHTTPServer(HTTPServer): + """HTTPServer subclass to hold OAuth state.""" + + def __init__(self, state: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.expected_state = state + self.oauth_code = None + self.oauth_error = None + self.timeout = 0.5 # For handle_request() polling + + +# ── Main OAuth client ──────────────────────────────────────────── + +class NexusOAuthClient: + """OAuth 2.0 PKCE client for Nexus Mods desktop authorization. + + Usage: + client = NexusOAuthClient() + client.start() # opens browser + starts localhost server + # poll periodically: + tokens, err = client.poll() + if tokens: ... # got tokens dict + if err: ... # something went wrong + client.stop() # clean up + """ + + def __init__(self): + self._tokens: dict | None = None + self._error: str | None = None + self._code_verifier: str = "" + self._state: str = "" + self._server: _OAuthHTTPServer | None = None + self._thread: threading.Thread | None = None + self._done = threading.Event() + + def start(self): + """Generate PKCE params, start localhost server, and open browser.""" + self._tokens = None + self._error = None + self._done.clear() + + self._code_verifier = _generate_code_verifier() + code_challenge = _generate_code_challenge(self._code_verifier) + self._state = str(uuid.uuid4()) + + # Start localhost callback server + try: + self._server = _OAuthHTTPServer( + self._state, + ("127.0.0.1", REDIRECT_PORT), + _CallbackHandler, + ) + except OSError as e: + self._error = f"Could not start callback server on port {REDIRECT_PORT}: {e}" + return + + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + # Build and open authorize URL + params = urlencode({ + "client_id": CLIENT_ID, + "response_type": "code", + "scope": "", + "redirect_uri": REDIRECT_URI, + "state": self._state, + "code_challenge_method": "S256", + "code_challenge": code_challenge, + }) + webbrowser.open(f"{NEXUS_AUTH_URL}?{params}") + + def poll(self) -> tuple[dict | None, str | None]: + """Non-blocking check for results. + + Returns (tokens_dict, error). + tokens_dict keys: access_token, refresh_token, expires_at, user + """ + return self._tokens, self._error + + def stop(self): + """Shut down the callback server and clean up.""" + self._done.set() + if self._server: + try: + # Close the socket so handle_request() unblocks immediately. + # Do NOT call shutdown() — it deadlocks when handle_request() + # is blocking in the serve thread. + self._server.server_close() + except Exception: + pass + self._server = None + + def _serve(self): + """Run the callback server until we get a code/error or are stopped.""" + server = self._server + if not server: + return + + while not self._done.is_set(): + try: + server.handle_request() + except Exception: + # Socket closed by stop() — exit cleanly + break + + if server.oauth_error: + self._error = server.oauth_error + self._done.set() + break + + if server.oauth_code: + # Exchange the code for tokens + tokens = exchange_code_for_tokens( + server.oauth_code, self._code_verifier + ) + if "error" in tokens: + self._error = tokens["error"] + else: + # Attach user info extracted from JWT + tokens["user"] = extract_user_info( + tokens.get("access_token", "") + ) + self._tokens = tokens + self._done.set() + break diff --git a/app/services/nexus_service.py b/app/services/nexus_service.py index adac656..ad14de6 100644 --- a/app/services/nexus_service.py +++ b/app/services/nexus_service.py @@ -39,8 +39,29 @@ def parse_nexus_url(url: str) -> "tuple[str, int] | None": class NexusService: - def __init__(self, api_key: str = ""): - self.api_key = api_key + def __init__(self, access_token: str = "", config=None): + self.access_token = access_token + self._config = config # ConfigManager for auto-refresh + + def _ensure_token(self): + """Refresh the access token if expired. Updates self and config.""" + if not self._config or not self.access_token: + return + if not self._config.is_nexus_token_expired(): + return + tokens = self._config.get_nexus_tokens() + refresh_token = tokens.get("refresh_token", "") + if not refresh_token: + return + from app.services.nexus_oauth import refresh_access_token + new_tokens = refresh_access_token(refresh_token) + if "error" in new_tokens: + # Token revoked — clear auth so UI shows login button + self._config.clear_nexus_auth() + self.access_token = "" + return + self._config.set_nexus_tokens(new_tokens) + self.access_token = new_tokens.get("access_token", "") def _headers(self) -> dict: h = { @@ -49,11 +70,12 @@ def _headers(self) -> dict: "User-Agent": f"FromSoftModManager/{APPLICATION_VERSION}", "Accept": "application/json", } - if self.api_key: - h["apikey"] = self.api_key + if self.access_token: + h["Authorization"] = f"Bearer {self.access_token}" return h def _get(self, path: str) -> dict: + self._ensure_token() url = f"{NEXUS_API_BASE}{path}" req = urllib.request.Request(url, headers=self._headers()) try: @@ -61,7 +83,7 @@ def _get(self, path: str) -> dict: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: if e.code == 401: - return {"error": "Nexus API key invalid or missing", "requires_auth": True} + return {"error": "Nexus authorization invalid or expired", "requires_auth": True} elif e.code == 429: return {"error": "Rate limited. Try again later."} elif e.code == 404: @@ -71,7 +93,7 @@ def _get(self, path: str) -> dict: return {"error": str(e)} def validate_user(self) -> dict: - """Validate API key and get user info.""" + """Validate token and get user info.""" return self._get("/users/validate.json") def get_mod_info(self, game_domain: str, mod_id: int) -> dict: @@ -88,6 +110,7 @@ def get_game_categories(self, game_domain: str) -> list[dict]: def get_trending_mods(self, game_domain: str) -> list[dict]: """Fetch trending mods for a game domain.""" + self._ensure_token() url = f"{NEXUS_API_BASE}/games/{game_domain}/mods/trending.json" req = urllib.request.Request(url, headers=self._headers()) try: @@ -234,6 +257,7 @@ def _inner_progress(pct): def download_file(self, url: str, dest_path: str, progress_callback=None) -> dict: """Download a file from a URL with progress reporting.""" + self._ensure_token() try: # Encode any non-ASCII / space characters in the URL path+query while # leaving the scheme, host, and already-encoded sequences intact. diff --git a/app/services/nexus_sso.py b/app/services/nexus_sso.py deleted file mode 100644 index d828cec..0000000 --- a/app/services/nexus_sso.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Nexus Mods SSO authentication via WebSocket. -Connects to wss://sso.nexusmods.com, opens browser for user authorization, -and receives the API key automatically when approved. -""" - -import json -import threading -import uuid -import webbrowser - -import websocket - -NEXUS_SSO_URL = "wss://sso.nexusmods.com" -NEXUS_SSO_AUTHORIZE = "https://www.nexusmods.com/sso" -APPLICATION_SLUG = "fromsoft-coop-manager" - - -class NexusSSOClient: - """WebSocket-based Nexus Mods SSO client. - - Usage: - client = NexusSSOClient() - client.start() # connects WS + opens browser - # poll periodically: - key, err = client.poll() - if key: ... # got the API key - if err: ... # something went wrong - client.stop() # clean up - """ - - def __init__(self): - self._api_key: str | None = None - self._error: str | None = None - self._connection_token: str | None = None - self._uuid: str = str(uuid.uuid4()) - self._done = threading.Event() - self._ws: websocket.WebSocketApp | None = None - self._thread: threading.Thread | None = None - - # ── public API ──────────────────────────────────────── - - def start(self): - """Connect to the SSO WebSocket and open the browser for authorization.""" - self._api_key = None - self._error = None - self._done.clear() - - self._ws = websocket.WebSocketApp( - NEXUS_SSO_URL, - on_open=self._on_open, - on_message=self._on_message, - on_error=self._on_error, - on_close=self._on_close, - ) - self._thread = threading.Thread(target=self._run_ws, daemon=True) - self._thread.start() - - def poll(self) -> tuple[str | None, str | None]: - """Non-blocking check for results. Returns (api_key, error).""" - return self._api_key, self._error - - def stop(self): - """Close the WebSocket connection and clean up.""" - self._done.set() - if self._ws: - try: - self._ws.close() - except Exception: - pass - - # ── internal ────────────────────────────────────────── - - def _run_ws(self): - try: - self._ws.run_forever() - except Exception as e: - self._error = str(e) - self._done.set() - - def _on_open(self, ws): - """Send SSO request and open browser.""" - data = { - "id": self._uuid, - "token": self._connection_token, - "protocol": 2, - } - ws.send(json.dumps(data)) - - def _on_message(self, ws, message): - """Handle SSO responses: connection_token or api_key.""" - try: - response = json.loads(message) - except (json.JSONDecodeError, TypeError): - return - - if not response.get("success"): - self._error = response.get("error", "SSO authorization failed") - self._done.set() - return - - data = response.get("data", {}) - - if "connection_token" in data: - # First response — store token for reconnection, then open browser - self._connection_token = data["connection_token"] - url = f"{NEXUS_SSO_AUTHORIZE}?id={self._uuid}&application={APPLICATION_SLUG}" - webbrowser.open(url) - - elif "api_key" in data: - # User authorized — we have the key - self._api_key = data["api_key"] - self._done.set() - ws.close() - - def _on_error(self, ws, error): - self._error = str(error) if error else "WebSocket connection error" - self._done.set() - - def _on_close(self, ws, close_status_code, close_msg): - # If we don't have a key yet and weren't intentionally stopped, - # this is an unexpected disconnect - if not self._api_key and not self._done.is_set(): - self._error = "Connection closed before authorization completed" - self._done.set() diff --git a/app/ui/dialogs/add_mod_dialog.py b/app/ui/dialogs/add_mod_dialog.py index a3562b2..d956044 100644 --- a/app/ui/dialogs/add_mod_dialog.py +++ b/app/ui/dialogs/add_mod_dialog.py @@ -189,14 +189,14 @@ def _on_install(self): self._error_lbl.setVisible(True) return - api_key = self._config.get_nexus_api_key() - if not api_key: + access_token = self._config.get_nexus_access_token() + if not access_token: self._error_lbl.setText("Connect your Nexus account first to download mods.") self._error_lbl.setVisible(True) return nexus_domain, nexus_mod_id = parsed - self._start_nexus_install(api_key, nexus_domain, nexus_mod_id) + self._start_nexus_install(access_token, nexus_domain, nexus_mod_id) # ── Shared: lock UI + start poll timer ────────────────── @@ -291,7 +291,7 @@ def _on_premium_fallback(self, mod_name: str, nexus_url: str): # ── Nexus install flow ────────────────────────────────── - def _start_nexus_install(self, api_key: str, domain: str, nexus_mod_id: int): + def _start_nexus_install(self, access_token: str, domain: str, nexus_mod_id: int): from app.core.me3_service import slugify, ME3_GAME_MAP slug = slugify(f"{domain}-{nexus_mod_id}") self._enter_installing(f"Fetching mod info...") @@ -307,7 +307,7 @@ def _work(): from app.services.nexus_service import NexusService from app.core.mod_installer import install_mod_from_zip - svc = NexusService(api_key) + svc = NexusService(access_token, config=config) # 1. Fetch mod info for the name q.put(("progress", 2, "Fetching mod info from Nexus...")) diff --git a/app/ui/dialogs/me3_update_dialog.py b/app/ui/dialogs/me3_update_dialog.py new file mode 100644 index 0000000..0fa5bc7 --- /dev/null +++ b/app/ui/dialogs/me3_update_dialog.py @@ -0,0 +1,146 @@ +""" +ME3 update dialog — downloads and installs the latest ME3 version. +""" + +import threading +import queue as _queue +from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QProgressBar, QFrame) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont +from app.core.me3_service import download_and_install_me3 +from app.config.config_manager import ConfigManager + + +class ME3UpdateDialog(QDialog): + """Dialog to update ME3 to the latest version.""" + + def __init__(self, config: ConfigManager, latest_ver: str, parent=None): + super().__init__(parent) + self._config = config + self._latest_ver = latest_ver + self._pending: _queue.SimpleQueue = _queue.SimpleQueue() + + self.setWindowTitle("Update Mod Engine 3") + self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) + self.setMinimumWidth(480) + self.setMinimumHeight(240) + self._build() + + self._poll_timer = QTimer(self) + self._poll_timer.timeout.connect(self._poll) + self._poll_timer.start(100) + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(28, 24, 28, 24) + layout.setSpacing(16) + + # Icon + title row + title_row = QHBoxLayout() + icon_lbl = QLabel("\uE895") # Download icon + icon_lbl.setFont(QFont("Segoe MDL2 Assets", 24)) + icon_lbl.setStyleSheet("color:#e0e0ec;") + title_row.addWidget(icon_lbl) + + title = QLabel("ME3 Update Available") + title.setStyleSheet("font-size:16px;font-weight:700;color:#e0e0ec;") + title_row.addWidget(title) + title_row.addStretch() + layout.addLayout(title_row) + + # Description + desc = QLabel( + f"A new version of Mod Engine 3 ({self._latest_ver}) is available.\n\n" + "Click Update to download and install it automatically." + ) + desc.setWordWrap(True) + desc.setStyleSheet("font-size:12px;color:#a0a0c0;line-height:1.5;") + layout.addWidget(desc) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setStyleSheet("color:#2a2a4a;") + layout.addWidget(sep) + + # Status label + self._status_lbl = QLabel("Ready to update") + self._status_lbl.setStyleSheet("font-size:11px;color:#8888aa;") + layout.addWidget(self._status_lbl) + + # Progress bar + self._progress = QProgressBar() + self._progress.setRange(0, 100) + self._progress.setValue(0) + self._progress.setFixedHeight(6) + self._progress.setVisible(False) + layout.addWidget(self._progress) + + layout.addStretch() + + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch() + + self._cancel_btn = QPushButton("Not Now") + self._cancel_btn.setObjectName("sidebar_mgmt_btn") + self._cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self._cancel_btn) + + self._update_btn = QPushButton(" Update ME3") + self._update_btn.setObjectName("btn_accent") + self._update_btn.setFixedHeight(36) + self._update_btn.setFixedWidth(160) + self._update_btn.clicked.connect(self._on_update) + btn_row.addWidget(self._update_btn) + + layout.addLayout(btn_row) + + def _on_update(self): + self._update_btn.setEnabled(False) + self._cancel_btn.setEnabled(False) + self._progress.setVisible(True) + self._status_lbl.setText("Starting download...") + + pending = self._pending + + def _work(): + def _cb(msg, pct): + pending.put(("progress", pct, msg)) + result = download_and_install_me3(progress_callback=_cb) + pending.put(("done", result)) + + threading.Thread(target=_work, daemon=True).start() + + def _poll(self): + try: + while True: + item = self._pending.get_nowait() + tag = item[0] + if tag == "progress": + _, pct, msg = item + self._progress.setValue(pct) + self._status_lbl.setText(msg) + elif tag == "done": + _, result = item + self._on_done(result) + except _queue.Empty: + pass + + def _on_done(self, result: dict): + self._poll_timer.stop() + if result.get("success"): + self._progress.setValue(100) + self._status_lbl.setText("ME3 updated successfully!") + self._status_lbl.setStyleSheet("font-size:11px;color:#4ecca3;font-weight:600;") + if result.get("path"): + self._config.set_me3_path(result["path"]) + QTimer.singleShot(800, self.accept) + else: + self._progress.setVisible(False) + self._status_lbl.setText(f"Update failed: {result.get('message', 'Unknown error')}") + self._status_lbl.setStyleSheet("font-size:11px;color:#e74c3c;") + self._update_btn.setEnabled(True) + self._cancel_btn.setEnabled(True) + self._update_btn.setText(" Retry") diff --git a/app/ui/dialogs/settings_dialog.py b/app/ui/dialogs/settings_dialog.py index d39f863..d4cb633 100644 --- a/app/ui/dialogs/settings_dialog.py +++ b/app/ui/dialogs/settings_dialog.py @@ -1,4 +1,4 @@ -"""App-level settings dialog — Nexus API key, ME3 path, preferences.""" +"""App-level settings dialog — Nexus account, ME3 path, preferences.""" import os from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, @@ -11,6 +11,7 @@ class SettingsDialog(QDialog): settings_saved = Signal() _update_checked = Signal(object) # internal: update check result from bg thread + _me3_update_checked = Signal(object) # internal: ME3 update check result def __init__(self, config: ConfigManager, parent=None): super().__init__(parent) @@ -35,25 +36,9 @@ def _build(self): nexus_layout = QFormLayout(nexus_group) nexus_layout.setSpacing(10) - self._nexus_key = QLineEdit() - self._nexus_key.setPlaceholderText("Paste your Nexus API key here") - self._nexus_key.setEchoMode(QLineEdit.Password) - show_btn = QPushButton("Show") - show_btn.setFixedWidth(60) - show_btn.setCheckable(True) - show_btn.toggled.connect(lambda on: self._nexus_key.setEchoMode( - QLineEdit.Normal if on else QLineEdit.Password - )) - key_row = QHBoxLayout() - key_row.addWidget(self._nexus_key) - key_row.addWidget(show_btn) - nexus_layout.addRow("API Key:", key_row) - - nexus_help = QLabel('Get your API key from Nexus') - nexus_help.setOpenExternalLinks(True) - nexus_help.setStyleSheet("font-size:11px;") - nexus_layout.addRow("", nexus_help) + self._nexus_status_lbl = QLabel("Not connected") + self._nexus_status_lbl.setStyleSheet("font-size:12px;color:#8888aa;") + nexus_layout.addRow("Status:", self._nexus_status_lbl) self._signout_btn = QPushButton("Sign Out") self._signout_btn.setFixedWidth(80) @@ -95,6 +80,26 @@ def _build(self): me3_import_btn.clicked.connect(self._import_me3_profiles) me3_layout.addRow("", me3_import_btn) + # ME3 version + update check + me3_ver_row = QHBoxLayout() + self._me3_ver_lbl = QLabel("") + self._me3_ver_lbl.setStyleSheet("font-size:12px;color:#e0e0ec;font-weight:600;") + me3_ver_row.addWidget(self._me3_ver_lbl) + me3_ver_row.addStretch() + me3_layout.addRow("Version:", me3_ver_row) + + me3_check_row = QHBoxLayout() + self._me3_check_btn = QPushButton("Check for ME3 Updates") + self._me3_check_btn.setObjectName("btn_blue") + self._me3_check_btn.setFixedWidth(180) + self._me3_check_btn.clicked.connect(self._check_me3_updates) + me3_check_row.addWidget(self._me3_check_btn) + self._me3_update_lbl = QLabel("") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#8888aa;") + me3_check_row.addWidget(self._me3_update_lbl) + me3_check_row.addStretch() + me3_layout.addRow("", me3_check_row) + layout.addWidget(me3_group) # ── Mod Storage ─────────────────────────────────────── @@ -152,11 +157,21 @@ def _build(self): layout.addWidget(btn_box) def _load(self): - key = self._config.get_nexus_api_key() - self._nexus_key.setText(key) - self._signout_btn.setVisible(bool(key)) + token = self._config.get_nexus_access_token() + user = self._config.get_nexus_user_info() + if token and user: + name = user.get("name", "Connected") + self._nexus_status_lbl.setText(f"Connected as {name}") + self._nexus_status_lbl.setStyleSheet("font-size:12px;color:#4ecca3;") + else: + self._nexus_status_lbl.setText("Not connected") + self._nexus_status_lbl.setStyleSheet("font-size:12px;color:#8888aa;") + self._signout_btn.setVisible(bool(token)) self._me3_path.setText(self._config.get_me3_path()) self._use_me3.setChecked(self._config.get_use_me3()) + from app.core.me3_service import get_me3_version + me3_ver = get_me3_version(self._config.get_me3_path()) + self._me3_ver_lbl.setText(me3_ver or "Not found") self._mods_dir.setText(self._config.get_mods_dir()) def _browse_me3(self): @@ -249,6 +264,92 @@ def _on_update_check_done(self, result): self._update_status_lbl.setText("Up to date") self._update_status_lbl.setStyleSheet("font-size:11px;color:#4a6a2a;font-weight:600;") + def _check_me3_updates(self): + self._me3_check_btn.setEnabled(False) + self._me3_update_lbl.setText("Checking...") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#8888aa;") + self._me3_update_checked.connect(self._on_me3_update_done) + + import threading + + def _work(): + from app.core.me3_service import get_me3_version, get_latest_me3_release + installed = get_me3_version(self._config.get_me3_path()) + latest = get_latest_me3_release() + self._me3_update_checked.emit({ + "installed": installed, + "latest": latest, + }) + + threading.Thread(target=_work, daemon=True).start() + + def _on_me3_update_done(self, result): + import re + self._me3_check_btn.setEnabled(True) + self._me3_update_checked.disconnect(self._on_me3_update_done) + + installed = result.get("installed") + latest_info = result.get("latest") + + if not installed: + self._me3_update_lbl.setText("ME3 not installed") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#e74c3c;") + return + + if not latest_info or latest_info.get("error"): + err = latest_info.get("error", "Unknown error") if latest_info else "Network error" + self._me3_update_lbl.setText(f"Check failed: {err}") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#e74c3c;") + return + + latest_ver = latest_info.get("version", "") + + def _norm(v): + return re.sub(r'^(me3\s+|[vV])', '', v.strip()) + + inst_n = _norm(installed) + latest_n = _norm(latest_ver) + + if inst_n == latest_n: + self._me3_update_lbl.setText("Up to date") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#4ecca3;font-weight:600;") + return + + try: + inst_parts = tuple(int(x) for x in inst_n.split(".")) + latest_parts = tuple(int(x) for x in latest_n.split(".")) + if inst_parts >= latest_parts: + self._me3_update_lbl.setText("Up to date") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#4ecca3;font-weight:600;") + return + except ValueError: + pass + + self._me3_update_lbl.setText(f"Update available: {latest_ver}") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#e94560;font-weight:600;") + + # Replace check button with update button + self._me3_check_btn.setText("Update ME3") + self._me3_check_btn.setObjectName("btn_accent") + self._me3_check_btn.setStyle(self._me3_check_btn.style()) # force style refresh + self._me3_check_btn.clicked.disconnect() + self._me3_check_btn.clicked.connect(lambda: self._run_me3_update(latest_ver)) + + def _run_me3_update(self, latest_ver: str): + from app.ui.dialogs.me3_update_dialog import ME3UpdateDialog + dlg = ME3UpdateDialog(self._config, latest_ver, parent=self) + if dlg.exec(): + from app.core.me3_service import get_me3_version + ver = get_me3_version(self._config.get_me3_path()) + self._me3_ver_lbl.setText(ver or "Not found") + self._me3_update_lbl.setText("Updated successfully!") + self._me3_update_lbl.setStyleSheet("font-size:11px;color:#4ecca3;font-weight:600;") + self._me3_check_btn.setText("Check for ME3 Updates") + self._me3_check_btn.setObjectName("btn_blue") + self._me3_check_btn.setStyle(self._me3_check_btn.style()) + self._me3_check_btn.clicked.disconnect() + self._me3_check_btn.clicked.connect(self._check_me3_updates) + def _browse_mods_dir(self): path = QFileDialog.getExistingDirectory( self, "Select Mod Storage Directory", self._mods_dir.text() or "" @@ -261,14 +362,12 @@ def _reset_mods_dir(self): def _sign_out(self): self._config.clear_nexus_auth() - self._nexus_key.clear() + self._nexus_status_lbl.setText("Not connected") + self._nexus_status_lbl.setStyleSheet("font-size:12px;color:#8888aa;") self._signout_btn.setVisible(False) self.settings_saved.emit() def _save(self): - key = self._nexus_key.text().strip() - if key: - self._config.set_nexus_api_key(key) self._config.set_me3_path(self._me3_path.text().strip()) self._config.set_use_me3(self._use_me3.isChecked()) mods_dir = self._mods_dir.text().strip() diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 37c67ad..3daaf21 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -68,6 +68,39 @@ def __init__(self, config: ConfigManager): self._build() self._load_games() + # ------------------------------------------------------------------ + # Auto-auth on first launch + # ------------------------------------------------------------------ + def showEvent(self, event): + super().showEvent(event) + if not getattr(self, "_show_event_fired", False): + self._show_event_fired = True + QTimer.singleShot(800, self._maybe_auto_auth) + + def _maybe_auto_auth(self): + """On first launch, prompt for Nexus auth if not already connected.""" + if self._config.get_nexus_access_token(): + return + if self._config.get("nexus_auth_prompted"): + return + logged_in = self._sidebar.nexus_widget.prompt_login() + self._config.set("nexus_auth_prompted", True) + if not logged_in: + from PySide6.QtWidgets import QMessageBox + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Information) + msg.setWindowTitle("Nexus Mods Sign-In Skipped") + msg.setText( + "You can still use the app, but without a Nexus Mods account " + "the following features will be unavailable:\n\n" + " - Automatic mod update checks\n" + " - Trending mods\n" + " - Direct mod downloads from Nexus\n\n" + "You can sign in at any time from the sidebar." + ) + msg.setStandardButtons(QMessageBox.Ok) + msg.exec() + # ------------------------------------------------------------------ # Layout construction # ------------------------------------------------------------------ @@ -602,10 +635,11 @@ def _progress(msg, pct): 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() - if not api_key: + access_token = self._config.get_nexus_access_token() + if not access_token: return pending = self._pending + config = self._config for game_id, game_info in self._games.items(): mods = self._config.get_game_mods(game_id) @@ -620,7 +654,7 @@ def _check_all_mod_updates(self): def _work(game_id=game_id, game_name=gname, mod=dict(mod)): from app.services.nexus_service import NexusService from app.core.mod_updater import version_compare - svc = NexusService(api_key) + svc = NexusService(access_token, config=config) domain = mod.get("nexus_domain", "") nid = mod.get("nexus_mod_id", 0) if not domain or not nid: diff --git a/app/ui/nexus_widget.py b/app/ui/nexus_widget.py index fb2db21..69c0cb4 100644 --- a/app/ui/nexus_widget.py +++ b/app/ui/nexus_widget.py @@ -1,39 +1,39 @@ """Nexus Mods authentication widget — shows login button or user info.""" +import time import threading from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QLabel, - QPushButton, QDialog, QLineEdit, QDialogButtonBox, - QFrame) + QPushButton, QDialog, QDialogButtonBox) from PySide6.QtCore import Qt, Signal, QTimer, QThread, QObject -from PySide6.QtGui import QPixmap, QCursor, QFont, QIcon, QPainter, QColor +from PySide6.QtGui import QPixmap, QFont, QPainter, QColor from app.config.config_manager import ConfigManager from app.services.nexus_service import NexusService -from app.services.nexus_sso import NexusSSOClient +from app.services.nexus_oauth import NexusOAuthClient, refresh_access_token -class _ValidateWorker(QObject): +class _RefreshWorker(QObject): + """Background worker to refresh an OAuth token.""" finished = Signal(dict) - def __init__(self, api_key: str): + def __init__(self, refresh_token: str): super().__init__() - self._key = api_key + self._refresh_token = refresh_token def run(self): - svc = NexusService(self._key) - result = svc.validate_user() + result = refresh_access_token(self._refresh_token) self.finished.emit(result) -class NexusApiKeyDialog(QDialog): - """Dialog for Nexus SSO authorization with manual API key fallback.""" +class NexusAuthDialog(QDialog): + """Dialog for Nexus OAuth 2.0 PKCE authorization.""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Connect Nexus Account") self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) self.setMinimumWidth(440) - self.api_key = "" - self._sso_client = None + self.tokens = None # dict with access_token, refresh_token, expires_at, user + self._oauth_client = None self._poll_timer = None self._build() @@ -52,163 +52,111 @@ def _build(self): desc.setStyleSheet("color:#8888aa;font-size:11px;") layout.addWidget(desc) - # ── SSO authorize button ────────────────────────── - self._sso_btn = QPushButton(" Authorize with Nexus Mods") - self._sso_btn.setObjectName("btn_accent") - self._sso_btn.setFixedHeight(36) - self._sso_btn.setCursor(Qt.PointingHandCursor) - self._sso_btn.clicked.connect(self._on_sso_start) - layout.addWidget(self._sso_btn) - - # SSO status label (hidden initially) - self._sso_status = QLabel("") - self._sso_status.setStyleSheet("color:#8888aa;font-size:11px;") - self._sso_status.setWordWrap(True) - self._sso_status.setVisible(False) - layout.addWidget(self._sso_status) - - # ── Separator ──────────────────────────────────── - sep_row = QHBoxLayout() - line1 = QFrame() - line1.setFrameShape(QFrame.HLine) - line1.setStyleSheet("color:#2a2a4a;") - sep_lbl = QLabel("or") - sep_lbl.setStyleSheet("color:#555577;font-size:10px;padding:0 8px;") - line2 = QFrame() - line2.setFrameShape(QFrame.HLine) - line2.setStyleSheet("color:#2a2a4a;") - sep_row.addWidget(line1) - sep_row.addWidget(sep_lbl) - sep_row.addWidget(line2) - layout.addLayout(sep_row) - - # ── Manual API key fallback (collapsed) ────────── - self._manual_toggle = QPushButton("Paste API key manually") - self._manual_toggle.setFlat(True) - self._manual_toggle.setCursor(Qt.PointingHandCursor) - self._manual_toggle.setStyleSheet( - "QPushButton{color:#8888aa;font-size:11px;text-align:left;" - "padding:0;border:none;background:transparent;}" - "QPushButton:hover{color:#e0e0ec;}" - ) - self._manual_toggle.clicked.connect(self._toggle_manual) - layout.addWidget(self._manual_toggle) - - self._manual_widget = QWidget() - ml = QVBoxLayout(self._manual_widget) - ml.setContentsMargins(0, 0, 0, 0) - ml.setSpacing(8) - - self._key_edit = QLineEdit() - self._key_edit.setPlaceholderText("Paste your API key from nexusmods.com/users/myaccount") - ml.addWidget(self._key_edit) - - open_btn = QPushButton("Open Nexus API key page") - open_btn.setFlat(True) - open_btn.setCursor(Qt.PointingHandCursor) - open_btn.setStyleSheet( - "QPushButton{color:#7b8cde;font-size:10px;text-align:left;" - "padding:0;border:none;background:transparent;}" - "QPushButton:hover{color:#e0e0ec;text-decoration:underline;}" + # ── OAuth authorize button ────────────────────────── + self._auth_btn = QPushButton(" Authorize with Nexus Mods") + self._auth_btn.setObjectName("btn_accent") + self._auth_btn.setFixedHeight(36) + self._auth_btn.setCursor(Qt.PointingHandCursor) + self._auth_btn.clicked.connect(self._on_auth_start) + layout.addWidget(self._auth_btn) + + # Status label (hidden initially) + self._status_lbl = QLabel("") + self._status_lbl.setStyleSheet("color:#8888aa;font-size:11px;") + self._status_lbl.setWordWrap(True) + self._status_lbl.setVisible(False) + layout.addWidget(self._status_lbl) + + # ── Cancel button ────────────────────────────────── + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self._on_cancel) + layout.addWidget(cancel_btn) + + # ── OAuth PKCE flow ────────────────────────────────────── + + def _on_auth_start(self): + """Start the OAuth 2.0 PKCE flow.""" + self._auth_btn.setText(" Waiting for authorization...") + self._auth_btn.setEnabled(False) + self._status_lbl.setText( + "Your browser has been opened. Approve the request to continue." ) - open_btn.clicked.connect(self._open_nexus) - ml.addWidget(open_btn) - - self._manual_widget.setVisible(False) - layout.addWidget(self._manual_widget) - - # ── Buttons ────────────────────────────────────── - btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - btns.accepted.connect(self._on_ok) - btns.rejected.connect(self._on_cancel) - layout.addWidget(btns) - - # ── SSO flow ────────────────────────────────────────── - - def _on_sso_start(self): - """Start the WebSocket SSO flow.""" - self._sso_btn.setText(" Waiting for authorization...") - self._sso_btn.setEnabled(False) - self._sso_status.setText("Approve the request in your browser to continue.") - self._sso_status.setStyleSheet("color:#8888aa;font-size:11px;") - self._sso_status.setVisible(True) - - self._sso_client = NexusSSOClient() - self._sso_client.start() + self._status_lbl.setStyleSheet("color:#8888aa;font-size:11px;") + self._status_lbl.setVisible(True) + + self._oauth_client = NexusOAuthClient() + self._oauth_client.start() + + # Check for error from server startup + _, err = self._oauth_client.poll() + if err: + self._status_lbl.setText(f"Failed to start: {err}") + self._status_lbl.setStyleSheet("color:#e74c3c;font-size:11px;") + self._auth_btn.setText(" Authorize with Nexus Mods") + self._auth_btn.setEnabled(True) + self._oauth_client = None + return self._poll_timer = QTimer(self) - self._poll_timer.timeout.connect(self._poll_sso) + self._poll_timer.timeout.connect(self._poll_oauth) self._poll_timer.start(500) - def _poll_sso(self): - """Check if SSO has returned a key or error.""" - if not self._sso_client: + def _poll_oauth(self): + """Check if OAuth has returned tokens or error.""" + if not self._oauth_client: return - key, err = self._sso_client.poll() + tokens, err = self._oauth_client.poll() - if key: - self._stop_sso() - self.api_key = key + if tokens: + self._stop_oauth() + self.tokens = tokens self.accept() elif err: - self._stop_sso() - self._sso_status.setText(f"Authorization failed: {err}") - self._sso_status.setStyleSheet("color:#e74c3c;font-size:11px;") - self._sso_btn.setText(" Authorize with Nexus Mods") - self._sso_btn.setEnabled(True) - - def _stop_sso(self): - """Clean up SSO client and timer.""" + self._stop_oauth() + self._status_lbl.setText(f"Authorization failed: {err}") + self._status_lbl.setStyleSheet("color:#e74c3c;font-size:11px;") + self._auth_btn.setText(" Authorize with Nexus Mods") + self._auth_btn.setEnabled(True) + + def _stop_oauth(self): + """Clean up OAuth client and timer.""" if self._poll_timer: self._poll_timer.stop() self._poll_timer = None - if self._sso_client: - self._sso_client.stop() - self._sso_client = None - - # ── Manual fallback ────────────────────────────────── - - def _toggle_manual(self): - visible = not self._manual_widget.isVisible() - self._manual_widget.setVisible(visible) - self._manual_toggle.setText( - "Hide manual entry" if visible else "Paste API key manually" - ) - - def _open_nexus(self): - import webbrowser - webbrowser.open("https://www.nexusmods.com/users/myaccount?tab=api+access") - - def _on_ok(self): - key = self._key_edit.text().strip() - if key: - self._stop_sso() - self.api_key = key - self.accept() + if self._oauth_client: + self._oauth_client.stop() + self._oauth_client = None def _on_cancel(self): - self._stop_sso() + self._stop_oauth() self.reject() def closeEvent(self, event): - self._stop_sso() + self._stop_oauth() super().closeEvent(event) class NexusWidget(QWidget): """Top of sidebar — shows login button or logged-in user.""" - auth_changed = Signal(str) # emits api_key on change + auth_changed = Signal(str) # emits access_token on change _avatar_ready = Signal(bytes) # internal: avatar image data from bg thread def __init__(self, config: ConfigManager, parent=None): super().__init__(parent) self._config = config + self._thread = None + self._worker = None self._build() self._refresh() - # Re-validate cached key in background to catch expired/revoked keys - if self._config.get_nexus_api_key(): - QTimer.singleShot(500, self._revalidate_key) + # Try to refresh token in background to catch revoked tokens + if self._config.get_nexus_access_token(): + QTimer.singleShot(500, self._revalidate_token) + # Silent renew: check token every 5 minutes, refresh if near expiry + self._renew_timer = QTimer(self) + self._renew_timer.setInterval(5 * 60 * 1000) # 5 minutes + self._renew_timer.timeout.connect(self._silent_renew) + self._renew_timer.start() def _build(self): self._avatar_ready.connect(self._on_avatar_ready) @@ -232,6 +180,20 @@ def _build(self): self._login_btn.clicked.connect(self._on_login) ll.addWidget(self._login_btn) + info_lbl = QLabel( + "Sign in to enable update checking,\n" + "trending mods, and Nexus downloads." + ) + info_lbl.setWordWrap(True) + info_lbl.setStyleSheet( + "font-size:10px;" + "color:#8888aa;" + "background:rgba(42,42,74,0.5);" + "border-radius:4px;" + "padding:6px 8px;" + ) + ll.addWidget(info_lbl) + self._layout.addWidget(self._login_widget) # Logged in state @@ -254,7 +216,7 @@ def _build(self): user_info.setSpacing(1) self._name_lbl = QLabel("User") self._name_lbl.setStyleSheet("font-size:12px;font-weight:700;color:#e0e0ec;") - self._status_lbl = QLabel("Premium" ) + self._status_lbl = QLabel("Premium") self._status_lbl.setStyleSheet("font-size:10px;color:#4ecca3;") user_info.addWidget(self._name_lbl) user_info.addWidget(self._status_lbl) @@ -275,9 +237,9 @@ def _set_default_avatar(self): self._avatar_lbl.setPixmap(px) def _refresh(self): - key = self._config.get_nexus_api_key() + token = self._config.get_nexus_access_token() user = self._config.get_nexus_user_info() - logged_in = bool(key and user) + logged_in = bool(token and user) self._login_widget.setVisible(not logged_in) self._user_widget.setVisible(logged_in) @@ -321,7 +283,7 @@ def _on_avatar_ready(self, data: bytes): y = (scaled.height() - 32) // 2 scaled = scaled.copy(x, y, 32, 32) # Apply circular mask - from PySide6.QtGui import QPainterPath, QBrush + from PySide6.QtGui import QPainterPath circle = QPixmap(32, 32) circle.fill(QColor("transparent")) p = QPainter(circle) @@ -333,73 +295,164 @@ def _on_avatar_ready(self, data: bytes): p.end() self._avatar_lbl.setPixmap(circle) - def _revalidate_key(self): - """Background check that the stored API key is still valid.""" - key = self._config.get_nexus_api_key() - if not key: - return + def _start_bg_work(self, worker, on_finished): + """Safely start a background QThread, cleaning up any previous one.""" + if self._thread is not None: + self._thread.quit() + self._thread.wait() self._thread = QThread() - self._worker = _ValidateWorker(key) + self._worker = worker self._worker.moveToThread(self._thread) self._thread.started.connect(self._worker.run) - self._worker.finished.connect(self._on_revalidated) + self._worker.finished.connect(on_finished) self._worker.finished.connect(self._thread.quit) self._thread.start() - def _on_revalidated(self, result: dict): + def _silent_renew(self): + """Periodically refresh the token before it expires.""" + if self._thread is not None and self._thread.isRunning(): + return + tokens = self._config.get_nexus_tokens() + if not tokens.get("refresh_token"): + return + expires_at = tokens.get("expires_at", 0) + # Refresh if token expires within the next 10 minutes + if time.time() >= expires_at - 600: + print("[NEXUS] Silent renew: token near expiry, refreshing", flush=True) + self._start_bg_work( + _RefreshWorker(tokens["refresh_token"]), + self._on_token_refreshed, + ) + + def _revalidate_token(self): + """Background check that the stored token is still valid.""" + if self._thread is not None and self._thread.isRunning(): + return + tokens = self._config.get_nexus_tokens() + refresh_token = tokens.get("refresh_token", "") + if not refresh_token: + return + # If token is not expired, just validate via API + if not self._config.is_nexus_token_expired(): + token = tokens.get("access_token", "") + self._start_bg_work( + _ValidateWorker(token, self._config), + self._on_revalidated, + ) + else: + # Token expired — try refresh + self._start_bg_work( + _RefreshWorker(refresh_token), + self._on_token_refreshed, + ) + + def _on_token_refreshed(self, result: dict): if "error" in result: - print(f"[NEXUS] Stored API key is invalid, clearing auth", flush=True) + print("[NEXUS] Token refresh failed, clearing auth", flush=True) self._config.clear_nexus_auth() self._refresh() self.auth_changed.emit("") else: - # Update cached user info in case it changed - self._config.set_nexus_user_info({ - "name": result.get("name", ""), - "is_premium": result.get("is_premium", False), - "is_supporter": result.get("is_supporter", False), - "profile_url": result.get("profile_url", ""), - }) + self._config.set_nexus_tokens(result) + # Update user info from JWT + from app.services.nexus_oauth import extract_user_info + user_info = extract_user_info(result.get("access_token", "")) + if user_info.get("name"): + self._config.set_nexus_user_info(user_info) self._refresh() - def _on_login(self): - dlg = NexusApiKeyDialog(self) - if dlg.exec() == QDialog.Accepted and dlg.api_key: - self._validate_and_save(dlg.api_key) - - def _validate_and_save(self, api_key: str): - self._login_btn.setText("Validating...") - self._login_btn.setEnabled(False) + def _on_revalidated(self, result: dict): + if "error" in result: + # Token might be expired — try refresh before giving up + tokens = self._config.get_nexus_tokens() + refresh_token = tokens.get("refresh_token", "") + if refresh_token: + QTimer.singleShot(0, lambda: self._start_bg_work( + _RefreshWorker(refresh_token), + self._on_token_refreshed, + )) + else: + print("[NEXUS] Stored token is invalid, clearing auth", flush=True) + self._config.clear_nexus_auth() + self._refresh() + self.auth_changed.emit("") + else: + # Update cached user info — merge avatar from GraphQL with JWT data + existing = self._config.get_nexus_user_info() or {} + avatar_url = result.get("avatar", "") or result.get("profile_url", "") + existing["profile_url"] = avatar_url + if result.get("name"): + existing["name"] = result["name"] + self._config.set_nexus_user_info(existing) + self._refresh() - self._thread = QThread() - self._worker = _ValidateWorker(api_key) - self._worker.moveToThread(self._thread) - self._thread.started.connect(self._worker.run) - self._worker.finished.connect(self._on_validated) - self._worker.finished.connect(self._thread.quit) - self._thread.start() - self._pending_key = api_key + def prompt_login(self) -> bool: + """Programmatically open the auth dialog (used for auto-auth on first launch). - def _on_validated(self, result: dict): - self._login_btn.setText("Connect Account") - self._login_btn.setEnabled(True) + Returns True if the user successfully authenticated. + """ + if self._config.get_nexus_access_token(): + return True + self._on_login() + return bool(self._config.get_nexus_access_token()) - if "error" in result: - from PySide6.QtWidgets import QMessageBox - QMessageBox.warning(self, "Nexus Auth", f"Failed to validate key:\n{result['error']}") - return - - self._config.set_nexus_api_key(self._pending_key) - self._config.set_nexus_user_info({ - "name": result.get("name", ""), - "is_premium": result.get("is_premium", False), - "is_supporter": result.get("is_supporter", False), - "profile_url": result.get("profile_url", ""), - }) + def _on_login(self): + dlg = NexusAuthDialog(self) + if dlg.exec() == QDialog.Accepted and dlg.tokens: + self._save_tokens(dlg.tokens) + + def _save_tokens(self, tokens: dict): + """Store OAuth tokens and user info, update UI.""" + self._config.set_nexus_tokens(tokens) + user_info = tokens.get("user", {}) + if user_info: + self._config.set_nexus_user_info(user_info) self._refresh() - self.auth_changed.emit(self._pending_key) + self.auth_changed.emit(tokens.get("access_token", "")) + # Fetch full profile (including avatar) from the Nexus API + QTimer.singleShot(200, self._revalidate_token) def _on_logout(self): self._config.clear_nexus_auth() self._refresh() self.auth_changed.emit("") + + +class _ValidateWorker(QObject): + """Background worker to validate a token via the Nexus API.""" + finished = Signal(dict) + + def __init__(self, access_token: str, config=None): + super().__init__() + self._token = access_token + self._config = config + + def run(self): + import json + import urllib.request + import urllib.error + # Use Nexus v2 GraphQL API (supports OAuth Bearer tokens) + try: + # Get user ID from JWT to query their profile + from app.services.nexus_oauth import decode_jwt_payload + jwt_user = decode_jwt_payload(self._token).get("user", {}) + user_id = jwt_user.get("id", 0) + query = json.dumps({"query": f'{{ user(id: {user_id}) {{ avatar, name, memberId }} }}'}) + req = urllib.request.Request( + "https://api.nexusmods.com/v2/graphql", + data=query.encode("utf-8"), + headers={ + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + "User-Agent": "FromSoftModManager/2.1.0", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read().decode()) + user_data = result.get("data", {}).get("user", {}) + self.finished.emit(user_data if user_data else {"error": "No user data"}) + return + except Exception: + pass + self.finished.emit({"error": "Could not fetch user info"}) diff --git a/app/ui/sidebar.py b/app/ui/sidebar.py index 7a9d30c..dc3a9a2 100644 --- a/app/ui/sidebar.py +++ b/app/ui/sidebar.py @@ -221,10 +221,23 @@ def _build(self): self._me3_lbl = QLabel("ME3: checking…") self._me3_lbl.setStyleSheet( - "font-size:10px;color:#3a3a5a;padding:0px 14px 6px 14px;" + "font-size:10px;color:#3a3a5a;padding:0px 14px 2px 14px;" ) layout.addWidget(self._me3_lbl) + # ME3 update button (hidden until update detected) + self._me3_update_btn = QPushButton("\uE895 Update Available") + self._me3_update_btn.setFont(QFont(_MDL2, 9)) + self._me3_update_btn.setStyleSheet( + "QPushButton{font-family:'Segoe UI','Segoe MDL2 Assets';font-size:10px;" + "color:#e94560;background:rgba(233,69,96,0.08);border:1px solid rgba(233,69,96,0.25);" + "border-radius:4px;padding:3px 8px;margin:0px 14px 6px 14px;text-align:left;}" + "QPushButton:hover{background:rgba(233,69,96,0.18);color:#ff6b8a;}" + ) + self._me3_update_btn.setCursor(Qt.PointingHandCursor) + self._me3_update_btn.setVisible(False) + layout.addWidget(self._me3_update_btn) + def populate_games(self, games: dict): """Rebuild the game button list.""" # Clear existing buttons @@ -319,9 +332,18 @@ def _start_me3_version_check(self): pending = self._pending def _work(): - from app.core.me3_service import get_me3_version + from app.core.me3_service import get_me3_version, get_latest_me3_release ver = get_me3_version(config.get_me3_path()) pending.put(("me3_ver", ver)) + # Check for ME3 updates + if ver: + latest = get_latest_me3_release() + if latest and not latest.get("error") and latest.get("version"): + pending.put(("me3_update", ver, latest["version"])) + # Check for app updates + from app.services.update_service import check_for_update + app_result = check_for_update() + pending.put(("app_update", app_result)) threading.Thread(target=_work, daemon=True).start() @@ -330,17 +352,22 @@ def _poll_updates(self): while True: item = self._pending.get_nowait() if item[0] == "me3_ver": + import re as _re_mod ver = item[1] if ver: - self._me3_lbl.setText(f"ME3: {ver}") + display = _re_mod.sub(r'^me3\s+', '', ver) + self._me3_lbl.setText(f"ME3: {display}") self._me3_lbl.setStyleSheet( - "font-size:10px;color:#3a3a5a;padding:0px 14px 6px 14px;" + "font-size:10px;color:#3a3a5a;padding:0px 14px 2px 14px;" ) else: self._me3_lbl.setText("ME3: not found") self._me3_lbl.setStyleSheet( - "font-size:10px;color:#e74c3c;padding:0px 14px 6px 14px;" + "font-size:10px;color:#e74c3c;padding:0px 14px 2px 14px;" ) + elif item[0] == "me3_update": + installed_ver, latest_ver = item[1], item[2] + self._check_me3_update(installed_ver, latest_ver) elif item[0] == "logo_ready": game_id, path = item[1], item[2] if game_id in self._game_buttons: @@ -351,9 +378,67 @@ def _poll_updates(self): self._game_buttons[game_id].set_player_count(count) elif item[0] == "fetch_counts_done": self._fetching_counts = False + elif item[0] == "app_update": + result = item[1] + if not result.get("has_update"): + self._version_lbl.setStyleSheet( + "font-size:10px;color:#4ecca3;padding:6px 14px 2px 14px;" + ) except _queue.Empty: pass + def _check_me3_update(self, installed_ver: str, latest_ver: str): + """Compare installed and latest ME3 versions, prompt update if newer.""" + import re + def _norm(v: str) -> str: + # Strip prefixes like "me3 " or "v" + v = re.sub(r'^(me3\s+|[vV])', '', v.strip()) + return v + + inst = _norm(installed_ver) + latest = _norm(latest_ver) + if inst == latest: + self._me3_lbl.setStyleSheet( + "font-size:10px;color:#4ecca3;padding:0px 14px 2px 14px;" + ) + return + + # Simple version comparison via tuple + try: + inst_parts = tuple(int(x) for x in inst.split(".")) + latest_parts = tuple(int(x) for x in latest.split(".")) + if inst_parts >= latest_parts: + self._me3_lbl.setStyleSheet( + "font-size:10px;color:#4ecca3;padding:0px 14px 2px 14px;" + ) + return + except ValueError: + # Non-numeric version, fall back to string comparison + if inst >= latest: + return + + self._me3_lbl.setText(f"ME3: {inst}") + self._me3_lbl.setStyleSheet( + "font-size:10px;color:#3a3a5a;padding:0px 14px 2px 14px;" + ) + self._me3_update_btn.setText(f"Update to {latest_ver}") + self._me3_update_btn.setVisible(True) + self._me3_update_btn.clicked.connect(lambda: self._prompt_me3_update(latest_ver)) + + def _prompt_me3_update(self, latest_ver: str): + from app.ui.dialogs.me3_update_dialog import ME3UpdateDialog + dlg = ME3UpdateDialog(self._config, latest_ver, parent=self.window()) + if dlg.exec(): + from app.core.me3_service import get_me3_version + import re + ver = get_me3_version(self._config.get_me3_path()) + display = re.sub(r'^me3\s+', '', ver) if ver else "updated" + self._me3_lbl.setText(f"ME3: {display}") + self._me3_lbl.setStyleSheet( + "font-size:10px;color:#4ecca3;padding:0px 14px 2px 14px;" + ) + self._me3_update_btn.setVisible(False) + @property def nexus_widget(self): return self._nexus diff --git a/app/ui/tabs/mods_tab.py b/app/ui/tabs/mods_tab.py index ca27082..4be3c94 100644 --- a/app/ui/tabs/mods_tab.py +++ b/app/ui/tabs/mods_tab.py @@ -672,7 +672,7 @@ def _update_header(self): # Update checks # ------------------------------------------------------------------ def _start_update_checks(self): - api_key = self._config.get_nexus_api_key() + api_key = self._config.get_nexus_access_token() for mod_id, card in self._cards.items(): if card.is_virtual: continue @@ -688,14 +688,15 @@ def _start_update_checks(self): self._spawn_update_check(mod) def _spawn_update_check(self, mod: dict): - api_key = self._config.get_nexus_api_key() + api_key = self._config.get_nexus_access_token() + config = self._config pending = self._pending mod_id = mod["id"] version_dir = self._get_mod_version_dir(mod_id) def _work(): from app.core.mod_updater import version_compare - svc = NexusService(api_key) + svc = NexusService(api_key, config=config) domain = mod.get("nexus_domain", "") nid = mod.get("nexus_mod_id", 0) # Use the Nexus mod-page version as single source of truth @@ -720,16 +721,17 @@ def _work(): # Trending mods # ------------------------------------------------------------------ def _start_trending_fetch(self): - api_key = self._config.get_nexus_api_key() + api_key = self._config.get_nexus_access_token() if not api_key or self._trending_loaded: return nexus_domain = self._gdef.get("nexus_domain", "") if not nexus_domain: return + config = self._config pending = self._pending def _work(): - svc = NexusService(api_key) + svc = NexusService(api_key, config=config) mods = svc.get_trending_mods(nexus_domain) # Fetch game categories to identify utility/tool categories cats = svc.get_game_categories(nexus_domain) @@ -883,18 +885,20 @@ def _do_install(self, mod_id: str): if not card: return mod = card.mod - api_key = self._config.get_nexus_api_key() + api_key = self._config.get_nexus_access_token() if mod.get("nexus_mod_id") and api_key: # Download from Nexus self._run_nexus_install(mod_id, mod) elif mod.get("nexus_mod_id") and not api_key: - # Mod has Nexus info but no API key — open SSO dialog - from app.ui.nexus_widget import NexusApiKeyDialog - dlg = NexusApiKeyDialog(parent=self) - if dlg.exec() == QDialog.Accepted and dlg.api_key: - self._config.set_nexus_api_key(dlg.api_key) - self._validate_and_save_nexus_key(dlg.api_key) + # Mod has Nexus info but no token — open OAuth dialog + from app.ui.nexus_widget import NexusAuthDialog + dlg = NexusAuthDialog(parent=self) + if dlg.exec() == QDialog.Accepted and dlg.tokens: + self._config.set_nexus_tokens(dlg.tokens) + user_info = dlg.tokens.get("user", {}) + if user_info: + self._config.set_nexus_user_info(user_info) self._run_nexus_install(mod_id, mod) else: # No Nexus info — fall back to zip browser @@ -927,7 +931,7 @@ def _run_nexus_install(self, mod_id: str, mod: dict): fake_gdef["nexus_mod_id"] = mod.get("nexus_mod_id", 0) def _work(): - svc = NexusService(config.get_nexus_api_key()) + svc = NexusService(config.get_nexus_access_token(), config=config) def _cb(pct, msg): pending.put(("install_progress", mod_id, pct, msg)) @@ -996,12 +1000,13 @@ def _work(): threading.Thread(target=_work, daemon=True).start() - def _validate_and_save_nexus_key(self, api_key: str): - """Validate a newly-obtained API key and save user info in background.""" + def _validate_and_save_nexus_key(self, token: str): + """Validate a newly-obtained token and save user info in background.""" pending = self._pending + config = self._config def _work(): - svc = NexusService(api_key) + svc = NexusService(token, config=config) result = svc.validate_user() if "error" not in result: pending.put(("nexus_validated", result)) @@ -1040,7 +1045,7 @@ def _on_install_done(self, mod_id: str, result: dict, mod_dict: dict): self.log_message.emit(f"Installed {mod_dict.get('name', mod_id)}", "success") self.mod_installed.emit() - if mod_dict.get("nexus_mod_id") and self._config.get_nexus_api_key(): + if mod_dict.get("nexus_mod_id") and self._config.get_nexus_access_token(): self._spawn_update_check(mod_dict) else: if result.get("requires_premium"): @@ -1107,9 +1112,9 @@ def _do_update(self, mod_id: str): if not card: return mod = card.mod - api_key = self._config.get_nexus_api_key() + api_key = self._config.get_nexus_access_token() if not api_key: - self.log_message.emit("No Nexus API key — cannot auto-update", "error") + self.log_message.emit("Not connected to Nexus — cannot auto-update", "error") return gdef = self._gdef @@ -1141,7 +1146,7 @@ def _do_update(self, mod_id: str): def _work(): from app.core.mod_installer import _merge_ini_settings - svc = NexusService(api_key) + svc = NexusService(api_key, config=config) temp_dir = os.path.join(config.get_mods_dir(), "_tmp") def _cb(pct, msg): @@ -1344,7 +1349,7 @@ def _on_add_mod(self): ) # Check for updates on the newly installed mod - if mod_dict.get("nexus_mod_id") and self._config.get_nexus_api_key(): + if mod_dict.get("nexus_mod_id") and self._config.get_nexus_access_token(): self._spawn_update_check(mod_dict) # ------------------------------------------------------------------ diff --git a/build/build.py b/build/build.py index ded0423..39d7259 100644 --- a/build/build.py +++ b/build/build.py @@ -41,7 +41,7 @@ "--hidden-import", "app.core.mod_updater", "--hidden-import", "app.core.me3_service", "--hidden-import", "app.services.nexus_service", - "--hidden-import", "app.services.nexus_sso", + "--hidden-import", "app.services.nexus_oauth", "--hidden-import", "app.services.steam_service", "--hidden-import", "app.services.update_service", "--hidden-import", "py7zr", diff --git a/requirements.txt b/requirements.txt index 6f5cde9..3363502 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ PySide6>=6.6.0 requests>=2.31.0 tomli-w>=1.0.0 tomlkit>=0.12.0 -websocket-client>=1.6.0 py7zr>=0.20.0 rarfile>=4.0 pyinstaller>=6.0.0