mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-13 23:33:43 +02:00
Release v0.7.2.2 - DirectX Download and Install Fixes
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
logger.info("OAuth token expiring soon, attempting refresh")
|
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()
|
refresh_token = self.token_handler.get_refresh_token()
|
||||||
if refresh_token:
|
if not 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:
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
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 = [
|
targets = [
|
||||||
prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll",
|
(_FXC2_D3DCOMPILER_X86_URL, "fxc2_d3dcompiler_47_x86.dll",
|
||||||
prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.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():
|
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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user