Release v0.7.2.2 - DirectX Download and Install Fixes

This commit is contained in:
Omni
2026-08-10 16:43:38 +01:00
parent 22bf5e30e5
commit 7e2121c334
13 changed files with 302 additions and 218 deletions
+11
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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)
@@ -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():
@@ -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
@@ -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
@@ -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
@@ -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)
+36 -18
View File
@@ -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):
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")
# 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()
if not self._refresh_lock.acquire(timeout=self._REFRESH_LOCK_TIMEOUT):
logger.error("Timed out waiting for OAuth refresh lock")
return None
else:
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()
refresh_token = self.token_handler.get_refresh_token()
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:
"""
@@ -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 = """<html><head><title>Authorization Successful</title></head><body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"><h1>Authorization Successful!</h1><p>You can close this window and return to Jackify.</p><script>setTimeout(function() { window.close(); }, 3000);</script></body></html>"""
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"<html><head><title>Authorization Failed</title></head><body style='font-family: Arial, sans-serif; text-align: center; padding: 50px;'><h1>Authorization Failed</h1><p>Error: {service._auth_error}</p><p>{error_desc}</p><p>You can close this window and try again in Jackify.</p></body></html>"
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 = "<html><head><title>Invalid Request</title></head><body style='font-family: Arial, sans-serif; text-align: center; padding: 50px;'><h1>Invalid OAuth Callback</h1><p>You can close this window.</p></body></html>"
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."""
@@ -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
+19 -12
View File
@@ -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
targets = [
(_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 url, cache_name, target in targets:
cached_dll = cache_dir / cache_name
if not cached_dll.exists():
log("Downloading fxc2 d3dcompiler_47.dll...")
urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll)
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")
import shutil
targets = [
prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll",
prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll",
]
for target in targets:
if target.parent.exists():
shutil.copy2(cached_dll, target)
log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}")
+32
View File
@@ -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