Sync from development - prepare for v0.5.0.3

This commit is contained in:
Omni
2026-03-23 13:46:27 +00:00
parent e52e1427f6
commit 8e4dd06f11
241 changed files with 713 additions and 441 deletions

View File

@@ -4,6 +4,7 @@ list of pending manual download items by lax filename comparison.
"""
import os
import re
import time
import logging
from dataclasses import dataclass, field
@@ -106,6 +107,14 @@ class DownloadWatcherService:
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}")
self._debounce_and_emit(path, item)
return
# Some modlist metadata stores filenames with a leading numeric prefix
# (e.g. "1_filename.zip") that is absent from the browser-saved 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)
return
def _debounce_and_emit(self, path: Path, item: dict) -> None:
def _wait_and_emit():

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import re
import subprocess
from pathlib import Path
from typing import Optional
@@ -380,6 +381,13 @@ class ManualDownloadManagerRuntimeMixin:
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:
continue
used_paths.add(exact)

View File

@@ -145,6 +145,8 @@ class ModlistServiceInstallationMixin:
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
if context.get('skip_disk_check'):
cmd.append('--skip-disk-check')
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
@@ -199,9 +201,10 @@ class ModlistServiceInstallationMixin:
except (OSError, BrokenPipeError):
return False
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
import json as _json
_cc_filename = None
_ck_missing = False
_pending_manual: list = []
buffer = b''
while True:
@@ -263,6 +266,8 @@ class ModlistServiceInstallationMixin:
output_callback(decoded)
if _cc_filename is None and is_cc_content_error(decoded):
_cc_filename = extract_cc_filename(decoded) or ""
if not _ck_missing and is_creation_kit_missing_error(decoded):
_ck_missing = True
if buffer:
line = buffer.decode('utf-8', errors='replace')
@@ -271,6 +276,8 @@ class ModlistServiceInstallationMixin:
output_callback(decoded)
if _cc_filename is None and is_cc_content_error(decoded):
_cc_filename = extract_cc_filename(decoded) or ""
if not _ck_missing and is_creation_kit_missing_error(decoded):
_ck_missing = True
proc.wait()
if proc.returncode != 0:
@@ -285,6 +292,16 @@ class ModlistServiceInstallationMixin:
output_callback(" - If specific files are still missing, search for and download them from the Creations menu.")
output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.")
output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.")
if _ck_missing and output_callback:
output_callback("")
output_callback("[WARN] Creation Kit Files Missing")
output_callback(" This modlist requires the Skyrim Special Edition Creation Kit.")
output_callback(" - In Steam, search for 'Skyrim Special Edition: Creation Kit' and install it.")
output_callback(" - Right-click it in Steam > Properties > Compatibility and set a Proton version.")
output_callback(" - Click Play to launch the Creation Kit.")
output_callback(" - When asked whether to unzip Scripts.zip, select NO.")
output_callback(" - Once the Creation Kit opens successfully, close it.")
output_callback(" - Re-run the modlist install in Jackify.")
return False
if output_callback:
output_callback("Installation completed successfully")

View File

@@ -369,15 +369,70 @@ class UpdateService:
if progress_callback:
progress_callback(downloaded_size, total_size)
# Nexus delivers a 7z archive — extract the AppImage before handing off
if self._is_7z_archive(temp_file):
logger.info("Downloaded file is a 7z archive, extracting AppImage")
extracted = self._extract_appimage_from_7z(temp_file, update_dir, update_info.version)
temp_file.unlink(missing_ok=True)
if not extracted:
logger.error("Failed to extract AppImage from 7z archive")
return None
temp_file = extracted
# Make executable
temp_file.chmod(0o755)
logger.info("Update downloaded successfully: %s from %s -> %s", update_info.version, update_info.source, temp_file)
return temp_file
except Exception as e:
logger.error(f"Failed to download update manually: {e}")
return None
def _is_7z_archive(self, path: Path) -> bool:
"""Detect 7z archive by magic bytes (37 7A BC AF 27 1C)."""
try:
with open(path, 'rb') as f:
magic = f.read(6)
return magic == b'7z\xbc\xaf\x27\x1c'
except Exception:
return False
def _get_bundled_7z_path(self) -> Optional[Path]:
"""Return path to bundled 7z binary (AppImage or dev)."""
import os
candidates = []
appdir = os.environ.get('APPDIR')
if appdir:
candidates.append(Path(appdir) / 'opt' / 'jackify' / 'tools' / '7z')
candidates.append(Path(__file__).parent.parent.parent / 'tools' / '7z')
for p in candidates:
if p.exists() and os.access(p, os.X_OK):
return p
return None
def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]:
"""Extract Jackify.AppImage from a 7z archive into dest_dir."""
seven_z = self._get_bundled_7z_path()
if not seven_z:
logger.error("Bundled 7z not found, cannot extract update archive")
return None
out_path = dest_dir / f"Jackify-{version}.AppImage"
try:
result = subprocess.run(
[str(seven_z), 'e', str(archive), 'Jackify.AppImage', f'-o{dest_dir}', '-y'],
capture_output=True, text=True, timeout=120
)
extracted = dest_dir / 'Jackify.AppImage'
if result.returncode != 0 or not extracted.exists():
logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip())
return None
extracted.rename(out_path)
logger.info("Extracted AppImage from archive: %s", out_path)
return out_path
except Exception as e:
logger.error("Exception during 7z extraction: %s", e)
return None
def apply_update(self, new_appimage_path: Path) -> bool:
"""