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 # 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 ## v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix
**Release Date:** 2026-07-25 **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. 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 datetime
import hashlib
import json import json
import logging import logging
import os import os
@@ -13,11 +12,11 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
import time import time
import urllib.request
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple 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 from jackify.shared.paths import get_jackify_data_dir
logger = logging.getLogger(__name__) 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_X86_SHA256 = "2ad0d4987fc4624566b190e747c9d95038443956ed816abfd1e2d389b5ec0851"
_D3DCOMPILER_47_X64_SHA256 = "4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3" _D3DCOMPILER_47_X64_SHA256 = "4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3"
_DIRECTX_CAB_URL = "https://files.holarse-linuxgaming.de/mirrors/microsoft/directx_Jun2010_redist.exe" # The holarse mirror this used to point at now returns 403 for the whole
_DIRECTX_CAB_SHA256 = "8746ee1a84a083a90e37899d71d50d5c7c015e69688a466aa80447f011780c0d" # /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_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" _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.""" """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): 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._direct_reg_write(r'Software\Wine\DllOverrides', overrides)
self.logger.debug("DLL overrides written directly to user.reg (%d entries)", len(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]: def _get_cabextract(self) -> Optional[str]:
if os.environ.get('APPDIR'): if os.environ.get('APPDIR'):
candidate = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract') 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 = get_jackify_data_dir() / 'component_cache' / 'directx'
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
redist = cache_dir / 'directx_Jun2010_redist.exe' 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 return False
if not self._verify_sha256(redist, _DIRECTX_CAB_SHA256): if not self._verify_sha256(redist, _DIRECTX_CAB_SHA256):
self.logger.error("SHA256 mismatch on DirectX redistributable") self.logger.error("SHA256 mismatch on DirectX redistributable")
@@ -430,6 +390,7 @@ class NativeComponentInstaller:
if r.returncode not in (0, 3010): if r.returncode not in (0, 3010):
self.logger.error("vcrun2022: x86 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2022: x86 installer failed (rc=%d)", r.returncode)
self.logger.error("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace')) self.logger.error("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace'))
self._discard_cached_installer(x86)
return False return False
# x64: same msvcp140.dll pre-extraction workaround # x64: same msvcp140.dll pre-extraction workaround
@@ -458,6 +419,7 @@ class NativeComponentInstaller:
if r.returncode not in (0, 3010): if r.returncode not in (0, 3010):
self.logger.error("vcrun2022: x64 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2022: x64 installer failed (rc=%d)", r.returncode)
self.logger.error("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace')) self.logger.error("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace'))
self._discard_cached_installer(x64)
return False return False
critical = [ critical = [
@@ -499,6 +461,7 @@ class NativeComponentInstaller:
if r.returncode not in (0, 3010): if r.returncode not in (0, 3010):
self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode)
self.logger.error("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace')) self.logger.error("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace'))
self._discard_cached_installer(x86)
return False return False
self.logger.info("vcrun2012: running x64 installer") self.logger.info("vcrun2012: running x64 installer")
@@ -511,6 +474,7 @@ class NativeComponentInstaller:
if r.returncode not in (0, 3010): if r.returncode not in (0, 3010):
self.logger.error("vcrun2012: x64 installer failed (rc=%d)", r.returncode) self.logger.error("vcrun2012: x64 installer failed (rc=%d)", r.returncode)
self.logger.error("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace')) self.logger.error("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace'))
self._discard_cached_installer(x64)
return False return False
if not (syswow64 / 'msvcr110.dll').is_file(): if not (syswow64 / 'msvcr110.dll').is_file():
@@ -44,6 +44,10 @@ class CLF3ProgressStateManager:
self._seen_actual_download: bool = False self._seen_actual_download: bool = False
# name -> (downloaded, total, speed) # name -> (downloaded, total, speed)
self._active_downloads: dict = {} 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: def get_state(self) -> InstallationProgress:
return self.state return self.state
@@ -113,6 +117,12 @@ class CLF3ProgressStateManager:
total_speed = sum(v[2] for v in self._active_downloads.values()) total_speed = sum(v[2] for v in self._active_downloads.values())
speed_mb = total_speed / 1_048_576 speed_mb = total_speed / 1_048_576
self.state.message = f"Downloading {len(active_files)} file(s) | {speed_mb:.1f} MB/s" 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 self._total_archives > 0:
if in_concurrent: if in_concurrent:
@@ -128,7 +138,11 @@ class CLF3ProgressStateManager:
def _on_download_complete(self, obj: dict) -> bool: def _on_download_complete(self, obj: dict) -> bool:
name = obj.get('name', '') 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] self.state.active_files = [f for f in self.state.active_files if f.filename != name]
return True return True
@@ -136,8 +150,15 @@ class CLF3ProgressStateManager:
index = obj.get('index', 0) index = obj.get('index', 0)
total = obj.get('total', 0) total = obj.get('total', 0)
if total: if total:
self._total_archives = total # CLF3 can emit ArchiveComplete from more than one counter within the same
actual_total = total or self._total_archives # 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 self._completed_archives = index
# CLF3 emits a single cumulative ArchiveComplete counter spanning both download # CLF3 emits a single cumulative ArchiveComplete counter spanning both download
@@ -204,6 +225,10 @@ class CLF3ProgressStateManager:
self.state.active_files = [] self.state.active_files = []
self._active_downloads.clear() self._active_downloads.clear()
self._seen_actual_download = False 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) logger.debug("CLF3 phase: %s -> %s", phase_label, phase)
return True return True
@@ -16,11 +16,14 @@ logger = logging.getLogger(__name__)
VALVE_PROTON_APPID_MAP = { VALVE_PROTON_APPID_MAP = {
'2805730': 'proton_9', '2805730': 'proton_9',
'3658110': 'proton_10', '3658110': 'proton_10',
'4628710': 'proton_11',
'1493710': 'proton_experimental', '1493710': 'proton_experimental',
'2180100': 'proton_hotfix', '2180100': 'proton_hotfix',
'1887720': 'proton_8', '1887720': 'proton_8',
} }
VALVE_PROTON_DIR_PATTERN = re.compile(r'^Proton (\d+)\.\d+(?: \(Beta\))?$')
class WineUtilsProtonMixin: class WineUtilsProtonMixin:
"""Mixin providing Proton scanning, selection, and path resolution.""" """Mixin providing Proton scanning, selection, and path resolution."""
@@ -288,6 +291,11 @@ class WineUtilsProtonMixin:
return name return name
if dir_name.startswith('GE-Proton'): if dir_name.startswith('GE-Proton'):
return dir_name 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}") logger.warning(f"Could not resolve Steam compat name for: {proton_path}")
return None return None
@@ -13,6 +13,20 @@ from typing import Callable, Optional
logger = logging.getLogger(__name__) 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 @dataclass
class WatcherConfig: class WatcherConfig:
watch_directory: Path watch_directory: Path
@@ -98,18 +112,10 @@ class DownloadWatcherService:
logger.debug(f"Candidate exact match: {path.name}") logger.debug(f"Candidate exact match: {path.name}")
self._debounce_and_emit(path, item) self._debounce_and_emit(path, item)
return 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: for expected_name, item in self._pending_exact:
if expected_name.lstrip('.') == candidate_name: if normalize_download_name(expected_name) == candidate_normalized:
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}") logger.debug(f"Candidate 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}")
self._debounce_and_emit(path, item) self._debounce_and_emit(path, item)
return return
@@ -5,12 +5,12 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
import re
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from jackify.backend.services.download_watcher_service import normalize_download_name
from jackify.backend.services.file_validator_service import ValidationResult from jackify.backend.services.file_validator_service import ValidationResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -382,8 +382,10 @@ class ManualDownloadManagerRuntimeMixin:
return 0 return 0
exact_map: dict[str, Path] = {} exact_map: dict[str, Path] = {}
normalized_map: dict[str, Path] = {}
for p in existing_files: for p in existing_files:
exact_map.setdefault(p.name.lower(), p) exact_map.setdefault(p.name.lower(), p)
normalized_map.setdefault(normalize_download_name(p.name), p)
with self._lock: with self._lock:
targets = [ targets = [
@@ -408,18 +410,7 @@ class ManualDownloadManagerRuntimeMixin:
name = hint['file_name'] name = hint['file_name']
exact = exact_map.get(name.lower()) exact = exact_map.get(name.lower())
if exact is None: if exact is None:
# Leading-dot normalization: browser may strip a leading dot that exact = normalized_map.get(normalize_download_name(name))
# 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)
if exact is None or exact in used_paths: if exact is None or exact in used_paths:
continue continue
used_paths.add(exact) 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 logging
import os import os
import threading
from typing import Optional, Tuple from typing import Optional, Tuple
from .nexus_oauth_service import NexusOAuthService from .nexus_oauth_service import NexusOAuthService
from ..handlers.oauth_token_handler import OAuthTokenHandler from ..handlers.oauth_token_handler import OAuthTokenHandler
@@ -21,6 +22,11 @@ class NexusAuthService:
Handles OAuth 2.0 (preferred) with API key fallback (legacy) 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): def __init__(self):
"""Initialize authentication service""" """Initialize authentication service"""
self.oauth_service = NexusOAuthService() self.oauth_service = NexusOAuthService()
@@ -63,30 +69,42 @@ class NexusAuthService:
return None return None
# Check if token is expired (15 minute buffer for long installs) # 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") logger.info("OAuth token expiring soon, attempting refresh")
# Try to refresh if not self._refresh_lock.acquire(timeout=self._REFRESH_LOCK_TIMEOUT):
refresh_token = self.token_handler.get_refresh_token() logger.error("Timed out waiting for OAuth refresh lock")
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 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") logger.warning("No refresh token available")
return None return None
# Token is valid, return it new_token_data = self.oauth_service.refresh_token(refresh_token)
return self.token_handler.get_access_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: 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 time
import logging import logging
import tempfile
import urllib.parse
from pathlib import Path from pathlib import Path
from http.server import BaseHTTPRequestHandler
from typing import Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NexusOAuthCallbackMixin: class NexusOAuthCallbackMixin:
"""Mixin providing callback server and wait logic for NexusOAuthService.""" """Mixin providing callback 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
def _wait_for_callback(self) -> bool: def _wait_for_callback(self) -> bool:
"""Wait for OAuth callback via jackify:// protocol handler. Returns True if callback received.""" """Wait for OAuth callback via jackify:// protocol handler. Returns True if callback received."""
@@ -13,7 +13,6 @@ import webbrowser
import urllib.parse import urllib.parse
import requests import requests
import json import json
import threading
import logging import logging
import time import time
import subprocess import subprocess
@@ -50,7 +49,6 @@ class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin):
self._auth_code = None self._auth_code = None
self._auth_state = None self._auth_state = None
self._auth_error = None self._auth_error = None
self._server_done = threading.Event()
# Ensure jackify:// protocol is registered on first use # Ensure jackify:// protocol is registered on first use
self._ensure_protocol_registered() self._ensure_protocol_registered()
@@ -221,7 +219,6 @@ class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin):
self._auth_code = None self._auth_code = None
self._auth_state = None self._auth_state = None
self._auth_error = None self._auth_error = None
self._server_done.clear()
# Generate PKCE parameters # Generate PKCE parameters
code_verifier, code_challenge, state = self._generate_pkce_params() code_verifier, code_challenge, state = self._generate_pkce_params()
@@ -357,4 +354,4 @@ class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin):
return token_data return token_data
finally: 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. # fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation.
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models # The winetricks-provided d3dcompiler_47 lacks support for certain shader models
# used by Community Shaders, causing "failed shaders" during compilation. # used by Community Shaders, causing "failed shaders" during compilation.
_FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll" # URLs shared with native_component_installer.py, which is the other place this same
_FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll" # 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( def _install_fxc2_d3dcompiler(
@@ -165,27 +169,30 @@ def _install_fxc2_d3dcompiler(
""" """
Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2 Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2
build, which supports shader models required by Community Shaders. 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: try:
from jackify.shared.paths import get_jackify_data_dir from jackify.shared.paths import get_jackify_data_dir
import shutil
cache_dir = get_jackify_data_dir() / "cache" cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True) 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(): if not cached_dll.exists():
log("Downloading fxc2 d3dcompiler_47.dll...") log(f"Downloading fxc2 d3dcompiler_47.dll ({cache_name})...")
urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll) urllib.request.urlretrieve(url, cached_dll)
log("fxc2 d3dcompiler_47.dll downloaded") log("fxc2 d3dcompiler_47.dll downloaded")
else: else:
log("fxc2 d3dcompiler_47.dll already cached, skipping download") 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(): if target.parent.exists():
shutil.copy2(cached_dll, target) shutil.copy2(cached_dll, target)
log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}") 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" 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 # Checks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1114,6 +1132,20 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
else: else:
r.warn("Global DLL overrides not found") 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 # 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 # 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 # scoped to SkyrimSE.exe, which is also Enderal's own game process, so