diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4d718a --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Compiled Swift binaries +createinstalliso-interactive + +# macOS application bundles +*.app + +# Swift build artifacts +*.swiftmodule +*.swiftdoc +*.dSYM/ + +# macOS system files +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# AppleScript compiled files (keep source) +*.scpt diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..06c0c8a --- /dev/null +++ b/BUILD.md @@ -0,0 +1,48 @@ +# Building the Interactive Swift Version + +## Quick Build + +```bash +# Compile the binary +swiftc -O createinstalliso-interactive.swift -o createinstalliso-interactive + +# Make executable +chmod +x createinstalliso-interactive + +# Run it +./createinstalliso-interactive +``` + +## Create Double-Clickable App (Optional) + +The repository includes `CreateInstallISO-Sudo.applescript` which creates a sudo-enabled app: + +```bash +# Compile the AppleScript into an app bundle +osacompile -o "CreateInstallISO Interactive.app" CreateInstallISO-Sudo.applescript +``` + +This app will: +- Open Terminal automatically +- Prompt for your password with sudo +- Work with all installer types including Sierra 10.12+ +- Support the new "Write ISO to USB Drive" feature (option 7) + +## One-Liner + +```bash +swiftc -O createinstalliso-interactive.swift -o createinstalliso-interactive && chmod +x createinstalliso-interactive && ./createinstalliso-interactive +``` + +## Requirements + +- macOS with Swift compiler (Xcode Command Line Tools) +- Swift 5.0 or later + +## Notes + +- Compiled files are `.gitignore`d (not committed to repository) +- Binary size: ~123KB +- App bundle size: ~200KB + +See **README-SWIFT-INTERACTIVE.md** for complete documentation. diff --git a/CreateInstallISO-Sudo.applescript b/CreateInstallISO-Sudo.applescript new file mode 100644 index 0000000..a05d3cd --- /dev/null +++ b/CreateInstallISO-Sudo.applescript @@ -0,0 +1,11 @@ +#!/usr/bin/osascript +on run + set scriptPath to POSIX path of (path to me) + set scriptDir to do shell script "dirname " & quoted form of scriptPath + set executablePath to scriptDir & "/createinstalliso-interactive" + + tell application "Terminal" + activate + do script "cd " & quoted form of scriptDir & " && sudo " & quoted form of executablePath & "; echo ''; echo 'Press any key to close this window...'; read -n 1; exit" + end tell +end run diff --git a/README-SWIFT-INTERACTIVE.md b/README-SWIFT-INTERACTIVE.md new file mode 100644 index 0000000..dfb1541 --- /dev/null +++ b/README-SWIFT-INTERACTIVE.md @@ -0,0 +1,508 @@ +# createinstalliso - Interactive Swift Version + +**No Commands to Type!** Create bootable macOS ISO images with a simple menu interface. + +--- + +## πŸš€ Quick Start + +### Step 1: Build from Source + +First, compile the app. See **BUILD.md** for detailed instructions, or use this quick command: + +```bash +swiftc -O createinstalliso-interactive.swift -o createinstalliso-interactive && chmod +x createinstalliso-interactive +``` + +### Step 2: Run the App + +**Option 1: Terminal** +```bash +./createinstalliso-interactive + +# Or with sudo (required for macOS 10.12+) +sudo ./createinstalliso-interactive + +# Debug mode (shows detailed process information) +sudo ./createinstalliso-interactive --debug +# or +sudo ./createinstalliso-interactive -d +``` + +**Option 2: Double-Click (optional)** +Create an app bundle for double-clicking. See **BUILD.md** for instructions. + +> **Note:** For macOS 10.12+ installers that require sudo, you must use Terminal with `sudo ./createinstalliso-interactive` or create a sudo-enabled app (see Advanced Usage below). + +--- + +## πŸ“– Complete User Guide + +### Step 1: Launch the App + +**Double-click** `CreateInstallISO Interactive.app` or run `./createinstalliso-interactive` in Terminal. + +You'll see: + +``` +╔════════════════════════════════════════════════════════════════════╗ +β•‘ createinstalliso v1.2.0 β•‘ +β•‘ Create Bootable macOS ISO Images β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +Current Configuration: +──────────────────────────────────────────────────────────────────── +1. Select Installer Application: [Not Selected] +2. Select Output Directory: [Not Selected] +3. Set ISO Name: [Auto-generate] +──────────────────────────────────────────────────────────────────── + +Options: +4. [ ] Patch macOS Sierra Installer (version 12.6.06) +5. [ ] Replace Code Signatures +──────────────────────────────────────────────────────────────────── + +Actions: +6. Create ISO Image +7. Write ISO to USB Drive +8. View System Information +9. Help +0. Exit +──────────────────────────────────────────────────────────────────── + +Select option (0-9): _ +``` + +### Step 2: Select Your Installer (Press 1) + +The app automatically scans `/Applications/` for valid macOS installers: + +``` +Found installer applications: +──────────────────────────────────────────────────────────────────── +1. Install macOS Big Sur +2. Install macOS Monterey +3. Install macOS Sonoma +4. Install macOS Ventura +0. Enter custom path +──────────────────────────────────────────────────────────────────── + +Select option (0-4): 3 +βœ… Selected: Install macOS Sonoma +Press Enter to continue... +``` + +**Tips:** +- Press a number to select from the list +- Press 0 to enter a custom path (e.g., external drive) +- The app validates the installer automatically + +### Step 3: Choose Output Directory (Press 2) + +Pick from common locations or enter a custom path: + +``` +Common locations: +──────────────────────────────────────────────────────────────────── +1. Desktop (/Users/you/Desktop) +2. Downloads (/Users/you/Downloads) +3. Documents (/Users/you/Documents) +4. Current directory (/Users/you/createinstalliso) +5. Enter custom path +──────────────────────────────────────────────────────────────────── + +Select option (1-5): 1 +βœ… Output directory set to Desktop +Press Enter to continue... +``` + +### Step 4: Customize ISO Name (Optional - Press 3) + +``` +Default ISO name: Install macOS Sonoma.iso + +Enter custom ISO name (without .iso extension) +Press Enter to use default name +ISO Name: [press Enter or type custom name] +``` + +### Step 5: Configure Options (Optional - Press 4 or 5) + +**Option 4: Patch macOS Sierra** +- Only needed for defective Sierra installer build 12.6.06 +- Press 4 to toggle: `[ ]` β†’ `[βœ“]` + +**Option 5: Replace Code Signatures** +- For modified installer applications +- Press 5 to toggle: `[ ]` β†’ `[βœ“]` + +### Step 6: Review Configuration + +The main menu now shows your complete configuration: + +``` +Current Configuration: +──────────────────────────────────────────────────────────────────── +1. Select Installer Application: βœ“ + β†’ Install macOS Sonoma + β†’ Type: Big Sur and later (11+) +2. Select Output Directory: βœ“ + β†’ /Users/you/Desktop +3. Set ISO Name: [Auto-generate] +──────────────────────────────────────────────────────────────────── + +Options: +4. [ ] Patch macOS Sierra Installer (version 12.6.06) +5. [ ] Replace Code Signatures +``` + +### Step 7: Create ISO (Press 6) + +``` +Configuration Summary: + Installer: Install macOS Sonoma + Type: Big Sur and later (11+) + Output: /Users/you/Desktop + ISO Name: [Auto-generate] +──────────────────────────────────────────────────────────────────── + +Proceed with ISO creation? [Y/n]: y +``` + +**If sudo is required:** +``` +⚠️ Warning: This installer type requires root privileges +Please run this script with sudo: + sudo ./createinstalliso-interactive +``` + +Exit and restart with: `sudo ./createinstalliso-interactive` + +### Step 8: Write ISO to USB Drive (Press 7) + +After creating an ISO, you can write it directly to a USB drive to create a bootable installer: + +``` +Write ISO to USB Drive + +Available ISO files: +──────────────────────────────────────────────────────────────────── +1. Install macOS Catalina.iso (7.7 GB) +2. Install macOS Sonoma.iso (13.2 GB) +0. Enter custom path +──────────────────────────────────────────────────────────────────── + +Select ISO file: 1 + +Selected ISO: /Users/you/Downloads/Install macOS Catalina.iso + +Scanning for USB drives... + +Available disks: +──────────────────────────────────────────────────────────────────── +/dev/disk0 (internal, physical): +/dev/disk2 (external, physical): + #: TYPE NAME SIZE + 0: FDisk_partition_scheme *16.0 GB disk2 + 1: DOS_FAT_32 USB DRIVE 16.0 GB disk2s1 +──────────────────────────────────────────────────────────────────── + +⚠️ WARNING: This will ERASE ALL DATA on the selected disk! + +Enter disk identifier (e.g., disk2) or 'cancel': disk2 + +You are about to write: + ISO: /Users/you/Downloads/Install macOS Catalina.iso + To: /dev/disk2 + +⚠️ This will PERMANENTLY ERASE ALL DATA on /dev/disk2! + +Type 'YES' to proceed: YES + +ℹ️ Starting USB creation process... + +Unmounting disk... +Writing ISO to USB drive (this may take several minutes)... +Please wait... + +[Progress output...] + +Ejecting disk... + +βœ… USB installer created successfully! +You can now safely remove the USB drive. +``` + +**Important Notes:** +- ⚠️ **All data on the USB drive will be erased** +- Requires sudo privileges (app must be run with sudo) +- Minimum USB size: varies by macOS version (typically 16GB+) +- The process may take 10-30 minutes depending on USB speed +- You must type "YES" in all capitals to confirm +- The USB drive will be automatically ejected when complete + +**Safety Features:** +- Multiple confirmation prompts +- Shows disk list before selection +- Validates disk identifier format +- Cannot proceed without explicit "YES" confirmation + +### Additional Menu Options + +**Option 8: View System Information** +``` +System Information +──────────────────────────────────────────────────────────────────── +macOS Version: 14.2.1 +Running as: regular user +Current Directory: /Users/you/createinstalliso +Home Directory: /Users/you +──────────────────────────────────────────────────────────────────── + +Supported macOS Installers: + β€’ Install Mac OS X Lion + β€’ Install OS X Mountain Lion + ... + β€’ Install macOS Sequoia +``` + +**Option 9: Help** +Shows built-in documentation without leaving the app. + +**Option 0: Exit** +Cleanly exits the program. + +--- + +## πŸ’» Supported macOS Installers + +βœ… Mac OS X Lion 10.7 +βœ… OS X Mountain Lion 10.8 +βœ… OS X Mavericks 10.9 +βœ… OS X Yosemite 10.10 +βœ… OS X El Capitan 10.11 +βœ… macOS Sierra 10.12 +βœ… macOS High Sierra 10.13 +βœ… macOS Mojave 10.14 +βœ… macOS Catalina 10.15 +βœ… macOS Big Sur 11 +βœ… macOS Monterey 12 +βœ… macOS Ventura 13 +βœ… macOS Sonoma 14 +βœ… macOS Sequoia 15 +βœ… macOS Tahoe 26 + +**All installer types supported β€’ Intel & Apple Silicon** + +--- + +## πŸ” Requirements + +- macOS 10.6 (Snow Leopard) or later to run the script +- Valid macOS installer application +- Root privileges (sudo) for Sierra 10.12 and later +- Sufficient disk space (varies by installer, typically 8-25GB) + +--- + +## 🎯 Real-World Example + +**Creating an ISO for macOS Sonoma:** + +1. **Double-click** `CreateInstallISO Interactive.app` +2. **Press 1**, then **3** (select Sonoma) +3. **Press 2**, then **1** (Desktop) +4. **Press 6** (Create ISO) +5. **Press Y** (Confirm) + +**Result:** ISO created in `~/Desktop/Install macOS Sonoma.iso` + +**Creating a bootable USB installer:** + +1. **Run with sudo:** `sudo ./createinstalliso-interactive` +2. **Press 7** (Write ISO to USB Drive) +3. **Select ISO** from the list +4. **Enter disk identifier** (e.g., disk2) +5. **Type YES** to confirm + +**Result:** Bootable USB installer ready to use + +--- + +## πŸ”§ Troubleshooting + +### Debug Mode + +If you encounter issues during ISO creation, enable debug mode to see detailed process information: + +```bash +sudo ./createinstalliso-interactive --debug +# or +sudo ./createinstalliso-interactive -d +``` + +Debug mode shows: +- Script paths and commands being executed +- Process IDs for tracking +- Exit status codes +- Detailed step-by-step progress + +This is helpful for diagnosing process suspension issues or tracking where the ISO creation might be failing. + +### First Time: "Unidentified Developer" Error + +macOS blocks unsigned apps from running. This is normal! + +**Solution:** +1. Right-click (or Ctrl+click) `CreateInstallISO Interactive.app` +2. Select **Open** +3. Click **Open** in the dialog +4. macOS remembers your choice (only needed once!) + +**Alternative:** +1. System Preferences β†’ Security & Privacy +2. Click **Open Anyway** +3. Confirm + +### "Permission Denied" Error + +The compiled binary needs execute permission. + +**Solution:** +```bash +chmod +x createinstalliso-interactive +``` + +### Need Sudo But Using the App? + +The .app runs as regular user. For Sierra and later installers: + +**Solution:** +```bash +# Close the app and run in Terminal: +sudo ./createinstalliso-interactive +``` + +### No Installers Found + +**Possible causes:** +- No installer apps in `/Applications/` +- Installer is on external drive +- Installer is incomplete/corrupted + +**Solution:** +- Move installer to `/Applications/`, or +- Press **0** to enter custom path, or +- Download complete installer from Apple + +### App Opens Then Immediately Closes + +**Cause:** Binary `createinstalliso-interactive` not found + +**Solution:** +- Keep the .app and binary in same folder, or +- Recompile: `swiftc -O createinstalliso-interactive.swift -o createinstalliso-interactive` + +--- + +## πŸ› οΈ Building from Source + +See **BUILD.md** for complete build instructions including: +- Compiling the binary +- Creating the double-clickable app +- One-liner build commands +- Build requirements + +Quick rebuild after changes: +```bash +swiftc -O createinstalliso-interactive.swift -o createinstalliso-interactive +``` + +### Running Without the App + +```bash +# Regular user (for Lion through El Capitan) +./createinstalliso-interactive + +# With sudo (for Sierra and later) +sudo ./createinstalliso-interactive +``` + +### Creating a Sudo-Enabled App + +The standard app runs as a regular user. For Sierra (10.12) and later installers that require root privileges, create a sudo-enabled version: + +**Option A: Using AppleScript (simpler)** + +1. Open **Script Editor** (/Applications/Utilities/) +2. Create new script: +```applescript +set currentPath to do shell script "pwd" +do shell script "cd " & quoted form of currentPath & " && ./createinstalliso-interactive" with administrator privileges +``` +3. File β†’ Export β†’ Format: **Application** +4. Save as `CreateInstallISO Interactive (Sudo).app` in the same folder + +**Option B: Using BUILD.md method** + +See **BUILD.md** for creating a sudo-enabled app that finds the binary automatically. + +**Important:** The sudo-enabled app will prompt for your password when launched and can create ISOs for all macOS versions. + +--- + +## πŸ“‚ What's Included + +| File | Purpose | +|------|---------| +| `createinstalliso-interactive.swift` | Swift source code (compile this!) | +| `createinstalliso` | Original bash script (full functionality) | +| `README-SWIFT-INTERACTIVE.md` | This complete documentation | + +**After compilation, you'll have:** +- `createinstalliso-interactive` - Compiled binary +- `CreateInstallISO Interactive.app` - Double-clickable app (optional) + +> **Note:** Compiled files (`createinstalliso-interactive` and `*.app`) are not included in the repository. You must compile them from source. + +--- + +**⚠️ In Development:** +- Native Swift ISO creation logic + +**Current Behavior:** +When you press "Create ISO", the app acts as a GUI wrapper and directly executes the original bash script with the configured parameters. The interactive configuration is complete and workingβ€”it validates everything, builds the correct command, and runs it for you. + +**Future Updates:** +Will add a native Swift implementation of the ISO creation process to make the app fully self-contained (removing the dependency on the bash script). + +--- + +## πŸ“ License + +GNU General Public License v3.0 or later + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +Copyright (C) 2021-2025 Michael Berger + +--- + +## πŸ™ Credits + +- **Original Author:** Michael Berger +- **Original Script:** bash createinstalliso +- **Swift Interactive Port:** Based on version 1.2.0 + +--- + +## πŸ“ž Support + +**For the Interactive Swift Version:** +- Built-in help: Press **9** in the app +- This documentation: You're reading it! + +**For the Original Bash Script:** +- See original `README.md` +- Contact: michael.berger@gmx.de + diff --git a/createinstalliso b/createinstalliso index 67e0e9d..d2d37f5 100755 --- a/createinstalliso +++ b/createinstalliso @@ -152,6 +152,7 @@ declare +x -r -a EXTERNAL_COMMANDS=( declare +x g_patch_macos_sierra_installer_application declare +x g_replace_code_signatures_in_installer_application +declare +x g_nointeraction_mode declare +x g_user_id declare +x g_example_installer_application_name declare +x g_shell_columns @@ -725,6 +726,7 @@ set_environment_variables() { set_global_variables() { g_patch_macos_sierra_installer_application="false" g_replace_code_signatures_in_installer_application="false" + g_nointeraction_mode="false" unset g_tmp_directory || return 1 unset g_install_esd_mountpoint || return 1 @@ -1773,7 +1775,32 @@ create_install_media() { fi exit_status=0 - { unused="$({ /usr/bin/script -k -q -- /dev/null "${installer_application_path}/Contents/Resources/createinstallmedia" --volume "/Volumes/${install_disk_volname}" "${applicationpath_option_name}" "${applicationpath_option_parameter}" --nointeraction 2>/dev/null > >(parse_create_install_media); } 3>&1 >&4 4>&-)"; } 4>&1 || exit_status="$?" + + # Use direct execution when in nointeraction mode (called from Swift app) to avoid terminal suspension + # Use script wrapper for interactive mode to enable output parsing + if [[ "${g_nointeraction_mode}" == "true" ]]; then + # Direct execution - no script wrapper (avoids suspension from non-interactive contexts) + # Build argument array for createinstallmedia + local +x args=("--volume" "/Volumes/${install_disk_volname}") + if [[ -n "${applicationpath_option_name}" && -n "${applicationpath_option_parameter}" ]]; then + args+=("${applicationpath_option_name}" "${applicationpath_option_parameter}") + fi + args+=("--nointeraction") + + "${installer_application_path}/Contents/Resources/createinstallmedia" "${args[@]}" 2>&1 | while IFS= read -r line; do + echo "$line" + done + exit_status="${PIPESTATUS[0]}" + else + # Original behavior with script wrapper and output parsing for interactive use + local +x args=("--volume" "/Volumes/${install_disk_volname}") + if [[ -n "${applicationpath_option_name}" && -n "${applicationpath_option_parameter}" ]]; then + args+=("${applicationpath_option_name}" "${applicationpath_option_parameter}") + fi + args+=("--nointeraction") + + { unused="$({ /usr/bin/script -k -q -- /dev/null "${installer_application_path}/Contents/Resources/createinstallmedia" "${args[@]}" 2>/dev/null > >(parse_create_install_media); } 3>&1 >&4 4>&-)"; } 4>&1 || exit_status="$?" + fi if has_script_createinstallmedia_been_canceled_by_sigint "${exit_status}"; then kill_process_group_with_signal_name "SIGINT" || true @@ -1991,7 +2018,20 @@ create_iso_image() { [[ -e "${iso_source_image}" ]] || return 1 exit_status=0 - { unused="$({ /usr/bin/script -k -q -- "${typescript_file}" /usr/bin/hdiutil makehybrid -o "${iso_destination_image}" "$(get_argument_safe_path "${iso_source_image}")" -hfs -udf -default-volume-name "${default_volume_name}" 2>/dev/null > >(parse_create_iso_image); } 3>&1 >&4 4>&-)"; } 4>&1 || exit_status="$?" + + # Use direct execution when in nointeraction mode (called from Swift app) to avoid terminal suspension + # Use script wrapper for interactive mode to enable output parsing + if [[ "${g_nointeraction_mode}" == "true" ]]; then + # Direct execution - no script wrapper (avoids suspension from non-interactive contexts) + output="$(/usr/bin/hdiutil makehybrid -o "${iso_destination_image}" "$(get_argument_safe_path "${iso_source_image}")" -hfs -udf -default-volume-name "${default_volume_name}" 2>&1)" + exit_status="$?" + while IFS= read -r line; do + echo "$line" + done <<< "$output" + else + # Original behavior with script wrapper and output parsing for interactive use + { unused="$({ /usr/bin/script -k -q -- "${typescript_file}" /usr/bin/hdiutil makehybrid -o "${iso_destination_image}" "$(get_argument_safe_path "${iso_source_image}")" -hfs -udf -default-volume-name "${default_volume_name}" 2>/dev/null > >(parse_create_iso_image); } 3>&1 >&4 4>&-)"; } 4>&1 || exit_status="$?" + fi if has_script_hdiutil_makehybrid_been_canceled_by_sigint "${typescript_file}"; then kill_process_group_with_signal_name "SIGINT" || true @@ -2511,6 +2551,7 @@ main() { fi option_nointeraction="true" + g_nointeraction_mode="true" ;; # End of options diff --git a/createinstalliso-interactive.swift b/createinstalliso-interactive.swift new file mode 100755 index 0000000..20e6ab8 --- /dev/null +++ b/createinstalliso-interactive.swift @@ -0,0 +1,1073 @@ +#!/usr/bin/env swift + +// createinstalliso - Creates a bootable ISO image from a macOS +// installer application (Interactive Version) +// Copyright (C) 2021-2025 Michael Berger +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Foundation + +// MARK: - Global Constants + +struct Constants { + static let scriptName = "createinstalliso" + static let scriptVersion = "1.2.0" + static let minimumRequiredMacOSVersion = "10.6" + static let rootUserId = 0 + + static let supportedMacOSVersionsRegex = [ + "^10\\.6(\\.[[:digit:]]+)?$", // Mac OS X Snow Leopard 10.6 + "^10\\.7(\\.[[:digit:]]+)?$", // Mac OS X Lion 10.7 + "^10\\.8(\\.[[:digit:]]+)?$", // OS X Mountain Lion 10.8 + "^10\\.9(\\.[[:digit:]]+)?$", // OS X Mavericks 10.9 + "^10\\.10(\\.[[:digit:]]+)?$", // OS X Yosemite 10.10 + "^10\\.11(\\.[[:digit:]]+)?$", // OS X El Capitan 10.11 + "^10\\.12(\\.[[:digit:]]+)?$", // macOS Sierra 10.12 + "^10\\.13(\\.[[:digit:]]+)?$", // macOS High Sierra 10.13 + "^10\\.14(\\.[[:digit:]]+)?$", // macOS Mojave 10.14 + "^10\\.15(\\.[[:digit:]]+)?$", // macOS Catalina 10.15 + "^11\\.[[:digit:]]+(\\.[[:digit:]]+)?$", // macOS Big Sur 11 + "^12\\.[[:digit:]]+(\\.[[:digit:]]+)?$", // macOS Monterey 12 + "^13\\.[[:digit:]]+(\\.[[:digit:]]+)?$", // macOS Ventura 13 + "^14\\.[[:digit:]]+(\\.[[:digit:]]+)?$", // macOS Sonoma 14 + "^15\\.[[:digit:]]+(\\.[[:digit:]]+)?$", // macOS Sequoia 15 + "^26\\.[[:digit:]]+(\\.[[:digit:]]+)?$" // macOS Tahoe 26 + ] + + static let supportedInstallerNames = [ + "Install Mac OS X Lion", + "Install OS X Mountain Lion", + "Install OS X Mavericks", + "Install OS X Yosemite", + "Install OS X El Capitan", + "Install macOS Sierra", + "Install macOS High Sierra", + "Install macOS Mojave", + "Install macOS Catalina", + "Install macOS Big Sur", + "Install macOS Monterey", + "Install macOS Ventura", + "Install macOS Sonoma", + "Install macOS Sequoia", + "Install macOS Tahoe" + ] +} + +// MARK: - Global State + +class Configuration { + var installerPath: String = "" + var outputDirectory: String = "" + var isoName: String = "" + var installerDisplayName: String = "" + var installerType: InstallerType = .unknown + var debugMode: Bool = false +} + +let config = Configuration() + +// MARK: - Installer Types + +enum InstallerType: Int { + case unknown = 0 + case lionMountainLion = 1 + case mavericksElCapitan = 2 + case sierraCatalina = 3 + case bigSurAndLater = 4 + + var description: String { + switch self { + case .unknown: return "Unknown" + case .lionMountainLion: return "Lion/Mountain Lion (10.7-10.8)" + case .mavericksElCapitan: return "Mavericks/Yosemite/El Capitan (10.9-10.11)" + case .sierraCatalina: return "Sierra through Catalina (10.12-10.15)" + case .bigSurAndLater: return "Big Sur and later (11+)" + } + } + + var requiresRoot: Bool { + return self == .sierraCatalina || self == .bigSurAndLater + } +} + +// MARK: - Helper Functions + +func getRealUserHomeDirectory() -> String { + // When running with sudo, get the actual user's home directory + if let sudoUser = ProcessInfo.processInfo.environment["SUDO_USER"] { + let pw = getpwnam(sudoUser) + if let homeDir = pw?.pointee.pw_dir { + return String(cString: homeDir) + } + } + return FileManager.default.homeDirectoryForCurrentUser.path +} + +// MARK: - UI Utilities + +class UI { + static func clearScreen() { + print("\u{001B}[2J\u{001B}[H", terminator: "") + } + + static func printHeader() { + clearScreen() + print(""" + ╔════════════════════════════════════════════════════════════════════╗ + β•‘ createinstalliso v\(Constants.scriptVersion) β•‘ + β•‘ Create Bootable macOS ISO Images β•‘ + β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + + """) + } + + static func printSeparator() { + print("────────────────────────────────────────────────────────────────────") + } + + static func printError(_ message: String) { + print("❌ Error: \(message)") + } + + static func printSuccess(_ message: String) { + print("βœ… \(message)") + } + + static func printInfo(_ message: String) { + print("ℹ️ \(message)") + } + + static func printWarning(_ message: String) { + print("⚠️ Warning: \(message)") + } + + static func readLine(prompt: String) -> String { + print(prompt, terminator: "") + return Swift.readLine() ?? "" + } + + static func readYesNo(prompt: String, defaultYes: Bool = false) -> Bool { + let suffix = defaultYes ? " [Y/n]: " : " [y/N]: " + let input = readLine(prompt: prompt + suffix).lowercased() + + if input.isEmpty { + return defaultYes + } + return input.hasPrefix("y") + } + + static func pressEnterToContinue() { + print("\nPress Enter to continue...", terminator: "") + _ = Swift.readLine() + } +} + +// MARK: - System Utilities + +func executeCommand(_ command: String, arguments: [String] = []) -> (output: String, exitCode: Int32) { + let task = Process() + task.executableURL = URL(fileURLWithPath: command) + task.arguments = arguments + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + return (output, task.terminationStatus) + } catch { + return ("", -1) + } +} + +func getCurrentMacOSVersion() -> String? { + let result = executeCommand("/usr/bin/sw_vers", arguments: ["-productVersion"]) + guard result.exitCode == 0 else { return nil } + return result.output.trimmingCharacters(in: .whitespacesAndNewlines) +} + +func isRoot() -> Bool { + return getuid() == Constants.rootUserId +} + +func directoryExists(_ path: String) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue +} + +func fileExists(_ path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) +} + +func getPlistValue(key: String, plistPath: String) -> String? { + let result = executeCommand("/usr/libexec/PlistBuddy", + arguments: ["-c", "Print \(key)", plistPath]) + guard result.exitCode == 0 else { return nil } + return result.output.trimmingCharacters(in: .whitespacesAndNewlines) +} + +func expandTilde(_ path: String) -> String { + return NSString(string: path).expandingTildeInPath +} + +func isDiskExternalOrRemovable(diskIdentifier: String) -> Bool { + // Use diskutil info to check if disk is external or removable + let result = executeCommand("/usr/sbin/diskutil", arguments: ["info", diskIdentifier]) + + guard result.exitCode == 0 else { + return false + } + + let output = result.output.lowercased() + + // Check for external or removable indicators + // Looking for "removable media: yes" or "protocol: usb" or "external: yes" + if output.contains("removable media: yes") { + return true + } + + if output.contains("protocol: usb") || output.contains("protocol: thunderbolt") { + return true + } + + // Check for "device location: external" + if output.contains("device location: external") { + return true + } + + // Check if device is not internal + if output.contains("internal: no") { + return true + } + + return false +} + +// MARK: - Installer Detection + +func getInstallerApplicationDisplayName(_ installerPath: String) -> String? { + let infoPlistPath = "\(installerPath)/Contents/Info.plist" + return getPlistValue(key: ":CFBundleDisplayName", plistPath: infoPlistPath) +} + +func getInstallerType(_ installerPath: String) -> InstallerType { + let sharedSupportPath = "\(installerPath)/Contents/SharedSupport" + let createInstallMediaPath = "\(installerPath)/Contents/Resources/createinstallmedia" + let installInfoPlistPath = "\(installerPath)/Contents/SharedSupport/InstallInfo.plist" + let sharedSupportDMGPath = "\(installerPath)/Contents/SharedSupport/SharedSupport.dmg" + + guard directoryExists(sharedSupportPath) else { + return .unknown + } + + if fileExists(createInstallMediaPath) { + if fileExists(installInfoPlistPath) { + return .sierraCatalina + } else if fileExists(sharedSupportDMGPath) { + return .bigSurAndLater + } else { + return .mavericksElCapitan + } + } else { + return .lionMountainLion + } +} + +func findInstallerApplications() -> [(path: String, name: String)] { + var installers: [(String, String)] = [] + let applicationsPath = "/Applications" + + do { + let contents = try FileManager.default.contentsOfDirectory(atPath: applicationsPath) + for item in contents { + let fullPath = "\(applicationsPath)/\(item)" + if item.hasPrefix("Install") && item.hasSuffix(".app") { + if let displayName = getInstallerApplicationDisplayName(fullPath) { + if Constants.supportedInstallerNames.contains(displayName) { + installers.append((fullPath, displayName)) + } + } + } + } + } catch { + // Ignore errors + } + + return installers.sorted(by: { $0.1 < $1.1 }) +} + +// MARK: - Menu Functions + +func showMainMenu() { + UI.printHeader() + + print("Current Configuration:") + UI.printSeparator() + if config.installerPath.isEmpty { + print("1. Select Installer Application: [Not Selected]") + } else { + print("1. Select Installer Application: βœ“") + print(" β†’ \(config.installerDisplayName)") + print(" β†’ Type: \(config.installerType.description)") + } + + if config.outputDirectory.isEmpty { + print("2. Select Output Directory: [Not Selected]") + } else { + print("2. Select Output Directory: βœ“") + print(" β†’ \(config.outputDirectory)") + } + + if config.isoName.isEmpty { + print("3. Set ISO Name: [Auto-generate]") + } else { + print("3. Set ISO Name: βœ“") + print(" β†’ \(config.isoName)") + } + + UI.printSeparator() + print("\nActions:") + print("4. Create ISO Image") + print("5. Write ISO to USB Drive") + print("6. View System Information") + print("7. Help") + print("0. Exit") + + UI.printSeparator() + print() +} + +func selectInstallerApplication() { + UI.printHeader() + print("Select Installer Application\n") + + let installers = findInstallerApplications() + + if !installers.isEmpty { + print("Found installer applications:") + UI.printSeparator() + for (index, installer) in installers.enumerated() { + print("\(index + 1). \(installer.name)") + } + print("0. Enter custom path") + UI.printSeparator() + print() + + let choice = UI.readLine(prompt: "Select option (0-\(installers.count)): ") + + if let number = Int(choice) { + if number == 0 { + selectCustomInstallerPath() + } else if number > 0 && number <= installers.count { + let selected = installers[number - 1] + config.installerPath = selected.path + config.installerDisplayName = selected.name + config.installerType = getInstallerType(selected.path) + UI.printSuccess("Selected: \(selected.name)") + UI.pressEnterToContinue() + } + } + } else { + UI.printInfo("No installer applications found in /Applications") + print("\nWould you like to enter a custom path?") + if UI.readYesNo(prompt: "Enter custom path?", defaultYes: true) { + selectCustomInstallerPath() + } + } +} + +func selectCustomInstallerPath() { + print("\nEnter the full path to the installer application:") + print("(e.g., /Applications/Install macOS Sonoma.app)") + var path = UI.readLine(prompt: "Path: ") + + // Expand tilde + path = expandTilde(path) + + // Remove trailing quotes if present + path = path.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + + if directoryExists(path) { + if let displayName = getInstallerApplicationDisplayName(path) { + config.installerPath = path + config.installerDisplayName = displayName + config.installerType = getInstallerType(path) + UI.printSuccess("Selected: \(displayName)") + } else { + UI.printError("Path exists but is not a recognized macOS installer. Please ensure it contains Contents/Info.plist with CFBundleDisplayName and is one of the supported installer types (Lion through Sequoia).") + } + } else { + UI.printError("Path does not exist") + } + + UI.pressEnterToContinue() +} + +func selectOutputDirectory() { + UI.printHeader() + print("Select Output Directory\n") + + print("Common locations:") + UI.printSeparator() + let userHome = getRealUserHomeDirectory() + let desktopPath = (userHome as NSString).appendingPathComponent("Desktop") + let downloadsPath = (userHome as NSString).appendingPathComponent("Downloads") + let documentsPath = (userHome as NSString).appendingPathComponent("Documents") + print("1. Desktop (\(desktopPath))") + print("2. Downloads (\(downloadsPath))") + print("3. Documents (\(documentsPath))") + print("4. Current directory (\(FileManager.default.currentDirectoryPath))") + print("5. Enter custom path") + UI.printSeparator() + print() + + let choice = UI.readLine(prompt: "Select option (1-5): ") + + switch choice { + case "1": + config.outputDirectory = desktopPath + UI.printSuccess("Output directory set to Desktop") + case "2": + config.outputDirectory = downloadsPath + UI.printSuccess("Output directory set to Downloads") + case "3": + config.outputDirectory = documentsPath + UI.printSuccess("Output directory set to Documents") + case "4": + config.outputDirectory = FileManager.default.currentDirectoryPath + UI.printSuccess("Output directory set to current directory") + case "5": + print("\nEnter the full path to the output directory:") + var path = UI.readLine(prompt: "Path: ") + path = expandTilde(path) + path = path.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + + if directoryExists(path) { + config.outputDirectory = path + UI.printSuccess("Output directory set") + } else { + UI.printError("Directory does not exist") + } + default: + UI.printError("Invalid choice") + } + + UI.pressEnterToContinue() +} + +func setISOName() { + UI.printHeader() + print("Set ISO Name\n") + + if config.installerPath.isEmpty { + UI.printError("Please select an installer application first") + UI.pressEnterToContinue() + return + } + + let defaultName = URL(fileURLWithPath: config.installerPath).deletingPathExtension().lastPathComponent + + print("Default ISO name: \(defaultName).iso") + print("\nEnter custom ISO name (without .iso extension)") + print("Press Enter to use default name") + print() + UI.printInfo("Note: The ISO will be created with the default name and renamed if you specify a custom name.") + print() + + let customName = UI.readLine(prompt: "ISO Name: ") + + if customName.isEmpty { + config.isoName = "" + UI.printSuccess("Using default name: \(defaultName).iso") + } else { + let cleanName = customName.replacingOccurrences(of: ".iso", with: "") + config.isoName = "\(cleanName).iso" + UI.printSuccess("ISO will be renamed to: \(config.isoName)") + } + + UI.pressEnterToContinue() +} + +func showSystemInformation() { + UI.printHeader() + print("System Information\n") + UI.printSeparator() + + if let version = getCurrentMacOSVersion() { + print("macOS Version: \(version)") + } + + print("Running as: \(isRoot() ? "root (sudo)" : "regular user")") + print("Current Directory: \(FileManager.default.currentDirectoryPath)") + print("Home Directory: \(FileManager.default.homeDirectoryForCurrentUser.path)") + + UI.printSeparator() + print("\nSupported macOS Installers:") + for name in Constants.supportedInstallerNames { + print(" β€’ \(name)") + } + + UI.printSeparator() + UI.pressEnterToContinue() +} + +func showHelp() { + UI.printHeader() + print("Help\n") + UI.printSeparator() + + print(""" + This interactive tool helps you create bootable ISO images from macOS + installer applications without typing complex commands. + + Steps to create an ISO: + + 1. Select an installer application from the list or enter a custom path + 2. Choose where to save the ISO file + 3. Optionally customize the ISO name + 4. Enable any special options if needed: + β€’ Patch Sierra: Fix for defective macOS Sierra 12.6.06 installer + β€’ Replace Signatures: Re-sign installer components + 5. Press 6 or select "Create ISO Image" to start the process + + Requirements: + β€’ Valid macOS installer application + β€’ Sufficient disk space (varies by installer) + β€’ Root privileges (sudo) for newer installers (10.12+) + + Supported Versions: + β€’ Mac OS X Lion through macOS Tahoe (10.7 - 26) + + Note: This is a Swift implementation. For full functionality, some + features may need completion. Refer to the original bash script for + complete ISO creation capabilities. + """) + + UI.printSeparator() + UI.pressEnterToContinue() +} + +func createISO() { + UI.printHeader() + print("Create ISO Image\n") + + // Validate configuration + var errors: [String] = [] + + if config.installerPath.isEmpty { + errors.append("No installer application selected") + } + + if config.outputDirectory.isEmpty { + errors.append("No output directory selected") + } + + if !errors.isEmpty { + UI.printError("Cannot create ISO:") + for error in errors { + print(" β€’ \(error)") + } + UI.pressEnterToContinue() + return + } + + // Show summary + UI.printSeparator() + print("Configuration Summary:") + print(" Installer: \(config.installerDisplayName)") + print(" Type: \(config.installerType.description)") + print(" Output: \(config.outputDirectory)") + print(" ISO Name: \(config.isoName.isEmpty ? "[Auto-generate]" : config.isoName)") + UI.printSeparator() + + // Check root requirement + if config.installerType.requiresRoot && !isRoot() { + print() + UI.printWarning("This installer type requires root privileges") + print("Please run this script with sudo:") + print(" sudo ./createinstalliso-interactive") + UI.pressEnterToContinue() + return + } + + print() + if !UI.readYesNo(prompt: "Proceed with ISO creation?", defaultYes: true) { + print("Cancelled") + UI.pressEnterToContinue() + return + } + + print() + UI.printInfo("Starting ISO creation process...") + print() + + // Get the directory where this executable is located + let executablePath = CommandLine.arguments[0] + let executableURL = URL(fileURLWithPath: executablePath) + let scriptDirectory = executableURL.deletingLastPathComponent().path + let bashScriptPath = "\(scriptDirectory)/createinstalliso" + + // Check if bash script exists + if !FileManager.default.fileExists(atPath: bashScriptPath) { + UI.printError("Cannot find createinstalliso bash script at: \(bashScriptPath)") + print() + UI.pressEnterToContinue() + return + } + + // Note: The bash script auto-generates the ISO name based on installer name + // If user specified a custom name, we'll rename the ISO after creation + + print() + print("Launching ISO creation in bash...") + print() + + // Execute bash script directly - the Terminal already has proper sudo + // Build command with available bash script options + let scriptArgs = [ + "--isodirectory", config.outputDirectory, + "--applicationpath", config.installerPath, + "--nointeraction" + ] + + // Prepare debug command string for display + let debugCommand = ([bashScriptPath] + scriptArgs).map { "\"\($0)\"" }.joined(separator: " ") + + if config.debugMode { + print("[DEBUG] Script path: \(bashScriptPath)") + print("[DEBUG] ISO directory: \(config.outputDirectory)") + print("[DEBUG] Application path: \(config.installerPath)") + print("[DEBUG] Full command: \(debugCommand)") + print("[DEBUG] Current directory: \(scriptDirectory)") + print() + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: bashScriptPath) + task.arguments = scriptArgs + task.currentDirectoryURL = URL(fileURLWithPath: scriptDirectory) + task.environment = ProcessInfo.processInfo.environment + + // Set up proper I/O to prevent process suspension + task.standardInput = FileHandle.standardInput + task.standardOutput = FileHandle.standardOutput + task.standardError = FileHandle.standardError + + if config.debugMode { + print("[DEBUG] Starting process...") + } + + do { + try task.run() + + if config.debugMode { + print("[DEBUG] Process launched with PID: \(task.processIdentifier)") + print("[DEBUG] Waiting for process to complete...") + print() + } + + task.waitUntilExit() + + if config.debugMode { + print() + print("[DEBUG] Process completed with status: \(task.terminationStatus)") + } + + print() + if task.terminationStatus == 0 { + UI.printSuccess("ISO creation completed successfully!") + + // Rename ISO if custom name was specified + // Note: This assumes the bash script creates an ISO named after the installer application + // (e.g., "Install macOS Sonoma.iso" for "Install macOS Sonoma.app") + if !config.isoName.isEmpty { + let defaultName = URL(fileURLWithPath: config.installerPath).deletingPathExtension().lastPathComponent + let defaultISOPath = "\(config.outputDirectory)/\(defaultName).iso" + let customISOPath = "\(config.outputDirectory)/\(config.isoName)" + + // Only attempt rename if paths differ + if defaultISOPath != customISOPath { + if fileExists(defaultISOPath) { + // Check if target already exists + if fileExists(customISOPath) { + UI.printWarning("Target ISO already exists: \(config.isoName)") + print("Default ISO is available at: \(defaultName).iso") + } else { + do { + try FileManager.default.moveItem(atPath: defaultISOPath, toPath: customISOPath) + print("ISO renamed to: \(config.isoName)") + } catch { + UI.printWarning("Could not rename ISO: \(error.localizedDescription)") + print("ISO is available at: \(defaultName).iso") + } + } + } else { + // Default ISO not found - report what we expected + UI.printWarning("Expected ISO file not found: \(defaultName).iso") + print("Check output directory for ISO file.") + + // Try to find any ISO files created + if let files = try? FileManager.default.contentsOfDirectory(atPath: config.outputDirectory) { + let isoFiles = files.filter { $0.hasSuffix(".iso") } + if !isoFiles.isEmpty { + print("Found ISO file(s): \(isoFiles.joined(separator: ", "))") + } + } + } + } + } + } else { + UI.printError("ISO creation failed with exit code \(task.terminationStatus)") + } + } catch { + UI.printError("Failed to execute createinstalliso script: \(error)") + } + + print() + UI.pressEnterToContinue() +} + +// MARK: - Write ISO to USB + +func writeISOToUSB() { + UI.printHeader() + print("Write ISO to USB Drive\n") + + // Check for root privileges first + if !isRoot() { + UI.printWarning("This feature requires root privileges") + print("Please run this script with sudo:") + print(" sudo ./createinstalliso-interactive") + print() + UI.pressEnterToContinue() + return + } + + // Search for ISO files in common locations + var searchDirectories: [String] = [] + + if !config.outputDirectory.isEmpty { + searchDirectories.append(config.outputDirectory) + } + + // Get the real user's home directory (even when running as sudo) + let homeDir = getRealUserHomeDirectory() + + searchDirectories.append(contentsOf: [ + "\(homeDir)/Downloads", + "\(homeDir)/Desktop", + "\(homeDir)/Documents" + ]) + + // Remove duplicates, preserving order + searchDirectories = searchDirectories.reduce(into: []) { result, dir in + if !result.contains(dir) { result.append(dir) } + } + // Find all ISO files + var isoFileMap: [(path: String, file: String)] = [] + + for dir in searchDirectories { + guard directoryExists(dir) else { continue } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: dir) + let isos = files.filter { $0.hasSuffix(".iso") } + + for iso in isos { + isoFileMap.append((path: dir, file: iso)) + } + } catch { + // Skip directories we can't read + continue + } + } + + // Display found ISO files + print("Available ISO files:") + UI.printSeparator() + + if isoFileMap.isEmpty { + print("No ISO files found in common locations.") + print("\nSearched directories:") + for dir in searchDirectories.prefix(3) { + print(" β€’ \(dir)") + } + print("\nYou can enter a custom path to an ISO file.") + print() + } else { + for (index, item) in isoFileMap.enumerated() { + let fullPath = "\(item.path)/\(item.file)" + if let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath), + let size = attrs[.size] as? UInt64 { + let sizeGB = Double(size) / 1_073_741_824.0 + print("\(index + 1). \(item.file) (\(String(format: "%.1f", sizeGB)) GB)") + print(" \(item.path)") + } else { + print("\(index + 1). \(item.file)") + print(" \(item.path)") + } + } + } + + print("0. Enter custom path") + UI.printSeparator() + print() + + let choice = UI.readLine(prompt: "Select ISO file: ") + + var isoPath = "" + if let index = Int(choice), index > 0, index <= isoFileMap.count { + let item = isoFileMap[index - 1] + isoPath = "\(item.path)/\(item.file)" + } else if choice == "0" { + let customPath = UI.readLine(prompt: "Enter ISO file path: ") + let expandedPath = expandTilde(customPath) + let url = URL(fileURLWithPath: expandedPath) + // Check for .iso extension (case-insensitive) + guard url.path.lowercased().hasSuffix(".iso") else { + UI.printError("File does not have a .iso extension: \(expandedPath)") + print() + UI.pressEnterToContinue() + return + } + // Check that it's a regular file + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: expandedPath, isDirectory: &isDirectory) + if !exists || isDirectory.boolValue { + UI.printError("File is not a regular ISO file: \(expandedPath)") + print() + UI.pressEnterToContinue() + return + } + isoPath = expandedPath + } else { + UI.printError("Invalid choice.") + print() + UI.pressEnterToContinue() + return + } + + guard fileExists(isoPath) else { + UI.printError("ISO file not found: \(isoPath)") + print() + UI.pressEnterToContinue() + return + } + + print() + print("Selected ISO: \(isoPath)") + print() + + // List available disks + print("Scanning for USB drives...") + let result = executeCommand("/usr/sbin/diskutil", arguments: ["list"]) + + if result.exitCode != 0 { + UI.printError("Failed to list disks") + print() + UI.pressEnterToContinue() + return + } + + print() + print("Available disks:") + UI.printSeparator() + print(result.output) + UI.printSeparator() + print() + + UI.printWarning("⚠️ WARNING: This will ERASE ALL DATA on the selected disk!") + print() + + let diskChoice = UI.readLine(prompt: "Enter disk identifier (e.g., disk2) or 'cancel': ") + + if diskChoice.lowercased() == "cancel" || diskChoice.isEmpty { + print("Operation cancelled.") + print() + UI.pressEnterToContinue() + return + } + + // Validate disk identifier + guard let diskRegex = try? NSRegularExpression(pattern: "^disk[0-9]+$", options: []) else { + UI.printError("Internal error: Failed to compile disk identifier regex.") + print() + UI.pressEnterToContinue() + return + } + let range = NSRange(location: 0, length: diskChoice.utf16.count) + guard diskRegex.firstMatch(in: diskChoice, options: [], range: range) != nil else { + UI.printError("Invalid disk identifier. Must match format 'disk' followed by digits (e.g., disk2)") + print() + UI.pressEnterToContinue() + return + } + // Warn if disk0 is selected, but allow if it is external/removable + if diskChoice == "disk0" { + print("Warning: disk0 is typically the system disk. Proceed only if you are certain it is external/removable.") + print("To proceed with disk0, you must type the following phrase exactly: I UNDERSTAND THE RISK") + let disk0Confirmation = UI.readLine(prompt: "Type 'I UNDERSTAND THE RISK' to continue, or anything else to cancel: ") + if disk0Confirmation != "I UNDERSTAND THE RISK" { + print("Operation cancelled.") + print() + UI.pressEnterToContinue() + return + } + } + + // Check if the selected disk is external/removable + if !isDiskExternalOrRemovable(diskIdentifier: diskChoice) { + UI.printError("Refusing to write to /dev/\(diskChoice) because it is not detected as external or removable. Please choose an external/removable disk.") + print() + UI.pressEnterToContinue() + return + } + + print() + print("You are about to write:") + print(" ISO: \(isoPath)") + print(" To: /dev/\(diskChoice)") + print() + UI.printWarning("⚠️ This will PERMANENTLY ERASE ALL DATA on /dev/\(diskChoice)!") + print() + + let confirm = UI.readLine(prompt: "Type 'YES' to proceed: ") + + if confirm != "YES" { + print("Operation cancelled.") + print() + UI.pressEnterToContinue() + return + } + + print() + UI.printInfo("Starting USB creation process...") + print() + + // Unmount the disk + print("Unmounting disk...") + let unmountResult = executeCommand("/usr/sbin/diskutil", arguments: ["unmountDisk", "/dev/\(diskChoice)"]) + if unmountResult.exitCode != 0 { + UI.printError("Failed to unmount disk: \(unmountResult.output)") + print() + UI.pressEnterToContinue() + return + } + + // Write ISO to disk using dd + print("Writing ISO to USB drive (this may take several minutes)...") + print("Please wait...") + print() + + // Use BSD dd without GNU-specific status=progress option + // Already running as root, no need for sudo + if config.debugMode { + print("[DEBUG] Command: dd if=\"\(isoPath)\" of=/dev/r\(diskChoice) bs=1m") + print("[DEBUG] Running as root: \(isRoot())") + print() + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/dd") + task.arguments = [ + "if", isoPath, + "of", "/dev/r\(diskChoice)", + "bs", "1m" + ] + task.standardInput = FileHandle.standardInput + task.standardOutput = FileHandle.standardOutput + task.standardError = FileHandle.standardError + + do { + try task.run() + task.waitUntilExit() + + print() + if task.terminationStatus == 0 { + print("Ejecting disk...") + _ = executeCommand("/usr/sbin/diskutil", arguments: ["eject", "/dev/\(diskChoice)"]) + print() + UI.printSuccess("USB installer created successfully!") + print("You can now safely remove the USB drive.") + } else { + UI.printError("Failed to write ISO to USB (exit code: \(task.terminationStatus))") + } + } catch { + UI.printError("Failed to execute dd command: \(error)") + } + + print() + UI.pressEnterToContinue() +} + +// MARK: - Main Program + +func main() { + // Parse command-line arguments + let arguments = CommandLine.arguments + if arguments.contains("--debug") || arguments.contains("-d") { + config.debugMode = true + print("[DEBUG MODE ENABLED]") + print() + } + + // Check if running on macOS + let result = executeCommand("/usr/bin/uname", arguments: ["-s"]) + if result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "Darwin" { + print("Error: This script must be run on macOS") + exit(1) + } + + var running = true + + while running { + showMainMenu() + let choice = UI.readLine(prompt: "Select option (0-9): ") + + switch choice { + case "1": + selectInstallerApplication() + case "2": + selectOutputDirectory() + case "3": + setISOName() + case "4": + createISO() + case "5": + writeISOToUSB() + case "6": + showSystemInformation() + case "7": + showHelp() + case "0": + UI.printHeader() + print("Thank you for using createinstalliso!") + print("Goodbye! πŸ‘‹\n") + running = false + default: + UI.printError("Invalid choice. Please select 0-9.") + UI.pressEnterToContinue() + } + } +} + +main()