mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 03:27:45 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
@@ -12,9 +12,9 @@ import time
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QProgressBar, QHBoxLayout, QSizePolicy
|
||||
QHBoxLayout, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QTimer
|
||||
from PySide6.QtCore import Qt, QSize, QTimer, QThread, Signal
|
||||
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
|
||||
@@ -24,11 +24,95 @@ from .file_progress_item import FileProgressItem
|
||||
__all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList']
|
||||
|
||||
|
||||
class _CpuWorker(QThread):
|
||||
"""Background worker for CPU usage sampling — keeps psutil off the main thread."""
|
||||
result = Signal(str)
|
||||
caches_updated = Signal(object, object, float) # process_cache, child_cache, smoothed_pct
|
||||
|
||||
def __init__(self, last_pct, process_cache, child_cache):
|
||||
super().__init__()
|
||||
self._last_pct = last_pct
|
||||
self._process_cache = process_cache
|
||||
self._child_cache = dict(child_cache) if child_cache else {}
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
import psutil, os
|
||||
|
||||
if self._process_cache is None:
|
||||
self._process_cache = psutil.Process(os.getpid())
|
||||
# Establish baseline (blocking, but only once and in background)
|
||||
self._process_cache.cpu_percent(interval=0.1)
|
||||
|
||||
num_cpus = psutil.cpu_count() or 1
|
||||
total_cpu = self._process_cache.cpu_percent(interval=None) / num_cpus
|
||||
|
||||
current_child_pids = set()
|
||||
try:
|
||||
for child in self._process_cache.children(recursive=True):
|
||||
try:
|
||||
current_child_pids.add(child.pid)
|
||||
if child.pid not in self._child_cache:
|
||||
# Baseline in background — no longer blocks main thread
|
||||
child.cpu_percent(interval=0.1)
|
||||
self._child_cache[child.pid] = child
|
||||
continue
|
||||
total_cpu += self._child_cache[child.pid].cpu_percent(interval=None) / num_cpus
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
for pid in set(self._child_cache.keys()) - current_child_pids:
|
||||
del self._child_cache[pid]
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
jackify_names = [
|
||||
'jackify-engine', 'texconv', 'texdiag', 'directxtex',
|
||||
'texconv_jackify', 'texdiag_jackify', 'directxtex_jackify',
|
||||
'7z', '7zz', 'bsarch', 'wine', 'wine64', 'wine64-preloader',
|
||||
'steam-run', 'proton',
|
||||
]
|
||||
tracked_pids = {self._process_cache.pid} | current_child_pids
|
||||
try:
|
||||
for proc in psutil.process_iter(['name', 'pid', 'cmdline']):
|
||||
try:
|
||||
if proc.pid in tracked_pids:
|
||||
continue
|
||||
proc_name = proc.info.get('name', '').lower()
|
||||
cmdline_str = ' '.join(proc.info.get('cmdline', []) or []).lower()
|
||||
is_jackify = any(n in proc_name for n in jackify_names)
|
||||
if not is_jackify and cmdline_str:
|
||||
is_jackify = any(n in cmdline_str for n in jackify_names)
|
||||
if not is_jackify:
|
||||
is_jackify = any(f'{n}.exe' in cmdline_str for n in jackify_names)
|
||||
if not is_jackify:
|
||||
is_jackify = 'jackify' in cmdline_str and any(
|
||||
t in cmdline_str for t in ['engine', 'tools', 'binaries']
|
||||
)
|
||||
if is_jackify:
|
||||
if proc.pid not in self._child_cache:
|
||||
proc.cpu_percent(interval=0.1)
|
||||
self._child_cache[proc.pid] = proc
|
||||
continue
|
||||
total_cpu += self._child_cache[proc.pid].cpu_percent(interval=None) / num_cpus
|
||||
tracked_pids.add(proc.pid)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._last_pct > 0:
|
||||
total_cpu = self._last_pct * 0.3 + total_cpu * 0.7
|
||||
display = min(100.0, total_cpu)
|
||||
self.result.emit(f"CPU: {display:.0f}%")
|
||||
self.caches_updated.emit(self._process_cache, self._child_cache, total_cpu)
|
||||
|
||||
except Exception:
|
||||
self.result.emit("")
|
||||
|
||||
|
||||
def _debug_log(message):
|
||||
"""Log message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
if ConfigHandler().get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
@@ -37,49 +121,36 @@ class FileProgressList(QWidget):
|
||||
Widget displaying a list of files currently being processed.
|
||||
Shows individual progress for each file.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
Initialize file progress list.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._file_items: dict[str, FileProgressItem] = {}
|
||||
self._summary_widget: Optional[SummaryProgressWidget] = None
|
||||
self._last_phase: Optional[str] = None # Track phase changes for transition messages
|
||||
self._transition_label: Optional[QLabel] = None # Label for "Preparing..." message
|
||||
self._last_summary_time: float = 0.0 # Track when summary widget was last shown
|
||||
self._summary_hold_duration: float = 0.5 # Hold summary for minimum 0.5s to prevent flicker
|
||||
self._last_summary_update: float = 0.0 # Track last summary update for throttling
|
||||
self._summary_update_interval: float = 0.1 # Update summary every 100ms (simple throttling)
|
||||
|
||||
self._last_phase: Optional[str] = None
|
||||
self._transition_label: Optional[QLabel] = None
|
||||
self._last_summary_time: float = 0.0
|
||||
self._summary_hold_duration: float = 0.5
|
||||
self._last_summary_update: float = 0.0
|
||||
self._summary_update_interval: float = 0.1
|
||||
|
||||
self._setup_ui()
|
||||
# Set size policy to match Process Monitor - expand to fill available space
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI - match Process Monitor layout structure exactly."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2) # Match Process Monitor spacing (was 4, now 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
# Header row with CPU usage only (tab label replaces "[Activity]" header)
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.setSpacing(8)
|
||||
|
||||
# CPU usage indicator (right-aligned)
|
||||
self.cpu_label = QLabel("")
|
||||
self.cpu_label.setStyleSheet("color: #888; font-size: 11px; margin-bottom: 2px;")
|
||||
self.cpu_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
header_layout.addStretch() # Push CPU label to the right
|
||||
header_layout.addStretch()
|
||||
header_layout.addWidget(self.cpu_label, 0)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# List widget for file items - match Process Monitor size constraints
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setStyleSheet("""
|
||||
QListWidget {
|
||||
@@ -95,86 +166,55 @@ class FileProgressList(QWidget):
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
""")
|
||||
# Match Process Monitor minimum size: QSize(300, 20)
|
||||
self.list_widget.setMinimumSize(QSize(300, 20))
|
||||
# Match Process Monitor - no maximum height constraint, expand to fill available space
|
||||
# The list will scroll if there are more items than can fit
|
||||
self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
# Match Process Monitor size policy - expand to fill available space
|
||||
self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
layout.addWidget(self.list_widget, stretch=1) # Match Process Monitor stretch
|
||||
|
||||
# Throttle timer for updates when there are many files
|
||||
import time
|
||||
layout.addWidget(self.list_widget, stretch=1)
|
||||
|
||||
self._last_update_time = 0.0
|
||||
|
||||
# CPU usage tracking
|
||||
# CPU usage tracking — worker thread to avoid blocking the main thread
|
||||
self._cpu_timer = QTimer(self)
|
||||
self._cpu_timer.timeout.connect(self._update_cpu_usage)
|
||||
self._cpu_timer.setInterval(2000) # Update every 2 seconds
|
||||
self._cpu_timer.timeout.connect(self._start_cpu_worker)
|
||||
self._cpu_timer.setInterval(2000)
|
||||
self._last_cpu_percent = 0.0
|
||||
self._cpu_process_cache = None # Cache the process object for better performance
|
||||
self._child_process_cache = {} # Cache child Process objects by PID for persistent CPU tracking
|
||||
|
||||
self._cpu_process_cache = None
|
||||
self._child_process_cache = {}
|
||||
self._cpu_worker = None
|
||||
|
||||
def update_files(self, file_progresses: list[FileProgress], current_phase: str = None, summary_info: dict = None):
|
||||
"""
|
||||
Update the list with current file progresses.
|
||||
|
||||
Args:
|
||||
file_progresses: List of FileProgress objects for active files
|
||||
current_phase: Optional phase name to display in header (e.g., "Downloading", "Extracting")
|
||||
summary_info: Optional dict with 'current_step' and 'max_steps' for summary display (e.g., Installing phase)
|
||||
"""
|
||||
# Throttle updates to prevent UI freezing with many files
|
||||
# If we have many files (>50), throttle updates to every 100ms
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
# Throttle for large file lists
|
||||
if len(file_progresses) > 50:
|
||||
if current_time - self._last_update_time < 0.1: # 100ms throttle
|
||||
return # Skip this update
|
||||
if current_time - self._last_update_time < 0.1:
|
||||
return
|
||||
self._last_update_time = current_time
|
||||
|
||||
# If we have summary info (e.g., Installing phase), show summary widget instead of file list
|
||||
|
||||
# Summary widget path (Installing phase etc.)
|
||||
if summary_info and not file_progresses:
|
||||
current_time = time.time()
|
||||
|
||||
# Get new values
|
||||
current_step = summary_info.get('current_step', 0)
|
||||
max_steps = summary_info.get('max_steps', 0)
|
||||
phase_name = current_phase or "Installing files"
|
||||
max_steps = summary_info.get('max_steps', 0)
|
||||
phase_name = current_phase or "Installing files"
|
||||
|
||||
# Check if summary widget already exists and is valid
|
||||
summary_widget_valid = self._summary_widget and shiboken6.isValid(self._summary_widget)
|
||||
if not summary_widget_valid:
|
||||
self._summary_widget = None
|
||||
|
||||
# If widget exists, check if we should throttle the update
|
||||
if self._summary_widget:
|
||||
# Throttle updates to prevent flickering with rapidly changing counters
|
||||
if current_time - self._last_summary_update < self._summary_update_interval:
|
||||
return # Skip update, too soon
|
||||
|
||||
# Update existing summary widget (no clearing needed)
|
||||
return
|
||||
self._summary_widget.update_progress(current_step, max_steps)
|
||||
# Update phase name if it changed
|
||||
if self._summary_widget.phase_name != phase_name:
|
||||
self._summary_widget.phase_name = phase_name
|
||||
self._summary_widget._update_display()
|
||||
self._last_summary_update = current_time
|
||||
return
|
||||
|
||||
# Widget doesn't exist - create it (only clear when creating new widget)
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
|
||||
# Create new summary widget
|
||||
self._summary_widget = SummaryProgressWidget(phase_name, current_step, max_steps)
|
||||
summary_item = QListWidgetItem()
|
||||
summary_item.setSizeHint(self._summary_widget.sizeHint())
|
||||
@@ -183,453 +223,195 @@ class FileProgressList(QWidget):
|
||||
self.list_widget.setItemWidget(summary_item, self._summary_widget)
|
||||
self._last_summary_time = current_time
|
||||
self._last_summary_update = current_time
|
||||
|
||||
return
|
||||
|
||||
# Clear summary widget and transition label when showing file list
|
||||
# But only if enough time has passed to prevent flickering
|
||||
current_time = time.time()
|
||||
|
||||
# Remove stale summary widget
|
||||
if self._summary_widget:
|
||||
# Hold summary widget for minimum duration to prevent rapid flickering
|
||||
if current_time - self._last_summary_time >= self._summary_hold_duration:
|
||||
# Remove summary widget from list
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == "__summary__":
|
||||
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
self._remove_keyed_item("__summary__")
|
||||
self._summary_widget = None
|
||||
else:
|
||||
# Too soon to clear summary, keep it visible
|
||||
return
|
||||
|
||||
# Clear transition label if it exists
|
||||
# Remove transition label
|
||||
if self._transition_label:
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == "__transition__":
|
||||
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
self._remove_keyed_item("__transition__")
|
||||
self._transition_label = None
|
||||
|
||||
|
||||
if not file_progresses:
|
||||
# No files - check if this is a phase transition
|
||||
if current_phase and self._last_phase and current_phase != self._last_phase:
|
||||
# Phase changed - show transition message briefly
|
||||
self._show_transition_message(current_phase)
|
||||
else:
|
||||
# Show empty state but keep header stable
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
|
||||
# Update last phase tracker
|
||||
if current_phase:
|
||||
self._last_phase = current_phase
|
||||
return
|
||||
|
||||
# Determine phase from file operations if not provided
|
||||
|
||||
# Resolve phase label from operations if not provided
|
||||
if not current_phase and file_progresses:
|
||||
# Get the most common operation type
|
||||
operations = [fp.operation for fp in file_progresses if fp.operation != OperationType.UNKNOWN]
|
||||
if operations:
|
||||
operation_counts = {}
|
||||
counts = {}
|
||||
for op in operations:
|
||||
operation_counts[op] = operation_counts.get(op, 0) + 1
|
||||
most_common = max(operation_counts.items(), key=lambda x: x[1])[0]
|
||||
counts[op] = counts.get(op, 0) + 1
|
||||
phase_map = {
|
||||
OperationType.DOWNLOAD: "Downloading",
|
||||
OperationType.EXTRACT: "Extracting",
|
||||
OperationType.EXTRACT: "Extracting",
|
||||
OperationType.VALIDATE: "Validating",
|
||||
OperationType.INSTALL: "Installing",
|
||||
OperationType.INSTALL: "Installing",
|
||||
}
|
||||
current_phase = phase_map.get(most_common, "")
|
||||
|
||||
# Remove completed files
|
||||
# Build set of current item keys (using stable keys for counters)
|
||||
current_phase = phase_map.get(max(counts, key=counts.get), "")
|
||||
|
||||
# Build stable key set from incoming data
|
||||
current_keys = set()
|
||||
for fp in file_progresses:
|
||||
if 'Installing Files:' in fp.filename:
|
||||
current_keys.add("__installing_files__")
|
||||
elif 'Converting Texture:' in fp.filename:
|
||||
base_name = fp.filename.split('(')[0].strip()
|
||||
current_keys.add(f"__texture_{base_name}__")
|
||||
elif fp.filename.startswith('BSA:'):
|
||||
bsa_name = fp.filename.split('(')[0].strip()
|
||||
current_keys.add(f"__bsa_{bsa_name}__")
|
||||
elif fp.filename.startswith('Wine component:'):
|
||||
rest = fp.filename.split(':', 1)[1].strip()
|
||||
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
||||
current_keys.add(f"__wine_comp_{comp_id}__")
|
||||
else:
|
||||
current_keys.add(fp.filename)
|
||||
|
||||
current_keys.add(self._stable_key(fp))
|
||||
|
||||
# Remove items no longer active
|
||||
for item_key in list(self._file_items.keys()):
|
||||
if item_key not in current_keys:
|
||||
# Find and remove the item
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == item_key:
|
||||
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
del self._file_items[item_key]
|
||||
|
||||
# Update or add files - maintain specific ordering
|
||||
# Use stable identifiers for special items (like "Installing Files: X/Y")
|
||||
for idx, file_progress in enumerate(file_progresses):
|
||||
# For items with changing counters in filename, use a stable key
|
||||
if 'Installing Files:' in file_progress.filename:
|
||||
item_key = "__installing_files__"
|
||||
elif 'Converting Texture:' in file_progress.filename:
|
||||
base_name = file_progress.filename.split('(')[0].strip()
|
||||
item_key = f"__texture_{base_name}__"
|
||||
elif file_progress.filename.startswith('BSA:'):
|
||||
bsa_name = file_progress.filename.split('(')[0].strip()
|
||||
item_key = f"__bsa_{bsa_name}__"
|
||||
elif file_progress.filename.startswith('Wine component:'):
|
||||
rest = file_progress.filename.split(':', 1)[1].strip()
|
||||
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
||||
item_key = f"__wine_comp_{comp_id}__"
|
||||
else:
|
||||
item_key = file_progress.filename
|
||||
|
||||
|
||||
# Update existing or add new items
|
||||
for file_progress in file_progresses:
|
||||
item_key = self._stable_key(file_progress)
|
||||
|
||||
if item_key in self._file_items:
|
||||
# Update existing widget - DO NOT reorder items (causes segfaults)
|
||||
# Reordering with takeItem/insertItem can delete widgets and cause crashes
|
||||
# Order is less important than stability - just update the widget in place
|
||||
item_widget = self._file_items[item_key]
|
||||
# CRITICAL: Check widget is still valid before updating
|
||||
if shiboken6.isValid(item_widget):
|
||||
try:
|
||||
item_widget.update_progress(file_progress)
|
||||
except RuntimeError:
|
||||
# Widget was deleted - remove from dict and create new one below
|
||||
del self._file_items[item_key]
|
||||
# Fall through to create new widget
|
||||
else:
|
||||
# Update successful - skip creating new widget
|
||||
continue
|
||||
except RuntimeError:
|
||||
del self._file_items[item_key]
|
||||
else:
|
||||
# Widget invalid - remove from dict and create new one
|
||||
del self._file_items[item_key]
|
||||
# Fall through to create new widget
|
||||
# Create new widget (either because it didn't exist or was invalid)
|
||||
# CRITICAL: Use addItem instead of insertItem to avoid position conflicts
|
||||
# Order is less important than stability - addItem is safer than insertItem
|
||||
|
||||
item_widget = FileProgressItem(file_progress)
|
||||
list_item = QListWidgetItem()
|
||||
list_item.setSizeHint(item_widget.sizeHint())
|
||||
list_item.setData(Qt.UserRole, item_key) # Use stable key
|
||||
self.list_widget.addItem(list_item) # Use addItem for safety (avoids segfaults)
|
||||
list_item.setData(Qt.UserRole, item_key)
|
||||
self.list_widget.addItem(list_item)
|
||||
self.list_widget.setItemWidget(list_item, item_widget)
|
||||
self._file_items[item_key] = item_widget
|
||||
|
||||
# Update last phase tracker
|
||||
if current_phase:
|
||||
self._last_phase = current_phase
|
||||
|
||||
def _show_transition_message(self, new_phase: str):
|
||||
"""Show a brief 'Preparing...' message during phase transitions."""
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0):
|
||||
file_progress = FileProgress(
|
||||
filename=label,
|
||||
operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN,
|
||||
percent=progress,
|
||||
current_size=0,
|
||||
total_size=0,
|
||||
)
|
||||
self.update_files([file_progress], current_phase=None)
|
||||
|
||||
def clear_summary(self):
|
||||
if self._summary_widget:
|
||||
self._remove_keyed_item("__summary__")
|
||||
self._summary_widget = None
|
||||
|
||||
def clear(self):
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
self._summary_widget = None
|
||||
self._transition_label = None
|
||||
self._last_phase = None
|
||||
self.stop_cpu_tracking()
|
||||
self.cpu_label.setText("")
|
||||
|
||||
def start_cpu_tracking(self):
|
||||
if not self._cpu_timer.isActive():
|
||||
self._cpu_timer.start()
|
||||
self._start_cpu_worker()
|
||||
|
||||
def stop_cpu_tracking(self):
|
||||
self._cpu_timer.stop()
|
||||
if self._cpu_worker and self._cpu_worker.isRunning():
|
||||
self._cpu_worker.quit()
|
||||
self._cpu_worker.wait(500)
|
||||
self._cpu_worker = None
|
||||
|
||||
def _start_cpu_worker(self):
|
||||
# Skip if a worker is already running to avoid pileup
|
||||
if self._cpu_worker and self._cpu_worker.isRunning():
|
||||
return
|
||||
self._cpu_worker = _CpuWorker(self._last_cpu_percent, self._cpu_process_cache, self._child_process_cache)
|
||||
self._cpu_worker.result.connect(self._on_cpu_result)
|
||||
self._cpu_worker.caches_updated.connect(self._on_cpu_caches)
|
||||
self._cpu_worker.start()
|
||||
|
||||
def _on_cpu_result(self, text: str):
|
||||
self.cpu_label.setText(text)
|
||||
|
||||
def _on_cpu_caches(self, process_cache, child_cache, smoothed_pct):
|
||||
self._cpu_process_cache = process_cache
|
||||
self._child_process_cache = child_cache
|
||||
self._last_cpu_percent = smoothed_pct
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _stable_key(fp: FileProgress) -> str:
|
||||
if 'Installing Files:' in fp.filename:
|
||||
return "__installing_files__"
|
||||
if 'Converting Texture:' in fp.filename:
|
||||
return f"__texture_{fp.filename.split('(')[0].strip()}__"
|
||||
if fp.filename.startswith('BSA:'):
|
||||
return f"__bsa_{fp.filename.split('(')[0].strip()}__"
|
||||
if fp.filename.startswith('Wine component:'):
|
||||
rest = fp.filename.split(':', 1)[1].strip()
|
||||
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
||||
return f"__wine_comp_{comp_id}__"
|
||||
return fp.filename
|
||||
|
||||
def _clear_item_widgets(self):
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
|
||||
def _remove_keyed_item(self, key: str):
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == key:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
|
||||
def _show_transition_message(self, new_phase: str):
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
|
||||
# Header removed - tab label provides context
|
||||
|
||||
# Create or update transition label
|
||||
if self._transition_label is None or not shiboken6.isValid(self._transition_label):
|
||||
self._transition_label = QLabel()
|
||||
self._transition_label.setAlignment(Qt.AlignCenter)
|
||||
self._transition_label.setStyleSheet("color: #888; font-style: italic; padding: 20px;")
|
||||
|
||||
self._transition_label.setText(f"Preparing {new_phase.lower()}...")
|
||||
|
||||
# Add to list widget
|
||||
transition_item = QListWidgetItem()
|
||||
transition_item.setSizeHint(self._transition_label.sizeHint())
|
||||
transition_item.setData(Qt.UserRole, "__transition__")
|
||||
self.list_widget.addItem(transition_item)
|
||||
self.list_widget.setItemWidget(transition_item, self._transition_label)
|
||||
|
||||
# Remove transition message after brief delay (will be replaced by actual content)
|
||||
# The next update_files call with actual content will clear this automatically
|
||||
|
||||
def clear_summary(self):
|
||||
"""Remove the summary widget so file-list items can take over immediately."""
|
||||
if self._summary_widget:
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == "__summary__":
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
self._summary_widget = None
|
||||
|
||||
def clear(self):
|
||||
"""Clear all file items."""
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
self._summary_widget = None
|
||||
self._transition_label = None
|
||||
self._last_phase = None
|
||||
# Header removed - tab label provides context
|
||||
# Stop CPU timer and clear CPU label
|
||||
self.stop_cpu_tracking()
|
||||
self.cpu_label.setText("")
|
||||
|
||||
def start_cpu_tracking(self):
|
||||
"""Start tracking CPU usage."""
|
||||
if not self._cpu_timer.isActive():
|
||||
# Initialize process and take first measurement to establish baseline
|
||||
try:
|
||||
import psutil
|
||||
import os
|
||||
self._cpu_process_cache = psutil.Process(os.getpid())
|
||||
# First call with interval to establish baseline
|
||||
self._cpu_process_cache.cpu_percent(interval=0.1)
|
||||
# Cache child processes
|
||||
self._child_process_cache = {}
|
||||
for child in self._cpu_process_cache.children(recursive=True):
|
||||
try:
|
||||
child.cpu_percent(interval=0.1)
|
||||
self._child_process_cache[child.pid] = child
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._cpu_timer.start()
|
||||
self._update_cpu_usage() # Update immediately after baseline
|
||||
|
||||
def stop_cpu_tracking(self):
|
||||
"""Stop tracking CPU usage."""
|
||||
if self._cpu_timer.isActive():
|
||||
self._cpu_timer.stop()
|
||||
|
||||
def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0):
|
||||
"""
|
||||
Add or update a single status item in the Activity window.
|
||||
Useful for simple status messages like "Downloading...", "Extracting...", etc.
|
||||
|
||||
Args:
|
||||
item_id: Unique identifier for this item
|
||||
label: Display label for the item
|
||||
progress: Progress percentage (0-100), or 0 for indeterminate
|
||||
"""
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
|
||||
# Create a FileProgress object for this status item
|
||||
file_progress = FileProgress(
|
||||
filename=label,
|
||||
operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN,
|
||||
percent=progress,
|
||||
current_size=0,
|
||||
total_size=0
|
||||
)
|
||||
|
||||
# Use update_files with a single-item list
|
||||
self.update_files([file_progress], current_phase=None)
|
||||
|
||||
def _update_cpu_usage(self):
|
||||
"""
|
||||
Update CPU usage display with Jackify-related processes.
|
||||
|
||||
Shows total CPU usage across all cores as a percentage of system capacity.
|
||||
E.g., on an 8-core system:
|
||||
- 100% = using all 8 cores fully
|
||||
- 50% = using 4 cores fully (or 8 cores at half capacity)
|
||||
- 12.5% = using 1 core fully
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Get or create process cache
|
||||
if self._cpu_process_cache is None:
|
||||
self._cpu_process_cache = psutil.Process(os.getpid())
|
||||
|
||||
# Get current process CPU (Jackify GUI)
|
||||
# cpu_percent() returns percentage relative to one core
|
||||
# We need to divide by num_cpus to get system-wide percentage
|
||||
num_cpus = psutil.cpu_count()
|
||||
|
||||
main_cpu_raw = self._cpu_process_cache.cpu_percent(interval=None)
|
||||
main_cpu = main_cpu_raw / num_cpus
|
||||
total_cpu = main_cpu
|
||||
|
||||
# Add CPU usage from ALL child processes recursively
|
||||
# Includes jackify-engine, texconv.exe, wine processes, etc.
|
||||
child_count = 0
|
||||
child_cpu_sum = 0.0
|
||||
try:
|
||||
children = self._cpu_process_cache.children(recursive=True)
|
||||
current_child_pids = set()
|
||||
|
||||
for child in children:
|
||||
try:
|
||||
current_child_pids.add(child.pid)
|
||||
|
||||
# Check if this is a new process we haven't cached
|
||||
if child.pid not in self._child_process_cache:
|
||||
# Cache new process and establish baseline
|
||||
child.cpu_percent(interval=0.1)
|
||||
self._child_process_cache[child.pid] = child
|
||||
# Skip this iteration since baseline was just set
|
||||
continue
|
||||
|
||||
# Use cached process object for consistent cpu_percent tracking
|
||||
cached_child = self._child_process_cache[child.pid]
|
||||
child_cpu_raw = cached_child.cpu_percent(interval=None)
|
||||
child_cpu = child_cpu_raw / num_cpus
|
||||
total_cpu += child_cpu
|
||||
child_count += 1
|
||||
child_cpu_sum += child_cpu_raw
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
# Clean up cache for processes that no longer exist
|
||||
dead_pids = set(self._child_process_cache.keys()) - current_child_pids
|
||||
for pid in dead_pids:
|
||||
del self._child_process_cache[pid]
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
# Also search for ALL Jackify-related processes by name/cmdline
|
||||
# Catches non-direct children: shell launches, Proton/wine wrappers, etc.
|
||||
# children() is recursive, so typically only finds Proton spawn cases
|
||||
tracked_pids = {self._cpu_process_cache.pid} # Avoid double-counting
|
||||
tracked_pids.update(current_child_pids)
|
||||
|
||||
extra_count = 0
|
||||
extra_cpu_sum = 0.0
|
||||
try:
|
||||
for proc in psutil.process_iter(['name', 'pid', 'cmdline']):
|
||||
try:
|
||||
if proc.pid in tracked_pids:
|
||||
continue
|
||||
|
||||
proc_name = proc.info.get('name', '').lower()
|
||||
cmdline = proc.info.get('cmdline', [])
|
||||
cmdline_str = ' '.join(cmdline).lower() if cmdline else ''
|
||||
|
||||
# Match Jackify-related process names (include Proton/wine wrappers)
|
||||
# Include all tools that jackify-engine uses during installation
|
||||
jackify_names = [
|
||||
'jackify-engine', # Main engine
|
||||
'texconv', # Texture conversion
|
||||
'texdiag', # Texture diagnostics
|
||||
'directxtex', # DirectXTex helper binaries
|
||||
'texconv_jackify', # Bundled texconv build
|
||||
'texdiag_jackify', # Bundled texdiag build
|
||||
'directxtex_jackify', # Bundled DirectXTex build
|
||||
'7z', # Archive extraction (7z)
|
||||
'7zz', # Archive extraction (7zz)
|
||||
'bsarch', # BSA archive tool
|
||||
'wine', # Proton/wine launcher
|
||||
'wine64', # Proton/wine 64-bit launcher
|
||||
'wine64-preloader', # Proton/wine preloader
|
||||
'steam-run', # Steam runtime wrapper
|
||||
'proton', # Proton launcher scripts
|
||||
]
|
||||
|
||||
# Check process name
|
||||
is_jackify = any(name in proc_name for name in jackify_names)
|
||||
|
||||
# Check command line (e.g., wine running jackify tools, or paths containing jackify)
|
||||
if not is_jackify and cmdline_str:
|
||||
# Check for jackify tool names in command line (catches wine running texconv.exe, etc.)
|
||||
# Includes texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine
|
||||
is_jackify = any(name in cmdline_str for name in jackify_names)
|
||||
|
||||
# Also check for .exe variants (wine runs .exe files)
|
||||
if not is_jackify:
|
||||
exe_names = [f'{name}.exe' for name in jackify_names]
|
||||
is_jackify = any(exe_name in cmdline_str for exe_name in exe_names)
|
||||
|
||||
# Also check if command line contains jackify paths
|
||||
if not is_jackify:
|
||||
is_jackify = 'jackify' in cmdline_str and any(
|
||||
tool in cmdline_str for tool in ['engine', 'tools', 'binaries']
|
||||
)
|
||||
|
||||
if is_jackify:
|
||||
# Check if this is a new process we haven't cached
|
||||
if proc.pid not in self._child_process_cache:
|
||||
# Establish baseline for new process and cache it
|
||||
proc.cpu_percent(interval=0.1)
|
||||
self._child_process_cache[proc.pid] = proc
|
||||
# Skip this iteration since baseline was just set
|
||||
continue
|
||||
|
||||
# Use cached process object
|
||||
cached_proc = self._child_process_cache[proc.pid]
|
||||
proc_cpu_raw = cached_proc.cpu_percent(interval=None)
|
||||
proc_cpu = proc_cpu_raw / num_cpus
|
||||
total_cpu += proc_cpu
|
||||
tracked_pids.add(proc.pid)
|
||||
extra_count += 1
|
||||
extra_cpu_sum += proc_cpu_raw
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Smooth the value slightly to reduce jitter (less aggressive than before)
|
||||
if self._last_cpu_percent > 0:
|
||||
total_cpu = (self._last_cpu_percent * 0.3) + (total_cpu * 0.7)
|
||||
self._last_cpu_percent = total_cpu
|
||||
|
||||
# Always show CPU percentage when tracking is active
|
||||
# Cap at 100% for display (shouldn't exceed but just in case)
|
||||
display_percent = min(100.0, total_cpu)
|
||||
|
||||
if display_percent >= 0.1:
|
||||
self.cpu_label.setText(f"CPU: {display_percent:.0f}%")
|
||||
else:
|
||||
# Show 0% instead of hiding to indicate tracking is active
|
||||
self.cpu_label.setText("CPU: 0%")
|
||||
|
||||
except Exception as e:
|
||||
# Show error indicator if tracking fails
|
||||
import sys
|
||||
print(f"CPU tracking error: {e}", file=sys.stderr)
|
||||
self.cpu_label.setText("")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user