diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e94eb0..4c8a5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Jackify Changelog +## v0.7.2.2 - DirectX Download and Install Fixes +**Release Date:** 2026-08-10 + +### Fixes +- Fixed DirectX component installation failing after the download mirror Jackify used was taken offline. DirectX is now downloaded from Microsoft directly, with a fallback source. +- Fixed Jackify not recognising Proton 11 naming. +- Fixed manually downloaded archives not being recognised when the file name begins with a space, leaving the download stuck as pending. +- Fixed the Community Shaders d3dcompiler_47 replacement installing the wrong architecture into the 32-bit system folder. +- Fixed a false "Download Stalled" warning on slow archives, and the archive counter flickering between totals during installs. +- Fixed a Nexus login race that could log you out mid-install. + ## v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix **Release Date:** 2026-07-25 diff --git a/jackify/__init__.py b/jackify/__init__.py index a339979..f24b5ee 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing Wabbajack modlists natively on Linux systems. """ -__version__ = "0.7.2.1" +__version__ = "0.7.2.2" diff --git a/jackify/backend/handlers/native_component_downloader.py b/jackify/backend/handlers/native_component_downloader.py new file mode 100644 index 0000000..0d94543 --- /dev/null +++ b/jackify/backend/handlers/native_component_downloader.py @@ -0,0 +1,115 @@ +"""Download helpers for the native Wine component installer. + +Multi-source fetching with integrity checking, split out of +native_component_installer.py to keep that module within the file size limit. +""" + +import hashlib +import logging +import time +import urllib.request +from pathlib import Path +from typing import Sequence, Union + +logger = logging.getLogger(__name__) + + +class ComponentDownloadMixin: + """Fetches component payloads. Expects the host class to provide `logger`, + `_emit_status()` and, where relevant, `_current_component`.""" + + def _download_file(self, url: Union[str, Sequence[str]], dest: Path, + sha256: Union[str, Sequence[str]] = "") -> bool: + urls = [url] if isinstance(url, str) else list(url) + + if dest.is_file(): + if not sha256: + return True + if self._verify_sha256(dest, sha256): + return True + self.logger.warning("SHA256 mismatch on cached %s, re-downloading", dest.name) + dest.unlink() + + for index, candidate in enumerate(urls): + if not self._download_from_url(candidate, dest): + continue + if sha256 and not self._verify_sha256(dest, sha256): + self.logger.error("SHA256 mismatch on %s from %s", dest.name, candidate) + dest.unlink() + continue + return True + + self.logger.error("All %d source(s) failed for %s", len(urls), dest.name) + return False + + def _download_from_url(self, url: str, dest: Path) -> bool: + """Download to a .part file and rename only once the transfer is verifiably + complete, so an interrupted download can never be left behind and trusted as a + cached copy on the next run.""" + component = getattr(self, '_current_component', dest.stem) + self.logger.info("Downloading %s ...", dest.name) + self._emit_status(f"Downloading {dest.name}...") + partial = dest.with_name(dest.name + '.part') + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Jackify/1.0'}) + with urllib.request.urlopen(req, timeout=120) as resp: + total = int(resp.headers.get('Content-Length', 0) or 0) + downloaded = 0 + start = time.monotonic() + last_emit = start + chunk_size = 65536 + with open(partial, 'wb') as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + now = time.monotonic() + if total > 0 and now - last_emit >= 0.5: + pct = downloaded / total * 100.0 + elapsed = now - start + speed = downloaded / elapsed / 1048576.0 if elapsed > 0.05 else 0.0 + self._emit_status(f"[NATIVE_DL] {component} {pct:.1f} {speed:.1f}") + last_emit = now + + if downloaded == 0: + self.logger.error("Download from %s produced an empty file", url) + partial.unlink(missing_ok=True) + return False + if total > 0 and downloaded != total: + self.logger.error( + "Truncated download from %s: got %d bytes, expected %d", + url, downloaded, total, + ) + partial.unlink(missing_ok=True) + return False + + partial.replace(dest) + return True + except Exception as exc: + self.logger.error("Download failed for %s: %s", url, exc) + partial.unlink(missing_ok=True) + return False + + def _verify_sha256(self, path: Path, expected: Union[str, Sequence[str]]) -> bool: + accepted = {expected.lower()} if isinstance(expected, str) else {e.lower() for e in expected} + h = hashlib.sha256() + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + return h.hexdigest().lower() in accepted + except Exception: + return False + + def _discard_cached_installer(self, path: Path) -> None: + """Drop a cached installer that Wine refused to run. The redist URLs are not + version-pinned, so a corrupt cached copy is otherwise re-used on every retry and + no amount of re-running configuration would clear it.""" + try: + path.unlink(missing_ok=True) + self.logger.info("Discarded cached installer %s so the next attempt re-downloads", path.name) + except Exception as exc: + self.logger.debug("Could not discard cached installer %s: %s", path.name, exc) + diff --git a/jackify/backend/handlers/native_component_installer.py b/jackify/backend/handlers/native_component_installer.py index b565ab0..79a37a0 100644 --- a/jackify/backend/handlers/native_component_installer.py +++ b/jackify/backend/handlers/native_component_installer.py @@ -5,7 +5,6 @@ Falls back to winetricks -> protontricks for unsupported or failed components. """ import datetime -import hashlib import json import logging import os @@ -13,11 +12,11 @@ import shutil import subprocess import tempfile import time -import urllib.request import zipfile from pathlib import Path from typing import Dict, List, Optional, Tuple +from jackify.backend.handlers.native_component_downloader import ComponentDownloadMixin from jackify.shared.paths import get_jackify_data_dir logger = logging.getLogger(__name__) @@ -27,8 +26,19 @@ _D3DCOMPILER_47_X64_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcom _D3DCOMPILER_47_X86_SHA256 = "2ad0d4987fc4624566b190e747c9d95038443956ed816abfd1e2d389b5ec0851" _D3DCOMPILER_47_X64_SHA256 = "4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3" -_DIRECTX_CAB_URL = "https://files.holarse-linuxgaming.de/mirrors/microsoft/directx_Jun2010_redist.exe" -_DIRECTX_CAB_SHA256 = "8746ee1a84a083a90e37899d71d50d5c7c015e69688a466aa80447f011780c0d" +# The holarse mirror this used to point at now returns 403 for the whole +# /mirrors/microsoft/ path. Microsoft's Download Center entry (id=8109) is the primary +# source, with the Wayback capture of the old mirror as fallback. +_DIRECTX_CAB_URLS = ( + "https://download.microsoft.com/download/8/4/A/84A35BF1-DAFE-4AE8-82AF-AD2AE20B6B14/directx_Jun2010_redist.exe", + "https://web.archive.org/web/20260218142109id_/https://files.holarse-linuxgaming.de/mirrors/microsoft/directx_Jun2010_redist.exe", +) +# Microsoft re-signed the package in 2024. The Download Center copy and the archived copy +# differ in the outer signature only; the inner cabinets are identical. +_DIRECTX_CAB_SHA256 = ( + "053f76dcbb28802e23341b6a787e3b0791c0fa5c8d4d011b1044172dbf89c73b", + "8746ee1a84a083a90e37899d71d50d5c7c015e69688a466aa80447f011780c0d", +) _VCRUN2022_X86_URL = "https://aka.ms/vs/17/release/vc_redist.x86.exe" _VCRUN2022_X64_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe" @@ -79,7 +89,7 @@ SUPPORTED_COMPONENTS = ( ) -class NativeComponentInstaller: +class NativeComponentInstaller(ComponentDownloadMixin): """Direct-source Wine component installer. Handles Groups 1-4 from the native install spec.""" def __init__(self, wineprefix: str, wine_binary: str, wine_env: dict, log=None): @@ -223,56 +233,6 @@ class NativeComponentInstaller: self._direct_reg_write(r'Software\Wine\DllOverrides', overrides) self.logger.debug("DLL overrides written directly to user.reg (%d entries)", len(overrides)) - def _download_file(self, url: str, dest: Path, sha256: str = "") -> bool: - if dest.is_file(): - if not sha256: - return True - if self._verify_sha256(dest, sha256): - return True - self.logger.warning("SHA256 mismatch on cached %s, re-downloading", dest.name) - dest.unlink() - component = getattr(self, '_current_component', dest.stem) - self.logger.info("Downloading %s ...", dest.name) - self._emit_status(f"Downloading {dest.name}...") - try: - req = urllib.request.Request(url, headers={'User-Agent': 'Jackify/1.0'}) - with urllib.request.urlopen(req, timeout=120) as resp: - total = int(resp.headers.get('Content-Length', 0) or 0) - downloaded = 0 - start = time.monotonic() - last_emit = start - chunk_size = 65536 - with open(dest, 'wb') as f: - while True: - chunk = resp.read(chunk_size) - if not chunk: - break - f.write(chunk) - downloaded += len(chunk) - now = time.monotonic() - if total > 0 and now - last_emit >= 0.5: - pct = downloaded / total * 100.0 - elapsed = now - start - speed = downloaded / elapsed / 1048576.0 if elapsed > 0.05 else 0.0 - self._emit_status(f"[NATIVE_DL] {component} {pct:.1f} {speed:.1f}") - last_emit = now - return True - except Exception as exc: - self.logger.error("Download failed for %s: %s", url, exc) - if dest.is_file(): - dest.unlink() - return False - - def _verify_sha256(self, path: Path, expected: str) -> bool: - h = hashlib.sha256() - try: - with open(path, 'rb') as f: - for chunk in iter(lambda: f.read(65536), b''): - h.update(chunk) - return h.hexdigest().lower() == expected.lower() - except Exception: - return False - def _get_cabextract(self) -> Optional[str]: if os.environ.get('APPDIR'): candidate = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract') @@ -331,7 +291,7 @@ class NativeComponentInstaller: cache_dir = get_jackify_data_dir() / 'component_cache' / 'directx' cache_dir.mkdir(parents=True, exist_ok=True) redist = cache_dir / 'directx_Jun2010_redist.exe' - if not self._download_file(_DIRECTX_CAB_URL, redist, _DIRECTX_CAB_SHA256): + if not self._download_file(_DIRECTX_CAB_URLS, redist, _DIRECTX_CAB_SHA256): return False if not self._verify_sha256(redist, _DIRECTX_CAB_SHA256): self.logger.error("SHA256 mismatch on DirectX redistributable") @@ -430,6 +390,7 @@ class NativeComponentInstaller: if r.returncode not in (0, 3010): self.logger.error("vcrun2022: x86 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace')) + self._discard_cached_installer(x86) return False # x64: same msvcp140.dll pre-extraction workaround @@ -458,6 +419,7 @@ class NativeComponentInstaller: if r.returncode not in (0, 3010): self.logger.error("vcrun2022: x64 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace')) + self._discard_cached_installer(x64) return False critical = [ @@ -499,6 +461,7 @@ class NativeComponentInstaller: if r.returncode not in (0, 3010): self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace')) + self._discard_cached_installer(x86) return False self.logger.info("vcrun2012: running x64 installer") @@ -511,6 +474,7 @@ class NativeComponentInstaller: if r.returncode not in (0, 3010): self.logger.error("vcrun2012: x64 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace')) + self._discard_cached_installer(x64) return False if not (syswow64 / 'msvcr110.dll').is_file(): diff --git a/jackify/backend/handlers/progress_parser_clf3.py b/jackify/backend/handlers/progress_parser_clf3.py index be27411..f4abb5e 100644 --- a/jackify/backend/handlers/progress_parser_clf3.py +++ b/jackify/backend/handlers/progress_parser_clf3.py @@ -44,6 +44,10 @@ class CLF3ProgressStateManager: self._seen_actual_download: bool = False # name -> (downloaded, total, speed) self._active_downloads: dict = {} + # Running totals for files that have already dropped out of _active_downloads, + # so data_processed/data_total keep advancing instead of resetting per-file. + self._completed_download_bytes: int = 0 + self._completed_download_total: int = 0 def get_state(self) -> InstallationProgress: return self.state @@ -113,6 +117,12 @@ class CLF3ProgressStateManager: total_speed = sum(v[2] for v in self._active_downloads.values()) speed_mb = total_speed / 1_048_576 self.state.message = f"Downloading {len(active_files)} file(s) | {speed_mb:.1f} MB/s" + self.state.update_speed('download', total_speed) + + active_bytes = sum(v[0] for v in self._active_downloads.values()) + active_total = sum(v[1] for v in self._active_downloads.values()) + self.state.data_processed = self._completed_download_bytes + active_bytes + self.state.data_total = self._completed_download_total + active_total if self._total_archives > 0: if in_concurrent: @@ -128,7 +138,11 @@ class CLF3ProgressStateManager: def _on_download_complete(self, obj: dict) -> bool: name = obj.get('name', '') - self._active_downloads.pop(name, None) + _, dl_total, _ = self._active_downloads.pop(name, (0, 0, 0.0)) + # Count the file as fully downloaded regardless of the last-seen partial byte count, + # so a completed file never regresses the cumulative processed/total running totals. + self._completed_download_bytes += dl_total + self._completed_download_total += dl_total self.state.active_files = [f for f in self.state.active_files if f.filename != name] return True @@ -136,8 +150,15 @@ class CLF3ProgressStateManager: index = obj.get('index', 0) total = obj.get('total', 0) if total: - self._total_archives = total - actual_total = total or self._total_archives + # CLF3 can emit ArchiveComplete from more than one counter within the same + # phase (e.g. a hash-verify pass followed by the download pass), each with its + # own total. Never let the displayed total shrink mid-phase - that produces a + # visible back-and-forth in the progress bar (index climbing steadily while the + # denominator flips between two values). A larger total mid-phase is simply the + # other counter, not a correction, so stick with the largest seen until the next + # PhaseChange resets it. + self._total_archives = max(self._total_archives, total) + actual_total = self._total_archives or total self._completed_archives = index # CLF3 emits a single cumulative ArchiveComplete counter spanning both download @@ -204,6 +225,10 @@ class CLF3ProgressStateManager: self.state.active_files = [] self._active_downloads.clear() self._seen_actual_download = False + self._completed_download_bytes = 0 + self._completed_download_total = 0 + self._total_archives = 0 + self._completed_archives = 0 logger.debug("CLF3 phase: %s -> %s", phase_label, phase) return True diff --git a/jackify/backend/handlers/wine_utils_proton.py b/jackify/backend/handlers/wine_utils_proton.py index e6c65a0..4b680a1 100644 --- a/jackify/backend/handlers/wine_utils_proton.py +++ b/jackify/backend/handlers/wine_utils_proton.py @@ -16,11 +16,14 @@ logger = logging.getLogger(__name__) VALVE_PROTON_APPID_MAP = { '2805730': 'proton_9', '3658110': 'proton_10', + '4628710': 'proton_11', '1493710': 'proton_experimental', '2180100': 'proton_hotfix', '1887720': 'proton_8', } +VALVE_PROTON_DIR_PATTERN = re.compile(r'^Proton (\d+)\.\d+(?: \(Beta\))?$') + class WineUtilsProtonMixin: """Mixin providing Proton scanning, selection, and path resolution.""" @@ -288,6 +291,11 @@ class WineUtilsProtonMixin: return name if dir_name.startswith('GE-Proton'): return dir_name + version_match = VALVE_PROTON_DIR_PATTERN.match(dir_name) + if version_match: + name = f"proton_{version_match.group(1)}" + logger.debug(f"Derived Valve Proton name from directory: {dir_name} -> {name}") + return name logger.warning(f"Could not resolve Steam compat name for: {proton_path}") return None diff --git a/jackify/backend/services/download_watcher_service.py b/jackify/backend/services/download_watcher_service.py index ddaee2a..a37e755 100644 --- a/jackify/backend/services/download_watcher_service.py +++ b/jackify/backend/services/download_watcher_service.py @@ -13,6 +13,20 @@ from typing import Callable, Optional logger = logging.getLogger(__name__) + +def normalize_download_name(name: str) -> str: + """ + Lax comparison form for matching a browser-saved file against engine metadata. + + Nexus filenames can legitimately begin with dots or spaces, which browsers and + file managers silently drop when saving. Engine metadata also sometimes carries + a leading numeric prefix (e.g. "1_filename.zip") that the saved file lacks. + Applied to both sides of a comparison; hash validation remains the real gate. + """ + normalized = re.sub(r'^[.\s]+', '', (name or "").lower()) + return re.sub(r'^\d+_', '', normalized) + + @dataclass class WatcherConfig: watch_directory: Path @@ -98,18 +112,10 @@ class DownloadWatcherService: logger.debug(f"Candidate exact match: {path.name}") self._debounce_and_emit(path, item) return - # Leading-dot normalisation: browsers strip a leading dot from filenames. + candidate_normalized = normalize_download_name(candidate_name) for expected_name, item in self._pending_exact: - if expected_name.lstrip('.') == candidate_name: - logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}") - self._debounce_and_emit(path, item) - return - # Numeric-prefix normalisation: engine metadata may include a leading - # numeric prefix (e.g. "1_filename.zip") absent from the downloaded file. - for expected_name, item in self._pending_exact: - stripped = re.sub(r'^\d+_', '', expected_name) - if stripped != expected_name and stripped == candidate_name: - logger.debug(f"Candidate numeric-prefix match: {path.name} -> {expected_name}") + if normalize_download_name(expected_name) == candidate_normalized: + logger.debug(f"Candidate normalized match: {path.name} -> {expected_name}") self._debounce_and_emit(path, item) return diff --git a/jackify/backend/services/manual_download_manager_runtime_mixin.py b/jackify/backend/services/manual_download_manager_runtime_mixin.py index 8e27743..de9d9e2 100644 --- a/jackify/backend/services/manual_download_manager_runtime_mixin.py +++ b/jackify/backend/services/manual_download_manager_runtime_mixin.py @@ -5,12 +5,12 @@ from __future__ import annotations import json import logging import os -import re import subprocess import time from pathlib import Path from typing import Optional +from jackify.backend.services.download_watcher_service import normalize_download_name from jackify.backend.services.file_validator_service import ValidationResult logger = logging.getLogger(__name__) @@ -382,8 +382,10 @@ class ManualDownloadManagerRuntimeMixin: return 0 exact_map: dict[str, Path] = {} + normalized_map: dict[str, Path] = {} for p in existing_files: exact_map.setdefault(p.name.lower(), p) + normalized_map.setdefault(normalize_download_name(p.name), p) with self._lock: targets = [ @@ -408,18 +410,7 @@ class ManualDownloadManagerRuntimeMixin: name = hint['file_name'] exact = exact_map.get(name.lower()) if exact is None: - # Leading-dot normalization: browser may strip a leading dot that - # the engine uses in its canonical filename. - stripped = name.lower().lstrip('.') - if stripped != name.lower(): - exact = exact_map.get(stripped) - if exact is None: - # Numeric prefix normalization: engine may store filenames with a - # leading numeric prefix (e.g. "1_filename.zip") absent from the - # browser-saved file. - stripped_num = re.sub(r'^\d+_', '', name.lower()) - if stripped_num != name.lower(): - exact = exact_map.get(stripped_num) + exact = normalized_map.get(normalize_download_name(name)) if exact is None or exact in used_paths: continue used_paths.add(exact) diff --git a/jackify/backend/services/nexus_auth_service.py b/jackify/backend/services/nexus_auth_service.py index 4e2ac43..e4a8a43 100644 --- a/jackify/backend/services/nexus_auth_service.py +++ b/jackify/backend/services/nexus_auth_service.py @@ -7,6 +7,7 @@ Unified service for Nexus authentication using OAuth or API key fallback import logging import os +import threading from typing import Optional, Tuple from .nexus_oauth_service import NexusOAuthService from ..handlers.oauth_token_handler import OAuthTokenHandler @@ -21,6 +22,11 @@ class NexusAuthService: Handles OAuth 2.0 (preferred) with API key fallback (legacy) """ + # Class-level: NexusAuthService is instantiated fresh per call site, so the lock + # must live on the class to serialise refreshes across instances sharing one token file. + _refresh_lock = threading.Lock() + _REFRESH_LOCK_TIMEOUT = 30 + def __init__(self): """Initialize authentication service""" self.oauth_service = NexusOAuthService() @@ -63,30 +69,42 @@ class NexusAuthService: return None # Check if token is expired (15 minute buffer for long installs) - if self.token_handler.is_token_expired(buffer_minutes=15): - logger.info("OAuth token expiring soon, attempting refresh") + if not self.token_handler.is_token_expired(buffer_minutes=15): + return self.token_handler.get_access_token() + + logger.info("OAuth token expiring soon, attempting refresh") + + if not self._refresh_lock.acquire(timeout=self._REFRESH_LOCK_TIMEOUT): + logger.error("Timed out waiting for OAuth refresh lock") + return None + try: + # Another thread may have already refreshed while we waited for the lock + if not self.token_handler.is_token_expired(buffer_minutes=15): + return self.token_handler.get_access_token() - # Try to refresh refresh_token = self.token_handler.get_refresh_token() - if refresh_token: - new_token_data = self.oauth_service.refresh_token(refresh_token) - - if new_token_data: - # Save refreshed token - self.token_handler.save_token({'oauth': new_token_data}) - logger.info("OAuth token refreshed successfully") - return new_token_data.get('access_token') - else: - logger.warning("Token refresh failed, OAuth token invalid") - # Delete invalid token - self.token_handler.delete_token() - return None - else: + if not refresh_token: logger.warning("No refresh token available") return None - # Token is valid, return it - return self.token_handler.get_access_token() + new_token_data = self.oauth_service.refresh_token(refresh_token) + + if new_token_data: + self.token_handler.save_token({'oauth': new_token_data}) + logger.info("OAuth token refreshed successfully") + return new_token_data.get('access_token') + + logger.warning("Token refresh failed, OAuth token invalid") + # Only delete if the stored refresh token still matches what we attempted with - + # a concurrent caller may have already saved a newer token since we started. + current_refresh_token = self.token_handler.get_refresh_token() + if current_refresh_token == refresh_token: + self.token_handler.delete_token() + else: + logger.info("Refresh token changed during failed attempt, not deleting") + return None + finally: + self._refresh_lock.release() def is_authenticated(self) -> bool: """ diff --git a/jackify/backend/services/nexus_oauth_callback.py b/jackify/backend/services/nexus_oauth_callback.py index 99e1297..f2280da 100644 --- a/jackify/backend/services/nexus_oauth_callback.py +++ b/jackify/backend/services/nexus_oauth_callback.py @@ -1,110 +1,20 @@ """ -Nexus OAuth callback: _generate_self_signed_cert, _create_callback_handler, _wait_for_callback. +Nexus OAuth callback: _wait_for_callback. + +Callback delivery is the jackify:// protocol handler (registered by +NexusOAuthProtocolMixin), which writes code+state to oauth_callback.tmp for this +method to poll. There is no localhost HTTP server in this flow. """ -import os import time import logging -import tempfile -import urllib.parse from pathlib import Path -from http.server import BaseHTTPRequestHandler -from typing import Optional, Tuple logger = logging.getLogger(__name__) class NexusOAuthCallbackMixin: - """Mixin providing callback server and wait logic for NexusOAuthService.""" - - def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]: - """Generate self-signed certificate for HTTPS localhost. Returns (cert_file_path, key_file_path) or (None, None).""" - redirect_host = getattr(self, 'REDIRECT_HOST', '127.0.0.1') - try: - from cryptography import x509 - from cryptography.x509.oid import NameOID - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.hazmat.primitives import serialization - import datetime - import ipaddress - logger.info("Generating self-signed certificate for OAuth callback") - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"), - x509.NameAttribute(NameOID.COMMON_NAME, redirect_host), - ]) - cert = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer).public_key( - private_key.public_key() - ).serial_number(x509.random_serial_number()).not_valid_before( - datetime.datetime.now(datetime.UTC) - ).not_valid_after( - datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365) - ).add_extension( - x509.SubjectAlternativeName([x509.IPAddress(ipaddress.IPv4Address(redirect_host))]), - critical=False, - ).sign(private_key, hashes.SHA256()) - temp_dir = tempfile.mkdtemp() - cert_file = os.path.join(temp_dir, "oauth_cert.pem") - key_file = os.path.join(temp_dir, "oauth_key.pem") - with open(cert_file, "wb") as f: - f.write(cert.public_bytes(serialization.Encoding.PEM)) - with open(key_file, "wb") as f: - f.write(private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - )) - return cert_file, key_file - except ImportError: - logger.error("cryptography package not installed - required for OAuth") - return None, None - except Exception as e: - logger.error("Failed to generate SSL certificate: %s", e) - return None, None - - def _create_callback_handler(self): - """Create HTTP request handler class for OAuth callback.""" - service = self - class OAuthCallbackHandler(BaseHTTPRequestHandler): - def log_message(self, format, *args): - logger.debug("OAuth callback: %s", format % args) - def do_GET(self): - logger.info("OAuth callback received: %s", self.path) - parsed = urllib.parse.urlparse(self.path) - params = urllib.parse.parse_qs(parsed.query) - if parsed.path == '/favicon.ico': - self.send_response(404) - self.end_headers() - return - if 'code' in params: - service._auth_code = params['code'][0] - service._auth_state = params.get('state', [None])[0] - logger.info("OAuth authorization code received: %s...", service._auth_code[:10]) - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - html = """
You can close this window and return to Jackify.
""" - self.wfile.write(html.encode()) - elif 'error' in params: - service._auth_error = params['error'][0] - error_desc = params.get('error_description', ['Unknown error'])[0] - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - html = f"Error: {service._auth_error}
{error_desc}
You can close this window and try again in Jackify.
" - self.wfile.write(html.encode()) - else: - logger.warning("OAuth callback with no code or error: %s", params) - self.send_response(400) - self.send_header('Content-type', 'text/html') - self.end_headers() - html = "You can close this window.
" - self.wfile.write(html.encode()) - service._server_done.set() - logger.debug("OAuth callback handler signaled server to shut down") - return OAuthCallbackHandler + """Mixin providing callback wait logic for NexusOAuthService.""" def _wait_for_callback(self) -> bool: """Wait for OAuth callback via jackify:// protocol handler. Returns True if callback received.""" diff --git a/jackify/backend/services/nexus_oauth_service.py b/jackify/backend/services/nexus_oauth_service.py index cd6610b..6edac76 100644 --- a/jackify/backend/services/nexus_oauth_service.py +++ b/jackify/backend/services/nexus_oauth_service.py @@ -13,7 +13,6 @@ import webbrowser import urllib.parse import requests import json -import threading import logging import time import subprocess @@ -50,7 +49,6 @@ class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin): self._auth_code = None self._auth_state = None self._auth_error = None - self._server_done = threading.Event() # Ensure jackify:// protocol is registered on first use self._ensure_protocol_registered() @@ -221,7 +219,6 @@ class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin): self._auth_code = None self._auth_state = None self._auth_error = None - self._server_done.clear() # Generate PKCE parameters code_verifier, code_challenge, state = self._generate_pkce_params() @@ -357,4 +354,4 @@ class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin): return token_data finally: - self._expected_oauth_state = None + self._auth_state = None diff --git a/jackify/backend/services/tool_config_service.py b/jackify/backend/services/tool_config_service.py index 22dc647..350d2a6 100644 --- a/jackify/backend/services/tool_config_service.py +++ b/jackify/backend/services/tool_config_service.py @@ -154,8 +154,12 @@ def _build_reg_content(apply_engine_mscoree: bool = True, install_dotnet_sdk: bo # fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation. # The winetricks-provided d3dcompiler_47 lacks support for certain shader models # used by Community Shaders, causing "failed shaders" during compilation. -_FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll" -_FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll" +# URLs shared with native_component_installer.py, which is the other place this same +# DLL pair is installed - keep both in sync rather than duplicating the URLs here. +from jackify.backend.handlers.native_component_installer import ( + _D3DCOMPILER_47_X86_URL as _FXC2_D3DCOMPILER_X86_URL, + _D3DCOMPILER_47_X64_URL as _FXC2_D3DCOMPILER_X64_URL, +) def _install_fxc2_d3dcompiler( @@ -165,27 +169,30 @@ def _install_fxc2_d3dcompiler( """ Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2 build, which supports shader models required by Community Shaders. - Applies to both system32 (64-bit) and syswow64 (32-bit) locations. + Applies to both system32 (64-bit) and syswow64 (32-bit) locations, each + with the matching architecture's DLL - syswow64 is 32-bit, system32 is 64-bit. """ try: from jackify.shared.paths import get_jackify_data_dir + import shutil cache_dir = get_jackify_data_dir() / "cache" cache_dir.mkdir(parents=True, exist_ok=True) - cached_dll = cache_dir / _FXC2_D3DCOMPILER_FILENAME - if not cached_dll.exists(): - log("Downloading fxc2 d3dcompiler_47.dll...") - urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll) - log("fxc2 d3dcompiler_47.dll downloaded") - else: - log("fxc2 d3dcompiler_47.dll already cached, skipping download") - - import shutil targets = [ - prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll", - prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll", + (_FXC2_D3DCOMPILER_X86_URL, "fxc2_d3dcompiler_47_x86.dll", + prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll"), + (_FXC2_D3DCOMPILER_X64_URL, "fxc2_d3dcompiler_47_x64.dll", + prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll"), ] - for target in targets: + for url, cache_name, target in targets: + cached_dll = cache_dir / cache_name + if not cached_dll.exists(): + log(f"Downloading fxc2 d3dcompiler_47.dll ({cache_name})...") + urllib.request.urlretrieve(url, cached_dll) + log("fxc2 d3dcompiler_47.dll downloaded") + else: + log("fxc2 d3dcompiler_47.dll already cached, skipping download") + if target.parent.exists(): shutil.copy2(cached_dll, target) log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}") diff --git a/jackify/tools/verify_install.py b/jackify/tools/verify_install.py index 035051d..7794aeb 100755 --- a/jackify/tools/verify_install.py +++ b/jackify/tools/verify_install.py @@ -434,6 +434,24 @@ def detect_game_type(modlist_dir: Path) -> str: return "unknown" +def _pe_machine_type(path: Path) -> Optional[str]: + """Return 'x86'/'x64' from a PE file's COFF header machine field, or None if unreadable.""" + try: + with open(path, "rb") as f: + data = f.read(0x40) + if len(data) < 0x40 or data[0:2] != b"MZ": + return None + pe_offset = int.from_bytes(data[0x3C:0x40], "little") + f.seek(pe_offset) + pe_header = f.read(6) + if pe_header[0:4] != b"PE\x00\x00": + return None + machine = int.from_bytes(pe_header[4:6], "little") + return {0x014C: "x86", 0x8664: "x64"}.get(machine) + except OSError: + return None + + # --------------------------------------------------------------------------- # Checks # --------------------------------------------------------------------------- @@ -1114,6 +1132,20 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results): else: r.warn("Global DLL overrides not found") + if game_type in ("skyrim", "enderal"): + syswow64_dll = pfx / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll" + system32_dll = pfx / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll" + if syswow64_dll.exists() and system32_dll.exists(): + syswow64_arch = _pe_machine_type(syswow64_dll) + system32_arch = _pe_machine_type(system32_dll) + if syswow64_arch == "x86" and system32_arch == "x64": + r.ok("d3dcompiler_47.dll architecture correct (syswow64=x86, system32=x64)") + else: + r.warn( + f"d3dcompiler_47.dll architecture mismatch " + f"(syswow64={syswow64_arch or 'unknown'}, system32={system32_arch or 'unknown'})" + ) + # Synthesis/dotnet checks apply to both Skyrim and Enderal SE (same engine and # plugin format). The mscoree AppDefaults entry itself is Skyrim-only: it is # scoped to SkyrimSE.exe, which is also Enderal's own game process, so