├── .drstring.toml ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── draft_new_release.yml │ ├── linting.yml │ └── unit_tests.yml ├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── Mist.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── mist.xcscheme ├── Mist ├── Commands │ ├── Download │ │ ├── DownloadCommand.swift │ │ ├── DownloadFirmwareCommand.swift │ │ ├── DownloadFirmwareOptions.swift │ │ ├── DownloadInstallerCommand.swift │ │ └── DownloadInstallerOptions.swift │ ├── List │ │ ├── ListCommand.swift │ │ ├── ListFirmwareCommand.swift │ │ ├── ListFirmwareOptions.swift │ │ ├── ListInstallerCommand.swift │ │ └── ListInstallerOptions.swift │ └── Mist.swift ├── Extensions │ ├── Dictionary+Extension.swift │ ├── Int64+Extension.swift │ ├── Sequence+Extension.swift │ ├── String+Extension.swift │ ├── UInt32+Extension.swift │ ├── UInt64+Extension.swift │ ├── UInt8+Extension.swift │ ├── URL+Extension.swift │ └── [UInt8]+Extension.swift ├── Helpers │ ├── Downloader.swift │ ├── Generator.swift │ ├── HTTP.swift │ ├── InstallerCreator.swift │ ├── PrettyPrint.swift │ ├── Shell.swift │ └── Validator.swift ├── Model │ ├── Architecture.swift │ ├── Catalog.swift │ ├── Chunk.swift │ ├── Chunklist.swift │ ├── Firmware.swift │ ├── Hardware.swift │ ├── Installer.swift │ ├── InstallerOutputType.swift │ ├── ListOutputType.swift │ ├── MistError.swift │ ├── Package.swift │ └── Product.swift └── main.swift ├── MistTests └── MistTests.swift ├── Package.swift ├── README Resources ├── Example.png ├── Full Disk Access.png └── Slack.png └── README.md /.drstring.toml: -------------------------------------------------------------------------------- 1 | include = ["Mist/*.swift", "Mist/**/*.swift"] 2 | align-after-colon = ["parameters", "throws", "returns"] 3 | parameter-style = "grouped" 4 | needs-separation = ["description", "parameters", "throws"] 5 | vertical-align = true 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: File a bug report to fix something that is not working 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ### :beetle: Description 10 | 11 | Describe clearly and concisely what is not working. 12 | 13 | ### :clipboard: Steps to Reproduce 14 | 15 | 1. Run `mist ...` 16 | 1. Wait for mist to ... 17 | 1. Observe error 18 | 19 | ### :white_check_mark: Expected Behaviour 20 | 21 | Describe what should be happening (ie. the happy path). 22 | 23 | ### :computer: Environment 24 | 25 | - mist version (`mist --version`): **VERSION** 26 | - macOS Version (`sw_vers`): **VERSION BUILD** 27 | - Hardware (`system_profiler SPHardwareDataType`): 28 | - Model Identifier: **MODEL_IDENTIFIER** 29 | - Chip : **CHIP** 30 | 31 | ### :camera: Screenshots 32 | 33 | If applicable, add screenshots to help explain the bug. 34 | 35 | ### :information_source: Additional context 36 | 37 | - Is this also a bug in [Mist.app](https://github.com/ninxsoft/Mist)? :white_check_mark: / :x: 38 | - Provide links to GitHub Issues 39 | - Links to 3rd-party tools / references / documentation 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Submit a feature request to help improve this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ### :bulb: Description 10 | 11 | Describe clearly and concisely the feature you would like to see implemented. Is your feature request related to an active bug report? 12 | 13 | ### :white_check_mark: Proposed solution 14 | 15 | Describe what you would like to see happen. 16 | 17 | ### :adhesive_bandage: Alternatives Solutions + Workarounds 18 | 19 | Describe any alternative solutions or approaches you have considered or implemented to workaround the missing feature. 20 | 21 | ### :information_source: Additional context 22 | 23 | - Would you also like to see this implemented in [Mist.app](https://github.com/ninxsoft/Mist)? :white_check_mark: / :x: 24 | - Links to 3rd-party tools / references / documentation 25 | - Screenshots / diagrams that help explain the feature request 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | paths: ["**.swift"] 5 | branches: [main] 6 | pull_request: 7 | paths: ["**.swift"] 8 | branches: [main] 9 | workflow_dispatch: 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: macos-14 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: swift-actions/setup-swift@v2 17 | - name: Install Apple Developer ID Application Certificate 18 | env: 19 | APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE }} 20 | APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }} 21 | APPLE_DEVELOPER_CERTIFICATE_AUTHORITY: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_AUTHORITY }} 22 | APPLE_DEVELOPER_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_DEVELOPER_KEYCHAIN_PASSWORD }} 23 | run: | 24 | APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH="$RUNNER_TEMP/apple-developer-id-application-certificate.p12" 25 | APPLE_DEVELOPER_CERTIFICATE_AUTHORITY_PATH="$RUNNER_TEMP/apple-developer-certificate-authority.cer" 26 | KEYCHAIN_PATH="$RUNNER_TEMP/apple-developer.keychain-db" 27 | echo -n "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE" | base64 --decode -i - -o "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH" 28 | echo -n "$APPLE_DEVELOPER_CERTIFICATE_AUTHORITY" | base64 --decode -i - -o "$APPLE_DEVELOPER_CERTIFICATE_AUTHORITY_PATH" 29 | security create-keychain -p "$APPLE_DEVELOPER_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 30 | security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" 31 | security unlock-keychain -p "$APPLE_DEVELOPER_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 32 | security import "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH" -P "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" 33 | security import "$APPLE_DEVELOPER_CERTIFICATE_AUTHORITY_PATH" -P "$APPLE_DEVELOPER_CERTIFICATE_PASSWORD" -A -t cert -f pkcs7 -k "$KEYCHAIN_PATH" 34 | security list-keychain -d user -s "$KEYCHAIN_PATH" 35 | - name: Build mist 36 | run: swift build --configuration release --arch arm64 --arch x86_64 37 | - name: Codesign mist 38 | run: | 39 | KEYCHAIN_PATH="$RUNNER_TEMP/apple-developer.keychain-db" 40 | SIGNING_IDENTITY="Developer ID Application: Nindi Gill (7K3HVCLV7Z)" 41 | codesign --keychain "$KEYCHAIN_PATH" --sign "$SIGNING_IDENTITY" --options runtime ".build/apple/Products/release/mist" 42 | - name: Add mist to $PATH 43 | run: echo "$GITHUB_WORKSPACE/.build/apple/Products/release" >> $GITHUB_PATH 44 | - name: Print mist version 45 | run: mist --version 46 | - name: Print mist list for Firmwares 47 | run: mist list firmware 48 | - name: Print mist list for Installers 49 | run: mist list installer 50 | - name: Remove Apple Developer Keychain 51 | if: ${{ always() }} 52 | run: security delete-keychain $RUNNER_TEMP/apple-developer.keychain-db 53 | -------------------------------------------------------------------------------- /.github/workflows/draft_new_release.yml: -------------------------------------------------------------------------------- 1 | name: Draft New Release 2 | on: workflow_dispatch 3 | jobs: 4 | build: 5 | name: Draft New Release 6 | runs-on: macos-14 7 | env: 8 | APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE }} 9 | APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }} 10 | APPLE_DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: "Developer ID Application: Nindi Gill (7K3HVCLV7Z)" 11 | APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE }} 12 | APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD }} 13 | APPLE_DEVELOPER_ID_INSTALLER_SIGNING_IDENTITY: "Developer ID Installer: Nindi Gill (7K3HVCLV7Z)" 14 | APPLE_DEVELOPER_CERTIFICATE_AUTHORITY: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_AUTHORITY }} 15 | APPLE_DEVELOPER_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_DEVELOPER_KEYCHAIN_PASSWORD }} 16 | APPLE_DEVELOPER_APPLE_ID: ${{ secrets.APPLE_DEVELOPER_APPLE_ID }} 17 | APPLE_DEVELOPER_APPLE_ID_PASSWORD: ${{ secrets.APPLE_DEVELOPER_APPLE_ID_PASSWORD }} 18 | APPLE_DEVELOPER_TEAM_ID: "7K3HVCLV7Z" 19 | KEYCHAIN_FILE: "apple-developer.keychain-db" 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: swift-actions/setup-swift@v2 23 | - name: Install Apple Developer ID Certificates 24 | run: | 25 | APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH="$RUNNER_TEMP/apple-developer-id-application-certificate.p12" 26 | APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH="$RUNNER_TEMP/apple-developer-id-installer-certificate.p12" 27 | APPLE_DEVELOPER_CERTIFICATE_AUTHORITY_PATH="$RUNNER_TEMP/apple-developer-certificate-authority.cer" 28 | echo -n "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE" | base64 --decode -i - -o "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH" 29 | echo -n "$APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE" | base64 --decode -i - -o "$APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH" 30 | echo -n "$APPLE_DEVELOPER_CERTIFICATE_AUTHORITY" | base64 --decode -i - -o "$APPLE_DEVELOPER_CERTIFICATE_AUTHORITY_PATH" 31 | security create-keychain -p "$APPLE_DEVELOPER_KEYCHAIN_PASSWORD" "$RUNNER_TEMP/$KEYCHAIN_FILE" 32 | security set-keychain-settings -lut 21600 "$RUNNER_TEMP/$KEYCHAIN_FILE" 33 | security unlock-keychain -p "$APPLE_DEVELOPER_KEYCHAIN_PASSWORD" "$RUNNER_TEMP/$KEYCHAIN_FILE" 34 | security import "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH" -P "$APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$RUNNER_TEMP/$KEYCHAIN_FILE" 35 | security import "$APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH" -P "$APPLE_DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$RUNNER_TEMP/$KEYCHAIN_FILE" 36 | security import "$APPLE_DEVELOPER_CERTIFICATE_AUTHORITY_PATH" -P "$APPLE_DEVELOPER_CERTIFICATE_PASSWORD" -A -t cert -f pkcs7 -k "$RUNNER_TEMP/$KEYCHAIN_FILE" 37 | security list-keychain -d user -s "$RUNNER_TEMP/$KEYCHAIN_FILE" 38 | - name: Build mist 39 | run: swift build --configuration release --arch arm64 --arch x86_64 40 | - name: Codesign mist 41 | run: | 42 | codesign --keychain "$RUNNER_TEMP/$KEYCHAIN_FILE" --sign "$APPLE_DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --options runtime ".build/apple/Products/release/mist" 43 | - name: Package mist 44 | run: | 45 | PACKAGE_IDENTIFIER="com.ninxsoft.pkg.mist-cli" 46 | PACKAGE_TEMP="$RUNNER_TEMP/$PACKAGE_IDENTIFIER" 47 | PACKAGE_VERSION="$(.build/apple/Products/release/mist --version | head -n 1 | awk '{ print $1 }')" 48 | echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV" 49 | PACKAGE_FILENAME="mist-cli.$PACKAGE_VERSION.pkg" 50 | echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> "$GITHUB_ENV" 51 | mkdir -p "$PACKAGE_TEMP/usr/local/bin" 52 | cp ".build/apple/Products/release/mist" "$PACKAGE_TEMP/usr/local/bin/mist" 53 | pkgbuild --root "$PACKAGE_TEMP" \ 54 | --identifier "$PACKAGE_IDENTIFIER" \ 55 | --version "$PACKAGE_VERSION" \ 56 | --min-os-version "10.15" \ 57 | --sign "$APPLE_DEVELOPER_ID_INSTALLER_SIGNING_IDENTITY" \ 58 | "$PACKAGE_FILENAME" 59 | - name: Notarize mist 60 | run: | 61 | xcrun notarytool submit "${{ env.PACKAGE_FILENAME }}" --apple-id "$APPLE_DEVELOPER_APPLE_ID" --password "$APPLE_DEVELOPER_APPLE_ID_PASSWORD" --team-id "$APPLE_DEVELOPER_TEAM_ID" --wait 62 | xcrun stapler staple "${{ env.PACKAGE_FILENAME }}" 63 | - name: Draft New Release 64 | uses: softprops/action-gh-release@v2 65 | with: 66 | name: ${{ env.PACKAGE_VERSION }} 67 | tag_name: v${{ env.PACKAGE_VERSION }} 68 | draft: true 69 | files: ${{ env.PACKAGE_FILENAME }} 70 | - name: Remove Apple Developer Keychain 71 | if: ${{ always() }} 72 | run: security delete-keychain $RUNNER_TEMP/apple-developer.keychain-db 73 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: 3 | push: 4 | paths: ["**.swift"] 5 | branches: [main] 6 | pull_request: 7 | paths: ["**.swift"] 8 | branches: [main] 9 | workflow_dispatch: 10 | jobs: 11 | linting: 12 | name: Linting 13 | runs-on: macos-14 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: swift-actions/setup-swift@v2 17 | - uses: sinoru/actions-swiftlint@v6 18 | - name: Print SwiftLint version 19 | run: swiftlint --version 20 | - name: Run SwiftLint 21 | run: swiftlint --strict 22 | - name: Print SwiftFormat version 23 | run: swiftformat --version 24 | - name: Run SwiftFormat 25 | run: swiftformat --lint . 26 | - name: Download DrString 27 | run: curl --location --remote-name https://github.com/dduan/DrString/releases/latest/download/drstring-universal-apple-darwin.tar.gz 28 | - name: Extract DrString 29 | run: | 30 | mkdir drstring-universal-apple-darwin 31 | tar --extract --file drstring-universal-apple-darwin.tar.gz --directory drstring-universal-apple-darwin 32 | - name: Add DrString to $PATH 33 | run: echo "$GITHUB_WORKSPACE/drstring-universal-apple-darwin" >> $GITHUB_PATH 34 | - name: Print DrString version 35 | run: drstring --version 36 | - name: Run DrString 37 | run: drstring check --config-file .drstring.toml 38 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | push: 4 | paths: ["**.swift"] 5 | branches: [main] 6 | pull_request: 7 | paths: ["**.swift"] 8 | branches: [main] 9 | workflow_dispatch: 10 | jobs: 11 | unit_tests: 12 | name: Unit Tests 13 | runs-on: macos-14 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: swift-actions/setup-swift@v2 17 | - name: Run Unit Tests 18 | run: swift test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build/ 3 | .swiftpm/ 4 | build/ 5 | xcuserdata/ 6 | Package.resolved 7 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.10 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --commas inline 2 | --decimalgrouping 3,4 3 | --disable wrapMultilineStatementBraces 4 | --enable docComments 5 | --hexgrouping none 6 | --redundanttype explicit 7 | --wrapconditions before-first 8 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - .build 3 | 4 | closure_body_length: 5 | warning: 20 6 | error: 40 7 | 8 | line_length: 9 | warning: 200 10 | error: 220 11 | 12 | missing_docs: 13 | warning: 14 | - private 15 | - fileprivate 16 | - internal 17 | - public 18 | - open 19 | 20 | disabled_rules: 21 | - void_function_in_ternary 22 | 23 | analyzer_rules: 24 | - capture_variable 25 | - unused_declaration 26 | - unused_import 27 | 28 | opt_in_rules: 29 | - attributes 30 | - balanced_xctest_lifecycle 31 | - closure_body_length 32 | - closure_end_indentation 33 | - closure_spacing 34 | - collection_alignment 35 | - conditional_returns_on_newline 36 | - discarded_notification_center_observer 37 | - discouraged_optional_boolean 38 | - discouraged_optional_collection 39 | - empty_collection_literal 40 | - empty_count 41 | - empty_string 42 | - empty_xctest_method 43 | - enum_case_associated_values_count 44 | - explicit_init 45 | - explicit_type_interface 46 | - file_header 47 | - file_name 48 | - file_name_no_space 49 | - file_types_order 50 | - first_where 51 | - flatmap_over_map_reduce 52 | - function_default_parameter_at_end 53 | - force_unwrapping 54 | - identical_operands 55 | - implicit_return 56 | - implicitly_unwrapped_optional 57 | - indentation_width 58 | - joined_default_parameter 59 | - last_where 60 | - literal_expression_end_indentation 61 | - missing_docs 62 | - modifier_order 63 | - multiline_arguments 64 | - multiline_arguments_brackets 65 | - multiline_function_chains 66 | - multiline_literal_brackets 67 | - multiline_parameters 68 | - multiline_parameters_brackets 69 | - nimble_operator 70 | - number_separator 71 | - operator_usage_whitespace 72 | - prefer_zero_over_explicit_init 73 | - redundant_nil_coalescing 74 | - sorted_first_last 75 | - sorted_imports 76 | - switch_case_on_newline 77 | - toggle_bool 78 | - trailing_closure 79 | - type_contents_order 80 | - unneeded_parentheses_in_closure_argument 81 | - vertical_parameter_alignment_on_call 82 | - vertical_whitespace_closing_braces 83 | - yoda_condition 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.1.1](https://github.com/ninxsoft/mist-cli/releases/tag/v2.1) - 2024-07-09 4 | 5 | - `mist` now correctly exports firmware URL and installer package metadata - thanks [chilcote](https://github.com/chilcote)! 6 | - `mist` now suppresses update notifications when the `-q` / `--quiet` flag is used - thanks [mattlqx](https://github.com/mattlqx)! 7 | - Fixed a bug that prevented CSV output being displayed / exported correctly - thanks [NorseGaud](https://github.com/NorseGaud)! 8 | 9 | ## [2.1](https://github.com/ninxsoft/mist-cli/releases/tag/v2.1) - 2024-06-11 10 | 11 | - Added _beta_ support for **macOS Sequoia 15** :tada: 12 | - Added support for creating legacy Bootable Disk Images (ISO) on Apple Silicon - thanks [PicoMitchell](https://github.com/PicoMitchell)! 13 | - Added an `ipsw` alias for `firmware` 14 | - `mist list ipsw` behaves the same as `mist list firmware` 15 | - `mist download ipsw` behaves the same as `mist download firmware` 16 | - `mist` now returns an exit code of `1` for all errors - thanks [BigMacAdmin](https://github.com/BigMacAdmin)! 17 | - URLs are now formatted correctly when exporting CSVs - thanks [BigMacAdmin](https://github.com/BigMacAdmin)! 18 | - Bumped [Swift Argument Parser](https://github.com/apple/swift-argument-parser) version to **1.4.0** 19 | - Bumped [Yams](https://github.com/jpsim/Yams) version to **5.1.2** 20 | 21 | ## [2.0](https://github.com/ninxsoft/mist-cli/releases/tag/v2.0) - 2023-09-27 22 | 23 | - Added support for **macOS Sonoma 14** :tada: 24 | - `mist` will now only search the standard Software Update Catalog by default 25 | - Use the `--include-betas` flag to include additional Software Update Catalog seeds for macOS betas and release candidates 26 | - Thanks [grahampugh](https://github.com/grahampugh)! 27 | - Searching for a macOS Firmware / Installer by version is now more accurate, for example: 28 | 29 | - A search string of `13.5` will find an exact match of `macOS Ventura 13.5 (22G74)` 30 | - A search string of `13.5.2` will find an exact match of `macOS Ventura 13.5.2 (22G91)` 31 | - A search string of `13.5.` will find the most recent match from the list of matching releases, `macOS Ventura 13.5.2 (22G91)` 32 | - A search string of `13` will find the most recent match from the list of matching releases, `macOS Ventura 13.6 (22G120)` 33 | - Thanks [grahampugh](https://github.com/grahampugh)! 34 | 35 | **Note:** Version **2.0** requires **macOS Big Sur 11** or later. If you need to run **mist** on an older operating system, you can still use version **1.14**. 36 | 37 | ## [1.15](https://github.com/ninxsoft/mist-cli/releases/tag/v1.15) - 2023-08-23 38 | 39 | - Added a temporary POSIX permissions fix to Installer applications that are being set incorrectly - thanks [meta-github](https://github.com/meta-github), [grahampugh](https://github.com/grahampugh), [PicoMitchell](https://github.com/PicoMitchell) and [k0nker](https://github.com/k0nker)! 40 | - Rolled back the Bootable Disk Image (ISO) shrinking logic that was preventing the ISOs from booting correctly 41 | - Bumped [Swift Argument Parser](https://github.com/apple/swift-argument-parser) version to **1.2.3** 42 | - Bumped [Yams](https://github.com/jpsim/Yams) version to **5.0.6** 43 | 44 | **Note:** Version **1.15** requires **macOS Big Sur 11** or later. If you need to run **mist** on an older operating system, you can still use version **1.14**. 45 | 46 | ## [1.14](https://github.com/ninxsoft/mist-cli/releases/tag/v1.14) - 2023-06-26 47 | 48 | - `mist` will now inform you when a new update is available! 49 | - Added colored borders to the ASCII table output when running `mist list` 50 | 51 | ## [1.13](https://github.com/ninxsoft/mist-cli/releases/tag/v1.13) - 2023-06-22 52 | 53 | - Added support for the following legacy operating systems: 54 | - macOS Sierra 10.12.6 55 | - OS X El Capitan 10.11.6 56 | - OS X Yosemite 10.10.5 57 | - OS X Mountain Lion 10.8.5 58 | - Mac OS X Lion 10.7.5 59 | - Thanks [n8felton](https://github.com/n8felton)! 60 | - Added support for creating Bootable Installers! 61 | - Specify the `bootableinstaller` argument for the `` 62 | - Provide a `--bootable-installer-volume` argument for the mounted volume that will be used to create the Bootable Installer 63 | - **Note:** The volume must be formatted as **Mac OS Extended (Journaled)**. Use **Disk Utility** to format volumes as required. 64 | - **Note:** The volume will be erased automatically. Ensure you have backed up any necessary data before proceeding. 65 | - Available for **macOS Big Sur 11** and newer on **Apple Silicon Macs** 66 | - Available for **OS X Yosemite 10.10.5** and newer on **Intel-based Macs** 67 | - Thanks [5T33Z0](https://github.com/5T33Z0)! 68 | - Added support for downloading Firmwares and Installers from an [Apple Content Caching Server](https://support.apple.com/en-us/guide/deployment/depde72e125f/web)! 69 | - Provide a `--caching-server` argument for the `` that points to a Content Caching Server on the local network 70 | - **Note:** The cached content is served over HTTP, **not** HTTPS 71 | - Thanks [carlashley](https://github.com/carlashley)! 72 | - Bootable Disk Image (ISO) sizes are now calculated dynamically, with minimal free space 73 | - Thanks [devZer0](https://github.com/devZer0) and [carlashley](https://github.com/carlashley)! 74 | - Improved free disk space validation when running `mist` as `root` (ie. at the login screen) - thanks [TSPARR](https://github.com/TSPARR) and [PicoMitchell](https://github.com/PicoMitchell)! 75 | - Improved / updated several `--help` descriptions 76 | 77 | ## [1.12](https://github.com/ninxsoft/mist-cli/releases/tag/v1.12) - 2023-05-20 78 | 79 | - The percentage progress now displays correctly when the `--no-ansi` flag is used - thanks [grahampugh](https://github.com/grahampugh)! 80 | - Improved how available free space is calculated - thanks [PicoMitchell](https://github.com/PicoMitchell)! 81 | - Searching for a major macOS release number (ie. **13**) will now download the latest Firmware / Installer of said version - thanks [aschwanb](https://github.com/aschwanb)! 82 | - Attempting to generate a macOS Catalina 10.15 or older Bootable Disk Image on Apple Silicon Macs will inform the user and exit (rather than failing after the download) - thanks [KenjiTakahashi](https://github.com/KenjiTakahashi)! 83 | 84 | ## [1.11](https://github.com/ninxsoft/mist-cli/releases/tag/v1.11) - 2023-04-16 85 | 86 | - Specifying a macOS version with only one decimal no longer results in downloading a partial / incorrect match - thanks [kylerobertson0404](https://github.com/kylerobertson0404)! 87 | - Using the `--no-ansi` flag when downloading now only outputs progress once per percentage increase, resulting in less verbose logging - thanks [grahampugh](https://github.com/grahampugh)! 88 | - `mist` no longer displays mounted volumes in the Finder during disk image creation - thanks [wakco](https://github.com/wakco)! 89 | - Improved free disk space detection - thanks [anewhouse](https://github.com/anewhouse)! 90 | - Bumped [Swift Argument Parser](https://github.com/apple/swift-argument-parser) version to **1.2.2** 91 | - Bumped [Yams](https://github.com/jpsim/Yams) version to **5.0.5** 92 | 93 | ## [1.10](https://github.com/ninxsoft/mist-cli/releases/tag/v1.10) - 2022-12-29 94 | 95 | - When exporting a package for macOS 11 or newer, `mist` now saves time by re-using the Apple-provided Installer package when exporting a package - thanks [grahampugh](https://github.com/grahampugh)! 96 | - macOS Firmware and Installer downloads that error (eg. due to timeouts) can now be resumed when `mist` is run again - thanks [Guisch](https://github.com/Guisch)! 97 | - Use the `--cache-downloads` flag to cache incomplete downloads 98 | - Listing or downloading macOS Firmwares now caches the metadata from the [IPSW Downloads API](https://ipswdownloads.docs.apiary.io/) - thanks [NorseGaud](https://github.com/NorseGaud)! 99 | - Use the `--metadata-cache` option to specify a custom macOS Firmware metadata cache path 100 | - `mist` output can now be redirected to a log file without ANSI escape sequences - thanks [NinjaFez](https://github.com/NinjaFez) and [n8felton](https://github.com/n8felton)! 101 | - Use the `--no-ansi` flag to remove all ANSI escape sequences, as well as limit the download progress output to once per second 102 | - `mist` now defaults to creating a macOS Installer in a temporary disk image under-the-hood (no longer creating a macOS Installer in the `/Applications` directory) - thanks [grahampugh](https://github.com/grahampugh)! 103 | - `mist` no longer outputs error messages twice - once is enough! 104 | - Bumped [Swift Argument Parser](https://github.com/apple/swift-argument-parser) version to **1.2.0** 105 | - Removed unused declarations and imports (ie. dead code) 106 | 107 | ## [1.9.1](https://github.com/ninxsoft/mist-cli/releases/tag/v1.9.1) - 2022-10-08 108 | 109 | - Firmware SHA-1 checksum validation is now working correctly again - thanks [NorseGaud](https://github.com/NorseGaud)! 110 | 111 | ## [1.9](https://github.com/ninxsoft/mist-cli/releases/tag/v1.9) - 2022-09-26 112 | 113 | - Added support for macOS Ventura 13 114 | - macOS Installer files are retried when invalid cache files are detected on-disk 115 | - Calculating ISO image sizes is _slightly_ more dynamic (to better support macOS Ventura ISOs) 116 | - macOS Firmware / Installer lists are now sorted by version, then by date 117 | - Firmwares with invalid SHA-1 checksums are now ignored and unavailable for download 118 | - SHA-1 checksum validation logic is now implemented in Swift (no longer shells out to `shasum`) 119 | - stdout stream buffering is disabled to improve output frequency - thanks [n8felton](https://github.com/n8felton)! 120 | - Checking for mist updates now points to the recently renamed [mist-cli](https://github.com/ninxsoft/mist-cli) repository URL 121 | - Looking up the version of mist-cli is now performed using the built-in `mist --version` command 122 | - General code refactoring 123 | 124 | **Note:** To help avoid conflicts with the [Mist](https://github.com/ninxsoft/Mist) companion Mac app, the mist-cli installer package + installer package identifier have been renamed to `mist-cli` and `com.ninxsoft.pkg.mist-cli` respectively. 125 | 126 | ## [1.8](https://github.com/ninxsoft/mist-cli/releases/tag/v1.8) - 2022-06-14 127 | 128 | - `mist` is now a [Universal macOS Binary](https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary) 129 | - Supports Apple Silicon 130 | - Supports Intel-based Macs 131 | - `mist` now supports automatic retrying failed downloads: 132 | - Specify a value to the `--retries` option to override the total number of retry attempts **(default: 10)** 133 | - Specify a value to the `--retry-delay` option to override the number of seconds to wait before the next retry attempt **(default: 30)** 134 | - To help keep the `mist` command line options unambiguous, the `-k, --kind` option has been removed: 135 | - Use `mist list firmware` to list all available macOS Firmwares 136 | - Use `mist list installer` to list all available macOS Installers 137 | - Use `mist download firmware` to download a macOS Firmware 138 | - Use `mist download installer` to download a macOS Installer 139 | - Add `--help` to any of the above commands for additional information 140 | - Firmware downloads now have `0x644` POSIX permissions correctly applied upon successful download 141 | - Installer downloads can be cached using the `--cache-downloads` flag 142 | - Cached downloads will be stored in the temporary directory 143 | - Supply a value to the `--temporary-directory` option to change the temporary directory location 144 | - Installers downloads are now chunklist-verified upon successful download 145 | - The `--compatible` flag has been added to `mist list` and `mist download` to list / download Firmwares and Installers compatible with the Mac that is running `mist` 146 | - The `--export` option has been added to `mist download` to optionally generate a report of performed actions 147 | - The `--quiet` flag has been added to `mist download` to optionally suppress verbose output 148 | - Reports exported as JSON now have their keys sorted alphabetically 149 | - Bumped [Swift Argument Parser](https://github.com/apple/swift-argument-parser) version to **1.1.2** 150 | - Bumped [Yams](https://github.com/jpsim/Yams) version to **5.0.1** 151 | - General code refactoring and print message formatting fixes 152 | 153 | **Note:** Requires macOS Catalina 10.15 or later 154 | 155 | ## [1.7.0](https://github.com/ninxsoft/mist-cli/releases/tag/v1.7.0) - 2022-03-06 156 | 157 | - The `--platform` option has been renamed to `-k, --kind`, to improve readability and reduce confusion 158 | - Specify `firmware` or `ipsw` to download a macOS Firmware IPSW file 159 | - Specify `installer` or `app` to download a macOS Install Application bundle 160 | - Support for generating Bootable Disk Images (.iso) 161 | - For use with virtualization software (ie. Parallels Desktop, VMware Fusion, VirtualBox) 162 | - `mist download --iso` 163 | - Optionally specify `--iso-name` for a custom output file name 164 | - Downloading macOS Firmware IPSW files no longer requires escalated `sudo` privileges 165 | - Improved error messaging for when things go wrong (no longer outputs just the command that failed) 166 | - Granular error messages for when searching for Firmwares fails 167 | 168 | ## [1.6.1](https://github.com/ninxsoft/mist-cli/releases/tag/v1.6.1) - 2021-11-20 169 | 170 | - `mist version` now correctly displays the current version when offline 171 | 172 | ## [1.6](https://github.com/ninxsoft/mist-cli/releases/tag/v1.6) - 2021-11-08 173 | 174 | - SUCatalog URLs have been updated to point to **macOS Monterey (12)** URLs 175 | - Beta versions of macOS are now excluded by default in search results 176 | - Use `--include-betas` to include betas in search results 177 | - `mist version` now informs you if a new version is available for download 178 | - Bumped [Swift Argument Parser](https://github.com/apple/swift-argument-parser) version to **1.0.1** 179 | 180 | ## [1.5](https://github.com/ninxsoft/mist-cli/releases/tag/v1.5) - 2021-09-03 181 | 182 | - Added List search support 183 | - `mist list ` to filter on results 184 | - `--latest` to filter the latest (first) match found 185 | - `--quiet` to suppress verbose output 186 | - `--output-type` to specify a custom output type 187 | - Added progress indicators 188 | - Displays current and total download amounts 189 | - Displays overal percentage downloaded 190 | - macOS Firmwares and Installers will fail to download if they already exist 191 | - Use `--force` to overwrite this behaviour 192 | - Faster macOS Firmwares list retrieval time 193 | - Faster macOS Installers list retrieval time 194 | - Replaced **SANITY CHECKS** headers with more inclusive **INPUT VALIDATION** 195 | - Fixed a bug with partial string matching when searching for downloads 196 | - Improved error handling and messaging 197 | 198 | ## [1.4](https://github.com/ninxsoft/mist-cli/releases/tag/v1.4) - 2021-08-27 199 | 200 | - Support for downloading macOS Firmware (IPSW) files 201 | - Shasum is validated upon download 202 | - Moved list, download and version options to subcommands: 203 | - `mist --list` is now `mist list` 204 | - `mist --download` is now `mist download` 205 | - `mist --version` is now `mist version` 206 | - See `mist --help` for detailed help 207 | - Renamed `--list-export` option to `--export` 208 | - Re-added `--application` output option, back by popular demand! 209 | - Removed short flags for output options due to naming collisions: 210 | - Removed `-a` for `--application` 211 | - Removed `-i` for `--image` 212 | - Removed `-p` for `--package` 213 | - Lists now display / export total size 214 | - More verbose output for input validation 215 | 216 | ## [1.3.1](https://github.com/ninxsoft/mist-cli/releases/tag/v1.3.1) - 2021-08-12 217 | 218 | - Fixed bug where SUCatalog files were not being parsed correctly 219 | 220 | ## [1.3](https://github.com/ninxsoft/mist-cli/releases/tag/v1.3) - 2021-06-21 221 | 222 | - Removed `--name`, `--mac-os-version` and `--build` options, `--download` now supports all three 223 | - Removed `--list-format` option and renamed `--list-path` to `--list-export`, file extension determines export type 224 | - Removed `--application` and `--zip` options 225 | - Added `--catalogURL` 226 | - Added `--temporary-directory` option 227 | - Added `--keychain` option 228 | - Added free space check before downloads are initiated 229 | - Support for building hardware specific installers on all Macs 230 | - macOS name is now determined from the distribution files, no longer hardcoded 231 | - CSV cells with spaces now display correctly 232 | - Better input validation before downloads are initiated 233 | - Cleanup of standard output messaging (less verbose) 234 | - Removed download progress output 235 | - General code refactoring 236 | 237 | ## [1.2](https://github.com/ninxsoft/mist-cli/releases/tag/v1.2) - 2021-03-20 238 | 239 | - Downloads now show progress: current + total download sizes and % completed 240 | - Mist will now create the `--output` directory if it does not exist 241 | 242 | ## [1.1.1](https://github.com/ninxsoft/mist-cli/releases/tag/v1.1.1) - 2021-03-19 243 | 244 | - `--application` and `--zip` flags are now detected correctly 245 | 246 | ## [1.1](https://github.com/ninxsoft/mist-cli/releases/tag/v1.1) - 2021-03-16 247 | 248 | - Specify custom catalog seeds: **Customer**, **Developer** and **Public** 249 | - This allows downloading macOS Install Betas 250 | - Output the macOS Installer application bundle to a custom directory 251 | - Generate a ZIP archive of the macOS Installer application bundle 252 | - Checks for free space before attempting any downloads and installations 253 | - Cleaned up CLI argument flags, options, and formatting 254 | 255 | ## [1.0](https://github.com/ninxsoft/mist-cli/releases/tag/v1.0) - 2021-03-15 256 | 257 | - Initial release 258 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Nindi Gill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | identifier = com.ninxsoft.pkg.mist-cli 2 | identity_app = Developer ID Application: Nindi Gill (7K3HVCLV7Z) 3 | identity_pkg = Developer ID Installer: Nindi Gill (7K3HVCLV7Z) 4 | binary = mist 5 | source = .build/apple/Products/release/$(binary) 6 | destination = /usr/local/bin/$(binary) 7 | temp = /private/tmp/$(identifier) 8 | version = $(shell mist --version | head -n 1 | awk '{ print $$1 }') 9 | min_os_version = 11.0 10 | package_dir = build 11 | package = $(package_dir)/mist-cli.$(version).pkg 12 | 13 | build: 14 | swift build --configuration release --arch arm64 --arch x86_64 15 | codesign --sign "$(identity_app)" --options runtime "$(source)" 16 | 17 | install: build 18 | install "$(source)" "$(destination)" 19 | 20 | package: install 21 | mkdir -p "$(temp)/usr/local/bin" 22 | mkdir -p "$(package_dir)" 23 | cp "$(destination)" "$(temp)$(destination)" 24 | pkgbuild --root "$(temp)" \ 25 | --identifier "$(identifier)" \ 26 | --version "$(version)" \ 27 | --min-os-version "$(min_os_version)" \ 28 | --sign "$(identity_pkg)" \ 29 | "$(package)" 30 | rm -r "$(temp)" 31 | 32 | uninstall: 33 | rm -rf "$(destination)" 34 | 35 | clean: 36 | rm -rf .build 37 | 38 | .PHONY: build install package uninstall clean 39 | -------------------------------------------------------------------------------- /Mist.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mist.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mist.xcodeproj/xcshareddata/xcschemes/mist.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 61 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Mist/Commands/Download/DownloadCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadCommand.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 26/8/21. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | struct DownloadCommand: ParsableCommand { 11 | static var configuration: CommandConfiguration = .init( 12 | commandName: "download", 13 | abstract: "Download a macOS Firmware / Installer.", 14 | subcommands: [DownloadFirmwareCommand.self, DownloadInstallerCommand.self] 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /Mist/Commands/Download/DownloadFirmwareCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadFirmwareCommand.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 30/5/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | /// Struct used to perform **Download Firmware** operations. 12 | struct DownloadFirmwareCommand: ParsableCommand { 13 | static var configuration: CommandConfiguration = .init( 14 | commandName: "firmware", 15 | abstract: """ 16 | Download a macOS Firmware. 17 | * macOS Firmwares are for Apple Silicon Macs only. 18 | """ 19 | ) 20 | @OptionGroup var options: DownloadFirmwareOptions 21 | 22 | /// Searches for and downloads a particular macOS version. 23 | /// 24 | /// - Parameters: 25 | /// - options: Download options for macOS Firmwares. 26 | /// 27 | /// - Throws: A `MistError` if a macOS version fails to download. 28 | static func run(options: DownloadFirmwareOptions) throws { 29 | !options.quiet ? Mist.checkForNewVersion(noAnsi: options.noAnsi) : Mist.noop() 30 | try inputValidation(options) 31 | !options.quiet ? PrettyPrint.printHeader("SEARCH", noAnsi: options.noAnsi) : Mist.noop() 32 | !options.quiet ? PrettyPrint.print("Searching for macOS download '\(options.searchString)'...", noAnsi: options.noAnsi) : Mist.noop() 33 | 34 | guard 35 | let firmware: Firmware = HTTP.firmware( 36 | from: HTTP.retrieveFirmwares(includeBetas: options.includeBetas, compatible: options.compatible, metadataCachePath: options.metadataCachePath, noAnsi: options.noAnsi), 37 | searchString: options.searchString 38 | ) else { 39 | !options.quiet ? PrettyPrint.print("No macOS Firmware found with '\(options.searchString)', exiting...", noAnsi: options.noAnsi, prefix: .ending) : Mist.noop() 40 | return 41 | } 42 | 43 | !options.quiet ? PrettyPrint.print("Found \(firmware.name) \(firmware.version) (\(firmware.build)) [\(firmware.dateDescription)]", noAnsi: options.noAnsi) : Mist.noop() 44 | try verifyExistingFiles(firmware, options: options) 45 | try setup(firmware, options: options) 46 | try verifyFreeSpace(firmware, options: options) 47 | try Downloader().download(firmware, options: options) 48 | try Generator.generate(firmware, options: options) 49 | try teardown(firmware, options: options) 50 | try export(firmware, options: options) 51 | } 52 | 53 | /// Performs a series of validations on input data, throwing an error if the input data is invalid. 54 | /// 55 | /// - Parameters: 56 | /// - options: Download options for macOS Firmwares. 57 | /// 58 | /// - Throws: A `MistError` if any of the input validations fail. 59 | private static func inputValidation(_ options: DownloadFirmwareOptions) throws { 60 | !options.quiet ? PrettyPrint.printHeader("INPUT VALIDATION", noAnsi: options.noAnsi) : Mist.noop() 61 | 62 | guard !options.searchString.isEmpty else { 63 | throw MistError.missingDownloadSearchString 64 | } 65 | 66 | !options.quiet ? PrettyPrint.print("Download search string will be '\(options.searchString)'...", noAnsi: options.noAnsi) : Mist.noop() 67 | 68 | guard !options.outputDirectory.isEmpty else { 69 | throw MistError.missingOutputDirectory 70 | } 71 | 72 | !options.quiet ? PrettyPrint.print("Include betas in search results will be '\(options.includeBetas)'...", noAnsi: options.noAnsi) : Mist.noop() 73 | !options.quiet ? PrettyPrint.print("Only include compatible firmwares will be '\(options.compatible)'...", noAnsi: options.noAnsi) : Mist.noop() 74 | !options.quiet ? PrettyPrint.print("Cache downloads will be '\(options.cacheDownloads)'...", noAnsi: options.noAnsi) : Mist.noop() 75 | !options.quiet ? PrettyPrint.print("Output directory will be '\(options.outputDirectory)'...", noAnsi: options.noAnsi) : Mist.noop() 76 | !options.quiet ? PrettyPrint.print("Temporary directory will be '\(options.temporaryDirectory)'...", noAnsi: options.noAnsi) : Mist.noop() 77 | 78 | if let cachingServer: String = options.cachingServer { 79 | guard let url: URL = URL(string: cachingServer) else { 80 | throw MistError.invalidURL(cachingServer) 81 | } 82 | 83 | guard 84 | let scheme: String = url.scheme, 85 | scheme == "http" else { 86 | throw MistError.invalidCachingServerProtocol(url) 87 | } 88 | 89 | !options.quiet ? PrettyPrint.print("Content Caching Server URL will be '\(url.absoluteString)'...", noAnsi: options.noAnsi) : Mist.noop() 90 | } 91 | 92 | let string: String = "Force flag\(options.force ? " " : " has not been ")set, existing files will\(options.force ? " " : " not ")be overwritten..." 93 | !options.quiet ? PrettyPrint.print(string, noAnsi: options.noAnsi) : Mist.noop() 94 | 95 | if let path: String = options.exportPath { 96 | guard !path.isEmpty else { 97 | throw MistError.missingExportPath 98 | } 99 | 100 | !options.quiet ? PrettyPrint.print("Export path will be '\(path)'...", noAnsi: options.noAnsi) : Mist.noop() 101 | 102 | let url: URL = .init(fileURLWithPath: path) 103 | 104 | guard ["json", "plist", "yaml"].contains(url.pathExtension) else { 105 | throw MistError.invalidExportFileExtension 106 | } 107 | 108 | !options.quiet ? PrettyPrint.print("Export path file extension is valid...", noAnsi: options.noAnsi) : Mist.noop() 109 | } 110 | 111 | guard !options.metadataCachePath.isEmpty else { 112 | throw MistError.missingFirmwareMetadataCachePath 113 | } 114 | 115 | !options.quiet ? PrettyPrint.print("macOS Firmware metadata cache path will be '\(options.metadataCachePath)'...", noAnsi: options.noAnsi) : Mist.noop() 116 | 117 | try inputValidationFirmware(options) 118 | } 119 | 120 | /// Performs a series of input validations specific to macOS Firmware output. 121 | /// 122 | /// - Parameters: 123 | /// - options: Download options for macOS Firmwares. 124 | /// 125 | /// - Throws: A `MistError` if any of the input validations fail. 126 | private static func inputValidationFirmware(_ options: DownloadFirmwareOptions) throws { 127 | guard !options.firmwareName.isEmpty else { 128 | throw MistError.missingFirmwareName 129 | } 130 | 131 | !options.quiet ? PrettyPrint.print("Firmware name will be '\(options.firmwareName)'...", noAnsi: options.noAnsi) : Mist.noop() 132 | } 133 | 134 | /// Verifies if macOS Firmware files already exist. 135 | /// 136 | /// - Parameters: 137 | /// - firmware: The selected macOS Firmware to be downloaded. 138 | /// - options: Download options for macOS Firmwares. 139 | /// 140 | /// - Throws: A `MistError` if an existing file is found. 141 | private static func verifyExistingFiles(_ firmware: Firmware, options: DownloadFirmwareOptions) throws { 142 | guard !options.force else { 143 | return 144 | } 145 | 146 | let path: String = firmwarePath(for: firmware, options: options) 147 | 148 | guard !FileManager.default.fileExists(atPath: path) else { 149 | throw MistError.existingFile(path: path) 150 | } 151 | } 152 | 153 | /// Sets up directory structure for macOS Firmware downloads. 154 | /// 155 | /// - Parameters: 156 | /// - firmware: The selected macOS Firmware to be downloaded. 157 | /// - options: Download options for macOS Firmwares. 158 | /// 159 | /// - Throws: An `Error` if any of the directory operations fail. 160 | private static func setup(_ firmware: Firmware, options: DownloadFirmwareOptions) throws { 161 | let outputURL: URL = .init(fileURLWithPath: outputDirectory(for: firmware, options: options)) 162 | let temporaryURL: URL = .init(fileURLWithPath: temporaryDirectory(for: firmware, options: options)) 163 | var processing: Bool = false 164 | 165 | !options.quiet ? PrettyPrint.printHeader("SETUP", noAnsi: options.noAnsi) : Mist.noop() 166 | 167 | if !FileManager.default.fileExists(atPath: outputURL.path) { 168 | !options.quiet ? PrettyPrint.print("Creating output directory '\(outputURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 169 | try FileManager.default.createDirectory(atPath: outputURL.path, withIntermediateDirectories: true, attributes: nil) 170 | processing = true 171 | } 172 | 173 | if FileManager.default.fileExists(atPath: temporaryURL.path), !options.cacheDownloads { 174 | !options.quiet ? PrettyPrint.print("Deleting old temporary directory '\(temporaryURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 175 | try FileManager.default.removeItem(at: temporaryURL) 176 | processing = true 177 | } 178 | 179 | if !FileManager.default.fileExists(atPath: temporaryURL.path) { 180 | !options.quiet ? PrettyPrint.print("Creating new temporary directory '\(temporaryURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 181 | try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true, attributes: nil) 182 | processing = true 183 | } 184 | 185 | if !processing { 186 | !options.quiet ? PrettyPrint.print("Nothing to do!", noAnsi: options.noAnsi) : Mist.noop() 187 | } 188 | } 189 | 190 | /// Verifies free space for macOS Firmware downloads. 191 | /// 192 | /// - Parameters: 193 | /// - firmware: The selected macOS Firmware to be downloaded. 194 | /// - options: Download options for macOS Firmwares. 195 | /// 196 | /// - Throws: A `MistError` if there is not enough free space. 197 | private static func verifyFreeSpace(_ firmware: Firmware, options: DownloadFirmwareOptions) throws { 198 | let outputURL: URL = .init(fileURLWithPath: outputDirectory(for: firmware, options: options)) 199 | let temporaryURL: URL = .init(fileURLWithPath: options.temporaryDirectory) 200 | let required: Int64 = firmware.size 201 | 202 | for url in [outputURL, temporaryURL] { 203 | let values: URLResourceValues = try url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey]) 204 | let free: Int64 205 | 206 | if 207 | let volumeAvailableCapacityForImportantUsage: Int64 = values.volumeAvailableCapacityForImportantUsage, 208 | volumeAvailableCapacityForImportantUsage > 0 { 209 | free = volumeAvailableCapacityForImportantUsage 210 | } else if let volumeAvailableCapacity: Int = values.volumeAvailableCapacity { 211 | free = Int64(volumeAvailableCapacity) 212 | } else { 213 | throw MistError.notEnoughFreeSpace(volume: url.path, free: 0, required: required) 214 | } 215 | 216 | guard required < free else { 217 | throw MistError.notEnoughFreeSpace(volume: url.path, free: free, required: required) 218 | } 219 | } 220 | } 221 | 222 | /// Tears down temporary directory structure for macOS Firmware downloads. 223 | /// 224 | /// - Parameters: 225 | /// - firmware: The selected macOS Firmware that was downloaded. 226 | /// - options: Download options for macOS Firmwares. 227 | /// 228 | /// - Throws: An `Error` if any of the directory operations fail. 229 | private static func teardown(_ firmware: Firmware, options: DownloadFirmwareOptions) throws { 230 | let temporaryURL: URL = .init(fileURLWithPath: temporaryDirectory(for: firmware, options: options)) 231 | !options.quiet ? PrettyPrint.printHeader("TEARDOWN", noAnsi: options.noAnsi) : Mist.noop() 232 | 233 | if FileManager.default.fileExists(atPath: temporaryURL.path), !options.cacheDownloads { 234 | !options.quiet ? PrettyPrint.print("Deleting temporary directory '\(temporaryURL.path)'...", noAnsi: options.noAnsi, prefix: .ending) : Mist.noop() 235 | try FileManager.default.removeItem(at: temporaryURL) 236 | } else { 237 | !options.quiet ? PrettyPrint.print("Nothing to do!", noAnsi: options.noAnsi, prefix: options.exportPath != nil ? .default : .ending) : Mist.noop() 238 | } 239 | } 240 | 241 | /// Exports the results for macOS Firmware downloads. 242 | /// 243 | /// - Parameters: 244 | /// - firmware: The selected macOS Firmware that was downloaded. 245 | /// - options: Download options for macOS Firmwares. 246 | /// 247 | /// - Throws: An `Error` if any of the directory operations fail. 248 | private static func export(_ firmware: Firmware, options: DownloadFirmwareOptions) throws { 249 | guard let path: String = exportPath(for: firmware, options: options) else { 250 | return 251 | } 252 | 253 | let url: URL = .init(fileURLWithPath: path) 254 | let directory: URL = url.deletingLastPathComponent() 255 | 256 | if !FileManager.default.fileExists(atPath: directory.path) { 257 | !options.quiet ? PrettyPrint.print("Creating parent directory '\(directory.path)'...", noAnsi: options.noAnsi) : Mist.noop() 258 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) 259 | } 260 | 261 | let dictionary: [String: Any] = [ 262 | "firmware": firmware.dictionary, 263 | "options": exportDictionary(for: firmware, options: options) 264 | ] 265 | 266 | switch url.pathExtension { 267 | case "json": 268 | try dictionary.jsonString().write(toFile: path, atomically: true, encoding: .utf8) 269 | !options.quiet ? PrettyPrint.print("Exported download results as JSON: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 270 | case "plist": 271 | try dictionary.propertyListString().write(toFile: path, atomically: true, encoding: .utf8) 272 | !options.quiet ? PrettyPrint.print("Exported download results as Property List: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 273 | case "yaml": 274 | try dictionary.yamlString().write(toFile: path, atomically: true, encoding: .utf8) 275 | !options.quiet ? PrettyPrint.print("Exported download results as YAML: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 276 | default: 277 | break 278 | } 279 | } 280 | 281 | private static func exportDictionary(for firmware: Firmware, options: DownloadFirmwareOptions) -> [String: Any] { 282 | [ 283 | "includeBetas": options.includeBetas, 284 | "force": options.force, 285 | "firmwarePath": firmwarePath(for: firmware, options: options), 286 | "outputDirectory": outputDirectory(for: firmware, options: options), 287 | "temporaryDirectory": temporaryDirectory(for: firmware, options: options), 288 | "exportPath": exportPath(for: firmware, options: options) ?? "", 289 | "quiet": options.quiet 290 | ] 291 | } 292 | 293 | private static func exportPath(for firmware: Firmware, options: DownloadFirmwareOptions) -> String? { 294 | guard let path: String = options.exportPath else { 295 | return nil 296 | } 297 | 298 | return path.stringWithSubstitutions(using: firmware) 299 | } 300 | 301 | static func firmwarePath(for firmware: Firmware, options: DownloadFirmwareOptions) -> String { 302 | "\(options.outputDirectory)/\(options.firmwareName)".stringWithSubstitutions(using: firmware) 303 | } 304 | 305 | private static func outputDirectory(for firmware: Firmware, options: DownloadFirmwareOptions) -> String { 306 | options.outputDirectory.stringWithSubstitutions(using: firmware) 307 | } 308 | 309 | static func temporaryDirectory(for firmware: Firmware, options: DownloadFirmwareOptions) -> String { 310 | "\(options.temporaryDirectory)/\(firmware.identifier)".replacingOccurrences(of: "//", with: "/") 311 | } 312 | 313 | static func cachingServerURL(for source: URL, options: DownloadFirmwareOptions) -> URL? { 314 | guard 315 | let cachingServerHost: String = options.cachingServer, 316 | let sourceHost: String = source.host else { 317 | return nil 318 | } 319 | 320 | let cachingServerPath: String = source.path.replacingOccurrences(of: sourceHost, with: "") 321 | let cachingServerString: String = "\(cachingServerHost)\(cachingServerPath)?source=\(sourceHost)&sourceScheme=https" 322 | 323 | guard let cachingServerURL: URL = URL(string: cachingServerString) else { 324 | return nil 325 | } 326 | 327 | return cachingServerURL 328 | } 329 | 330 | static func resumeDataURL(for firmware: Firmware, options: DownloadFirmwareOptions) -> URL { 331 | let temporaryDirectory: String = temporaryDirectory(for: firmware, options: options) 332 | let string: String = "\(temporaryDirectory)/\(firmware.filename).resumeData" 333 | let url: URL = .init(fileURLWithPath: string) 334 | return url 335 | } 336 | 337 | mutating func run() throws { 338 | do { 339 | try DownloadFirmwareCommand.run(options: options) 340 | } catch { 341 | if let mistError: MistError = error as? MistError { 342 | PrettyPrint.print(mistError.description, noAnsi: options.noAnsi, prefix: .ending, prefixColor: .red) 343 | } else { 344 | PrettyPrint.print(error.localizedDescription, noAnsi: options.noAnsi, prefix: .ending, prefixColor: .red) 345 | } 346 | 347 | throw ExitCode(1) 348 | } 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /Mist/Commands/Download/DownloadFirmwareOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadFirmwareOptions.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 30/5/2022. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | struct DownloadFirmwareOptions: ParsableArguments { 11 | @Argument(help: """ 12 | Specify a macOS name, version or build to download: 13 | 14 | Name │ Version │ Build 15 | ───────────────┼─────────┼────── 16 | macOS Sequoia | 15.x │ 24xyz 17 | macOS Sonoma │ 14.x │ 23xyz 18 | macOS Ventura │ 13.x │ 22xyz 19 | macOS Monterey │ 12.x │ 21xyz 20 | macOS Big Sur │ 11.x │ 20xyz 21 | 22 | Note: Specifying a macOS name will assume the latest version and build of that particular macOS. 23 | Note: Specifying a macOS version will look for an exact match, otherwise assume the latest build of that particular macOS. 24 | """) 25 | var searchString: String 26 | 27 | @Flag(name: [.customShort("b"), .long], help: """ 28 | Include beta macOS Firmwares in search results. 29 | """) 30 | var includeBetas: Bool = false 31 | 32 | @Flag(name: .long, help: """ 33 | Only include macOS Firmwares that are compatible with this Mac in search results. 34 | """) 35 | var compatible: Bool = false 36 | 37 | @Flag(name: .long, help: """ 38 | Cache downloaded files in the temporary downloads directory. 39 | """) 40 | var cacheDownloads: Bool = false 41 | 42 | @Flag(name: .shortAndLong, help: """ 43 | Force overwriting existing macOS Downloads matching the provided filename(s). 44 | Note: Downloads will fail if an existing file is found and this flag is not provided. 45 | """) 46 | var force: Bool = false 47 | 48 | @Option(name: .long, help: """ 49 | Specify the macOS Firmware output filename. The following variables will be dynamically substituted: 50 | * %NAME% will be replaced with 'macOS Sonoma' 51 | * %VERSION% will be replaced with '14.0' 52 | * %BUILD% will be replaced with '23A344' 53 | """) 54 | var firmwareName: String = .filenameTemplate + ".ipsw" 55 | 56 | @Option(name: .shortAndLong, help: """ 57 | Specify the output directory. The following variables will be dynamically substituted: 58 | * %NAME% will be replaced with 'macOS Sonoma' 59 | * %VERSION% will be replaced with '14.0' 60 | * %BUILD% will be replaced with '23A344' 61 | Note: Parent directories will be created automatically. 62 | """) 63 | var outputDirectory: String = .outputDirectory 64 | 65 | @Option(name: .shortAndLong, help: """ 66 | Specify the temporary downloads directory. 67 | Note: Parent directories will be created automatically. 68 | """) 69 | var temporaryDirectory: String = .temporaryDirectory 70 | 71 | @Option(name: .long, help: """ 72 | Optionally specify the to an Apple Content Caching Server to help speed up downloads 73 | Note: Content Caching is only supported over HTTP, not HTTPS 74 | """) 75 | var cachingServer: String? 76 | 77 | @Option(name: [.customShort("e"), .customLong("export")], help: """ 78 | Specify the path to export the download results to one of the following formats: 79 | * /path/to/export.json (JSON file) 80 | * /path/to/export.plist (Property List file) 81 | * /path/to/export.yaml (YAML file) 82 | The following variables will be dynamically substituted: 83 | * %NAME% will be replaced with 'macOS Sonoma' 84 | * %VERSION% will be replaced with '14.0' 85 | * %BUILD% will be replaced with '23A344' 86 | Note: The file extension will determine the output file format. 87 | Note: Parent directories will be created automatically. 88 | """) 89 | var exportPath: String? 90 | 91 | @Option(name: .customLong("metadata-cache"), help: """ 92 | Optionally specify the path to cache the macOS Firmwares metadata JSON file. This cache is used when mist is unable to retrieve macOS Firmwares remotely. 93 | """) 94 | var metadataCachePath: String = .firmwaresMetadataCachePath 95 | 96 | @Flag(name: .long, help: """ 97 | Remove all ANSI escape sequences (ie. strip all color and formatting) from standard output. 98 | """) 99 | var noAnsi: Bool = false 100 | 101 | @Option(name: .long, help: """ 102 | Number of times to attempt resuming a download before failing. 103 | """) 104 | var retries: Int = 10 105 | 106 | @Option(name: .long, help: """ 107 | Number of seconds to wait before attempting to resume a download. 108 | """) 109 | var retryDelay: Int = 30 110 | 111 | @Flag(name: .shortAndLong, help: """ 112 | Suppress verbose output. 113 | """) 114 | var quiet: Bool = false 115 | } 116 | -------------------------------------------------------------------------------- /Mist/Commands/Download/DownloadInstallerOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadInstallerOptions.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 26/8/21. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | struct DownloadInstallerOptions: ParsableArguments { 11 | @Argument(help: """ 12 | Specify a macOS name, version or build to download: 13 | 14 | Name │ Version │ Build 15 | ───────────────────┼─────────┼────── 16 | macOS Sequoia │ 15.x │ 24xyz 17 | macOS Sonoma │ 14.x │ 23xyz 18 | macOS Ventura │ 13.x │ 22xyz 19 | macOS Monterey │ 12.x │ 21xyz 20 | macOS Big Sur │ 11.x │ 20xyz 21 | macOS Catalina │ 10.15.x │ 19xyz 22 | macOS Mojave │ 10.14.x │ 18xyz 23 | macOS High Sierra │ 10.13.x │ 17xyz 24 | macOS Sierra │ 10.12.x │ 16xyz 25 | OS X El Capitan │ 10.11.6 │ 15xyz 26 | OS X Yosemite │ 10.10.5 │ 14xyz 27 | OS X Mountain Lion │ 10.8.5 │ 12xyz 28 | Mac OS X Lion │ 10.7.5 │ 11xyz 29 | 30 | Note: Specifying a macOS name will assume the latest version and build of that particular macOS. 31 | Note: Specifying a macOS version will look for an exact match, otherwise assume the latest build of that particular macOS. 32 | """) 33 | var searchString: String 34 | 35 | @Argument(help: """ 36 | Specify the requested output type(s): 37 | * application to generate a macOS Installer Application Bundle (.app). 38 | * image to generate a macOS Disk Image (.dmg). 39 | * iso to generate a Bootable macOS Disk Image (.iso), for use with virtualization software (ie. Parallels Desktop, VMware Fusion, VirtualBox). 40 | Note: This option will fail when targeting OS X Mountain Lion 10.8 and older on Apple Silicon Macs. 41 | * package to generate a macOS Installer Package (.pkg). 42 | * bootableinstaller to create a Bootable macOS Installer on a mounted volume 43 | Note: This option will fail when targeting OS X Mountain Lion 10.8 and older on Apple Silicon Macs. 44 | """) 45 | var outputType: [InstallerOutputType] 46 | 47 | @Flag(name: [.customShort("b"), .long], help: """ 48 | Include beta macOS Installers in search results. 49 | """) 50 | var includeBetas: Bool = false 51 | 52 | @Flag(name: .long, help: """ 53 | Only include macOS Installers that are compatible with this Mac in search results. 54 | """) 55 | var compatible: Bool = false 56 | 57 | @Option(name: .shortAndLong, help: """ 58 | Override the default Software Update Catalog URLs. 59 | """) 60 | var catalogURL: String? 61 | 62 | @Flag(name: .long, help: """ 63 | Cache downloaded files in the temporary downloads directory. 64 | """) 65 | var cacheDownloads: Bool = false 66 | 67 | @Flag(name: .shortAndLong, help: """ 68 | Force overwriting existing macOS Downloads matching the provided filename(s). 69 | Note: Downloads will fail if an existing file is found and this flag is not provided. 70 | """) 71 | var force: Bool = false 72 | 73 | @Option(name: .long, help: """ 74 | Specify the macOS Installer output filename. The following variables will be dynamically substituted: 75 | * %NAME% will be replaced with 'macOS Sonoma' 76 | * %VERSION% will be replaced with '14.0' 77 | * %BUILD% will be replaced with '23A344' 78 | """) 79 | var applicationName: String = .filenameTemplate + ".app" 80 | 81 | @Option(name: .long, help: """ 82 | Specify the macOS Disk Image output filename. The following variables will be dynamically substituted: 83 | * %NAME% will be replaced with 'macOS Sonoma' 84 | * %VERSION% will be replaced with '14.0' 85 | * %BUILD% will be replaced with '23A344' 86 | """) 87 | var imageName: String = .filenameTemplate + ".dmg" 88 | 89 | @Option(name: .long, help: """ 90 | Codesign the exported macOS Disk Image (.dmg). 91 | Specify a signing identity name, eg. "Developer ID Application: Name (Team ID)". 92 | """) 93 | var imageSigningIdentity: String? 94 | 95 | @Option(name: .long, help: """ 96 | Specify the Bootable macOS Disk Image output filename. The following variables will be dynamically substituted: 97 | * %NAME% will be replaced with 'macOS Sonoma' 98 | * %VERSION% will be replaced with '14.0' 99 | * %BUILD% will be replaced with '23A344' 100 | """) 101 | var isoName: String = .filenameTemplate + ".iso" 102 | 103 | @Option(name: .long, help: """ 104 | Specify the macOS Installer Package output filename. The following variables will be dynamically substituted: 105 | * %NAME% will be replaced with 'macOS Sonoma' 106 | * %VERSION% will be replaced with '14.0' 107 | * %BUILD% will be replaced with '23A344' 108 | """) 109 | var packageName: String = .filenameTemplate + ".pkg" 110 | 111 | @Option(name: .long, help: """ 112 | Specify the macOS Installer Package identifier. The following variables will be dynamically substituted: 113 | * %NAME% will be replaced with 'macOS Sonoma' 114 | * %VERSION% will be replaced with '14.0' 115 | * %BUILD% will be replaced with '23A344' 116 | * Spaces will be replaced with hyphens - 117 | """) 118 | var packageIdentifier: String = .packageIdentifierTemplate 119 | 120 | @Option(name: .long, help: """ 121 | Codesign the exported macOS Installer Package (.pkg). 122 | Specify a signing identity name, eg. "Developer ID Installer: Name (Team ID)". 123 | """) 124 | var packageSigningIdentity: String? 125 | 126 | @Option(name: .long, help: """ 127 | Path to the mounted volume that will be used to create the Bootable macOS Installer. 128 | Note: The volume must be formatted as 'Mac OS Extended (Journaled)'. Use Disk Utility to format volumes as required. 129 | Note: The volume will be erased automatically. Ensure you have backed up any necessary data before proceeding. 130 | """) 131 | var bootableInstallerVolume: String? 132 | 133 | @Option(name: .long, help: """ 134 | Specify a keychain path to search for signing identities. 135 | Note: If no keychain is specified, the default user login keychain will be used. 136 | """) 137 | var keychain: String? 138 | 139 | @Option(name: .shortAndLong, help: """ 140 | Specify the output directory. The following variables will be dynamically substituted: 141 | * %NAME% will be replaced with 'macOS Sonoma' 142 | * %VERSION% will be replaced with '14.0' 143 | * %BUILD% will be replaced with '23A344' 144 | Note: Parent directories will be created automatically. 145 | """) 146 | var outputDirectory: String = .outputDirectory 147 | 148 | @Option(name: .shortAndLong, help: """ 149 | Specify the temporary downloads directory. 150 | Note: Parent directories will be created automatically. 151 | """) 152 | var temporaryDirectory: String = .temporaryDirectory 153 | 154 | @Option(name: .long, help: """ 155 | Optionally specify the to an Apple Content Caching Server to help speed up downloads 156 | Note: Content Caching is only supported over HTTP, not HTTPS 157 | """) 158 | var cachingServer: String? 159 | 160 | @Option(name: [.customShort("e"), .customLong("export")], help: """ 161 | Specify the path to export the download results to one of the following formats: 162 | * /path/to/export.json (JSON file) 163 | * /path/to/export.plist (Property List file) 164 | * /path/to/export.yaml (YAML file) 165 | The following variables will be dynamically substituted: 166 | * %NAME% will be replaced with 'macOS Sonoma' 167 | * %VERSION% will be replaced with '14.0' 168 | * %BUILD% will be replaced with '23A344' 169 | Note: The file extension will determine the output file format. 170 | Note: Parent directories will be created automatically. 171 | """) 172 | var exportPath: String? 173 | 174 | @Flag(name: .long, help: """ 175 | Remove all ANSI escape sequences (ie. strip all color and formatting) from standard output. 176 | """) 177 | var noAnsi: Bool = false 178 | 179 | @Option(name: .long, help: """ 180 | Number of times to attempt resuming a download before failing. 181 | """) 182 | var retries: Int = 10 183 | 184 | @Option(name: .long, help: """ 185 | Number of seconds to wait before attempting to resume a download. 186 | """) 187 | var retryDelay: Int = 30 188 | 189 | @Flag(name: .shortAndLong, help: """ 190 | Suppress verbose output. 191 | """) 192 | var quiet: Bool = false 193 | } 194 | -------------------------------------------------------------------------------- /Mist/Commands/List/ListCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListCommand.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 26/8/21. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | struct ListCommand: ParsableCommand { 11 | static var configuration: CommandConfiguration = .init( 12 | commandName: "list", 13 | abstract: "List all macOS Firmwares / Installers available to download.", 14 | subcommands: [ListFirmwareCommand.self, ListInstallerCommand.self] 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /Mist/Commands/List/ListFirmwareCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListFirmwareCommand.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 30/5/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | /// Struct used to perform **List Firmware** operations. 12 | struct ListFirmwareCommand: ParsableCommand { 13 | static var configuration: CommandConfiguration = .init( 14 | commandName: "firmware", 15 | abstract: """ 16 | List all macOS Firmwares available to download. 17 | * macOS Firmwares are for Apple Silicon Macs only. 18 | """, 19 | aliases: ["ipsw"] 20 | ) 21 | @OptionGroup var options: ListFirmwareOptions 22 | 23 | /// Searches and lists the macOS versions available for download, optionally exporting to a file. 24 | /// 25 | /// - Parameters: 26 | /// - options: List options for macOS Firmwares. 27 | /// 28 | /// - Throws: A `MistError` if macOS versions fail to be retrieved or exported. 29 | static func run(options: ListFirmwareOptions) throws { 30 | !options.quiet ? Mist.checkForNewVersion(noAnsi: options.noAnsi) : Mist.noop() 31 | try inputValidation(options) 32 | !options.quiet ? PrettyPrint.printHeader("SEARCH", noAnsi: options.noAnsi) : Mist.noop() 33 | !options.quiet ? PrettyPrint.print("Searching for macOS Firmware versions...", noAnsi: options.noAnsi) : Mist.noop() 34 | var firmwares: [Firmware] = HTTP.retrieveFirmwares( 35 | includeBetas: options.includeBetas, 36 | compatible: options.compatible, 37 | metadataCachePath: options.metadataCachePath, 38 | noAnsi: options.noAnsi, 39 | quiet: options.quiet 40 | ) 41 | 42 | if let searchString: String = options.searchString { 43 | firmwares = HTTP.firmwares(from: firmwares, searchString: searchString) 44 | } 45 | 46 | if options.latest { 47 | if let firmware: Firmware = firmwares.first { 48 | firmwares = [firmware] 49 | } 50 | } 51 | 52 | try export(firmwares.map(\.dictionary), options: options) 53 | !options.quiet ? PrettyPrint.print("Found \(firmwares.count) macOS Firmware(s) available for download\n", noAnsi: options.noAnsi, prefix: .ending) : Mist.noop() 54 | 55 | if !firmwares.isEmpty { 56 | try list(firmwares.map(\.dictionary), options: options) 57 | } 58 | } 59 | 60 | /// Perform a series of validations on input data, throwing an error if the input data is invalid. 61 | /// 62 | /// - Parameters: 63 | /// - options: List options for macOS Firmwares. 64 | /// 65 | /// - Throws: A `MistError` if any of the input validations fail. 66 | private static func inputValidation(_ options: ListFirmwareOptions) throws { 67 | !options.quiet ? PrettyPrint.printHeader("INPUT VALIDATION", noAnsi: options.noAnsi) : Mist.noop() 68 | 69 | if let string: String = options.searchString { 70 | guard !string.isEmpty else { 71 | throw MistError.missingListSearchString 72 | } 73 | 74 | !options.quiet ? PrettyPrint.print("List search string will be '\(string)'...", noAnsi: options.noAnsi) : Mist.noop() 75 | } 76 | 77 | !options.quiet ? PrettyPrint.print("Search only for latest (first) result will be '\(options.latest)'...", noAnsi: options.noAnsi) : Mist.noop() 78 | 79 | !options.quiet ? PrettyPrint.print("Include betas in search results will be '\(options.includeBetas)'...", noAnsi: options.noAnsi) : Mist.noop() 80 | 81 | !options.quiet ? PrettyPrint.print("Only include compatible firmwares will be '\(options.compatible)'...", noAnsi: options.noAnsi) : Mist.noop() 82 | 83 | if let path: String = options.exportPath { 84 | guard !path.isEmpty else { 85 | throw MistError.missingExportPath 86 | } 87 | 88 | !options.quiet ? PrettyPrint.print("Export path will be '\(path)'...", noAnsi: options.noAnsi) : Mist.noop() 89 | 90 | let url: URL = .init(fileURLWithPath: path) 91 | 92 | guard ["csv", "json", "plist", "yaml"].contains(url.pathExtension) else { 93 | throw MistError.invalidExportFileExtension 94 | } 95 | 96 | !options.quiet ? PrettyPrint.print("Export path file extension is valid...", noAnsi: options.noAnsi) : Mist.noop() 97 | } 98 | 99 | guard !options.metadataCachePath.isEmpty else { 100 | throw MistError.missingFirmwareMetadataCachePath 101 | } 102 | 103 | !options.quiet ? PrettyPrint.print("macOS Firmware metadata cache path will be '\(options.metadataCachePath)'...", noAnsi: options.noAnsi) : Mist.noop() 104 | 105 | !options.quiet ? PrettyPrint.print("Output type will be '\(options.outputType)'...", noAnsi: options.noAnsi) : Mist.noop() 106 | } 107 | 108 | /// Export the macOS downloads list. 109 | /// 110 | /// - Parameters: 111 | /// - dictionaries: The array of dictionaries to be written to disk. 112 | /// - options: List options for macOS Firmwares. 113 | /// 114 | /// - Throws: An `Error` if the dictionaries are unable to be written to disk. 115 | private static func export(_ dictionaries: [[String: Any]], options: ListFirmwareOptions) throws { 116 | guard let path: String = options.exportPath else { 117 | return 118 | } 119 | 120 | let url: URL = .init(fileURLWithPath: path) 121 | let directory: URL = url.deletingLastPathComponent() 122 | 123 | if !FileManager.default.fileExists(atPath: directory.path) { 124 | !options.quiet ? PrettyPrint.print("Creating parent directory '\(directory.path)'...", noAnsi: options.noAnsi) : Mist.noop() 125 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) 126 | } 127 | 128 | switch url.pathExtension { 129 | case "csv": 130 | try dictionaries.firmwaresCSVString().write(toFile: path, atomically: true, encoding: .utf8) 131 | !options.quiet ? PrettyPrint.print("Exported list as CSV: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 132 | case "json": 133 | try dictionaries.jsonString().write(toFile: path, atomically: true, encoding: .utf8) 134 | !options.quiet ? PrettyPrint.print("Exported list as JSON: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 135 | case "plist": 136 | try dictionaries.propertyListString().write(toFile: path, atomically: true, encoding: .utf8) 137 | !options.quiet ? PrettyPrint.print("Exported list as Property List: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 138 | case "yaml": 139 | try dictionaries.yamlString().write(toFile: path, atomically: true, encoding: .utf8) 140 | !options.quiet ? PrettyPrint.print("Exported list as YAML: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 141 | default: 142 | break 143 | } 144 | } 145 | 146 | /// List the macOS downloads to standard output. 147 | /// 148 | /// - Parameters: 149 | /// - dictionaries: The array of dictionaries to be printed to standard output. 150 | /// - options: List options for macOS Firmwares. 151 | /// 152 | /// - Throws: A `MistError` if the list is unable to be printed to standard output. 153 | private static func list(_ dictionaries: [[String: Any]], options: ListFirmwareOptions) throws { 154 | switch options.outputType { 155 | case .ascii: 156 | print(dictionaries.firmwaresASCIIString(noAnsi: options.noAnsi)) 157 | case .csv: 158 | print(dictionaries.firmwaresCSVString()) 159 | case .json: 160 | try print(dictionaries.jsonString()) 161 | case .plist: 162 | try print(dictionaries.propertyListString()) 163 | case .yaml: 164 | try print(dictionaries.yamlString()) 165 | } 166 | } 167 | 168 | mutating func run() throws { 169 | do { 170 | try ListFirmwareCommand.run(options: options) 171 | } catch { 172 | if let mistError: MistError = error as? MistError { 173 | PrettyPrint.print(mistError.description, noAnsi: options.noAnsi, prefix: .ending, prefixColor: .red) 174 | } else { 175 | PrettyPrint.print(error.localizedDescription, noAnsi: options.noAnsi, prefix: .ending, prefixColor: .red) 176 | } 177 | 178 | throw ExitCode(1) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Mist/Commands/List/ListFirmwareOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListFirmwareOptions.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 30/5/2022. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | struct ListFirmwareOptions: ParsableArguments { 11 | @Argument(help: """ 12 | Optionally specify a macOS name, version or build to filter the list results: 13 | 14 | Name │ Version │ Build 15 | ───────────────┼─────────┼────── 16 | macOS Sequoia │ 15.x │ 24xyz 17 | macOS Sonoma │ 14.x │ 23xyz 18 | macOS Ventura │ 13.x │ 22xyz 19 | macOS Monterey │ 12.x │ 21xyz 20 | macOS Big Sur │ 11.x │ 20xyz 21 | 22 | Note: Specifying a macOS name will assume the latest version and build of that particular macOS. 23 | Note: Specifying a macOS version will look for an exact match, otherwise assume the latest build of that particular macOS. 24 | """) 25 | var searchString: String? 26 | 27 | @Flag(name: .shortAndLong, help: """ 28 | Filter only the latest (first) result that is found. 29 | """) 30 | var latest: Bool = false 31 | 32 | @Flag(name: [.customShort("b"), .long], help: """ 33 | Include beta macOS Firmwares in search results. 34 | """) 35 | var includeBetas: Bool = false 36 | 37 | @Flag(name: .long, help: """ 38 | Only include macOS Firmwares that are compatible with this Mac in search results. 39 | """) 40 | var compatible: Bool = false 41 | 42 | @Option(name: [.customShort("e"), .customLong("export")], help: """ 43 | Specify the path to export the list to one of the following formats: 44 | * /path/to/export.csv (CSV file) 45 | * /path/to/export.json (JSON file) 46 | * /path/to/export.plist (Property List file) 47 | * /path/to/export.yaml (YAML file) 48 | Note: The file extension will determine the output file format. 49 | """) 50 | var exportPath: String? 51 | 52 | @Option(name: .customLong("metadata-cache"), help: """ 53 | Optionally specify the path to cache the macOS Firmwares metadata JSON file. This cache is used when mist is unable to retrieve macOS Firmwares remotely. 54 | """) 55 | var metadataCachePath: String = .firmwaresMetadataCachePath 56 | 57 | @Flag(name: .long, help: """ 58 | Remove all ANSI escape sequences (ie. strip all color and formatting) from standard output. 59 | """) 60 | var noAnsi: Bool = false 61 | 62 | @Option(name: .shortAndLong, help: """ 63 | Specify the standard output format: 64 | * ascii (ASCII table) 65 | * csv (Comma Separated Values) 66 | * json (JSON - pretty printed) 67 | * plist (Property List) 68 | * yaml (YAML file) 69 | """) 70 | var outputType: ListOutputType = .ascii 71 | 72 | @Flag(name: .shortAndLong, help: """ 73 | Suppress verbose output. 74 | """) 75 | var quiet: Bool = false 76 | } 77 | -------------------------------------------------------------------------------- /Mist/Commands/List/ListInstallerCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListInstallerCommand.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 10/3/21. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | /// Struct used to perform **List Installer** operations. 12 | struct ListInstallerCommand: ParsableCommand { 13 | static var configuration: CommandConfiguration = .init( 14 | commandName: "installer", 15 | abstract: """ 16 | List all macOS Installers available to download. 17 | * macOS Installers for macOS Catalina 10.15 and older are for Intel based Macs only. 18 | * macOS Installers for macOS Big Sur 11 and newer are Universal - for both Apple Silicon and Intel based Macs. 19 | """ 20 | ) 21 | @OptionGroup var options: ListInstallerOptions 22 | 23 | /// Searches and lists the macOS versions available for download, optionally exporting to a file. 24 | /// 25 | /// - Parameters: 26 | /// - options: List options for macOS Installers. 27 | /// 28 | /// - Throws: A `MistError` if macOS versions fail to be retrieved or exported. 29 | static func run(options: ListInstallerOptions) throws { 30 | !options.quiet ? Mist.checkForNewVersion(noAnsi: options.noAnsi) : Mist.noop() 31 | try inputValidation(options) 32 | !options.quiet ? PrettyPrint.printHeader("SEARCH", noAnsi: options.noAnsi) : Mist.noop() 33 | !options.quiet ? PrettyPrint.print("Searching for macOS Installer versions...", noAnsi: options.noAnsi) : Mist.noop() 34 | var catalogURLs: [String] = options.includeBetas ? Catalog.urls : [Catalog.standard.url] 35 | 36 | if let catalogURL: String = options.catalogURL { 37 | catalogURLs = [catalogURL] 38 | } 39 | 40 | var installers: [Installer] = HTTP.retrieveInstallers(from: catalogURLs, includeBetas: options.includeBetas, compatible: options.compatible, noAnsi: options.noAnsi, quiet: options.quiet) 41 | 42 | if let searchString: String = options.searchString { 43 | installers = HTTP.installers(from: installers, searchString: searchString) 44 | } 45 | 46 | if options.latest { 47 | if let installer: Installer = installers.first { 48 | installers = [installer] 49 | } 50 | } 51 | 52 | try export(installers.map(\.dictionary), options: options) 53 | !options.quiet ? PrettyPrint.print("Found \(installers.count) macOS Installer(s) available for download\n", noAnsi: options.noAnsi, prefix: .ending) : Mist.noop() 54 | 55 | if !installers.isEmpty { 56 | try list(installers.map(\.dictionary), options: options) 57 | } 58 | } 59 | 60 | /// Perform a series of validations on input data, throwing an error if the input data is invalid. 61 | /// 62 | /// - Parameters: 63 | /// - options: List options for macOS Installers. 64 | /// 65 | /// - Throws: A `MistError` if any of the input validations fail. 66 | private static func inputValidation(_ options: ListInstallerOptions) throws { 67 | !options.quiet ? PrettyPrint.printHeader("INPUT VALIDATION", noAnsi: options.noAnsi) : Mist.noop() 68 | 69 | if let string: String = options.searchString { 70 | guard !string.isEmpty else { 71 | throw MistError.missingListSearchString 72 | } 73 | 74 | !options.quiet ? PrettyPrint.print("List search string will be '\(string)'...", noAnsi: options.noAnsi) : Mist.noop() 75 | } 76 | 77 | !options.quiet ? PrettyPrint.print("Search only for latest (first) result will be '\(options.latest)'...", noAnsi: options.noAnsi) : Mist.noop() 78 | 79 | !options.quiet ? PrettyPrint.print("Include betas in search results will be '\(options.includeBetas)'...", noAnsi: options.noAnsi) : Mist.noop() 80 | 81 | !options.quiet ? PrettyPrint.print("Only include compatible installers will be '\(options.compatible)'...", noAnsi: options.noAnsi) : Mist.noop() 82 | 83 | if let path: String = options.exportPath { 84 | guard !path.isEmpty else { 85 | throw MistError.missingExportPath 86 | } 87 | 88 | !options.quiet ? PrettyPrint.print("Export path will be '\(path)'...", noAnsi: options.noAnsi) : Mist.noop() 89 | 90 | let url: URL = .init(fileURLWithPath: path) 91 | 92 | guard ["csv", "json", "plist", "yaml"].contains(url.pathExtension) else { 93 | throw MistError.invalidExportFileExtension 94 | } 95 | 96 | !options.quiet ? PrettyPrint.print("Export path file extension is valid...", noAnsi: options.noAnsi) : Mist.noop() 97 | } 98 | 99 | !options.quiet ? PrettyPrint.print("Output type will be '\(options.outputType)'...", noAnsi: options.noAnsi) : Mist.noop() 100 | } 101 | 102 | /// Export the macOS downloads list. 103 | /// 104 | /// - Parameters: 105 | /// - dictionaries: The array of dictionaries to be written to disk. 106 | /// - options: List options for macOS Installers. 107 | /// 108 | /// - Throws: An `Error` if the dictionaries are unable to be written to disk. 109 | private static func export(_ dictionaries: [[String: Any]], options: ListInstallerOptions) throws { 110 | guard let path: String = options.exportPath else { 111 | return 112 | } 113 | 114 | let url: URL = .init(fileURLWithPath: path) 115 | let directory: URL = url.deletingLastPathComponent() 116 | 117 | if !FileManager.default.fileExists(atPath: directory.path) { 118 | !options.quiet ? PrettyPrint.print("Creating parent directory '\(directory.path)'...", noAnsi: options.noAnsi) : Mist.noop() 119 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) 120 | } 121 | 122 | switch url.pathExtension { 123 | case "csv": 124 | try dictionaries.installersCSVString().write(toFile: path, atomically: true, encoding: .utf8) 125 | !options.quiet ? PrettyPrint.print("Exported list as CSV: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 126 | case "json": 127 | try dictionaries.jsonString().write(toFile: path, atomically: true, encoding: .utf8) 128 | !options.quiet ? PrettyPrint.print("Exported list as JSON: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 129 | case "plist": 130 | try dictionaries.propertyListString().write(toFile: path, atomically: true, encoding: .utf8) 131 | !options.quiet ? PrettyPrint.print("Exported list as Property List: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 132 | case "yaml": 133 | try dictionaries.yamlString().write(toFile: path, atomically: true, encoding: .utf8) 134 | !options.quiet ? PrettyPrint.print("Exported list as YAML: '\(path)'", noAnsi: options.noAnsi) : Mist.noop() 135 | default: 136 | break 137 | } 138 | } 139 | 140 | /// List the macOS downloads to standard output. 141 | /// 142 | /// - Parameters: 143 | /// - dictionaries: The array of dictionaries to be printed to standard output. 144 | /// - options: List options for macOS Installers. 145 | /// 146 | /// - Throws: A `MistError` if the list is unable to be printed to standard output. 147 | private static func list(_ dictionaries: [[String: Any]], options: ListInstallerOptions) throws { 148 | switch options.outputType { 149 | case .ascii: 150 | print(dictionaries.installersASCIIString(noAnsi: options.noAnsi)) 151 | case .csv: 152 | print(dictionaries.installersCSVString()) 153 | case .json: 154 | try print(dictionaries.jsonString()) 155 | case .plist: 156 | try print(dictionaries.propertyListString()) 157 | case .yaml: 158 | try print(dictionaries.yamlString()) 159 | } 160 | } 161 | 162 | mutating func run() throws { 163 | do { 164 | try ListInstallerCommand.run(options: options) 165 | } catch { 166 | if let mistError: MistError = error as? MistError { 167 | PrettyPrint.print(mistError.description, noAnsi: options.noAnsi, prefix: .ending, prefixColor: .red) 168 | } else { 169 | PrettyPrint.print(error.localizedDescription, noAnsi: options.noAnsi, prefix: .ending, prefixColor: .red) 170 | } 171 | 172 | throw ExitCode(1) 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Mist/Commands/List/ListInstallerOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListInstallerOptions.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 26/8/21. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | struct ListInstallerOptions: ParsableArguments { 11 | @Argument(help: """ 12 | Optionally specify a macOS name, version or build to filter the list results: 13 | 14 | Name │ Version │ Build 15 | ───────────────────┼─────────┼────── 16 | macOS Sequoia │ 15.x │ 24xyz 17 | macOS Sonoma │ 14.x │ 23xyz 18 | macOS Ventura │ 13.x │ 22xyz 19 | macOS Monterey │ 12.x │ 21xyz 20 | macOS Big Sur │ 11.x │ 20xyz 21 | macOS Catalina │ 10.15.x │ 19xyz 22 | macOS Mojave │ 10.14.x │ 18xyz 23 | macOS High Sierra │ 10.13.x │ 17xyz 24 | macOS Sierra │ 10.12.x │ 16xyz 25 | OS X El Capitan │ 10.11.6 │ 15xyz 26 | OS X Yosemite │ 10.10.5 │ 14xyz 27 | OS X Mountain Lion │ 10.8.5 │ 12xyz 28 | Mac OS X Lion │ 10.7.5 │ 11xyz 29 | 30 | Note: Specifying a macOS name will assume the latest version and build of that particular macOS. 31 | Note: Specifying a macOS version will look for an exact match, otherwise assume the latest build of that particular macOS. 32 | """) 33 | var searchString: String? 34 | 35 | @Flag(name: .shortAndLong, help: """ 36 | Filter only the latest (first) result that is found. 37 | """) 38 | var latest: Bool = false 39 | 40 | @Flag(name: [.customShort("b"), .long], help: """ 41 | Include beta macOS Installers in search results. 42 | """) 43 | var includeBetas: Bool = false 44 | 45 | @Flag(name: .long, help: """ 46 | Only include macOS Installers that are compatible with this Mac in search results. 47 | """) 48 | var compatible: Bool = false 49 | 50 | @Option(name: .shortAndLong, help: """ 51 | Override the default Software Update Catalog URLs. 52 | """) 53 | var catalogURL: String? 54 | 55 | @Option(name: [.customShort("e"), .customLong("export")], help: """ 56 | Specify the path to export the list to one of the following formats: 57 | * /path/to/export.csv (CSV file) 58 | * /path/to/export.json (JSON file) 59 | * /path/to/export.plist (Property List file) 60 | * /path/to/export.yaml (YAML file) 61 | Note: The file extension will determine the output file format. 62 | """) 63 | var exportPath: String? 64 | 65 | @Flag(name: .long, help: """ 66 | Remove all ANSI escape sequences (ie. strip all color and formatting) from standard output. 67 | """) 68 | var noAnsi: Bool = false 69 | 70 | @Option(name: .shortAndLong, help: """ 71 | Specify the standard output format: 72 | * ascii (ASCII table) 73 | * csv (Comma Separated Values) 74 | * json (JSON - pretty printed) 75 | * plist (Property List) 76 | * yaml (YAML file) 77 | """) 78 | var outputType: ListOutputType = .ascii 79 | 80 | @Flag(name: .shortAndLong, help: """ 81 | Suppress verbose output. 82 | """) 83 | var quiet: Bool = false 84 | } 85 | -------------------------------------------------------------------------------- /Mist/Commands/Mist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mist.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 10/3/21. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | struct Mist: ParsableCommand { 12 | static let configuration: CommandConfiguration = .init(abstract: .abstract, discussion: .discussion, version: version(), subcommands: [ListCommand.self, DownloadCommand.self]) 13 | 14 | /// Current version. 15 | private static let currentVersion: String = "2.1.1" 16 | 17 | /// Visit URL string. 18 | private static let visitURLString: String = "Visit \(String.repositoryURL) to grab the latest release of \(String.appName)" 19 | 20 | static func noop() {} 21 | 22 | private static func getLatestVersion() -> String? { 23 | guard let url: URL = URL(string: .latestReleaseURL) else { 24 | return nil 25 | } 26 | 27 | do { 28 | let string: String = try String(contentsOf: url, encoding: .utf8) 29 | 30 | guard 31 | let data: Data = string.data(using: .utf8), 32 | let dictionary: [String: Any] = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], 33 | let tag: String = dictionary["tag_name"] as? String else { 34 | return nil 35 | } 36 | 37 | let latestVersion: String = tag.replacingOccurrences(of: "v", with: "") 38 | return latestVersion 39 | } catch { 40 | return nil 41 | } 42 | } 43 | 44 | static func version() -> String { 45 | guard let latestVersion: String = getLatestVersion() else { 46 | return "\(currentVersion) (Unable to check for latest version)" 47 | } 48 | 49 | var string: String = "\(currentVersion) (latest: \(latestVersion))" 50 | 51 | guard currentVersion.compare(latestVersion, options: .numeric) == .orderedAscending else { 52 | return string 53 | } 54 | 55 | string += "\n\(visitURLString)" 56 | return string 57 | } 58 | 59 | static func checkForNewVersion(noAnsi: Bool) { 60 | guard 61 | let latestVersion: String = getLatestVersion(), 62 | currentVersion.compare(latestVersion, options: .numeric) == .orderedAscending else { 63 | return 64 | } 65 | 66 | PrettyPrint.printHeader("UPDATE AVAILABLE", noAnsi: noAnsi) 67 | let updateAvailableString: String = "There is a \(String.appName) update available (current version: \(currentVersion), latest version: \(latestVersion))".color(noAnsi ? .reset : .yellow) 68 | let visitURLString: String = visitURLString.color(noAnsi ? .reset : .yellow) 69 | PrettyPrint.print(updateAvailableString, noAnsi: noAnsi) 70 | PrettyPrint.print(visitURLString, noAnsi: noAnsi) 71 | } 72 | 73 | mutating func run() { 74 | print(Mist.helpMessage()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Mist/Extensions/Dictionary+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 31/8/21. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | extension Dictionary where Key == String { 12 | /// Returns a CSV-formatted string for the provided `Firmware` dictionary. 13 | /// 14 | /// - Returns: A CSV-formatted string for the provided `Firmware` dictionary. 15 | func firmwareCSVString() -> String { 16 | guard 17 | let name: String = self["name"] as? String, 18 | let version: String = self["version"] as? String, 19 | let build: String = self["build"] as? String, 20 | let size: Int64 = self["size"] as? Int64, 21 | let url: String = self["url"] as? String, 22 | let date: String = self["date"] as? String, 23 | let compatible: Bool = self["compatible"] as? Bool, 24 | let signed: Bool = self["signed"] as? Bool, 25 | let beta: Bool = self["beta"] as? Bool else { 26 | return "" 27 | } 28 | 29 | let nameString: String = "\"\(name)\"" 30 | let versionString: String = "\"=\"\"\(version)\"\"\"" 31 | let buildString: String = "\"=\"\"\(build)\"\"\"" 32 | let sizeString: String = "\(size)" 33 | let urlString: String = "\"=\"\"\(url)\"\"\"" 34 | let dateString: String = "\(date)" 35 | let compatibleString: String = "\(compatible ? "YES" : "NO")" 36 | let signedString: String = "\(signed ? "YES" : "NO")" 37 | let betaString: String = "\(beta ? "YES" : "NO")" 38 | 39 | let string: String = [ 40 | nameString, 41 | versionString, 42 | buildString, 43 | sizeString, 44 | urlString, 45 | dateString, 46 | compatibleString, 47 | signedString, 48 | betaString 49 | ].joined(separator: ",") + "\n" 50 | return string 51 | } 52 | 53 | /// Returns a CSV-formatted string for the provided `Installer` dictionary. 54 | /// 55 | /// - Returns: A CSV-formatted string for the provided `Installer` dictionary. 56 | func installerCSVString() -> String { 57 | guard 58 | let identifier: String = self["identifier"] as? String, 59 | let name: String = self["name"] as? String, 60 | let version: String = self["version"] as? String, 61 | let build: String = self["build"] as? String, 62 | let size: Int64 = self["size"] as? Int64, 63 | let date: String = self["date"] as? String, 64 | let compatible: Bool = self["compatible"] as? Bool, 65 | let beta: Bool = self["beta"] as? Bool else { 66 | return "" 67 | } 68 | 69 | let identifierString: String = "\"\(identifier)\"" 70 | let nameString: String = "\"\(name)\"" 71 | let versionString: String = "\"=\"\"\(version)\"\"\"" 72 | let buildString: String = "\"=\"\"\(build)\"\"\"" 73 | let sizeString: String = "\(size)" 74 | let dateString: String = "\(date)" 75 | let compatibleString: String = "\(compatible ? "YES" : "NO")" 76 | let betaString: String = "\(beta ? "YES" : "NO")" 77 | 78 | let string: String = [ 79 | identifierString, 80 | nameString, 81 | versionString, 82 | buildString, 83 | sizeString, 84 | dateString, 85 | compatibleString, 86 | betaString 87 | ].joined(separator: ",") + "\n" 88 | return string 89 | } 90 | 91 | /// Returns a JSON string for the provided dictionary. 92 | /// 93 | /// - Throws: An error if the JSON string cannot be created. 94 | /// 95 | /// - Returns: A JSON string for the provided dictionary. 96 | func jsonString() throws -> String { 97 | let data: Data = try JSONSerialization.data(withJSONObject: self, options: [.prettyPrinted, .sortedKeys]) 98 | let string: String = .init(decoding: data, as: UTF8.self) 99 | return string 100 | } 101 | 102 | /// Returns a Property List string for the provided dictionary. 103 | /// 104 | /// - Throws: An error if the Property List string cannot be created. 105 | /// 106 | /// - Returns: A Propery List string for the provided dictionary. 107 | func propertyListString() throws -> String { 108 | let data: Data = try PropertyListSerialization.data(fromPropertyList: self, format: .xml, options: .bitWidth) 109 | let string: String = .init(decoding: data, as: UTF8.self) 110 | return string 111 | } 112 | 113 | /// Returns a YAML string for the provided dictionary. 114 | /// 115 | /// - Throws: An error if the YAML string cannot be created. 116 | /// 117 | /// - Returns: A YAML string for the provided dictionary. 118 | func yamlString() throws -> String { 119 | try Yams.dump(object: self) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Mist/Extensions/Int64+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Int64+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 16/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Int64 { 11 | /// KiloBytes constant. 12 | static let kilobyte: Int64 = 1_000 13 | /// MegaBytes constant. 14 | static let megabyte: Int64 = .kilobyte * 1_000 15 | /// GigaBytes constant. 16 | static let gigabyte: Int64 = .megabyte * 1_000 17 | 18 | /// Returns a bytes-formatted string for the provided `Int64`. 19 | /// 20 | /// - Returns: A bytes-formatted string for the provided `Int64`. 21 | func bytesString() -> String { 22 | if self < .kilobyte { 23 | String(format: " %04d B", self) 24 | } else if self < .megabyte { 25 | String(format: "%05.2f KB", Double(self) / Double(Int64.kilobyte)) 26 | } else if self < .gigabyte { 27 | String(format: "%05.2f MB", Double(self) / Double(Int64.megabyte)) 28 | } else { 29 | String(format: "%05.2f GB", Double(self) / Double(Int64.gigabyte)) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Mist/Extensions/Sequence+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 1/9/21. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | extension Sequence where Iterator.Element == [String: Any] { 12 | // swiftlint:disable function_body_length 13 | /// Returns an ASCII-formatted table string for the provided array of `Firmware` dictionaries. 14 | /// 15 | /// - Parameters: 16 | /// - noAnsi: Set to `true` to return the string without any color or formatting. 17 | /// 18 | /// - Returns: An ASCII-formatted table string for the provided array of `Firmware` dictionaries. 19 | func firmwaresASCIIString(noAnsi: Bool) -> String { 20 | let signedHeading: String = "SIGNED" 21 | let nameHeading: String = "NAME" 22 | let versionHeading: String = "VERSION" 23 | let buildHeading: String = "BUILD" 24 | let sizeHeading: String = "SIZE" 25 | let dateHeading: String = "DATE" 26 | let compatibleHeading: String = "COMPATIBLE" 27 | 28 | let maximumSignedLength: Int = compactMap { $0["signed"] as? Bool }.map { $0 ? "True" : "False" }.maximumStringLength(comparing: signedHeading) 29 | let maximumNameLength: Int = compactMap { $0["name"] as? String }.maximumStringLength(comparing: nameHeading) 30 | let maximumVersionLength: Int = compactMap { $0["version"] as? String }.maximumStringLength(comparing: versionHeading) 31 | let maximumBuildLength: Int = compactMap { $0["build"] as? String }.maximumStringLength(comparing: buildHeading) 32 | let maximumSizeLength: Int = compactMap { $0["size"] as? Int64 }.map { $0.bytesString() }.maximumStringLength(comparing: sizeHeading) 33 | let maximumDateLength: Int = compactMap { $0["date"] as? String }.maximumStringLength(comparing: dateHeading) 34 | let maximumCompatibleLength: Int = compactMap { $0["compatible"] as? Bool }.map { $0 ? "True" : "False" }.maximumStringLength(comparing: compatibleHeading) 35 | 36 | let signedPadding: Int = Swift.max(maximumSignedLength - signedHeading.count, 0) 37 | let namePadding: Int = Swift.max(maximumNameLength - nameHeading.count, 0) 38 | let versionPadding: Int = Swift.max(maximumVersionLength - versionHeading.count, 0) 39 | let buildPadding: Int = Swift.max(maximumBuildLength - buildHeading.count, 0) 40 | let sizePadding: Int = Swift.max(maximumSizeLength - sizeHeading.count, 0) 41 | let datePadding: Int = Swift.max(maximumDateLength - dateHeading.count, 0) 42 | let compatiblePadding: Int = Swift.max(maximumCompatibleLength - compatibleHeading.count, 0) 43 | 44 | let columns: [(string: String, padding: Int)] = [ 45 | (string: signedHeading, padding: signedPadding), 46 | (string: nameHeading, padding: namePadding), 47 | (string: versionHeading, padding: versionPadding), 48 | (string: buildHeading, padding: buildPadding), 49 | (string: sizeHeading, padding: sizePadding), 50 | (string: dateHeading, padding: datePadding), 51 | (string: compatibleHeading, padding: compatiblePadding) 52 | ] 53 | 54 | var string: String = headerASCIIString(columns: columns, noAnsi: noAnsi) 55 | 56 | for item in self { 57 | let signed: String = (item["signed"] as? Bool ?? false) ? "True" : "False" 58 | let name: String = item["name"] as? String ?? "" 59 | let version: String = item["version"] as? String ?? "" 60 | let build: String = item["build"] as? String ?? "" 61 | let size: String = (item["size"] as? Int64 ?? 0).bytesString() 62 | let date: String = item["date"] as? String ?? "" 63 | let compatible: String = (item["compatible"] as? Bool ?? false) ? "True" : "False" 64 | 65 | let signedPadding: Int = Swift.max(maximumSignedLength - signed.count, 0) 66 | let namePadding: Int = Swift.max(maximumNameLength - name.count, 0) 67 | let versionPadding: Int = Swift.max(maximumVersionLength - version.count, 0) 68 | let buildPadding: Int = Swift.max(maximumBuildLength - build.count, 0) 69 | let sizePadding: Int = Swift.max(maximumSizeLength - size.count, 0) 70 | let datePadding: Int = Swift.max(maximumDateLength - date.count, 0) 71 | let compatiblePadding: Int = Swift.max(maximumCompatibleLength - compatible.count, 0) 72 | 73 | let columns: [(string: String, padding: Int)] = [ 74 | (string: signed, padding: signedPadding), 75 | (string: name, padding: namePadding), 76 | (string: version, padding: versionPadding), 77 | (string: build, padding: buildPadding), 78 | (string: size, padding: sizePadding), 79 | (string: date, padding: datePadding), 80 | (string: compatible, padding: compatiblePadding) 81 | ] 82 | 83 | string += rowASCIIString(columns: columns, noAnsi: noAnsi) 84 | } 85 | 86 | string += footerASCIIString(columns: columns, noAnsi: noAnsi) 87 | return string 88 | } 89 | 90 | // swiftlint:enable function_body_length 91 | 92 | // swiftlint:disable function_body_length 93 | /// Returns an ASCII-formatted table string for the provided array of `Installer` dictionaries. 94 | /// 95 | /// - Parameters: 96 | /// - noAnsi: Set to `true` to return the string without any color or formatting. 97 | /// 98 | /// - Returns: An ASCII-formatted table string for the provided array of `Installer` dictionaries. 99 | func installersASCIIString(noAnsi: Bool) -> String { 100 | let identifierHeading: String = "IDENTIFIER" 101 | let nameHeading: String = "NAME" 102 | let versionHeading: String = "VERSION" 103 | let buildHeading: String = "BUILD" 104 | let sizeHeading: String = "SIZE" 105 | let dateHeading: String = "DATE" 106 | let compatibleHeading: String = "COMPATIBLE" 107 | 108 | let maximumIdentifierLength: Int = compactMap { $0["identifier"] as? String }.maximumStringLength(comparing: identifierHeading) 109 | let maximumNameLength: Int = compactMap { $0["name"] as? String }.maximumStringLength(comparing: nameHeading) 110 | let maximumVersionLength: Int = compactMap { $0["version"] as? String }.maximumStringLength(comparing: versionHeading) 111 | let maximumBuildLength: Int = compactMap { $0["build"] as? String }.maximumStringLength(comparing: buildHeading) 112 | let maximumSizeLength: Int = compactMap { $0["size"] as? Int64 }.map { $0.bytesString() }.maximumStringLength(comparing: sizeHeading) 113 | let maximumDateLength: Int = compactMap { $0["date"] as? String }.maximumStringLength(comparing: dateHeading) 114 | let maximumCompatibleLength: Int = compactMap { $0["compatible"] as? Bool }.map { $0 ? "True" : "False" }.maximumStringLength(comparing: compatibleHeading) 115 | 116 | let identifierPadding: Int = Swift.max(maximumIdentifierLength - identifierHeading.count, 0) 117 | let namePadding: Int = Swift.max(maximumNameLength - nameHeading.count, 0) 118 | let versionPadding: Int = Swift.max(maximumVersionLength - versionHeading.count, 0) 119 | let buildPadding: Int = Swift.max(maximumBuildLength - buildHeading.count, 0) 120 | let sizePadding: Int = Swift.max(maximumSizeLength - sizeHeading.count, 0) 121 | let datePadding: Int = Swift.max(maximumDateLength - dateHeading.count, 0) 122 | let compatiblePadding: Int = Swift.max(maximumCompatibleLength - compatibleHeading.count, 0) 123 | 124 | let columns: [(string: String, padding: Int)] = [ 125 | (string: identifierHeading, padding: identifierPadding), 126 | (string: nameHeading, padding: namePadding), 127 | (string: versionHeading, padding: versionPadding), 128 | (string: buildHeading, padding: buildPadding), 129 | (string: sizeHeading, padding: sizePadding), 130 | (string: dateHeading, padding: datePadding), 131 | (string: compatibleHeading, padding: compatiblePadding) 132 | ] 133 | 134 | var string: String = headerASCIIString(columns: columns, noAnsi: noAnsi) 135 | 136 | for item in self { 137 | let identifier: String = item["identifier"] as? String ?? "" 138 | let name: String = item["name"] as? String ?? "" 139 | let version: String = item["version"] as? String ?? "" 140 | let build: String = item["build"] as? String ?? "" 141 | let size: String = (item["size"] as? Int64 ?? 0).bytesString() 142 | let date: String = item["date"] as? String ?? "" 143 | let compatible: String = (item["compatible"] as? Bool ?? false) ? "True" : "False" 144 | 145 | let identifierPadding: Int = Swift.max(maximumIdentifierLength - identifier.count, 0) 146 | let namePadding: Int = Swift.max(maximumNameLength - name.count, 0) 147 | let versionPadding: Int = Swift.max(Swift.max(maximumVersionLength, versionHeading.count) - version.count, 0) 148 | let buildPadding: Int = Swift.max(maximumBuildLength - build.count, 0) 149 | let sizePadding: Int = Swift.max(maximumSizeLength - size.count, 0) 150 | let datePadding: Int = Swift.max(maximumDateLength - date.count, 0) 151 | let compatiblePadding: Int = Swift.max(maximumCompatibleLength - compatible.count, 0) 152 | 153 | let columns: [(string: String, padding: Int)] = [ 154 | (string: identifier, padding: identifierPadding), 155 | (string: name, padding: namePadding), 156 | (string: version, padding: versionPadding), 157 | (string: build, padding: buildPadding), 158 | (string: size, padding: sizePadding), 159 | (string: date, padding: datePadding), 160 | (string: compatible, padding: compatiblePadding) 161 | ] 162 | 163 | string += rowASCIIString(columns: columns, noAnsi: noAnsi) 164 | } 165 | 166 | string += footerASCIIString(columns: columns, noAnsi: noAnsi) 167 | return string 168 | } 169 | 170 | // swiftlint:enable function_body_length 171 | 172 | /// Generates a Header ASCII string based on the provided columns. 173 | /// 174 | /// - Parameters: 175 | /// - columns: An array of column tuples, each containing a column string and padding integer. 176 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 177 | /// 178 | /// - Returns: The Header ASCII string. 179 | private func headerASCIIString(columns: [(string: String, padding: Int)], noAnsi: Bool) -> String { 180 | var string: String = "┌─" 181 | 182 | for (index, column) in columns.enumerated() { 183 | string += [String](repeating: "─", count: column.string.count + column.padding).joined() 184 | string += index < columns.count - 1 ? "─┬─" : "─┐\n" 185 | } 186 | 187 | string += "│ " 188 | 189 | for (index, column) in columns.enumerated() { 190 | string += column.string + [String](repeating: " ", count: column.padding).joined() 191 | string += index < columns.count - 1 ? " │ " : " │\n" 192 | } 193 | 194 | string += "├─" 195 | 196 | for (index, column) in columns.enumerated() { 197 | string += [String](repeating: "─", count: column.string.count + column.padding).joined() 198 | string += index < columns.count - 1 ? "─┼─" : "─┤\n" 199 | } 200 | 201 | return string.color(noAnsi ? .reset : .blue) 202 | } 203 | 204 | /// Generates a Row ASCII string based on the provided columns. 205 | /// 206 | /// - Parameters: 207 | /// - columns: An array of column tuples, each containing a column string and padding integer. 208 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 209 | /// 210 | /// - Returns: The Row ASCII string. 211 | private func rowASCIIString(columns: [(string: String, padding: Int)], noAnsi: Bool) -> String { 212 | var string: String = "│ ".color(noAnsi ? .reset : .blue) 213 | 214 | for (index, column) in columns.enumerated() { 215 | // size column should be right-aligned 216 | if column.string.lowercased().contains("gb") { 217 | string += [String](repeating: " ", count: column.padding).joined() + column.string 218 | } else { 219 | string += column.string + [String](repeating: " ", count: column.padding).joined() 220 | } 221 | 222 | string += (index < columns.count - 1 ? " │ " : " │\n").color(noAnsi ? .reset : .blue) 223 | } 224 | 225 | return string 226 | } 227 | 228 | /// Generates a Footer ASCII string based on the provided columns. 229 | /// 230 | /// - Parameters: 231 | /// - columns: An array of column tuples, each containing a column string and padding integer. 232 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 233 | /// 234 | /// - Returns: The Header ASCII string. 235 | private func footerASCIIString(columns: [(string: String, padding: Int)], noAnsi: Bool) -> String { 236 | var string: String = "└─" 237 | 238 | for (index, column) in columns.enumerated() { 239 | string += [String](repeating: "─", count: column.string.count + column.padding).joined() 240 | string += index < columns.count - 1 ? "─┴─" : "─┘\n" 241 | } 242 | 243 | return string.color(noAnsi ? .reset : .blue) 244 | } 245 | 246 | /// Returns a CSV-formatted string for the provided array of `Firmware` objects. 247 | /// 248 | /// - Returns: A CSV-formatted string for the provided array of `Firmware` objects. 249 | func firmwaresCSVString() -> String { 250 | "Name,Version,Build,Size,URL,Date,Compatible,Signed,Beta\n" + map { $0.firmwareCSVString() }.joined() 251 | } 252 | 253 | /// Returns a CSV-formatted string for the provided array of `Installer` objects. 254 | /// 255 | /// - Returns: A CSV-formatted string for the provided array of `Installer` objects. 256 | func installersCSVString() -> String { 257 | "Identifier,Name,Version,Build,Size,Date,Compatible,Beta\n" + map { $0.installerCSVString() }.joined() 258 | } 259 | 260 | /// Returns a JSON string for the provided array. 261 | /// 262 | /// - Throws: An error if the JSON string cannot be created. 263 | /// 264 | /// - Returns: A JSON string for the provided array. 265 | func jsonString() throws -> String { 266 | let data: Data = try JSONSerialization.data(withJSONObject: self, options: [.prettyPrinted, .sortedKeys]) 267 | let string: String = .init(decoding: data, as: UTF8.self) 268 | return string 269 | } 270 | 271 | /// Returns a Property List string for the provided array. 272 | /// 273 | /// - Throws: An error if the Property List string cannot be created. 274 | /// 275 | /// - Returns: A Property List string for the provided array. 276 | func propertyListString() throws -> String { 277 | let data: Data = try PropertyListSerialization.data(fromPropertyList: self, format: .xml, options: .bitWidth) 278 | let string: String = .init(decoding: data, as: UTF8.self) 279 | return string 280 | } 281 | 282 | /// Returns a YAML string for the provided array. 283 | /// 284 | /// - Throws: An error if the YAML string cannot be created. 285 | /// 286 | /// - Returns: A YAML string for the provided array. 287 | func yamlString() throws -> String { 288 | try Yams.dump(object: self) 289 | } 290 | } 291 | 292 | extension Sequence where Iterator.Element == String { 293 | /// Returns the maximum string length, comparing an array of strings against the passed in string. 294 | /// 295 | /// - Parameters: 296 | /// - string: The string to compare against the array of strings. 297 | /// 298 | /// - Returns: The maximum string length. 299 | func maximumStringLength(comparing string: String) -> Int { 300 | Swift.max(self.max { $0.count < $1.count }?.count ?? 0, string.count) 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /Mist/Extensions/String+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 10/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | enum Color: String, CaseIterable { 12 | case black = "\u{001B}[0;30m" 13 | case red = "\u{001B}[0;31m" 14 | case green = "\u{001B}[0;32m" 15 | case yellow = "\u{001B}[0;33m" 16 | case blue = "\u{001B}[0;34m" 17 | case magenta = "\u{001B}[0;35m" 18 | case cyan = "\u{001B}[0;36m" 19 | case white = "\u{001B}[0;37m" 20 | case brightBlack = "\u{001B}[0;90m" 21 | case brightRed = "\u{001B}[0;91m" 22 | case brightGreen = "\u{001B}[0;92m" 23 | case brightYellow = "\u{001B}[0;93m" 24 | case brightBlue = "\u{001B}[0;94m" 25 | case brightMagenta = "\u{001B}[0;95m" 26 | case brightCyan = "\u{001B}[0;96m" 27 | case brightWhite = "\u{001B}[0;97m" 28 | case reset = "\u{001B}[0;0m" 29 | } 30 | 31 | /// App name. 32 | static let appName: String = "mist" 33 | /// Project name. 34 | static let projectName: String = "mist-cli" 35 | /// App identifier. 36 | static let identifier: String = "com.ninxsoft.\(appName)" 37 | /// App abstract string. 38 | static let abstract: String = "macOS Installer Super Tool." 39 | /// App discussion string. 40 | static let discussion: String = "Automatically download macOS Firmwares / Installers." 41 | /// Default temporary directory. 42 | static let temporaryDirectory: String = "/private/tmp/com.ninxsoft.mist" 43 | /// Default Firmwares metadata cache path. 44 | static let firmwaresMetadataCachePath: String = "/Users/Shared/Mist/firmwares.json" 45 | /// Default output directory. 46 | static let outputDirectory: String = "/Users/Shared/Mist" 47 | /// Default filename template. 48 | static let filenameTemplate: String = "Install %NAME% %VERSION%-%BUILD%" 49 | /// Default package identifier template. 50 | static let packageIdentifierTemplate: String = "com.company.pkg.%NAME%.%VERSION%.%BUILD%" 51 | /// GitHub repository URL 52 | static let repositoryURL: String = "https://github.com/ninxsoft/\(projectName)" 53 | /// GitHub latest release URL 54 | static let latestReleaseURL: String = "https://api.github.com/repos/ninxsoft/\(projectName)/releases/latest" 55 | 56 | /// Returns a string wrapped with the provided color (ANSI codes). 57 | /// 58 | /// - Parameters: 59 | /// - color: The color to wrap the string with. 60 | /// 61 | /// - Returns: The string wrapped in the provided color (ANSI codes). 62 | func color(_ color: Color) -> String { 63 | color.rawValue + self + Color.reset.rawValue 64 | } 65 | 66 | /// Returns a string with the `%NAME%`, `%VERSION%` and `%BUILD%` placeholders substituted with values from the provided `Firmware`. 67 | /// 68 | /// - Parameters: 69 | /// - firmware: The `Firmware` to use as the basis of substitutions. 70 | /// 71 | /// - Returns: A string with the `%NAME%`, `%VERSION%` and `%BUILD%` placeholders substituted with values from the provided `Firmware`. 72 | func stringWithSubstitutions(using firmware: Firmware) -> String { 73 | replacingOccurrences(of: "%NAME%", with: firmware.name) 74 | .replacingOccurrences(of: "%VERSION%", with: firmware.version) 75 | .replacingOccurrences(of: "%BUILD%", with: firmware.build) 76 | .replacingOccurrences(of: "//", with: "/") 77 | } 78 | 79 | /// Returns a string with the `%NAME%`, `%VERSION%` and `%BUILD%` placeholders substituted with values from the provided `Installer`. 80 | /// 81 | /// - Parameters: 82 | /// - installer: The `Installer` to use as the basis of substitutions. 83 | /// 84 | /// - Returns: A string with the `%NAME%`, `%VERSION%` and `%BUILD%` placeholders substituted with values from the provided `Installer`. 85 | func stringWithSubstitutions(using installer: Installer) -> String { 86 | replacingOccurrences(of: "%NAME%", with: installer.name) 87 | .replacingOccurrences(of: "%VERSION%", with: installer.version) 88 | .replacingOccurrences(of: "%BUILD%", with: installer.build) 89 | .replacingOccurrences(of: "//", with: "/") 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Mist/Extensions/UInt32+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UInt32+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension UInt32 { 11 | /// Returns a hex-formatted string for the provided `UInt32`. 12 | /// 13 | /// - Returns: A hex-formatted string for `UInt32`. 14 | func hexString() -> String { 15 | String(format: "0x%08X", self) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Mist/Extensions/UInt64+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UInt64+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension UInt64 { 11 | /// Returns a hex-formatted string for the provided `UInt64`. 12 | /// 13 | /// - Returns: A hex-formatted string for `UInt64`. 14 | func hexString() -> String { 15 | String(format: "0x%016X", self) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Mist/Extensions/UInt8+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UInt8+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension UInt8 { 11 | /// Returns a hex-formatted string for the provided `UInt8`. 12 | /// 13 | /// - Returns: A hex-formatted string for `UInt8`. 14 | func hexString() -> String { 15 | String(format: "0x%02X", self) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Mist/Extensions/URL+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 15/8/2022. 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | extension URL { 12 | /// Returns a SHA1 checksum for the file at the provided URL. 13 | /// 14 | /// - Returns: A SHA1 checksum string if the hash was successfully calculated, otherwise `nil`. 15 | func shasum() -> String? { 16 | let length: Int = 1_024 * 1_024 * 50 // 50 MB 17 | 18 | do { 19 | let fileHandle: FileHandle = try FileHandle(forReadingFrom: self) 20 | 21 | defer { 22 | fileHandle.closeFile() 23 | } 24 | 25 | var shasum: Insecure.SHA1 = .init() 26 | 27 | while 28 | try autoreleasepool(invoking: { 29 | try Task.checkCancellation() 30 | let data: Data = fileHandle.readData(ofLength: length) 31 | 32 | if !data.isEmpty { 33 | shasum.update(data: data) 34 | } 35 | 36 | return !data.isEmpty 37 | }) {} 38 | 39 | let data: Data = .init(shasum.finalize()) 40 | return data.map { String(format: "%02hhx", $0) }.joined() 41 | } catch { 42 | return nil 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Mist/Extensions/[UInt8]+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // [UInt8]+Extension.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | extension [UInt8] { 9 | /// Returns the `UInt8` at the provided offset. 10 | /// 11 | /// - Parameters: 12 | /// - offset: The `[UInt8]` array offset. 13 | /// 14 | /// - Returns: The `UInt8` at the provided offset. 15 | func uInt8(at offset: Int) -> UInt8 { 16 | self[offset] 17 | } 18 | 19 | /// Returns the `UInt32` at the provided offset. 20 | /// 21 | /// - Parameters: 22 | /// - offset: The `[UInt8]` array offset. 23 | /// 24 | /// - Returns: The `UInt32` at the provided offset. 25 | func uInt32(at offset: Int) -> UInt32 { 26 | self[offset ... offset + 0x03].reversed().reduce(0) { 27 | $0 << 0x08 + UInt32($1) 28 | } 29 | } 30 | 31 | /// Returns the `UInt64` at the provided offset. 32 | /// 33 | /// - Parameters: 34 | /// - offset: The `[UInt8]` array offset. 35 | /// 36 | /// - Returns: The `UInt64` at the provided offset. 37 | func uInt64(at offset: Int) -> UInt64 { 38 | self[offset ... offset + 0x07].reversed().reduce(0) { 39 | $0 << 0x08 + UInt64($1) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Mist/Helpers/Downloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Downloader.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 11/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Helper Class used to download macOS Firmwares and Installers. 11 | class Downloader: NSObject { 12 | private static let maximumWidth: Int = 95 13 | private var temporaryURL: URL? 14 | private var sourceURL: URL? 15 | private var current: Int64 = 0 16 | private var total: Int64 = 0 17 | private var prefixString: String = "" 18 | private var urlError: URLError? 19 | private var mistError: MistError? 20 | private let semaphore: DispatchSemaphore = .init(value: 0) 21 | private var noAnsi: Bool = false 22 | private var quiet: Bool = false 23 | private var previousPercentage: Int = 0 24 | 25 | /// Downloads a macOS Firmware. 26 | /// 27 | /// - Parameters: 28 | /// - firmware: The selected macOS Firmware to be downloaded. 29 | /// - options: Download options for macOS Firmwares. 30 | /// 31 | /// - Throws: A `MistError` if the macOS Firmware fails to download. 32 | func download(_ firmware: Firmware, options: DownloadFirmwareOptions) throws { 33 | noAnsi = options.noAnsi 34 | quiet = options.quiet 35 | !quiet ? PrettyPrint.printHeader("DOWNLOAD", noAnsi: noAnsi) : Mist.noop() 36 | temporaryURL = URL(fileURLWithPath: DownloadFirmwareCommand.temporaryDirectory(for: firmware, options: options)) 37 | 38 | guard var source: URL = URL(string: firmware.url) else { 39 | throw MistError.invalidURL(firmware.url) 40 | } 41 | 42 | if let url: URL = DownloadFirmwareCommand.cachingServerURL(for: source, options: options) { 43 | source = url 44 | } 45 | 46 | sourceURL = source 47 | let session: URLSession = .init(configuration: .default, delegate: self, delegateQueue: nil) 48 | let resumeDataURL: URL = DownloadFirmwareCommand.resumeDataURL(for: firmware, options: options) 49 | let task: URLSessionDownloadTask 50 | 51 | if FileManager.default.fileExists(atPath: resumeDataURL.path) { 52 | do { 53 | let resumeData: Data = try Data(contentsOf: resumeDataURL) 54 | task = session.downloadTask(withResumeData: resumeData) 55 | !quiet ? PrettyPrint.print("Resuming download...", noAnsi: noAnsi) : Mist.noop() 56 | } catch { 57 | task = session.downloadTask(with: source) 58 | } 59 | 60 | try FileManager.default.removeItem(at: resumeDataURL) 61 | } else { 62 | task = session.downloadTask(with: source) 63 | } 64 | 65 | prefixString = source.lastPathComponent 66 | updateProgress(replacing: false) 67 | task.resume() 68 | semaphore.wait() 69 | var retries: Int = 0 70 | 71 | while urlError != nil { 72 | if retries >= options.retries { 73 | if 74 | let error: URLError = urlError, 75 | let data: Data = error.downloadTaskResumeData { 76 | !quiet ? PrettyPrint.print("Saving resume data to '\(resumeDataURL.path)'...", noAnsi: noAnsi) : Mist.noop() 77 | try data.write(to: resumeDataURL) 78 | } 79 | 80 | throw MistError.maximumRetriesReached 81 | } 82 | 83 | retries += 1 84 | retry(attempt: retries, of: options.retries, with: options.retryDelay, using: session) 85 | } 86 | 87 | if let mistError: MistError = mistError { 88 | throw mistError 89 | } 90 | 91 | updateProgress() 92 | } 93 | 94 | // swiftlint:disable cyclomatic_complexity function_body_length 95 | 96 | /// Downloads a macOS Installer. 97 | /// 98 | /// - Parameters: 99 | /// - installer: The selected macOS Installer that was downloaded. 100 | /// - options: Download options for macOS Installers. 101 | /// 102 | /// - Throws: A `MistError` if the macOS Installer fails to download. 103 | func download(_ installer: Installer, options: DownloadInstallerOptions) throws { 104 | noAnsi = options.noAnsi 105 | quiet = options.quiet 106 | !quiet ? PrettyPrint.printHeader("DOWNLOAD", noAnsi: noAnsi) : Mist.noop() 107 | temporaryURL = URL(fileURLWithPath: DownloadInstallerCommand.temporaryDirectory(for: installer, options: options)) 108 | let session: URLSession = .init(configuration: .default, delegate: self, delegateQueue: nil) 109 | 110 | guard let temporaryURL: URL = temporaryURL else { 111 | throw MistError.generalError("There was an error retrieving the temporary URL") 112 | } 113 | 114 | var index: Int = 0 115 | 116 | while index < installer.allDownloads.count { 117 | let package: Package = installer.allDownloads[index] 118 | 119 | guard var source: URL = URL(string: package.url) else { 120 | throw MistError.invalidURL(package.url) 121 | } 122 | 123 | if let url: URL = DownloadInstallerCommand.cachingServerURL(for: source, options: options) { 124 | source = url 125 | } 126 | 127 | sourceURL = source 128 | let destination: URL = temporaryURL.appendingPathComponent(source.lastPathComponent) 129 | let currentString: String = "\(index + 1 < 10 && installer.allDownloads.count >= 10 ? "0" : "")\(index + 1)" 130 | prefixString = "[ \(currentString) / \(installer.allDownloads.count) ] \(source.lastPathComponent)" 131 | current = 0 132 | previousPercentage = 0 133 | 134 | if FileManager.default.fileExists(atPath: destination.path), package.size == 0 { 135 | let attributes: [FileAttributeKey: Any] = try FileManager.default.attributesOfItem(atPath: destination.path) 136 | 137 | if let size: Int64 = attributes[FileAttributeKey.size] as? Int64 { 138 | total = size 139 | } 140 | } else { 141 | total = Int64(package.size) 142 | } 143 | 144 | updateProgress(replacing: false) 145 | 146 | if !FileManager.default.fileExists(atPath: destination.path) { 147 | let resumeDataURL: URL = DownloadInstallerCommand.resumeDataURL(for: package, in: installer, options: options) 148 | let task: URLSessionDownloadTask 149 | 150 | if FileManager.default.fileExists(atPath: resumeDataURL.path) { 151 | do { 152 | let resumeData: Data = try Data(contentsOf: resumeDataURL) 153 | task = session.downloadTask(withResumeData: resumeData) 154 | !quiet ? PrettyPrint.print("Resuming download...", noAnsi: noAnsi, replacing: true) : Mist.noop() 155 | !quiet ? PrettyPrint.print("", noAnsi: noAnsi) : Mist.noop() 156 | } catch { 157 | task = session.downloadTask(with: source) 158 | } 159 | 160 | try FileManager.default.removeItem(at: resumeDataURL) 161 | } else { 162 | task = session.downloadTask(with: source) 163 | } 164 | 165 | task.resume() 166 | semaphore.wait() 167 | var retries: Int = 0 168 | 169 | while urlError != nil { 170 | if retries >= options.retries { 171 | if 172 | let error: URLError = urlError, 173 | let data: Data = error.downloadTaskResumeData { 174 | !quiet ? PrettyPrint.print("Saving resume data to '\(resumeDataURL.path)'...", noAnsi: noAnsi) : Mist.noop() 175 | try data.write(to: resumeDataURL) 176 | } 177 | 178 | throw MistError.maximumRetriesReached 179 | } 180 | 181 | retries += 1 182 | retry(attempt: retries, of: options.retries, with: options.retryDelay, using: session) 183 | } 184 | 185 | if let mistError: MistError = mistError { 186 | throw mistError 187 | } 188 | } 189 | 190 | current = total 191 | updateProgress() 192 | 193 | if verify(package, at: destination, current: currentString, total: installer.allDownloads.count) { 194 | index += 1 195 | } 196 | } 197 | } 198 | 199 | // swiftlint:enable cyclomatic_complexity function_body_length 200 | 201 | private func verify(_ package: Package, at destination: URL, current: String, total: Int) -> Bool { 202 | let paddingLength: Int = "[ \(current) / \(total) ]".count 203 | let padding: String = .init(repeating: " ", count: paddingLength) 204 | !quiet ? PrettyPrint.print("\(padding) Verifying...", noAnsi: noAnsi, prefix: .continuing) : Mist.noop() 205 | 206 | do { 207 | try Validator.validate(package, at: destination) 208 | !quiet ? PrettyPrint.print("\(padding) Verifying... \("✓✓✓".color(.green))", noAnsi: noAnsi, prefix: .continuing, replacing: true) : Mist.noop() 209 | return true 210 | } catch { 211 | !quiet ? PrettyPrint.print("\(padding) Verifying... \("xxx".color(.red))", noAnsi: noAnsi, prefix: .continuing, replacing: true) : Mist.noop() 212 | 213 | if let error: MistError = error as? MistError { 214 | !quiet ? PrettyPrint.print("\(padding) \(error.description)", noAnsi: noAnsi, prefix: .continuing) : Mist.noop() 215 | } 216 | 217 | !quiet ? PrettyPrint.print("\(padding) Trying again...", noAnsi: noAnsi, prefix: .continuing) : Mist.noop() 218 | 219 | if FileManager.default.fileExists(atPath: destination.path) { 220 | try? FileManager.default.removeItem(at: destination) 221 | } 222 | 223 | return false 224 | } 225 | } 226 | 227 | private func retry(attempt retry: Int, of maximumRetries: Int, with delay: Int, using session: URLSession) { 228 | guard 229 | let urlError: URLError = urlError, 230 | let data: Data = urlError.downloadTaskResumeData else { 231 | mistError = MistError.generalError("Unable to retrieve URL Error data") 232 | return 233 | } 234 | 235 | self.urlError = nil 236 | 237 | !quiet ? PrettyPrint.print(urlError.localizedDescription, noAnsi: noAnsi, prefixColor: .red) : Mist.noop() 238 | !quiet ? PrettyPrint.print("Retrying attempt [ \(retry) / \(maximumRetries) ] in \(delay) seconds...", noAnsi: noAnsi) : Mist.noop() 239 | sleep(UInt32(delay)) 240 | 241 | let task: URLSessionDownloadTask = session.downloadTask(withResumeData: data) 242 | updateProgress(replacing: false) 243 | task.resume() 244 | semaphore.wait() 245 | } 246 | 247 | private func updateProgress(replacing: Bool = true) { 248 | let currentString: String = current.bytesString() 249 | let totalString: String = total.bytesString() 250 | let percentage: Double = total > 0 ? Double(current) / Double(total) * 100 : 0 251 | let format: String = percentage == 100 ? "%05.1f%%" : "%05.2f%%" 252 | let percentageString: String = .init(format: format, percentage) 253 | let suffixString: String = "[ \(currentString) / \(totalString) (\(percentageString)) ]" 254 | let paddingSize: Int = Downloader.maximumWidth - PrettyPrint.Prefix.default.rawValue.count - prefixString.count - suffixString.count 255 | let paddingString: String = .init(repeating: ".", count: paddingSize - 1) + " " 256 | !quiet ? PrettyPrint.print("\(prefixString)\(paddingString)\(suffixString)", noAnsi: noAnsi, replacing: replacing) : Mist.noop() 257 | } 258 | } 259 | 260 | extension Downloader: URLSessionDownloadDelegate { 261 | func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 262 | current = totalBytesWritten 263 | total = totalBytesExpectedToWrite 264 | 265 | if noAnsi { 266 | let percentage: Int = .init(total > 0 ? Double(current) / Double(total) * 100 : 0) 267 | 268 | if percentage > previousPercentage { 269 | updateProgress() 270 | previousPercentage = percentage 271 | } 272 | } else { 273 | updateProgress() 274 | } 275 | } 276 | 277 | func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 278 | if let expectedContentLength: Int64 = downloadTask.response?.expectedContentLength { 279 | current = expectedContentLength 280 | total = expectedContentLength 281 | } 282 | 283 | guard let temporaryURL: URL = temporaryURL else { 284 | mistError = MistError.generalError("There was an error retrieving the temporary URL") 285 | semaphore.signal() 286 | return 287 | } 288 | 289 | guard let sourceURL: URL = sourceURL else { 290 | mistError = MistError.generalError("There was an error retrieving the source URL") 291 | semaphore.signal() 292 | return 293 | } 294 | 295 | let destination: URL = temporaryURL.appendingPathComponent(sourceURL.lastPathComponent) 296 | 297 | do { 298 | try FileManager.default.moveItem(at: location, to: destination) 299 | } catch { 300 | mistError = MistError.generalError(error.localizedDescription) 301 | semaphore.signal() 302 | } 303 | } 304 | 305 | func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 306 | if let error: URLError = error as? URLError { 307 | urlError = error 308 | semaphore.signal() 309 | return 310 | } 311 | 312 | urlError = nil 313 | 314 | if let error: Error = error { 315 | mistError = MistError.generalError(error.localizedDescription) 316 | semaphore.signal() 317 | return 318 | } 319 | 320 | guard let file: String = task.currentRequest?.url?.lastPathComponent else { 321 | mistError = MistError.generalError("There was an error retrieving the URL") 322 | semaphore.signal() 323 | return 324 | } 325 | 326 | guard let response: HTTPURLResponse = task.response as? HTTPURLResponse else { 327 | mistError = MistError.generalError("There was an error retrieving \(file))") 328 | semaphore.signal() 329 | return 330 | } 331 | 332 | guard [200, 206].contains(response.statusCode) else { 333 | mistError = MistError.generalError("Invalid HTTP status code: \(response.statusCode)") 334 | semaphore.signal() 335 | return 336 | } 337 | 338 | semaphore.signal() 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /Mist/Helpers/HTTP.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTP.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 11/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | // swiftlint:disable file_length 11 | 12 | /// Helper Struct used to perform HTTP queries. 13 | enum HTTP { 14 | // swiftlint:disable cyclomatic_complexity function_body_length 15 | 16 | /// Searches and retrieves a list of all macOS Firmwares that can be downloaded. 17 | /// 18 | /// - Parameters: 19 | /// - includeBetas: Set to `true` to prevent skipping of macOS Firmwares in search results. 20 | /// - compatible: Set to `true` to filter down compatible macOS Firmwares in search results. 21 | /// - metadataCachePath: Path to cache the macOS Firmwares metadata JSON file. 22 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 23 | /// - quiet: Set to `true` to suppress verbose output. 24 | /// 25 | /// - Returns: An array of macOS Firmwares. 26 | static func retrieveFirmwares(includeBetas: Bool, compatible: Bool, metadataCachePath: String, noAnsi: Bool, quiet: Bool = false) -> [Firmware] { 27 | var firmwares: [Firmware] = [] 28 | 29 | do { 30 | var devices: [String: Any] = [:] 31 | let metadataURL: URL = .init(fileURLWithPath: metadataCachePath) 32 | 33 | if 34 | let url: URL = URL(string: Firmware.firmwaresURL), 35 | let (string, dictionary): (String, [String: Any]) = try retrieveMetadata(url, noAnsi: noAnsi, quiet: quiet) { 36 | devices = dictionary 37 | let directory: URL = metadataURL.deletingLastPathComponent() 38 | 39 | if !FileManager.default.fileExists(atPath: directory.path) { 40 | !quiet ? PrettyPrint.print("Creating parent directory '\(directory.path)'...", noAnsi: noAnsi) : Mist.noop() 41 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) 42 | } 43 | 44 | !quiet ? PrettyPrint.print("Caching macOS Firmware metadata to '\(metadataCachePath)'...", noAnsi: noAnsi) : Mist.noop() 45 | try string.write(to: metadataURL, atomically: true, encoding: .utf8) 46 | } else if FileManager.default.fileExists(atPath: metadataURL.path) { 47 | !quiet ? PrettyPrint.print("Retrieving macOS Firmware metadata from '\(metadataCachePath)'...", noAnsi: noAnsi) : Mist.noop() 48 | 49 | if let (_, dictionary): (String, [String: Any]) = try retrieveMetadata(metadataURL, noAnsi: noAnsi, quiet: quiet) { 50 | devices = dictionary 51 | } 52 | } else { 53 | !quiet ? PrettyPrint.print("Unable to retrieve macOS Firmware metadata from missing cache '\(metadataCachePath)'", noAnsi: noAnsi, prefixColor: .red) : Mist.noop() 54 | } 55 | 56 | let supportedBuilds: [String] = Firmware.supportedBuilds() 57 | 58 | for (identifier, device) in devices { 59 | guard 60 | identifier.contains("Mac"), 61 | let device: [String: Any] = device as? [String: Any], 62 | let firmwaresArray: [[String: Any]] = device["firmwares"] as? [[String: Any]] else { 63 | continue 64 | } 65 | 66 | for var firmwareDictionary in firmwaresArray { 67 | firmwareDictionary["compatible"] = supportedBuilds.contains(firmwareDictionary["buildid"] as? String ?? "") 68 | let firmwareData: Data = try JSONSerialization.data(withJSONObject: firmwareDictionary, options: .prettyPrinted) 69 | let firmware: Firmware = try JSONDecoder().decode(Firmware.self, from: firmwareData) 70 | 71 | if 72 | !firmware.shasum.isEmpty, 73 | !firmwares.contains(where: { $0 == firmware }) { 74 | firmwares.append(firmware) 75 | } 76 | } 77 | } 78 | } catch { 79 | !quiet ? PrettyPrint.print(error.localizedDescription, noAnsi: noAnsi, prefixColor: .red) : Mist.noop() 80 | } 81 | 82 | if !includeBetas { 83 | firmwares = firmwares.filter { !$0.beta } 84 | } 85 | 86 | if compatible { 87 | firmwares = firmwares.filter(\.compatible) 88 | } 89 | 90 | firmwares.sort { $0.version == $1.version ? $0.date > $1.date : $0.version > $1.version } 91 | return firmwares 92 | } 93 | 94 | // swiftlint:enable cyclomatic_complexity function_body_length 95 | 96 | /// Retrieves a dictionary containing macOS Firmwares metadata. 97 | /// 98 | /// - Parameters: 99 | /// - url: URL to the macOS Firmwares metadata JSON file. 100 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 101 | /// - quiet: Set to `true` to suppress verbose output. 102 | /// 103 | /// - Throws: An error if the macOS Firmwares metadata cannot be retrieved. 104 | /// 105 | /// - Returns: A dictionary of macOS Firmwares metadata. 106 | private static func retrieveMetadata(_ url: URL, noAnsi: Bool, quiet: Bool) throws -> (String, [String: Any])? { 107 | let string: String = try String(contentsOf: url, encoding: .utf8) 108 | 109 | guard 110 | let data: Data = string.data(using: .utf8), 111 | let dictionary: [String: Any] = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], 112 | let devices: [String: Any] = dictionary["devices"] as? [String: Any] else { 113 | let path: String = url.absoluteString.replacingOccurrences(of: "file://", with: "") 114 | !quiet ? PrettyPrint.print("There was an error retrieving macOS Firmware metadata from '\(path)'", noAnsi: noAnsi, prefixColor: .red) : Mist.noop() 115 | 116 | if url.scheme == "https" { 117 | !quiet ? PrettyPrint.print("This may indicate the API is being updated, please try again shortly...", noAnsi: noAnsi) : Mist.noop() 118 | } 119 | 120 | return nil 121 | } 122 | 123 | return (string, devices) 124 | } 125 | 126 | /// Retrieves the first macOS Firmware download match for the provided search string. 127 | /// 128 | /// - Parameters: 129 | /// - firmwares: The array of possible macOS Firmwares that can be downloaded. 130 | /// - searchString: The download search string. 131 | /// 132 | /// - Returns: The first match of a macOS Firmware, otherwise nil. 133 | static func firmware(from firmwares: [Firmware], searchString: String) -> Firmware? { 134 | let searchString: String = searchString.lowercased().replacingOccurrences(of: "macos ", with: "") 135 | let filteredFirmwaresByName: [Firmware] = firmwares.filter { $0.name.lowercased().replacingOccurrences(of: "macos ", with: "").contains(searchString) } 136 | let filteredFirmwaresByVersionExact: [Firmware] = firmwares.filter { $0.version.lowercased() == searchString } 137 | let filteredFirmwaresByVersionStartsWith: [Firmware] = firmwares.filter { $0.version.lowercased().starts(with: searchString) } 138 | let filteredFirmwaresByBuild: [Firmware] = firmwares.filter { $0.build.lowercased().starts(with: searchString) } 139 | return filteredFirmwaresByName.first ?? filteredFirmwaresByVersionExact.first ?? filteredFirmwaresByVersionStartsWith.first ?? filteredFirmwaresByBuild.first 140 | } 141 | 142 | /// Retrieves macOS Firmware downloads matching the provided search string. 143 | /// 144 | /// - Parameters: 145 | /// - firmwares: The array of possible macOS Firmwares that can be downloaded. 146 | /// - searchString: The download search string. 147 | /// 148 | /// - Returns: An array of macOS Firmware matches. 149 | static func firmwares(from firmwares: [Firmware], searchString: String) -> [Firmware] { 150 | let searchString: String = searchString.lowercased().replacingOccurrences(of: "macos ", with: "") 151 | let filteredFirmwaresByName: [Firmware] = firmwares.filter { $0.name.lowercased().replacingOccurrences(of: "macos ", with: "").contains(searchString) } 152 | let filteredFirmwaresByVersion: [Firmware] = firmwares.filter { $0.version.lowercased().starts(with: searchString) } 153 | let filteredFirmwaresByBuild: [Firmware] = firmwares.filter { $0.build.lowercased().starts(with: searchString) } 154 | return filteredFirmwaresByName + filteredFirmwaresByVersion + filteredFirmwaresByBuild 155 | } 156 | 157 | /// Searches and retrieves a list of all macOS Installers that can be downloaded. 158 | /// 159 | /// - Parameters: 160 | /// - catalogURLs: The Apple Software Update catalog URLs to base the search queries against. 161 | /// - includeBetas: Set to `true` to prevent skipping of macOS Installers in search results. 162 | /// - compatible: Set to `true` to filter down compatible macOS Installers in search results. 163 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 164 | /// - quiet: Set to `true` to suppress verbose output. 165 | /// 166 | /// - Returns: An array of macOS Installers. 167 | static func retrieveInstallers(from catalogURLs: [String], includeBetas: Bool, compatible: Bool, noAnsi: Bool, quiet: Bool = false) -> [Installer] { 168 | var installers: [Installer] = [] 169 | 170 | for catalogURL in catalogURLs { 171 | guard let url: URL = URL(string: catalogURL) else { 172 | !quiet ? PrettyPrint.print("There was an error retrieving the catalog from \(catalogURL), skipping...", noAnsi: noAnsi) : Mist.noop() 173 | continue 174 | } 175 | 176 | do { 177 | let string: String = try String(contentsOf: url, encoding: .utf8) 178 | 179 | guard let data: Data = string.data(using: .utf8) else { 180 | !quiet ? PrettyPrint.print("Unable to get data from catalog, skipping...", noAnsi: noAnsi) : Mist.noop() 181 | continue 182 | } 183 | 184 | var format: PropertyListSerialization.PropertyListFormat = .xml 185 | 186 | guard 187 | let catalog: [String: Any] = try PropertyListSerialization.propertyList(from: data, options: [.mutableContainers], format: &format) as? [String: Any], 188 | let installersDictionary: [String: Any] = catalog["Products"] as? [String: Any] else { 189 | !quiet ? PrettyPrint.print("Unable to get 'Products' dictionary from catalog, skipping...", noAnsi: noAnsi) : Mist.noop() 190 | continue 191 | } 192 | 193 | installers.append(contentsOf: getInstallers(from: installersDictionary, noAnsi: noAnsi, quiet: quiet).filter { !installers.map(\.identifier).contains($0.identifier) }) 194 | } catch { 195 | !quiet ? PrettyPrint.print(error.localizedDescription, noAnsi: noAnsi, prefixColor: .red) : Mist.noop() 196 | } 197 | } 198 | 199 | installers.append(contentsOf: Installer.legacyInstallers) 200 | 201 | if !includeBetas { 202 | installers = installers.filter { !$0.beta } 203 | } 204 | 205 | if compatible { 206 | installers = installers.filter(\.compatible) 207 | } 208 | 209 | installers.sort { $0.version == $1.version ? $0.date > $1.date : $0.version.compare($1.version, options: .numeric) == .orderedDescending } 210 | return installers 211 | } 212 | 213 | /// Filters and extracts a list of macOS Installers from the Apple Software Update Catalog Property List. 214 | /// 215 | /// - Parameters: 216 | /// - dictionary: The dictionary values obtained from the Apple Software Update Catalog Property List. 217 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 218 | /// - quiet: Set to `true` to suppress verbose output. 219 | /// 220 | /// - Returns: The filtered list of macOS Installers. 221 | private static func getInstallers(from dictionary: [String: Any], noAnsi: Bool, quiet: Bool) -> [Installer] { 222 | var installers: [Installer] = [] 223 | let dateFormatter: DateFormatter = .init() 224 | dateFormatter.dateFormat = "yyyy-MM-dd" 225 | 226 | for (key, value) in dictionary { 227 | guard 228 | var value: [String: Any] = value as? [String: Any], 229 | let date: Date = value["PostDate"] as? Date, 230 | let extendedMetaInfo: [String: Any] = value["ExtendedMetaInfo"] as? [String: Any], 231 | extendedMetaInfo["InstallAssistantPackageIdentifiers"] is [String: Any], 232 | let distributions: [String: Any] = value["Distributions"] as? [String: Any], 233 | let distributionURL: String = distributions["English"] as? String, 234 | let url: URL = URL(string: distributionURL) else { 235 | continue 236 | } 237 | 238 | do { 239 | let string: String = try String(contentsOf: url, encoding: .utf8) 240 | 241 | guard 242 | let name: String = nameFromDistribution(string), 243 | let version: String = versionFromDistribution(string), 244 | let build: String = buildFromDistribution(string), 245 | !name.isEmpty, !version.isEmpty, !build.isEmpty else { 246 | !quiet ? PrettyPrint.print("No 'Name', 'Version' or 'Build' found, skipping...", noAnsi: noAnsi) : Mist.noop() 247 | continue 248 | } 249 | 250 | let boardIDs: [String] = boardIDsFromDistribution(string) 251 | let deviceIDs: [String] = deviceIDsFromDistribution(string) 252 | let unsupportedModelIdentifiers: [String] = unsupportedModelIdentifiersFromDistribution(string) 253 | 254 | value["Identifier"] = key 255 | value["Name"] = name 256 | value["Version"] = version 257 | value["Build"] = build 258 | value["BoardIDs"] = boardIDs 259 | value["DeviceIDs"] = deviceIDs 260 | value["UnsupportedModelIdentifiers"] = unsupportedModelIdentifiers 261 | value["PostDate"] = dateFormatter.string(from: date) 262 | value["DistributionURL"] = distributionURL 263 | 264 | // JSON object creation freaks out with the default DeferredSUEnablementDate date format 265 | value.removeValue(forKey: "DeferredSUEnablementDate") 266 | 267 | let installerData: Data = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) 268 | let installer: Installer = try JSONDecoder().decode(Installer.self, from: installerData) 269 | installers.append(installer) 270 | } catch { 271 | !quiet ? PrettyPrint.print(error.localizedDescription, noAnsi: noAnsi, prefixColor: .red) : Mist.noop() 272 | } 273 | } 274 | 275 | return installers 276 | } 277 | 278 | /// Returns the macOS Installer **Name** value from the provided distribution file string. 279 | /// 280 | /// - Parameters: 281 | /// - string: The distribution string. 282 | /// 283 | /// - Returns: The macOS Installer **Name** string if present, otherwise `nil`. 284 | private static func nameFromDistribution(_ string: String) -> String? { 285 | guard string.contains("suDisabledGroupID") else { 286 | return nil 287 | } 288 | 289 | return string.replacingOccurrences(of: "^[\\s\\S]*suDisabledGroupID=\"", with: "", options: .regularExpression) 290 | .replacingOccurrences(of: "\"[\\s\\S]*$", with: "", options: .regularExpression) 291 | .replacingOccurrences(of: "Install ", with: "") 292 | } 293 | 294 | /// Returns the macOS Installer **Version** value from the provided distribution file string. 295 | /// 296 | /// - Parameters: 297 | /// - string: The distribution string. 298 | /// 299 | /// - Returns: The macOS Installer **Version** string if present, otherwise `nil`. 300 | private static func versionFromDistribution(_ string: String) -> String? { 301 | guard string.contains("VERSION") else { 302 | return nil 303 | } 304 | 305 | return string.replacingOccurrences(of: "^[\\s\\S]*VERSION<\\/key>\\s*", with: "", options: .regularExpression) 306 | .replacingOccurrences(of: "<\\/string>[\\s\\S]*$", with: "", options: .regularExpression) 307 | } 308 | 309 | /// Returns the macOS Installer **Build** value from the provided distribution file string. 310 | /// 311 | /// - Parameters: 312 | /// - string: The distribution string. 313 | /// 314 | /// - Returns: The macOS Installer **Build** string if present, otherwise `nil`. 315 | private static func buildFromDistribution(_ string: String) -> String? { 316 | guard string.contains("BUILD") else { 317 | return nil 318 | } 319 | 320 | return string.replacingOccurrences(of: "^[\\s\\S]*BUILD<\\/key>\\s*", with: "", options: .regularExpression) 321 | .replacingOccurrences(of: "<\\/string>[\\s\\S]*$", with: "", options: .regularExpression) 322 | } 323 | 324 | /// Returns the macOS Installer **Board ID** values from the provided distribution file string. 325 | /// 326 | /// - Parameters: 327 | /// - string: The distribution string. 328 | /// 329 | /// - Returns: An array of **Board ID** strings. 330 | private static func boardIDsFromDistribution(_ string: String) -> [String] { 331 | guard string.contains("supportedBoardIDs") || string.contains("boardIds") else { 332 | return [] 333 | } 334 | 335 | return string.replacingOccurrences(of: "^[\\s\\S]*(supportedBoardIDs|boardIds) = \\[", with: "", options: .regularExpression) 336 | .replacingOccurrences(of: ",?\\];[\\s\\S]*$", with: "", options: .regularExpression) 337 | .replacingOccurrences(of: "'", with: "") 338 | .replacingOccurrences(of: " ", with: "") 339 | .components(separatedBy: ",") 340 | .sorted() 341 | } 342 | 343 | /// Returns the macOS Installer **Device ID** values from the provided distribution file string. 344 | /// 345 | /// - Parameters: 346 | /// - string: The distribution string. 347 | /// 348 | /// - Returns: An array of **Device ID** strings. 349 | private static func deviceIDsFromDistribution(_ string: String) -> [String] { 350 | guard string.contains("supportedDeviceIDs") else { 351 | return [] 352 | } 353 | 354 | return string.replacingOccurrences(of: "^[\\s\\S]*supportedDeviceIDs = \\[", with: "", options: .regularExpression) 355 | .replacingOccurrences(of: "\\];[\\s\\S]*$", with: "", options: .regularExpression) 356 | .replacingOccurrences(of: "'", with: "") 357 | .replacingOccurrences(of: " ", with: "") 358 | .uppercased() 359 | .components(separatedBy: ",") 360 | .sorted() 361 | } 362 | 363 | /// Returns the macOS Installer **Unsupported Model Identifier** values from the provided distribution file string. 364 | /// 365 | /// - Parameters: 366 | /// - string: The distribution string. 367 | /// 368 | /// - Returns: An array of **Unsupported Model Identifier** strings. 369 | private static func unsupportedModelIdentifiersFromDistribution(_ string: String) -> [String] { 370 | guard string.contains("nonSupportedModels") else { 371 | return [] 372 | } 373 | 374 | return string.replacingOccurrences(of: "^[\\s\\S]*nonSupportedModels = \\[", with: "", options: .regularExpression) 375 | .replacingOccurrences(of: ",?\\];[\\s\\S]*$", with: "", options: .regularExpression) 376 | .replacingOccurrences(of: "','", with: "'|'") 377 | .replacingOccurrences(of: "'", with: "") 378 | .components(separatedBy: "|") 379 | .sorted() 380 | } 381 | 382 | /// Retrieves the first macOS Installer download match for the provided search string. 383 | /// 384 | /// - Parameters: 385 | /// - installers: The array of possible macOS Installers that can be downloaded. 386 | /// - searchString: The download search string. 387 | /// 388 | /// - Returns: The first match of a macOS Installer, otherwise `nil`. 389 | static func installer(from installers: [Installer], searchString: String) -> Installer? { 390 | let searchString: String = searchString.lowercased().replacingOccurrences(of: "macos ", with: "") 391 | let filteredInstallersByName: [Installer] = installers.filter { $0.name.lowercased().replacingOccurrences(of: "macos ", with: "").contains(searchString) } 392 | let filteredInstallersByVersionExact: [Installer] = installers.filter { $0.version.lowercased() == searchString } 393 | let filteredInstallersByVersionStartsWith: [Installer] = installers.filter { $0.version.lowercased().starts(with: searchString) } 394 | let filteredInstallersByBuild: [Installer] = installers.filter { $0.build.lowercased().starts(with: searchString) } 395 | return filteredInstallersByName.first ?? filteredInstallersByVersionExact.first ?? filteredInstallersByVersionStartsWith.first ?? filteredInstallersByBuild.first 396 | } 397 | 398 | /// Retrieves macOS Installer downloads matching the provided search string. 399 | /// 400 | /// - Parameters: 401 | /// - installers: The array of possible macOS Installers that can be downloaded. 402 | /// - searchString: The download search string. 403 | /// 404 | /// - Returns: An array of macOS Installer matches. 405 | static func installers(from installers: [Installer], searchString: String) -> [Installer] { 406 | let searchString: String = searchString.lowercased().replacingOccurrences(of: "macos ", with: "") 407 | let filteredInstallersByName: [Installer] = installers.filter { $0.name.lowercased().replacingOccurrences(of: "macos ", with: "").contains(searchString) } 408 | let filteredInstallersByVersion: [Installer] = installers.filter { $0.version.lowercased().starts(with: searchString) } 409 | let filteredInstallersByBuild: [Installer] = installers.filter { $0.build.lowercased().starts(with: searchString) } 410 | return filteredInstallersByName + filteredInstallersByVersion + filteredInstallersByBuild 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /Mist/Helpers/InstallerCreator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallerCreator.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 11/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Helper Struct used to install macOS Installers. 11 | enum InstallerCreator { 12 | /// Creates a recently downloaded macOS Installer. 13 | /// 14 | /// - Parameters: 15 | /// - installer: The selected macOS Installer that was downloaded. 16 | /// - options: Download options for macOS Installers. 17 | /// 18 | /// - Throws: A `MistError` if the downloaded macOS Installer fails to install. 19 | static func create(_ installer: Installer, options: DownloadInstallerOptions) throws { 20 | !options.quiet ? PrettyPrint.printHeader("INSTALL", noAnsi: options.noAnsi) : Mist.noop() 21 | 22 | let imageURL: URL = DownloadInstallerCommand.temporaryImage(for: installer, options: options) 23 | let temporaryURL: URL = .init(fileURLWithPath: DownloadInstallerCommand.temporaryDirectory(for: installer, options: options)) 24 | 25 | if FileManager.default.fileExists(atPath: imageURL.path) { 26 | !options.quiet ? PrettyPrint.print("Deleting old image '\(imageURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 27 | try FileManager.default.removeItem(at: imageURL) 28 | } 29 | 30 | !options.quiet ? PrettyPrint.print("Creating image '\(imageURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 31 | var arguments: [String] = ["hdiutil", "create", "-fs", "HFS+", "-layout", "SPUD", "-size", "\(installer.diskImageSize)g", "-volname", installer.identifier, imageURL.path] 32 | _ = try Shell.execute(arguments) 33 | 34 | !options.quiet ? PrettyPrint.print("Mounting disk image at mount point '\(installer.temporaryDiskImageMountPointURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 35 | arguments = ["hdiutil", "attach", imageURL.path, "-noverify", "-nobrowse", "-mountpoint", installer.temporaryDiskImageMountPointURL.path] 36 | _ = try Shell.execute(arguments) 37 | 38 | if 39 | installer.sierraOrOlder, 40 | let package: Package = installer.packages.first { 41 | let legacyDiskImageURL: URL = temporaryURL.appendingPathComponent(package.filename) 42 | let legacyDiskImageMountPointURL: URL = .init(fileURLWithPath: "/Volumes/Install \(installer.name)") 43 | let packageURL: URL = .init(fileURLWithPath: "/Volumes/Install \(installer.name)").appendingPathComponent(package.filename.replacingOccurrences(of: ".dmg", with: ".pkg")) 44 | 45 | !options.quiet ? PrettyPrint.print("Mounting Installer disk image at mount point '\(legacyDiskImageMountPointURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 46 | arguments = ["hdiutil", "attach", legacyDiskImageURL.path, "-noverify", "-nobrowse", "-mountpoint", legacyDiskImageMountPointURL.path] 47 | _ = try Shell.execute(arguments) 48 | 49 | !options.quiet ? PrettyPrint.print("Creating Installer in disk image at mount point '\(legacyDiskImageMountPointURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 50 | arguments = ["installer", "-pkg", packageURL.path, "-target", installer.temporaryDiskImageMountPointURL.path] 51 | let variables: [String: String] = ["CM_BUILD": "CM_BUILD"] 52 | _ = try Shell.execute(arguments, environment: variables) 53 | 54 | !options.quiet ? PrettyPrint.print("Unmounting Installer disk image at mount point '\(legacyDiskImageMountPointURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 55 | let arguments: [String] = ["hdiutil", "detach", legacyDiskImageMountPointURL.path, "-force"] 56 | _ = try Shell.execute(arguments) 57 | } else { 58 | guard let url: URL = URL(string: installer.distribution) else { 59 | throw MistError.invalidURL(installer.distribution) 60 | } 61 | 62 | let distributionURL: URL = temporaryURL.appendingPathComponent(url.lastPathComponent) 63 | 64 | !options.quiet ? PrettyPrint.print("Creating new installer '\(installer.temporaryInstallerURL.path)'...", noAnsi: options.noAnsi) : Mist.noop() 65 | arguments = ["installer", "-pkg", distributionURL.path, "-target", installer.temporaryDiskImageMountPointURL.path] 66 | let variables: [String: String] = ["CM_BUILD": "CM_BUILD"] 67 | _ = try Shell.execute(arguments, environment: variables) 68 | } 69 | 70 | if installer.catalinaOrNewer { 71 | arguments = ["ditto", "\(installer.temporaryDiskImageMountPointURL.path)Applications", "\(installer.temporaryDiskImageMountPointURL.path)/Applications"] 72 | _ = try Shell.execute(arguments) 73 | arguments = ["rm", "-r", "\(installer.temporaryDiskImageMountPointURL.path)Applications"] 74 | _ = try Shell.execute(arguments) 75 | } 76 | 77 | // temporary fix for applying correct posix permissions 78 | arguments = ["chmod", "-R", "755", installer.temporaryInstallerURL.path] 79 | _ = try Shell.execute(arguments) 80 | 81 | !options.quiet ? PrettyPrint.print("Created new installer '\(installer.temporaryInstallerURL.path)'", noAnsi: options.noAnsi) : Mist.noop() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Mist/Helpers/PrettyPrint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrettyPrint.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 12/3/21. 6 | // 7 | 8 | /// Helper Struct used to format printed messages. 9 | enum PrettyPrint { 10 | enum Prefix: String { 11 | case `default` = " ├─ " 12 | case continuing = " │ " 13 | case ending = " └─ " 14 | 15 | var description: String { 16 | rawValue 17 | } 18 | } 19 | 20 | /// Prints a string with a border, in blue. 21 | /// 22 | /// - Parameters: 23 | /// - header: The string to print. 24 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 25 | static func printHeader(_ header: String, noAnsi: Bool) { 26 | let horizontal: String = .init(repeating: "─", count: header.count + 2) 27 | let string: String = "┌\(horizontal)┐\n│ \(header) │\n└\(horizontal)┘" 28 | Swift.print(noAnsi ? string : string.color(.blue)) 29 | } 30 | 31 | /// Prints a string with an optional custom prefix. 32 | /// 33 | /// - Parameters: 34 | /// - string: The string to print. 35 | /// - noAnsi: Set to `true` to print the string without any color or formatting. 36 | /// - prefix: The optional prefix. 37 | /// - prefixColor: The optional prefix color. 38 | /// - replacing: Optionally set to `true` to replace the previous line. 39 | static func print(_ string: String, noAnsi: Bool, prefix: Prefix = .default, prefixColor: String.Color = .green, replacing: Bool = false) { 40 | let replacingString: String = replacing && !noAnsi ? "\u{1B}[1A\u{1B}[K" : "" 41 | let prefixString: String = "\(noAnsi ? prefix.description : prefix.description.color(prefixColor))" 42 | Swift.print("\(replacingString)\(prefixString)\(string)") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Mist/Helpers/Shell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shell.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 14/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Helper Struct used to execute shell commands 11 | enum Shell { 12 | /// Executes custom shell commands. 13 | /// 14 | /// - Parameters: 15 | /// - arguments: An array of arguments to execute. 16 | /// - variables: Optionally set custom environment variables. 17 | /// - currentDirectoryPath: Optionally set the current directory path 18 | /// 19 | /// - Throws: A `MistError` if the exit code is not zero. 20 | /// 21 | /// - Returns: The contents of standard output, if any, otherwise `nil`. 22 | static func execute(_ arguments: [String], environment variables: [String: String] = [:], currentDirectoryPath: String? = nil) throws -> String? { 23 | let output: Pipe = .init() 24 | let error: Pipe = .init() 25 | let process: Process = .init() 26 | process.launchPath = "/usr/bin/env" 27 | process.arguments = arguments 28 | process.standardOutput = output 29 | process.standardError = error 30 | 31 | var environment: [String: String] = ProcessInfo.processInfo.environment 32 | 33 | for (key, value) in variables { 34 | environment[key] = value 35 | } 36 | 37 | process.environment = environment 38 | 39 | if let currentDirectoryPath: String = currentDirectoryPath { 40 | process.currentDirectoryPath = currentDirectoryPath 41 | } 42 | 43 | process.launch() 44 | process.waitUntilExit() 45 | 46 | guard process.terminationStatus == 0 else { 47 | let data: Data = error.fileHandleForReading.readDataToEndOfFile() 48 | let message: String = .init(data: data, encoding: .utf8) ?? "[\(arguments.joined(separator: ", "))]" 49 | throw MistError.invalidExitStatus(code: process.terminationStatus, message: message) 50 | } 51 | 52 | let data: Data = output.fileHandleForReading.readDataToEndOfFile() 53 | let string: String = .init(decoding: data, as: UTF8.self) 54 | return string 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Mist/Helpers/Validator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validator.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | /// Helper Struct used to validate macOS Firmware and Installer downloads. 12 | enum Validator { 13 | /// Validates the macOS Firmware IPSW that was downloaded. 14 | /// 15 | /// - Parameters: 16 | /// - firmware: The selected macOS Firmware IPSW that was downloaded. 17 | /// - destination: The destination file URL of the downloaded firmware. 18 | /// 19 | /// - Throws: A `MistError` if the macOS Firmware IPSW fails validation. 20 | static func validate(_ firmware: Firmware, at destination: URL) throws { 21 | guard let shasum: String = destination.shasum() else { 22 | throw MistError.invalidData 23 | } 24 | 25 | guard shasum == firmware.shasum else { 26 | throw MistError.invalidShasum(invalid: shasum, valid: firmware.shasum) 27 | } 28 | } 29 | 30 | /// Validates the macOS Installer package that was downloaded. 31 | /// 32 | /// - Parameters: 33 | /// - package: The selected macOS Installer package that was downloaded. 34 | /// - destination: The destination file URL of the downloaded package. 35 | /// 36 | /// - Throws: A `MistError` if the macOS Installer package fails validation. 37 | static func validate(_ package: Package, at destination: URL) throws { 38 | guard !package.url.hasSuffix("English.dist") else { 39 | return 40 | } 41 | 42 | let attributes: [FileAttributeKey: Any] = try FileManager.default.attributesOfItem(atPath: destination.path) 43 | 44 | guard let fileSize: UInt64 = attributes[.size] as? UInt64 else { 45 | throw MistError.generalError("Unble to retrieve file size from file '\(destination.path)'") 46 | } 47 | 48 | guard fileSize == package.size else { 49 | throw MistError.invalidFileSize(invalid: fileSize, valid: UInt64(package.size)) 50 | } 51 | 52 | guard 53 | let string: String = package.integrityDataURL, 54 | let url: URL = URL(string: string), 55 | let size: Int = package.integrityDataSize else { 56 | return 57 | } 58 | 59 | let chunklist: Chunklist = try Chunklist(from: url, size: size) 60 | let fileHandle: FileHandle = try FileHandle(forReadingFrom: destination) 61 | var offset: UInt64 = 0 62 | 63 | for chunk in chunklist.chunks { 64 | try autoreleasepool { 65 | try fileHandle.seek(toOffset: offset) 66 | let data: Data = fileHandle.readData(ofLength: Int(chunk.size)) 67 | let shasum: String = SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined().uppercased() 68 | 69 | guard shasum == chunk.shasum else { 70 | try fileHandle.close() 71 | throw MistError.invalidShasum(invalid: shasum, valid: chunk.shasum) 72 | } 73 | 74 | offset += UInt64(chunk.size) 75 | } 76 | } 77 | 78 | try fileHandle.close() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Mist/Model/Architecture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Architecture.swift 3 | // mist 4 | // 5 | // Created by Nindi Gill on 15/6/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Architecture: String { 11 | case appleSilicon = "arm64" 12 | case intel = "x86_64" 13 | 14 | var identifier: String { 15 | rawValue 16 | } 17 | 18 | var description: String { 19 | switch self { 20 | case .appleSilicon: 21 | "Apple Silicon" 22 | case .intel: 23 | "Intel-based" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Mist/Model/Catalog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Catalog.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 16/3/21. 6 | // 7 | 8 | enum Catalog: String, CaseIterable { 9 | case standard 10 | case customer 11 | case developer 12 | case `public` 13 | 14 | static var urls: [String] { 15 | allCases.map(\.url) + allCases.map(\.sequoiaURL) 16 | } 17 | 18 | var url: String { 19 | switch self { 20 | case .standard: 21 | "https://swscan.apple.com/content/catalogs/others/index-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 22 | case .customer: 23 | "https://swscan.apple.com/content/catalogs/others/index-14customerseed-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 24 | case .developer: 25 | "https://swscan.apple.com/content/catalogs/others/index-14seed-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 26 | case .public: 27 | "https://swscan.apple.com/content/catalogs/others/index-14beta-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 28 | } 29 | } 30 | 31 | private var sequoiaURL: String { 32 | switch self { 33 | case .standard: 34 | "https://swscan.apple.com/content/catalogs/others/index-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 35 | case .customer: // swiftlint:disable:next line_length 36 | "https://swscan.apple.com/content/catalogs/others/index-15customerseed-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 37 | case .developer: 38 | "https://swscan.apple.com/content/catalogs/others/index-15seed-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 39 | case .public: 40 | "https://swscan.apple.com/content/catalogs/others/index-15beta-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Mist/Model/Chunk.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chunk.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Struct used to store each chunk's size and hash values. 11 | struct Chunk { 12 | /// Chunk Size 13 | let size: UInt32 14 | /// Chunk Hash Array 15 | let hash: [UInt8] 16 | /// Chunk Hash Array represented as a shasum string 17 | var shasum: String { 18 | hash.map { $0.hexString() } 19 | .joined() 20 | .replacingOccurrences(of: "0x", with: "") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Mist/Model/Chunklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chunklist.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/4/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Struct used to store all elements of the Chunklist. 11 | struct Chunklist { 12 | /// Chunklist Magic Header constant 13 | static let magicHeader: UInt32 = 0x4C4B4E43 14 | /// Chunklist Header Size constant 15 | static let headerSize: UInt32 = 0x00000024 16 | /// Chunklist File Version constant 17 | static let fileVersion: UInt8 = 0x01 18 | /// Chunklist Chunk Method constant 19 | static let chunkMethod: UInt8 = 0x01 20 | /// Chunklist Signature Method constant 21 | static let signatureMethod: UInt8 = 0x02 22 | /// Chunklist Padding constant 23 | static let padding: UInt8 = 0x00 24 | /// Chunklist Chunks Offset constant 25 | static let chunksOffset: UInt64 = 0x0000000000000024 26 | 27 | /// Magic Header 28 | let magicHeader: UInt32 29 | /// Header Size 30 | let headerSize: UInt32 31 | /// File Version 32 | let fileVersion: UInt8 33 | /// Chunk Method 34 | let chunkMethod: UInt8 35 | /// Signature Method 36 | let signatureMethod: UInt8 37 | /// Padding 38 | let padding: UInt8 39 | /// Total Chunks 40 | let totalChunks: UInt64 41 | /// Chunks Offset 42 | let chunksOffset: UInt64 43 | /// Signature Offset 44 | let signatureOffset: UInt64 45 | /// Chunks Array 46 | let chunks: [Chunk] 47 | /// Signature Array 48 | let signature: [UInt8] 49 | 50 | /// Initializes a chunklist from the provided URL. 51 | /// 52 | /// - Parameters: 53 | /// - url: The URL used to retrieve the chunklist. 54 | /// - size: The expected file size of the chunklist. 55 | /// 56 | /// - Throws: A `MistError` if the Chunklist validation fails 57 | init(from url: URL, size: Int) throws { 58 | let data: Data = try Data(contentsOf: url) 59 | 60 | guard data.count == size else { 61 | throw MistError.chunklistValidationFailed("Invalid file size: '\(data.count)', should be '\(size)'") 62 | } 63 | 64 | let array: [UInt8] = .init(data) 65 | magicHeader = array.uInt32(at: 0x00) 66 | headerSize = array.uInt32(at: 0x04) 67 | fileVersion = array.uInt8(at: 0x08) 68 | chunkMethod = array.uInt8(at: 0x09) 69 | signatureMethod = array.uInt8(at: 0x0A) 70 | padding = array.uInt8(at: 0x0B) 71 | totalChunks = array.uInt64(at: 0x0C) 72 | chunksOffset = array.uInt64(at: 0x14) 73 | signatureOffset = array.uInt64(at: 0x1C) 74 | chunks = Chunklist.chunks(Array(array[Int(chunksOffset) ..< Int(signatureOffset)]), totalChunks: Int(totalChunks)) 75 | signature = Array(array[Int(signatureOffset)...]) 76 | 77 | guard magicHeader == Chunklist.magicHeader else { 78 | throw MistError.chunklistValidationFailed("Invalid magic header: '\(magicHeader.hexString())', should be '\(Chunklist.magicHeader.hexString())'") 79 | } 80 | 81 | guard headerSize == Chunklist.headerSize else { 82 | throw MistError.chunklistValidationFailed("Invalid header size: '\(headerSize.hexString())', should be '\(Chunklist.headerSize.hexString())'") 83 | } 84 | 85 | guard fileVersion == Chunklist.fileVersion else { 86 | throw MistError.chunklistValidationFailed("Invalid file version: '\(fileVersion.hexString())', should be '\(Chunklist.fileVersion.hexString())'") 87 | } 88 | 89 | guard chunkMethod == Chunklist.chunkMethod else { 90 | throw MistError.chunklistValidationFailed("Invalid chunk method: '\(chunkMethod.hexString())', should be '\(Chunklist.chunkMethod.hexString())'") 91 | } 92 | 93 | guard signatureMethod == Chunklist.signatureMethod else { 94 | throw MistError.chunklistValidationFailed("Invalid signature method: '\(signatureMethod.hexString())', should be '\(Chunklist.signatureMethod.hexString())'") 95 | } 96 | 97 | guard padding == Chunklist.padding else { 98 | throw MistError.chunklistValidationFailed("Invalid padding: '\(padding.hexString())', should be '\(Chunklist.padding.hexString())'") 99 | } 100 | 101 | guard chunksOffset == Chunklist.chunksOffset else { 102 | throw MistError.chunklistValidationFailed("Invalid chunks offset: '\(chunksOffset.hexString())', should be '\(Chunklist.chunksOffset.hexString())'") 103 | } 104 | 105 | guard chunks.count == totalChunks else { 106 | throw MistError.chunklistValidationFailed("Incorrect number of chunks: '\(chunks.count)', should be '\(totalChunks)'") 107 | } 108 | 109 | guard signatureOffset == chunksOffset + totalChunks * 36 else { 110 | throw MistError.chunklistValidationFailed("Invalid signature offset: '\(signatureOffset.hexString())', should be '\((chunksOffset + totalChunks * 36).hexString())'") 111 | } 112 | } 113 | 114 | /// Returns an array of Chunk structs based on the provided input array. 115 | /// 116 | /// - Parameters: 117 | /// - array: An array of `UInt8` values containing the chunk size and hash data. 118 | /// - totalChunks: The total number of expected chunks. 119 | /// 120 | /// - Returns: An array of Chunk structs. 121 | private static func chunks(_ array: [UInt8], totalChunks: Int) -> [Chunk] { 122 | var chunks: [Chunk] = [] 123 | 124 | for offset in 0 ..< totalChunks { 125 | let size: UInt32 = array.uInt32(at: offset * 0x24) 126 | let hash: [UInt8] = Array(array[offset * 0x24 + 0x04 ... (offset * 0x24 + 0x04) + 0x1F]) 127 | let chunk: Chunk = .init(size: size, hash: hash) 128 | chunks.append(chunk) 129 | } 130 | 131 | return chunks 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Mist/Model/Firmware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Firmware.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 26/8/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Firmware: Decodable { 11 | enum CodingKeys: String, CodingKey { 12 | case version 13 | case build = "buildid" 14 | case shasum = "sha1sum" 15 | case size 16 | case url 17 | case date = "releasedate" 18 | case signed 19 | case compatible 20 | } 21 | 22 | static let firmwaresURL: String = "https://api.ipsw.me/v3/firmwares.json/condensed" 23 | static let deviceURLTemplate: String = "https://api.ipsw.me/v4/device/MODELIDENTIFIER?type=ipsw" 24 | 25 | var identifier: String { 26 | "\(String.identifier).\(version)-\(build)" 27 | } 28 | 29 | var name: String { 30 | var name: String = "" 31 | 32 | if version.range(of: "^15", options: .regularExpression) != nil { 33 | name = "macOS Sequoia" 34 | } else if version.range(of: "^14", options: .regularExpression) != nil { 35 | name = "macOS Sonoma" 36 | } else if version.range(of: "^13", options: .regularExpression) != nil { 37 | name = "macOS Ventura" 38 | } else if version.range(of: "^12", options: .regularExpression) != nil { 39 | name = "macOS Monterey" 40 | } else if version.range(of: "^11", options: .regularExpression) != nil { 41 | name = "macOS Big Sur" 42 | } else { 43 | name = "macOS \(version)" 44 | } 45 | 46 | name = beta ? "\(name) beta" : name 47 | return name 48 | } 49 | 50 | let version: String 51 | let build: String 52 | let shasum: String 53 | let size: Int64 54 | let url: String 55 | let date: String 56 | let compatible: Bool 57 | var dateDescription: String { 58 | String(date.prefix(10)) 59 | } 60 | 61 | let signed: Bool 62 | var beta: Bool { 63 | build.range(of: "[a-z]$", options: .regularExpression) != nil 64 | } 65 | 66 | var filename: String { 67 | url.components(separatedBy: "/").last ?? url 68 | } 69 | 70 | var dictionary: [String: Any] { 71 | [ 72 | "name": name, 73 | "version": version, 74 | "build": build, 75 | "size": size, 76 | "url": url, 77 | "date": date, 78 | "compatible": compatible, 79 | "signed": signed, 80 | "beta": beta 81 | ] 82 | } 83 | 84 | /// Perform a lookup and retrieve a list of supported Firmware builds for this Mac. 85 | /// 86 | /// - Returns: An array of Firmware build strings. 87 | static func supportedBuilds() -> [String] { 88 | guard 89 | let architecture: Architecture = Hardware.architecture, 90 | architecture == .appleSilicon, 91 | let modelIdentifier: String = Hardware.modelIdentifier, 92 | let url: URL = URL(string: Firmware.deviceURLTemplate.replacingOccurrences(of: "MODELIDENTIFIER", with: modelIdentifier)) else { 93 | return [] 94 | } 95 | 96 | do { 97 | let string: String = try String(contentsOf: url) 98 | 99 | guard 100 | let data: Data = string.data(using: .utf8), 101 | let dictionary: [String: Any] = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], 102 | let array: [[String: Any]] = dictionary["firmwares"] as? [[String: Any]] else { 103 | return [] 104 | } 105 | 106 | return array.compactMap { $0["buildid"] as? String } 107 | } catch { 108 | return [] 109 | } 110 | } 111 | } 112 | 113 | extension Firmware: Equatable { 114 | static func == (lhs: Firmware, rhs: Firmware) -> Bool { 115 | lhs.version == rhs.version && 116 | lhs.build == rhs.build && 117 | lhs.shasum == rhs.shasum && 118 | lhs.size == rhs.size && 119 | lhs.url == rhs.url && 120 | lhs.signed == rhs.signed 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Mist/Model/Hardware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Hardware.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 31/5/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Hardware Struct used to retrieve Hardware information. 11 | enum Hardware { 12 | /// Hardware Architecture (Apple Silicon or Intel). 13 | static var architecture: Architecture? { 14 | #if arch(arm64) 15 | return .appleSilicon 16 | #elseif arch(x86_64) 17 | return .intel 18 | #else 19 | return nil 20 | #endif 21 | } 22 | 23 | /// Hardware Board ID (Intel). 24 | static var boardID: String? { 25 | architecture == .intel ? registryProperty(for: "board-id") : nil 26 | } 27 | 28 | /// Hardware Device ID (Apple Silicon or Intel T2). 29 | static var deviceID: String? { 30 | switch architecture { 31 | case .appleSilicon: 32 | registryProperty(for: "compatible")?.components(separatedBy: "\0").first?.uppercased() 33 | case .intel: 34 | registryProperty(for: "bridge-model")?.uppercased() 35 | default: 36 | nil 37 | } 38 | } 39 | 40 | /// Hardware Model Identifier (Apple Silicon or Intel). 41 | static var modelIdentifier: String? { 42 | registryProperty(for: "model") 43 | } 44 | 45 | /// Retrieves the IOKit Registry **IOPlatformExpertDevice** entity property for the provided key. 46 | /// 47 | /// - Parameters: 48 | /// - key: The key for the entity property. 49 | /// 50 | /// - Returns: The entity property for the provided key. 51 | private static func registryProperty(for key: String) -> String? { 52 | let entry: io_service_t = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) 53 | 54 | defer { 55 | IOObjectRelease(entry) 56 | } 57 | 58 | var properties: Unmanaged? 59 | 60 | guard 61 | IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS, 62 | let properties: Unmanaged = properties else { 63 | return nil 64 | } 65 | 66 | let nsDictionary: NSDictionary = properties.takeRetainedValue() as NSDictionary 67 | 68 | guard 69 | let dictionary: [String: Any] = nsDictionary as? [String: Any], 70 | dictionary.keys.contains(key), 71 | let data: Data = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data else { 72 | return nil 73 | } 74 | 75 | let string: String = .init(decoding: data, as: UTF8.self) 76 | return string.trimmingCharacters(in: CharacterSet(["\0"])) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Mist/Model/InstallerOutputType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallerOutputType.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 29/5/2022. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | enum InstallerOutputType: String, ExpressibleByArgument { 11 | case application 12 | case image 13 | case iso 14 | case package 15 | case bootableInstaller = "bootableinstaller" 16 | 17 | var description: String { 18 | rawValue 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mist/Model/ListOutputType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListOutputType.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 1/9/21. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | enum ListOutputType: String, ExpressibleByArgument { 11 | case ascii 12 | case csv 13 | case json 14 | case plist 15 | case yaml 16 | } 17 | -------------------------------------------------------------------------------- /Mist/Model/MistError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MistError.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 15/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum MistError: Error { 11 | case generalError(_ string: String) 12 | case missingListSearchString 13 | case missingExportPath 14 | case invalidExportFileExtension 15 | case invalidUser 16 | case missingDownloadSearchString 17 | case missingFirmwareName 18 | case missingFirmwareMetadataCachePath 19 | case missingApplicationName 20 | case missingImageName 21 | case missingImageSigningIdentity 22 | case missingIsoName 23 | case missingPackageName 24 | case missingPackageIdentifier 25 | case missingPackageSigningIdentity 26 | case missingBootableInstallerVolume 27 | case bootableInstallerVolumeNotFound(_ volume: String) 28 | case bootableInstallerVolumeUnknownFormat(_ volume: String) 29 | case bootableInstallerVolumeInvalidFormat(volume: String, format: String) 30 | case bootableInstallerVolumeIsReadOnly(_ volume: String) 31 | case missingOutputDirectory 32 | case maximumRetriesReached 33 | case notEnoughFreeSpace(volume: String, free: Int64, required: Int64) 34 | case existingFile(path: String) 35 | case chunklistValidationFailed(_ string: String) 36 | case invalidChunklist(url: URL) 37 | case invalidData 38 | case invalidExitStatus(code: Int32, message: String) 39 | case invalidFileSize(invalid: UInt64, valid: UInt64) 40 | case invalidShasum(invalid: String, valid: String) 41 | case invalidURL(_ url: String) 42 | case invalidCachingServerProtocol(_ url: URL) 43 | 44 | var description: String { 45 | switch self { 46 | case let .generalError(string): 47 | "Error: \(string)" 48 | case .missingListSearchString: 49 | "List is missing or empty." 50 | case .missingExportPath: 51 | "[-e, --export] Export path is missing or empty." 52 | case .invalidExportFileExtension: 53 | "Export file extension is invalid." 54 | case .invalidUser: 55 | "This command requires to be run as 'root'." 56 | case .missingDownloadSearchString: 57 | "Download is missing or empty." 58 | case .missingFirmwareName: 59 | "[--firmware-name] macOS Restore Firmware output filename is missing or empty." 60 | case .missingFirmwareMetadataCachePath: 61 | "[--metadata-cache] macOS Firmware metadata cache path is missing or empty." 62 | case .missingApplicationName: 63 | "[--application-name] macOS Installer output filename is missing or empty." 64 | case .missingImageName: 65 | "[--image-name] macOS Disk Image output filename is missing or empty." 66 | case .missingImageSigningIdentity: 67 | "[--image-signing-identity] macOS Disk Image signing identity is missing or empty." 68 | case .missingIsoName: 69 | "[--iso-name] Bootable macOS Disk Image output filename is missing or empty." 70 | case .missingPackageName: 71 | "[--package-name] macOS Installer Package output filename is missing or empty." 72 | case .missingPackageIdentifier: 73 | "[--package-identifier] macOS Installer Package identifier is missing or empty." 74 | case .missingPackageSigningIdentity: 75 | "[--package-signing-identity] macOS Installer Package signing identity is missing or empty." 76 | case .missingBootableInstallerVolume: 77 | "[--bootable-installer-volume] Bootable macOS Installer volume is missing or empty." 78 | case let .bootableInstallerVolumeNotFound(volume): 79 | "Unable to find Bootable macOS Installer volume '\(volume)'." 80 | case let .bootableInstallerVolumeUnknownFormat(volume): 81 | "Unable to determine format of Bootable macOS Installer volume '\(volume)'." 82 | case let .bootableInstallerVolumeInvalidFormat(volume, format): 83 | "Bootable macOS Installer volume '\(volume)' has invalid format '\(format)'. Format to 'Mac OS Extended (Journaled)' using Disk Utility." 84 | case let .bootableInstallerVolumeIsReadOnly(volume): 85 | "Bootable macOS Installer volume '\(volume)' is read-only. Format using Disk Utility." 86 | case .missingOutputDirectory: 87 | "[-o, --output-directory] Output directory is missing or empty." 88 | case .maximumRetriesReached: 89 | "Maximum number of retries reached." 90 | case let .notEnoughFreeSpace(volume, free, required): 91 | "Not enough free space on volume '\(volume)': \(free.bytesString()) free, \(required.bytesString()) required" 92 | case let .existingFile(path): 93 | "Existing file: '\(path)'. Use [--force] to overwrite." 94 | case let .chunklistValidationFailed(string): 95 | "Chunklist validation failed: \(string)" 96 | case let .invalidChunklist(url): 97 | "Unable to validate data integrity due to invalid chunklist: \(url.path)" 98 | case .invalidData: 99 | "Invalid data." 100 | case let .invalidExitStatus(code, message): 101 | "Invalid Exit Status Code: '\(code)', Message: \(message)" 102 | case let .invalidFileSize(invalid, valid): 103 | "Invalid File Size: '\(invalid)', should be: '\(valid)'" 104 | case let .invalidShasum(invalid, valid): 105 | "Invalid Shasum: '\(invalid)', should be: '\(valid)'" 106 | case let .invalidURL(url): 107 | "Invalid URL: '\(url)'" 108 | case let .invalidCachingServerProtocol(url): 109 | "Invalid Content Caching Server protocol in URL: '\(url.absoluteString)', should be HTTP." 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Mist/Model/Package.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Package.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 11/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Package: Decodable { 11 | enum CodingKeys: String, CodingKey { 12 | case url = "URL" 13 | case size = "Size" 14 | case integrityDataURL = "IntegrityDataURL" 15 | case integrityDataSize = "IntegrityDataSize" 16 | } 17 | 18 | let url: String 19 | let size: Int 20 | let integrityDataURL: String? 21 | let integrityDataSize: Int? 22 | var filename: String { 23 | url.components(separatedBy: "/").last ?? url 24 | } 25 | 26 | var dictionary: [String: Any] { 27 | [ 28 | "url": url, 29 | "size": size, 30 | "integrityDataURL": integrityDataURL ?? "", 31 | "integrityDataSize": integrityDataSize ?? 0 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Mist/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Mist 4 | // 5 | // Created by Nindi Gill on 10/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | // Disable stdout stream buffering for more immediate output. 11 | setbuf(__stdoutp, nil) 12 | 13 | Mist.main() 14 | exit(0) 15 | -------------------------------------------------------------------------------- /MistTests/MistTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MistTests.swift 3 | // MistTests 4 | // 5 | // Created by Nindi Gill on 10/12/2022. 6 | // 7 | 8 | import XCTest 9 | 10 | final class MistTests: XCTestCase { 11 | func test() throws { 12 | XCTAssertTrue(true) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | /// Package configuration 7 | let package: Package = .init( 8 | name: "Mist", 9 | platforms: [ 10 | .macOS(.v11) 11 | ], 12 | products: [ 13 | .executable(name: "mist", targets: ["Mist"]) 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.4.0"), 17 | .package(url: "https://github.com/jpsim/Yams", from: "5.1.2") 18 | ], 19 | targets: [ 20 | .executableTarget( 21 | name: "Mist", 22 | dependencies: [ 23 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 24 | .product(name: "Yams", package: "Yams") 25 | ], 26 | path: "Mist" 27 | ), 28 | .testTarget( 29 | name: "MistTests", 30 | dependencies: [ 31 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 32 | .product(name: "Yams", package: "Yams") 33 | ], 34 | path: "MistTests" 35 | ) 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /README Resources/Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninxsoft/mist-cli/d5715b1b6da245599d8e91c568b01ea18667cda5/README Resources/Example.png -------------------------------------------------------------------------------- /README Resources/Full Disk Access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninxsoft/mist-cli/d5715b1b6da245599d8e91c568b01ea18667cda5/README Resources/Full Disk Access.png -------------------------------------------------------------------------------- /README Resources/Slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninxsoft/mist-cli/d5715b1b6da245599d8e91c568b01ea18667cda5/README Resources/Slack.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MIST - macOS Installer Super Tool 2 | 3 | ![Latest Release](https://img.shields.io/github/v/release/ninxsoft/mist-cli?display_name=tag&label=Latest%20Release&sort=semver) ![Downloads](https://img.shields.io/github/downloads/ninxsoft/mist-cli/total?label=Downloads) [![Linting](https://github.com/ninxsoft/mist-cli/actions/workflows/linting.yml/badge.svg)](https://github.com/ninxsoft/mist-cli/actions/workflows/linting.yml) [![Unit Tests](https://github.com/ninxsoft/mist-cli/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/ninxsoft/mist-cli/actions/workflows/unit_tests.yml) [![Build](https://github.com/ninxsoft/mist-cli/actions/workflows/build.yml/badge.svg)](https://github.com/ninxsoft/mist-cli/actions/workflows/build.yml) 4 | 5 | A Mac command-line tool that automatically downloads **macOS Firmwares** / **Installers**: 6 | 7 | ![Example Screenshot](README%20Resources/Example.png) 8 | 9 | 10 | 11 | ## :information_source: Check out [Mist](https://github.com/ninxsoft/Mist) for the companion Mac app! 12 | 13 | ## ![Slack](README%20Resources/Slack.png) Check out [#mist](https://macadmins.slack.com/archives/CF0CFM5B7) on the [Mac Admins Slack](https://macadmins.slack.com) to discuss all things mist-cli! 14 | 15 | 16 | 17 | 18 | Buy me a coffee 19 | 20 | 21 | 22 | ## Features 23 | 24 | - [x] List all available macOS Firmwares / Installers available for download: 25 | - Display names, versions, builds, sizes and release dates 26 | - Optionally list beta versions of macOS 27 | - Filter macOS versions that are compatible with the Mac the app is being run from 28 | - Export lists as **CSV**, **JSON**, **Property List** or **YAML** 29 | - [x] Download available macOS Firmwares / Installers: 30 | - For Apple Silicon Macs: 31 | - Download a Firmware Restore file (.ipsw) 32 | - Validates the SHA-1 checksum upon download 33 | - For Intel based Macs (Universal for macOS Big Sur and later): 34 | - Generate an Application Bundle (.app) 35 | - Generate a Disk Image (.dmg) 36 | - Generate a Bootable Disk Image (.iso) 37 | - For use with virtualization software (ie. Parallels Desktop, VMware Fusion, VirtualBox) 38 | - Generate a macOS Installer Package (.pkg) 39 | - Supports packages on **macOS Big Sur and newer** with a massive 12GB+ payload! 40 | - Optionally codesign Disk Images and macOS Installer Packages 41 | - Check for free space before attempting any downloads or installations 42 | - Cache downloads to speed up build operations 43 | - Optionally specify a custom catalog URL, allowing you to list and download macOS Installers from the following: 44 | - **Customer Seed:** The catalog available as part of the [AppleSeed Program](https://appleseed.apple.com/) 45 | - **Developer Seed:** The catalog available as part of the [Apple Developer Program](https://developer.apple.com/programs/) 46 | - **Public Seed:** The catalog available as part of the [Apple Beta Software Program](https://beta.apple.com/) 47 | - **Note:** Catalogs from the Seed Programs may contain beta / unreleased versions of macOS. Ensure you are a member of these programs before proceeding. 48 | - Validates the Chunklist checksums upon download 49 | - Automatic retries for failed downloads! 50 | 51 | ## Usage 52 | 53 | ```bash 54 | OVERVIEW: macOS Installer Super Tool. 55 | 56 | Automatically download macOS Firmwares / Installers. 57 | 58 | USAGE: mist 59 | 60 | OPTIONS: 61 | -h, --help Show help information. 62 | 63 | SUBCOMMANDS: 64 | list List all macOS Firmwares / Installers available to download. 65 | download Download a macOS Firmware / Installer. 66 | version Display the version of mist. 67 | 68 | See 'mist help ' for detailed help. 69 | ``` 70 | 71 | **Note:** Depending on what **mist** downloads, you may require allowing **Full Disk Access** for your **Terminal** application of choice via [System Settings](https://support.apple.com/en-us/guide/mac-help/mh15217/13.0/mac/13.0): 72 | 73 | ![Full Disk Access](README%20Resources/Full%20Disk%20Access.png) 74 | 75 | ## Examples 76 | 77 | ```bash 78 | # List all available macOS Firmwares for Apple Silicon Macs: 79 | mist list firmware 80 | 81 | # List all available macOS Installers for Intel Macs, 82 | # including Universal Installers for macOS Big Sur and later: 83 | mist list installer 84 | 85 | # List all macOS Installers that are compatible with this Mac, 86 | # including Universal Installers for macOS Big Sur and later: 87 | mist list installer --compatible 88 | 89 | # List all available macOS Installers for Intel Macs, including betas, 90 | # also including Universal Installers for macOS Big Sur and later: 91 | mist list installer --include-betas 92 | 93 | # List only macOS Sonoma Installers for Intel Macs, 94 | # including Universal Installers for macOS Big Sur and later: 95 | mist list installer "macOS Sonoma" 96 | 97 | # List only the latest macOS Sonoma Installer for Intel Macs, 98 | # including Universal Installers for macOS Big Sur and later: 99 | mist list installer --latest "macOS Sonoma" 100 | 101 | # List + Export macOS Installers to a CSV file: 102 | mist list installer --export "/path/to/export.csv" 103 | 104 | # List + Export macOS Installers to a JSON file: 105 | mist list installer --export "/path/to/export.json" 106 | 107 | # List + Export macOS Installers to a Property List: 108 | mist list installer --export "/path/to/export.plist" 109 | 110 | # List + Export macOS Installers to a YAML file: 111 | mist list installer --export "/path/to/export.yaml" 112 | 113 | # Download the latest macOS Sonoma Firmware for 114 | # Apple Silicon Macs, with a custom name: 115 | mist download firmware "macOS Sonoma" --firmware-name "Install %NAME% %VERSION%-%BUILD%.ipsw" 116 | 117 | # Download the latest macOS Sonoma Installer for Intel Macs, 118 | # including Universal Installers for macOS Big Sur and later: 119 | mist download installer "macOS Sonoma" application 120 | 121 | # Download a specific macOS Installer version for Intel Macs, 122 | # including Universal Installers for macOS Big Sur and later: 123 | mist download installer "13.5.2" application 124 | 125 | # Download a specific macOS Installer version for Intel Macs, 126 | # including Universal Installers for macOS Big Sur and later, 127 | # with a custom name: 128 | mist download installer "13.5.2" application --application-name "Install %NAME% %VERSION%-%BUILD%.app" 129 | 130 | # Download a specific macOS Installer version for Intel Macs, 131 | # including Universal Installers for macOS Big Sur and later, 132 | # and generate a Disk Image with a custom name: 133 | mist download installer "13.5.2" image --image-name "Install %NAME% %VERSION%-%BUILD%.dmg" 134 | 135 | # Download a specific macOS Installer build for Inte Macs, 136 | # including Universal Installers for macOS Big Sur and later, 137 | # and generate a codesigned Disk Image output to a custom directory: 138 | mist download installer "22G91" image \ 139 | --image-signing-identity "Developer ID Application: Name (Team ID)" \ 140 | --output-directory "/path/to/custom/directory" 141 | 142 | # Download the latest macOS Sonoma Installer for Intel Macs, 143 | # including Universal Installers for macOS Big Sur and later, 144 | # and generate an Installer Application bundle, a Disk Image, 145 | # a Bootable Disk Image, a macOS Installer Package, 146 | # all with custom names, codesigned, output to a custom directory: 147 | mist download installer "macOS Sonoma" application image iso package \ 148 | --application-name "Install %NAME% %VERSION%-%BUILD%.app" \ 149 | --image-name "Install %NAME% %VERSION%-%BUILD%.dmg" \ 150 | --image-signing-identity "Developer ID Application: Name (Team ID)" \ 151 | --iso-name "Install %NAME% %VERSION%-%BUILD%.iso" \ 152 | --package-name "Install %NAME% %VERSION%-%BUILD%.pkg" \ 153 | --package-identifier "com.mycompany.pkg.install-%NAME%" \ 154 | --package-signing-identity "Developer ID Installer: Name (Team ID)" \ 155 | --output-directory "/path/to/custom/directory" 156 | ``` 157 | 158 | ## Build Requirements 159 | 160 | - Swift **5.10**. 161 | - Runs on **macOS Big Sur 11** and later. 162 | 163 | ## Download 164 | 165 | - Grab the latest version of **mist** from the [releases page](https://github.com/ninxsoft/mist-cli/releases). 166 | - Alternatively, install via [Homebrew](https://brew.sh) by running `brew install mist-cli` 167 | - **Note:** Version **1.15** and newer requires **macOS Big Sur 11** or later. 168 | - If you need to run **mist** on an older operating system, you can still use version **1.14**. 169 | 170 | ## Credits / Thank You 171 | 172 | - Project created and maintained by Nindi Gill ([ninxsoft](https://github.com/ninxsoft)). 173 | - Apple ([apple](https://github.com/apple)) for [Swift Argument Parser](https://github.com/apple/swift-argument-parser), used to perform command line argument and flag operations. 174 | - JP Simard ([jpsim](https://github.com/jpsim)) for [Yams](https://github.com/jpsim/Yams), used to export YAML. 175 | - Callum Jones ([cj123](https://github.com/cj123)) for [IPSW Downloads API](https://ipswdownloads.docs.apiary.io), used to determine macOS Firmware metadata. 176 | - Timothy Sutton ([timsutton](https://github.com/timsutton)) for the [mist-cli Homebrew Formula](https://formulae.brew.sh/formula/mist-cli). 177 | 178 | ## License 179 | 180 | > Copyright © 2021-2024 Nindi Gill 181 | > 182 | > Permission is hereby granted, free of charge, to any person obtaining a copy 183 | > of this software and associated documentation files (the "Software"), to deal 184 | > in the Software without restriction, including without limitation the rights 185 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 186 | > copies of the Software, and to permit persons to whom the Software is 187 | > furnished to do so, subject to the following conditions: 188 | > 189 | > The above copyright notice and this permission notice shall be included in all 190 | > copies or substantial portions of the Software. 191 | > 192 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 193 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 194 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 195 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 196 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 197 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 198 | > SOFTWARE. 199 | --------------------------------------------------------------------------------