Docker-OSX/main_app.py
google-labs-jules[bot] e81120e8e9 feat: Add config.plist auto-enhancement, UI/UX improvements, and docs rework
This commit introduces several major enhancements:
1.  **Experimental `config.plist` Auto-Enhancement (Linux Host for Detection):**
    *   `linux_hardware_info.py`: Added audio codec detection.
    *   `plist_modifier.py`:
        *   Uses detected audio codecs for more accurate `layout-id` selection.
        *   Expanded mappings for Intel Alder Lake iGPUs, more audio devices, and Ethernet kexts.
        *   Refined NVIDIA GTX 970 `boot-args` logic based on target macOS version and iGPU presence.
        *   Creates a `.backup` of `config.plist` before modification and attempts restore on save failure.
    *   Integrated into `main_app.py` with a user-selectable experimental checkbox.

2.  **Reworked `README.md`:**
    *   Completely rewritten for "Skyscope" branding and project vision.
    *   Details all current features, including platform-specific USB writing (manual Windows dd step).
    *   Comprehensive prerequisites, including `apfs-fuse` build dependencies for Debian.
    *   Updated usage guide and future enhancement plans. Version set to 0.8.2.

3.  **UI/UX Enhancements for Task Progress:**
    *   Added a QTimer-driven text-based spinner to the status bar for active operations.
    *   Centralized UI state management (`_set_ui_busy`, `update_all_button_states`) for consistent feedback and control enabling/disabling.
    *   Refactored completion/error handling into generic slots.

4.  **Improved Windows USB Writing Guidance:**
    *   `usb_writer_windows.py` now uses `diskpart` to fetch and display the
        macOS partition number and byte offset, providing more specific details
        for your manual `dd` operation.

5.  **Debian 13 "Trixie" Compatibility:**
    *   Reviewed dependencies and updated `README.md` with specific notes for
        `hfsprogs` and `apfs-fuse` installation on Debian-based systems.

This set of changes makes the application more intelligent in its OpenCore
configuration attempts, improves your feedback during operations, and provides
much more comprehensive documentation, while also advancing the capabilities
of the platform-specific USB writers.
2025-06-05 21:47:07 +00:00

662 lines
43 KiB
Python

# main_app.py
import sys
import subprocess
import os
import psutil
import platform
import ctypes
import json # For parsing PowerShell JSON output
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QComboBox, QPushButton, QTextEdit, QMessageBox, QMenuBar,
QFileDialog, QGroupBox, QLineEdit, QProgressBar
)
from PyQt6.QtGui import QAction
from PyQt6.QtCore import pyqtSignal, pyqtSlot, QObject, QThread, QTimer, Qt # Added QTimer
# ... (Worker classes and other imports remain the same) ...
from constants import APP_NAME, DEVELOPER_NAME, BUSINESS_NAME, MACOS_VERSIONS, DOCKER_IMAGE_BASE
from utils import (
build_docker_command, get_unique_container_name,
build_docker_cp_command, CONTAINER_MACOS_IMG_PATH, CONTAINER_OPENCORE_QCOW2_PATH,
build_docker_stop_command, build_docker_rm_command
)
USBWriterLinux = None; USBWriterMacOS = None; USBWriterWindows = None
if platform.system() == "Linux":
try: from usb_writer_linux import USBWriterLinux
except ImportError as e: print(f"Could not import USBWriterLinux: {e}")
elif platform.system() == "Darwin":
try: from usb_writer_macos import USBWriterMacOS
except ImportError as e: print(f"Could not import USBWriterMacOS: {e}")
elif platform.system() == "Windows":
try: from usb_writer_windows import USBWriterWindows
except ImportError as e: print(f"Could not import USBWriterWindows: {e}")
class WorkerSignals(QObject): progress = pyqtSignal(str); finished = pyqtSignal(str); error = pyqtSignal(str)
class DockerPullWorker(QObject): # ... ( 그대로 )
signals = WorkerSignals()
def __init__(self, image_name: str): super().__init__(); self.image_name = image_name
@pyqtSlot()
def run(self):
try:
command = ["docker", "pull", self.image_name]; self.signals.progress.emit(f"Pulling Docker image: {self.image_name}...\n")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0)
if process.stdout:
for line in iter(process.stdout.readline, ''): self.signals.progress.emit(line)
process.stdout.close()
return_code = process.wait()
if return_code == 0: self.signals.finished.emit(f"Image '{self.image_name}' pulled successfully or already exists.")
else: self.signals.error.emit(f"Failed to pull image '{self.image_name}' (exit code {return_code}).")
except FileNotFoundError: self.signals.error.emit("Error: Docker command not found.")
except Exception as e: self.signals.error.emit(f"An error occurred during docker pull: {str(e)}")
class DockerRunWorker(QObject): # ... ( 그대로 )
signals = WorkerSignals()
def __init__(self, command_list): super().__init__(); self.command_list = command_list; self.process = None; self._is_running = True
@pyqtSlot()
def run(self):
try:
self.signals.progress.emit(f"Executing: {' '.join(self.command_list)}\n")
self.process = subprocess.Popen(self.command_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0)
if self.process.stdout:
for line in iter(self.process.stdout.readline, ''):
if not self._is_running: self.signals.progress.emit("Docker process stopping at user request.\n"); break
self.signals.progress.emit(line)
self.process.stdout.close()
return_code = self.process.wait()
if not self._is_running and return_code != 0 : self.signals.finished.emit(f"Docker process cancelled or stopped early (exit code {return_code})."); return
if return_code == 0: self.signals.finished.emit("Docker VM process (QEMU) closed by user or completed.")
else: self.signals.finished.emit(f"Docker VM process exited (code {return_code}). Assuming macOS setup was attempted or QEMU window closed.")
except FileNotFoundError: self.signals.error.emit("Error: Docker command not found.")
except Exception as e: self.signals.error.emit(f"An error occurred during Docker run: {str(e)}")
finally: self._is_running = False
def stop(self):
self._is_running = False
if self.process and self.process.poll() is None:
self.signals.progress.emit("Attempting to stop Docker process...\n")
try: self.process.terminate(); self.process.wait(timeout=5)
except subprocess.TimeoutExpired: self.signals.progress.emit("Process did not terminate gracefully, killing.\n"); self.process.kill()
self.signals.progress.emit("Docker process stopped.\n")
elif self.process and self.process.poll() is not None: self.signals.progress.emit("Docker process already stopped.\n")
class DockerCommandWorker(QObject): # ... ( 그대로 )
signals = WorkerSignals()
def __init__(self, command_list, success_message="Command completed."): super().__init__(); self.command_list = command_list; self.signals = WorkerSignals(); self.success_message = success_message
@pyqtSlot()
def run(self):
try:
self.signals.progress.emit(f"Executing: {' '.join(self.command_list)}\n"); result = subprocess.run(self.command_list, capture_output=True, text=True, check=False, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0)
if result.stdout and result.stdout.strip(): self.signals.progress.emit(result.stdout)
if result.stderr and result.stderr.strip(): self.signals.progress.emit(f"STDERR: {result.stderr}")
if result.returncode == 0: self.signals.finished.emit(self.success_message)
else: self.signals.error.emit(f"Command failed (code {result.returncode}): {result.stderr or result.stdout or 'Unknown error'}".strip())
except FileNotFoundError: self.signals.error.emit("Error: Docker command not found.")
except Exception as e: self.signals.error.emit(f"An error occurred: {str(e)}")
class USBWriterWorker(QObject):
signals = WorkerSignals()
def __init__(self, device, opencore_path, macos_path, enhance_plist: bool, target_macos_version: str): # Added new args
super().__init__()
self.device = device
self.opencore_path = opencore_path
self.macos_path = macos_path
self.enhance_plist = enhance_plist # Store
self.target_macos_version = target_macos_version # Store
self.writer_instance = None
@pyqtSlot()
def run(self):
current_os = platform.system()
try:
writer_cls = None
if current_os == "Linux": writer_cls = USBWriterLinux
elif current_os == "Darwin": writer_cls = USBWriterMacOS
elif current_os == "Windows": writer_cls = USBWriterWindows
if writer_cls is None:
self.signals.error.emit(f"{current_os} USB writer module not available or OS not supported."); return
# Pass new args to platform writer constructor
self.writer_instance = writer_cls(
self.device, self.opencore_path, self.macos_path,
progress_callback=lambda msg: self.signals.progress.emit(msg), # Ensure progress_callback is named if it's a kwarg in writers
enhance_plist_enabled=self.enhance_plist,
target_macos_version=self.target_macos_version
)
if self.writer_instance.format_and_write():
self.signals.finished.emit("USB writing process completed successfully.")
else:
self.signals.error.emit("USB writing process failed. Check output for details.")
except Exception as e:
self.signals.error.emit(f"USB writing preparation error: {str(e)}")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
self.setGeometry(100, 100, 800, 900) # Adjusted height for progress bar in status bar
self.current_container_name = None; self.extracted_main_image_path = None; self.extracted_opencore_image_path = None
self.extraction_status = {"main": False, "opencore": False}; self.active_worker_thread = None
self.docker_run_worker_instance = None; self.docker_pull_worker_instance = None # Specific worker instances
self._current_usb_selection_text = None
self.spinner_chars = ["|", "/", "-", "\\"]
self.spinner_index = 0
self.spinner_timer = QTimer(self)
self.spinner_timer.timeout.connect(self._update_spinner_status)
self.base_status_message = "Ready." # Default status message
self._setup_ui() # Call before using self.statusBar
self.status_bar = self.statusBar() # Initialize status bar early
self.status_bar.addPermanentWidget(self.progressBar) # Add progress bar to status bar
self.status_bar.showMessage(self.base_status_message, 5000) # Initial ready message
self.refresh_usb_drives()
def _setup_ui(self):
menubar = self.menuBar(); file_menu = menubar.addMenu("&File"); help_menu = menubar.addMenu("&Help")
exit_action = QAction("&Exit", self); exit_action.triggered.connect(self.close); file_menu.addAction(exit_action)
about_action = QAction("&About", self); about_action.triggered.connect(self.show_about_dialog); help_menu.addAction(about_action)
central_widget = QWidget(); self.setCentralWidget(central_widget); main_layout = QVBoxLayout(central_widget)
# Steps 1, 2, 3 remain the same UI structure
vm_creation_group = QGroupBox("Step 1: Create and Install macOS VM"); vm_layout = QVBoxLayout()
selection_layout = QHBoxLayout(); self.version_label = QLabel("Select macOS Version:"); self.version_combo = QComboBox()
self.version_combo.addItems(MACOS_VERSIONS.keys()); selection_layout.addWidget(self.version_label); selection_layout.addWidget(self.version_combo)
vm_layout.addLayout(selection_layout); self.run_vm_button = QPushButton("Create VM and Start macOS Installation")
self.run_vm_button.clicked.connect(self.initiate_vm_creation_flow); vm_layout.addWidget(self.run_vm_button)
self.stop_vm_button = QPushButton("Stop/Cancel Current Docker Operation"); self.stop_vm_button.clicked.connect(self.stop_current_docker_operation)
self.stop_vm_button.setEnabled(False); vm_layout.addWidget(self.stop_vm_button); vm_creation_group.setLayout(vm_layout)
main_layout.addWidget(vm_creation_group)
extraction_group = QGroupBox("Step 2: Extract VM Images"); ext_layout = QVBoxLayout()
self.extract_images_button = QPushButton("Extract Images from Container"); self.extract_images_button.clicked.connect(self.extract_vm_images)
self.extract_images_button.setEnabled(False); ext_layout.addWidget(self.extract_images_button); extraction_group.setLayout(ext_layout)
main_layout.addWidget(extraction_group)
mgmt_group = QGroupBox("Step 3: Container Management (Optional)"); mgmt_layout = QHBoxLayout()
self.stop_container_button = QPushButton("Stop Container"); self.stop_container_button.clicked.connect(self.stop_persistent_container)
self.stop_container_button.setEnabled(False); mgmt_layout.addWidget(self.stop_container_button)
self.remove_container_button = QPushButton("Remove Container"); self.remove_container_button.clicked.connect(self.remove_persistent_container)
self.remove_container_button.setEnabled(False); mgmt_layout.addWidget(self.remove_container_button); mgmt_group.setLayout(mgmt_layout)
main_layout.addWidget(mgmt_group)
# Step 4: USB Drive Selection - UI now adapts to Windows
usb_group = QGroupBox("Step 4: Select Target USB Drive and Write")
self.usb_layout = QVBoxLayout()
self.usb_drive_label = QLabel("Available USB Drives:")
self.usb_layout.addWidget(self.usb_drive_label)
usb_selection_layout = QHBoxLayout()
self.usb_drive_combo = QComboBox()
self.usb_drive_combo.currentIndexChanged.connect(self.update_write_to_usb_button_state)
usb_selection_layout.addWidget(self.usb_drive_combo)
self.refresh_usb_button = QPushButton("Refresh List")
self.refresh_usb_button.clicked.connect(self.refresh_usb_drives)
usb_selection_layout.addWidget(self.refresh_usb_button)
self.usb_layout.addLayout(usb_selection_layout)
# Windows-specific input for disk ID - initially hidden and managed by refresh_usb_drives
self.windows_usb_guidance_label = QLabel("For Windows: Detected USB Disks (select from dropdown).")
self.windows_usb_input_label = QLabel("Manual Fallback: Enter USB Disk Number (e.g., 1, 2):")
self.windows_disk_id_input = QLineEdit()
self.windows_disk_id_input.setPlaceholderText("Enter Disk Number if dropdown empty")
self.windows_disk_id_input.textChanged.connect(self.update_write_to_usb_button_state)
self.usb_layout.addWidget(self.windows_usb_guidance_label)
self.usb_layout.addWidget(self.windows_usb_input_label)
self.usb_layout.addWidget(self.windows_disk_id_input)
# Visibility will be toggled in refresh_usb_drives based on OS
self.enhance_plist_checkbox = QCheckBox("Try to auto-enhance config.plist for this system's hardware (Experimental, Linux Host Only for detection)")
self.enhance_plist_checkbox.setChecked(False) # Off by default
self.enhance_plist_checkbox.setToolTip(
"If checked, attempts to modify the OpenCore config.plist based on detected host hardware (Linux only for detection part).\n"
"This might improve compatibility for iGPU, audio, Ethernet. Use with caution."
)
self.usb_layout.addWidget(self.enhance_plist_checkbox)
warning_label = QLabel("WARNING: Selecting a drive and proceeding to write will ERASE ALL DATA on it!")
warning_label.setStyleSheet("color: red; font-weight: bold;")
self.usb_layout.addWidget(warning_label)
self.write_to_usb_button = QPushButton("Write Images to USB Drive")
self.write_to_usb_button.clicked.connect(self.handle_write_to_usb)
self.write_to_usb_button.setEnabled(False)
self.usb_layout.addWidget(self.write_to_usb_button)
usb_group.setLayout(self.usb_layout)
main_layout.addWidget(usb_group)
self.output_area = QTextEdit(); self.output_area.setReadOnly(True); main_layout.addWidget(self.output_area)
# Status Bar and Progress Bar
self.statusBar = self.statusBar()
self.progressBar = QProgressBar(self)
self.progressBar.setRange(0, 0) # Indeterminate
self.progressBar.setVisible(False)
self.statusBar.addPermanentWidget(self.progressBar) # Corrected addPermanentWidget call
def _set_ui_busy(self, is_busy: bool, status_message: str = "Processing..."): # Default busy message
"""Manages UI element states and progress indicators, including spinner."""
self.general_interactive_widgets = [
self.run_vm_button, self.version_combo, self.extract_images_button,
self.stop_container_button, self.remove_container_button,
self.usb_drive_combo, self.refresh_usb_button, self.write_to_usb_button,
self.windows_disk_id_input, self.enhance_plist_checkbox
]
if is_busy:
self.base_status_message = status_message # Store the core message for spinner
for widget in self.general_interactive_widgets:
widget.setEnabled(False)
# self.stop_vm_button is handled by _start_worker
self.progressBar.setVisible(True)
if not self.spinner_timer.isActive(): # Start spinner if not already active
self.spinner_index = 0
self.spinner_timer.start(150)
self._update_spinner_status() # Show initial spinner message
else:
self.spinner_timer.stop()
self.progressBar.setVisible(False)
self.statusBar.showMessage(status_message or "Ready.", 7000) # Show final message longer
self.update_all_button_states() # Centralized button state update
def _update_spinner_status(self):
"""Updates the status bar message with a spinner."""
if self.spinner_timer.isActive() and self.active_worker_thread and self.active_worker_thread.isRunning():
char = self.spinner_chars[self.spinner_index % len(self.spinner_chars)]
# Check if current worker is providing determinate progress
worker_name = self.active_worker_thread.objectName().replace("_thread", "")
worker_provides_progress = getattr(self, f"{worker_name}_provides_progress", False)
if worker_provides_progress and self.progressBar.maximum() == 100 and self.progressBar.value() > 0 : # Determinate
# For determinate, status bar shows base message, progress bar shows percentage
self.statusBar.showMessage(f"{char} {self.base_status_message} ({self.progressBar.value()}%)")
else: # Indeterminate
if self.progressBar.maximum() != 0: self.progressBar.setRange(0,0) # Ensure indeterminate
self.statusBar.showMessage(f"{char} {self.base_status_message}")
self.spinner_index = (self.spinner_index + 1) % len(self.spinner_chars)
elif not (self.active_worker_thread and self.active_worker_thread.isRunning()): # If timer is somehow active but no worker
self.spinner_timer.stop()
# self.statusBar.showMessage(self.base_status_message or "Ready.", 5000) # Show last base message or ready
def update_all_button_states(self): # Renamed from update_button_states_after_operation
"""Centralized method to update button states based on app's current state."""
is_worker_running = self.active_worker_thread and self.active_worker_thread.isRunning()
self.run_vm_button.setEnabled(not is_worker_running)
self.version_combo.setEnabled(not is_worker_running)
pull_worker_active = getattr(self, "docker_pull_instance", None) is not None
run_worker_active = getattr(self, "docker_run_instance", None) is not None
self.stop_vm_button.setEnabled(is_worker_running and (pull_worker_active or run_worker_active))
can_extract = self.current_container_name is not None and not is_worker_running
self.extract_images_button.setEnabled(can_extract)
can_manage_container = self.current_container_name is not None and not is_worker_running
self.stop_container_button.setEnabled(can_manage_container)
# Remove button is enabled if container exists and no worker is running (simplification)
# A more accurate state for remove_container_button would be if the container is actually stopped.
# This is typically handled by the finished slot of the stop_container worker.
# For now, this is a general enablement if not busy.
self.remove_container_button.setEnabled(can_manage_container)
self.refresh_usb_button.setEnabled(not is_worker_running)
self.update_write_to_usb_button_state() # This handles its own complex logic
def show_about_dialog(self):
QMessageBox.about(self, f"About {APP_NAME}", f"Version: 0.8.2\nDeveloper: {DEVELOPER_NAME}\nBusiness: {BUSINESS_NAME}\n\nThis tool helps create bootable macOS USB drives using Docker-OSX.")
def _start_worker(self, worker_instance, on_finished_slot, on_error_slot, worker_name="worker", busy_message="Processing...", provides_progress=False): # Added provides_progress
if self.active_worker_thread and self.active_worker_thread.isRunning():
QMessageBox.warning(self, "Busy", "Another operation is in progress."); return False
self._set_ui_busy(True, busy_message) # This now also starts the spinner
# Set progress bar type based on worker capability
if provides_progress:
self.progress_bar.setRange(0, 100) # Determinate
self.progress_bar.setValue(0)
else:
self.progress_bar.setRange(0, 0) # Indeterminate
# Store if this worker provides progress for spinner logic
setattr(self, f"{worker_name}_provides_progress", provides_progress)
if worker_name in ["docker_pull", "docker_run"]:
self.stop_vm_button.setEnabled(True)
else:
self.stop_vm_button.setEnabled(False)
self.active_worker_thread = QThread(); self.active_worker_thread.setObjectName(worker_name + "_thread"); setattr(self, f"{worker_name}_instance", worker_instance)
worker_instance.moveToThread(self.active_worker_thread)
worker_instance.signals.progress.connect(self.update_output)
if provides_progress: # Connect progress_value only if worker provides it
worker_instance.signals.progress_value.connect(self.update_progress_bar_value)
worker_instance.signals.finished.connect(lambda message, wn=worker_name, slot=on_finished_slot: self._handle_worker_finished(message, wn, slot))
worker_instance.signals.error.connect(lambda error_message, wn=worker_name, slot=on_error_slot: self._handle_worker_error(error_message, wn, slot))
self.active_worker_thread.finished.connect(self.active_worker_thread.deleteLater)
self.active_worker_thread.started.connect(worker_instance.run); self.active_worker_thread.start(); return True
@pyqtSlot(int)
def update_progress_bar_value(self, value):
if self.progress_bar.minimum() == 0 and self.progress_bar.maximum() == 0: # If it was indeterminate
self.progress_bar.setRange(0,100) # Switch to determinate
self.progress_bar.setValue(value)
# Spinner will update with percentage from progress_bar.value()
def _handle_worker_finished(self, message, worker_name, specific_finished_slot):
final_status_message = f"{worker_name.replace('_', ' ').capitalize()} completed."
self._clear_worker_instance(worker_name)
self.active_worker_thread = None
if specific_finished_slot: specific_finished_slot(message)
self._set_ui_busy(False, final_status_message)
def _handle_worker_error(self, error_message, worker_name, specific_error_slot):
final_status_message = f"{worker_name.replace('_', ' ').capitalize()} failed."
self._clear_worker_instance(worker_name)
self.active_worker_thread = None
if specific_error_slot: specific_error_slot(error_message)
self._set_ui_busy(False, final_status_message)
def _clear_worker_instance(self, worker_name):
attr_name = f"{worker_name}_instance"
if hasattr(self, attr_name): delattr(self, attr_name)
def initiate_vm_creation_flow(self):
self.output_area.clear(); selected_version_name = self.version_combo.currentText(); image_tag = MACOS_VERSIONS.get(selected_version_name)
if not image_tag: self.handle_error(f"Invalid macOS version: {selected_version_name}"); return
full_image_name = f"{DOCKER_IMAGE_BASE}:{image_tag}"
pull_worker = DockerPullWorker(full_image_name)
self._start_worker(pull_worker,
self.docker_pull_finished,
self.docker_pull_error,
"docker_pull", # worker_name
f"Pulling image {full_image_name}...", # busy_message
provides_progress=False) # Docker pull progress is complex to parse reliably for a percentage
@pyqtSlot(str)
def docker_pull_finished(self, message): # Specific handler
self.output_area.append(f"Step 1.2: Proceeding to run Docker container for macOS installation...")
self.run_macos_vm()
@pyqtSlot(str)
def docker_pull_error(self, error_message): # Specific handler
QMessageBox.critical(self, "Docker Pull Error", error_message)
def run_macos_vm(self):
selected_version_name = self.version_combo.currentText(); self.current_container_name = get_unique_container_name()
try:
command_list = build_docker_command(selected_version_name, self.current_container_name)
run_worker = DockerRunWorker(command_list)
self._start_worker(run_worker,
self.docker_run_finished,
self.docker_run_error,
"docker_run",
f"Starting container {self.current_container_name}...",
provides_progress=False) # Docker run output is also streamed, not easily percentage
except ValueError as e: self.handle_error(f"Failed to build command: {str(e)}")
except Exception as e: self.handle_error(f"An unexpected error: {str(e)}")
@pyqtSlot(str)
def update_output(self, text): self.output_area.append(text.strip()); QApplication.processEvents()
@pyqtSlot(str)
def docker_run_finished(self, message): # Specific handler
QMessageBox.information(self, "VM Setup Complete", f"{message}\nYou can now proceed to extract images.")
@pyqtSlot(str)
def docker_run_error(self, error_message): # Specific handler
if "exited" in error_message.lower() and self.current_container_name:
QMessageBox.warning(self, "VM Setup Ended", f"{error_message}\nAssuming macOS setup was attempted...")
else:
QMessageBox.critical(self, "VM Setup Error", error_message)
def stop_current_docker_operation(self):
pull_worker = getattr(self, "docker_pull_instance", None); run_worker = getattr(self, "docker_run_instance", None)
if pull_worker: self.output_area.append("\n--- Docker pull cannot be directly stopped by this button. Close app to abort. ---")
elif run_worker: self.output_area.append("\n--- Attempting to stop macOS VM creation (docker run) ---"); run_worker.stop()
else: self.output_area.append("\n--- No stoppable Docker operation active. ---")
def extract_vm_images(self):
if not self.current_container_name: QMessageBox.warning(self, "Warning", "No active container."); return
save_dir = QFileDialog.getExistingDirectory(self, "Select Directory to Save VM Images");
if not save_dir: return
self.output_area.append(f"\n--- Starting Image Extraction from {self.current_container_name} to {save_dir} ---"); self.extract_images_button.setEnabled(False); self.write_to_usb_button.setEnabled(False)
self.extracted_main_image_path = os.path.join(save_dir, "mac_hdd_ng.img"); self.extracted_opencore_image_path = os.path.join(save_dir, "OpenCore.qcow2"); self.extraction_status = {"main": False, "opencore": False}
cp_main_cmd = build_docker_cp_command(self.current_container_name, CONTAINER_MACOS_IMG_PATH, self.extracted_main_image_path); main_worker = DockerCommandWorker(cp_main_cmd, f"Main macOS image copied to {self.extracted_main_image_path}")
if not self._start_worker(main_worker, lambda msg: self.docker_utility_finished(msg, "main_img_extract"), lambda err: self.docker_utility_error(err, "main_img_extract_error"), "cp_main_worker"): self.extract_images_button.setEnabled(True); return
self.output_area.append(f"Extraction for main image started. OpenCore extraction will follow.")
def _start_opencore_extraction(self):
if not self.current_container_name or not self.extracted_opencore_image_path: return
cp_oc_cmd = build_docker_cp_command(self.current_container_name, CONTAINER_OPENCORE_QCOW2_PATH, self.extracted_opencore_image_path); oc_worker = DockerCommandWorker(cp_oc_cmd, f"OpenCore image copied to {self.extracted_opencore_image_path}")
self._start_worker(oc_worker, lambda msg: self.docker_utility_finished(msg, "oc_img_extract"), lambda err: self.docker_utility_error(err, "oc_img_extract_error"), "cp_oc_worker")
def stop_persistent_container(self):
if not self.current_container_name: QMessageBox.warning(self, "Warning", "No container name."); return
cmd = build_docker_stop_command(self.current_container_name); worker = DockerCommandWorker(cmd, f"Container {self.current_container_name} stopped.")
if self._start_worker(worker, lambda msg: self.docker_utility_finished(msg, "stop_container"), lambda err: self.docker_utility_error(err, "stop_container_error"), "stop_worker"): self.stop_container_button.setEnabled(False)
def remove_persistent_container(self):
if not self.current_container_name: QMessageBox.warning(self, "Warning", "No container name."); return
reply = QMessageBox.question(self, 'Confirm Remove', f"Remove container '{self.current_container_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.No: return
cmd = build_docker_rm_command(self.current_container_name); worker = DockerCommandWorker(cmd, f"Container {self.current_container_name} removed.")
if self._start_worker(worker, lambda msg: self.docker_utility_finished(msg, "rm_container"), lambda err: self.docker_utility_error(err, "rm_container_error"), "rm_worker"): self.remove_container_button.setEnabled(False)
def docker_utility_finished(self, message, task_id): # Specific handler
QMessageBox.information(self, f"Task Complete", message) # Show specific popup
# Core logic based on task_id
if task_id == "main_img_extract":
self.extraction_status["main"] = True
# _handle_worker_finished (generic) has already reset active_worker_thread.
self._start_opencore_extraction() # Start the next part of the sequence
return # Return here as active_worker_thread will be managed by _start_opencore_extraction
elif task_id == "oc_img_extract":
self.extraction_status["opencore"] = True
elif task_id == "rm_container": # Specific logic for after rm
self.current_container_name = None
# For other utility tasks (like stop_container), or after oc_img_extract,
# or after rm_container specific logic, the generic handler _handle_worker_finished
# (which called this) will then call _set_ui_busy(False) -> update_button_states_after_operation.
# So, no explicit call to self.update_button_states_after_operation() is needed here
# unless a state relevant to it changed *within this specific handler*.
# In case of rm_container, current_container_name changes, so a UI update is good.
if task_id == "rm_container" or (task_id == "oc_img_extract" and self.extraction_status.get("main")):
self.update_button_states_after_operation()
def docker_utility_error(self, error_message, task_id): # Specific handler
QMessageBox.critical(self, f"Task Error: {task_id}", error_message)
# UI state reset by generic _handle_worker_error -> _set_ui_busy(False) -> update_button_states_after_operation
# Task-specific error UI updates if needed can be added here, but usually generic reset is enough.
def handle_error(self, message): # General error handler for non-worker related setup issues
self.output_area.append(f"ERROR: {message}"); QMessageBox.critical(self, "Error", message)
self.run_vm_button.setEnabled(True); self.version_combo.setEnabled(True); self.stop_vm_button.setEnabled(False); self.extract_images_button.setEnabled(False); self.write_to_usb_button.setEnabled(False)
self.active_worker_thread = None;
for worker_name_suffix in ["pull", "run", "cp_main_worker", "cp_oc_worker", "stop_worker", "rm_worker", "usb_write_worker"]: self._clear_worker_instance(worker_name_suffix)
def check_admin_privileges(self) -> bool:
try:
if platform.system() == "Windows": return ctypes.windll.shell32.IsUserAnAdmin() != 0
else: return os.geteuid() == 0
except Exception as e: self.output_area.append(f"Could not check admin privileges: {e}"); return False
def refresh_usb_drives(self): # Modified for Windows WMI
self.usb_drive_combo.clear()
self._current_usb_selection_text = self.usb_drive_combo.currentText() # Store to reselect if possible
self.output_area.append("\nScanning for disk devices...")
current_os = platform.system()
self.windows_usb_guidance_label.setVisible(current_os == "Windows")
self.windows_usb_input_label.setVisible(False) # Hide manual input by default
self.windows_disk_id_input.setVisible(False) # Hide manual input by default
self.usb_drive_combo.setVisible(True) # Always visible, populated differently
if current_os == "Windows":
self.usb_drive_label.setText("Available USB Disks (Windows - WMI):")
self.refresh_usb_button.setText("Refresh USB List")
powershell_command = "Get-WmiObject Win32_DiskDrive | Where-Object {$_.InterfaceType -eq 'USB'} | Select-Object DeviceID, Index, Model, @{Name='SizeGB';Expression={[math]::Round($_.Size / 1GB, 2)}} | ConvertTo-Json"
try:
process = subprocess.run(["powershell", "-Command", powershell_command], capture_output=True, text=True, check=True, creationflags=subprocess.CREATE_NO_WINDOW)
disks_data = json.loads(process.stdout)
if not isinstance(disks_data, list): disks_data = [disks_data] # Ensure it's a list
if disks_data:
for disk in disks_data:
if disk.get('DeviceID') is None or disk.get('Index') is None: continue
disk_text = f"Disk {disk['Index']}: {disk.get('Model','N/A')} ({disk.get('SizeGB','N/A')} GB) - {disk['DeviceID']}"
self.usb_drive_combo.addItem(disk_text, userData=str(disk['Index']))
self.output_area.append(f"Found {len(disks_data)} USB disk(s) via WMI. Select from dropdown.")
if self._current_usb_selection_text:
for i in range(self.usb_drive_combo.count()):
if self.usb_drive_combo.itemText(i) == self._current_usb_selection_text: self.usb_drive_combo.setCurrentIndex(i); break
else:
self.output_area.append("No USB disks found via WMI/PowerShell. Manual input field shown as fallback.")
self.windows_usb_input_label.setVisible(True); self.windows_disk_id_input.setVisible(True) # Show manual input as fallback
except Exception as e:
self.output_area.append(f"Error querying WMI for USB disks: {e}. Manual input field shown.")
self.windows_usb_input_label.setVisible(True); self.windows_disk_id_input.setVisible(True)
else: # Linux / macOS
self.usb_drive_label.setText("Available USB Drives (for Linux/macOS):")
self.refresh_usb_button.setText("Refresh List")
try:
partitions = psutil.disk_partitions(all=False); potential_usbs = []
for p in partitions:
is_removable = 'removable' in p.opts; is_likely_usb = False
if current_os == "Darwin" and p.device.startswith("/dev/disk") and 'external' in p.opts.lower() and 'physical' in p.opts.lower(): is_likely_usb = True
elif current_os == "Linux" and ((p.mountpoint and ("/media/" in p.mountpoint or "/run/media/" in p.mountpoint)) or (p.device.startswith("/dev/sd") and not p.device.endswith("da"))): is_likely_usb = True
if is_removable or is_likely_usb:
try: usage = psutil.disk_usage(p.mountpoint); size_gb = usage.total / (1024**3)
except Exception: continue
if size_gb < 0.1 : continue
drive_text = f"{p.device} @ {p.mountpoint} ({p.fstype}, {size_gb:.2f} GB)"
potential_usbs.append((drive_text, p.device))
if potential_usbs:
idx_to_select = -1
for i, (text, device_path) in enumerate(potential_usbs): self.usb_drive_combo.addItem(text, userData=device_path);
if text == self._current_usb_selection_text: idx_to_select = i
if idx_to_select != -1: self.usb_drive_combo.setCurrentIndex(idx_to_select)
self.output_area.append(f"Found {len(potential_usbs)} potential USB drive(s). Please verify carefully.")
else: self.output_area.append("No suitable USB drives found for Linux/macOS.")
except ImportError: self.output_area.append("psutil library not found.")
except Exception as e: self.output_area.append(f"Error scanning for USB drives: {e}")
self.update_write_to_usb_button_state()
def handle_write_to_usb(self): # Modified for Windows WMI
if not self.check_admin_privileges():
QMessageBox.warning(self, "Privileges Required", "This operation requires Administrator/root privileges."); return
current_os = platform.system(); usb_writer_module = None; target_device_id_for_worker = None
enhance_plist_enabled = self.enhance_plist_checkbox.isChecked() # Get state
target_macos_ver = self.version_combo.currentText() # Get macOS version
if current_os == "Windows":
target_device_id_for_worker = self.usb_drive_combo.currentData() # Disk Index from WMI
if not target_device_id_for_worker:
if self.windows_disk_id_input.isVisible():
target_device_id_for_worker = self.windows_disk_id_input.text().strip()
if not target_device_id_for_worker: QMessageBox.warning(self, "Input Required", "Please select a USB disk or enter its Disk Number."); return
if not target_device_id_for_worker.isdigit(): QMessageBox.warning(self, "Input Invalid", "Windows Disk Number must be a digit."); return
else:
QMessageBox.warning(self, "USB Error", "No USB disk selected for Windows."); return
usb_writer_module = USBWriterWindows
else: # Linux/macOS
target_device_id_for_worker = self.usb_drive_combo.currentData()
if current_os == "Linux": usb_writer_module = USBWriterLinux
elif current_os == "Darwin": usb_writer_module = USBWriterMacOS
if not usb_writer_module: QMessageBox.warning(self, "Unsupported Platform", f"USB writing not supported/enabled for {current_os}."); return
if not (self.extracted_main_image_path and self.extracted_opencore_image_path and self.extraction_status["main"] and self.extraction_status["opencore"]):
QMessageBox.warning(self, "Missing Images", "Ensure both images are extracted."); return
if not target_device_id_for_worker: QMessageBox.warning(self, "No USB Selected/Identified", f"Please select/identify target USB for {current_os}."); return
confirm_msg = (f"WARNING: ALL DATA ON TARGET '{target_device_id_for_worker}' WILL BE ERASED PERMANENTLY.\n"
f"Enhance config.plist: {'Yes' if enhance_plist_enabled else 'No'}.\nProceed?")
reply = QMessageBox.warning(self, "Confirm Write Operation", confirm_msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel)
if reply == QMessageBox.StandardButton.Cancel: self.output_area.append("\nUSB write cancelled."); return
self.output_area.append(f"\n--- Starting USB Write for {target_device_id_for_worker} on {current_os} ---")
if enhance_plist_enabled: self.output_area.append("Attempting config.plist enhancement...")
usb_worker = USBWriterWorker(
target_device_id_for_worker,
self.extracted_opencore_image_path,
self.extracted_main_image_path,
enhance_plist_enabled,
target_macos_ver
)
self._start_worker(usb_worker,
self.usb_write_finished,
self.usb_write_error,
"usb_write_worker",
f"Writing to USB {target_device_id_for_worker}...")
@pyqtSlot(str)
def usb_write_finished(self, message): # Specific handler
QMessageBox.information(self, "USB Write Complete", message)
# UI state reset by generic _handle_worker_finished -> _set_ui_busy(False)
@pyqtSlot(str)
def usb_write_error(self, error_message): # Specific handler
QMessageBox.critical(self, "USB Write Error", error_message)
# UI state reset by generic _handle_worker_error -> _set_ui_busy(False)
def update_write_to_usb_button_state(self):
images_ready = self.extraction_status.get("main", False) and self.extraction_status.get("opencore", False); usb_identified = False; current_os = platform.system(); writer_module = None
if current_os == "Linux": writer_module = USBWriterLinux; usb_identified = bool(self.usb_drive_combo.currentData())
elif current_os == "Darwin": writer_module = USBWriterMacOS; usb_identified = bool(self.usb_drive_combo.currentData())
elif current_os == "Windows":
writer_module = USBWriterWindows
usb_identified = bool(self.usb_drive_combo.currentData()) or bool(self.windows_disk_id_input.text().strip().isdigit() and self.windows_disk_id_input.isVisible())
self.write_to_usb_button.setEnabled(images_ready and usb_identified and writer_module is not None)
tooltip = ""
if writer_module is None: tooltip = f"USB Writing not supported on {current_os} or module missing."
elif not images_ready: tooltip = "Extract VM images first."
elif not usb_identified: tooltip = "Select a USB disk from dropdown (or enter Disk Number if dropdown empty on Windows)."
else: tooltip = ""
self.write_to_usb_button.setToolTip(tooltip)
def closeEvent(self, event):
self._current_usb_selection_text = self.usb_drive_combo.currentText()
if self.active_worker_thread and self.active_worker_thread.isRunning():
reply = QMessageBox.question(self, 'Confirm Exit', "An operation is running. Exit anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
worker_instance_attr_name = self.active_worker_thread.objectName().replace("_thread", "_instance")
worker_to_stop = getattr(self, worker_instance_attr_name, None)
if worker_to_stop and hasattr(worker_to_stop, 'stop'): worker_to_stop.stop()
else: self.active_worker_thread.quit()
self.active_worker_thread.wait(1000); event.accept()
else: event.ignore(); return
elif self.current_container_name and self.stop_container_button.isEnabled():
reply = QMessageBox.question(self, 'Confirm Exit', f"Container '{self.current_container_name}' may still exist. Exit anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes: event.accept()
else: event.ignore()
else: event.accept()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())