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.
This commit is contained in:
google-labs-jules[bot] 2025-06-05 21:47:07 +00:00
parent cf19c71494
commit e81120e8e9
5 changed files with 710 additions and 511 deletions

190
README.md
View File

@ -4,113 +4,137 @@
**Developer:** Miss Casey Jay Topojani
**Business:** Skyscope Sentinel Intelligence
## Overview
## Vision: Your Effortless Bridge to macOS on PC
This tool provides a graphical user interface to automate the creation of a bootable macOS USB drive for PC (Hackintosh) using the Docker-OSX project. It guides the user through selecting a macOS version, running the Docker-OSX container for macOS installation, extracting the necessary image files, and (currently for Linux users) writing these images to a USB drive.
Welcome to the Skyscope macOS on PC USB Creator Tool! Our vision is to provide an exceptionally user-friendly, GUI-driven application that fully automates the complex process of creating a bootable macOS USB drive for virtually any PC. This tool leverages the power of Docker-OSX and OpenCore, aiming to simplify the Hackintosh journey from start to finish.
## Features
This project is dedicated to creating a seamless experience, from selecting your desired macOS version to generating a USB drive that's ready to boot your PC into macOS, complete with efforts to auto-configure for your hardware.
* User-friendly GUI for selecting macOS versions (Sonoma, Ventura, Monterey, Big Sur, Catalina).
* Automated Docker command generation and execution for Docker-OSX.
* Streams Docker logs directly into the application.
* Extraction of the generated `mac_hdd_ng.img` (macOS system) and `OpenCore.qcow2` (EFI bootloader).
* Management of the created Docker container (stop/remove).
* USB drive detection.
* Automated USB partitioning and image writing for **Linux systems**.
* Creates GPT partition table.
* Creates an EFI System Partition (ESP) and a main HFS+ partition for macOS.
* Copies EFI files and writes the macOS system image.
* Warning prompts before destructive operations like USB writing.
* Experimental `config.plist` auto-enhancement based on detected host hardware (currently Linux-only for hardware detection) to potentially improve iGPU, audio, and Ethernet compatibility, and handle NVIDIA GTX 970 specifics. A backup of the original `config.plist` is created.
## Current Features & Capabilities
## Current Status & Known Issues/Limitations
* **Intuitive Graphical User Interface (PyQt6):** Guides you through each step of the process.
* **macOS Version Selection:** Easily choose from popular macOS versions (Sonoma, Ventura, Monterey, Big Sur, Catalina).
* **Automated Docker-OSX Orchestration:**
* **Intelligent Image Pulling:** Automatically pulls the required `sickcodes/docker-osx` image from Docker Hub, with progress displayed.
* **VM Creation & macOS Installation:** Launches the Docker-OSX container where you can interactively install macOS within a QEMU virtual machine.
* **Log Streaming:** View Docker and QEMU logs directly in the application for transparency.
* **VM Image Extraction:** Once macOS is installed in the VM, the tool helps you extract the essential disk images (`mac_hdd_ng.img` and `OpenCore.qcow2`).
* **Container Management:** Stop and remove the Docker-OSX container after use.
* **Cross-Platform USB Drive Preparation:**
* **USB Detection:** Identifies potential USB drives on Linux, macOS, and Windows (using WMI for more accurate detection on Windows).
* **Automated EFI & macOS System Write (Linux & macOS):**
* Partitions the USB drive with a GUID Partition Table (GPT).
* Creates and formats an EFI System Partition (FAT32) and a main macOS partition (HFS+).
* Uses a robust file-level copy (`rsync`) for both EFI content and the main macOS system, ensuring compatibility with various USB sizes and only copying necessary data.
* **Windows USB Writing (Partial Automation):**
* Automates EFI partition creation and EFI file copying.
* **Important:** Writing the main macOS system image currently requires a guided manual step using an external "dd for Windows" utility due to Windows' limitations with direct, scriptable raw partition writing of HFS+/APFS filesystems. The tool prepares the raw image and provides instructions.
* **Experimental `config.plist` Auto-Enhancement:**
* **Linux Host Detection:** If the tool is run on a Linux system, it can gather information about your host computer's hardware (iGPU, audio, Ethernet, CPU).
* **Targeted Modifications:** Optionally attempts to modify the `config.plist` (from the generated `OpenCore.qcow2`) to:
* Add common `DeviceProperties` for Intel iGPUs.
* Set appropriate audio `layout-id`s.
* Ensure necessary Ethernet kexts are enabled.
* Apply boot-args for NVIDIA GTX 970 based on target macOS version (e.g., `nv_disable=1` or `nvda_drv=1`).
* A backup of the original `config.plist` is created before modifications.
* **Privilege Checking:** Warns if administrative/root privileges are needed for USB writing and are not detected.
* **UI Feedback:** Status bar messages and an indeterminate progress bar keep you informed during long operations.
* **USB Writing Platform Support:** USB writing functionality is currently **only implemented and tested for Linux**. macOS and Windows users can use the tool to generate and extract images but will need to use other methods for USB creation.
* **macOS Image Size for USB:** The current Linux USB writing process for the main macOS system uses `dd` to write the converted raw image. While the source `mac_hdd_ng.img` is sparse, the raw conversion makes it its full provisioned size (e.g., 200GB). This means:
* The target USB drive must be large enough to hold this full raw size.
* This is inefficient and needs to be changed to a file-level copy (e.g., using `rsync` after mounting the source image) to only copy actual data and better fit various USB sizes. (This is a high-priority item based on recent feedback).
* **Intel iGPU Compatibility:** Relies on the generic iGPU support provided by WhateverGreen.kext within the OpenCore configuration from Docker-OSX. This works for many iGPUs but isn't guaranteed for all without specific `config.plist` tuning.
* **Dependency on Docker-OSX:** This tool orchestrates Docker-OSX. Changes or issues in the upstream Docker-OSX project might affect this tool.
* **Elevated Privileges:** For USB writing on Linux, the application currently requires being run with `sudo`. It does not yet have in-app checks or prompts for this.
* `config.plist` auto-enhancement is experimental. The hardware detection component for this feature is **currently only implemented for Linux hosts**. While the modification logic is called on macOS, it will not apply hardware-specific changes due to lack of macOS hardware detection in `plist_modifier.py`. Modifications are based on common configurations and may not be optimal for all hardware. Always test thoroughly. A backup of the original `config.plist` (as `config.plist.backup`) is created in the source OpenCore image's EFI directory before modification attempts.
## Current Status & Known Limitations
* **Windows Main OS USB Write:** This is the primary limitation, requiring a manual `dd` step. Future work aims to automate this if a reliable, redistributable CLI tool for raw partition writing is identified or developed.
* **`config.plist` Enhancement is Experimental:**
* Hardware detection for this feature is **currently only implemented for Linux hosts.** On macOS/Windows, the plist modification step will run but won't apply hardware-specific changes.
* The applied patches are based on common configurations and may not be optimal or work for all hardware. Always test thoroughly.
* **NVIDIA dGPU Support on Newer macOS:** Modern macOS (Mojave+) does not support NVIDIA Maxwell/Pascal/Turing/Ampere GPUs. The tool attempts to configure systems with these cards for basic display or to use an iGPU if available. Full acceleration is not possible on these macOS versions with these cards.
* **Universal Compatibility:** While the goal is broad PC compatibility, Hackintoshing can be hardware-specific. Success is not guaranteed on all possible PC configurations.
* **Dependency on External Projects:** Relies on Docker-OSX, OpenCore, and various community-sourced kexts and configurations.
## Prerequisites
1. **Docker:** Docker must be installed and running on your system. The current user must have permissions to run Docker commands.
1. **Docker:** Must be installed and running. Your user account needs permission to manage Docker.
* [Install Docker Engine](https://docs.docker.com/engine/install/)
2. **Python:** Python 3.8+
3. **Python Libraries:**
* `PyQt6`
* `psutil`
* Installation: `pip install PyQt6 psutil`
4. **(For Linux USB Writing ONLY)**: The following command-line utilities must be installed and accessible in your PATH:
* `qemu-img` (usually from `qemu-utils` package)
* `parted`
* `kpartx` (often part of `multipath-tools` or `kpartx` package)
* `rsync`
* `mkfs.vfat` (usually from `dosfstools` package)
* `mkfs.hfsplus` (usually from `hfsprogs` package)
* `apfs-fuse` (may require manual installation from source or a third-party repository/PPA, as it's not always in standard Debian/Ubuntu repos)
* `lsblk` (usually from `util-linux` package)
* `partprobe` (usually from `parted` or `util-linux` package)
* You can typically install most of these on Debian/Ubuntu (including Debian 13 Trixie) with:
```bash
sudo apt update
sudo apt install qemu-utils parted kpartx rsync dosfstools hfsprogs util-linux
```
* For `apfs-fuse` on Debian/Ubuntu (including Debian 13 Trixie), you will likely need to compile it from its source (e.g., from the `sgan81/apfs-fuse` repository on GitHub). Typical build dependencies include `git g++ cmake libfuse3-dev libicu-dev zlib1g-dev libbz2-dev libssl-dev` (package names may vary slightly, e.g. `libfuse-dev`). Ensure the compiled `apfs-fuse` binary is in your system PATH.
2. **Python:** Version 3.8 or newer.
3. **Python Libraries:** Install with `pip install PyQt6 psutil`.
4. **Platform-Specific CLI Tools for USB Writing:**
* **Linux (including Debian 13 "Trixie"):**
* `qemu-img` (from `qemu-utils`)
* `parted`
* `kpartx` (from `kpartx` or `multipath-tools`)
* `rsync`
* `mkfs.vfat` (from `dosfstools`)
* `mkfs.hfsplus` (from `hfsprogs`)
* `apfs-fuse`: Often requires manual compilation (e.g., from `sgan81/apfs-fuse` on GitHub). Typical build dependencies: `git g++ cmake libfuse3-dev libicu-dev zlib1g-dev libbz2-dev libssl-dev`. Ensure it's in your PATH.
* `lsblk`, `partprobe` (from `util-linux`)
* Install most via: `sudo apt update && sudo apt install qemu-utils parted kpartx rsync dosfstools hfsprogs util-linux`
* **macOS:**
* `qemu-img` (e.g., via Homebrew: `brew install qemu`)
* `diskutil`, `hdiutil`, `rsync` (standard macOS tools).
* **Windows:**
* `qemu-img` (install and add to PATH).
* `diskpart`, `robocopy` (standard Windows tools).
* `7z.exe` (7-Zip command-line tool, install and add to PATH) - for EFI file extraction.
* A "dd for Windows" utility (e.g., from SUSE, chrysocome.net, or similar). Ensure it's in your PATH and you know how to use it for writing to a physical disk's partition or offset.
## How to Run
1. Clone this repository or download the source files (`main_app.py`, `utils.py`, `constants.py`, `usb_writer_linux.py`).
2. Install the prerequisite Python libraries: `pip install PyQt6 psutil`.
3. **(Linux for USB Writing):** Ensure all command-line utilities listed under prerequisites are installed.
4. Run the application:
```bash
python main_app.py
```
**(Linux for USB Writing):** You will need to run the application with `sudo` for USB writing operations to succeed, due to the nature of disk partitioning and direct write commands:
```bash
sudo python main_app.py
```
1. Ensure all prerequisites for your operating system are met.
2. Clone this repository or download the source files.
3. Install Python libraries: `pip install PyQt6 psutil`.
4. Execute `python main_app.py`.
5. **Important for USB Writing:**
* **Linux:** Run with `sudo python main_app.py`.
* **macOS:** The script will use `sudo` internally for `rsync` to USB EFI if needed. You might be prompted for your password. Ensure the main application has Full Disk Access if issues arise with `hdiutil` or `diskutil` not having permissions (System Settings > Privacy & Security).
* **Windows:** Run the application as Administrator.
## Usage Steps
## Step-by-Step Usage Guide
1. **Step 1: Create and Install macOS VM**
* Select your desired macOS version from the dropdown.
* Launch the "Skyscope macOS on PC USB Creator Tool".
* Select your desired macOS version from the dropdown menu.
* Click "Create VM and Start macOS Installation".
* A Docker container will be started, and a QEMU window will appear.
* Follow the on-screen instructions within the QEMU window to install macOS. This is an interactive process (formatting the virtual disk, installing macOS).
* Once macOS is installed and you have shut down or closed the QEMU window, the Docker process will finish.
* The tool will first pull the necessary Docker image (progress shown).
* Then, a QEMU window will appear. This is your virtual machine. Follow the standard macOS installation procedure within this window (use Disk Utility to erase and format the virtual hard drive, then install macOS). This part is interactive.
* Once macOS is fully installed in QEMU, shut down the macOS VM from within its own interface (Apple Menu > Shut Down). Closing the QEMU window will also terminate the process.
2. **Step 2: Extract VM Images**
* After the VM setup process is complete, the "Extract Images from Container" button will become enabled.
* Click it and select a directory on your computer where the `mac_hdd_ng.img` and `OpenCore.qcow2` files will be saved.
* Wait for both extraction processes to complete.
* After the Docker process from Step 1 finishes (QEMU window closes), the "Extract Images from Container" button will become active.
* Click it. You'll be prompted to select a directory on your computer. The `mac_hdd_ng.img` (macOS system) and `OpenCore.qcow2` (EFI bootloader) files will be copied here. This may take some time.
3. **Step 3: Container Management (Optional)**
* After image extraction (or if the VM setup finished), you can "Stop Container" (if it's somehow still running) and then "Remove Container" to clean up the Docker container (which is no longer needed if images are extracted).
* Once images are extracted, the Docker container used for installation is no longer strictly needed.
* You can "Stop Container" (if it's listed as running by Docker for any reason) and then "Remove Container" to free up disk space.
4. **Step 4: Select Target USB Drive and Write**
* Connect your target USB drive.
* Click "Refresh List" to scan for USB drives.
* Select your intended USB drive from the dropdown. **VERIFY CAREFULLY!**
* **WARNING:** The next step will erase all data on the selected USB drive.
* Optionally, check the '\[Experimental] Auto-enhance config.plist...' box if you want the tool to attempt to modify the OpenCore configuration based on your Linux host's hardware (this feature is Linux-only for detection). This may improve compatibility but use with caution. A backup (`config.plist.backup`) is created in the source OpenCore image's EFI directory before modification.
* If you are on Linux and have all dependencies, and the images from Step 2 are ready, the "Write Images to USB Drive" button will be enabled.
* Click it and confirm the warning dialog. The application will then partition the USB and write the images. This will take a significant amount of time.
* Physically connect your USB flash drive.
* Click "Refresh List".
* **Linux/macOS:** Select your USB drive from the dropdown. Verify size and identifier carefully.
* **Windows:** USB drives detected via WMI will appear in the dropdown. Select the correct one. Ensure it's the `Disk X` number you intend.
* **(Optional, Experimental):** Check the "Try to auto-enhance config.plist..." box if you are on a Linux host and wish to attempt automatic `config.plist` modification for your hardware. A backup of the original `config.plist` will be made.
* **CRITICAL WARNING:** Double-check your selection. The next action will erase the selected USB drive.
* Click "Write Images to USB Drive". Confirm the data erasure warning.
* The process will now:
* (If enhancement enabled) Attempt to modify the `config.plist` within the source OpenCore image.
* Partition and format your USB drive.
* Copy EFI files to the USB's EFI partition.
* Copy macOS system files to the USB's main partition. (On Windows, this step requires manual `dd` operation as guided by the application).
* This is a lengthy process. Monitor the progress in the output area.
5. **Boot!**
* Once complete, safely eject the USB drive. You can now try booting your PC from it. Remember to configure your PC's BIOS/UEFI for booting from USB and for macOS compatibility (e.g., disable Secure Boot, enable AHCI, XHCI Handoff, etc., as per standard Hackintosh guides like Dortania).
## Future Enhancements (Based on Feedback)
## Future Vision & Enhancements
* **Improve USB Writing for Image Sizing (High Priority):** Modify the USB writing process (especially for the main macOS system) to use file-level copies (e.g., `rsync` after mounting the source image) instead of `dd` for the entire raw image. This will correctly handle various USB drive sizes by only copying used data and fitting it to the partition.
* **Explicit Docker Image Pull:** Add a separate step/feedback for `docker pull` before `docker run`.
* **Privilege Handling:** Add checks to see if the application is run with necessary privileges for USB writing and guide the user if not.
* **USB Writing for macOS and Windows:** Implement the `usb_writer_macos.py` and `usb_writer_windows.py` modules.
* **GUI for Advanced Options:** Potentially allow users to specify custom Docker parameters or OpenCore properties.
* **Expand hardware detection for `config.plist` enhancement to also support macOS and Windows hosts.**
* **Provide more granular user control and detailed feedback for the `config.plist` enhancement feature (e.g., preview changes, select specific patches).**
* **Fully Automated Windows USB Writing:** Replace the manual `dd` step with a reliable, integrated solution.
* **Advanced `config.plist` Customization:**
* Expand hardware detection to macOS and Windows hosts.
* Provide more granular UI controls for plist enhancements (e.g., preview changes, select specific patches).
* Allow users to load/save `config.plist` modification profiles.
* **Enhanced UI/UX for Progress:** Implement determinate progress bars with percentage completion and more dynamic status updates.
* **Debian 13 "Trixie" (and other distros) Validation:** Continuous compatibility checks and dependency streamlining.
* **"Universal" Config Strategy (Research):** Investigate advanced techniques for more adaptive OpenCore configurations, though true universality is a significant challenge.
## Contributing
Contributions are welcome! Please fork the repository and submit a pull request.
Your contributions, feedback, and bug reports are highly welcome! Please fork the repository and submit pull requests, or open issues for discussion.
## License
(To be decided - likely MIT or GPLv3)
(To be decided - e.g., MIT or GPLv3)

View File

@ -1,60 +1,63 @@
# linux_hardware_info.py
import subprocess
import re
import os # For listing /proc/asound
import glob # For wildcard matching in /proc/asound
def _run_command(command: list[str]) -> str:
"""Helper to run a command and return its stdout."""
def _run_command(command: list[str], check_stderr_for_error=False) -> tuple[str, str, int]:
"""
Helper to run a command and return its stdout, stderr, and return code.
Args:
check_stderr_for_error: If True, treat any output on stderr as an error condition for return code.
Returns:
(stdout, stderr, return_code)
"""
try:
process = subprocess.run(command, capture_output=True, text=True, check=True)
return process.stdout
process = subprocess.run(command, capture_output=True, text=True, check=False) # check=False to handle errors manually
# Some tools (like lspci without -k if no driver) might return 0 but print to stderr.
# However, for most tools here, a non-zero return code is the primary error indicator.
# If check_stderr_for_error is True and stderr has content, consider it an error for simplicity here.
# effective_return_code = process.returncode
# if check_stderr_for_error and process.stderr and process.returncode == 0:
# effective_return_code = 1 # Treat as error
return process.stdout, process.stderr, process.returncode
except FileNotFoundError:
print(f"Error: Command '{command[0]}' not found. Is 'pciutils' (for lspci) installed?")
return ""
except subprocess.CalledProcessError as e:
print(f"Error executing {' '.join(command)}: {e.stderr}")
return ""
print(f"Error: Command '{command[0]}' not found.")
return "", f"Command not found: {command[0]}", 127 # Standard exit code for command not found
except Exception as e:
print(f"An unexpected error occurred with command {' '.join(command)}: {e}")
return ""
return "", str(e), 1
def get_pci_devices_info() -> list[dict]:
"""
Gets a list of dictionaries, each containing info about a PCI device,
focusing on VGA, Audio, and Ethernet controllers.
Output format for relevant devices:
{'type': 'VGA', 'vendor_id': '10de', 'device_id': '13c2', 'description': 'NVIDIA GTX 970'}
{'type': 'Audio', 'vendor_id': '8086', 'device_id': 'a170', 'description': 'Intel Sunrise Point-H HD Audio'}
{'type': 'Ethernet', 'vendor_id': '8086', 'device_id': '15b8', 'description': 'Intel Ethernet Connection I219-V'}
focusing on VGA, Audio, and Ethernet controllers using lspci.
"""
output = _run_command(["lspci", "-nnk"])
if not output:
stdout, stderr, return_code = _run_command(["lspci", "-nnk"])
if return_code != 0 or not stdout:
print(f"lspci command failed or produced no output. stderr: {stderr}")
return []
devices = []
# Regex to capture device type (from description), description, and [vendor:device]
# Example line: 01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GM204 [GeForce GTX 970] [10de:13c2] (rev a1)
# Example line: 00:1f.3 Audio device [0403]: Intel Corporation Sunrise Point-H HD Audio [8086:a170] (rev 31)
# Example line: 00:1f.6 Ethernet controller [0200]: Intel Corporation Ethernet Connection (2) I219-V [8086:15b8] (rev 31)
# More robust regex:
# It captures the class description (like "VGA compatible controller", "Audio device")
# and the main device description (like "NVIDIA Corporation GM204 [GeForce GTX 970]")
# and the vendor/device IDs like "[10de:13c2]"
regex = re.compile(
r"^[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.\d\s+" # PCI Address (e.g., 01:00.0 )
r"(.+?)\s+" # Class Description (e.g., "VGA compatible controller")
r"\[[0-9a-fA-F]{4}\]:\s+" # PCI Class Code (e.g., [0300]: )
r"(.+?)\s+" # Full Device Description (e.g., "NVIDIA Corporation GM204 [GeForce GTX 970]")
r"\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]" # Vendor and Device ID (e.g., [10de:13c2])
r"^[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.\d\s+"
r"(.+?)\s+"
r"\[([0-9a-fA-F]{4})\]:\s+" # Class Code in hex, like 0300 for VGA
r"(.+?)\s+"
r"\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]" # Vendor and Device ID
)
for line in output.splitlines():
for line in stdout.splitlines():
match = regex.search(line)
if match:
class_desc = match.group(1).strip()
full_desc = match.group(2).strip()
vendor_id = match.group(3).lower()
device_id = match.group(4).lower()
# class_code = match.group(2).strip() # Not directly used yet but captured
full_desc = match.group(3).strip()
vendor_id = match.group(4).lower()
device_id = match.group(5).lower()
device_type = None
if "VGA compatible controller" in class_desc or "3D controller" in class_desc:
@ -63,52 +66,42 @@ def get_pci_devices_info() -> list[dict]:
device_type = "Audio"
elif "Ethernet controller" in class_desc:
device_type = "Ethernet"
elif "Network controller" in class_desc: # Could be Wi-Fi
elif "Network controller" in class_desc:
device_type = "Network (Wi-Fi?)"
if device_type:
# Try to get a cleaner description if possible, removing vendor name if it's at the start
# e.g. "Intel Corporation Ethernet Connection (2) I219-V" -> "Ethernet Connection (2) I219-V"
# This is a simple attempt.
cleaned_desc = full_desc
if full_desc.lower().startswith("intel corporation "):
cleaned_desc = full_desc[len("intel corporation "):]
elif full_desc.lower().startswith("nvidia corporation "):
cleaned_desc = full_desc[len("nvidia corporation "):]
elif full_desc.lower().startswith("advanced micro devices, inc.") or full_desc.lower().startswith("amd"):
# Handle different AMD namings
if full_desc.lower().startswith("advanced micro devices, inc."):
cleaned_desc = re.sub(r"Advanced Micro Devices, Inc\.\s*\[AMD/ATI\]\s*", "", full_desc, flags=re.IGNORECASE)
else: # Starts with AMD
cleaned_desc = re.sub(r"AMD\s*\[ATI\]\s*", "", full_desc, flags=re.IGNORECASE)
elif full_desc.lower().startswith("realtek semiconductor co., ltd."):
cleaned_desc = full_desc[len("realtek semiconductor co., ltd. "):]
# Simple cleanup attempts (can be expanded)
vendors_to_strip = ["Intel Corporation", "NVIDIA Corporation", "Advanced Micro Devices, Inc. [AMD/ATI]", "AMD [ATI]", "Realtek Semiconductor Co., Ltd."]
for v_strip in vendors_to_strip:
if cleaned_desc.startswith(v_strip):
cleaned_desc = cleaned_desc[len(v_strip):].strip()
break
# Remove revision if present at end, e.g. (rev 31)
cleaned_desc = re.sub(r'\s*\(rev [0-9a-fA-F]{2}\)$', '', cleaned_desc)
devices.append({
"type": device_type,
"vendor_id": vendor_id,
"device_id": device_id,
"description": cleaned_desc.strip(),
"full_lspci_line": line.strip() # For debugging or more info
"description": cleaned_desc.strip() if cleaned_desc else full_desc, # Fallback to full_desc
"full_lspci_line": line.strip()
})
return devices
def get_cpu_info() -> dict:
"""
Gets CPU information using lscpu.
Returns a dictionary with 'Model name', 'Vendor ID', 'CPU family', 'Model', 'Stepping', 'Flags'.
"""
output = _run_command(["lscpu"])
if not output:
stdout, stderr, return_code = _run_command(["lscpu"])
if return_code != 0 or not stdout:
print(f"lscpu command failed or produced no output. stderr: {stderr}")
return {}
info = {}
# Regex to capture key-value pairs from lscpu output
# Handles spaces in values for "Model name"
regex = re.compile(r"^(CPU family|Model name|Vendor ID|Model|Stepping|Flags):\s+(.*)$")
for line in output.splitlines():
regex = re.compile(r"^(CPU family|Model name|Vendor ID|Model|Stepping|Flags|Architecture):\s+(.*)$")
for line in stdout.splitlines():
match = regex.match(line)
if match:
key = match.group(1).strip()
@ -116,24 +109,68 @@ def get_cpu_info() -> dict:
info[key] = value
return info
def get_audio_codecs() -> list[str]:
"""
Detects audio codec names by parsing /proc/asound/card*/codec#*.
Returns a list of unique codec name strings.
E.g., ["Realtek ALC897", "Intel Kaby Lake HDMI"]
"""
codec_files = glob.glob("/proc/asound/card*/codec#*")
if not codec_files:
# Fallback for systems where codec#* might not exist, try card*/id
codec_files = glob.glob("/proc/asound/card*/id")
codecs = set() # Use a set to store unique codec names
for codec_file_path in codec_files:
try:
with open(codec_file_path, 'r') as f:
content = f.read()
# For codec#* files
codec_match = re.search(r"Codec:\s*(.*)", content)
if codec_match:
codecs.add(codec_match.group(1).strip())
# For card*/id files (often just the card name, but sometimes hints at codec)
# This is a weaker source but a fallback.
if "/id" in codec_file_path and not codec_match: # Only if no "Codec:" line found
# The content of /id is usually the card name, e.g. "HDA Intel PCH"
# This might not be the specific codec chip but can be a hint.
# For now, let's only add if it seems like a specific codec name.
# This part needs more refinement if used as a primary source.
# For now, we prioritize "Codec: " lines.
if "ALC" in content or "CS" in content or "AD" in content: # Common codec prefixes
codecs.add(content.strip())
except Exception as e:
print(f"Error reading or parsing codec file {codec_file_path}: {e}")
if not codecs and not codec_files: # If no files found at all
print("No /proc/asound/card*/codec#* or /proc/asound/card*/id files found. Cannot detect audio codecs this way.")
return sorted(list(codecs))
if __name__ == '__main__':
print("--- PCI Devices ---")
pci_devs = get_pci_devices_info()
if pci_devs:
for dev in pci_devs:
print(f" Type: {dev['type']}")
print(f" Vendor ID: {dev['vendor_id']}")
print(f" Device ID: {dev['device_id']}")
print(f" Description: {dev['description']}")
# print(f" Full Line: {dev['full_lspci_line']}")
else:
print(" No relevant PCI devices found or lspci not available.")
print("\n--- CPU Info ---")
print("--- CPU Info ---")
cpu_info = get_cpu_info()
if cpu_info:
for key, value in cpu_info.items():
print(f" {key}: {value}")
else: print(" Could not retrieve CPU info.")
print("\n--- PCI Devices ---")
pci_devs = get_pci_devices_info()
if pci_devs:
for dev in pci_devs:
print(f" Type: {dev['type']}, Vendor: {dev['vendor_id']}, Device: {dev['device_id']}, Desc: {dev['description']}")
else: print(" No relevant PCI devices found or lspci not available.")
print("\n--- Audio Codecs ---")
audio_codecs = get_audio_codecs()
if audio_codecs:
for codec in audio_codecs:
print(f" Detected Codec: {codec}")
else:
print(" Could not retrieve CPU info or lscpu not available.")
print(" No specific audio codecs detected via /proc/asound.")

View File

@ -10,10 +10,10 @@ 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, QCheckBox # Added QCheckBox
QFileDialog, QGroupBox, QLineEdit, QProgressBar
)
from PyQt6.QtGui import QAction
from PyQt6.QtCore import pyqtSignal, pyqtSlot, QObject, QThread, Qt
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
@ -136,15 +136,30 @@ class USBWriterWorker(QObject):
class MainWindow(QMainWindow):
def __init__(self): # ... (init remains the same)
super().__init__(); self.setWindowTitle(APP_NAME); self.setGeometry(100, 100, 800, 850)
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
self.docker_run_worker_instance = None; self.docker_pull_worker_instance = None # Specific worker instances
self._current_usb_selection_text = None
self._setup_ui(); self.refresh_usb_drives()
def _setup_ui(self): # Updated for Windows USB detection
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)
@ -226,31 +241,55 @@ class MainWindow(QMainWindow):
self.progressBar = QProgressBar(self)
self.progressBar.setRange(0, 0) # Indeterminate
self.progressBar.setVisible(False)
self.statusBar.addPermanentWidget(self.progressBar, 0)
self.statusBar.addPermanentWidget(self.progressBar) # Corrected addPermanentWidget call
def _set_ui_busy(self, is_busy: bool, status_message: str = None):
"""Manages UI element states and progress indicators."""
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.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)
self.statusBar.showMessage(status_message or "Processing...", 0)
# stop_vm_button's state is managed specifically by the calling function if needed
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:
# Re-enable based on current application state by calling a dedicated method
self.update_button_states_after_operation() # This will set appropriate states
self.spinner_timer.stop()
self.progressBar.setVisible(False)
self.statusBar.showMessage(status_message or "Ready.", 5000) # Message disappears after 5s
self.statusBar.showMessage(status_message or "Ready.", 7000) # Show final message longer
self.update_all_button_states() # Centralized button state update
def update_button_states_after_operation(self):
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()
@ -276,49 +315,63 @@ class MainWindow(QMainWindow):
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): # Updated version
QMessageBox.about(self, f"About {APP_NAME}", f"Version: 0.8.1\nDeveloper: {DEVELOPER_NAME}\nBusiness: {BUSINESS_NAME}\n\nThis tool helps create bootable macOS USB drives using Docker-OSX.")
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..."):
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)
if worker_name in ["docker_pull", "docker_run"]:
self.stop_vm_button.setEnabled(True) # Enable stop for these specific long ops
else: # For other workers, the main stop button for docker ops is not relevant
self.stop_vm_button.setEnabled(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)
# Connect to generic handlers
worker_instance.signals.progress.connect(self.update_output)
worker_instance.signals.finished.connect(lambda message: self._handle_worker_finished(message, on_finished_slot, worker_name))
worker_instance.signals.error.connect(lambda error_message: self._handle_worker_error(error_message, on_error_slot, worker_name))
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)
# No need to call _clear_worker_instance here, _handle_worker_finished/error will do it.
self.active_worker_thread.started.connect(worker_instance.run); self.active_worker_thread.start(); return True
def _handle_worker_finished(self, message, specific_finished_slot, worker_name):
"""Generic handler for worker finished signals."""
self.output_area.append(f"\n--- Worker '{worker_name}' Finished --- \n{message}") # Generic log
self._clear_worker_instance(worker_name) # Clear the worker instance from self
self.active_worker_thread = None # Mark thread as free
if specific_finished_slot:
specific_finished_slot(message) # Call the specific logic for this worker
self._set_ui_busy(False, "Operation completed successfully.") # Reset UI
@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_error(self, error_message, specific_error_slot, worker_name):
"""Generic handler for worker error signals."""
self.output_area.append(f"\n--- Worker '{worker_name}' Error --- \n{error_message}") # Generic log
self._clear_worker_instance(worker_name) # Clear the worker instance from self
self.active_worker_thread = None # Mark thread as free
if specific_error_slot:
specific_error_slot(error_message) # Call the specific logic for this worker
self._set_ui_busy(False, "An error occurred.") # Reset UI
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"
@ -326,58 +379,50 @@ class MainWindow(QMainWindow):
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 # handle_error calls _set_ui_busy(False)
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)
# Pass busy message to _start_worker
self._start_worker(pull_worker,
self.docker_pull_finished,
self.docker_pull_error,
"docker_pull",
f"Pulling image {full_image_name}...")
"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
# Generic handler (_handle_worker_finished) already logged, cleared instance, and reset UI.
# This slot now only handles the next step in the sequence.
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
# Generic handler (_handle_worker_error) already logged, cleared instance, and reset UI.
QMessageBox.critical(self, "Docker Pull Error", error_message)
# No further specific action needed here, UI reset is handled by the generic error handler.
def run_macos_vm(self): # This is now part 2 of the flow
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)
# Pass busy message to _start_worker
self._start_worker(run_worker,
self.docker_run_finished,
self.docker_run_error,
"docker_run",
f"Starting container {self.current_container_name}...")
except ValueError as e: self.handle_error(f"Failed to build command: {str(e)}") # This error is before worker start
except Exception as e: self.handle_error(f"An unexpected error: {str(e)}") # This error is before worker start
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
# Generic handler already took care of logging, instance clearing, and UI reset.
QMessageBox.information(self, "VM Setup Complete", f"{message}\nYou can now proceed to extract images.")
# Specific logic after run finishes (e.g. enabling extraction) is now in update_button_states_after_operation
@pyqtSlot(str)
def docker_run_error(self, error_message): # Specific handler
# Generic handler already took care of logging, instance clearing, and UI reset.
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...")
# Specific logic (e.g. enabling extraction) is now in update_button_states_after_operation
else:
QMessageBox.critical(self, "VM Setup Error", error_message)

View File

@ -1,293 +1,294 @@
# plist_modifier.py
import plistlib
import platform
import shutil # For backup
import os # For path operations
import shutil
import os
import re # For parsing codec names
# Attempt to import hardware info, will only work if run in an environment
# where linux_hardware_info.py is accessible and on Linux.
if platform.system() == "Linux":
try:
from linux_hardware_info import get_pci_devices_info, get_cpu_info
from linux_hardware_info import get_pci_devices_info, get_cpu_info, get_audio_codecs
except ImportError:
print("Warning: linux_hardware_info.py not found. Plist enhancement will be limited.")
get_pci_devices_info = lambda: [] # Dummy function
get_cpu_info = lambda: {} # Dummy function
else: # For other OS, create dummy functions so the rest of the module can be parsed
get_pci_devices_info = lambda: []
get_cpu_info = lambda: {}
get_audio_codecs = lambda: []
else:
print(f"Warning: Hardware info gathering not implemented for {platform.system()} in plist_modifier.")
get_pci_devices_info = lambda: []
get_cpu_info = lambda: {}
get_audio_codecs = lambda: [] # Dummy function for non-Linux
# --- Mappings ---
# Values are typically byte-swapped for device-id and some ig-platform-id representations in OpenCore
# For AAPL,ig-platform-id, the first two bytes are often the device-id (swapped), last two are platform related.
# Example: UHD 630 (Desktop Coffee Lake) device-id 0x3E9B -> data <9B3E0000>
# ig-platform-id commonly 0x3E9B0007 -> data <07009B3E> (or other variants)
# --- Illustrative Mappings (Proof of Concept) ---
# Keys are VENDOR_ID:DEVICE_ID (lowercase)
INTEL_IGPU_DEFAULTS = {
# Coffee Lake Desktop (UHD 630)
# Coffee Lake Desktop (UHD 630) - Common
"8086:3e9b": {"AAPL,ig-platform-id": b"\x07\x00\x9B\x3E", "device-id": b"\x9B\x3E\x00\x00", "framebuffer-patch-enable": b"\x01\x00\x00\x00"},
# Kaby Lake Desktop (HD 630)
# Kaby Lake Desktop (HD 630) - Common
"8086:5912": {"AAPL,ig-platform-id": b"\x05\x00\x12\x59", "device-id": b"\x12\x59\x00\x00", "framebuffer-patch-enable": b"\x01\x00\x00\x00"},
# Skylake Desktop (HD 530)
# Skylake Desktop (HD 530) - Common
"8086:1912": {"AAPL,ig-platform-id": b"\x00\x00\x12\x19", "device-id": b"\x12\x19\x00\x00", "framebuffer-patch-enable": b"\x01\x00\x00\x00"},
# Alder Lake-S Desktop (UHD 730/750/770) - device-id often needs to be accurate
"8086:4680": {"AAPL,ig-platform-id": b"\x0A\x00\x9B\x46", "device-id": b"\x80\x46\x00\x00", "enable-hdmi20": b"\x01\x00\x00\x00"}, # e.g. i9-12900K UHD 770 (0x4680) -> common platform ID for iGPU only
"8086:4690": {"AAPL,ig-platform-id": b"\x0A\x00\x9B\x46", "device-id": b"\x90\x46\x00\x00", "enable-hdmi20": b"\x01\x00\x00\x00"}, # e.g. i5-12600K UHD 770 (0x4690)
"8086:4692": {"AAPL,ig-platform-id": b"\x0A\x00\x9B\x46", "device-id": b"\x92\x46\x00\x00", "enable-hdmi20": b"\x01\x00\x00\x00"}, # e.g. i5-12400 UHD 730 (0x4692)
# Alternative Alder Lake platform-id (often when dGPU is primary)
"8086:4680_dgpu": {"AAPL,ig-platform-id": b"\x04\x00\x12\x40", "device-id": b"\x80\x46\x00\x00", "enable-hdmi20": b"\x01\x00\x00\x00"}, # Using a suffix for internal logic, not a real PCI ID
"8086:4690_dgpu": {"AAPL,ig-platform-id": b"\x04\x00\x12\x40", "device-id": b"\x90\x46\x00\x00", "enable-hdmi20": b"\x01\x00\x00\x00"},
"8086:4692_dgpu": {"AAPL,ig-platform-id": b"\x04\x00\x12\x40", "device-id": b"\x92\x46\x00\x00", "enable-hdmi20": b"\x01\x00\x00\x00"},
}
INTEL_IGPU_PCI_PATH = "PciRoot(0x0)/Pci(0x2,0x0)"
# Primary keys are now Codec Names. PCI IDs are secondary/fallback.
AUDIO_LAYOUTS = {
# Intel HDA - common controllers, layout 1 is a frequent default
"8086:a170": 1, # Sunrise Point-H HD Audio
"8086:a2f0": 1, # Series 200 HD Audio
"8086:a348": 3, # Cannon Point-LP HD Audio
"8086:f0c8": 3, # Comet Lake HD Audio
# Realtek Codecs (often on Intel HDA controller, actual codec detection is harder)
# If a Realtek PCI ID is found for audio, one of these layouts might work.
# This map is simplified; usually, you detect the codec name (e.g. ALC255, ALC892)
"10ec:0255": 3, # ALC255 Example
"10ec:0892": 1, # ALC892 Example
# Codec Names (Prefer these) - Extracted from "Codec: Realtek ALCXXX" or similar
"Realtek ALC221": 11, "Realtek ALC233": 11, "Realtek ALC235": 28,
"Realtek ALC255": 11, "Realtek ALC256": 11, "Realtek ALC257": 11,
"Realtek ALC269": 11, "Realtek ALC271": 11, "Realtek ALC282": 11,
"Realtek ALC283": 11, "Realtek ALC285": 11, "Realtek ALC289": 11,
"Realtek ALC295": 11,
"Realtek ALC662": 5, "Realtek ALC671": 11,
"Realtek ALC887": 7, "Realtek ALC888": 7,
"Realtek ALC892": 1, "Realtek ALC897": 11, # Common, 11 often works
"Realtek ALC1150": 1,
"Realtek ALC1200": 7,
"Realtek ALC1220": 7, "Realtek ALC1220-VB": 7, # VB variant often uses same layouts
"Conexant CX20756": 3, # Example Conexant
# Fallback PCI IDs for generic Intel HDA controllers if codec name not matched
"pci_8086:a170": 1, # Sunrise Point-H HD Audio
"pci_8086:a2f0": 1, # Series 200 HD Audio (Kaby Lake)
"pci_8086:a348": 3, # Cannon Point-LP HD Audio
"pci_8086:f0c8": 3, # Comet Lake HD Audio (Series 400)
"pci_8086:43c8": 11,# Tiger Lake-H HD Audio (Series 500)
"pci_8086:7ad0": 11,# Alder Lake PCH-P HD Audio
}
AUDIO_PCI_PATH_FALLBACK = "PciRoot(0x0)/Pci(0x1f,0x3)" # Common, but needs verification
AUDIO_PCI_PATH_FALLBACK = "PciRoot(0x0)/Pci(0x1f,0x3)"
ETHERNET_KEXT_MAP = {
"8086:15b8": "IntelMausi.kext", # Intel I219-V
"8086:153a": "IntelMausi.kext", # Intel I217-V
"8086:10f0": "IntelMausi.kext", # Intel 82579LM
"10ec:8168": "RealtekRTL8111.kext", # Realtek RTL8111/8168
"10ec:8111": "RealtekRTL8111.kext",
"14e4:1686": "AirportBrcmFixup.kext", # Example Broadcom Wi-Fi (though kext name might be BrcmPatchRAM related)
# Proper Ethernet kext for Broadcom depends on model e.g. AppleBCM5701Ethernet.kext
ETHERNET_KEXT_MAP = { # vendor_id:device_id -> kext_name
"8086:15b8": "IntelMausi.kext", "8086:153a": "IntelMausi.kext", "8086:10f0": "IntelMausi.kext",
"8086:15be": "IntelMausi.kext", "8086:0d4f": "IntelMausi.kext", "8086:15b7": "IntelMausi.kext", # I219-V(3)
"8086:1a1c": "IntelMausi.kext", # Comet Lake-S vPro (I219-LM)
"10ec:8168": "RealtekRTL8111.kext", "10ec:8111": "RealtekRTL8111.kext",
"10ec:2502": "LucyRTL8125Ethernet.kext", # Realtek RTL8125 2.5GbE
"10ec:2600": "LucyRTL8125Ethernet.kext", # Realtek RTL8125B 2.5GbE
"8086:15ec": "AppleIntelI210Ethernet.kext", # I225-V (Often needs AppleIGB.kext or specific patches)
"8086:15f3": "AppleIntelI210Ethernet.kext", # I225-V / I226-V
"14e4:1686": "AirportBrcmFixup.kext", # Placeholder for Broadcom Wi-Fi, actual kext depends on model
}
def _get_pci_path_for_device(pci_devices, target_vendor_id, target_device_id_prefix):
# This is a placeholder. A real implementation would need to parse lspci's bus info (00:1f.3)
# and convert that to an OpenCore PciRoot string. For now, uses fallbacks.
# Example: lspci output "00:1f.3 Audio device [0403]: Intel Corporation Sunrise Point-H HD Audio [8086:a170] (rev 31)"
# PciRoot(0x0)/Pci(0x1f,0x3)
# For now, this function is not fully implemented and we'll use hardcoded common paths.
return None
def enhance_config_plist(plist_path: str, target_macos_version_name: str, progress_callback=None) -> bool:
"""
Loads a config.plist, gathers hardware info (Linux only for now),
applies targeted enhancements, and saves it back.
Args:
plist_path: Path to the config.plist file.
target_macos_version_name: e.g., "Sonoma", "High Sierra". Used for version-specific logic.
progress_callback: Optional function to report progress.
Returns:
True if successful, False otherwise.
"""
def _report(msg):
if progress_callback: progress_callback(f"[PlistModifier] {msg}")
else: print(f"[PlistModifier] {msg}")
_report(f"Starting config.plist enhancement for: {plist_path}")
_report(f"Target macOS version: {target_macos_version_name}")
if not os.path.exists(plist_path):
_report(f"Error: Plist file not found at {plist_path}")
return False
# Create a backup
# ... (backup logic same as before) ...
_report(f"Starting config.plist enhancement for: {plist_path}"); _report(f"Target macOS version: {target_macos_version_name.lower()}")
if not os.path.exists(plist_path): _report(f"Error: Plist file not found at {plist_path}"); return False
backup_plist_path = plist_path + ".backup"
try: shutil.copy2(plist_path, backup_plist_path); _report(f"Created backup: {backup_plist_path}")
except Exception as e: _report(f"Error creating backup for {plist_path}: {e}. Proceeding cautiously.")
config_data = {};
try:
shutil.copy2(plist_path, backup_plist_path)
_report(f"Created backup of config.plist at: {backup_plist_path}")
except Exception as e:
_report(f"Error creating backup for {plist_path}: {e}. Proceeding without backup.")
# Decide if this should be a fatal error for the modification step
# For now, we'll proceed cautiously.
with open(plist_path, 'rb') as f: config_data = plistlib.load(f)
except Exception as e: _report(f"Error loading plist {plist_path}: {e}"); return False
if platform.system() != "Linux":
_report("Hardware detection for plist enhancement currently only supported on Linux. Skipping hardware-specific modifications.")
# Still load and save to ensure plist is valid, but no hardware changes.
try:
with open(plist_path, 'rb') as f: config_data = plistlib.load(f)
# No changes made, so just confirm it's okay.
# If we wanted to ensure it's valid and resave (pretty print), we could do:
# with open(plist_path, 'wb') as f: plistlib.dump(config_data, f, sort_keys=True)
_report("Plist not modified on non-Linux host (hardware detection skipped).")
return True
except Exception as e:
_report(f"Error processing plist file {plist_path} even without hardware changes: {e}")
return False
pci_devices = []; cpu_info = {}; audio_codecs_detected = []
if platform.system() == "Linux":
pci_devices = get_pci_devices_info(); cpu_info = get_cpu_info(); audio_codecs_detected = get_audio_codecs()
if not pci_devices: _report("Warning: Could not retrieve PCI hardware info on Linux.")
if not audio_codecs_detected: _report("Warning: Could not detect specific audio codecs on Linux.")
else: _report("Hardware detection for plist enhancement Linux-host only. Skipping hardware-specific mods.")
try:
with open(plist_path, 'rb') as f:
config_data = plistlib.load(f)
except Exception as e:
_report(f"Error loading plist file {plist_path} for modification: {e}")
return False
pci_devices = get_pci_devices_info()
cpu_info = get_cpu_info() # Currently not used in logic below but fetched
if not pci_devices: # cpu_info might be empty too
_report("Could not retrieve PCI hardware information. Skipping most plist enhancements.")
# Still try to save (pretty-print/validate) the plist if loaded.
try:
with open(plist_path, 'wb') as f: plistlib.dump(config_data, f, sort_keys=True)
_report("Plist re-saved (no hardware changes applied due to missing PCI info).")
return True
except Exception as e:
_report(f"Error re-saving plist file {plist_path}: {e}")
return False
# Ensure sections exist
dev_props = config_data.setdefault("DeviceProperties", {}).setdefault("Add", {})
kernel_add = config_data.setdefault("Kernel", {}).setdefault("Add", [])
nvram_add = config_data.setdefault("NVRAM", {}).setdefault("Add", {})
boot_args_uuid = "7C436110-AB2A-4BBB-A880-FE41995C9F82"
boot_args_section = nvram_add.setdefault(boot_args_uuid, {})
current_boot_args_str = boot_args_section.get("boot-args", "")
boot_args = set(current_boot_args_str.split())
modified = False # Flag to track if any changes were made
current_boot_args_str = boot_args_section.get("boot-args", ""); boot_args = set(current_boot_args_str.split())
modified_plist = False
# 1. Intel iGPU Enhancement
intel_igpu_device_id_on_host = None
for dev in pci_devices:
if dev['type'] == 'VGA' and dev['vendor_id'] == '8086': # Intel iGPU
intel_igpu_device_id_on_host = dev['device_id']
lookup_key = f"{dev['vendor_id']}:{dev['device_id']}"
if lookup_key in INTEL_IGPU_DEFAULTS:
_report(f"Found Intel iGPU: {dev['description']}. Applying properties.")
igpu_path_properties = dev_props.setdefault(INTEL_IGPU_PCI_PATH, {})
for key, value in INTEL_IGPU_DEFAULTS[lookup_key].items():
igpu_path_properties[key] = value
_report(f" Set {INTEL_IGPU_PCI_PATH} -> {key}")
else:
_report(f"Found Intel iGPU: {dev['description']} ({lookup_key}) but no default properties defined for it.")
break # Assume only one active iGPU for primary display configuration
# 1. Intel iGPU
intel_igpu_on_host = next((dev for dev in pci_devices if dev['type'] == 'VGA' and dev['vendor_id'] == '8086'), None)
dgpu_present = any(dev['type'] == 'VGA' and dev['vendor_id'] != '8086' for dev in pci_devices)
# 2. Audio Enhancement (Layout ID)
audio_device_path_in_plist = AUDIO_PCI_PATH_FALLBACK # Default, may need to be dynamic
for dev in pci_devices:
if dev['type'] == 'Audio':
lookup_key = f"{dev['vendor_id']}:{dev['device_id']}"
if lookup_key in AUDIO_LAYOUTS:
layout_id = AUDIO_LAYOUTS[lookup_key]
_report(f"Found Audio device: {dev['description']}. Setting layout-id to {layout_id}.")
audio_path_properties = dev_props.setdefault(audio_device_path_in_plist, {})
new_layout_data = plistlib.Data(layout_id.to_bytes(1, 'little')) # Common layout IDs are small integers
if audio_path_properties.get("layout-id") != new_layout_data:
audio_path_properties["layout-id"] = new_layout_data
_report(f" Set {audio_device_path_in_plist} -> layout-id = {layout_id}")
modified = True
for kext in kernel_add: # Ensure AppleALC is enabled
if isinstance(kext, dict) and kext.get("BundlePath") == "AppleALC.kext":
if not kext.get("Enabled", False):
kext["Enabled"] = True; _report(" Ensured AppleALC.kext is enabled."); modified = True
break
if intel_igpu_on_host:
lookup_key = f"{intel_igpu_on_host['vendor_id']}:{intel_igpu_on_host['device_id']}"
# For Alder Lake, if a dGPU is also present, a different platform-id might be preferred.
if lookup_key.startswith("8086:46") and dgpu_present: # Basic check for Alder Lake iGPU + dGPU
lookup_key_dgpu = f"{lookup_key}_dgpu"
if lookup_key_dgpu in INTEL_IGPU_DEFAULTS:
lookup_key = lookup_key_dgpu
_report(f"Intel Alder Lake iGPU ({intel_igpu_on_host['description']}) detected with a dGPU. Using dGPU-specific properties if available.")
if lookup_key in INTEL_IGPU_DEFAULTS:
_report(f"Applying properties for Intel iGPU: {intel_igpu_on_host['description']} ({lookup_key}).")
igpu_path_properties = dev_props.setdefault(INTEL_IGPU_PCI_PATH, {})
for key, value in INTEL_IGPU_DEFAULTS[lookup_key].items():
if igpu_path_properties.get(key) != value: igpu_path_properties[key] = value; _report(f" Set {INTEL_IGPU_PCI_PATH} -> {key}"); modified_plist = True
else: _report(f"Found Intel iGPU: {intel_igpu_on_host['description']} ({lookup_key}) but no default properties in map.")
# 2. Audio Enhancement - Prioritize detected codec name
audio_device_pci_path_to_patch = AUDIO_PCI_PATH_FALLBACK # Default
audio_layout_set = False
if audio_codecs_detected:
_report(f"Detected audio codecs: {audio_codecs_detected}")
for codec_name_full in audio_codecs_detected:
# Try to match known parts of codec names, e.g. "Realtek ALC897" from "Codec: Realtek ALC897"
# Or "ALC897" if that's how it's stored in AUDIO_LAYOUTS keys
for known_codec_key, layout_id in AUDIO_LAYOUTS.items():
if not known_codec_key.startswith("pci_"): # Ensure we are checking codec names, not PCI IDs
# Simple substring match or more specific regex
# Example: "Realtek ALC255" should match "ALC255" if key is "ALC255"
# Or if key is "Realtek ALC255" it matches directly
# For "Codec: Realtek ALC255" we might want to extract "Realtek ALC255"
# Attempt to extract the core codec part (e.g., "ALC255", "CX20756")
simple_codec_name_match = re.search(r"(ALC\d{3,4}(?:-VB)?|CX\d{4,})", codec_name_full, re.IGNORECASE)
simple_codec_name = simple_codec_name_match.group(1) if simple_codec_name_match else None
if (known_codec_key in codec_name_full) or \
(simple_codec_name and known_codec_key in simple_codec_name) or \
(known_codec_key.replace("Realtek ", "") in codec_name_full.replace("Realtek ", "")): # Try matching without "Realtek "
_report(f"Matched Audio Codec: '{codec_name_full}' (using key '{known_codec_key}'). Setting layout-id to {layout_id}.")
audio_path_properties = dev_props.setdefault(audio_device_pci_path_to_patch, {})
new_layout_data = plistlib.Data(layout_id.to_bytes(1, 'little'))
if audio_path_properties.get("layout-id") != new_layout_data:
audio_path_properties["layout-id"] = new_layout_data; _report(f" Set {audio_device_pci_path_to_patch} -> layout-id = {layout_id}"); modified_plist = True
audio_layout_set = True; break
if audio_layout_set: break
if not audio_layout_set: # Fallback to PCI ID of audio controller
_report("No specific audio codec match found or no codecs detected. Falling back to PCI ID for audio controller.")
for dev in pci_devices:
if dev['type'] == 'Audio':
lookup_key = f"pci_{dev['vendor_id']}:{dev['device_id']}" # PCI ID keys are prefixed
if lookup_key in AUDIO_LAYOUTS:
layout_id = AUDIO_LAYOUTS[lookup_key]
_report(f"Found Audio device (PCI): {dev['description']}. Setting layout-id to {layout_id} via PCI ID map.")
audio_path_properties = dev_props.setdefault(audio_device_pci_path_to_patch, {})
new_layout_data = plistlib.Data(layout_id.to_bytes(1, 'little'))
if audio_path_properties.get("layout-id") != new_layout_data:
audio_path_properties["layout-id"] = new_layout_data; _report(f" Set {audio_device_pci_path_to_patch} -> layout-id = {layout_id}"); modified_plist = True
audio_layout_set = True; break
if audio_layout_set: # Common action if any layout was set
for kext_entry in kernel_add:
if isinstance(kext_entry, dict) and kext_entry.get("BundlePath") == "AppleALC.kext":
if not kext_entry.get("Enabled", False): kext_entry["Enabled"] = True; _report(" Ensured AppleALC.kext is enabled."); modified_plist = True
break
# 3. Ethernet Kext Enablement
# 3. Ethernet Kext Enablement (same logic as before)
for dev in pci_devices:
if dev['type'] == 'Ethernet':
lookup_key = f"{dev['vendor_id']}:{dev['device_id']}"
if lookup_key in ETHERNET_KEXT_MAP:
kext_name = ETHERNET_KEXT_MAP[lookup_key]; _report(f"Found Ethernet device: {dev['description']}. Will ensure {kext_name} is enabled.")
kext_found_and_enabled_or_modified = False
kext_name = ETHERNET_KEXT_MAP[lookup_key]; _report(f"Found Ethernet: {dev['description']}. Ensuring {kext_name} is enabled.")
kext_modified_in_plist = False
for kext_entry in kernel_add:
if isinstance(kext_entry, dict) and kext_entry.get("BundlePath") == kext_name:
if not kext_entry.get("Enabled", False):
kext_entry["Enabled"] = True; _report(f" Enabled {kext_name}."); modified = True
else:
_report(f" {kext_name} already enabled.")
kext_found_and_enabled_or_modified = True; break
if not kext_found_and_enabled_or_modified: _report(f" Warning: {kext_name} for {dev['description']} not in Kernel->Add.")
if not kext_entry.get("Enabled", False): kext_entry["Enabled"] = True; _report(f" Enabled {kext_name}."); modified_plist = True
else: _report(f" {kext_name} already enabled.")
kext_modified_in_plist = True; break
if not kext_modified_in_plist: _report(f" Warning: {kext_name} for {dev['description']} not in Kernel->Add list of config.plist.")
break
# 4. NVIDIA GTX 970 Specific Adjustments
gtx_970_present = any(dev['vendor_id'] == '10de' and dev['device_id'] == '13c2' for dev in pci_devices)
if gtx_970_present:
_report("NVIDIA GTX 970 detected.")
is_high_sierra_or_older = target_macos_version_name.lower() in ["high sierra"]
original_boot_args_len = len(boot_args) # To check if boot_args actually change
if is_high_sierra_or_older:
high_sierra_and_older_versions = ["high sierra", "sierra", "el capitan"]
is_high_sierra_or_older_target = target_macos_version_name.lower() in high_sierra_and_older_versions
original_boot_args_set = set(boot_args)
if is_high_sierra_or_older_target:
boot_args.add('nvda_drv=1'); boot_args.discard('nv_disable=1')
_report(" Configured for NVIDIA Web Drivers (High Sierra target).")
else:
_report(" Configured for NVIDIA Web Drivers (High Sierra or older target).")
else: # Mojave and newer
boot_args.discard('nvda_drv=1')
if intel_igpu_device_id_on_host:
boot_args.add('nv_disable=1'); _report(f" Added nv_disable=1 for {target_macos_version_name} to prioritize iGPU.")
if intel_igpu_on_host:
boot_args.add('nv_disable=1')
_report(f" Added nv_disable=1 for {target_macos_version_name} to prioritize detected host iGPU over GTX 970.")
else:
boot_args.discard('nv_disable=1'); _report(f" GTX 970 likely only GPU for {target_macos_version_name}. `nv_disable=1` not forced.")
# Check if boot_args actually changed before setting modified = True
if len(boot_args) != original_boot_args_len or ' '.join(sorted(list(boot_args))) != current_boot_args_str : modified = True
boot_args.discard('nv_disable=1')
_report(f" GTX 970 is likely only GPU. `nv_disable=1` not forced for {target_macos_version_name}. Basic display expected.")
if boot_args != original_boot_args_set: modified_plist = True
final_boot_args = ' '.join(sorted(list(boot_args)))
if final_boot_args != current_boot_args_str: # Check if boot-args actually changed
boot_args_section['boot-args'] = final_boot_args
_report(f"Updated boot-args to: '{final_boot_args}'")
modified = True # Ensure modified is true if boot_args changed
final_boot_args_str = ' '.join(sorted(list(boot_args)))
if boot_args_section.get('boot-args') != final_boot_args_str:
boot_args_section['boot-args'] = final_boot_args_str
_report(f"Updated boot-args to: '{final_boot_args_str}'")
modified_plist = True
if not modified:
_report("No changes made to config.plist based on detected hardware or existing settings.")
return True # Successful in the sense that no changes were needed or applied.
if not modified_plist:
_report("No changes made to config.plist based on detected hardware or existing settings were different from defaults.")
# If no hardware changes on non-Linux, this is expected.
if platform.system() != "Linux" and not pci_devices : return True # No error, just no action
# Save the modified plist
try:
with open(plist_path, 'wb') as f:
plistlib.dump(config_data, f, sort_keys=True)
_report(f"Successfully saved enhanced config.plist to {plist_path}")
plistlib.dump(config_data, f, sort_keys=True, fmt=plistlib.PlistFormat.XML) # Ensure XML format
_report(f"Successfully saved config.plist to {plist_path}")
return True
except Exception as e:
except Exception as e: # ... (restore backup logic same as before)
_report(f"Error saving modified plist file {plist_path}: {e}")
_report(f"Attempting to restore backup to {plist_path}...")
try:
shutil.copy2(backup_plist_path, plist_path)
_report("Restored backup successfully.")
except Exception as backup_error:
_report(f"CRITICAL: FAILED TO RESTORE BACKUP. {plist_path} may be corrupt. Backup is at {backup_plist_path}. Error: {backup_error}")
try: shutil.copy2(backup_plist_path, plist_path); _report("Restored backup successfully.")
except Exception as backup_error: _report(f"CRITICAL: FAILED TO RESTORE BACKUP. {plist_path} may be corrupt. Backup is at {backup_plist_path}. Error: {backup_error}")
return False
# if __name__ == '__main__': (Keep the same test block as before)
# if __name__ == '__main__': (Keep the same test block as before, ensure dummy data for kexts is complete)
if __name__ == '__main__':
print("Plist Modifier Standalone Test")
print("Plist Modifier Standalone Test") # ... (rest of test block as in previous version)
dummy_plist_path = "test_config.plist"
dummy_data = {
"DeviceProperties": {"Add": {}},
"Kernel": {"Add": [
{"BundlePath": "Lilu.kext", "Enabled": True, "Arch": "Any", "Comment": "", "ExecutablePath": "Contents/MacOS/Lilu", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"BundlePath": "WhateverGreen.kext", "Enabled": True, "Arch": "Any", "Comment": "", "ExecutablePath": "Contents/MacOS/WhateverGreen", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"BundlePath": "AppleALC.kext", "Enabled": False, "Arch": "Any", "Comment": "", "ExecutablePath": "Contents/MacOS/AppleALC", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"BundlePath": "IntelMausi.kext", "Enabled": False, "Arch": "Any", "Comment": "", "ExecutablePath": "Contents/MacOS/IntelMausi", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"Arch": "Any", "BundlePath": "Lilu.kext", "Comment": "Lilu", "Enabled": True, "ExecutablePath": "Contents/MacOS/Lilu", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"Arch": "Any", "BundlePath": "WhateverGreen.kext", "Comment": "WG", "Enabled": True, "ExecutablePath": "Contents/MacOS/WhateverGreen", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"Arch": "Any", "BundlePath": "AppleALC.kext", "Comment": "AppleALC", "Enabled": False, "ExecutablePath": "Contents/MacOS/AppleALC", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"Arch": "Any", "BundlePath": "IntelMausi.kext", "Comment": "IntelMausi", "Enabled": False, "ExecutablePath": "Contents/MacOS/IntelMausi", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"Arch": "Any", "BundlePath": "RealtekRTL8111.kext", "Comment": "Realtek", "Enabled": False, "ExecutablePath": "Contents/MacOS/RealtekRTL8111", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
{"Arch": "Any", "BundlePath": "LucyRTL8125Ethernet.kext", "Comment": "LucyRealtek", "Enabled": False, "ExecutablePath": "Contents/MacOS/LucyRTL8125Ethernet", "MaxKernel": "", "MinKernel": "", "PlistPath": "Contents/Info.plist"},
]},
"NVRAM": {"Add": {"7C436110-AB2A-4BBB-A880-FE41995C9F82": {"boot-args": "-v"}}}
"NVRAM": {"Add": {"7C436110-AB2A-4BBB-A880-FE41995C9F82": {"boot-args": "-v debug=0x100"}}}
}
with open(dummy_plist_path, 'wb') as f:
plistlib.dump(dummy_data, f)
with open(dummy_plist_path, 'wb') as f: plistlib.dump(dummy_data, f)
print(f"Created dummy {dummy_plist_path} for testing.")
original_get_pci = get_pci_devices_info; original_get_cpu = get_cpu_info # Store originals
needs_mocking = platform.system() != "Linux"
if not needs_mocking:
try:
get_pci_devices_info()
except Exception:
print("Hardware info functions seem problematic, forcing mock.")
needs_mocking = True
if needs_mocking:
print("Mocking hardware info for non-Linux or if module not loaded properly.")
original_get_pci = get_pci_devices_info; original_get_cpu = get_cpu_info; original_get_audio_codecs = get_audio_codecs
if platform.system() != "Linux":
print("Mocking hardware info for non-Linux.")
get_pci_devices_info = lambda: [
{'type': 'VGA', 'vendor_id': '8086', 'device_id': '3e9b', 'description': 'Intel UHD Graphics 630 (Desktop Coffee Lake)', 'full_lspci_line':''},
{'type': 'Audio', 'vendor_id': '8086', 'device_id': 'a348', 'description': 'Intel Cannon Point-LP HD Audio', 'full_lspci_line':''},
{'type': 'Ethernet', 'vendor_id': '8086', 'device_id': '15b8', 'description': 'Intel I219-V Ethernet', 'full_lspci_line':''},
{'type': 'VGA', 'vendor_id': '8086', 'device_id': '4680', 'description': 'Alder Lake UHD 770', 'full_lspci_line':''},
{'type': 'Audio', 'vendor_id': '8086', 'device_id': '7ad0', 'description': 'Alder Lake PCH-P HD Audio', 'full_lspci_line':''},
{'type': 'Ethernet', 'vendor_id': '10ec', 'device_id': '2502', 'description': 'Realtek RTL8125', 'full_lspci_line':''},
]
get_cpu_info = lambda: {"Model name": "Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz", "Flags": "avx avx2"}
get_cpu_info = lambda: {"Model name": "12th Gen Intel(R) Core(TM) i7-12700K", "Flags": "avx avx2"}
get_audio_codecs = lambda: ["Realtek ALC1220", "Intel Alder Lake-S HDMI"]
success = enhance_config_plist(dummy_plist_path, "Sonoma", print)
print(f"Plist enhancement {'succeeded' if success else 'failed'}.")
if success:
with open(dummy_plist_path, 'rb') as f:
modified_data = plistlib.load(f)
print("\n--- Modified Plist Content (first level keys) ---")
for k,v in modified_data.items(): print(f"{k}: {type(v)}")
if needs_mocking:
get_pci_devices_info = original_get_pci; get_cpu_info = original_get_cpu
print("\n--- Testing with Sonoma (should enable iGPU, audio [ALC1220 layout 7], ethernet [LucyRTL8125]) ---")
success_sonoma = enhance_config_plist(dummy_plist_path, "Sonoma", print)
print(f"Plist enhancement for Sonoma {'succeeded' if success_sonoma else 'failed'}.")
if success_sonoma:
with open(dummy_plist_path, 'rb') as f: modified_data = plistlib.load(f)
print(f" Sonoma boot-args: {modified_data.get('NVRAM',{}).get('Add',{}).get(boot_args_uuid,{}).get('boot-args')}")
print(f" Sonoma iGPU props: {modified_data.get('DeviceProperties',{}).get('Add',{}).get(INTEL_IGPU_PCI_PATH)}")
print(f" Sonoma Audio props: {modified_data.get('DeviceProperties',{}).get('Add',{}).get(AUDIO_PCI_PATH_FALLBACK)}")
for kext in modified_data.get("Kernel",{}).get("Add",[]):
if "LucyRTL8125Ethernet.kext" in kext.get("BundlePath",""): print(f" LucyRTL8125Ethernet.kext Enabled: {kext.get('Enabled')}")
if "AppleALC.kext" in kext.get("BundlePath",""): print(f" AppleALC.kext Enabled: {kext.get('Enabled')}")
if platform.system() != "Linux":
get_pci_devices_info = original_get_pci; get_cpu_info = original_get_cpu; get_audio_codecs = original_get_audio_codecs
if os.path.exists(dummy_plist_path): os.remove(dummy_plist_path)
if os.path.exists(dummy_plist_path + ".backup"): os.remove(dummy_plist_path + ".backup")

View File

@ -3,16 +3,38 @@ import subprocess
import os
import time
import shutil
import re # For parsing diskpart output
import sys # For checking psutil import
# Try to import QMessageBox for the placeholder, otherwise use a mock for standalone test
try:
from PyQt6.QtWidgets import QMessageBox
except ImportError:
class QMessageBox: # Mock for standalone testing
@staticmethod
def information(*args): print(f"INFO (QMessageBox mock): Title='{args[1]}', Message='{args[2]}'")
@staticmethod
def warning(*args): print(f"WARNING (QMessageBox mock): Title='{args[1]}', Message='{args[2]}'"); return QMessageBox # Mock button press
Yes = 1 # Mock value
No = 0 # Mock value
Cancel = 0 # Mock value
class USBWriterWindows:
def __init__(self, device_id: str, opencore_qcow2_path: str, macos_qcow2_path: str, progress_callback=None):
self.device_id = device_id
# Construct PhysicalDrive path carefully
disk_number_str = "".join(filter(str.isdigit, device_id))
self.physical_drive_path = f"\\\\.\\PhysicalDrive{disk_number_str}"
def __init__(self, device_id: str, opencore_qcow2_path: str, macos_qcow2_path: str,
progress_callback=None, enhance_plist_enabled: bool = False, target_macos_version: str = ""):
# device_id is expected to be the disk number string, e.g., "1", "2" or "disk 1", "disk 2"
self.disk_number = "".join(filter(str.isdigit, device_id))
if not self.disk_number:
raise ValueError(f"Invalid device_id format: '{device_id}'. Must contain a disk number.")
self.physical_drive_path = f"\\\\.\\PhysicalDrive{self.disk_number}"
self.opencore_qcow2_path = opencore_qcow2_path
self.macos_qcow2_path = macos_qcow2_path
self.progress_callback = progress_callback
self.enhance_plist_enabled = enhance_plist_enabled # Not used in Windows writer yet
self.target_macos_version = target_macos_version # Not used in Windows writer yet
pid = os.getpid()
self.opencore_raw_path = f"opencore_temp_{pid}.raw"
@ -24,10 +46,8 @@ class USBWriterWindows:
self.assigned_efi_letter = None
def _report_progress(self, message: str):
if self.progress_callback:
self.progress_callback(message)
else:
print(message)
if self.progress_callback: self.progress_callback(message)
else: print(message)
def _run_command(self, command: list[str] | str, check=True, capture_output=False, timeout=None, shell=False, working_dir=None):
self._report_progress(f"Executing: {command if isinstance(command, str) else ' '.join(command)}")
@ -40,112 +60,183 @@ class USBWriterWindows:
if process.stdout and process.stdout.strip(): self._report_progress(f"STDOUT: {process.stdout.strip()}")
if process.stderr and process.stderr.strip(): self._report_progress(f"STDERR: {process.stderr.strip()}")
return process
except subprocess.TimeoutExpired:
self._report_progress(f"Command timed out after {timeout} seconds.")
raise
except subprocess.CalledProcessError as e:
self._report_progress(f"Error executing (code {e.returncode}): {e.stderr or e.stdout or str(e)}")
raise
except FileNotFoundError:
self._report_progress(f"Error: Command '{command[0] if isinstance(command, list) else command.split()[0]}' not found.")
raise
except subprocess.TimeoutExpired: self._report_progress(f"Command timed out after {timeout} seconds."); raise
except subprocess.CalledProcessError as e: self._report_progress(f"Error executing (code {e.returncode}): {e.stderr or e.stdout or str(e)}"); raise
except FileNotFoundError: self._report_progress(f"Error: Command '{command[0] if isinstance(command, list) else command.split()[0]}' not found."); raise
def _run_diskpart_script(self, script_content: str):
def _run_diskpart_script(self, script_content: str, capture_output_for_parse=False) -> str | None:
script_file_path = f"diskpart_script_{os.getpid()}.txt"
with open(script_file_path, "w") as f:
f.write(script_content)
with open(script_file_path, "w") as f: f.write(script_content)
output_text = "" # Initialize to empty string
try:
self._report_progress(f"Running diskpart script...\n{script_content}")
self._run_command(["diskpart", "/s", script_file_path], capture_output=True, check=False)
self._report_progress(f"Running diskpart script:\n{script_content}")
process = self._run_command(["diskpart", "/s", script_file_path], capture_output=True, check=False)
output_text = (process.stdout or "") + "\n" + (process.stderr or "") # Combine, as diskpart output can be inconsistent
# Check for known success messages, otherwise assume potential issue or log output for manual check.
# This is not a perfect error check for diskpart.
success_indicators = [
"DiskPart successfully", "successfully completed", "succeeded in creating",
"successfully formatted", "successfully assigned"
]
has_success_indicator = any(indicator in output_text for indicator in success_indicators)
has_error_indicator = "Virtual Disk Service error" in output_text or "DiskPart has encountered an error" in output_text
if has_error_indicator:
self._report_progress(f"Diskpart script may have failed. Output:\n{output_text}")
# Optionally raise an error here if script is critical
# raise subprocess.CalledProcessError(1, "diskpart", output=output_text)
elif not has_success_indicator and "There are no partitions on this disk to show" not in output_text: # Allow benign message
self._report_progress(f"Diskpart script output does not clearly indicate success. Output:\n{output_text}")
if capture_output_for_parse:
return output_text
finally:
if os.path.exists(script_file_path): os.remove(script_file_path)
return output_text if capture_output_for_parse else None # Return None if not capturing for parse
def _cleanup_temp_files_and_dirs(self):
self._report_progress("Cleaning up...")
for f_path in self.temp_files_to_clean:
if os.path.exists(f_path): os.remove(f_path)
if os.path.exists(f_path):
try: os.remove(f_path)
except Exception as e: self._report_progress(f"Could not remove temp file {f_path}: {e}")
for d_path in self.temp_dirs_to_clean:
if os.path.exists(d_path): shutil.rmtree(d_path, ignore_errors=True)
if os.path.exists(d_path):
try: shutil.rmtree(d_path, ignore_errors=True)
except Exception as e: self._report_progress(f"Could not remove temp dir {d_path}: {e}")
def _find_available_drive_letter(self) -> str | None:
import string
# This is a placeholder. Actual psutil or ctypes calls would be more robust.
# For now, assume 'S' is available if not 'E' through 'Z'.
return 'S'
import string; used_letters = set()
try:
# Check if psutil was imported by the main application
if 'psutil' in sys.modules:
partitions = sys.modules['psutil'].disk_partitions(all=True)
for p in partitions:
if p.mountpoint and len(p.mountpoint) >= 2 and p.mountpoint[1] == ':': # Check for "X:"
used_letters.add(p.mountpoint[0].upper())
except Exception as e:
self._report_progress(f"Could not list used drive letters with psutil: {e}. Will try common letters.")
for letter in "STUVWXYZGHIJKLMNOPQR":
if letter not in used_letters and letter > 'D': # Avoid A, B, C, D
# Further check if letter is truly available (e.g. subst) - more complex, skip for now
return letter
return None
def check_dependencies(self):
self._report_progress("Checking dependencies (qemu-img, diskpart, robocopy)... DD for Win & 7z are manual checks.")
dependencies = ["qemu-img", "diskpart", "robocopy"]
missing = [dep for dep in dependencies if not shutil.which(dep)]
if missing:
raise RuntimeError(f"Missing dependencies: {', '.join(missing)}. qemu-img needs install & PATH.")
dependencies = ["qemu-img", "diskpart", "robocopy"]; missing = [dep for dep in dependencies if not shutil.which(dep)]
if missing: raise RuntimeError(f"Missing dependencies: {', '.join(missing)}. qemu-img needs install & PATH.")
self._report_progress("Base dependencies found. Ensure 'dd for Windows' and '7z.exe' are in PATH if needed.")
return True
def format_and_write(self) -> bool:
try:
self.check_dependencies()
self._cleanup_temp_files_and_dirs()
self._cleanup_temp_files_and_dirs() # Clean before start
os.makedirs(self.temp_efi_extract_dir, exist_ok=True)
disk_number = "".join(filter(str.isdigit, self.device_id))
self._report_progress(f"WARNING: ALL DATA ON DISK {disk_number} ({self.physical_drive_path}) WILL BE ERASED!")
self._report_progress(f"WARNING: ALL DATA ON DISK {self.disk_number} ({self.physical_drive_path}) WILL BE ERASED!")
self.assigned_efi_letter = self._find_available_drive_letter()
if not self.assigned_efi_letter:
raise RuntimeError("Could not find an available drive letter for EFI.")
self._report_progress(f"Attempting to use letter {self.assigned_efi_letter}: for EFI.")
if not self.assigned_efi_letter: raise RuntimeError("Could not find an available drive letter for EFI.")
self._report_progress(f"Will attempt to assign letter {self.assigned_efi_letter}: to EFI partition.")
script = f"select disk {disk_number}\nclean\nconvert gpt\n"
script += f"create partition efi size=550\nformat fs=fat32 quick label=EFI\nassign letter={self.assigned_efi_letter}\n"
script += "create partition primary label=macOS_USB\nexit\n"
self._run_diskpart_script(script)
diskpart_script_part1 = f"select disk {self.disk_number}\nclean\nconvert gpt\n"
diskpart_script_part1 += f"create partition efi size=550\nformat fs=fat32 quick label=EFI\nassign letter={self.assigned_efi_letter}\n"
diskpart_script_part1 += "create partition primary label=macOS_USB\nexit\n"
self._run_diskpart_script(diskpart_script_part1)
time.sleep(5)
macos_partition_offset_str = "Offset not determined"
macos_partition_number_str = "2 (assumed)"
diskpart_script_detail = f"select disk {self.disk_number}\nselect partition 2\ndetail partition\nexit\n"
detail_output = self._run_diskpart_script(diskpart_script_detail, capture_output_for_parse=True)
if detail_output:
self._report_progress(f"Detail Partition Output:\n{detail_output}")
offset_match = re.search(r"Offset in Bytes\s*:\s*(\d+)", detail_output, re.IGNORECASE)
if offset_match: macos_partition_offset_str = f"{offset_match.group(1)} bytes ({int(offset_match.group(1)) // (1024*1024)} MiB)"
# Try to find the line "Partition X" where X is the number we want
part_num_search = re.search(r"Partition\s+(\d+)\s*\n\s*Type", detail_output, re.IGNORECASE | re.MULTILINE)
if part_num_search:
macos_partition_number_str = part_num_search.group(1)
self._report_progress(f"Determined macOS partition number: {macos_partition_number_str}")
else: # Fallback if the above specific regex fails
# Look for lines like "Partition 2", "Type : xxxxx"
# This is brittle if diskpart output format changes
partition_lines = [line for line in detail_output.splitlines() if "Partition " in line and "Type :" in line]
if len(partition_lines) > 0 : # Assuming the one we want is the last "Partition X" before other details
last_part_match = re.search(r"Partition\s*(\d+)", partition_lines[-1])
if last_part_match: macos_partition_number_str = last_part_match.group(1)
self._report_progress(f"Converting OpenCore QCOW2 to RAW: {self.opencore_raw_path}")
self._run_command(["qemu-img", "convert", "-O", "raw", self.opencore_qcow2_path, self.opencore_raw_path])
self._report_progress("Extracting EFI files (using 7z if available)...")
if shutil.which("7z"):
# Simplified 7z call, assumes EFI folder is at root of first partition image by 7z
self._run_command([
"7z", "x", self.opencore_raw_path,
f"-o{self.temp_efi_extract_dir}", "EFI", "-r", "-y"
], check=False)
self._report_progress("Attempting EFI extraction using 7-Zip...")
self._run_command(["7z", "x", self.opencore_raw_path, f"-o{self.temp_efi_extract_dir}", "EFI", "-r", "-y"], check=False)
source_efi_folder = os.path.join(self.temp_efi_extract_dir, "EFI")
if not os.path.isdir(source_efi_folder):
# Fallback: check if files were extracted to temp_efi_extract_dir directly
if os.path.exists(os.path.join(self.temp_efi_extract_dir, "BOOTX64.EFI")):
source_efi_folder = self.temp_efi_extract_dir
else:
raise RuntimeError("Could not extract EFI folder using 7-Zip.")
if os.path.exists(os.path.join(self.temp_efi_extract_dir, "BOOTX64.EFI")): source_efi_folder = self.temp_efi_extract_dir
else: raise RuntimeError("Could not extract EFI folder using 7-Zip from OpenCore image.")
target_efi_on_usb = f"{self.assigned_efi_letter}:\\EFI"
if not os.path.exists(f"{self.assigned_efi_letter}:\\"):
raise RuntimeError(f"EFI partition {self.assigned_efi_letter}: not accessible after assign.")
if not os.path.exists(f"{self.assigned_efi_letter}:\\"): # Check if drive letter is mounted
time.sleep(3) # Wait a bit more
if not os.path.exists(f"{self.assigned_efi_letter}:\\"):
# Attempt to re-assign just in case
self._report_progress(f"Re-assigning drive letter {self.assigned_efi_letter} to EFI partition...")
reassign_script = f"select disk {self.disk_number}\nselect partition 1\nassign letter={self.assigned_efi_letter}\nexit\n"
self._run_diskpart_script(reassign_script)
time.sleep(3)
if not os.path.exists(f"{self.assigned_efi_letter}:\\"):
raise RuntimeError(f"EFI partition {self.assigned_efi_letter}: not accessible after assign/re-assign.")
if not os.path.exists(target_efi_on_usb): os.makedirs(target_efi_on_usb, exist_ok=True)
self._report_progress(f"Copying EFI files to {target_efi_on_usb}")
self._run_command(["robocopy", source_efi_folder, target_efi_on_usb, "/E", "/S", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP"], check=True)
else:
raise RuntimeError("7-Zip CLI (7z.exe) not found in PATH for EFI extraction.")
self._report_progress(f"Copying EFI files from '{source_efi_folder}' to '{target_efi_on_usb}'")
self._run_command(["robocopy", source_efi_folder, target_efi_on_usb, "/E", "/S", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP", "/XO"], check=True) # Added /XO to exclude older
else: raise RuntimeError("7-Zip CLI (7z.exe) not found in PATH for EFI extraction.")
self._report_progress(f"Converting macOS QCOW2 to RAW: {self.macos_raw_path}")
self._run_command(["qemu-img", "convert", "-O", "raw", self.macos_qcow2_path, self.macos_raw_path])
self._report_progress("Windows RAW macOS image writing is a placeholder.")
self._report_progress(f"RAW image at: {self.macos_raw_path}")
self._report_progress(f"Target physical drive: {self.physical_drive_path}")
self._report_progress("User needs to use 'dd for Windows' to write the above raw image to the second partition of the USB drive.")
# Placeholder for actual dd command, as it's complex and risky to automate fully without specific dd tool knowledge
# E.g. dd if=self.macos_raw_path of=\\\\.\\PhysicalDriveX --partition 2 bs=4M status=progress (syntax depends on dd variant)
abs_macos_raw_path = os.path.abspath(self.macos_raw_path)
guidance_message = (
f"RAW macOS image conversion complete:\n'{abs_macos_raw_path}'\n\n"
f"Target USB: Disk {self.disk_number} (Path: {self.physical_drive_path})\n"
f"The target macOS partition is: Partition {macos_partition_number_str}\n"
f"Calculated Offset (approx): {macos_partition_offset_str}\n\n"
"MANUAL STEP REQUIRED using a 'dd for Windows' utility:\n"
"1. Open Command Prompt or PowerShell AS ADMINISTRATOR.\n"
"2. Carefully identify your 'dd for Windows' utility and its exact syntax.\n"
" Common utilities: dd from SUSE (recommended), dd by chrysocome.net.\n"
"3. Example 'dd' command (SYNTAX VARIES GREATLY BETWEEN DD TOOLS!):\n"
f" `dd if=\"{abs_macos_raw_path}\" of={self.physical_drive_path} bs=4M --progress`\n"
" (This example writes to the whole disk, which might be okay if your macOS partition is the first primary after EFI and occupies the rest). \n"
" A SAFER (but more complex) approach if your 'dd' supports it, is to write directly to the partition's OFFSET (requires dd that handles PhysicalDrive offsets correctly):\n"
f" `dd if=\"{abs_macos_raw_path}\" of={self.physical_drive_path} seek=<PARTITION_OFFSET_IN_BLOCKS_OR_BYTES> bs=<YOUR_BLOCK_SIZE> ...`\n"
" (The 'seek' parameter and its units depend on your dd tool. The offset from diskpart is in bytes.)\n\n"
"VERIFY YOUR DD COMMAND AND TARGETS BEFORE EXECUTION. DATA LOSS IS LIKELY IF INCORRECT.\n"
"This tool cannot automate this step due to the variability and risks of 'dd' utilities on Windows."
)
self._report_progress(f"GUIDANCE:\n{guidance_message}")
QMessageBox.information(None, "Manual macOS Image Write Required", guidance_message)
self._report_progress("Windows USB writing process (EFI part done, macOS part placeholder) completed.")
self._report_progress("Windows USB writing (EFI part automated, macOS part manual guidance provided) process initiated.")
return True
except Exception as e:
self._report_progress(f"Error during Windows USB writing: {e}")
import traceback
self._report_progress(traceback.format_exc())
import traceback; self._report_progress(traceback.format_exc())
return False
finally:
if self.assigned_efi_letter:
@ -155,20 +246,21 @@ class USBWriterWindows:
if __name__ == '__main__':
if platform.system() != "Windows":
print("This script is for Windows standalone testing."); exit(1)
print("USB Writer Windows Standalone Test - Partial Implementation")
# Requires Admin privileges
mock_oc = "mock_oc_win.qcow2"
mock_mac = "mock_mac_win.qcow2"
print("USB Writer Windows Standalone Test - Improved Guidance")
mock_oc = "mock_oc_win.qcow2"; mock_mac = "mock_mac_win.qcow2"
# Ensure qemu-img is available for mock file creation
if not shutil.which("qemu-img"):
print("qemu-img not found, cannot create mock files for test. Exiting.")
exit(1)
if not os.path.exists(mock_oc): subprocess.run(["qemu-img", "create", "-f", "qcow2", mock_oc, "384M"])
if not os.path.exists(mock_mac): subprocess.run(["qemu-img", "create", "-f", "qcow2", mock_mac, "1G"])
disk_id = input("Enter target disk ID (e.g., '1' for 'disk 1'). WIPES DISK: ")
if not disk_id.isdigit(): print("Invalid disk ID."); exit(1)
actual_disk_id = f"disk {disk_id}" # This is how it's used in the class, but the input is just the number.
disk_id_input = input("Enter target disk NUMBER (e.g., '1' for 'disk 1'). THIS DISK WILL BE WIPES: ")
if not disk_id_input.isdigit(): print("Invalid disk number."); exit(1)
if input(f"Sure to wipe disk {disk_id}? (yes/NO): ").lower() == 'yes':
# Pass the disk number string to the constructor, it will form \\.\PhysicalDriveX
writer = USBWriterWindows(disk_id, mock_oc, mock_mac, print)
if input(f"Sure to wipe disk {disk_id_input}? (yes/NO): ").lower() == 'yes':
# USBWriterWindows expects just the disk number string (e.g., "1")
writer = USBWriterWindows(disk_id_input, mock_oc, mock_mac, print)
writer.format_and_write()
else: print("Cancelled.")