mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 03:13:44 +02:00
Release v0.7.2.2 - DirectX Download and Install Fixes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user