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
@@ -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)
+37 -19
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):
logger.info("OAuth token expiring soon, attempting refresh")
if not self.token_handler.is_token_expired(buffer_minutes=15):
return self.token_handler.get_access_token()
logger.info("OAuth token expiring soon, attempting refresh")
if not self._refresh_lock.acquire(timeout=self._REFRESH_LOCK_TIMEOUT):
logger.error("Timed out waiting for OAuth refresh lock")
return None
try:
# Another thread may have already refreshed while we waited for the lock
if not self.token_handler.is_token_expired(buffer_minutes=15):
return self.token_handler.get_access_token()
# Try to refresh
refresh_token = self.token_handler.get_refresh_token()
if refresh_token:
new_token_data = self.oauth_service.refresh_token(refresh_token)
if new_token_data:
# Save refreshed token
self.token_handler.save_token({'oauth': new_token_data})
logger.info("OAuth token refreshed successfully")
return new_token_data.get('access_token')
else:
logger.warning("Token refresh failed, OAuth token invalid")
# Delete invalid token
self.token_handler.delete_token()
return None
else:
if not refresh_token:
logger.warning("No refresh token available")
return None
# Token is valid, return it
return self.token_handler.get_access_token()
new_token_data = self.oauth_service.refresh_token(refresh_token)
if new_token_data:
self.token_handler.save_token({'oauth': new_token_data})
logger.info("OAuth token refreshed successfully")
return new_token_data.get('access_token')
logger.warning("Token refresh failed, OAuth token invalid")
# Only delete if the stored refresh token still matches what we attempted with -
# a concurrent caller may have already saved a newer token since we started.
current_refresh_token = self.token_handler.get_refresh_token()
if current_refresh_token == refresh_token:
self.token_handler.delete_token()
else:
logger.info("Refresh token changed during failed attempt, not deleting")
return None
finally:
self._refresh_lock.release()
def is_authenticated(self) -> bool:
"""
@@ -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
+22 -15
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
if not cached_dll.exists():
log("Downloading fxc2 d3dcompiler_47.dll...")
urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll)
log("fxc2 d3dcompiler_47.dll downloaded")
else:
log("fxc2 d3dcompiler_47.dll already cached, skipping download")
import shutil
targets = [
prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll",
prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll",
(_FXC2_D3DCOMPILER_X86_URL, "fxc2_d3dcompiler_47_x86.dll",
prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll"),
(_FXC2_D3DCOMPILER_X64_URL, "fxc2_d3dcompiler_47_x64.dll",
prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll"),
]
for target in targets:
for url, cache_name, target in targets:
cached_dll = cache_dir / cache_name
if not cached_dll.exists():
log(f"Downloading fxc2 d3dcompiler_47.dll ({cache_name})...")
urllib.request.urlretrieve(url, cached_dll)
log("fxc2 d3dcompiler_47.dll downloaded")
else:
log("fxc2 d3dcompiler_47.dll already cached, skipping download")
if target.parent.exists():
shutil.copy2(cached_dll, target)
log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}")