├── .devcontainer └── devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── action-runner.plist │ ├── build_and_test.yml │ └── build_and_test_gh_hosted.yml ├── .gitignore ├── .swift-format ├── .swiftlint.yml ├── CHANGELOG ├── LICENSE ├── Makefile ├── NOTICE ├── Package.swift ├── README.md ├── Sources └── xcodeinstall │ ├── API │ ├── Authentication+Hashcash.swift │ ├── Authentication+MFA.swift │ ├── Authentication+SRP.swift │ ├── Authentication+UsernamePassword.swift │ ├── Authentication.swift │ ├── DispatchSemaphore.swift │ ├── Download.swift │ ├── DownloadDelegate.swift │ ├── DownloadListData.swift │ ├── HTTPClient.swift │ ├── Install.swift │ ├── InstallCLTools.swift │ ├── InstallDownloadListExtension.swift │ ├── InstallPkg.swift │ ├── InstallSupportedFiles.swift │ ├── InstallXcode.swift │ ├── List.swift │ ├── URLLogger.swift │ └── URLRequestExtension.swift │ ├── CLI-driver │ ├── CLIAuthenticate.swift │ ├── CLIDownload.swift │ ├── CLIInstall.swift │ ├── CLIList.swift │ ├── CLIMain.swift │ ├── CLIProgressBar.swift │ └── CLIStoreSecrets.swift │ ├── Environment.swift │ ├── Secrets │ ├── AWSSecretsHandler.swift │ ├── AWSSecretsHandlerSoto.swift │ ├── FileSecretsHandler.swift │ └── SecretsHandler.swift │ ├── Utilities │ ├── Array+AsyncMap.swift │ ├── FileHandler.swift │ └── ShellOutput.swift │ ├── Version.swift │ └── xcodeInstall │ ├── AuthenticateCommand.swift │ ├── DownloadCommand.swift │ ├── DownloadListParser.swift │ ├── InstallCommand.swift │ ├── ListCommand.swift │ ├── SignOutCommand.swift │ ├── StoreSecretsCommand.swift │ └── XcodeInstallCommand.swift ├── Tests ├── coverage.html ├── coverage.json ├── coverage.svg └── xcodeinstallTests │ ├── API │ ├── AuthenticationMFATest.swift │ ├── AuthenticationSRPTest.swift │ ├── AuthenticationTests.swift │ ├── DownloadDelegateTest.swift │ ├── DownloadTests.swift │ ├── HTTPClientTests.swift │ ├── InstallTest.swift │ ├── ListTest.swift │ ├── MockedNetworkClasses.swift │ ├── SRPTest.swift │ └── URLRequestCurlTest.swift │ ├── CLI │ ├── CLIAuthTest.swift │ ├── CLIDownloadTest.swift │ ├── CLIInstallTest.swift │ ├── CLIListTest.swift │ ├── CLIStoreSecretsTest.swift │ ├── CLITests.swift │ ├── DownloadListParserTest.swift │ └── MockedCLIClasses.swift │ ├── EnvironmentMock.swift │ ├── Secrets │ ├── AWSSecretsHandlerSotoTest.swift │ ├── AWSSecretsHandlerTest.swift │ ├── AppleSessionSecretTest.swift │ ├── FileSecretsHandlerTest.swift │ ├── MockedSecretsHandler.swift │ ├── SecretsHandlerTests.swift │ └── SotoTestEnvironment.swift │ ├── TestHelpers.swift │ ├── Utilities │ ├── FileHandlerTests.swift │ ├── MockedUtilitiesClasses.swift │ └── TestHelper.swift │ └── data │ ├── download-error.json │ ├── download-list-20220723.json │ ├── download-list-20231115.json │ └── download-unknown-error.json ├── VERSION ├── iam ├── createRole.sh ├── ec2-policy.json └── ec2-role-trust-policy.json ├── img ├── download.png ├── install.png ├── mfa-01.png ├── mfa-02.png └── xcodeinstall-demo.gif ├── renovate.json └── scripts ├── ProcessCoverage.swift └── deploy ├── RELEASE_DOC.md ├── bootstrap.sh ├── bottle.sh ├── build_binaries.sh ├── build_debug.sh ├── build_fat_binary.sh ├── clean.sh ├── delete_release.sh ├── release_binaries.sh ├── release_sources.sh ├── restoreSession.sh ├── version.sh ├── xcodeinstall.rb └── xcodeinstall.template /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Swift", 3 | "image": "swift:6.1", 4 | "features": { 5 | "ghcr.io/devcontainers/features/common-utils:2": { 6 | "installZsh": "false", 7 | "username": "vscode", 8 | "upgradePackages": "false" 9 | }, 10 | "ghcr.io/devcontainers/features/git:1": { 11 | "version": "os-provided", 12 | "ppa": "false" 13 | } 14 | }, 15 | "runArgs": [ 16 | "--cap-add=SYS_PTRACE", 17 | "--security-opt", 18 | "seccomp=unconfined" 19 | ], 20 | // Configure tool-specific properties. 21 | "customizations": { 22 | // Configure properties specific to VS Code. 23 | "vscode": { 24 | // Set *default* container specific settings.json values on container create. 25 | "settings": { 26 | "lldb.library": "/usr/lib/liblldb.so" 27 | }, 28 | // Add the IDs of extensions you want installed when the container is created. 29 | "extensions": [ 30 | "sswg.swift-lang" 31 | ] 32 | } 33 | }, 34 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 35 | // "forwardPorts": [], 36 | 37 | // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 38 | "remoteUser": "vscode" 39 | } 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | Do not forget to 17 | 1. type the exact command you typed, including all inputs and output 18 | 2. run the command with `--verbose` to collect debugging output 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. macOS 12.5] 28 | - xcodeinstall version [e.g. 0.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/action-runner.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | actions.runner.sebsto-xcodeinstall.xcodeinstall 7 | ProgramArguments 8 | 9 | /Users/ec2-user/actions-runner-xcodeinstall/runsvc.sh 10 | 11 | UserName 12 | ec2-user 13 | WorkingDirectory 14 | /Users/ec2-user/actions-runner-xcodeinstall 15 | RunAtLoad 16 | 17 | StandardOutPath 18 | /Users/ec2-user/Library/Logs/actions.runner.sebsto-xcodeinstall.xcodeinstall/stdout.log 19 | StandardErrorPath 20 | /Users/ec2-user/Library/Logs/actions.runner.sebsto-xcodeinstall.xcodeinstall/stderr.log 21 | EnvironmentVariables 22 | 23 | ACTIONS_RUNNER_SVC 24 | 1 25 | 26 | 30 | SessionCreate 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Build And Test on EC2 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: self-hosted 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Build 17 | run: swift build 18 | 19 | test: 20 | runs-on: self-hosted 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - name: Run tests 25 | run: swift test -------------------------------------------------------------------------------- /.github/workflows/build_and_test_gh_hosted.yml: -------------------------------------------------------------------------------- 1 | name: Build And Test on GitHub 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # env: 10 | # DEVELOPER_DIR: /Applications/Xcode_15.1.app/Contents/Developer 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | container: swift:6.1-noble 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Build 22 | run: swift build 23 | 24 | # disabled until resolved 25 | # https://github.com/sebsto/xcodeinstall/issues/51 26 | test: 27 | if: true 28 | runs-on: ubuntu-latest 29 | container: swift:6.1-noble 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Run tests 34 | run: swift test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Project specific files 4 | secrets/* 5 | .vscode 6 | video/* 7 | test.log 8 | dist 9 | Tests/Data/curl.txt 10 | scripts/xcodeinstall.rb 11 | BOTTLE_BLOCK 12 | Package.resolved 13 | 14 | # Xcode 15 | # 16 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 17 | 18 | ## User settings 19 | xcuserdata/ 20 | 21 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 22 | *.xcscmblueprint 23 | *.xccheckout 24 | 25 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 26 | build/ 27 | DerivedData/ 28 | *.moved-aside 29 | *.pbxuser 30 | !default.pbxuser 31 | *.mode1v3 32 | !default.mode1v3 33 | *.mode2v3 34 | !default.mode2v3 35 | *.perspectivev3 36 | !default.perspectivev3 37 | 38 | ## Obj-C/Swift specific 39 | *.hmap 40 | 41 | ## App packaging 42 | *.ipa 43 | *.dSYM.zip 44 | *.dSYM 45 | 46 | ## Playgrounds 47 | timeline.xctimeline 48 | playground.xcworkspace 49 | *.playground 50 | 51 | # Swift Package Manager 52 | # 53 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 54 | Packages/ 55 | Package.pins 56 | # Package.resolved 57 | #*.xcodeproj 58 | # 59 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 60 | # hence it is not needed unless you have added a package configuration file to your project 61 | .swiftpm 62 | 63 | .build/ 64 | .index-build 65 | 66 | # CocoaPods 67 | # 68 | # We recommend against adding the Pods directory to your .gitignore. However 69 | # you should judge for yourself, the pros and cons are mentioned at: 70 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 71 | # 72 | # Pods/ 73 | # 74 | # Add this line if you want to avoid checking in source code from the Xcode workspace 75 | # *.xcworkspace 76 | 77 | # Carthage 78 | # 79 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 80 | # Carthage/Checkouts 81 | 82 | Carthage/Build/ 83 | 84 | # Accio dependency management 85 | Dependencies/ 86 | .accio/ 87 | 88 | # fastlane 89 | # 90 | # It is recommended to not store the screenshots in the git repo. 91 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 92 | # For more information about the recommended setup visit: 93 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 94 | 95 | fastlane/report.xml 96 | fastlane/Preview.html 97 | fastlane/screenshots/**/*.png 98 | fastlane/test_output 99 | 100 | # Code Injection 101 | # 102 | # After new code Injection tools there's a generated folder /iOSInjectionProject 103 | # https://github.com/johnno1962/injectionforxcode 104 | 105 | iOSInjectionProject/ 106 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version" : 1, 3 | "indentation" : { 4 | "spaces" : 4 5 | }, 6 | "tabWidth" : 4, 7 | "fileScopedDeclarationPrivacy" : { 8 | "accessLevel" : "private" 9 | }, 10 | "spacesAroundRangeFormationOperators" : false, 11 | "indentConditionalCompilationBlocks" : false, 12 | "indentSwitchCaseLabels" : false, 13 | "lineBreakAroundMultilineExpressionChainComponents" : false, 14 | "lineBreakBeforeControlFlowKeywords" : false, 15 | "lineBreakBeforeEachArgument" : true, 16 | "lineBreakBeforeEachGenericRequirement" : true, 17 | "lineLength" : 120, 18 | "maximumBlankLines" : 1, 19 | "respectsExistingLineBreaks" : true, 20 | "prioritizeKeepingFunctionOutputTogether" : true, 21 | "rules" : { 22 | "AllPublicDeclarationsHaveDocumentation" : false, 23 | "AlwaysUseLiteralForEmptyCollectionInit" : false, 24 | "AlwaysUseLowerCamelCase" : false, 25 | "AmbiguousTrailingClosureOverload" : true, 26 | "BeginDocumentationCommentWithOneLineSummary" : false, 27 | "DoNotUseSemicolons" : true, 28 | "DontRepeatTypeInStaticProperties" : true, 29 | "FileScopedDeclarationPrivacy" : true, 30 | "FullyIndirectEnum" : true, 31 | "GroupNumericLiterals" : true, 32 | "IdentifiersMustBeASCII" : true, 33 | "NeverForceUnwrap" : false, 34 | "NeverUseForceTry" : false, 35 | "NeverUseImplicitlyUnwrappedOptionals" : false, 36 | "NoAccessLevelOnExtensionDeclaration" : true, 37 | "NoAssignmentInExpressions" : true, 38 | "NoBlockComments" : true, 39 | "NoCasesWithOnlyFallthrough" : true, 40 | "NoEmptyTrailingClosureParentheses" : true, 41 | "NoLabelsInCasePatterns" : true, 42 | "NoLeadingUnderscores" : false, 43 | "NoParensAroundConditions" : true, 44 | "NoVoidReturnOnFunctionSignature" : true, 45 | "OmitExplicitReturns" : true, 46 | "OneCasePerLine" : true, 47 | "OneVariableDeclarationPerLine" : true, 48 | "OnlyOneTrailingClosureArgument" : true, 49 | "OrderedImports" : true, 50 | "ReplaceForEachWithForLoop" : true, 51 | "ReturnVoidInsteadOfEmptyTuple" : true, 52 | "UseEarlyExits" : false, 53 | "UseExplicitNilCheckInConditions" : false, 54 | "UseLetInEveryBoundCaseVariable" : false, 55 | "UseShorthandTypeNames" : true, 56 | "UseSingleLinePropertyGetter" : false, 57 | "UseSynthesizedInitializer" : false, 58 | "UseTripleSlashForDocumentationComments" : true, 59 | "UseWhereClausesInForLoops" : false, 60 | "ValidateDocumentationComments" : false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | 2 | included: # paths to include during linting. `--path` is ignored if present. 3 | - Sources 4 | excluded: # paths to ignore during linting. Takes precedence over `included`. 5 | - Carthage 6 | - Pods 7 | - Source/ExcludedFolder 8 | - Source/ExcludedFile.swift 9 | - Source/*/ExcludedFile.swift # Exclude files with a wildcard 10 | analyzer_rules: # Rules run by `swiftlint analyze` 11 | - explicit_self 12 | 13 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | ## version 0.3 2 | 3 | - Build scripts now produce a fat binary (multiple commits) 4 | - Build scripts produce brew bottles (binaries) (multiple commits) 5 | - Build scripts update brew formula (multiple commits) 6 | - bump version to v0.3 7 | - add initial version for build and release shell scripts ([#e84f9f6](https://github.com/sebsto/xcodeinstall/commit/e84f9f6a3ccd48e3850b925d2431aabf38030ad7)) 8 | - add AWS Secrets Manager support to `signout` command ([#6d6380f](https://github.com/sebsto/xcodeinstall/commit/6d6380f46c570bc1a4ff8950f972edb13c371f05)) 9 | 10 | ## version 0.1 11 | 12 | Initial Release -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | build: 3 | swift build 4 | test: 5 | swift test 6 | clean: 7 | rm -rf .build 8 | 9 | all: clean format build test 10 | 11 | format: 12 | swift format --recursive -i Sources/* 13 | swift format --recursive -i Tests/* 14 | 15 | test-coverage: 16 | swift test --enable-code-coverage 17 | ./scripts/ProcessCoverage.swift \ 18 | `swift test --show-codecov-path` \ 19 | Tests/coverage.json \ 20 | Tests/coverage.html \ 21 | Tests/coverage.svg 22 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | xcodeinstall, a command line to install Xcode from headless mac (no GUI) 2 | Copyright 2022 Sébastien Stormacq. All Rights Reserved. 3 | 4 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "xcodeinstall", 8 | platforms: [ 9 | .macOS(.v15) 10 | ], 11 | products: [ 12 | .executable(name: "xcodeinstall", targets: ["xcodeinstall"]) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.1"), 16 | .package(url: "https://github.com/soto-project/soto.git", from: "6.8.0"), 17 | .package(url: "https://github.com/sebsto/CLIlib/", branch: "main"), 18 | .package(url: "https://github.com/adam-fowler/swift-srp", from: "2.1.0"), 19 | .package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "main"), 20 | .package(url: "https://github.com/apple/swift-crypto", from: "3.12.3"), 21 | .package(url: "https://github.com/apple/swift-system", from: "1.5.0"), 22 | //.package(path: "../CLIlib") 23 | ], 24 | 25 | targets: [ 26 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 27 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 28 | .executableTarget( 29 | name: "xcodeinstall", 30 | dependencies: [ 31 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 32 | .product(name: "SotoSecretsManager", package: "soto"), 33 | .product(name: "SRP", package: "swift-srp"), 34 | .product(name: "CLIlib", package: "CLIlib"), 35 | .product(name: "_CryptoExtras", package: "swift-crypto"), 36 | .product(name: "Subprocess", package: "swift-subprocess"), 37 | .product(name: "SystemPackage", package: "swift-system"), 38 | ] 39 | ), 40 | .testTarget( 41 | name: "xcodeinstallTests", 42 | dependencies: ["xcodeinstall"], 43 | // https://stackoverflow.com/questions/47177036/use-resources-in-unit-tests-with-swift-package-manager 44 | resources: [.process("data/download-list-20220723.json"), 45 | .process("data/download-list-20231115.json"), 46 | .process("data/download-error.json"), 47 | .process("data/download-unknown-error.json")] //, 48 | // swiftSettings: [ 49 | // .define("SWIFTPM_COMPILATION") 50 | // ] 51 | ) 52 | ], 53 | // swiftLanguageModes: [.v5] 54 | ) 55 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/Authentication+Hashcash.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 30/10/2024. 6 | // 7 | 8 | import CLIlib 9 | import Crypto 10 | import Foundation 11 | 12 | extension AppleAuthenticator { 13 | func checkHashcash() async throws -> String { 14 | 15 | guard let serviceKey = session.itcServiceKey?.authServiceKey else { 16 | throw AuthenticationError.unableToRetrieveAppleHashcash(nil) 17 | } 18 | 19 | if session.hashcash == nil { 20 | var hashcash: String 21 | 22 | log.debug("Requesting data to compute a hashcash") 23 | 24 | do { 25 | hashcash = try await getAppleHashcash(itServiceKey: serviceKey) 26 | } catch { 27 | throw AuthenticationError.unableToRetrieveAppleHashcash(error) 28 | } 29 | session.hashcash = hashcash 30 | log.debug("Got an Apple hashcash : \(hashcash)") 31 | } 32 | 33 | // hashcash is never nil at this stage 34 | return session.hashcash! 35 | } 36 | 37 | internal func getAppleHashcash(itServiceKey: String, date: String? = nil) async throws -> String { 38 | 39 | /* 40 | ➜ ~ curl https://idmsa.apple.com/appleauth/auth/signin?widgetKey=e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42 41 | 42 | ... 43 | 44 | < X-Apple-HC-Bits: 10 45 | < X-Apple-HC-Challenge: 0daf59bcaf9d721c0375756c5e404652 46 | 47 | .... 48 | */ 49 | 50 | let url = 51 | "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=\(itServiceKey)" 52 | let (_, response) = try await apiCall( 53 | url: url, 54 | validResponse: .value(200) 55 | ) 56 | 57 | guard let hcString = response.allHeaderFields["X-Apple-HC-Bits"] as? String, 58 | let hcBits = Int(hcString), 59 | let hcChallenge = response.allHeaderFields["X-Apple-HC-Challenge"] as? String 60 | else { 61 | throw AuthenticationError.missingHTTPHeaders( 62 | "Unable to find 'X-Apple-HC-Bits' or 'X-Apple-HC-Challenge' to compute hashcash\n\(response.allHeaderFields)" 63 | ) 64 | } 65 | 66 | log.debug("Computing hashcash") 67 | 68 | if date == nil { 69 | return Hashcash.make(bits: hcBits, challenge: hcChallenge) 70 | } else { 71 | // just used for unit tests 72 | return Hashcash.make(bits: hcBits, challenge: hcChallenge, date: date) 73 | } 74 | } 75 | } 76 | 77 | /* 78 | # This App Store Connect hashcash spec was generously donated by... 79 | # 80 | # __ _ 81 | # __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___ 82 | # / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __| 83 | # | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \ 84 | # \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/ 85 | # |_| |_| |___/ 86 | # 87 | # 88 | # 89 | # 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 90 | # X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 91 | # ^ ^ ^ ^ ^ 92 | # | | | | +-- Counter 93 | # | | | +-- Resource 94 | # | | +-- Date YYMMDD[hhmm[ss]] 95 | # | +-- Bits (number of leading zeros) 96 | # +-- Version 97 | # 98 | # We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention. 99 | # 1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all. 100 | # 2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation. 101 | # 102 | # Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits 103 | # 104 | # 105 | # We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits 106 | # 107 | # 108 | */ 109 | 110 | struct Hashcash { 111 | static func make(bits: Int, challenge: String, date d: String? = nil) -> String { 112 | let version = 1 113 | 114 | let date: String 115 | if d != nil { 116 | // we received a date, use it (for testing) 117 | date = d! 118 | } else { 119 | let df = DateFormatter() 120 | df.dateFormat = "yyyyMMddHHmmss" 121 | date = df.string(from: Date()) 122 | } 123 | 124 | var counter = 0 125 | 126 | while true { 127 | let hc = [ 128 | String(version), 129 | String(bits), 130 | date, 131 | challenge, 132 | ":\(counter)", 133 | ].joined(separator: ":") 134 | 135 | if let data = hc.data(using: .utf8) { 136 | let hash = Insecure.SHA1.hash(data: data) 137 | let hashBits = hash.map { String($0, radix: 2).padding(toLength: 8, withPad: "0") }.joined() 138 | 139 | if hashBits.prefix(bits).allSatisfy({ $0 == "0" }) { 140 | return hc 141 | } 142 | } 143 | 144 | counter += 1 145 | } 146 | } 147 | } 148 | 149 | extension String { 150 | func padding(toLength length: Int, withPad character: Character) -> String { 151 | let paddingCount = length - self.count 152 | guard paddingCount > 0 else { return self } 153 | return String(repeating: character, count: paddingCount) + self 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/Authentication+UsernamePassword.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 30/10/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | extension AppleAuthenticator { 11 | func startUserPasswordAuthentication(username: String, password: String) async throws { 12 | 13 | let _ = try await self.checkHashcash() 14 | 15 | let (_, response) = 16 | try await apiCall( 17 | url: "https://idmsa.apple.com/appleauth/auth/signin", 18 | method: .POST, 19 | body: try JSONEncoder().encode(User(accountName: username, password: password)), 20 | validResponse: .range(0..<506) 21 | ) 22 | 23 | // store the response to keep cookies and HTTP headers 24 | session.xAppleIdSessionId = response.value(forHTTPHeaderField: "X-Apple-ID-Session-Id") 25 | session.scnt = response.value(forHTTPHeaderField: "scnt") 26 | 27 | // should I save other headers ? 28 | // X-Apple-Auth-Attributes 29 | 30 | try await handleResponse(response) 31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/DispatchSemaphore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DispatchSemaphore.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 21/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | // abstract protocol for testing 11 | @MainActor 12 | protocol DispatchSemaphoreProtocol: Sendable { 13 | func wait() 14 | func signal() -> Int 15 | } 16 | extension DispatchSemaphore: DispatchSemaphoreProtocol {} 17 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/Download.swift: -------------------------------------------------------------------------------- 1 | // 2 | // List.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 19/07/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | protocol AppleDownloaderProtocol: Sendable { 12 | func list(force: Bool) async throws -> DownloadList 13 | func download(file: DownloadList.File) async throws -> URLSessionDownloadTaskProtocol? 14 | } 15 | 16 | @MainActor 17 | class AppleDownloader: HTTPClient, AppleDownloaderProtocol { 18 | 19 | // control the progress of the download 20 | // not private for testability 21 | var downloadTask: URLSessionDownloadTaskProtocol? 22 | 23 | func download(file: DownloadList.File) async throws -> URLSessionDownloadTaskProtocol? { 24 | 25 | guard !file.remotePath.isEmpty, 26 | !file.filename.isEmpty, 27 | file.fileSize > 0 28 | else { 29 | log.error("🛑 Invalid file specification : \(file)") 30 | throw DownloadError.invalidFileSpec 31 | } 32 | 33 | let fileURL = "https://developer.apple.com/services-account/download?path=\(file.remotePath)" 34 | 35 | let fh = self.env.fileHandler 36 | //filehandler is not MainActor-isolated so we need to use Task { } to get the file path 37 | let filePath = await Task { 38 | await URL( 39 | fileURLWithPath: fh.downloadFilePath(file: file) 40 | ) 41 | }.value 42 | let urlSessionDownload = self.env.urlSessionDownload( 43 | dstFilePath: filePath, 44 | totalFileSize: file.fileSize, 45 | startTime: Date.now 46 | ) 47 | guard let downloadDelegate = urlSessionDownload.downloadDelegate() else { 48 | fatalError("This method requires an injected download delegate") 49 | } 50 | 51 | // make a call to start the download 52 | // first call, should send a redirect and an auth cookie 53 | self.downloadTask = try await downloadCall(url: fileURL, requestHeaders: ["Accept": "*/*"]) 54 | if let dlt = self.downloadTask { 55 | dlt.resume() 56 | downloadDelegate.sema.wait() 57 | } 58 | 59 | // returns when the download is completed 60 | return self.downloadTask 61 | 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/DownloadDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadDelegate.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 17/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | #if canImport(FoundationNetworking) 12 | import FoundationNetworking 13 | #endif 14 | 15 | // delegate class to receive download progress 16 | @MainActor 17 | final class DownloadDelegate: NSObject, URLSessionDownloadDelegate { 18 | 19 | var env: Environment 20 | let dstFilePath: URL? 21 | let totalFileSize: Int? 22 | let startTime: Date? 23 | 24 | // to notify the main thread when download is finish 25 | let sema: DispatchSemaphoreProtocol 26 | 27 | init( 28 | env: Environment, 29 | dstFilePath: URL? = nil, 30 | totalFileSize: Int? = nil, 31 | startTime: Date? = nil, 32 | semaphore: DispatchSemaphoreProtocol 33 | ) { 34 | self.env = env 35 | self.dstFilePath = dstFilePath 36 | self.totalFileSize = totalFileSize 37 | self.startTime = startTime 38 | self.sema = semaphore 39 | } 40 | 41 | nonisolated func urlSession( 42 | _ session: URLSession, 43 | downloadTask: URLSessionDownloadTask, 44 | didFinishDownloadingTo location: URL 45 | ) { 46 | Task { 47 | await completeTransfer(from: location) 48 | } 49 | } 50 | 51 | func completeTransfer(from location: URL) async { 52 | // tell the progress bar that we're done 53 | self.env.progressBar.complete(success: true) 54 | 55 | guard let dst = dstFilePath else { 56 | log.warning("⚠️ No destination specified. I am keeping the file at \(location)") 57 | return 58 | } 59 | 60 | log.debug("Finished at \(location)\nMoving to \(dst)") 61 | 62 | // ignore the error here ? It is logged one level down. How to bring it up to the user ? 63 | // file handler is not isolated to MainActor, need to use Task 64 | let fh = env.fileHandler 65 | let _ = await Task { try? await fh.move(from: location, to: dst) }.value 66 | 67 | // tell the main thread that we're done 68 | _ = self.sema.signal() 69 | } 70 | 71 | nonisolated func urlSession( 72 | _ session: URLSession, 73 | downloadTask: URLSessionDownloadTask, 74 | didWriteData bytesWritten: Int64, 75 | totalBytesWritten: Int64, 76 | totalBytesExpectedToWrite: Int64 77 | ) { 78 | Task { 79 | await updateTransfer(totalBytesWritten: totalBytesWritten) 80 | } 81 | } 82 | 83 | func updateTransfer(totalBytesWritten: Int64) async { 84 | guard let tfs = totalFileSize else { 85 | fatalError("Developer forgot to share the total file size") 86 | } 87 | 88 | var text = "\(totalBytesWritten/1024/1024) MB" 89 | 90 | // when a start time is specified, compute the bandwidth 91 | if let start = self.startTime { 92 | 93 | let dif: TimeInterval = 0 - start.timeIntervalSinceNow 94 | let bandwidth = Double(totalBytesWritten) / Double(dif) / 1024 / 1024 95 | 96 | text += String(format: " / %.2f MBs", bandwidth) 97 | } 98 | env.progressBar.update( 99 | step: Int(totalBytesWritten / 1024), 100 | total: Int(tfs / 1024), 101 | text: text 102 | ) 103 | 104 | } 105 | 106 | func urlSession( 107 | _ session: URLSession, 108 | task: URLSessionTask, 109 | willPerformHTTPRedirection response: HTTPURLResponse, 110 | newRequest request: URLRequest 111 | ) async -> URLRequest? { 112 | request 113 | } 114 | 115 | nonisolated func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { 116 | Task { 117 | log.warning("error \(String(describing: error))") 118 | _ = await self.sema.signal() 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/Install.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Install.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 22/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | protocol InstallerProtocol { 12 | func install(file: URL) async throws 13 | func installCommandLineTools(atPath file: URL) async throws 14 | func installPkg(atURL pkg: URL) async throws -> ShellOutput 15 | func installXcode(at src: URL) async throws 16 | func uncompressXIP(atURL file: URL) async throws -> ShellOutput 17 | func moveApp(at src: URL) async throws -> String 18 | func fileMatch(file: URL) async -> Bool 19 | } 20 | 21 | enum InstallerError: Error { 22 | case unsupportedInstallation 23 | case fileDoesNotExistOrIncorrect 24 | case xCodeXIPInstallationError 25 | case xCodeMoveInstallationError 26 | case xCodePKGInstallationError 27 | case CLToolsInstallationError 28 | } 29 | 30 | @MainActor 31 | class ShellInstaller: InstallerProtocol { 32 | 33 | let env: Environment 34 | public init(env: inout Environment) { 35 | self.env = env 36 | } 37 | 38 | // the shell commands we need to install XCode and its command line tools 39 | let XIPCOMMAND = "/usr/bin/xip" 40 | let HDIUTILCOMMAND = "/usr/bin/hdiutil" 41 | let INSTALLERCOMMAND = "/usr/sbin/installer" 42 | 43 | // the pkg provided by Xcode to install 44 | let PKGTOINSTALL = [ 45 | "XcodeSystemResources.pkg", 46 | "CoreTypes.pkg", 47 | "MobileDevice.pkg", 48 | "MobileDeviceDevelopment.pkg", 49 | ] 50 | 51 | /// Install Xcode or Xcode Command Line Tools 52 | /// At this stage, we do support only these two installation. 53 | /// 54 | /// **Xcode** is provided as a XIP file. The installation procedure is as follow: 55 | /// - It is uncompressed 56 | /// - It is moved to /Applications 57 | /// - Four packages are installed 58 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/XcodeSystemResources.pkg` 59 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/CoreTypes.pkg` 60 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/MobileDevice.pkg` 61 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/MobileDeviceDevelopment.pkg` 62 | /// 63 | /// **Command_Line_Tools_for_Xcode** is provided as a DMG file. The installation procedure is as follow: 64 | /// - the DMG file is mounted 65 | /// - Package `/Volumes/Command\ Line\ Developer\ Tools/Command\ Line\ Tools.pkg` is installed. 66 | func install(file: URL) async throws { 67 | 68 | // verify this is one the files we do support 69 | let installationType = SupportedInstallation.supported(file.lastPathComponent) 70 | guard installationType != .unsuported else { 71 | log.debug("Unsupported installation type") 72 | throw InstallerError.unsupportedInstallation 73 | } 74 | 75 | // find matching File in DownloadList (if there is one) 76 | // and compare existing filesize vs expected filesize 77 | guard fileMatch(file: file) else { 78 | log.debug("File does not exist or has incorrect size") 79 | throw InstallerError.fileDoesNotExistOrIncorrect 80 | } 81 | 82 | // Dispatch installation between DMG and XIP 83 | switch installationType { 84 | case .xCode: 85 | try await self.installXcode(at: file) 86 | case .xCodeCommandLineTools: 87 | try await self.installCommandLineTools(atPath: file) 88 | case .unsuported: 89 | throw InstallerError.unsupportedInstallation 90 | } 91 | } 92 | 93 | // swiftlint:disable line_length 94 | /// 95 | /// Verifies if file exists on disk. Also check if file exists in cached download list, 96 | /// in that case, it verifies the actuali file size is the same as the one from the cached list 97 | /// 98 | /// - Parameters 99 | /// - file : the full path of the file to test 100 | /// - Returns 101 | /// - true when file exists and, when download list cache exists too, if file size matches the one mentioned in the cached download list 102 | /// 103 | // swiftlint:enable line_length 104 | func fileMatch(file: URL) -> Bool { 105 | 106 | // File exist on disk ? 107 | // no => return FALSE 108 | // yes - do an additional check 109 | // if there is a download list cache AND file is present in list AND size DOES NOT match => False 110 | // all other cases return true (we can try to install even if their is no cached download list) 111 | 112 | var match = self.env.fileHandler.fileExists(file: file, fileSize: 0) 113 | 114 | if !match { 115 | return false 116 | } 117 | 118 | // find file in downloadlist (if the cached download list exists) 119 | if let dll = try? self.env.fileHandler.loadDownloadList() { 120 | if let dlFile = dll.find(fileName: file.lastPathComponent) { 121 | // compare download list cached sized with actual size 122 | match = self.env.fileHandler.fileExists(file: file, fileSize: dlFile.fileSize) 123 | } 124 | } 125 | return match 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallCLTools.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallCLTools.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 29/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | import Subprocess 11 | 12 | // MARK: Command Line Tools 13 | // Command Line Tools installation functions 14 | extension ShellInstaller { 15 | 16 | func installCommandLineTools(atPath file: URL) async throws { 17 | 18 | let filePath = file.path 19 | 20 | // check if file exists 21 | guard self.env.fileHandler.fileExists(file: file, fileSize: 0) else { 22 | log.error("Command line disk image does not exist : \(filePath)") 23 | throw InstallerError.fileDoesNotExistOrIncorrect 24 | } 25 | 26 | // mount, install, unmount 27 | let totalSteps = 3 28 | var currentStep: Int = 0 29 | 30 | var result: ShellOutput 31 | 32 | // first mount the disk image 33 | log.debug("Mounting disk image \(file.lastPathComponent)") 34 | currentStep += 1 35 | self.env.progressBar.update(step: currentStep, total: totalSteps, text: "Mounting disk image...") 36 | result = try await self.mountDMG(atURL: file) 37 | if !result.terminationStatus.isSuccess { 38 | log.error("Can not mount disk image : \(filePath)\n\(String(describing: result))") 39 | throw InstallerError.CLToolsInstallationError 40 | } 41 | 42 | // second install the package 43 | // find the name of the package ? 44 | let pkg = URL(fileURLWithPath: "/Volumes/Command Line Developer Tools/Command Line Tools.pkg") 45 | let pkgPath = pkg.path 46 | log.debug("Installing pkg \(pkgPath)") 47 | currentStep += 1 48 | self.env.progressBar.update(step: currentStep, total: totalSteps, text: "Installing package...") 49 | result = try await self.installPkg(atURL: pkg) 50 | if !result.terminationStatus.isSuccess { 51 | log.error("Can not install package : \(pkgPath)\n\(String(describing: result))") 52 | throw InstallerError.CLToolsInstallationError 53 | } 54 | 55 | // third unmount the disk image 56 | let mountedDiskImage = URL(fileURLWithPath: "/Volumes/Command Line Developer Tools") 57 | log.debug("Unmounting volume \(mountedDiskImage)") 58 | currentStep += 1 59 | self.env.progressBar.update(step: currentStep, total: totalSteps, text: "Unmounting volume...") 60 | result = try await self.unmountDMG(volume: mountedDiskImage) 61 | if !result.terminationStatus.isSuccess { 62 | log.error( 63 | "Can not unmount volume : \(mountedDiskImage)\n\(String(describing: result))" 64 | ) 65 | throw InstallerError.CLToolsInstallationError 66 | } 67 | } 68 | 69 | private func mountDMG(atURL dmg: URL) async throws -> ShellOutput { 70 | 71 | let dmgPath = dmg.path 72 | 73 | // check if file exists 74 | guard self.env.fileHandler.fileExists(file: dmg, fileSize: 0) else { 75 | log.error("Disk Image does not exist : \(dmgPath)") 76 | throw InstallerError.fileDoesNotExistOrIncorrect 77 | } 78 | 79 | // hdiutil mount ./xcode-cli.dmg 80 | return try await run(.path(HDIUTILCOMMAND), arguments: ["mount", dmgPath]) 81 | } 82 | 83 | private func unmountDMG(volume: URL) async throws -> ShellOutput { 84 | 85 | // hdiutil unmount /Volumes/Command\ Line\ Developer\ Tools/ 86 | try await self.env.run(.path(HDIUTILCOMMAND), arguments: ["unmount", volume.path]) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallDownloadListExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallDownloadListExtension.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 29/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: Extensions - DownloadList 11 | // not fileprivate to allow testing 12 | extension DownloadList { 13 | 14 | /// Check an entire list for files matching the given filename 15 | /// This generic function avoids repeating code in the two `find(...)` below 16 | /// - Parameters 17 | /// - fileName: the file name to check (without full path) 18 | /// - inList: either a [Download] or a [File] 19 | /// - comparison: a function that receives either a `Download` either a `File` 20 | /// and returns a `File` when there is a file name match, nil otherwise 21 | /// - Returns 22 | /// a File struct if a file matches, nil otherwise 23 | 24 | private func _find( 25 | fileName: String, 26 | inList list: T, 27 | comparison: (T.Element) -> File? 28 | ) -> File? { 29 | 30 | // first returns an array of File? with nil when filename does not match 31 | // or file otherwise. 32 | // for example : [nil, file, nil, nil] 33 | let result: [File?] = list.compactMap { element -> File? in 34 | return comparison(element) 35 | } 36 | // then remove all nil values 37 | // .filter { file in 38 | // return file != nil 39 | // } 40 | 41 | // we should have 0 or 1 element 42 | if result.count > 0 { 43 | assert(result.count == 1) 44 | return result[0] 45 | } else { 46 | return nil 47 | } 48 | 49 | } 50 | 51 | /// check the entire list of downloads for files matching the given filename 52 | /// - Parameters 53 | /// - fileName: the file name to check (without full path) 54 | /// - Returns 55 | /// a File struct if a file matches, nil otherwise 56 | func find(fileName: String) -> File? { 57 | 58 | guard let listOfDownloads = self.downloads else { 59 | return nil 60 | } 61 | 62 | return _find( 63 | fileName: fileName, 64 | inList: listOfDownloads, 65 | comparison: { element in 66 | let download = element as Download 67 | return find(fileName: fileName, inDownload: download) 68 | } 69 | ) 70 | } 71 | 72 | // search the list of files ([File]) for an individual file match 73 | func find(fileName: String, inDownload download: Download) -> File? { 74 | 75 | _find( 76 | fileName: fileName, 77 | inList: download.files, 78 | comparison: { element in 79 | let file = element as File 80 | return file.filename == fileName ? file : nil 81 | } 82 | ) 83 | 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallPkg.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallPkg.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 29/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | import Subprocess 11 | 12 | // MARK: PKG 13 | // generic PKG installation function 14 | extension ShellInstaller { 15 | 16 | func installPkg(atURL pkg: URL) async throws -> ShellOutput { 17 | 18 | let pkgPath = pkg.path 19 | 20 | // check if file exists 21 | guard self.env.fileHandler.fileExists(file: pkg, fileSize: 0) else { 22 | log.error("Package does not exist : \(pkgPath)") 23 | throw InstallerError.fileDoesNotExistOrIncorrect 24 | } 25 | 26 | return try await run(.path(INSTALLERCOMMAND), arguments: ["-pkg", pkgPath, "-target", "/"]) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallSupportedFiles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallSupportedFiles.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 28/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupportedInstallation { 11 | case xCode 12 | case xCodeCommandLineTools 13 | case unsuported 14 | 15 | static func supported(_ file: String) -> SupportedInstallation { 16 | 17 | // generic method to test file type 18 | 19 | struct SupportedFiles { 20 | // the start of the file names we currently support for installtion 21 | static let packages = ["Xcode", "Command Line Tools for Xcode"] 22 | 23 | // the file extensions of the the file names we currently support for installation 24 | static let extensions = ["xip", "dmg"] 25 | 26 | // the return values for this function 27 | static let values: [SupportedInstallation] = [.xCode, .xCodeCommandLineTools] 28 | 29 | static func enumerated() -> EnumeratedSequence<[String]> { 30 | assert(packages.count == extensions.count) 31 | assert(packages.count == values.count) 32 | return packages.enumerated() 33 | } 34 | } 35 | 36 | // first return a [SupportedInstallation] with either unsupported or installation type 37 | let tempResult: [SupportedInstallation] = SupportedFiles.enumerated().compactMap { 38 | (index, filePrefix) in 39 | if file.hasPrefix(filePrefix) && file.hasSuffix(SupportedFiles.extensions[index]) { 40 | return SupportedFiles.values[index] 41 | } else { 42 | return SupportedInstallation.unsuported 43 | } 44 | } 45 | 46 | // then remove all unsupported values 47 | let result: [SupportedInstallation] = tempResult.filter { installationType in 48 | return installationType != .unsuported 49 | } 50 | 51 | // at this stage we should have 0 or 1 value left 52 | assert(result.count == 0 || result.count == 1) 53 | return result.count == 0 ? .unsuported : result[0] 54 | 55 | // non generic method to test the file type 56 | 57 | // if file.hasPrefix("Command Line Tools for Xcode") && file.hasSuffix(".dmg") { 58 | // result = .xCodeCommandLineTools 59 | // } else if file.hasPrefix("Xcode") && file.hasSuffix(".xip") { 60 | // result = .xCode 61 | // } else { 62 | // result = .unsuported 63 | // } 64 | 65 | // return result 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallXcode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallXcode.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 29/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | import Subprocess 11 | 12 | // MARK: XCODE 13 | // XCode installation functions 14 | extension ShellInstaller { 15 | 16 | func installXcode(at src: URL) async throws { 17 | 18 | // unXIP, mv, 4 PKG to install 19 | let totalSteps = 2 + PKGTOINSTALL.count 20 | var currentStep: Int = 0 21 | 22 | var result: ShellOutput 23 | 24 | // first uncompress file 25 | log.debug("Decompressing files") 26 | // run synchronously as there is no output for this operation 27 | currentStep += 1 28 | self.env.progressBar.update( 29 | step: currentStep, 30 | total: totalSteps, 31 | text: "Expanding Xcode xip (this might take a while)" 32 | ) 33 | result = try await self.uncompressXIP(atURL: src) 34 | if !result.terminationStatus.isSuccess { 35 | log.error("Can not unXip file : \(result)") 36 | throw InstallerError.xCodeXIPInstallationError 37 | } 38 | 39 | // second move file to /Applications 40 | log.debug("Moving app to destination") 41 | currentStep += 1 42 | self.env.progressBar.update( 43 | step: currentStep, 44 | total: totalSteps, 45 | text: "Moving Xcode to /Applications" 46 | ) 47 | // find .app file 48 | let appFile = try env.fileHandler.downloadedFiles().filter({ fileName in 49 | return fileName.hasSuffix(".app") 50 | }) 51 | if appFile.count != 1 { 52 | log.error( 53 | "Zero or several app file to install in \(appFile), not sure which one is the correct one" 54 | ) 55 | throw InstallerError.xCodeMoveInstallationError 56 | } 57 | 58 | let installedFile = 59 | try await self.moveApp(at: self.env.fileHandler.downloadDirectory().appendingPathComponent(appFile[0])) 60 | 61 | // /Applications/Xcode.app/Contents/Resources/Packages/ 62 | 63 | // third install packages provided with Xcode app 64 | for pkg in PKGTOINSTALL { 65 | log.debug("Installing package \(pkg)") 66 | currentStep += 1 67 | self.env.progressBar.update( 68 | step: currentStep, 69 | total: totalSteps, 70 | text: "Installing additional packages... \(pkg)" 71 | ) 72 | result = try await self.installPkg( 73 | atURL: URL(fileURLWithPath: "\(installedFile)/Contents/resources/Packages/\(pkg)") 74 | ) 75 | if !result.terminationStatus.isSuccess { 76 | log.error("Can not install pkg at : \(pkg)\n\(result)") 77 | throw InstallerError.xCodePKGInstallationError 78 | } 79 | } 80 | 81 | } 82 | 83 | // expand a XIP file. There is no way to create XIP file. 84 | // This code can not be tested without a valid, signed, Xcode archive 85 | // https://en.wikipedia.org/wiki/.XIP 86 | func uncompressXIP(atURL file: URL) async throws -> ShellOutput { 87 | 88 | let filePath = file.path 89 | 90 | // not necessary, file existence has been checked before 91 | guard self.env.fileHandler.fileExists(file: file, fileSize: 0) else { 92 | log.error("File to unXip does not exist : \(filePath)") 93 | throw InstallerError.fileDoesNotExistOrIncorrect 94 | } 95 | 96 | // synchronously uncompress in the download directory 97 | let result = try await self.env.run( 98 | .name(XIPCOMMAND), 99 | arguments: ["--expand", filePath], 100 | workingDirectory: .init(env.fileHandler.downloadDirectory().path) 101 | ) 102 | 103 | return result 104 | } 105 | 106 | func moveApp(at src: URL) async throws -> String { 107 | 108 | // extract file name 109 | let fileName = src.lastPathComponent 110 | 111 | // create source and destination URL 112 | let appURL = URL(fileURLWithPath: "/Applications/\(fileName)") 113 | 114 | log.debug("Going to move \n \(src) to \n \(appURL)") 115 | // move synchronously 116 | try await self.env.fileHandler.move(from: src, to: appURL) 117 | 118 | return appURL.path 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/List.swift: -------------------------------------------------------------------------------- 1 | // 2 | // List.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 21/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | extension AppleDownloader { 12 | 13 | // load the list of available downloads 14 | // when force is true, dowload from Apple even when there is a cache on disk 15 | // https://developer.apple.com 16 | // POST /services-account/QH65B2/downloadws/listDownloads.action 17 | // 18 | func list(force: Bool) async throws -> DownloadList { 19 | 20 | var downloadList: DownloadList? 21 | 22 | if !force { 23 | // load the list from file if we have it 24 | downloadList = try? self.env.fileHandler.loadDownloadList() 25 | } 26 | 27 | if downloadList == nil { 28 | let url = 29 | "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action" 30 | let (data, response) = try await apiCall( 31 | url: url, 32 | method: .POST, 33 | validResponse: .range(200..<400) 34 | ) 35 | 36 | guard response.statusCode == 200 else { 37 | log.error("🛑 Download List response is not 200, something is incorrect") 38 | log.debug("URLResponse = \(response)") 39 | throw DownloadError.invalidResponse 40 | } 41 | 42 | do { 43 | downloadList = try JSONDecoder().decode(DownloadList.self, from: data) 44 | } catch { 45 | throw DownloadError.parsingError(error: error) 46 | } 47 | 48 | if downloadList!.resultCode == 0 { 49 | 50 | // grab authentication cookie for later download 51 | if let cookies = response.value(forHTTPHeaderField: "Set-Cookie") { 52 | // save the new cookies we received (ADCDownloadAuth) 53 | _ = try await self.env.secrets!.saveCookies(cookies) 54 | } else { 55 | // swiftlint:disable line_length 56 | log.error( 57 | "🛑 Download List response does not contain authentication cookie, something is incorrect" 58 | ) 59 | log.debug("URLResponse = \(response)") 60 | throw DownloadError.invalidResponse 61 | } 62 | 63 | // success, save the list for reuse 64 | _ = try self.env.fileHandler.saveDownloadList(list: downloadList!) 65 | 66 | } else { 67 | 68 | switch downloadList!.resultCode { 69 | case 1100: // authentication expired 70 | throw DownloadError.authenticationRequired 71 | case 2100: // needs to accept ToC 72 | throw DownloadError.needToAcceptTermsAndCondition 73 | case 2170: // accounts need upgrade 74 | log.error( 75 | "Error \(downloadList!.resultCode) : \(downloadList!.userString ?? "no user string")" 76 | ) 77 | throw DownloadError.accountneedUpgrade( 78 | errorCode: downloadList!.resultCode, 79 | errorMessage: downloadList!.userString ?? "Your developer account needs to be updated" 80 | ) 81 | default: 82 | // is there other error cases that I need to handle explicitly ? 83 | throw DownloadError.unknownError( 84 | errorCode: downloadList!.resultCode, 85 | errorMessage: downloadList!.userString ?? "Unknwon error" 86 | ) 87 | } 88 | } 89 | } 90 | 91 | guard let dList = downloadList else { 92 | throw DownloadError.noDownloadsInDownloadList 93 | } 94 | return dList 95 | 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/URLLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLLogger.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 18/07/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | import Logging 11 | 12 | #if canImport(FoundationNetworking) 13 | import FoundationNetworking 14 | #endif 15 | 16 | // Using Swift's modern regex syntax 17 | func filterPassword(_ input: String) -> String { 18 | let regex = /("password":").*?("[\,\}])/ 19 | return input.replacing(regex) { match in 20 | "\(match.1)*****\(match.2)" 21 | } 22 | } 23 | 24 | func log(request: URLRequest, to logger: Logger) { 25 | 26 | log.debug("\n - - - - - - - - - - OUTGOING - - - - - - - - - - \n") 27 | defer { log.debug("\n - - - - - - - - - - END - - - - - - - - - - \n") } 28 | let urlAsString = request.url?.absoluteString ?? "" 29 | let urlComponents = URLComponents(string: urlAsString) 30 | let method = request.httpMethod != nil ? "\(request.httpMethod ?? "")" : "" 31 | let path = "\(urlComponents?.path ?? "")" 32 | let query = "\(urlComponents?.query ?? "")" 33 | let host = "\(urlComponents?.host ?? "")" 34 | var output = """ 35 | \(urlAsString) \n\n 36 | \(method) \(path)?\(query) HTTP/1.1 \n 37 | HOST: \(host)\n 38 | """ 39 | 40 | for (key, value) in request.allHTTPHeaderFields ?? [:] { 41 | output += "\(key): \(value)\n" 42 | 43 | } 44 | 45 | if let body = request.httpBody { 46 | output += "\n \(String(data: body, encoding: .utf8) ?? "")" 47 | } 48 | logger.debug("\(filterPassword(output))") 49 | } 50 | 51 | func log(response: HTTPURLResponse?, data: Data?, error: Error?, to logger: Logger) { 52 | 53 | logger.debug("\n - - - - - - - - - - INCOMMING - - - - - - - - - - \n") 54 | defer { logger.debug("\n - - - - - - - - - - END - - - - - - - - - - \n") } 55 | let urlString = response?.url?.absoluteString 56 | let components = NSURLComponents(string: urlString ?? "") 57 | let path = "\(components?.path ?? "")" 58 | let query = "\(components?.query ?? "")" 59 | var output = "" 60 | if let urlString { 61 | output += "\(urlString)" 62 | output += "\n\n" 63 | } 64 | if let statusCode = response?.statusCode { 65 | output += "HTTP \(statusCode) \(path)?\(query)\n" 66 | } 67 | if let host = components?.host { 68 | output += "Host: \(host)\n" 69 | } 70 | for (key, value) in response?.allHeaderFields ?? [:] { 71 | output += "\(key): \(value)\n" 72 | } 73 | if let data { 74 | output += "\n\(String(data: data, encoding: .utf8) ?? "")\n" 75 | } 76 | if error != nil { 77 | output += "\nError: \(error!.localizedDescription)\n" 78 | } 79 | logger.debug("\(output)") 80 | } 81 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/URLRequestExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtensionURLRequest.swift 3 | // 4 | // Created by Abhishek Maurya on 16/07/20. 5 | // Copyright © 2020. All rights reserved. 6 | // 7 | import Foundation 8 | 9 | #if canImport(FoundationNetworking) 10 | import FoundationNetworking 11 | #endif 12 | 13 | extension URLRequest { 14 | public func cURL(pretty: Bool = false) -> String { 15 | let newLine = pretty ? "\\\n" : "" 16 | let method = (pretty ? "--request " : "-X ") + "\(self.httpMethod ?? "GET") \(newLine)" 17 | let url: String = (pretty ? "--url " : "") + "\'\(self.url?.absoluteString ?? "")\' \(newLine)" 18 | 19 | var cURL = (pretty ? "curl -v --disable " : "curl -q ") 20 | var header = "" 21 | var data: String = "" 22 | 23 | if let httpHeaders = self.allHTTPHeaderFields, httpHeaders.keys.count > 0 { 24 | for (key, value) in httpHeaders { 25 | header += (pretty ? "--header " : "-H ") + "\'\(key): \(value)\' \(newLine)" 26 | } 27 | } 28 | 29 | if let bodyData = self.httpBody, let bodyString = String(data: bodyData, encoding: .utf8), 30 | !bodyString.isEmpty 31 | { 32 | data = "--data '\(bodyString)'" 33 | } 34 | 35 | cURL += method + url + header + data 36 | 37 | return cURL 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIAuthenticate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIAuthenticate.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 23/07/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import CLIlib 10 | import Foundation 11 | import Logging 12 | 13 | extension MainCommand { 14 | 15 | struct Authenticate: AsyncParsableCommand { 16 | nonisolated static let configuration = 17 | CommandConfiguration(abstract: "Authenticate yourself against Apple Developer Portal") 18 | 19 | @OptionGroup var globalOptions: GlobalOptions 20 | @OptionGroup var cloudOption: CloudOptions 21 | 22 | @Option(name: .long, help: "Use SRP authentication") 23 | var srp = true 24 | 25 | func run() async throws { 26 | try await run(with: RuntimeEnvironment(region: cloudOption.secretManagerRegion)) 27 | } 28 | 29 | func run(with env: Environment) async throws { 30 | 31 | let xci = try await MainCommand.XCodeInstaller( 32 | with: env, 33 | for: cloudOption.secretManagerRegion, 34 | verbose: globalOptions.verbose 35 | ) 36 | 37 | try await xci.authenticate(with: AuthenticationMethod.withSRP(srp)) 38 | } 39 | } 40 | 41 | struct Signout: AsyncParsableCommand { 42 | nonisolated static let configuration = CommandConfiguration(abstract: "Signout from Apple Developer Portal") 43 | 44 | @OptionGroup var globalOptions: GlobalOptions 45 | @OptionGroup var cloudOption: CloudOptions 46 | 47 | func run() async throws { 48 | try await run(with: RuntimeEnvironment(region: cloudOption.secretManagerRegion)) 49 | } 50 | 51 | func run(with env: Environment) async throws { 52 | 53 | let xci = try await MainCommand.XCodeInstaller( 54 | with: env, 55 | for: cloudOption.secretManagerRegion, 56 | verbose: globalOptions.verbose 57 | ) 58 | try await xci.signout() 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIDownload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIAuthenticate.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 23/07/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import CLIlib 10 | import Foundation 11 | import Logging 12 | 13 | // download implementation 14 | extension MainCommand { 15 | 16 | struct Download: AsyncParsableCommand { 17 | nonisolated static let configuration = CommandConfiguration( 18 | abstract: "Download the specified version of Xcode" 19 | ) 20 | 21 | @OptionGroup var globalOptions: GlobalOptions 22 | @OptionGroup var downloadListOptions: DownloadListOptions 23 | @OptionGroup var cloudOption: CloudOptions 24 | 25 | @Option( 26 | name: .shortAndLong, 27 | help: "The exact package name to downloads. When omited, it asks interactively" 28 | ) 29 | var name: String? 30 | 31 | func run() async throws { 32 | try await run(with: RuntimeEnvironment(region: cloudOption.secretManagerRegion)) 33 | } 34 | 35 | func run(with env: Environment) async throws { 36 | 37 | let xci = try await MainCommand.XCodeInstaller( 38 | with: env, 39 | for: cloudOption.secretManagerRegion, 40 | verbose: globalOptions.verbose 41 | ) 42 | 43 | try await xci.download( 44 | fileName: name, 45 | force: downloadListOptions.force, 46 | xCodeOnly: downloadListOptions.onlyXcode, 47 | majorVersion: downloadListOptions.xCodeVersion, 48 | sortMostRecentFirst: downloadListOptions.mostRecentFirst, 49 | datePublished: downloadListOptions.datePublished 50 | ) 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIInstall.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIInstall.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 22/08/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import CLIlib 10 | import Foundation 11 | import Logging 12 | 13 | // Install implementation 14 | @MainActor 15 | extension MainCommand { 16 | 17 | struct Install: AsyncParsableCommand { 18 | 19 | nonisolated static let configuration = 20 | CommandConfiguration(abstract: "Install a specific XCode version or addon package") 21 | 22 | @OptionGroup var globalOptions: GlobalOptions 23 | 24 | @Option( 25 | name: .shortAndLong, 26 | help: "The exact package name to install. When omited, it asks interactively" 27 | ) 28 | var name: String? 29 | 30 | func run() async throws { 31 | try await run(with: RuntimeEnvironment()) 32 | } 33 | 34 | func run(with env: Environment) async throws { 35 | 36 | let xci = try await MainCommand.XCodeInstaller( 37 | with: env, 38 | verbose: globalOptions.verbose 39 | ) 40 | 41 | _ = try await xci.install(file: name) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIAuthenticate.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 23/07/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import CLIlib 10 | import Foundation 11 | import Logging 12 | 13 | // list implementation 14 | extension MainCommand { 15 | 16 | struct DownloadListOptions: ParsableArguments { 17 | 18 | nonisolated static let configuration = 19 | CommandConfiguration( 20 | abstract: "Common options for list and download commands", 21 | shouldDisplay: false 22 | ) 23 | 24 | @Flag( 25 | name: .shortAndLong, 26 | help: 27 | "Force to download the list from Apple Developer Portal, even if we have it in the cache" 28 | ) 29 | var force: Bool = false 30 | 31 | @Flag(name: .shortAndLong, help: "Filter on Xcode package only") 32 | var onlyXcode: Bool = false 33 | 34 | @Option( 35 | name: [.customLong("xcode-version"), .short], 36 | help: "Filter on provided Xcode version number" 37 | ) 38 | var xCodeVersion: String = "15" 39 | 40 | @Flag(name: .shortAndLong, help: "Sort by most recent releases first") 41 | var mostRecentFirst: Bool = false 42 | 43 | @Flag(name: .shortAndLong, help: "Show publication date") 44 | var datePublished: Bool = false 45 | 46 | } 47 | 48 | @MainActor 49 | struct List: AsyncParsableCommand { 50 | 51 | nonisolated static let configuration = 52 | CommandConfiguration(abstract: "List available versions of Xcode and development tools") 53 | 54 | @OptionGroup var globalOptions: GlobalOptions 55 | @OptionGroup var downloadListOptions: DownloadListOptions 56 | @OptionGroup var cloudOption: CloudOptions 57 | 58 | func run() async throws { 59 | try await run(with: RuntimeEnvironment(region: cloudOption.secretManagerRegion)) 60 | } 61 | 62 | func run(with env: Environment) async throws { 63 | 64 | let xci = try await MainCommand.XCodeInstaller( 65 | with: env, 66 | for: cloudOption.secretManagerRegion, 67 | verbose: globalOptions.verbose 68 | ) 69 | 70 | _ = try await xci.list( 71 | force: downloadListOptions.force, 72 | xCodeOnly: downloadListOptions.onlyXcode, 73 | majorVersion: downloadListOptions.xCodeVersion, 74 | sortMostRecentFirst: downloadListOptions.mostRecentFirst, 75 | datePublished: downloadListOptions.datePublished 76 | ) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIMain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLI.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 18/07/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import CLIlib 10 | import Foundation 11 | import Logging 12 | 13 | enum CLIError: Error { 14 | case invalidInput 15 | } 16 | 17 | @main 18 | @MainActor 19 | struct MainCommand: AsyncParsableCommand { 20 | 21 | // arguments that are global to all commands 22 | struct GlobalOptions: ParsableArguments { 23 | 24 | @Flag(name: .shortAndLong, help: "Produce verbose output for debugging") 25 | var verbose = false 26 | } 27 | 28 | // arguments for Authenticate, Signout, List, and Download 29 | struct CloudOptions: ParsableArguments { 30 | 31 | @Option( 32 | name: [.customLong("secretmanager-region"), .short], 33 | help: "Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region" 34 | ) 35 | var secretManagerRegion: String? 36 | } 37 | 38 | @OptionGroup var globalOptions: GlobalOptions 39 | 40 | // Customize the command's help and subcommands by implementing the 41 | // `configuration` property. 42 | nonisolated static let configuration = CommandConfiguration( 43 | commandName: "xcodeinstall", 44 | 45 | // Optional abstracts and discussions are used for help output. 46 | abstract: "A utility to download and install Xcode", 47 | 48 | // Commands can define a version for automatic '--version' support. 49 | version: Version.version, // generated by scripts/version.sh 50 | 51 | // Pass an array to `subcommands` to set up a nested tree of subcommands. 52 | // With language support for type-level introspection, this could be 53 | // provided by automatically finding nested `ParsableCommand` types. 54 | subcommands: [ 55 | Authenticate.self, Signout.self, List.self, 56 | Download.self, Install.self, StoreSecrets.self, 57 | ] 58 | 59 | // A default subcommand, when provided, is automatically selected if a 60 | // subcommand is not given on the command line. 61 | // defaultSubcommand: List.self) 62 | ) 63 | 64 | public static func XCodeInstaller( 65 | with env: Environment, 66 | for region: String? = nil, 67 | verbose: Bool 68 | ) async throws -> XCodeInstall { 69 | let logger: Logger! 70 | if verbose { 71 | logger = Log.defaultLogger(logLevel: .debug, label: "xcodeinstall") 72 | } else { 73 | logger = Log.defaultLogger(logLevel: .error, label: "xcodeinstall") 74 | } 75 | 76 | var runtimeEnv = env 77 | let xci: XCodeInstall! 78 | if let region { 79 | runtimeEnv.secrets = try AWSSecretsHandler(env: runtimeEnv, region: region) 80 | xci = XCodeInstall(log: logger, env: runtimeEnv) 81 | } else { 82 | xci = XCodeInstall(log: logger, env: runtimeEnv) 83 | } 84 | return xci 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIProgressBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIProgressBar.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 10/08/2022. 6 | // 7 | 8 | // found here https://www.fivestars.blog/articles/ultimate-guide-swift-executables/ and 9 | // https://www.fivestars.blog/articles/executables-progress/ 10 | 11 | // alternatives to consider to reduce size of dependencies 12 | // https://github.com/vapor/console-kit/tree/main/Sources/ConsoleKit/Activity 13 | // https://github.com/nsscreencast/469-swift-command-line-progress-bar 14 | // https://github.com/jkandzi/Progress.swift/blob/master/Sources/Progress.swift 15 | 16 | import CLIlib 17 | import Foundation 18 | 19 | protocol CLIProgressBarProtocol: ProgressUpdateProtocol { 20 | func define(animationType: ProgressBarType, message: String) 21 | } 22 | 23 | class CLIProgressBar: CLIProgressBarProtocol { 24 | 25 | private var progressAnimation: ProgressUpdateProtocol? 26 | private var message: String? 27 | private let stream: OutputBuffer = FileHandle.standardOutput 28 | 29 | func define(animationType: ProgressBarType, message: String) { 30 | self.message = message 31 | self.progressAnimation = ProgressBar( 32 | output: stream, 33 | progressBarType: animationType, 34 | title: self.message 35 | ) 36 | } 37 | 38 | /// Update the animation with a new step. 39 | /// - Parameters: 40 | /// - step: The index of the operation's current step. 41 | /// - total: The total number of steps before the operation is complete. 42 | /// - text: The description of the current step. 43 | func update(step: Int, total: Int, text: String) { 44 | self.progressAnimation?.update(step: step, total: total, text: text) 45 | } 46 | 47 | /// Complete the animation. 48 | /// - Parameters: 49 | /// - success: Defines if the operation the animation represents was succesful. 50 | func complete(success: Bool) { 51 | self.progressAnimation?.complete(success: success) 52 | } 53 | 54 | /// Clear the animation. 55 | func clear() { 56 | self.progressAnimation?.clear() 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/CLI-driver/CLIStoreSecrets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIStoreSecrets.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 01/09/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import CLIlib 10 | import Foundation 11 | import Logging 12 | 13 | extension MainCommand { 14 | 15 | struct StoreSecrets: AsyncParsableCommand { 16 | nonisolated static let configuration = 17 | CommandConfiguration( 18 | commandName: "storesecrets", 19 | abstract: "Store your Apple Developer Portal username and password in AWS Secrets Manager" 20 | ) 21 | 22 | @OptionGroup var globalOptions: GlobalOptions 23 | 24 | // repeat of CloudOption but this time mandatory 25 | @Option( 26 | name: [.customLong("secretmanager-region"), .short], 27 | help: "Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region" 28 | ) 29 | var secretManagerRegion: String 30 | 31 | func run() async throws { 32 | // this command works with secrets stored in the cloud 33 | // var env = await RuntimeEnvironment(region: secretManagerRegion) 34 | // env.secrets = try await AWSSecretsHandler(env: env, region: secretManagerRegion) 35 | // try await run(with: env) 36 | 37 | //TODO: I think we don't need to create a secret handler here, XCodeInstaller will create one 38 | try await run(with: RuntimeEnvironment(region: secretManagerRegion)) 39 | } 40 | 41 | func run(with env: Environment) async throws { 42 | 43 | let xci = try await MainCommand.XCodeInstaller( 44 | with: env, 45 | for: secretManagerRegion, 46 | verbose: globalOptions.verbose 47 | ) 48 | 49 | _ = try await xci.storeSecrets() 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment.swift 3 | // 4 | // 5 | // Created by Stormacq, Sebastien on 22/11/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | import Subprocess 11 | #if canImport(System) 12 | import System 13 | #else 14 | import SystemPackage 15 | #endif 16 | 17 | #if canImport(FoundationNetworking) 18 | import FoundationNetworking 19 | #endif 20 | 21 | /** 22 | 23 | a global struct to give access to classes for which I wrote tests. 24 | this global object allows me to simplify dependency injection */ 25 | 26 | @MainActor 27 | protocol Environment: Sendable { 28 | var fileHandler: FileHandlerProtocol { get } 29 | var display: DisplayProtocol { get } 30 | var readLine: ReadLineProtocol { get } 31 | var progressBar: CLIProgressBarProtocol { get } 32 | var secrets: SecretsHandlerProtocol? { get set } 33 | var authenticator: AppleAuthenticatorProtocol { get } 34 | var downloader: AppleDownloaderProtocol { get } 35 | var urlSessionData: URLSessionProtocol { get } 36 | func urlSessionDownload(dstFilePath: URL?, totalFileSize: Int?, startTime: Date?) -> URLSessionProtocol 37 | func run( 38 | _ executable: Executable, 39 | arguments: Arguments, 40 | workingDirectory: FilePath?, 41 | ) async throws -> CollectedResult, DiscardedOutput> 42 | func run( 43 | _ executable: Executable, 44 | arguments: Arguments, 45 | ) async throws -> CollectedResult, DiscardedOutput> 46 | } 47 | 48 | @MainActor 49 | struct RuntimeEnvironment: Environment { 50 | 51 | let region: String? 52 | init(region: String? = nil) { 53 | self.region = region 54 | } 55 | 56 | // Utilities classes 57 | var fileHandler: FileHandlerProtocol = FileHandler() 58 | 59 | // CLI related classes 60 | var display: DisplayProtocol = Display() 61 | var readLine: ReadLineProtocol = ReadLine() 62 | 63 | // progress bar 64 | var progressBar: CLIProgressBarProtocol = CLIProgressBar() 65 | 66 | // Secrets - will be overwritten by CLI when using AWS Secrets Manager 67 | var secrets: SecretsHandlerProtocol? = FileSecretsHandler() 68 | 69 | // Commands 70 | var authenticator: AppleAuthenticatorProtocol { 71 | AppleAuthenticator(env: self) 72 | } 73 | var downloader: AppleDownloaderProtocol { 74 | AppleDownloader(env: self) 75 | } 76 | 77 | // Network 78 | var urlSessionData: URLSessionProtocol = URLSession.shared 79 | func urlSessionDownload( 80 | dstFilePath: URL? = nil, 81 | totalFileSize: Int? = nil, 82 | startTime: Date? = nil 83 | ) -> URLSessionProtocol { 84 | URLSession( 85 | configuration: .default, 86 | delegate: DownloadDelegate( 87 | env: self, 88 | dstFilePath: dstFilePath, 89 | totalFileSize: totalFileSize, 90 | startTime: startTime, 91 | semaphore: DispatchSemaphore(value: 0) 92 | ), 93 | delegateQueue: nil 94 | ) 95 | } 96 | 97 | func run ( 98 | _ executable: Executable, 99 | arguments: Arguments, 100 | ) async throws -> CollectedResult, DiscardedOutput> { 101 | return try await run(executable, 102 | arguments: arguments, 103 | workingDirectory: nil 104 | ) 105 | } 106 | func run ( 107 | _ executable: Executable, 108 | arguments: Arguments, 109 | workingDirectory: FilePath?, 110 | ) async throws -> CollectedResult, DiscardedOutput> { 111 | try await Subprocess.run( 112 | executable, 113 | arguments: arguments, 114 | environment: .inherit, 115 | workingDirectory: workingDirectory, 116 | platformOptions: PlatformOptions(), 117 | input: .none, 118 | output: .string, 119 | error: .discarded) 120 | 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Secrets/FileSecretsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileSecretsHandler.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 14/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | #if canImport(FoundationNetworking) 12 | import FoundationNetworking 13 | #endif 14 | 15 | // store secrets on files in $HOME/.xcodeinstaller 16 | @MainActor 17 | struct FileSecretsHandler: SecretsHandlerProtocol { 18 | private var fileManager: FileManager 19 | private var baseDirectory: URL 20 | private let cookiesPath: URL 21 | private let sessionPath: URL 22 | private let newCookiesPath: URL 23 | private let newSessionPath: URL 24 | 25 | init() { 26 | self.fileManager = FileManager.default 27 | 28 | baseDirectory = FileHandler().baseFilePath() 29 | 30 | cookiesPath = baseDirectory.appendingPathComponent("cookies") 31 | sessionPath = baseDirectory.appendingPathComponent("session") 32 | 33 | newCookiesPath = cookiesPath.appendingPathExtension("copy") 34 | newSessionPath = sessionPath.appendingPathExtension("copy") 35 | } 36 | 37 | // used when testing to start from a clean place 38 | // func restoreSecrets() { 39 | // 40 | // // remove file 41 | // try? fileManager.removeItem(at: sessionPath) 42 | // 43 | // // copy backup to file 44 | // try? fileManager.copyItem(at: newSessionPath, to: sessionPath) 45 | // 46 | // // remove backup 47 | // try? fileManager.removeItem(at: newSessionPath) 48 | // 49 | // // do it again with cookies file 50 | // 51 | // try? fileManager.removeItem(at: cookiesPath) 52 | // try? fileManager.copyItem(at: newCookiesPath, to: cookiesPath) 53 | // try? fileManager.removeItem(at: newCookiesPath) 54 | // 55 | // } 56 | 57 | // used when testing to start from a clean place 58 | // func clearSecrets(preserve: Bool = false) { 59 | func clearSecrets() async throws { 60 | 61 | // if preserve { 62 | // 63 | // // move files instead of deleting them (if they exist) 64 | // try? fileManager.copyItem(at: cookiesPath, to: newCookiesPath) 65 | // try? fileManager.copyItem(at: sessionPath, to: newSessionPath) 66 | // 67 | // } 68 | 69 | try? fileManager.removeItem(at: cookiesPath) 70 | try? fileManager.removeItem(at: sessionPath) 71 | 72 | } 73 | 74 | // save cookies in an HTTPUrlResponse 75 | // save to ~/.xcodeinstall/cookies 76 | // merge existing cookies into file when file already exists 77 | func saveCookies(_ cookies: String?) async throws -> String? { 78 | 79 | guard let cookieString = cookies else { 80 | return nil 81 | } 82 | 83 | var result: String? = cookieString 84 | 85 | do { 86 | 87 | // if file exists, 88 | if fileManager.fileExists(atPath: cookiesPath.path) { 89 | 90 | // load existing cookies as [HTTPCookie] 91 | let existingCookies = try await self.loadCookies() 92 | 93 | // read it, append the new cookies and save the whole new thing 94 | result = try await mergeCookies(existingCookies: existingCookies, newCookies: cookies) 95 | try result?.data(using: .utf8)!.write(to: cookiesPath) 96 | 97 | } else { 98 | 99 | // otherwise, just save the cookies 100 | try cookieString.data(using: .utf8)!.write(to: cookiesPath) 101 | } 102 | } catch { 103 | log.error("⚠️ can not write cookies file: \(error)") 104 | throw error 105 | } 106 | 107 | return result 108 | 109 | } 110 | 111 | // retrieve cookies 112 | func loadCookies() async throws -> [HTTPCookie] { 113 | 114 | // read the raw file saved on disk 115 | let cookieLongString = try String(contentsOf: cookiesPath, encoding: .utf8) 116 | let result = cookieLongString.cookies() 117 | return result 118 | } 119 | 120 | // save Apple Session values as JSON 121 | func saveSession(_ session: AppleSession) async throws -> AppleSession { 122 | 123 | // save session 124 | try session.data().write(to: sessionPath) 125 | 126 | return session 127 | } 128 | 129 | // load Apple Session from JSON 130 | // returns nil when can not read file 131 | func loadSession() async throws -> AppleSession? { 132 | 133 | // read the raw file saved on disk 134 | if let sessionData = try? Data(contentsOf: sessionPath) { 135 | return try AppleSession(fromData: sessionData) 136 | } else { 137 | return nil 138 | } 139 | } 140 | 141 | //MARK: these operations are only valid on AWSSecretsHandler 142 | func retrieveAppleCredentials() async throws -> AppleCredentialsSecret { 143 | throw AWSSecretsHandlerError.invalidOperation 144 | } 145 | func storeAppleCredentials(_ credentials: AppleCredentialsSecret) async throws { 146 | throw AWSSecretsHandlerError.invalidOperation 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Secrets/SecretsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helper.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 19/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(FoundationNetworking) 11 | import FoundationNetworking 12 | #endif 13 | 14 | protocol Secrets { 15 | func data() throws -> Data 16 | func string() throws -> String? 17 | } 18 | 19 | // the data to be stored in Secrets Manager as JSON 20 | struct AppleCredentialsSecret: Codable, Secrets { 21 | 22 | let username: String 23 | let password: String 24 | 25 | func data() throws -> Data { 26 | try JSONEncoder().encode(self) 27 | } 28 | 29 | func string() throws -> String? { 30 | String(data: try self.data(), encoding: .utf8) 31 | } 32 | 33 | init(fromData data: Data) throws { 34 | self = try JSONDecoder().decode(AppleCredentialsSecret.self, from: data) 35 | } 36 | 37 | init(fromString string: String) throws { 38 | if let data = string.data(using: .utf8) { 39 | try self.init(fromData: data) 40 | } else { 41 | fatalError("Can not create data from string : \(string)") 42 | } 43 | } 44 | 45 | init(username: String = "", password: String = "") { 46 | self.username = username 47 | self.password = password 48 | } 49 | 50 | } 51 | 52 | protocol SecretsHandlerProtocol: Sendable { 53 | 54 | func clearSecrets() async throws 55 | 56 | // func clearSecrets(preserve: Bool) 57 | // func restoreSecrets() 58 | 59 | func saveCookies(_ cookies: String?) async throws -> String? 60 | func loadCookies() async throws -> [HTTPCookie] 61 | 62 | func saveSession(_ session: AppleSession) async throws -> AppleSession 63 | func loadSession() async throws -> AppleSession? 64 | 65 | func retrieveAppleCredentials() async throws -> AppleCredentialsSecret 66 | func storeAppleCredentials(_ credentials: AppleCredentialsSecret) async throws 67 | } 68 | 69 | extension SecretsHandlerProtocol { 70 | 71 | /// 72 | /// Merge given cookies with the one stored already 73 | /// 74 | /// - Parameters 75 | /// - cookies : the new cookies to store (or to append) 76 | /// 77 | /// - Returns : the new string with all cookies 78 | /// 79 | func mergeCookies(existingCookies: [HTTPCookie], newCookies: String?) async throws -> String? { 80 | 81 | guard let cookieString = newCookies else { 82 | return nil 83 | } 84 | 85 | var result = existingCookies 86 | 87 | // transform received cookie string into [HTTPCookie] 88 | let newCookies = cookieString.cookies() 89 | 90 | // merge cookies, new values have priority 91 | 92 | // browse new cookies 93 | for newCookie in newCookies { 94 | 95 | // if a newCookie match an existing one 96 | if (existingCookies.contains { cookie in cookie.name == newCookie.name }) { 97 | 98 | // replace old with new 99 | // assuming there is only one !! 100 | result.removeAll { cookie in cookie.name == newCookie.name } 101 | result.append(newCookie) 102 | } else { 103 | // add new to existing 104 | result.append(newCookie) 105 | } 106 | 107 | } 108 | 109 | // save new set of cookie as string 110 | return result.string() 111 | 112 | } 113 | } 114 | 115 | extension String { 116 | 117 | func cookies() -> [HTTPCookie] { 118 | var fakeHttpHeader = [String: String]() 119 | fakeHttpHeader["Set-Cookie"] = self 120 | // only cookies from this domain or subdomains are going to be created 121 | return HTTPCookie.cookies( 122 | withResponseHeaderFields: fakeHttpHeader, 123 | for: URL(string: "https://apple.com")! 124 | ) 125 | 126 | } 127 | 128 | } 129 | 130 | extension Array where Element == HTTPCookie { 131 | 132 | func string() -> String? { 133 | 134 | var cookieString = "" 135 | 136 | // for each cookie 137 | for cookie in self { 138 | 139 | if let props = cookie.properties { 140 | 141 | // return all properties as an array of strings with key=value 142 | var cookieAsString = props.map { (key: HTTPCookiePropertyKey, value: Any) -> String in 143 | switch key.rawValue { 144 | // boolean values are handled separately 145 | case "Secure": return "Secure" 146 | case "HttpOnly": return "HttpOnly" 147 | case "Discard": return "" 148 | 149 | // name and value are handled separately to produce name=value 150 | // (and not Name=name and Value=value) 151 | case "Name": return "" 152 | case "Value": return "" 153 | 154 | default: return "\(key.rawValue)=\(value)" 155 | } 156 | } 157 | 158 | // remove empty strings 159 | cookieAsString.removeAll { string in string == "" } 160 | 161 | // add a coma in between cookies 162 | if cookieString != "" { 163 | cookieString += ", " 164 | } 165 | 166 | // add name=value 167 | if let name = props[HTTPCookiePropertyKey.name] as? String, 168 | let value = props[HTTPCookiePropertyKey.value] as? String 169 | { 170 | cookieString += "\(name)=\(value); " 171 | } else { 172 | fatalError("Cookie string has no name or value values") 173 | } 174 | 175 | // concatenate all strings, spearated by a coma 176 | cookieString += cookieAsString.joined(separator: "; ") 177 | } 178 | } 179 | 180 | // remove last 181 | return cookieString 182 | } 183 | 184 | } 185 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Utilities/Array+AsyncMap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 17/05/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | func asyncMap(_ transform: @Sendable (Element) async throws -> T) async rethrows -> [T] { 12 | var results = [T]() 13 | for element in self { 14 | try await results.append(transform(element)) 15 | } 16 | return results 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Utilities/FileHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManagerExtension.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 20/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | // the methods I want to mock for unit testing 12 | @MainActor 13 | protocol FileHandlerProtocol: Sendable, Decodable { 14 | func move(from src: URL, to dst: URL) async throws 15 | func fileExists(file: URL, fileSize: Int) -> Bool 16 | func checkFileSize(file: URL, fileSize: Int) throws -> Bool 17 | func downloadedFiles() throws -> [String] 18 | func downloadFilePath(file: DownloadList.File) async -> String 19 | func downloadFileURL(file: DownloadList.File) async -> URL 20 | func saveDownloadList(list: DownloadList) throws -> DownloadList 21 | func loadDownloadList() throws -> DownloadList 22 | func baseFilePath() -> URL 23 | func baseFilePath() -> String 24 | func downloadDirectory() -> URL 25 | } 26 | 27 | enum FileHandlerError: Error { 28 | case fileDoesNotExist 29 | case noDownloadedList 30 | } 31 | 32 | struct FileHandler: FileHandlerProtocol { 33 | 34 | private static let baseDirectory = FileManager.default.homeDirectoryForCurrentUser 35 | .appendingPathComponent(".xcodeinstall") 36 | func downloadDirectory() -> URL { FileHandler.baseDirectory.appendingPathComponent("download") } 37 | func downloadListPath() -> URL { FileHandler.baseDirectory.appendingPathComponent("downloadList") } 38 | 39 | func baseFilePath() -> String { 40 | baseFilePath().path 41 | } 42 | func baseFilePath() -> URL { 43 | 44 | // if base directory does not exist, create it 45 | let fm = FileManager.default // swiftlint:disable:this identifier_name 46 | if !fm.fileExists(atPath: FileHandler.baseDirectory.path) { 47 | do { 48 | try FileManager.default.createDirectory(at: downloadDirectory(), withIntermediateDirectories: true) 49 | } catch { 50 | log.error("🛑 Can not create base directory : \(FileHandler.baseDirectory.path)\n\(error)") 51 | } 52 | } 53 | 54 | return FileHandler.baseDirectory 55 | } 56 | 57 | func move(from src: URL, to dst: URL) async throws { 58 | do { 59 | if FileManager.default.fileExists(atPath: dst.path) { 60 | log.debug("⚠️ File \(dst) exists, I am overwriting it") 61 | try FileManager.default.removeItem(atPath: dst.path) 62 | } 63 | 64 | let dstUrl = URL(fileURLWithPath: dst.path) 65 | try FileManager.default.moveItem(at: src, to: dstUrl) 66 | 67 | } catch { 68 | log.error("🛑 Can not move file : \(error)") 69 | throw error 70 | } 71 | } 72 | 73 | func downloadFilePath(file: DownloadList.File) async -> String { 74 | await downloadFileURL(file: file).path 75 | } 76 | func downloadFileURL(file: DownloadList.File) async -> URL { 77 | 78 | // if download directory does not exist, create it 79 | if !FileManager.default.fileExists(atPath: downloadDirectory().path) { 80 | do { 81 | try FileManager.default.createDirectory(at: downloadDirectory(), withIntermediateDirectories: true) 82 | } catch { 83 | log.error( 84 | "🛑 Can not create base directory : \(downloadDirectory().path)\n\(error)" 85 | ) 86 | } 87 | } 88 | return downloadDirectory().appendingPathComponent(file.filename) 89 | 90 | } 91 | 92 | /// Check if file exists and has correct size 93 | /// - Parameters: 94 | /// - filePath the path of the file to verify 95 | /// - fileSize the expected size of the file (in bytes). 96 | /// - Returns : true when the file exists and has the given size, false otherwise 97 | /// - Throws: 98 | /// - FileHandlerError.FileDoesNotExist when the file does not exists 99 | func checkFileSize(file: URL, fileSize: Int) throws -> Bool { 100 | 101 | let filePath = file.path 102 | 103 | // file exists ? 104 | let exists = FileManager.default.fileExists(atPath: filePath) 105 | if !exists { throw FileHandlerError.fileDoesNotExist } 106 | 107 | // file size ? 108 | let attributes = try? FileManager.default.attributesOfItem(atPath: filePath) 109 | let actualSize = attributes?[.size] as? Int 110 | 111 | // at this stage, we know the file exists, just check size now 112 | return actualSize == fileSize 113 | } 114 | 115 | /// Check if file exists and has correct size 116 | /// - Parameters: 117 | /// - filePath the path of the file to verify 118 | /// - fileSize the expected size of the file (in bytes). 119 | /// when omited, file size is not checked 120 | func fileExists(file: URL, fileSize: Int = 0) -> Bool { 121 | 122 | let filePath = file.path 123 | 124 | let fileExists = FileManager.default.fileExists(atPath: filePath) 125 | // does the file exists ? 126 | if !fileExists { 127 | return false 128 | } 129 | 130 | // is the file complete ? 131 | // use try! because I verified if file exists already 132 | let fileComplete = try? self.checkFileSize(file: file, fileSize: fileSize) 133 | 134 | return (fileSize > 0 ? fileComplete ?? false : fileExists) 135 | } 136 | 137 | func downloadedFiles() throws -> [String] { 138 | do { 139 | return try FileManager.default.contentsOfDirectory(atPath: downloadDirectory().path) 140 | } catch { 141 | log.debug("\(error)") 142 | throw FileHandlerError.noDownloadedList 143 | } 144 | } 145 | 146 | func saveDownloadList(list: DownloadList) throws -> DownloadList { 147 | 148 | // save list 149 | let data = try JSONEncoder().encode(list) 150 | try data.write(to: downloadListPath()) 151 | 152 | return list 153 | 154 | } 155 | 156 | func loadDownloadList() throws -> DownloadList { 157 | 158 | // read the raw file saved on disk 159 | let listData = try Data(contentsOf: downloadListPath()) 160 | 161 | return try JSONDecoder().decode(DownloadList.self, from: listData) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Utilities/ShellOutput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/05/2025. 6 | // 7 | 8 | import Subprocess 9 | #if canImport(System) 10 | import System 11 | #else 12 | import SystemPackage 13 | #endif 14 | 15 | typealias ShellOutput = CollectedResult, DiscardedOutput> 16 | 17 | extension Executable { 18 | public static func path(_ path: String) -> Self { 19 | Executable.path(FilePath(path)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Version.swift: -------------------------------------------------------------------------------- 1 | // Generated by: scripts/version 2 | enum Version { 3 | static let version = "0.10.1" 4 | } 5 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/AuthenticateCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticateCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension XCodeInstall { 11 | 12 | func authenticate(with authenticationMethod: AuthenticationMethod) async throws { 13 | 14 | let auth = self.env.authenticator 15 | 16 | do { 17 | 18 | // delete previous session, if any 19 | try await self.env.secrets!.clearSecrets() 20 | let appleCredentials = try await retrieveAppleCredentials() 21 | 22 | if authenticationMethod == .usernamePassword { 23 | display("Authenticating with username and password (likely to fail) ...") 24 | } else { 25 | display("Authenticating...") 26 | } 27 | try await auth.startAuthentication( 28 | with: authenticationMethod, 29 | username: appleCredentials.username, 30 | password: appleCredentials.password 31 | ) 32 | display("✅ Authenticated.") 33 | 34 | } catch AuthenticationError.invalidUsernamePassword { 35 | 36 | // handle invalid username or password 37 | display("🛑 Invalid username or password.") 38 | 39 | } catch AuthenticationError.requires2FA { 40 | 41 | // handle two factors authentication 42 | try await startMFAFlow() 43 | 44 | } catch AuthenticationError.serviceUnavailable { 45 | 46 | // service unavailable means that the authentication method requested is not available 47 | display("🛑 Requested authentication method is not available. Try with SRP.") 48 | 49 | } catch AuthenticationError.unableToRetrieveAppleServiceKey(let error) { 50 | 51 | // handle connection errors 52 | display( 53 | "🛑 Can not connect to Apple Developer Portal.\nOriginal error : \(error?.localizedDescription ?? "nil")" 54 | ) 55 | 56 | } catch AuthenticationError.notImplemented(let feature) { 57 | 58 | // handle not yet implemented errors 59 | display( 60 | "🛑 \(feature) is not yet implemented. Try the next version of xcodeinstall when it will be available." 61 | ) 62 | 63 | } catch { 64 | display("🛑 Unexpected Error : \(error)") 65 | } 66 | } 67 | 68 | // retrieve apple developer portal credentials. 69 | // either from AWS Secrets Manager, either interactively 70 | private func retrieveAppleCredentials() async throws -> AppleCredentialsSecret { 71 | 72 | var appleCredentials: AppleCredentialsSecret 73 | do { 74 | // first try on AWS Secrets Manager 75 | display("Retrieving Apple Developer Portal credentials...") 76 | appleCredentials = try await self.env.secrets!.retrieveAppleCredentials() 77 | 78 | } catch AWSSecretsHandlerError.invalidOperation { 79 | 80 | // we have a file secrets handler, prompt for credentials interactively 81 | appleCredentials = try promptForCredentials() 82 | 83 | } catch { 84 | 85 | // unexpected errors, do not handle here 86 | throw error 87 | } 88 | 89 | return appleCredentials 90 | } 91 | 92 | // prompt user for apple developer portal credentials interactively 93 | private func promptForCredentials() throws -> AppleCredentialsSecret { 94 | display( 95 | """ 96 | ⚠️⚠️ We prompt you for your Apple ID username, password, and two factors authentication code. 97 | These values are not stored anywhere. They are used to get an Apple session ID. ⚠️⚠️ 98 | 99 | Alternatively, you may store your credentials on AWS Secrets Manager 100 | """ 101 | ) 102 | 103 | guard 104 | let username = self.env.readLine.readLine( 105 | prompt: "⌨️ Enter your Apple ID username: ", 106 | silent: false 107 | ) 108 | else { 109 | throw CLIError.invalidInput 110 | } 111 | 112 | guard 113 | let password = self.env.readLine.readLine( 114 | prompt: "⌨️ Enter your Apple ID password: ", 115 | silent: true 116 | ) 117 | else { 118 | throw CLIError.invalidInput 119 | } 120 | 121 | return AppleCredentialsSecret(username: username, password: password) 122 | } 123 | 124 | // manage the MFA authentication sequence 125 | private func startMFAFlow() async throws { 126 | 127 | let auth = self.env.authenticator 128 | 129 | do { 130 | 131 | let codeLength = try await auth.handleTwoFactorAuthentication() 132 | assert(codeLength > 0) 133 | 134 | let prompt = "🔐 Two factors authentication is enabled, enter your 2FA code: " 135 | guard let pinCode = self.env.readLine.readLine(prompt: prompt, silent: false) else { 136 | throw CLIError.invalidInput 137 | } 138 | try await auth.twoFactorAuthentication(pin: pinCode) 139 | display("✅ Authenticated with MFA.") 140 | 141 | } catch AuthenticationError.requires2FATrustedPhoneNumber { 142 | 143 | display( 144 | """ 145 | 🔐 Two factors authentication is enabled, with 4 digits code and trusted phone numbers. 146 | This tool does not support SMS MFA at the moment. Please enable 2 factors authentication 147 | with trusted devices as described here: https://support.apple.com/en-us/HT204915 148 | """ 149 | ) 150 | 151 | } 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/DownloadCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension XCodeInstall { 11 | 12 | // swiftlint:disable: function_parameter_count 13 | func download( 14 | fileName: String?, 15 | force: Bool, 16 | xCodeOnly: Bool, 17 | majorVersion: String, 18 | sortMostRecentFirst: Bool, 19 | datePublished: Bool 20 | ) async throws { 21 | 22 | let download = self.env.downloader 23 | 24 | var fileToDownload: DownloadList.File 25 | do { 26 | 27 | // when filename was given by user 28 | if fileName != nil { 29 | 30 | // search matching filename in the download list cache 31 | let list = try await download.list(force: force) 32 | if let result = list.find(fileName: fileName!) { 33 | fileToDownload = result 34 | } else { 35 | throw DownloadError.unknownFile(file: fileName!) 36 | } 37 | 38 | } else { 39 | 40 | // when no file was given, ask user 41 | fileToDownload = try await self.askFile( 42 | force: force, 43 | xCodeOnly: xCodeOnly, 44 | majorVersion: majorVersion, 45 | sortMostRecentFirst: sortMostRecentFirst, 46 | datePublished: datePublished 47 | ) 48 | } 49 | 50 | // now we have a filename, let's proceed with download 51 | let progressBar = self.env.progressBar 52 | progressBar.define( 53 | animationType: .percentProgressAnimation, 54 | message: "Downloading \(fileToDownload.displayName ?? fileToDownload.filename)" 55 | ) 56 | 57 | _ = try await download.download(file: fileToDownload) 58 | 59 | // check if the downloaded file is complete 60 | let fh = self.env.fileHandler 61 | let file: URL = await Task { await fh.downloadFileURL(file: fileToDownload) }.value 62 | let complete = try? self.env.fileHandler.checkFileSize( 63 | file: file, 64 | fileSize: fileToDownload.fileSize 65 | ) 66 | if !(complete ?? false) { 67 | display("🛑 Downloaded file has incorrect size, it might be incomplete or corrupted") 68 | } 69 | display("✅ \(fileName ?? "file") downloaded") 70 | 71 | } catch DownloadError.zeroOrMoreThanOneFileToDownload(let count) { 72 | display("🛑 There are \(count) files to download " + "for this component. Not implemented.") 73 | } catch DownloadError.authenticationRequired { 74 | 75 | // error message has been printed already 76 | 77 | } catch CLIError.invalidInput { 78 | display("🛑 Invalid input") 79 | } catch DownloadError.unknownFile(let fileName) { 80 | display("🛑 Unknown file name : \(fileName)") 81 | } catch { 82 | display("🛑 Unexpected error : \(error)") 83 | } 84 | } 85 | 86 | func askFile( 87 | force: Bool, 88 | xCodeOnly: Bool, 89 | majorVersion: String, 90 | sortMostRecentFirst: Bool, 91 | datePublished: Bool 92 | ) async throws -> DownloadList.File { 93 | 94 | let parsedList = try await self.list( 95 | force: force, 96 | xCodeOnly: xCodeOnly, 97 | majorVersion: majorVersion, 98 | sortMostRecentFirst: sortMostRecentFirst, 99 | datePublished: datePublished 100 | ) 101 | 102 | let response: String? = self.env.readLine.readLine( 103 | prompt: "⌨️ Which one do you want to download? ", 104 | silent: false 105 | ) 106 | guard let number = response, 107 | let num = Int(number) 108 | else { 109 | 110 | if (response ?? "") == "" { 111 | exit(0) 112 | } 113 | throw CLIError.invalidInput 114 | } 115 | 116 | if parsedList[num].files.count == 1 { 117 | return parsedList[num].files[0] 118 | } else { 119 | throw DownloadError.zeroOrMoreThanOneFileToDownload(count: parsedList[num].files.count) 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/DownloadListParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadListParser.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 24/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | struct DownloadListParser { 12 | 13 | let env: Environment 14 | let xCodeOnly: Bool 15 | let majorVersion: String 16 | let sortMostRecentFirst: Bool 17 | 18 | init(env: Environment, xCodeOnly: Bool = true, majorVersion: String = "13", sortMostRecentFirst: Bool = false) { 19 | self.env = env 20 | self.xCodeOnly = xCodeOnly 21 | self.majorVersion = majorVersion 22 | self.sortMostRecentFirst = sortMostRecentFirst 23 | } 24 | 25 | func parse(list: DownloadList?) throws -> [DownloadList.Download] { 26 | 27 | guard let list = list?.downloads else { 28 | throw DownloadError.noDownloadsInDownloadList 29 | } 30 | 31 | // filter on items having Xcode in their name 32 | let listOfXcode = list.filter { download in 33 | if xCodeOnly { 34 | return download.name.starts(with: "Xcode \(majorVersion)") 35 | } else { 36 | return download.name.contains("Xcode \(majorVersion)") 37 | } 38 | } 39 | 40 | // sort by date (most recent last) 41 | let sortedList = listOfXcode.sorted { (downloadA, downloadB) in 42 | 43 | var dateA: String 44 | var dateB: String 45 | 46 | // select a non nil-date, either Published or Created. 47 | if let pubDateA = downloadA.datePublished, 48 | let pubDateB = downloadB.datePublished 49 | { 50 | dateA = pubDateA 51 | dateB = pubDateB 52 | } else { 53 | dateA = downloadA.dateCreated 54 | dateB = downloadB.dateCreated 55 | } 56 | 57 | // parse the string and return a date 58 | if let aAsDate = dateA.toDate(), 59 | let bAsDate = dateB.toDate() 60 | { 61 | return self.sortMostRecentFirst ? aAsDate > bAsDate : aAsDate < bAsDate 62 | } else { 63 | // I don't know what to do when we can not parse the date 64 | return false 65 | } 66 | } 67 | 68 | return sortedList 69 | } 70 | 71 | /// Enrich the list of available downloads. 72 | /// It adds a flag for each file in the list to indicate if the file is already downloaded and available in cache 73 | func enrich(list: [DownloadList.Download]) async -> [DownloadList.Download] { 74 | 75 | await list.asyncMap { download in 76 | let fileHandler = await self.env.fileHandler 77 | 78 | // swiftlint:disable identifier_name 79 | let file = download.files[0] 80 | 81 | let fileCopy = file 82 | let downloadFile: URL = await Task { 83 | await fileHandler.downloadFileURL(file: fileCopy) 84 | }.value 85 | let exists = await fileHandler.fileExists(file: downloadFile, fileSize: file.fileSize) 86 | 87 | // create a copy of the file to be used in the list 88 | let newFile = DownloadList.File.init(from: file, existInCache: exists) 89 | 90 | // create a copy of the download to be used in the list 91 | let newDownload = DownloadList.Download( 92 | from: download, 93 | replaceWith: newFile 94 | ) 95 | 96 | return newDownload 97 | 98 | } 99 | } 100 | 101 | func prettyPrint(list: [DownloadList.Download], withDate: Bool = true) -> String { 102 | 103 | // var result = "" 104 | 105 | // map returns a [String] each containing a line to display 106 | let result: String = list.enumerated().map { (index, download) in 107 | var line: String = "" 108 | let file = download.files[0] 109 | 110 | // swiftlint:disable line_length 111 | line += 112 | "[\(String(format: "%02d", index))] \(download.name) (\(file.fileSize/1024/1024) Mb) \(file.existInCache ? "(*)" : "")" 113 | 114 | if withDate { 115 | if let date = download.datePublished { 116 | let das = date.toDate() 117 | line += " (published on \(das?.formatted(date: .numeric, time: .omitted) ?? ""))" 118 | } else { 119 | let das = download.dateCreated.toDate() 120 | line += " (created on \(das?.formatted(date: .numeric, time: .omitted) ?? ""))" 121 | } 122 | } 123 | return line 124 | } 125 | // join all strings in [] with a \n 126 | .joined(separator: "\n") 127 | 128 | return result 129 | } 130 | } 131 | 132 | extension String { 133 | 134 | func toDate() -> Date? { 135 | 136 | let appleDownloadDateFormatter = DateFormatter() 137 | appleDownloadDateFormatter.locale = Locale(identifier: "en_US_POSIX") 138 | appleDownloadDateFormatter.dateFormat = "MM-dd-yy HH:mm" 139 | // appleDownloadDateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // assume GMT timezone 140 | 141 | return appleDownloadDateFormatter.date(from: self) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/InstallCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 22/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | extension XCodeInstall { 12 | 13 | func install(file: String?) async throws { 14 | 15 | let installer = ShellInstaller(env: &self.env) 16 | 17 | // progress bar to report progress feedback 18 | let progressBar = self.env.progressBar 19 | progressBar.define( 20 | animationType: .countingProgressAnimationMultiLine, 21 | message: "Installing..." 22 | ) 23 | 24 | var fileToInstall: URL? 25 | do { 26 | // when no file is specified, prompt user to select one 27 | if nil == file { 28 | fileToInstall = try promptForFile() 29 | } else { 30 | fileToInstall = FileHandler().downloadDirectory().appendingPathComponent(file!) 31 | } 32 | log.debug("Going to attemp to install \(fileToInstall!.path)") 33 | 34 | try await installer.install(file: fileToInstall!) 35 | self.env.progressBar.complete(success: true) 36 | display("✅ \(fileToInstall!) installed") 37 | } catch CLIError.invalidInput { 38 | display("🛑 Invalid input") 39 | self.env.progressBar.complete(success: false) 40 | } catch FileHandlerError.noDownloadedList { 41 | display("⚠️ There is no downloaded file to be installed") 42 | self.env.progressBar.complete(success: false) 43 | } catch InstallerError.xCodeXIPInstallationError { 44 | display("🛑 Can not expand XIP file. Is there enough space on / ? (16GiB required)") 45 | self.env.progressBar.complete(success: false) 46 | } catch InstallerError.xCodeMoveInstallationError { 47 | display("🛑 Can not move Xcode to /Applications") 48 | self.env.progressBar.complete(success: false) 49 | } catch InstallerError.xCodePKGInstallationError { 50 | display( 51 | "🛑 Can not install additional packages. Be sure to run this command as root (sudo xcodinstall)." 52 | ) 53 | self.env.progressBar.complete(success: false) 54 | } catch InstallerError.unsupportedInstallation { 55 | display( 56 | "🛑 Unsupported installation type. (We support Xcode XIP files and Command Line Tools PKG)" 57 | ) 58 | self.env.progressBar.complete(success: false) 59 | } catch { 60 | display("🛑 Error while installing \(String(describing: fileToInstall!))") 61 | log.debug("\(error)") 62 | self.env.progressBar.complete(success: false) 63 | } 64 | } 65 | 66 | func promptForFile() throws -> URL { 67 | 68 | // list files ready to install 69 | let installableFiles = try self.env.fileHandler.downloadedFiles().filter({ fileName in 70 | return fileName.hasSuffix(".xip") || fileName.hasSuffix(".dmg") 71 | }) 72 | 73 | display("") 74 | display("👉 Here is the list of available files to install:") 75 | display("") 76 | let printableList = installableFiles.enumerated().map({ (index, fileName) in 77 | return "[\(String(format: "%02d", index))] \(fileName)" 78 | }).joined(separator: "\n") 79 | display(printableList) 80 | display("\(installableFiles.count) items") 81 | 82 | let response: String? = self.env.readLine.readLine( 83 | prompt: "⌨️ Which one do you want to install? ", 84 | silent: false 85 | ) 86 | guard let number = response, 87 | let num = Int(number) 88 | else { 89 | 90 | if (response ?? "") == "" { 91 | exit(0) 92 | } 93 | throw CLIError.invalidInput 94 | } 95 | 96 | return FileHandler().downloadDirectory().appendingPathComponent(installableFiles[num]) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/ListCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension XCodeInstall { 11 | 12 | func list( 13 | force: Bool, 14 | xCodeOnly: Bool, 15 | majorVersion: String, 16 | sortMostRecentFirst: Bool, 17 | datePublished: Bool 18 | ) async throws -> [DownloadList.Download] { 19 | 20 | let download = self.env.downloader 21 | 22 | display("Loading list of available downloads ", terminator: "") 23 | display( 24 | "\(force ? "forced download from Apple Developer Portal" : "fetched from cache in \(self.env.fileHandler.baseFilePath())")" 25 | ) // swiftlint:disable:this line_length 26 | 27 | do { 28 | let list = try await download.list(force: force) 29 | display("✅ Done") 30 | 31 | let parser = DownloadListParser( 32 | env: self.env, 33 | xCodeOnly: xCodeOnly, 34 | majorVersion: majorVersion, 35 | sortMostRecentFirst: sortMostRecentFirst 36 | ) 37 | let parsedList = try parser.parse(list: list) 38 | 39 | // enrich the list to flag files already downloaded 40 | let enrichedList = await parser.enrich(list: parsedList) 41 | 42 | display("") 43 | display("👉 Here is the list of available downloads:") 44 | display("Files marked with (*) are already downloaded in \(self.env.fileHandler.baseFilePath()) ") 45 | display("") 46 | let string = parser.prettyPrint(list: enrichedList, withDate: datePublished) 47 | display(string) 48 | display("\(enrichedList.count) items") 49 | 50 | return enrichedList 51 | 52 | } catch let error as DownloadError { 53 | switch error { 54 | case .authenticationRequired: 55 | display("🛑 Session expired, you neeed to re-authenticate.") 56 | display("You can authenticate with the command: xcodeinstall authenticate") 57 | throw error 58 | case .accountneedUpgrade(let code, let message): 59 | display("🛑 \(message) (Apple Portal error code : \(code))") 60 | throw error 61 | case .needToAcceptTermsAndCondition: 62 | display( 63 | """ 64 | 🛑 This is a new Apple account, you need first to accept the developer terms of service. 65 | Open a session at https://developer.apple.com/register/agree/ 66 | Read and accept the ToS and try again. 67 | """ 68 | ) 69 | throw error 70 | case .unknownError(let code, let message): 71 | display("🛑 \(message) (Unhandled download error : \(code))") 72 | display( 73 | "Please file an error report at https://github.com/sebsto/xcodeinstall/issues/new?assignees=&labels=&template=bug_report.md&title=" 74 | ) 75 | throw error 76 | default: 77 | display("🛑 Unknown download error : \(error)") 78 | display( 79 | "Please file an error report at https://github.com/sebsto/xcodeinstall/issues/new?assignees=&labels=&template=bug_report.md&title=" 80 | ) 81 | throw error 82 | } 83 | } catch { 84 | display("🛑 Unexpected error : \(error)") 85 | display( 86 | "Please file an error repor at https://github.com/sebsto/xcodeinstall/issues/new?assignees=&labels=&template=bug_report.md&title=" 87 | ) 88 | throw error 89 | } 90 | 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/SignOutCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOutCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension XCodeInstall { 11 | 12 | func signout() async throws { 13 | 14 | let auth = self.env.authenticator 15 | 16 | display("Signing out...") 17 | try await auth.signout() 18 | display("✅ Signed out.") 19 | 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreSecretsCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 05/09/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension XCodeInstall { 11 | 12 | func storeSecrets() async throws { 13 | 14 | let secretsHandler = self.env.secrets! 15 | do { 16 | // separate func for testability 17 | let input = try promptForCredentials() 18 | let credentials = AppleCredentialsSecret(username: input[0], password: input[1]) 19 | 20 | try await secretsHandler.storeAppleCredentials(credentials) 21 | display("✅ Credentials are securely stored") 22 | 23 | } catch { 24 | display("🛑 Unexpected error : \(error)") 25 | throw error 26 | } 27 | 28 | } 29 | 30 | func promptForCredentials() throws -> [String] { 31 | display( 32 | """ 33 | 34 | This command captures your Apple ID username and password and store them securely in AWS Secrets Manager. 35 | It allows this command to authenticate automatically, as long as no MFA is prompted. 36 | 37 | """ 38 | ) 39 | 40 | guard 41 | let username = self.env.readLine.readLine( 42 | prompt: "⌨️ Enter your Apple ID username: ", 43 | silent: false 44 | ) 45 | else { 46 | throw CLIError.invalidInput 47 | } 48 | 49 | guard 50 | let password = self.env.readLine.readLine( 51 | prompt: "⌨️ Enter your Apple ID password: ", 52 | silent: true 53 | ) 54 | else { 55 | throw CLIError.invalidInput 56 | } 57 | 58 | return [username, password] 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/XcodeInstallCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XcodeInstall.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | import Logging 11 | 12 | @MainActor 13 | final class XCodeInstall { 14 | 15 | let log: Logger 16 | var env: Environment 17 | 18 | public init(log: Logger = Log.defaultLogger(), env: Environment) { 19 | self.log = log 20 | self.env = env 21 | } 22 | 23 | // display a message to the user 24 | // avoid having to replicate the \n torough the code 25 | func display(_ msg: String, terminator: String = "\n") { 26 | self.env.display.display(msg, terminator: terminator) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | Coverage - (76.7%)% 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Coverage 21 | 22 | 23 | 24 | 25 | 28 | 29 | 77% 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/DownloadDelegateTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadDelegateTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 23/08/2022. 6 | // 7 | 8 | import Logging 9 | import XCTest 10 | 11 | @testable import xcodeinstall 12 | 13 | @MainActor 14 | class DownloadDelegateTest: XCTestCase { 15 | 16 | let env = MockedEnvironment() 17 | 18 | func testDownloadDelegateCompleteTransfer() async { 19 | 20 | // given 21 | let testData = "test data" 22 | let sema = MockedDispatchSemaphore() 23 | let fileHandler = env.fileHandler 24 | (fileHandler as! MockedFileHandler).nextFileExist = true 25 | 26 | var srcUrl: URL = FileHandler().baseFilePath() 27 | srcUrl.appendPathComponent("xcodeinstall.source.test") 28 | var dstUrl: URL = FileHandler().baseFilePath() 29 | dstUrl.appendPathComponent("xcodeinstall.destination.test") 30 | 31 | do { 32 | try testData.data(using: .utf8)?.write(to: srcUrl) 33 | let delegate = DownloadDelegate(env: env, dstFilePath: dstUrl, semaphore: sema) 34 | 35 | // when 36 | await delegate.completeTransfer(from: srcUrl) 37 | 38 | } catch { 39 | XCTAssert(false, "Unexpected error : \(error)") 40 | } 41 | 42 | // then 43 | 44 | // destination file exists 45 | XCTAssert(fileHandler.fileExists(file: dstUrl, fileSize: 0)) 46 | 47 | // semaphore is calles 48 | XCTAssert(sema.wasSignalCalled()) 49 | 50 | // progress completed 51 | XCTAssertTrue((env.progressBar as! MockedProgressBar).isComplete) 52 | 53 | } 54 | 55 | func testDownloadDelegateUpdate() async { 56 | 57 | // given 58 | let sema = MockedDispatchSemaphore() 59 | let delegate = DownloadDelegate( 60 | env: env, 61 | totalFileSize: 1 * 1024 * 1024 * 1024, // 1 Gb 62 | startTime: Date.init(timeIntervalSinceNow: -60), // one minute ago 63 | semaphore: sema 64 | ) 65 | 66 | // when 67 | await delegate.updateTransfer(totalBytesWritten: 500 * 1024 * 1024) // 500 MB downloaded 68 | 69 | // then 70 | let mockedProgressBar = env.progressBar as! MockedProgressBar 71 | XCTAssertEqual("500 MB / 8.33 MBs", mockedProgressBar.text) 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/DownloadTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import xcodeinstall 5 | 6 | import Foundation 7 | 8 | #if canImport(FoundationNetworking) 9 | import FoundationNetworking 10 | #endif 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #endif 15 | 16 | // MARK: - Download Tests 17 | @MainActor 18 | @Suite("DownloadTests") 19 | final class DownloadTests { 20 | 21 | // MARK: - Test Environment 22 | var client: HTTPClient! 23 | var env: MockedEnvironment 24 | 25 | init() async throws { 26 | // Setup environment for each test 27 | self.env = MockedEnvironment() 28 | self.env.secrets = MockedSecretsHandler(env: &self.env) 29 | self.client = HTTPClient(env: env) 30 | try await env.secrets!.clearSecrets() 31 | } 32 | 33 | // MARK: - Helper Methods 34 | func getAppleDownloader() -> AppleDownloader { 35 | AppleDownloader(env: self.env) 36 | } 37 | 38 | func setNextURLSessionDownloadTask() { 39 | let mockedURLSession = env.urlSessionDownload() as? MockedURLSession 40 | #expect(mockedURLSession != nil) 41 | 42 | mockedURLSession!.nextURLSessionDownloadTask = MockedURLSessionDownloadTask() 43 | self.env.urlSessionData = mockedURLSession! 44 | } 45 | 46 | func setSessionData(data: Data?, response: HTTPURLResponse?) { 47 | #expect(self.env.urlSessionData as? MockedURLSession != nil) 48 | (self.env.urlSessionData as? MockedURLSession)?.nextData = data 49 | (self.env.urlSessionData as? MockedURLSession)?.nextResponse = response 50 | 51 | } 52 | } 53 | 54 | // MARK: - Test Cases 55 | @MainActor 56 | extension DownloadTests { 57 | 58 | @Test("Test Download Delegate Exists") 59 | func testHasDownloadDelegate() { 60 | // Given 61 | let sessionDownload = env.urlSessionDownload() 62 | 63 | // When 64 | let delegate = sessionDownload.downloadDelegate() 65 | 66 | // Then 67 | #expect(delegate != nil) 68 | } 69 | 70 | @Test("Test Download Process") 71 | func testDownload() async throws { 72 | // Given 73 | setNextURLSessionDownloadTask() 74 | 75 | // When 76 | let file: DownloadList.File = DownloadList.File( 77 | filename: "file.test", 78 | displayName: "File Test", 79 | remotePath: "/file.test", 80 | fileSize: 100, 81 | sortOrder: 1, 82 | dateCreated: "31/01/2022", 83 | dateModified: "30/03/2022", 84 | fileFormat: DownloadList.FileFormat(fileExtension: "xip", description: "xip encryption"), 85 | existInCache: false 86 | ) 87 | let ad = getAppleDownloader() 88 | let result = try await ad.download(file: file) 89 | 90 | // Then 91 | #expect(result != nil) 92 | 93 | // Verify if resume was called 94 | if let task = result as? MockedURLSessionDownloadTask { 95 | #expect(task.wasResumeCalled) 96 | } else { 97 | Issue.record("Error in test implementation, the return value must be a MockURLSessionDownloadTask") 98 | } 99 | 100 | // Verify if semaphore wait() was called 101 | if let sema = env.urlSessionDownload().downloadDelegate()?.sema as? MockedDispatchSemaphore { 102 | #expect(sema.wasWaitCalled()) 103 | } else { 104 | Issue.record( 105 | "Error in test implementation, the download delegate sema must be a MockDispatchSemaphore" 106 | ) 107 | } 108 | } 109 | 110 | @Test("Test Download with Invalid File Path") 111 | func testDownloadInvalidFile1() async throws { 112 | // Given 113 | setNextURLSessionDownloadTask() 114 | 115 | // When 116 | let file: DownloadList.File = DownloadList.File( 117 | filename: "file.test", 118 | displayName: "File Test", 119 | remotePath: "", // Empty path 120 | fileSize: 100, 121 | sortOrder: 1, 122 | dateCreated: "31/01/2022", 123 | dateModified: "30/03/2022", 124 | fileFormat: DownloadList.FileFormat(fileExtension: "xip", description: "xip encryption"), 125 | existInCache: false 126 | ) 127 | let ad = getAppleDownloader() 128 | 129 | // Then 130 | let error = await #expect(throws: DownloadError.self) { 131 | _ = try await ad.download(file: file) 132 | } 133 | #expect(error == DownloadError.invalidFileSpec) 134 | } 135 | 136 | @Test("Test Download with Invalid File Name") 137 | func testDownloadInvalidFile2() async throws { 138 | // Given 139 | setNextURLSessionDownloadTask() 140 | 141 | // When 142 | let file: DownloadList.File = DownloadList.File( 143 | filename: "", // Empty filename 144 | displayName: "File Test", 145 | remotePath: "/file.test", 146 | fileSize: 100, 147 | sortOrder: 1, 148 | dateCreated: "31/01/2022", 149 | dateModified: "30/03/2022", 150 | fileFormat: DownloadList.FileFormat(fileExtension: "xip", description: "xip encryption"), 151 | existInCache: false 152 | ) 153 | let ad = getAppleDownloader() 154 | 155 | // Then 156 | let error = await #expect(throws: DownloadError.self) { 157 | _ = try await ad.download(file: file) 158 | } 159 | #expect(error == DownloadError.invalidFileSpec) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/HTTPClientTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import xcodeinstall 5 | 6 | import Foundation 7 | 8 | #if canImport(FoundationNetworking) 9 | import FoundationNetworking 10 | #endif 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #endif 15 | 16 | // MARK: - Test Suite Setup 17 | @MainActor 18 | struct HTTPClientTests { 19 | 20 | // MARK: - Test Environment 21 | var sessionData: MockedURLSession! 22 | var sessionDownload: MockedURLSession! 23 | var client: HTTPClient! 24 | var delegate: DownloadDelegate! 25 | var env: MockedEnvironment 26 | 27 | init() async throws { 28 | // Setup environment for each test 29 | self.env = MockedEnvironment() 30 | self.env.secrets = MockedSecretsHandler(env: &self.env) 31 | self.sessionData = env.urlSessionData as? MockedURLSession 32 | self.sessionDownload = env.urlSessionDownload() as? MockedURLSession 33 | self.client = HTTPClient(env: env) 34 | try await env.secrets!.clearSecrets() 35 | } 36 | 37 | // MARK: - Helper Methods 38 | func getAppleSession() -> AppleSession { 39 | AppleSession( 40 | itcServiceKey: AppleServiceKey(authServiceUrl: "url", authServiceKey: "key"), 41 | xAppleIdSessionId: "x_apple_id_session_id", 42 | scnt: "scnt", 43 | hashcash: "hashcash" 44 | ) 45 | } 46 | } 47 | 48 | // MARK: - Test Cases 49 | @MainActor 50 | extension HTTPClientTests { 51 | 52 | @Test("Test HTTP Request Creation") 53 | func testRequest() async throws { 54 | let url = "https://test.com/path" 55 | let username = "username" 56 | let password = "password" 57 | 58 | let headers = [ 59 | "Header1": "value1", 60 | "Header2": "value2", 61 | ] 62 | let body = try JSONEncoder().encode(User(accountName: username, password: password)) 63 | let request = client.request( 64 | for: url, 65 | method: .POST, 66 | withBody: body, 67 | withHeaders: headers 68 | ) 69 | 70 | // Test URL 71 | #expect(request.url?.debugDescription == url) 72 | 73 | // Test method 74 | #expect(request.httpMethod == "POST") 75 | 76 | // Test body 77 | #expect(request.httpBody != nil) 78 | let user = try JSONDecoder().decode(User.self, from: request.httpBody!) 79 | #expect(user.accountName == username) 80 | #expect(user.password == password) 81 | 82 | // Test headers 83 | #expect(request.allHTTPHeaderFields != nil) 84 | #expect(request.allHTTPHeaderFields?.count == 2) 85 | #expect(request.allHTTPHeaderFields?["Header1"] == "value1") 86 | #expect(request.allHTTPHeaderFields?["Header2"] == "value2") 87 | } 88 | 89 | @Test("Test Password Obfuscation in Logs") 90 | func testPasswordObfuscation() async throws { 91 | // Given 92 | let username = "username" 93 | let password = "myComplexPassw0rd!" 94 | let body = try JSONEncoder().encode(User(accountName: username, password: password)) 95 | let str = String(data: body, encoding: .utf8) 96 | #expect(str != nil) 97 | 98 | // When 99 | let obfuscated = filterPassword(str!) 100 | 101 | // Then 102 | #expect(str != obfuscated) 103 | #expect(!obfuscated.contains(password)) 104 | } 105 | 106 | @Test("Test Data Request URL") 107 | func testDataRequestsTheURL() async throws { 108 | // Given 109 | let url = "http://dummy" 110 | 111 | self.sessionData.nextData = Data() 112 | // Create a mock URLResponse that works on both platforms 113 | self.sessionData.nextResponse = URLResponse(url: URL(string: "http://dummy")!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) 114 | 115 | // When 116 | let request = client.request(for: url) 117 | _ = try await self.sessionData.data(for: request, delegate: nil) 118 | 119 | // Then 120 | #expect(self.sessionData.lastURL?.debugDescription == url) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/MockedNetworkClasses.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedNetworkClasses.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 21/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | @testable import xcodeinstall 12 | 13 | // import Synchronization 14 | 15 | #if canImport(FoundationNetworking) 16 | import FoundationNetworking 17 | #endif 18 | 19 | // mocked URLSessionDownloadTask 20 | @MainActor 21 | final class MockedURLSessionDownloadTask: URLSessionDownloadTaskProtocol { 22 | 23 | var wasResumeCalled: Bool = false 24 | func resume() { 25 | wasResumeCalled = true 26 | } 27 | } 28 | 29 | // mocked URLSession to be used during test 30 | @MainActor 31 | final class MockedURLSession: URLSessionProtocol { 32 | 33 | private(set) var lastURL: URL? 34 | private(set) var lastRequest: URLRequest? 35 | 36 | var nextData: Data? 37 | var nextError: Error? 38 | var nextResponse: URLResponse? 39 | 40 | var nextURLSessionDownloadTask: URLSessionDownloadTaskProtocol? 41 | 42 | func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) { 43 | 44 | guard let data = nextData, 45 | let response = nextResponse 46 | else { 47 | throw MockError.invalidMockData 48 | } 49 | 50 | lastURL = request.url 51 | lastRequest = request 52 | 53 | if nextError != nil { 54 | throw nextError! 55 | } 56 | 57 | return (data, response) 58 | } 59 | 60 | func downloadTask(with request: URLRequest) throws -> URLSessionDownloadTaskProtocol { 61 | 62 | guard let downloadTask = nextURLSessionDownloadTask else { 63 | throw MockError.invalidMockData 64 | } 65 | 66 | lastURL = request.url 67 | lastRequest = request 68 | 69 | if nextError != nil { 70 | throw nextError! 71 | } 72 | 73 | return downloadTask 74 | } 75 | 76 | var delegate: DownloadDelegate? 77 | func downloadDelegate() -> DownloadDelegate? { 78 | if delegate == nil { 79 | delegate = DownloadDelegate( 80 | env: MockedEnvironment(), 81 | semaphore: MockedDispatchSemaphore() 82 | ) 83 | } 84 | return delegate 85 | } 86 | } 87 | 88 | @MainActor 89 | final class MockedAppleAuthentication: AppleAuthenticatorProtocol { 90 | 91 | var nextError: AuthenticationError? 92 | var nextMFAError: AuthenticationError? 93 | var session: AppleSession? 94 | 95 | func startAuthentication( 96 | with authenticationMethod: AuthenticationMethod, 97 | username: String, 98 | password: String 99 | ) async throws { 100 | 101 | if let nextError { 102 | throw nextError 103 | } 104 | 105 | } 106 | func signout() async throws {} 107 | func handleTwoFactorAuthentication() async throws -> Int { 108 | if let nextMFAError { 109 | throw nextMFAError 110 | } 111 | return 6 112 | } 113 | func twoFactorAuthentication(pin: String) async throws {} 114 | } 115 | 116 | struct MockedAppleDownloader: AppleDownloaderProtocol { 117 | var sema: DispatchSemaphoreProtocol = MockedDispatchSemaphore() 118 | var downloadDelegate: DownloadDelegate? 119 | 120 | func delegate() -> DownloadDelegate { 121 | self.downloadDelegate! 122 | } 123 | func list(force: Bool) async throws -> DownloadList { 124 | let listData = try loadTestData(file: .downloadList) 125 | let list: DownloadList = try JSONDecoder().decode(DownloadList.self, from: listData) 126 | 127 | guard let _ = list.downloads else { 128 | throw MockError.invalidMockData 129 | } 130 | return list 131 | } 132 | func download(file: DownloadList.File) async throws -> URLSessionDownloadTaskProtocol? { 133 | // should create a file with matching size 134 | let dlt = MockedURLSessionDownloadTask() 135 | return dlt 136 | } 137 | } 138 | 139 | @MainActor 140 | final class MockedDispatchSemaphore: DispatchSemaphoreProtocol { 141 | var _wasWaitCalled = false 142 | var _wasSignalCalled = false 143 | 144 | // reset flag when called 145 | func wasWaitCalled() -> Bool { 146 | let wwc = _wasWaitCalled 147 | _wasWaitCalled = false 148 | return wwc 149 | } 150 | 151 | // reset flag when called 152 | func wasSignalCalled() -> Bool { 153 | let wsc = _wasSignalCalled 154 | _wasSignalCalled = false 155 | return wsc 156 | } 157 | 158 | func wait() { _wasWaitCalled = true } 159 | func signal() -> Int { 160 | _wasSignalCalled = true 161 | return 0 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/SRPTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Test.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 26/10/2024. 6 | // 7 | 8 | import Crypto 9 | import Foundation 10 | import Testing 11 | 12 | @testable import SRP 13 | @testable import xcodeinstall 14 | 15 | @Suite("SRPKeysTestCase") 16 | struct SRPKeysTestCase { 17 | @Test func base64() async throws { 18 | // given 19 | let keyRawMaterial: [UInt8] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 20 | let key = SRPKey(keyRawMaterial) 21 | #expect(key.bytes == keyRawMaterial) 22 | 23 | // when 24 | let b64Key = key.base64 25 | let newKey = SRPKey(base64: b64Key) 26 | 27 | // then 28 | #expect(newKey != nil) 29 | #expect(newKey?.bytes == keyRawMaterial) 30 | } 31 | 32 | @Test func stringToUInt8Array() async throws { 33 | // given 34 | let s = "Hello World" 35 | 36 | // when 37 | let a = s.array 38 | 39 | // then 40 | #expect(a.count == s.count) 41 | #expect(a[5] == 32) //space character 42 | } 43 | 44 | @Test func hashcash1() async throws { 45 | // given 46 | let hcBits = 11 47 | let hcChallenge = "4d74fb15eb23f465f1f6fcbf534e5877" 48 | 49 | // when 50 | let hashcash = Hashcash.make(bits: hcBits, challenge: hcChallenge, date: "20230223170600") 51 | 52 | // then 53 | #expect(hashcash == "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373") 54 | } 55 | 56 | @Test func hashcash2() async throws { 57 | // given 58 | let hcBits = 10 59 | let hcChallenge = "bb63edf88d2f9c39f23eb4d6f0281158" 60 | 61 | // when 62 | let hashcash = Hashcash.make(bits: hcBits, challenge: hcChallenge, date: "20230224001754") 63 | 64 | // then 65 | #expect(hashcash == "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866") 66 | } 67 | 68 | @Test func sha1() { 69 | // given 70 | let hc = "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373" 71 | 72 | // when 73 | let sha1 = Insecure.SHA1.hash(data: Array(hc.data(using: .utf8)!)) 74 | 75 | // then 76 | // [UInt8].hexdigest() coming from Swift-SRP 77 | #expect(sha1.hexDigest().lowercased() == "001CC13831C63CA2E739DBCF47BDD4597535265F".lowercased()) 78 | 79 | } 80 | 81 | @Test( 82 | "PBKDF2 for S2K and S2K_FO authentication protocol", 83 | arguments: [ 84 | SRPProtocol.s2k, SRPProtocol.s2k_fo, 85 | ] 86 | ) 87 | func pbkdf2(srpProtocol: SRPProtocol) throws { 88 | 89 | // given 90 | let password = "password" 91 | let salt = "pLG+B7bChHWevylEQapMfQ==" 92 | let iterations = 20136 93 | let keyLength = 32 94 | 95 | // convert salt from base64 to [UInt8] 96 | let saltData = Data(base64Encoded: salt)! 97 | 98 | //given 99 | #expect(throws: Never.self) { 100 | let derivedKey = try PBKDF2.pbkdf2( 101 | password: password, 102 | salt: [UInt8](saltData), 103 | iterations: iterations, 104 | keyLength: keyLength, 105 | srpProtocol: srpProtocol 106 | ) 107 | // print(derivedKey.hexdigest().lowercased()) 108 | 109 | // then 110 | let hexResult: String 111 | switch srpProtocol { 112 | case .s2k: 113 | hexResult = "d7ff78163a0183db1e635ba5beaf4a45f7984b00aafec95e6a044fda331bbd45" 114 | case .s2k_fo: 115 | // TODO: verify this result is correct 116 | hexResult = "858f8ba24e48af6f9cd5c8d4738827eb91340b6901fc5e47ee0d73e3346b502a" 117 | } 118 | #expect(derivedKey.hexdigest().lowercased() == hexResult) 119 | } 120 | } 121 | 122 | @Test func hexString() { 123 | let bytes: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] 124 | 125 | let hexString = bytes.hexdigest() //hexdigest provided by Swift-SRP 126 | 127 | #expect(hexString.uppercased() == "000102030405060708090A0B0C0D0E0F") 128 | } 129 | } 130 | 131 | class DateFormatterMock: DateFormatter, @unchecked Sendable { 132 | override func string(from: Date) -> String { 133 | "20230223170600" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/URLRequestCurlTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequestCurlTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 04/08/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | import Foundation 13 | #if canImport(FoundationNetworking) 14 | import FoundationNetworking 15 | #endif 16 | 17 | @MainActor 18 | struct URLRequestCurlTest { 19 | 20 | var agent: HTTPClient! 21 | 22 | init() throws { 23 | self.agent = HTTPClient(env: MockedEnvironment()) 24 | } 25 | 26 | @Test("Test URLRequest to cURL") 27 | func testRequestToCurl() throws { 28 | 29 | //given 30 | let url = "https://dummy.com" 31 | var headers = [ 32 | "Header1": "value1", 33 | "Header2": "value2", 34 | ] 35 | let data = "test data".data(using: .utf8) 36 | let cookie = HTTPCookie(properties: [.name: "cookieName", .value: "cookieValue", .path: "/", .originURL: URL(string: url)!]) 37 | if let cookie { 38 | headers.merge(HTTPCookie.requestHeaderFields(with: [cookie])) { (current, _) in current } 39 | } 40 | 41 | // when 42 | let request = agent.request(for: url, method: .GET, withBody: data, withHeaders: headers) 43 | let curl = request.cURL(pretty: false) 44 | 45 | // then 46 | #expect(curl.starts(with: "curl ")) 47 | #expect(curl.contains("-H 'Header1: value1'")) 48 | #expect(curl.contains("-H 'Header2: value2'")) 49 | #expect(curl.contains("-H 'Cookie: cookieName=cookieValue'")) 50 | #expect(curl.contains("-X GET 'https://dummy.com'")) 51 | #expect(curl.contains("--data 'test data'")) 52 | 53 | } 54 | 55 | @Test("Test URLRequest to cURL pretty print") 56 | func testRequestToCurlPrettyPrint() throws { 57 | 58 | //given 59 | let url = "https://dummy.com" 60 | var headers = [ 61 | "Header1": "value1", 62 | "Header2": "value2", 63 | ] 64 | let data = "test data".data(using: .utf8) 65 | let cookie = HTTPCookie(properties: [.name: "cookieName", .value: "cookieValue", .path: "/", .originURL: URL(string: url)!]) 66 | if let cookie { 67 | headers.merge(HTTPCookie.requestHeaderFields(with: [cookie])) { (current, _) in current } 68 | } 69 | 70 | // when 71 | let request = agent.request(for: url, method: .GET, withBody: data, withHeaders: headers) 72 | let curl = request.cURL(pretty: true) 73 | 74 | // then 75 | #expect(curl.starts(with: "curl ")) 76 | #expect(curl.contains("--header 'Header1: value1'")) 77 | #expect(curl.contains("--header 'Header2: value2'")) 78 | #expect(curl.contains("--header 'Cookie: cookieName=cookieValue'")) 79 | #expect(curl.contains("--request GET")) 80 | #expect(curl.contains("--url 'https://dummy.com'")) 81 | #expect(curl.contains("--data 'test data'")) 82 | 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/CLIDownloadTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIDownloadTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 22/08/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Testing 10 | 11 | @testable import xcodeinstall 12 | 13 | @MainActor 14 | extension CLITests { 15 | 16 | @Test("Test Download") 17 | func testDownload() async throws { 18 | 19 | // given 20 | 21 | let mockedRL = MockedReadLine(["0"]) 22 | let mockedFH = MockedFileHandler() 23 | mockedFH.nextFileCorrect = true 24 | let env = MockedEnvironment(fileHandler: mockedFH, readLine: mockedRL) 25 | 26 | let download = try parse( 27 | MainCommand.Download.self, 28 | [ 29 | "download", 30 | "--verbose", 31 | "--force", 32 | "--only-xcode", 33 | "--xcode-version", 34 | "14", 35 | "--most-recent-first", 36 | "--date-published", 37 | ] 38 | ) 39 | 40 | // when 41 | await #expect(throws: Never.self) { 42 | let xci = XCodeInstall(env: env) 43 | try await xci.download( 44 | fileName: nil, 45 | force: true, 46 | xCodeOnly: true, 47 | majorVersion: "14", 48 | sortMostRecentFirst: true, 49 | datePublished: true 50 | ) 51 | } 52 | 53 | // test parsing of commandline arguments 54 | #expect(download.globalOptions.verbose) 55 | #expect(download.downloadListOptions.force) 56 | #expect(download.downloadListOptions.onlyXcode) 57 | #expect(download.downloadListOptions.xCodeVersion == "14") 58 | #expect(download.downloadListOptions.mostRecentFirst) 59 | #expect(download.downloadListOptions.datePublished) 60 | 61 | // verify if progressbar define() was called 62 | if let progress = env.progressBar as? MockedProgressBar { 63 | #expect(progress.defineCalled()) 64 | } else { 65 | Issue.record("Error in test implementation, the env.progressBar must be a MockedProgressBar") 66 | } 67 | 68 | // mocked list succeeded 69 | assertDisplay(env: env, "✅ file downloaded") 70 | } 71 | 72 | @Test("Test Download with correct file name") 73 | func testDownloadWithCorrectFileName() async throws { 74 | 75 | // given 76 | (env.fileHandler as! MockedFileHandler).nextFileCorrect = true 77 | let fileName = "Xcode 14.xip" 78 | 79 | let download = try parse( 80 | MainCommand.Download.self, 81 | [ 82 | "download", 83 | "--name", 84 | fileName, 85 | ] 86 | ) 87 | 88 | // when 89 | await #expect(throws: Never.self) { 90 | try await download.run(with: env) 91 | } 92 | 93 | // test parsing of commandline arguments 94 | #expect(download.name == fileName) 95 | #expect(!download.globalOptions.verbose) 96 | #expect(!download.downloadListOptions.force) 97 | #expect(!download.downloadListOptions.onlyXcode) 98 | #expect(!download.downloadListOptions.mostRecentFirst) 99 | #expect(!download.downloadListOptions.datePublished) 100 | 101 | // mocked list succeeded 102 | assertDisplay("✅ \(fileName) downloaded") 103 | } 104 | 105 | @Test("Test Download with incorrect file name") 106 | func testDownloadWithIncorrectFileName() async throws { 107 | 108 | // given 109 | (env.fileHandler as! MockedFileHandler).nextFileCorrect = false 110 | let fileName = "xxx.xip" 111 | 112 | let download = try parse( 113 | MainCommand.Download.self, 114 | [ 115 | "download", 116 | "--name", 117 | fileName, 118 | ] 119 | ) 120 | 121 | // when 122 | await #expect(throws: Never.self) { 123 | try await download.run(with: env) 124 | } 125 | 126 | // test parsing of commandline arguments 127 | #expect(download.name == fileName) 128 | #expect(!download.globalOptions.verbose) 129 | #expect(!download.downloadListOptions.force) 130 | #expect(!download.downloadListOptions.onlyXcode) 131 | #expect(!download.downloadListOptions.mostRecentFirst) 132 | #expect(!download.downloadListOptions.datePublished) 133 | 134 | // mocked list succeeded 135 | assertDisplay("🛑 Unknown file name : xxx.xip") 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/CLIInstallTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIInstallTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 22/08/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | @MainActor 13 | extension CLITests { 14 | 15 | @Test("Test Install Command") 16 | func testInstall() async throws { 17 | 18 | // given 19 | let inst = try parse( 20 | MainCommand.Install.self, 21 | [ 22 | "install", 23 | "--verbose", 24 | "--name", 25 | "test.xip", 26 | ] 27 | ) 28 | 29 | // when 30 | await #expect(throws: Never.self) { try await inst.run(with: env) } 31 | 32 | // test parsing of commandline arguments 33 | #expect(inst.globalOptions.verbose) 34 | #expect(inst.name == "test.xip") 35 | 36 | // verify if progressbar define() was called 37 | if let progress = env.progressBar as? MockedProgressBar { 38 | #expect(progress.defineCalled()) 39 | } else { 40 | Issue.record("Error in test implementation, the env.progressBar must be a MockedProgressBar") 41 | } 42 | } 43 | 44 | @Test("Test Install Command with no name") 45 | func testPromptForFile() { 46 | 47 | // given 48 | let env: MockedEnvironment = MockedEnvironment(readLine: MockedReadLine(["0"])) 49 | let xci = XCodeInstall(env: env) 50 | 51 | // when 52 | do { 53 | let result = try xci.promptForFile() 54 | 55 | // then 56 | #expect(result.lastPathComponent.hasSuffix("name.dmg")) 57 | 58 | } catch { 59 | // then 60 | Issue.record("unexpected exception : \(error)") 61 | } 62 | 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/CLIListTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CliListTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 22/08/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | @MainActor 13 | extension CLITests { 14 | 15 | @Test("Test List Command") 16 | func testList() async throws { 17 | 18 | // given 19 | let list = try parse( 20 | MainCommand.List.self, 21 | [ 22 | "list", 23 | "--verbose", 24 | "--force", 25 | "--only-xcode", 26 | "--xcode-version", 27 | "14", 28 | "--most-recent-first", 29 | "--date-published", 30 | ] 31 | ) 32 | 33 | // when 34 | 35 | await #expect(throws: Never.self) { try await list.run(with: env) } 36 | 37 | // test parsing of commandline arguments 38 | #expect(list.globalOptions.verbose) 39 | #expect(list.downloadListOptions.force) 40 | #expect(list.downloadListOptions.onlyXcode) 41 | #expect(list.downloadListOptions.xCodeVersion == "14") 42 | #expect(list.downloadListOptions.mostRecentFirst) 43 | #expect(list.downloadListOptions.datePublished) 44 | 45 | // mocked list succeeded 46 | assertDisplay("16 items") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/CLIStoreSecretsTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLIStoreSecrets.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 15/09/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | @MainActor 13 | extension CLITests { 14 | 15 | // on CI Linux, there is no AWS crednetials configured 16 | // this test throws "No credential provider found" of type CredentialProviderError 17 | #if os(macOS) 18 | @Test("Test Store Secrets") 19 | func testStoreSecrets() async throws { 20 | 21 | // given 22 | let mockedRL = MockedReadLine(["username", "password"]) 23 | var env: Environment = MockedEnvironment(readLine: mockedRL) 24 | // use the real AWS Secrets Handler, but with a mocked SDK 25 | let mcokedSDK = try MockedAWSSecretsHandlerSDK.forRegion("us-east-1") 26 | let secretsHandler = try AWSSecretsHandler(env: env, sdk: mcokedSDK) 27 | env.secrets = secretsHandler 28 | 29 | let storeSecrets = try parse( 30 | MainCommand.StoreSecrets.self, 31 | [ 32 | "storesecrets", 33 | "-s", "us-east-1", 34 | "--verbose", 35 | ] 36 | ) 37 | 38 | // when 39 | await #expect(throws: Never.self) { try await storeSecrets.run(with: env) } 40 | 41 | // test parsing of commandline arguments 42 | #expect(storeSecrets.globalOptions.verbose) 43 | 44 | //FIXME : can't do that here - because the mocked secret handler just has a copy of the env, 45 | //it can not modify the env we have here 46 | 47 | // did we call setRegion on the SDK class ? 48 | #expect((secretsHandler.awsSDK as? MockedAWSSecretsHandlerSDK)?.regionSet() ?? false) 49 | } 50 | #endif 51 | 52 | func testPromptForCredentials() { 53 | 54 | // given 55 | let mockedRL = MockedReadLine(["username", "password"]) 56 | let env = MockedEnvironment(readLine: mockedRL) 57 | let xci = XCodeInstall(env: env) 58 | 59 | // when 60 | do { 61 | let result = try xci.promptForCredentials() 62 | 63 | // then 64 | #expect(result.count == 2) 65 | #expect(result[0] == "username") 66 | #expect(result[1] == "password") 67 | 68 | } catch { 69 | // then 70 | Issue.record("unexpected exception : \(error)") 71 | } 72 | 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/CLITests.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import CLIlib 3 | import Foundation 4 | import Testing 5 | 6 | @testable import xcodeinstall 7 | 8 | // MARK: - CLI Tests Base 9 | @MainActor 10 | struct CLITests { 11 | 12 | // MARK: - Test Environment 13 | // some tests might override the environment with more specialized mocks. 14 | var env = MockedEnvironment() 15 | var secretsHandler: SecretsHandlerProtocol! 16 | 17 | init() async throws { 18 | self.secretsHandler = MockedSecretsHandler(env: &env) 19 | try await self.secretsHandler.clearSecrets() 20 | } 21 | 22 | // MARK: - Helper Methods 23 | func parse(_ type: A.Type, _ arguments: [String]) throws -> A where A: AsyncParsableCommand { 24 | try MainCommand.parseAsRoot(arguments) as! A 25 | } 26 | 27 | func assertDisplay(env: Environment, _ msg: String) { 28 | let actual = (env.display as! MockedDisplay).string 29 | #expect(actual == "\(msg)\n") 30 | } 31 | 32 | func assertDisplay(_ msg: String) { 33 | assertDisplay(env: self.env, msg) 34 | } 35 | 36 | func assertDisplayStartsWith(env: Environment, _ msg: String) { 37 | let actual = (env.display as! MockedDisplay).string 38 | #expect(actual.starts(with: msg)) 39 | } 40 | 41 | func assertDisplayStartsWith(_ msg: String) { 42 | assertDisplayStartsWith(env: self.env, msg) 43 | } 44 | } 45 | 46 | // MARK: - Basic CLI Tests 47 | @MainActor 48 | extension CLITests { 49 | 50 | @Test("Test CLI Display Assertion") 51 | func testDisplayAssertion() { 52 | // Given 53 | let testMessage = "Test message" 54 | 55 | // When 56 | (env.display as! MockedDisplay).string = "\(testMessage)\n" 57 | 58 | // Then 59 | assertDisplay(testMessage) 60 | } 61 | 62 | @Test("Test CLI Display Starts With Assertion") 63 | func testDisplayStartsWithAssertion() { 64 | // Given 65 | let testPrefix = "Test prefix" 66 | let fullMessage = "Test prefix with additional content" 67 | 68 | // When 69 | (env.display as! MockedDisplay).string = fullMessage 70 | 71 | // Then 72 | assertDisplayStartsWith(testPrefix) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/MockedCLIClasses.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 15/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | @testable import xcodeinstall 12 | 13 | // 14 | // CLI Testing 15 | // 16 | 17 | // mocked display (use class because string is mutating) 18 | @MainActor 19 | final class MockedDisplay: DisplayProtocol { 20 | var string: String = "" 21 | 22 | func display(_ msg: String, terminator: String) { 23 | self.string = msg + terminator 24 | } 25 | } 26 | 27 | // mocked read line 28 | @MainActor 29 | final class MockedReadLine: ReadLineProtocol { 30 | 31 | var input: [String] = [] 32 | 33 | init() {} 34 | init(_ input: [String]) { 35 | self.input = input.reversed() 36 | } 37 | 38 | func readLine(prompt: String, silent: Bool = false) -> String? { 39 | guard input.count > 0 else { 40 | fatalError("mocked not correctly initialized") 41 | } 42 | return input.popLast() 43 | } 44 | } 45 | 46 | enum MockError: Error { 47 | case invalidMockData 48 | } 49 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/EnvironmentMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentMock.swift 3 | // 4 | // 5 | // Created by Stormacq, Sebastien on 22/11/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | @testable import Subprocess // to be able to call internal init() functions 11 | #if canImport(System) 12 | import System 13 | #else 14 | import SystemPackage 15 | #endif 16 | 17 | @testable import xcodeinstall 18 | 19 | struct MockedEnvironment: xcodeinstall.Environment { 20 | 21 | var fileHandler: FileHandlerProtocol = MockedFileHandler() 22 | 23 | var display: DisplayProtocol = MockedDisplay() 24 | var readLine: ReadLineProtocol = MockedReadLine() 25 | var progressBar: CLIProgressBarProtocol = MockedProgressBar() 26 | 27 | // this has to be injected by the caller (it contains a reference to the env 28 | var secrets: SecretsHandlerProtocol? = nil 29 | var awsSDK: AWSSecretsHandlerSDKProtocol? = nil 30 | 31 | var authenticator: AppleAuthenticatorProtocol = MockedAppleAuthentication() 32 | var downloader: AppleDownloaderProtocol = MockedAppleDownloader() 33 | 34 | var urlSessionData: URLSessionProtocol = MockedURLSession() 35 | 36 | func urlSessionDownload( 37 | dstFilePath: URL? = nil, 38 | totalFileSize: Int? = nil, 39 | startTime: Date? = nil 40 | ) -> any xcodeinstall.URLSessionProtocol { 41 | self.urlSessionData 42 | } 43 | 44 | } 45 | 46 | @MainActor 47 | final class MockedRunRecorder: InputProtocol, OutputProtocol { 48 | func write(with writer: Subprocess.StandardInputWriter) async throws { 49 | 50 | } 51 | 52 | var lastExecutable: Executable? 53 | var lastArguments: Arguments = [] 54 | 55 | func containsExecutable(_ command: String) -> Bool { 56 | lastExecutable?.description.contains(command) ?? false 57 | } 58 | func containsArgument(_ argument: String) -> Bool { 59 | lastArguments.description.contains(argument) 60 | } 61 | func isEmpty() -> Bool { 62 | // print(lastExecutable?.description) 63 | return lastExecutable == nil || lastExecutable?.description.isEmpty == true 64 | } 65 | 66 | } 67 | 68 | extension MockedEnvironment { 69 | static var runRecorder = MockedRunRecorder() 70 | 71 | func run ( 72 | _ executable: Executable, 73 | arguments: Arguments, 74 | ) async throws -> CollectedResult, DiscardedOutput> { 75 | return try await run(executable, 76 | arguments: arguments, 77 | workingDirectory: nil 78 | ) 79 | } 80 | func run ( 81 | _ executable: Executable, 82 | arguments: Arguments, 83 | workingDirectory: FilePath?, 84 | ) async throws -> CollectedResult, DiscardedOutput> { 85 | 86 | MockedEnvironment.runRecorder.lastExecutable = executable 87 | MockedEnvironment.runRecorder.lastArguments = arguments 88 | 89 | // Return a dummy CollectedResult 90 | return CollectedResult( 91 | processIdentifier: ProcessIdentifier(value: 9999), 92 | terminationStatus: TerminationStatus.exited(0), 93 | standardOutput: "mocked output", 94 | standardError: DiscardedOutput.OutputType(), 95 | ) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AWSSecretsHandlerSotoTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 16/09/2022. 6 | // 7 | 8 | import SotoCore 9 | import SotoSecretsManager 10 | import XCTest 11 | 12 | @testable import xcodeinstall 13 | 14 | final class AWSSecretsHandlerSotoTest: XCTestCase { 15 | 16 | var secretHandler: AWSSecretsHandlerSoto? 17 | 18 | override func setUpWithError() throws { 19 | // given 20 | let region = "us-east-1" 21 | 22 | // when 23 | do { 24 | let awsClient = AWSClient( 25 | credentialProvider: TestEnvironment.credentialProvider, 26 | middlewares: TestEnvironment.middlewares, 27 | httpClientProvider: .createNew 28 | ) 29 | let smClient = SecretsManager( 30 | client: awsClient, 31 | endpoint: TestEnvironment.getEndPoint() 32 | ) 33 | 34 | secretHandler = 35 | try AWSSecretsHandlerSoto.forRegion(region, awsClient: awsClient, smClient: smClient) 36 | as? AWSSecretsHandlerSoto 37 | XCTAssertNotNil(secretHandler) 38 | 39 | if TestEnvironment.isUsingLocalstack { 40 | print("Connecting to Localstack") 41 | } else { 42 | print("Connecting to AWS") 43 | } 44 | 45 | // then 46 | // no error 47 | 48 | } catch AWSSecretsHandlerError.invalidRegion(let error) { 49 | XCTAssertEqual(region, error) 50 | } catch { 51 | XCTAssert(false, "unexpected error : \(error)") 52 | } 53 | 54 | } 55 | 56 | func testInitWithCorrectRegion() { 57 | 58 | // given 59 | let region = "us-east-1" 60 | 61 | // when 62 | do { 63 | let _ = try AWSSecretsHandlerSoto.forRegion(region) 64 | 65 | // then 66 | // no error 67 | 68 | } catch AWSSecretsHandlerError.invalidRegion(let error) { 69 | XCTAssert(false, "region rejected : \(error)") 70 | } catch { 71 | XCTAssert(false, "unexpected error : \(error)") 72 | } 73 | } 74 | 75 | func testInitWithIncorrectRegion() { 76 | 77 | // given 78 | let region = "invalid" 79 | 80 | // when 81 | do { 82 | let _ = try AWSSecretsHandlerSoto.forRegion(region) 83 | 84 | // then 85 | // error 86 | XCTAssert(false, "an error must be thrown") 87 | 88 | } catch AWSSecretsHandlerError.invalidRegion(let error) { 89 | XCTAssertEqual(region, error) 90 | } catch { 91 | XCTAssert(false, "unexpected error : \(error)") 92 | } 93 | } 94 | 95 | #if os(macOS) 96 | // [CI] on Linux fails because there is no AWS credentials provider configured 97 | func testCreateSecret() async { 98 | 99 | // given 100 | XCTAssertNotNil(secretHandler) 101 | let credentials = AppleCredentialsSecret(username: "username", password: "password") 102 | 103 | // when 104 | do { 105 | try await secretHandler!.updateSecret(secretId: .appleCredentials, newValue: credentials) 106 | } catch { 107 | XCTAssert(false, "unexpected error : \(error)") 108 | } 109 | 110 | } 111 | #endif 112 | } 113 | 114 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileSecretHandlerTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 05/08/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | struct AWSSecretsHandlerTest { 13 | 14 | var secretHandlerTest: SecretsHandlerTestsBase? 15 | 16 | init() async throws { 17 | 18 | secretHandlerTest = SecretsHandlerTestsBase() 19 | 20 | let AWS_REGION = "us-east-1" 21 | 22 | let env: Environment = await MockedEnvironment() 23 | let mockedSDK = try MockedAWSSecretsHandlerSDK.forRegion(AWS_REGION) 24 | secretHandlerTest!.secrets = try await AWSSecretsHandler(env: env, sdk: mockedSDK) 25 | try await secretHandlerTest!.secrets!.clearSecrets() 26 | } 27 | 28 | @Test("Test Merge Cookies No Conflict") 29 | func testMergeCookiesNoConflict() async throws { 30 | try await secretHandlerTest!.testMergeCookiesNoConflict() 31 | } 32 | 33 | @Test("Test Merge Cookies One Conflict") 34 | func testMergeCookiesOneConflict() async throws { 35 | try await secretHandlerTest!.testMergeCookiesOneConflict() 36 | } 37 | 38 | @Test("Test Load and Save Session") 39 | func testLoadAndSaveSession() async throws { 40 | try await secretHandlerTest!.testLoadAndSaveSession() 41 | } 42 | 43 | @Test("Test Load and Save Cookies") 44 | func testLoadAndSaveCookies() async throws { 45 | try await secretHandlerTest!.testLoadAndSaveCookies() 46 | } 47 | 48 | @Test("Test Load Session No Exist") 49 | func testLoadSessionNoExist() async { 50 | await secretHandlerTest!.testLoadSessionNoExist() 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/AppleSessionSecretTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleSessionSecretTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 15/09/2022. 6 | // 7 | 8 | import XCTest 9 | 10 | @testable import xcodeinstall 11 | 12 | #if canImport(FoundationNetworking) 13 | import FoundationNetworking 14 | #endif 15 | 16 | final class AppleSessionSecretTest: XCTestCase { 17 | 18 | override func setUpWithError() throws { 19 | // Put setup code here. This method is called before the invocation of each test method in the class. 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testFromString() { 27 | 28 | // given and when 29 | let ass = try? AppleSessionSecret( 30 | fromString: 31 | """ 32 | { 33 | "session": { 34 | "scnt":"scnt12345", 35 | "itcServiceKey": { 36 | "authServiceKey":"authServiceKey", 37 | "authServiceUrl":"authServiceUrl" 38 | }, 39 | "xAppleIdSessionId":"sessionid" 40 | }, 41 | "rawCookies":"DSESSIONID=150f81k3; Path=/; Domain=developer.apple.com; Secure; HttpOnly, ADCDownloadAuth=qMa%0D%0A;Version=1;Comment=;Domain=apple.com;Path=/;Max-Age=108000;Secure;HttpOnly;Expires=Fri, 05 Aug 2022 11:58:50 GMT" 42 | } 43 | """ 44 | ) 45 | 46 | // then 47 | XCTAssertNotNil(ass) 48 | 49 | let c = ass?.cookies() 50 | XCTAssertEqual(c?.count, 2) 51 | 52 | let s = ass?.session 53 | XCTAssertNotNil(s) 54 | } 55 | 56 | func testFromObject() { 57 | 58 | // given 59 | let cookies = 60 | "DSESSIONID=150f81k3; Path=/; Domain=developer.apple.com; Secure; HttpOnly, ADCDownloadAuth=qMa%0D%0A;Version=1;Comment=;Domain=apple.com;Path=/;Max-Age=108000;Secure;HttpOnly;Expires=Fri, 05 Aug 2022 11:58:50 GMT" 61 | let session = AppleSession( 62 | itcServiceKey: AppleServiceKey(authServiceUrl: "authServiceUrl", authServiceKey: "authServiceKey"), 63 | xAppleIdSessionId: "sessionid", 64 | scnt: "scnt12345" 65 | ) 66 | 67 | // when 68 | let ass = AppleSessionSecret(cookies: cookies, session: session) 69 | 70 | // then 71 | let c = ass.cookies() 72 | XCTAssertNotNil(c) 73 | XCTAssertEqual(c.count, 2) 74 | 75 | XCTAssertNoThrow(try ass.string()) 76 | if let a = try? ass.string() { 77 | XCTAssertNotNil(a) 78 | XCTAssertTrue(a.contains("scnt12345")) 79 | } else { 80 | XCTAssert(false) 81 | } 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/FileSecretsHandlerTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileSecretHandlerTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 05/08/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | @Suite("FileSecretsHandlerTest", .serialized) 13 | struct FileSecretsHandlerTest { 14 | 15 | var secretHandlerTest: SecretsHandlerTestsBase? 16 | 17 | init() async throws { 18 | secretHandlerTest = SecretsHandlerTestsBase() 19 | 20 | secretHandlerTest!.secrets = await FileSecretsHandler() 21 | try await secretHandlerTest!.secrets!.clearSecrets() 22 | } 23 | 24 | @Test("Test Merge Cookies No Conflict") 25 | func testMergeCookiesNoConflict() async throws { 26 | try await secretHandlerTest!.testMergeCookiesNoConflict() 27 | } 28 | 29 | @Test("Test Merge Cookies One Conflict") 30 | func testMergeCookiesOneConflict() async throws { 31 | try await secretHandlerTest!.testMergeCookiesOneConflict() 32 | } 33 | 34 | @Test("Test Load and Save Session") 35 | func testLoadAndSaveSession() async throws { 36 | try await secretHandlerTest!.testLoadAndSaveSession() 37 | } 38 | 39 | @Test("Test Load and Save Cookies") 40 | func testLoadAndSaveCookies() async throws { 41 | try await secretHandlerTest!.testLoadAndSaveCookies() 42 | } 43 | 44 | @Test("Test Load Session No Exist") 45 | func testLoadSessionNoExist() async { 46 | await secretHandlerTest!.testLoadSessionNoExist() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/MockedSecretsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedSecretsHandler.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 15/09/2022. 6 | // 7 | 8 | import Foundation 9 | import Synchronization 10 | 11 | @testable import xcodeinstall 12 | 13 | #if canImport(FoundationNetworking) 14 | import FoundationNetworking 15 | #endif 16 | 17 | @MainActor 18 | final class MockedSecretsHandler: SecretsHandlerProtocol { 19 | var nextError: AWSSecretsHandlerError? 20 | var env: Environment 21 | public init(env: inout MockedEnvironment, nextError: AWSSecretsHandlerError? = nil) { 22 | self.nextError = nextError 23 | self.env = env 24 | } 25 | 26 | func clearSecrets() async throws { 27 | 28 | } 29 | 30 | func saveCookies(_ cookies: String?) async throws -> String? { 31 | "" 32 | } 33 | 34 | func loadCookies() async throws -> [HTTPCookie] { 35 | [] 36 | } 37 | 38 | func saveSession(_ session: AppleSession) async throws -> AppleSession { 39 | session 40 | } 41 | 42 | func loadSession() async throws -> AppleSession? { 43 | nil 44 | } 45 | 46 | func retrieveAppleCredentials() async throws -> AppleCredentialsSecret { 47 | if let nextError = nextError { 48 | throw nextError 49 | } 50 | guard let rl = env.readLine as? MockedReadLine else { 51 | fatalError("Invalid Mocked Environment") 52 | } 53 | 54 | return AppleCredentialsSecret(username: rl.readLine(prompt: "")!, password: rl.readLine(prompt: "")!) 55 | } 56 | func storeAppleCredentials(_ credentials: xcodeinstall.AppleCredentialsSecret) async throws { 57 | //TODO: how can we set region on env.awsSDK ? We just have a copy of the env here 58 | // print("set region !!!") 59 | } 60 | 61 | } 62 | 63 | final class MockedAWSSecretsHandlerSDK: AWSSecretsHandlerSDKProtocol { 64 | 65 | private let _regionSet: Mutex = .init(false) 66 | let appleSession: Mutex 67 | let appleCredentials: Mutex 68 | 69 | private init() throws { 70 | appleSession = try .init(AppleSessionSecret(fromString: "{}")) 71 | appleCredentials = .init(AppleCredentialsSecret(username: "", password: "")) 72 | } 73 | 74 | static func forRegion(_ region: String) throws -> any xcodeinstall.AWSSecretsHandlerSDKProtocol { 75 | let mock = try MockedAWSSecretsHandlerSDK() 76 | mock._regionSet.withLock { $0 = true } 77 | return mock 78 | } 79 | 80 | func regionSet() -> Bool { 81 | _regionSet.withLock { $0 } 82 | } 83 | 84 | func saveSecret(secretId: AWSSecretsName, secret: T) async throws where T: Secrets { 85 | switch secretId { 86 | case .appleCredentials: 87 | appleCredentials.withLock { $0 = secret as! AppleCredentialsSecret } 88 | case .appleSessionToken: 89 | appleSession.withLock { $0 = secret as! AppleSessionSecret } 90 | } 91 | } 92 | 93 | func updateSecret(secretId: AWSSecretsName, newValue: T) async throws where T: Secrets { 94 | switch secretId { 95 | case .appleCredentials: 96 | 97 | appleCredentials.withLock { $0 = newValue as! AppleCredentialsSecret } 98 | case .appleSessionToken: 99 | appleSession.withLock { $0 = newValue as! AppleSessionSecret } 100 | } 101 | } 102 | 103 | func retrieveSecret(secretId: AWSSecretsName) async throws -> T where T: Secrets { 104 | switch secretId { 105 | case .appleCredentials: 106 | return appleCredentials.withLock { $0 as! T } 107 | case .appleSessionToken: 108 | return appleSession.withLock { $0 as! T } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/SecretsHandlerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileSecretHandlerTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 05/08/2022. 6 | // 7 | 8 | import Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | #if canImport(FoundationNetworking) 13 | import FoundationNetworking 14 | #endif 15 | 16 | protocol SecretsHandlerTestsProtocol { 17 | func testMergeCookiesNoConflict() async throws 18 | func testMergeCookiesOneConflict() async throws 19 | func testLoadAndSaveSession() async throws 20 | func testLoadAndSaveCookies() async throws 21 | func testLoadSessionNoExist() async 22 | } 23 | 24 | struct SecretsHandlerTestsBase { 25 | 26 | var secrets: T? 27 | 28 | // 4 cookies : dslang site, myacinfo, aasp 29 | private let cookieStringOne = 30 | "dslang=GB-EN; Domain=apple.com; Path=/; Secure; HttpOnly, site=GBR; Domain=apple.com; Path=/; Secure; HttpOnly, myacinfo=DAW47V3; Domain=apple.com; Path=/; Secure; HttpOnly, aasp=1AD6CF2; Domain=idmsa.apple.com; Path=/; Secure; HttpOnly%" 31 | 32 | // 2 cookies : DSESSIONID, ADCDownloadAuth 33 | private let cookieStringTwo = 34 | "DSESSIONID=150f81k3; Path=/; Domain=developer.apple.com; Secure; HttpOnly, ADCDownloadAuth=qMa%0D%0A;Version=1;Comment=;Domain=apple.com;Path=/;Max-Age=108000;Secure;HttpOnly;Expires=Fri, 05 Aug 2022 11:58:50 GMT" 35 | 36 | // 1 cookie : dslang conflict with string one 37 | private let cookieStringConflict = "dslang=FR-FR; Domain=apple.com; Path=/; Secure; HttpOnly" 38 | 39 | func testMergeCookiesNoConflict() async throws { 40 | 41 | // given 42 | 43 | // create a cookie file 44 | _ = try await self.secrets!.saveCookies(cookieStringOne) 45 | 46 | // when 47 | 48 | // merge with second set of cookies 49 | _ = try await self.secrets!.saveCookies(cookieStringTwo) 50 | 51 | // then 52 | 53 | // new file must be the merged results of the two set of cookies. 54 | let cookies = try await self.secrets!.loadCookies() 55 | 56 | // number of cookie is the sum of the two files 57 | #expect(cookies.count == 6) 58 | 59 | // cookies from second file are present with correct values 60 | #expect(cookies.contains(where: { c in c.name == "ADCDownloadAuth" })) 61 | #expect(cookies.contains(where: { c in c.name == "DSESSIONID" })) 62 | } 63 | 64 | func testMergeCookiesOneConflict() async throws { 65 | 66 | // given 67 | // create a cookie file 68 | _ = try await self.secrets!.saveCookies(cookieStringOne) 69 | 70 | // when 71 | 72 | // merge with second set of cookies 73 | _ = try await self.secrets!.saveCookies(cookieStringConflict) 74 | 75 | // then 76 | 77 | // new file must be the merged results of the two set of cookies. 78 | let cookies = try await self.secrets!.loadCookies() 79 | 80 | // number of cookie is the original count (conflicted cookie is not added, but merged) 81 | #expect(cookies.count == 4) 82 | 83 | // cookies from second file is present 84 | #expect(cookies.contains(where: { c in c.name == "dslang" })) 85 | 86 | // with correct values 87 | let c = cookies.first(where: { c in c.name == "dslang" && c.value == "FR-FR" }) 88 | #expect(c != nil) 89 | } 90 | 91 | func testLoadAndSaveSession() async throws { 92 | 93 | do { 94 | // given 95 | let session = AppleSession( 96 | itcServiceKey: AppleServiceKey(authServiceUrl: "authServiceUrl", authServiceKey: "authServiceKey"), 97 | xAppleIdSessionId: "xAppleIdSessionId", 98 | scnt: "scnt" 99 | ) 100 | 101 | // when 102 | let _ = try await secrets!.saveSession(session) 103 | let newSession = try await secrets!.loadSession() 104 | 105 | // then 106 | #expect(session == newSession) 107 | 108 | } catch { 109 | Issue.record("Unexpected exception while testing : \(error)") 110 | } 111 | } 112 | 113 | func testLoadAndSaveCookies() async throws { 114 | 115 | // given 116 | 117 | // create a cookie file 118 | _ = try await self.secrets!.saveCookies(cookieStringOne) 119 | 120 | // when 121 | 122 | // reading cookies 123 | let cookies = try await self.secrets!.loadCookies() 124 | 125 | // then 126 | 127 | // number of cookie is equal the orginal string 128 | #expect(cookies.count == 4) 129 | 130 | // cookies are present with correct values 131 | #expect(cookies.contains(where: { c in c.name == "dslang" })) 132 | #expect(cookies.contains(where: { c in c.name == "site" })) 133 | #expect(cookies.contains(where: { c in c.name == "myacinfo" })) 134 | #expect(cookies.contains(where: { c in c.name == "aasp" })) 135 | } 136 | 137 | func testLoadSessionNoExist() async { 138 | 139 | // given 140 | // no session exist (clear session happened as setup time) 141 | 142 | // when 143 | let newSession = try? await secrets!.loadSession() 144 | 145 | // then 146 | #expect(newSession == nil) 147 | 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/SotoTestEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SotoTestEnvironment.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 16/09/2022. 6 | // 7 | 8 | import Dispatch 9 | import Foundation 10 | import SotoCore 11 | import XCTest 12 | 13 | /// Provide various test environment variables 14 | struct TestEnvironment { 15 | /// are we using Localstack to test. Also return use localstack if we are running a github action and don't have an access key if 16 | static var isUsingLocalstack: Bool { 17 | // return Environment["AWS_DISABLE_LOCALSTACK"] != "true" || 18 | // (Environment["GITHUB_ACTIONS"] == "true" && Environment["AWS_ACCESS_KEY_ID"] == "") 19 | false 20 | } 21 | 22 | static var credentialProvider: CredentialProviderFactory { 23 | isUsingLocalstack 24 | ? .static(accessKeyId: "foo", secretAccessKey: "bar") : .selector(.configFile(), .environment, .ec2) 25 | } 26 | 27 | /// current list of middleware 28 | static var middlewares: [AWSServiceMiddleware] { 29 | // return (Environment["AWS_ENABLE_LOGGING"] == "true") ? [AWSLoggingMiddleware()] : [] 30 | [] 31 | } 32 | 33 | /// return endpoint 34 | static func getEndPoint(environment: String = "") -> String? { 35 | guard self.isUsingLocalstack == true else { return nil } 36 | // return Environment[environment] ?? "http://localhost:4566" 37 | return "http://localhost:4566" 38 | } 39 | 40 | /// get name to use for AWS resource 41 | // static func generateResourceName(_ function: String = #function) -> String { 42 | // let prefix = Environment["AWS_TEST_RESOURCE_PREFIX"] ?? "" 43 | // return "soto-" + (prefix + function).filter { $0.isLetter || $0.isNumber }.lowercased() 44 | // } 45 | 46 | // public static var logger: Logger = { 47 | // if let loggingLevel = Environment["AWS_LOG_LEVEL"] { 48 | // if let logLevel = Logger.Level(rawValue: loggingLevel.lowercased()) { 49 | // var logger = Logger(label: "soto") 50 | // logger.logLevel = logLevel 51 | // return logger 52 | // } 53 | // } 54 | // return AWSClient.loggingDisabled 55 | // }() 56 | } 57 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/TestHelpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import xcodeinstall 5 | 6 | // MARK: - Test Helpers for MainActor Isolation 7 | 8 | /// Helper to set session on an AppleAuthenticator (handles MainActor isolation) 9 | @MainActor 10 | func setSession(on authenticator: AppleAuthenticator, session: AppleSession) { 11 | authenticator.session = session 12 | } 13 | 14 | /// Helper to modify session properties (handles MainActor isolation) 15 | @MainActor 16 | func modifySession(on authenticator: AppleAuthenticator, modifier: (inout AppleSession) -> Void) { 17 | var session = authenticator.session 18 | modifier(&session) 19 | authenticator.session = session 20 | } 21 | 22 | /// Helper to access session properties safely (handles MainActor isolation) 23 | @MainActor 24 | func getSessionProperty(from authenticator: AppleAuthenticator, accessor: (AppleSession) -> T) -> T { 25 | accessor(authenticator.session) 26 | } 27 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Utilities/FileHandlerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import xcodeinstall 5 | 6 | // MARK: - File Handler Tests 7 | @Suite("FileHandlerTests", .serialized) 8 | struct FileHandlerTests { 9 | 10 | // MARK: - Test Environment 11 | var fileManager: FileManager 12 | let test_data: String = "test data éèà€ 🎧" 13 | 14 | init() { 15 | self.fileManager = FileManager.default 16 | } 17 | 18 | // MARK: - Helper Methods 19 | private func tempDir() -> URL { 20 | fileManager.temporaryDirectory 21 | } 22 | 23 | private func createSrcFile() -> URL { 24 | let srcFile: URL = self.tempDir().appendingPathComponent("temp.txt") 25 | let _ = fileManager.createFile(atPath: srcFile.path, contents: test_data.data(using: .utf8)) 26 | return srcFile 27 | } 28 | } 29 | 30 | // MARK: - Test Cases 31 | extension FileHandlerTests { 32 | 33 | @Test("Test Moving Files Successfully") 34 | func testMoveSucceed() async throws { 35 | // Given 36 | let srcFile = createSrcFile() 37 | 38 | // When 39 | let dstFile: URL = self.tempDir().appendingPathComponent("temp2.txt") 40 | let fh = FileHandler() 41 | try await fh.move(from: srcFile, to: dstFile) 42 | 43 | // Then 44 | // srcFile does not exist 45 | #expect(!fileManager.fileExists(atPath: srcFile.path)) 46 | 47 | // dstFile exists 48 | #expect(fileManager.fileExists(atPath: dstFile.path)) 49 | 50 | // dstFile contains "test data" 51 | let data: String = try String(contentsOf: dstFile, encoding: .utf8) 52 | #expect(data == test_data) 53 | 54 | // Cleanup 55 | try fileManager.removeItem(at: dstFile) 56 | } 57 | 58 | @Test("Test Moving Files When Destination Already Exists") 59 | func testMoveDstExists() async throws { 60 | // Given 61 | let test_data2: String = "data already exists" 62 | let srcFile = createSrcFile() 63 | 64 | // dst exists and has a different content 65 | let dstFile: URL = self.tempDir().appendingPathComponent("temp2.txt") 66 | let _ = fileManager.createFile(atPath: dstFile.path, contents: test_data2.data(using: .utf8)) 67 | 68 | // When 69 | let fh = FileHandler() 70 | try await fh.move(from: srcFile, to: dstFile) 71 | 72 | // Then 73 | // srcFile does not exist 74 | #expect(!fileManager.fileExists(atPath: srcFile.path)) 75 | 76 | // dstFile exists 77 | #expect(fileManager.fileExists(atPath: dstFile.path)) 78 | 79 | // dstFile contains "test data" (overwritten) 80 | let data: String = try String(contentsOf: dstFile, encoding: .utf8) 81 | #expect(data == test_data) 82 | 83 | // Cleanup 84 | try fileManager.removeItem(at: dstFile) 85 | } 86 | 87 | @Test("Test Moving Files with Invalid Destination") 88 | func testMoveDstInvalid() async throws { 89 | // Given 90 | let srcFile = createSrcFile() 91 | 92 | // dst file does not exist in an invalid location 93 | let dstFile = URL(fileURLWithPath: "/does_not_exist/tmp.txt") 94 | 95 | // When/Then 96 | let fh = FileHandler() 97 | do { 98 | try await fh.move(from: srcFile, to: dstFile) 99 | Issue.record("Should have thrown an error") 100 | } catch { 101 | // Expected error 102 | #expect(fileManager.fileExists(atPath: srcFile.path)) 103 | #expect(!fileManager.fileExists(atPath: dstFile.path)) 104 | } 105 | 106 | // Cleanup 107 | try? fileManager.removeItem(at: srcFile) 108 | } 109 | 110 | @Test("Test Checking File Size") 111 | @MainActor 112 | func testCheckFileSize() throws { 113 | // Given 114 | let fileToCheck = createSrcFile() 115 | 116 | // When 117 | let fh = FileHandler() 118 | let expectedFileSize = test_data.data(using: .utf8)?.count 119 | 120 | // Then 121 | #expect(expectedFileSize != nil) 122 | if let expectedFileSize = expectedFileSize { 123 | let result = try fh.checkFileSize(file: fileToCheck, fileSize: expectedFileSize) 124 | #expect(result) 125 | } 126 | 127 | // Cleanup 128 | try fileManager.removeItem(at: fileToCheck) 129 | } 130 | 131 | @Test("Test Checking File Size for Non-Existent File") 132 | @MainActor 133 | func testCheckFileSizeNotExist() throws { 134 | // Given 135 | let fileToCheck = URL(fileURLWithPath: "/does_not_exist/tmp.txt") 136 | 137 | // When/Then 138 | let fh = FileHandler() 139 | let error = #expect(throws: FileHandlerError.self) { 140 | _ = try fh.checkFileSize(file: fileToCheck, fileSize: 42) 141 | } 142 | #expect(error == FileHandlerError.fileDoesNotExist) 143 | } 144 | 145 | @Test("Test File Exists Check - Positive") 146 | @MainActor 147 | func testFileExistsYes() throws { 148 | // Given 149 | let fileToCheck = createSrcFile() 150 | 151 | // When 152 | let fh = FileHandler() 153 | let expectedFileSize = test_data.data(using: .utf8)?.count 154 | 155 | // Then 156 | #expect(expectedFileSize != nil) 157 | if let expectedFileSize = expectedFileSize { 158 | let exists = fh.fileExists(file: fileToCheck, fileSize: expectedFileSize) 159 | #expect(exists) 160 | } 161 | 162 | // Cleanup 163 | try fileManager.removeItem(at: fileToCheck) 164 | } 165 | 166 | @Test("Test File Exists Check - Negative") 167 | @MainActor 168 | func testFileExistsNo() { 169 | // Given 170 | let fileToCheck = URL(fileURLWithPath: "/does_not_exist/tmp.txt") 171 | 172 | // When 173 | let fh = FileHandler() 174 | let expectedFileSize = test_data.data(using: .utf8)?.count 175 | 176 | // Then 177 | #expect(expectedFileSize != nil) 178 | if let expectedFileSize = expectedFileSize { 179 | let exists = fh.fileExists(file: fileToCheck, fileSize: expectedFileSize) 180 | #expect(!exists) 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Utilities/MockedUtilitiesClasses.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedUtilitiesClasses.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 27/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Foundation 10 | 11 | @testable import xcodeinstall 12 | 13 | // used to test Installer component (see InstallerTest) 14 | @MainActor 15 | final class MockedFileHandler: FileHandlerProtocol { 16 | 17 | var moveSrc: URL? = nil 18 | var moveDst: URL? = nil 19 | var nextFileExist: Bool? = nil 20 | var nextFileCorrect: Bool? = nil 21 | 22 | func move(from src: URL, to dst: URL) throws { 23 | moveSrc = src 24 | moveDst = dst 25 | } 26 | func fileExists(file: URL, fileSize: Int) -> Bool { 27 | if let nextFileExist { 28 | return nextFileExist 29 | } else { 30 | return true 31 | } 32 | } 33 | func downloadedFiles() throws -> [String] { 34 | ["name.pkg", "name.dmg"] 35 | } 36 | 37 | func downloadDirectory() -> URL { 38 | baseFilePath() 39 | } 40 | 41 | func checkFileSize(file: URL, fileSize: Int) throws -> Bool { 42 | if let nextFileCorrect { 43 | return nextFileCorrect 44 | } else { 45 | return true 46 | } 47 | } 48 | 49 | func downloadFileURL(file: DownloadList.File) -> URL { 50 | URL(fileURLWithPath: downloadFilePath(file: file)) 51 | } 52 | 53 | func downloadFilePath(file: DownloadList.File) -> String { 54 | "/download/\(file.filename)" 55 | } 56 | 57 | func saveDownloadList(list: DownloadList) throws -> DownloadList { 58 | let listData = try loadTestData(file: .downloadList) 59 | return try JSONDecoder().decode(DownloadList.self, from: listData) 60 | } 61 | 62 | func loadDownloadList() throws -> DownloadList { 63 | let listData = try loadTestData(file: .downloadList) 64 | return try JSONDecoder().decode(DownloadList.self, from: listData) 65 | } 66 | 67 | func baseFilePath() -> URL { 68 | URL(string: "file:///tmp")! 69 | } 70 | 71 | func baseFilePath() -> String { 72 | "/tmp" 73 | } 74 | } 75 | 76 | @MainActor 77 | class MockedProgressBar: CLIProgressBarProtocol { 78 | 79 | var isComplete = false 80 | var isClear = false 81 | var step = 0 82 | var total = 0 83 | var text = "" 84 | private var _defineCalled = false 85 | 86 | func define(animationType: CLIlib.ProgressBarType, message: String) { 87 | _defineCalled = true 88 | } 89 | func defineCalled() -> Bool { 90 | let called = _defineCalled 91 | _defineCalled = false 92 | return called 93 | } 94 | 95 | func update(step: Int, total: Int, text: String) { 96 | self.step = step 97 | self.total = total 98 | self.text = text 99 | } 100 | 101 | func complete(success: Bool) { 102 | isComplete = success 103 | } 104 | 105 | func clear() { 106 | isClear = true 107 | } 108 | 109 | } 110 | 111 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Utilities/TestHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestHelper.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 04/08/2022. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | 11 | @testable import xcodeinstall 12 | 13 | enum TestData: String { 14 | case downloadList = "download-list-20231115" 15 | case downloadError = "download-error" 16 | case downloadUnknownError = "download-unknown-error" 17 | } 18 | 19 | // return the URL of a test file 20 | func urlForTestData(file: TestData) throws -> URL { 21 | // load list from file 22 | // https://stackoverflow.com/questions/47177036/use-resources-in-unit-tests-with-swift-package-manager 23 | let filePath = Bundle.module.path(forResource: file.rawValue, ofType: "json")! 24 | return URL(fileURLWithPath: filePath) 25 | } 26 | 27 | // load a test file added as a resource to the executable bundle 28 | func loadTestData(file: TestData) throws -> Data { 29 | // load list from file 30 | try Data(contentsOf: urlForTestData(file: file)) 31 | } 32 | 33 | @MainActor 34 | func createDownloadList() throws { 35 | 36 | let fm = FileManager.default 37 | 38 | // copy test file at destination 39 | 40 | // delete file at destination if it exists 41 | if fm.fileExists(atPath: FileHandler().downloadListPath().path) { 42 | XCTAssertNoThrow(try fm.removeItem(at: FileHandler().downloadListPath())) 43 | } 44 | // get the source URL 45 | guard let testFilePath = try? urlForTestData(file: .downloadList) else { 46 | fatalError("Can not retrieve url for \(TestData.downloadList.rawValue)") 47 | } 48 | // copy source to destination 49 | XCTAssertNoThrow(try fm.copyItem(at: testFilePath, to: FileHandler().downloadListPath())) 50 | } 51 | 52 | @MainActor 53 | func deleteDownloadList() { 54 | 55 | let fm = FileManager.default 56 | 57 | // remove test file from destination 58 | if fm.fileExists(atPath: FileHandler().downloadListPath().path) { 59 | XCTAssertNoThrow(try fm.removeItem(at: FileHandler().downloadListPath())) 60 | } 61 | } 62 | 63 | // https://stackoverflow.com/questions/47177036/use-resources-in-unit-tests-with-swift-package-manager 64 | //#if XCODE_BUILD - also defined in swiftpm, I use a custom flag defined in Package.swift instead 65 | // #if !SWIFTPM_COMPILATION 66 | // extension Foundation.Bundle { 67 | 68 | // /// Returns resource bundle as a `Bundle`. 69 | // /// Requires Xcode copy phase to locate files into `ExecutableName.bundle`; 70 | // /// or `ExecutableNameTests.bundle` for test resources 71 | // static var module: Bundle = { 72 | // var thisModuleName = "xcodeinstall" 73 | // var url = Bundle.main.bundleURL 74 | 75 | // for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 76 | // url = bundle.bundleURL.deletingLastPathComponent() 77 | // thisModuleName = thisModuleName.appending("Tests") 78 | // } 79 | 80 | // url = url.appendingPathComponent("\(thisModuleName).xctest") 81 | 82 | // guard let bundle = Bundle(url: url) else { 83 | // fatalError("Foundation.Bundle.module could not load resource bundle: \(url.path)") 84 | // } 85 | 86 | // return bundle 87 | // }() 88 | 89 | // /// Directory containing resource bundle 90 | // static var moduleDir: URL = { 91 | // var url = Bundle.main.bundleURL 92 | // for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 93 | // // remove 'ExecutableNameTests.xctest' path component 94 | // url = bundle.bundleURL.deletingLastPathComponent() 95 | // } 96 | // return url 97 | // }() 98 | // } 99 | // #endif 100 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/data/download-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "responseId": "8a091f80-fe44-4297-a5a6-26a37b18c777", 3 | "resultCode": 1100, 4 | "resultString": "Your session has expired. Please log in.", 5 | "userString": "Your session has expired. Please log in.", 6 | "creationTimestamp": "2022-07-24T18:59:46Z", 7 | "protocolVersion": "QH65B2", 8 | "userLocale": "en_US", 9 | "requestUrl": "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action", 10 | "httpCode": 200 11 | } 12 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/data/download-unknown-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "responseId": "8a091f80-fe44-4297-a5a6-26a37b18c777", 3 | "resultCode": 9999, 4 | "resultString": "Your session has expired. Please log in.", 5 | "userString": "Your session has expired. Please log in.", 6 | "creationTimestamp": "2022-07-24T18:59:46Z", 7 | "protocolVersion": "QH65B2", 8 | "userLocale": "en_US", 9 | "requestUrl": "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action", 10 | "httpCode": 200 11 | } 12 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.10.1 2 | -------------------------------------------------------------------------------- /iam/createRole.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | aws iam create-role \ 4 | --role-name xcodeinstall \ 5 | --assume-role-policy-document file://ec2-role-trust-policy.json 6 | 7 | aws iam create-policy \ 8 | --policy-name xcodeinstall-permissions \ 9 | --policy-document file://ec2-policy.json -------------------------------------------------------------------------------- /iam/ec2-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "xcodeinstall", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "secretsmanager:CreateSecret", 9 | "secretsmanager:GetSecretValue", 10 | "secretsmanager:PutSecretValue" 11 | ], 12 | "Resource": "arn:aws:secretsmanager:*:000000000000:secret:xcodeinstall-*" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /iam/ec2-role-trust-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Principal": { "Service": "ec2.amazonaws.com"}, 7 | "Action": "sts:AssumeRole" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /img/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/ec250626da7cb16479d7a4c927cc04b8d014cfc7/img/download.png -------------------------------------------------------------------------------- /img/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/ec250626da7cb16479d7a4c927cc04b8d014cfc7/img/install.png -------------------------------------------------------------------------------- /img/mfa-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/ec250626da7cb16479d7a4c927cc04b8d014cfc7/img/mfa-01.png -------------------------------------------------------------------------------- /img/mfa-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/ec250626da7cb16479d7a4c927cc04b8d014cfc7/img/mfa-02.png -------------------------------------------------------------------------------- /img/xcodeinstall-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/ec250626da7cb16479d7a4c927cc04b8d014cfc7/img/xcodeinstall-demo.gif -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /scripts/deploy/RELEASE_DOC.md: -------------------------------------------------------------------------------- 1 | ## TODO 2 | 3 | Consider using a github action for this 4 | https://github.com/Homebrew/actions 5 | https://github.com/ipatch/homebrew-freecad-pg13/blob/main/.github/workflows/publish.yml 6 | https://github.com/marketplace/actions/homebrew-bump-cask 7 | 8 | ## To release a new version. 9 | 10 | 1. Commit all other changes and push them 11 | 12 | 2. Update version number in `scripts/release-sources.sh` 13 | 14 | 3. `./scripts/release_sources.sh` 15 | 16 | This script 17 | - creates a new version 18 | - tags the branch and push the tag 19 | - creates a GitHub release 20 | - creates a brew formula with the new release 21 | 22 | 4. `./scripts/bottle.sh` 23 | 24 | This script 25 | - creates the brew bottles TAR file and the code to add to the formula 26 | 27 | 5. `./scripts/release_binaries.sh` 28 | 29 | This script 30 | - uploads the bottles to the GitHub Release 31 | - update the brew formula with the bootle definition 32 | 33 | ## To undo a release 34 | 35 | While testing this procedure, it is useful to undo a release. 36 | 37 | !! Destructive actions !! 38 | 39 | 1. `./scripts/delete_release.sh` 40 | 41 | 2. `git reset HEAD~1` 42 | 43 | 3. Reset Version file 44 | 45 | ```zsh 46 | SOURCE_FILE="Sources/xcodeinstall/Version.swift" 47 | 48 | cat <"$SOURCE_FILE" 49 | // Generated by: scripts/version 50 | enum Version { 51 | static let version = "" 52 | } 53 | EOF 54 | ``` 55 | 56 | 4. `rm -rf ~/Library/Caches/Homebrew/downloads/*xcodeinstall-*.tar.gz` -------------------------------------------------------------------------------- /scripts/deploy/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | # 3 | # script/bootstrap.sh 4 | # 5 | # Installs development dependencies and builds project dependencies. 6 | # 7 | 8 | main() { 9 | scripts/clean.sh 10 | 11 | echo "==> 👢 Bootstrapping" 12 | 13 | # When not installed, install Swift Lint 14 | if [[ ! -x "$(command -v swiftlint)" ]]; then 15 | brew install swiftlint 16 | fi 17 | 18 | # When not installed, install GitHub command line 19 | if [[ ! -x "$(command -v gh)" ]]; then 20 | brew install gh 21 | fi 22 | } 23 | 24 | main -------------------------------------------------------------------------------- /scripts/deploy/bottle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | # 3 | # 4 | # Builds bottles of xcodeinstall Homebrew formula for custom tap: 5 | # https://github.com/sebsto/homebrew-macos 6 | # 7 | 8 | ################################################################################ 9 | # 10 | # Variables 11 | # 12 | 13 | echo "\n➕ Get version number \n" 14 | if [ ! -f VERSION ]; then 15 | echo "VERSION file does not exist." 16 | echo "It is created by 'scripts/release_sources.sh" 17 | exit -1 18 | fi 19 | VERSION=$(cat VERSION) 20 | TAG=v$VERSION 21 | echo "🍼 Bottling version $VERSION" 22 | 23 | BOTTLE_DIR="$PWD/dist/bottle" 24 | ROOT_URL="https://github.com/sebsto/xcodeinstall/releases/download/${TAG}" 25 | 26 | if [ ! -f VERSION ]; then 27 | echo "VERSION file does not exist." 28 | echo "It is created by 'scripts/release_sources.sh" 29 | exit -1 30 | fi 31 | VERSION=$(cat VERSION) 32 | 33 | # Supports macOS 13 (Ventura) and later 34 | OS_NAMES=(arm64_ventura ventura arm64_sonoma sonoma arm64_sequoia sequoia) 35 | 36 | # Semantic version number split into a list using Ugly, bash 3 compatible syntax 37 | IFS=" " read -r -a CURRENT_OS_VERSION <<<"$(sw_vers -productVersion | sed 's/\./ /g')" 38 | CURRENT_OS_VERSION_MAJOR=${CURRENT_OS_VERSION[0]} 39 | CURRENT_OS_VERSION_MINOR=${CURRENT_OS_VERSION[1]} 40 | 41 | echo "CURRENT_OS_VERSION_MAJOR: $CURRENT_OS_VERSION_MAJOR" 42 | echo "CURRENT_OS_VERSION_MINOR: $CURRENT_OS_VERSION_MINOR" 43 | 44 | if [[ ${CURRENT_OS_VERSION_MAJOR} == "12" ]]; then 45 | if [[ "x86_64" == "$(uname -m)" ]]; then 46 | CURRENT_PLATFORM=monterey 47 | else 48 | CURRENT_PLATFORM=arm64_monterey 49 | fi 50 | elif [[ ${CURRENT_OS_VERSION_MAJOR} == "13" ]]; then 51 | if [[ "x86_64" == "$(uname -m)" ]]; then 52 | CURRENT_PLATFORM=ventura 53 | else 54 | CURRENT_PLATFORM=arm64_ventura 55 | fi 56 | elif [[ ${CURRENT_OS_VERSION_MAJOR} == "14" ]]; then 57 | if [[ "x86_64" == "$(uname -m)" ]]; then 58 | CURRENT_PLATFORM=sonoma 59 | else 60 | CURRENT_PLATFORM=arm64_sonoma 61 | fi 62 | elif [[ ${CURRENT_OS_VERSION_MAJOR} == "15" ]]; then 63 | if [[ "x86_64" == "$(uname -m)" ]]; then 64 | CURRENT_PLATFORM=sequoia 65 | else 66 | CURRENT_PLATFORM=arm64_sequoia 67 | fi 68 | else 69 | echo "Unsupported macOS version. This script requires Monterey or better." 70 | exit -1 71 | fi 72 | 73 | echo "CURRENT_PLATFORM: ${CURRENT_PLATFORM}" 74 | 75 | ################################################################################ 76 | # 77 | # Preflight checks 78 | # 79 | 80 | echo "🍼 Uninstall formula and it's tap. Then reinstalling and audit it" 81 | 82 | # Uninstall if necessary 83 | brew remove xcodeinstall 2>/dev/null || true # ignore failure 84 | brew untap sebsto/macos 2>/dev/null || true #ignore failure 85 | 86 | # Uninstall if still found on path 87 | # if command -v xcodeinstall >/dev/null; then 88 | # script/uninstall || true # ignore failure 89 | # fi 90 | 91 | # Use formula from custom tap 92 | brew tap sebsto/macos 93 | # brew update 94 | 95 | # Audit formula 96 | brew audit --strict sebsto/macos/xcodeinstall 97 | brew style sebsto/macos/xcodeinstall 98 | 99 | ################################################################################ 100 | # 101 | # Build the formula for the current macOS version and architecture. 102 | # 103 | 104 | echo "🍼 Bottling xcodeinstall ${VERSION} for: ${OS_NAMES[*]}" 105 | brew install --build-bottle sebsto/macos/xcodeinstall 106 | 107 | # Generate bottle do block, dropping last 2 lines 108 | brew bottle --verbose --no-rebuild --root-url="$ROOT_URL" sebsto/macos/xcodeinstall 109 | FILENAME="xcodeinstall--${VERSION}.${CURRENT_PLATFORM}.bottle.tar.gz" 110 | SHA256=$(shasum --algorithm 256 "${FILENAME}" | cut -f 1 -d ' ' -) 111 | 112 | mkdir -p "$BOTTLE_DIR" 113 | rm -rf "$BOTTLE_DIR/*" 114 | 115 | # Start of bottle block 116 | BOTTLE_BLOCK=$( 117 | cat <<-EOF 118 | bottle do 119 | root_url "$ROOT_URL" 120 | EOF 121 | ) 122 | 123 | ################################################################################ 124 | # 125 | # Copy the bottle for all macOS version + architecture combinations. 126 | # 127 | 128 | # Fix filename 129 | for os in "${OS_NAMES[@]}"; do 130 | echo "📂 Copying xcodeinstall ${VERSION} for: ${os}" 131 | new_filename="xcodeinstall-${VERSION}.${os}.bottle.tar.gz" 132 | cp -v "${FILENAME}" "${BOTTLE_DIR}/${new_filename}" 133 | 134 | # Append each os 135 | # BOTTLE_BLOCK="$(printf "${BOTTLE_BLOCK}\n sha256 cellar: :any_skip_relocation, %-15s %s" "${os}:" "${SHA256}")" 136 | BOTTLE_BLOCK="$BOTTLE_BLOCK"$( 137 | cat <<-EOF 138 | 139 | sha256 cellar: :any_skip_relocation, $os: "$SHA256" 140 | EOF 141 | ) 142 | done 143 | 144 | # End of bottle block 145 | BOTTLE_BLOCK="$BOTTLE_BLOCK"$( 146 | cat <<-EOF 147 | 148 | end 149 | EOF 150 | ) 151 | 152 | rm "${FILENAME}" 153 | ls -l "${BOTTLE_DIR}" 154 | echo "${BOTTLE_BLOCK}" > BOTTLE_BLOCK 155 | echo "${BOTTLE_BLOCK}" 156 | 157 | brew remove sebsto/macos/xcodeinstall 158 | -------------------------------------------------------------------------------- /scripts/deploy/build_binaries.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | set -o pipefail 4 | 5 | echo "\n➕ Get version number\n" 6 | if [ ! -f VERSION ]; then 7 | echo "VERSION file does not exist." 8 | echo "It is created by 'scripts/release_sources.sh" 9 | exit -1 10 | fi 11 | VERSION=$(cat VERSION) 12 | 13 | mkdir -p dist/arm64 14 | mkdir -p dist/x86_64 15 | 16 | echo "\n📦 Downloading packages according to Package.resolved\n" 17 | swift package resolve 18 | 19 | # echo "\n🩹 Patching Switft Tools Support Core dependency to produce a static library\n" 20 | # sed -i .bak -E -e "s/^( *type: .dynamic,)$/\/\/\1/" .build/checkouts/swift-tools-support-core/Package.swift 21 | 22 | echo "\n🏗 Building the ARM version\n" 23 | swift build --configuration release \ 24 | --arch arm64 25 | cp .build/arm64-apple-macosx/release/xcodeinstall dist/arm64 26 | 27 | echo "\n🏗 Building the x86_64 version\n" 28 | swift build --configuration release \ 29 | --arch x86_64 30 | cp .build/x86_64-apple-macosx/release/xcodeinstall dist/x86_64 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /scripts/deploy/build_debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | echo "🏗 Building a debug build for current machine architecture : $(uname -m)" 4 | swift build --configuration debug 5 | 6 | echo "🧪 Running unit tests" 7 | swift test > test.log 8 | 9 | if [ $? -eq 0 ]; then 10 | echo "✅ OK" 11 | else 12 | echo "🛑 Test failed, check test.log for details" 13 | fi -------------------------------------------------------------------------------- /scripts/deploy/build_fat_binary.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | set -o pipefail 4 | 5 | echo "\n➕ Get version number \n" 6 | if [ ! -f VERSION ]; then 7 | echo "VERSION file does not exist." 8 | echo "It is created by 'scripts/release_sources.sh" 9 | exit -1 10 | fi 11 | VERSION=$(cat VERSION) 12 | 13 | mkdir -p dist/fat 14 | 15 | echo "\n📦 Downloading packages according to Package.resolved\n" 16 | swift package --disable-sandbox resolve 17 | 18 | # echo "\n🩹 Patching Switft Tools Support Core dependency to produce a static library\n" 19 | # sed -i .bak -E -e "s/^( *type: .dynamic,)$/\/\/\1/" .build/checkouts/swift-tools-support-core/Package.swift 20 | 21 | echo "\n🏗 Building the fat binary (x86_64 and arm64) version\n" 22 | swift build --configuration release \ 23 | --arch arm64 \ 24 | --arch x86_64 \ 25 | --disable-sandbox 26 | cp .build/apple/Products/Release/xcodeinstall dist/fat 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /scripts/deploy/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | # 3 | # script/clean 4 | # 5 | # Deletes the build directory. 6 | # 7 | 8 | echo "🧻 Cleaning build artefacts" 9 | swift package clean 10 | swift package reset 11 | rm -rf dist/* 12 | rm -rf ~/Library/Caches/Homebrew/downloads/*xcodeinstall*.tar.gz -------------------------------------------------------------------------------- /scripts/deploy/delete_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | VERSION_TO_DELETE=$(cat VERSION) 4 | TAG=v$VERSION_TO_DELETE 5 | 6 | gh release delete $TAG 7 | git tag -d $TAG 8 | git push origin --delete $TAG -------------------------------------------------------------------------------- /scripts/deploy/release_binaries.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | echo "\n➕ Get version number \n" 4 | if [ ! -f VERSION ]; then 5 | echo "VERSION file does not exist." 6 | echo "It is created by 'scripts/release_sources.sh" 7 | exit -1 8 | fi 9 | VERSION=$(cat VERSION) 10 | TAG=v$VERSION 11 | 12 | echo "⬆️ Upload bottles" 13 | gh release upload $TAG dist/bottle/* 14 | 15 | echo "🍺 Add bottles to brew formula" 16 | if [ ! -f ./scripts/xcodeinstall.rb ]; then 17 | echo "Brew formula file does not exist. (./scripts/xcodeinstall.rb)" 18 | echo "It is created by 'scripts/release_sources.sh" 19 | exit -1 20 | fi 21 | if [ ! -f ./BOTTLE_BLOCK ]; then 22 | echo "Bottle block file does not exist. (./BOTTLE_BLOCK)" 23 | echo "It is created by 'scripts/bottle.sh" 24 | exit -1 25 | fi 26 | sed -i .bak -E -e "/ # insert bottle definition here/r BOTTLE_BLOCK" ./scripts/xcodeinstall.rb 27 | rm ./scripts/xcodeinstall.rb.bak 28 | 29 | echo "\n🍺 Pushing new formula\n" 30 | cp ./scripts/xcodeinstall.rb ../homebrew-macos 31 | pushd ../homebrew-macos 32 | git add xcodeinstall.rb 33 | git commit --quiet -m "update for $TAG" 34 | git push --quiet > /dev/null 2>&1 35 | popd 36 | -------------------------------------------------------------------------------- /scripts/deploy/release_sources.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -x 2 | set -e 3 | set -o pipefail 4 | 5 | # echo "Did you increment version number before running this script ?" 6 | # exit -1 7 | ###################### 8 | VERSION="0.10.1" 9 | ###################### 10 | 11 | echo $VERSION > VERSION 12 | TAG=v$VERSION 13 | 14 | echo "\n➕ Add new version to source code\n" 15 | scripts/version.sh 16 | 17 | echo "\n🏷 Tagging GitHub\n" 18 | git tag $TAG 19 | git push --quiet origin $TAG 20 | 21 | echo "\n📦 Create Source Code Release on GitHub\n" 22 | gh auth status > /dev/null 2>&1 23 | gh release create $TAG --generate-notes 24 | 25 | echo "\n⬇️ Downloading the source tarball\n" 26 | URL="https://github.com/sebsto/xcodeinstall/archive/refs/tags/$TAG.tar.gz" 27 | wget -q $URL 28 | 29 | echo "\n∑ Computing SHA 256\n" 30 | SHA256=$(shasum -a 256 $TAG.tar.gz | awk -s '{print $1}') 31 | rm $TAG.tar.gz 32 | 33 | echo "\n🍺 Generate brew formula\n" 34 | # do not use / as separator as it is confused with / from the URL 35 | sed -E -e "s+URL+url \"$URL\"+g" \ 36 | -e "s/SHA/sha256 \"$SHA256\"/g" \ 37 | scripts/xcodeinstall.template > scripts/xcodeinstall.rb 38 | 39 | echo "\n🍺 Pushing new formula\n" 40 | pushd ../homebrew-macos 41 | git pull 42 | cp ../xcodeinstall/scripts/xcodeinstall.rb . 43 | git add xcodeinstall.rb 44 | git commit --quiet -m "update for $TAG" 45 | git push --quiet > /dev/null 2>&1 46 | popd 47 | 48 | 49 | -------------------------------------------------------------------------------- /scripts/deploy/restoreSession.sh: -------------------------------------------------------------------------------- 1 | cp ~/.xcodeinstall/cookies.seb ~/.xcodeinstall/cookies 2 | cp ~/.xcodeinstall/session.seb ~/.xcodeinstall/session 3 | -------------------------------------------------------------------------------- /scripts/deploy/version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e -x 2 | # 3 | # Generate code level version file 4 | # 5 | 6 | if [ ! -f VERSION ]; then 7 | echo "VERSION file does not exist." 8 | echo "It is created by 'scripts/release_sources.sh" 9 | exit -1 10 | fi 11 | 12 | VERSION=$(cat VERSION) 13 | SCRIPT_PATH=$(dirname "$(which "$0")") 14 | SOURCE_FILE="${SCRIPT_PATH}/../Sources/xcodeinstall/Version.swift" 15 | 16 | cat <"$SOURCE_FILE" 17 | // Generated by: scripts/version 18 | enum Version { 19 | static let version = "${VERSION}" 20 | } 21 | EOF 22 | 23 | git add "$SOURCE_FILE" VERSION #> /dev/null 2>&1 24 | git commit --quiet -m "Bump source to version $VERSION" "$SOURCE_FILE" VERSION #> /dev/null 2>&1 25 | git push --quiet > /dev/null 2>&1 -------------------------------------------------------------------------------- /scripts/deploy/xcodeinstall.rb: -------------------------------------------------------------------------------- 1 | # Generated by following instructions at 2 | # https://betterprogramming.pub/a-step-by-step-guide-to-create-homebrew-taps-from-github-repos-f33d3755ba74 3 | # https://medium.com/@mxcl/maintaining-a-homebrew-tap-for-swift-projects-7287ed379324 4 | 5 | class Xcodeinstall < Formula 6 | desc "This is a command-line tool to download and install Apple's Xcode" 7 | homepage "https://github.com/sebsto/xcodeinstall" 8 | url "https://github.com/sebsto/xcodeinstall/archive/refs/tags/v0.10.1.tar.gz" 9 | sha256 "0e012e6b0f22e39f11a786accd42927a7f90253adc717bf30dee28143af6011e" 10 | license "Apache-2.0" 11 | 12 | # insert bottle definition here 13 | bottle do 14 | root_url "https://github.com/sebsto/xcodeinstall/releases/download/v0.10.1" 15 | sha256 cellar: :any_skip_relocation, arm64_ventura: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f" 16 | sha256 cellar: :any_skip_relocation, ventura: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f" 17 | sha256 cellar: :any_skip_relocation, arm64_sonoma: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f" 18 | sha256 cellar: :any_skip_relocation, sonoma: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f" 19 | sha256 cellar: :any_skip_relocation, arm64_sequoia: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f" 20 | sha256 cellar: :any_skip_relocation, sequoia: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f" 21 | end 22 | 23 | def install 24 | system "./scripts/build_fat_binary.sh" 25 | bin.install ".build/apple/Products/Release/xcodeinstall" 26 | end 27 | 28 | test do 29 | assert_equal version.to_s, shell_output("#{bin}/xcodeinstall --version").chomp 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /scripts/deploy/xcodeinstall.template: -------------------------------------------------------------------------------- 1 | # Generated by following instructions at 2 | # https://betterprogramming.pub/a-step-by-step-guide-to-create-homebrew-taps-from-github-repos-f33d3755ba74 3 | # https://medium.com/@mxcl/maintaining-a-homebrew-tap-for-swift-projects-7287ed379324 4 | 5 | class Xcodeinstall < Formula 6 | desc "This is a command-line tool to download and install Apple's Xcode" 7 | homepage "https://github.com/sebsto/xcodeinstall" 8 | URL 9 | SHA 10 | license "Apache-2.0" 11 | 12 | # insert bottle definition here 13 | 14 | def install 15 | system "./scripts/build_fat_binary.sh" 16 | bin.install ".build/apple/Products/Release/xcodeinstall" 17 | end 18 | 19 | test do 20 | assert_equal version.to_s, shell_output("#{bin}/xcodeinstall --version").chomp 21 | end 22 | end 23 | --------------------------------------------------------------------------------