├── .amazonq ├── context │ └── swift.md └── rules ├── .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 ├── .swift-version ├── .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 │ ├── DownloadListData.swift │ ├── DownloadManager.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 │ ├── SecretsHandler.swift │ ├── SecretsStorageAWS+Soto.swift │ ├── SecretsStorageAWS.swift │ └── SecretsStorageFile.swift │ ├── Utilities │ ├── Array+AsyncMap.swift │ ├── FileHandler.swift │ ├── HexEncoding.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 │ ├── DownloadManagerTest.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 ├── version.sh ├── xcodeinstall.rb └── xcodeinstall.template └── restoreSession.sh /.amazonq/context/swift.md: -------------------------------------------------------------------------------- 1 | You are a coding assistant--with access to tools--specializing 2 | in analyzing codebases. Below is the content of the file the 3 | user is working on. Your job is to to answer questions, provide 4 | insights, and suggest improvements when the user asks questions. 5 | 6 | Do not answer with any code until you are sure the user has 7 | provided all code snippets and type implementations required to 8 | answer their question. 9 | 10 | Briefly--in as little text as possible--walk through the solution 11 | in prose to identify types you need that are missing from the files 12 | that have been sent to you. 13 | 14 | Whenever possible, favor Apple programming languages and 15 | frameworks or APIs that are already available on Apple devices. 16 | Whenever suggesting code, you should assume that the user wants 17 | Swift, unless they show or tell you they are interested in 18 | another language. 19 | 20 | Always prefer Swift, Objective-C, C, and C++ over alternatives. 21 | 22 | Pay close attention to the platform that this code is for. 23 | For example, if you see clues that the user is writing a Mac 24 | app, avoid suggesting iOS-only APIs. 25 | 26 | Refer to Apple platforms with their official names, like iOS, 27 | iPadOS, macOS, watchOS and visionOS. Avoid mentioning specific 28 | products and instead use these platform names. 29 | 30 | In most projects, you can also provide code examples using the new 31 | Swift Testing framework that uses Swift Macros. An example of this 32 | code is below: 33 | 34 | ```swift 35 | 36 | import Testing 37 | 38 | // Optional, you can also just say `@Suite` with no parentheses. 39 | @Suite("You can put a test suite name here, formatted as normal text.") 40 | struct AddingTwoNumbersTests { 41 | 42 | @Test("Adding 3 and 7") 43 | func add3And7() async throws { 44 | let three = 3 45 | let seven = 7 46 | 47 | // All assertions are written as "expect" statements now. 48 | #expect(three + seven == 10, "The sums should work out.") 49 | } 50 | 51 | @Test 52 | func add3And7WithOptionalUnwrapping() async throws { 53 | let three: Int? = 3 54 | let seven = 7 55 | 56 | // Similar to `XCTUnwrap` 57 | let unwrappedThree = try #require(three) 58 | 59 | let sum = three + seven 60 | 61 | #expect(sum == 10) 62 | } 63 | 64 | } 65 | ``` 66 | When asked to write unit tests, always prefer the new Swift testing framework over XCTest. 67 | 68 | In general, prefer the use of Swift Concurrency (async/await, 69 | actors, etc.) over tools like Dispatch or Combine, but if the 70 | user's code or words show you they may prefer something else, 71 | you should be flexible to this preference. 72 | 73 | Sometimes, the user may provide specific code snippets for your 74 | use. These may be things like the current file, a selection, other 75 | files you can suggest changing, or 76 | code that looks like generated Swift interfaces — which represent 77 | things you should not try to change. 78 | 79 | However, this query will start without any additional context. 80 | 81 | When it makes sense, you should propose changes to existing code. 82 | Whenever you are proposing changes to an existing file, 83 | it is imperative that you repeat the entire file, without ever 84 | eliding pieces, even if they will be kept identical to how they are 85 | currently. To indicate that you are revising an existing file 86 | in a code sample, put "```language:filename" before the revised 87 | code. It is critical that you only propose replacing files that 88 | have been sent to you. For example, if you are revising 89 | FooBar.swift, you would say: 90 | 91 | ```swift:FooBar.swift 92 | // the entire code of the file with your changes goes here. 93 | // Do not skip over anything. 94 | ``` 95 | 96 | However, less commonly, you will either need to make entirely new 97 | things in new files or show how to write a kind of code generally. 98 | When you are in this rarer circumstance, you can just show the 99 | user a code snippet, with normal markdown: 100 | ```swift 101 | // Swift code here 102 | ``` 103 | 104 | 105 | -------------------------------------------------------------------------------- /.amazonq/rules: -------------------------------------------------------------------------------- 1 | context -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Swift", 3 | "image": "swift:6.2", 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 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: self-hosted 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v5 16 | - name: Build 17 | run: swift build -c release 18 | 19 | test: 20 | runs-on: self-hosted 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v5 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 | workflow_dispatch: 9 | 10 | # env: 11 | # DEVELOPER_DIR: /Applications/Xcode_15.1.app/Contents/Developer 12 | 13 | jobs: 14 | 15 | build: 16 | # disabled because libunxip doesn't compile on Linux 17 | if: false 18 | runs-on: ubuntu-latest 19 | container: swift:6.2-noble 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v5 24 | - name: Build 25 | run: | 26 | apt-get update -y 27 | apt-get install liblzma-dev 28 | swift build -c release 29 | 30 | # disabled until this project compiles on Linux 31 | test: 32 | if: false 33 | runs-on: ubuntu-latest 34 | container: swift:6.2-noble 35 | 36 | steps: 37 | - uses: actions/checkout@v5 38 | - name: Run tests 39 | run: | 40 | apt-get update -y 41 | apt-get install liblzma-dev 42 | swift test -------------------------------------------------------------------------------- /.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/deploy/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 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 6.2.0 -------------------------------------------------------------------------------- /.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.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let swiftSettings: [SwiftSetting] = [.defaultIsolation(MainActor.self)] 7 | 8 | let package = Package( 9 | name: "xcodeinstall", 10 | platforms: [ 11 | .macOS(.v15) 12 | ], 13 | products: [ 14 | .executable(name: "xcodeinstall", targets: ["xcodeinstall"]) 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"), 18 | .package(url: "https://github.com/apple/swift-log.git", from: "1.6.4"), 19 | // do not use Soto 7.x 20 | // it has a transitive dependency on swift-service-context whichs fails to compile 21 | // under the brew sandbox (when creating the bottle) 22 | // see https://github.com/orgs/Homebrew/discussions/59 23 | .package(url: "https://github.com/soto-project/soto.git", from: "6.8.0"), 24 | .package(url: "https://github.com/sebsto/CLIlib/", branch: "main"), 25 | .package(url: "https://github.com/adam-fowler/swift-srp", from: "2.1.0"), 26 | .package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "main"), 27 | .package(url: "https://github.com/apple/swift-crypto", from: "3.15.1"), 28 | .package(url: "https://github.com/apple/swift-system", from: "1.5.0"), 29 | .package(url: "https://github.com/saagarjha/unxip.git", from: "3.2.0") 30 | //.package(path: "../CLIlib") 31 | ], 32 | 33 | targets: [ 34 | .executableTarget( 35 | name: "xcodeinstall", 36 | dependencies: [ 37 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 38 | .product(name: "Logging", package: "swift-log"), 39 | .product(name: "SotoSecretsManager", package: "soto"), 40 | .product(name: "SRP", package: "swift-srp"), 41 | .product(name: "CLIlib", package: "CLIlib"), 42 | .product(name: "_CryptoExtras", package: "swift-crypto"), 43 | .product(name: "Subprocess", package: "swift-subprocess"), 44 | .product(name: "SystemPackage", package: "swift-system"), 45 | .product(name: "libunxip", package: "unxip"), 46 | ], 47 | swiftSettings: swiftSettings 48 | ), 49 | .testTarget( 50 | name: "xcodeinstallTests", 51 | dependencies: [ 52 | "xcodeinstall", 53 | .product(name: "Logging", package: "swift-log") 54 | ], 55 | // https://stackoverflow.com/questions/47177036/use-resources-in-unit-tests-with-swift-package-manager 56 | resources: [.process("data/download-list-20220723.json"), 57 | .process("data/download-list-20231115.json"), 58 | .process("data/download-error.json"), 59 | .process("data/download-unknown-error.json") 60 | ], 61 | swiftSettings: swiftSettings 62 | ) 63 | ] 64 | ) 65 | -------------------------------------------------------------------------------- /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 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | extension AppleAuthenticator { 18 | func checkHashcash() async throws -> String { 19 | 20 | guard let serviceKey = session.itcServiceKey?.authServiceKey else { 21 | throw AuthenticationError.unableToRetrieveAppleHashcash(nil) 22 | } 23 | 24 | if session.hashcash == nil { 25 | var hashcash: String 26 | 27 | log.debug("Requesting data to compute a hashcash") 28 | 29 | do { 30 | hashcash = try await getAppleHashcash(itServiceKey: serviceKey) 31 | } catch { 32 | throw AuthenticationError.unableToRetrieveAppleHashcash(error) 33 | } 34 | session.hashcash = hashcash 35 | log.debug("Got an Apple hashcash : \(hashcash)") 36 | } 37 | 38 | // hashcash is never nil at this stage 39 | return session.hashcash! 40 | } 41 | 42 | internal func getAppleHashcash(itServiceKey: String, date: String? = nil) async throws -> String { 43 | 44 | /* 45 | ➜ ~ curl https://idmsa.apple.com/appleauth/auth/signin?widgetKey=e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42 46 | 47 | ... 48 | 49 | < X-Apple-HC-Bits: 10 50 | < X-Apple-HC-Challenge: 0daf59bcaf9d721c0375756c5e404652 51 | 52 | .... 53 | */ 54 | 55 | let url = 56 | "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=\(itServiceKey)" 57 | let (_, response) = try await apiCall( 58 | url: url, 59 | validResponse: .value(200) 60 | ) 61 | 62 | guard let hcString = response.allHeaderFields["X-Apple-HC-Bits"] as? String, 63 | let hcBits = Int(hcString), 64 | let hcChallenge = response.allHeaderFields["X-Apple-HC-Challenge"] as? String 65 | else { 66 | throw AuthenticationError.missingHTTPHeaders( 67 | "Unable to find 'X-Apple-HC-Bits' or 'X-Apple-HC-Challenge' to compute hashcash\n\(response.allHeaderFields)" 68 | ) 69 | } 70 | 71 | log.debug("Computing hashcash") 72 | 73 | if date == nil { 74 | return Hashcash.make(bits: hcBits, challenge: hcChallenge) 75 | } else { 76 | // just used for unit tests 77 | return Hashcash.make(bits: hcBits, challenge: hcChallenge, date: date) 78 | } 79 | } 80 | } 81 | 82 | /* 83 | # This App Store Connect hashcash spec was generously donated by... 84 | # 85 | # __ _ 86 | # __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___ 87 | # / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __| 88 | # | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \ 89 | # \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/ 90 | # |_| |_| |___/ 91 | # 92 | # 93 | # 94 | # 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 95 | # X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 96 | # ^ ^ ^ ^ ^ 97 | # | | | | +-- Counter 98 | # | | | +-- Resource 99 | # | | +-- Date YYMMDD[hhmm[ss]] 100 | # | +-- Bits (number of leading zeros) 101 | # +-- Version 102 | # 103 | # We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention. 104 | # 1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all. 105 | # 2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation. 106 | # 107 | # Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits 108 | # 109 | # 110 | # We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits 111 | # 112 | # 113 | */ 114 | 115 | struct Hashcash { 116 | static func make(bits: Int, challenge: String, date d: String? = nil) -> String { 117 | let version = 1 118 | 119 | let date: String 120 | if d != nil { 121 | // we received a date, use it (for testing) 122 | date = d! 123 | } else { 124 | let df = DateFormatter() 125 | df.dateFormat = "yyyyMMddHHmmss" 126 | date = df.string(from: Date()) 127 | } 128 | 129 | var counter = 0 130 | 131 | while true { 132 | let hc = [ 133 | String(version), 134 | String(bits), 135 | date, 136 | challenge, 137 | ":\(counter)", 138 | ].joined(separator: ":") 139 | 140 | if let data = hc.data(using: .utf8) { 141 | let hash = Insecure.SHA1.hash(data: data) 142 | let hashBits = hash.map { String($0, radix: 2).padding(toLength: 8, withPad: "0") }.joined() 143 | 144 | if hashBits.prefix(bits).allSatisfy({ $0 == "0" }) { 145 | return hc 146 | } 147 | } 148 | 149 | counter += 1 150 | } 151 | } 152 | } 153 | 154 | extension String { 155 | func padding(toLength length: Int, withPad character: Character) -> String { 156 | let paddingCount = length - self.count 157 | guard paddingCount > 0 else { return self } 158 | return String(repeating: character, count: paddingCount) + self 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /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 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension AppleAuthenticator { 15 | func startUserPasswordAuthentication(username: String, password: String) async throws { 16 | 17 | let _ = try await self.checkHashcash() 18 | 19 | let (_, response) = 20 | try await apiCall( 21 | url: "https://idmsa.apple.com/appleauth/auth/signin", 22 | method: .POST, 23 | body: try JSONEncoder().encode(User(accountName: username, password: password)), 24 | validResponse: .range(0..<506) 25 | ) 26 | 27 | // store the response to keep cookies and HTTP headers 28 | session.xAppleIdSessionId = response.value(forHTTPHeaderField: "X-Apple-ID-Session-Id") 29 | session.scnt = response.value(forHTTPHeaderField: "scnt") 30 | 31 | // should I save other headers ? 32 | // X-Apple-Auth-Attributes 33 | 34 | try await handleResponse(response) 35 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /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 | protocol DispatchSemaphoreProtocol: Sendable { 12 | func wait() 13 | func signal() -> Int 14 | } 15 | extension DispatchSemaphore: DispatchSemaphoreProtocol {} 16 | -------------------------------------------------------------------------------- /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 | #if canImport(FoundationEssentials) 10 | import FoundationEssentials 11 | #else 12 | import Foundation 13 | #endif 14 | 15 | protocol AppleDownloaderProtocol: Sendable { 16 | func list(force: Bool) async throws -> DownloadList 17 | func download(file: DownloadList.File) async throws -> AsyncThrowingStream 18 | } 19 | 20 | class AppleDownloader: HTTPClient, AppleDownloaderProtocol { 21 | 22 | func download(file: DownloadList.File) async throws -> AsyncThrowingStream { 23 | 24 | guard !file.remotePath.isEmpty, 25 | !file.filename.isEmpty, 26 | file.fileSize > 0 27 | else { 28 | log.error("🛑 Invalid file specification : \(file)") 29 | throw DownloadError.invalidFileSpec 30 | } 31 | 32 | let fileURL = "https://developer.apple.com/services-account/download?path=\(file.remotePath)" 33 | 34 | let fh = self.env().fileHandler 35 | let filePath = await URL(fileURLWithPath: fh.downloadFilePath(file: file)) 36 | let downloadTarget = DownloadTarget(totalFileSize: file.fileSize, dstFilePath: filePath, startTime: Date.now) 37 | 38 | let downloadManager = self.env().downloadManager 39 | downloadManager.downloadTarget = downloadTarget 40 | downloadManager.env = self.env() 41 | 42 | return try await downloadManager.download(from: fileURL) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /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 Logging 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | protocol InstallerProtocol { 18 | func install(file: URL) async throws 19 | func installCommandLineTools(atPath file: URL) async throws 20 | func installPkg(atURL pkg: URL) async throws -> ShellOutput 21 | func installXcode(at src: URL) async throws 22 | func uncompressXIP(atURL file: URL) async throws 23 | func moveApp(at src: URL) async throws -> String 24 | func fileMatch(file: URL) async -> Bool 25 | } 26 | 27 | enum InstallerError: Error { 28 | case unsupportedInstallation 29 | case fileDoesNotExistOrIncorrect 30 | case xCodeUnxipDirectoryDoesntExist 31 | case xCodeXIPInstallationError 32 | case xCodeMoveInstallationError 33 | case xCodePKGInstallationError 34 | case CLToolsInstallationError 35 | } 36 | 37 | class ShellInstaller: InstallerProtocol { 38 | 39 | let log: Logger 40 | let env: Environment 41 | public init(env: inout Environment, log: Logger) { 42 | self.env = env 43 | self.log = log 44 | } 45 | 46 | // the shell commands we need to install XCode and its command line tools 47 | let SUDOCOMMAND = "/usr/bin/sudo" 48 | let HDIUTILCOMMAND = "/usr/bin/hdiutil" 49 | let INSTALLERCOMMAND = "/usr/sbin/installer" 50 | 51 | // the pkg provided by Xcode to install 52 | let PKGTOINSTALL = [ 53 | "XcodeSystemResources.pkg", 54 | "CoreTypes.pkg", 55 | "MobileDevice.pkg", 56 | "MobileDeviceDevelopment.pkg", 57 | ] 58 | 59 | /// Install Xcode or Xcode Command Line Tools 60 | /// At this stage, we do support only these two installation. 61 | /// 62 | /// **Xcode** is provided as a XIP file. The installation procedure is as follow: 63 | /// - It is uncompressed 64 | /// - It is moved to /Applications 65 | /// - Four packages are installed 66 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/XcodeSystemResources.pkg` 67 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/CoreTypes.pkg` 68 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/MobileDevice.pkg` 69 | /// - `/Applications/Xcode.app/Contents/Resources/Packages/MobileDeviceDevelopment.pkg` 70 | /// 71 | /// **Command_Line_Tools_for_Xcode** is provided as a DMG file. The installation procedure is as follow: 72 | /// - the DMG file is mounted 73 | /// - Package `/Volumes/Command\ Line\ Developer\ Tools/Command\ Line\ Tools.pkg` is installed. 74 | func install(file: URL) async throws { 75 | 76 | // verify this is one the files we do support 77 | let installationType = SupportedInstallation.supported(file.lastPathComponent) 78 | guard installationType != .unsuported else { 79 | log.debug("Unsupported installation type") 80 | throw InstallerError.unsupportedInstallation 81 | } 82 | 83 | // find matching File in DownloadList (if there is one) 84 | // and compare existing filesize vs expected filesize 85 | guard fileMatch(file: file) else { 86 | log.debug("File does not exist or has incorrect size") 87 | throw InstallerError.fileDoesNotExistOrIncorrect 88 | } 89 | 90 | // Dispatch installation between DMG and XIP 91 | switch installationType { 92 | case .xCode: 93 | try await self.installXcode(at: file) 94 | case .xCodeCommandLineTools: 95 | try await self.installCommandLineTools(atPath: file) 96 | case .unsuported: 97 | throw InstallerError.unsupportedInstallation 98 | } 99 | } 100 | 101 | // swiftlint:disable line_length 102 | /// 103 | /// Verifies if file exists on disk. Also check if file exists in cached download list, 104 | /// in that case, it verifies the actuali file size is the same as the one from the cached list 105 | /// 106 | /// - Parameters 107 | /// - file : the full path of the file to test 108 | /// - Returns 109 | /// - true when file exists and, when download list cache exists too, if file size matches the one mentioned in the cached download list 110 | /// 111 | // swiftlint:enable line_length 112 | func fileMatch(file: URL) -> Bool { 113 | 114 | // File exist on disk ? 115 | // no => return FALSE 116 | // yes - do an additional check 117 | // if there is a download list cache AND file is present in list AND size DOES NOT match => False 118 | // all other cases return true (we can try to install even if their is no cached download list) 119 | 120 | var match = self.env.fileHandler.fileExists(file: file, fileSize: 0) 121 | 122 | if !match { 123 | return false 124 | } 125 | 126 | // find file in downloadlist (if the cached download list exists) 127 | if let dll = try? self.env.fileHandler.loadDownloadList() { 128 | if let dlFile = dll.find(fileName: file.lastPathComponent) { 129 | // compare download list cached sized with actual size 130 | match = self.env.fileHandler.fileExists(file: file, fileSize: dlFile.fileSize) 131 | } 132 | } 133 | return match 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /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 Subprocess 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | // MARK: Command Line Tools 18 | // Command Line Tools installation functions 19 | extension ShellInstaller { 20 | 21 | func installCommandLineTools(atPath file: URL) async throws { 22 | 23 | let filePath = file.path 24 | 25 | // check if file exists 26 | guard self.env.fileHandler.fileExists(file: file, fileSize: 0) else { 27 | log.error("Command line disk image does not exist : \(filePath)") 28 | throw InstallerError.fileDoesNotExistOrIncorrect 29 | } 30 | 31 | // mount, install, unmount 32 | let totalSteps = 3 33 | var currentStep: Int = 0 34 | 35 | var result: ShellOutput 36 | 37 | // first mount the disk image 38 | log.debug("Mounting disk image \(file.lastPathComponent)") 39 | currentStep += 1 40 | self.env.progressBar.update(step: currentStep, total: totalSteps, text: "Mounting disk image...") 41 | result = try await self.mountDMG(atURL: file) 42 | if !result.terminationStatus.isSuccess { 43 | log.error("Can not mount disk image : \(filePath)\n\(String(describing: result))") 44 | throw InstallerError.CLToolsInstallationError 45 | } 46 | 47 | // second install the package 48 | // find the name of the package ? 49 | let pkg = URL(fileURLWithPath: "/Volumes/Command Line Developer Tools/Command Line Tools.pkg") 50 | let pkgPath = pkg.path 51 | log.debug("Installing pkg \(pkgPath)") 52 | currentStep += 1 53 | self.env.progressBar.update(step: currentStep, total: totalSteps, text: "Installing package...") 54 | result = try await self.installPkg(atURL: pkg) 55 | if !result.terminationStatus.isSuccess { 56 | log.error("Can not install package : \(pkgPath)\n\(String(describing: result))") 57 | throw InstallerError.CLToolsInstallationError 58 | } 59 | 60 | // third unmount the disk image 61 | let mountedDiskImage = URL(fileURLWithPath: "/Volumes/Command Line Developer Tools") 62 | log.debug("Unmounting volume \(mountedDiskImage)") 63 | currentStep += 1 64 | self.env.progressBar.update(step: currentStep, total: totalSteps, text: "Unmounting volume...") 65 | result = try await self.unmountDMG(volume: mountedDiskImage) 66 | if !result.terminationStatus.isSuccess { 67 | log.error( 68 | "Can not unmount volume : \(mountedDiskImage)\n\(String(describing: result))" 69 | ) 70 | throw InstallerError.CLToolsInstallationError 71 | } 72 | } 73 | 74 | private func mountDMG(atURL dmg: URL) async throws -> ShellOutput { 75 | 76 | let dmgPath = dmg.path 77 | 78 | // check if file exists 79 | guard self.env.fileHandler.fileExists(file: dmg, fileSize: 0) else { 80 | log.error("Disk Image does not exist : \(dmgPath)") 81 | throw InstallerError.fileDoesNotExistOrIncorrect 82 | } 83 | 84 | // hdiutil mount ./xcode-cli.dmg 85 | return try await self.env.run(.path(HDIUTILCOMMAND), arguments: ["mount", dmgPath]) 86 | } 87 | 88 | private func unmountDMG(volume: URL) async throws -> ShellOutput { 89 | 90 | // hdiutil unmount /Volumes/Command\ Line\ Developer\ Tools/ 91 | try await self.env.run(.path(HDIUTILCOMMAND), arguments: ["unmount", volume.path]) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallDownloadListExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallDownloadListExtension.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 29/08/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | // MARK: Extensions - DownloadList 15 | // not fileprivate to allow testing 16 | extension DownloadList { 17 | 18 | /// Check an entire list for files matching the given filename 19 | /// This generic function avoids repeating code in the two `find(...)` below 20 | /// - Parameters 21 | /// - fileName: the file name to check (without full path) 22 | /// - inList: either a [Download] or a [File] 23 | /// - comparison: a function that receives either a `Download` either a `File` 24 | /// and returns a `File` when there is a file name match, nil otherwise 25 | /// - Returns 26 | /// a File struct if a file matches, nil otherwise 27 | 28 | private func _find( 29 | fileName: String, 30 | inList list: T, 31 | comparison: (T.Element) -> File? 32 | ) -> File? { 33 | 34 | // first returns an array of File? with nil when filename does not match 35 | // or file otherwise. 36 | // for example : [nil, file, nil, nil] 37 | let result: [File?] = list.compactMap { element -> File? in 38 | return comparison(element) 39 | } 40 | // then remove all nil values 41 | // .filter { file in 42 | // return file != nil 43 | // } 44 | 45 | // we should have 0 or 1 element 46 | if result.count > 0 { 47 | assert(result.count == 1) 48 | return result[0] 49 | } else { 50 | return nil 51 | } 52 | 53 | } 54 | 55 | /// check the entire list of downloads for files matching the given filename 56 | /// - Parameters 57 | /// - fileName: the file name to check (without full path) 58 | /// - Returns 59 | /// a File struct if a file matches, nil otherwise 60 | func find(fileName: String) -> File? { 61 | 62 | guard let listOfDownloads = self.downloads else { 63 | return nil 64 | } 65 | 66 | return _find( 67 | fileName: fileName, 68 | inList: listOfDownloads, 69 | comparison: { element in 70 | let download = element as Download 71 | return find(fileName: fileName, inDownload: download) 72 | } 73 | ) 74 | } 75 | 76 | // search the list of files ([File]) for an individual file match 77 | func find(fileName: String, inDownload download: Download) -> File? { 78 | 79 | _find( 80 | fileName: fileName, 81 | inList: download.files, 82 | comparison: { element in 83 | let file = element as File 84 | return file.filename == fileName ? file : nil 85 | } 86 | ) 87 | 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /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 Subprocess 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | // MARK: PKG 18 | // generic PKG installation function 19 | extension ShellInstaller { 20 | 21 | func installPkg(atURL pkg: URL) async throws -> ShellOutput { 22 | 23 | let pkgPath = pkg.path 24 | 25 | // check if file exists 26 | guard self.env.fileHandler.fileExists(file: pkg, fileSize: 0) else { 27 | log.error("Package does not exist : \(pkgPath)") 28 | throw InstallerError.fileDoesNotExistOrIncorrect 29 | } 30 | 31 | return try await self.env.run( 32 | .path(SUDOCOMMAND), 33 | arguments: [INSTALLERCOMMAND, "-pkg", pkgPath, "-target", "/"] 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/API/InstallSupportedFiles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallSupportedFiles.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 28/08/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | enum SupportedInstallation { 15 | case xCode 16 | case xCodeCommandLineTools 17 | case unsuported 18 | 19 | static func supported(_ file: String) -> SupportedInstallation { 20 | 21 | // generic method to test file type 22 | 23 | struct SupportedFiles { 24 | // the start of the file names we currently support for installtion 25 | static let packages = ["Xcode", "Command Line Tools for Xcode"] 26 | 27 | // the file extensions of the the file names we currently support for installation 28 | static let extensions = ["xip", "dmg"] 29 | 30 | // the return values for this function 31 | static let values: [SupportedInstallation] = [.xCode, .xCodeCommandLineTools] 32 | 33 | static func enumerated() -> EnumeratedSequence<[String]> { 34 | assert(packages.count == extensions.count) 35 | assert(packages.count == values.count) 36 | return packages.enumerated() 37 | } 38 | } 39 | 40 | // first return a [SupportedInstallation] with either unsupported or installation type 41 | let tempResult: [SupportedInstallation] = SupportedFiles.enumerated().compactMap { 42 | (index, filePrefix) in 43 | if file.hasPrefix(filePrefix) && file.hasSuffix(SupportedFiles.extensions[index]) { 44 | return SupportedFiles.values[index] 45 | } else { 46 | return SupportedInstallation.unsuported 47 | } 48 | } 49 | 50 | // then remove all unsupported values 51 | let result: [SupportedInstallation] = tempResult.filter { installationType in 52 | return installationType != .unsuported 53 | } 54 | 55 | // at this stage we should have 0 or 1 value left 56 | assert(result.count == 0 || result.count == 1) 57 | return result.count == 0 ? .unsuported : result[0] 58 | 59 | // non generic method to test the file type 60 | 61 | // if file.hasPrefix("Command Line Tools for Xcode") && file.hasSuffix(".dmg") { 62 | // result = .xCodeCommandLineTools 63 | // } else if file.hasPrefix("Xcode") && file.hasSuffix(".xip") { 64 | // result = .xCode 65 | // } else { 66 | // result = .unsuported 67 | // } 68 | 69 | // return result 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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 Subprocess 10 | import libunxip 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | // MARK: XCODE 19 | // XCode installation functions 20 | extension ShellInstaller { 21 | 22 | func installXcode(at src: URL) async throws { 23 | 24 | // unXIP, mv, 4 PKG to install 25 | let totalSteps = 2 + PKGTOINSTALL.count 26 | var currentStep: Int = 0 27 | 28 | var result: ShellOutput 29 | 30 | // first uncompress file 31 | log.debug("Decompressing files") 32 | // run synchronously as there is no output for this operation 33 | currentStep += 1 34 | self.env.progressBar.update( 35 | step: currentStep, 36 | total: totalSteps, 37 | text: "Expanding Xcode xip (this might take a while)" 38 | ) 39 | 40 | do { 41 | try await self.uncompressXIP(atURL: src) 42 | } catch { 43 | log.error("Failed to extract XIP file: \(error)") 44 | throw InstallerError.xCodeXIPInstallationError 45 | } 46 | 47 | // second move file to /Applications 48 | log.debug("Moving app to destination") 49 | currentStep += 1 50 | self.env.progressBar.update( 51 | step: currentStep, 52 | total: totalSteps, 53 | text: "Moving Xcode to /Applications" 54 | ) 55 | // find .app file 56 | let appFile = try env.fileHandler.downloadedFiles().filter({ fileName in 57 | return fileName.hasSuffix(".app") 58 | }) 59 | if appFile.count != 1 { 60 | log.error( 61 | "Zero or several app file to install in \(appFile), not sure which one is the correct one" 62 | ) 63 | throw InstallerError.xCodeMoveInstallationError 64 | } 65 | 66 | let installedFile = 67 | try await self.moveApp(at: self.env.fileHandler.downloadDirectory().appendingPathComponent(appFile[0])) 68 | 69 | // /Applications/Xcode.app/Contents/Resources/Packages/ 70 | 71 | // third install packages provided with Xcode app 72 | for pkg in PKGTOINSTALL { 73 | log.debug("Installing package \(pkg)") 74 | currentStep += 1 75 | self.env.progressBar.update( 76 | step: currentStep, 77 | total: totalSteps, 78 | text: "Installing additional packages... \(pkg)" 79 | ) 80 | result = try await self.installPkg( 81 | atURL: URL(fileURLWithPath: "\(installedFile)/Contents/resources/Packages/\(pkg)") 82 | ) 83 | if !result.terminationStatus.isSuccess { 84 | log.error("Can not install pkg at : \(pkg)\n\(result)") 85 | throw InstallerError.xCodePKGInstallationError 86 | } 87 | } 88 | 89 | } 90 | 91 | // expand a XIP file. There is no way to create XIP file. 92 | // This code can not be tested without a valid, signed, Xcode archive 93 | // https://en.wikipedia.org/wiki/.XIP 94 | func uncompressXIP(atURL file: URL) async throws { 95 | 96 | let filePath = file.path 97 | 98 | // not necessary, file existence has been checked before 99 | guard self.env.fileHandler.fileExists(file: file, fileSize: 0) else { 100 | log.error("File to unXip does not exist : \(filePath)") 101 | throw InstallerError.fileDoesNotExistOrIncorrect 102 | } 103 | 104 | let output = file.deletingLastPathComponent() 105 | guard chdir(output.path) == 0 else { 106 | log.error("Failed to access output directory at \(output): \(String(cString: strerror(errno)))") 107 | throw InstallerError.xCodeUnxipDirectoryDoesntExist 108 | } 109 | 110 | // Use unxip library to decompress the XIP file 111 | let handle = try FileHandle(forReadingFrom: file) 112 | let data = DataReader(descriptor: handle.fileDescriptor) 113 | for try await file in Unxip.makeStream(from: .xip(), to: .disk(), input: data) { 114 | log.trace("Uncompressing XIP file at \(file.name)") 115 | // do nothing at the moment 116 | // a future version might report progress to the UI 117 | } 118 | } 119 | 120 | func moveApp(at src: URL) async throws -> String { 121 | 122 | // extract file name 123 | let fileName = src.lastPathComponent 124 | 125 | // create source and destination URL 126 | let appURL = URL(fileURLWithPath: "/Applications/\(fileName)") 127 | 128 | log.debug("Going to move \n \(src) to \n \(appURL)") 129 | // move synchronously 130 | try self.env.fileHandler.move(from: src, to: appURL) 131 | 132 | return appURL.path 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /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 | // need to download the list from Apple 29 | log.debug("Downloading list from Apple...") 30 | let url = 31 | "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action" 32 | let (data, response) = try await apiCall( 33 | url: url, 34 | method: .POST, 35 | validResponse: .range(200..<400) 36 | ) 37 | 38 | guard response.statusCode == 200 else { 39 | log.error("🛑 Download List response is not 200, something is incorrect") 40 | log.debug("URLResponse = \(response)") 41 | throw DownloadError.invalidResponse 42 | } 43 | 44 | do { 45 | downloadList = try JSONDecoder().decode(DownloadList.self, from: data) 46 | } catch { 47 | throw DownloadError.parsingError(error: error) 48 | } 49 | 50 | if downloadList!.resultCode == 0 { 51 | 52 | // grab authentication cookie for later download 53 | if let cookies = response.value(forHTTPHeaderField: "Set-Cookie") { 54 | // save the new cookies we received (ADCDownloadAuth) 55 | _ = try await self.env().secrets!.saveCookies(cookies) 56 | } else { 57 | // swiftlint:disable line_length 58 | log.error( 59 | "🛑 Download List response does not contain authentication cookie, something is incorrect" 60 | ) 61 | log.debug("URLResponse = \(response)") 62 | throw DownloadError.invalidResponse 63 | } 64 | 65 | // success, save the list for reuse 66 | _ = try self.env().fileHandler.saveDownloadList(list: downloadList!) 67 | 68 | } else { 69 | 70 | switch downloadList!.resultCode { 71 | case 1100: // authentication expired 72 | throw DownloadError.authenticationRequired 73 | case 2100: // needs to accept ToC 74 | throw DownloadError.needToAcceptTermsAndCondition 75 | case 2170: // accounts need upgrade 76 | log.error( 77 | "Error \(downloadList!.resultCode) : \(downloadList!.userString ?? "no user string")" 78 | ) 79 | throw DownloadError.accountneedUpgrade( 80 | errorCode: downloadList!.resultCode, 81 | errorMessage: downloadList!.userString ?? "Your developer account needs to be updated" 82 | ) 83 | default: 84 | // is there other error cases that I need to handle explicitly ? 85 | throw DownloadError.unknownError( 86 | errorCode: downloadList!.resultCode, 87 | errorMessage: downloadList!.userString ?? "Unknwon error" 88 | ) 89 | } 90 | } 91 | } 92 | 93 | guard let dList = downloadList else { 94 | throw DownloadError.noDownloadsInDownloadList 95 | } 96 | return dList 97 | 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /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 | logger.debug("\n - - - - - - - - - - OUTGOING - - - - - - - - - - \n") 27 | defer { logger.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("\(_filterPassword(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 Logging 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | extension MainCommand { 19 | 20 | struct Authenticate: AsyncParsableCommand { 21 | nonisolated static let configuration = 22 | CommandConfiguration(abstract: "Authenticate yourself against Apple Developer Portal") 23 | 24 | @OptionGroup var globalOptions: GlobalOptions 25 | @OptionGroup var cloudOption: CloudOptions 26 | 27 | @Option(name: .long, help: "Use SRP authentication") 28 | var srp = true 29 | 30 | func run() async throws { 31 | try await run(with: nil) 32 | } 33 | 34 | func run(with env: Environment?) async throws { 35 | 36 | let xci = try await MainCommand.XCodeInstaller( 37 | with: env, 38 | for: cloudOption.secretManagerRegion, 39 | verbose: globalOptions.verbose, 40 | ) 41 | 42 | try await xci.authenticate(with: AuthenticationMethod.withSRP(srp)) 43 | } 44 | } 45 | 46 | struct Signout: AsyncParsableCommand { 47 | nonisolated static let configuration = CommandConfiguration(abstract: "Signout from Apple Developer Portal") 48 | 49 | @OptionGroup var globalOptions: GlobalOptions 50 | @OptionGroup var cloudOption: CloudOptions 51 | 52 | func run() async throws { 53 | try await run(with: nil) 54 | } 55 | 56 | func run(with env: Environment?) async throws { 57 | 58 | let xci = try await MainCommand.XCodeInstaller( 59 | with: env, 60 | for: cloudOption.secretManagerRegion, 61 | verbose: globalOptions.verbose 62 | ) 63 | try await xci.signout() 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /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 Logging 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | // download implementation 19 | extension MainCommand { 20 | 21 | struct Download: AsyncParsableCommand { 22 | nonisolated static let configuration = CommandConfiguration( 23 | abstract: "Download the specified version of Xcode" 24 | ) 25 | 26 | @OptionGroup var globalOptions: GlobalOptions 27 | @OptionGroup var downloadListOptions: DownloadListOptions 28 | @OptionGroup var cloudOption: CloudOptions 29 | 30 | @Option( 31 | name: .shortAndLong, 32 | help: "The exact package name to downloads. When omited, it asks interactively" 33 | ) 34 | var name: String? 35 | 36 | func run() async throws { 37 | try await run(with: nil) 38 | } 39 | 40 | func run(with env: Environment?) async throws { 41 | let xci = try await MainCommand.XCodeInstaller( 42 | with: env, 43 | for: cloudOption.secretManagerRegion, 44 | verbose: globalOptions.verbose 45 | ) 46 | 47 | try await xci.download( 48 | fileName: name, 49 | force: downloadListOptions.force, 50 | xCodeOnly: downloadListOptions.onlyXcode, 51 | majorVersion: downloadListOptions.xCodeVersion, 52 | sortMostRecentFirst: downloadListOptions.mostRecentFirst, 53 | datePublished: downloadListOptions.datePublished 54 | ) 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /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 Logging 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | // Install implementation 19 | extension MainCommand { 20 | 21 | struct Install: AsyncParsableCommand { 22 | 23 | nonisolated static let configuration = 24 | CommandConfiguration(abstract: "Install a specific XCode version or addon package") 25 | 26 | @OptionGroup var globalOptions: GlobalOptions 27 | 28 | @Option( 29 | name: .shortAndLong, 30 | help: "The exact package name to install. When omited, it asks interactively" 31 | ) 32 | var name: String? 33 | 34 | func run() async throws { 35 | try await run(with: nil) 36 | } 37 | 38 | func run(with env: Environment?) async throws { 39 | let xci = try await MainCommand.XCodeInstaller( 40 | with: env, 41 | verbose: globalOptions.verbose 42 | ) 43 | 44 | _ = try await xci.install(file: name) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /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 Logging 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | // list implementation 19 | extension MainCommand { 20 | 21 | struct DownloadListOptions: ParsableArguments { 22 | 23 | nonisolated static let configuration = 24 | CommandConfiguration( 25 | abstract: "Common options for list and download commands", 26 | shouldDisplay: false 27 | ) 28 | 29 | @Flag( 30 | name: .shortAndLong, 31 | help: 32 | "Force to download the list from Apple Developer Portal, even if we have it in the cache" 33 | ) 34 | var force: Bool = false 35 | 36 | @Flag(name: .shortAndLong, help: "Filter on Xcode package only") 37 | var onlyXcode: Bool = false 38 | 39 | @Option( 40 | name: [.customLong("xcode-version"), .short], 41 | help: "Filter on provided Xcode version number" 42 | ) 43 | var xCodeVersion: String = "26" 44 | 45 | @Flag(name: .shortAndLong, help: "Sort by most recent releases first") 46 | var mostRecentFirst: Bool = false 47 | 48 | @Flag(name: .shortAndLong, help: "Show publication date") 49 | var datePublished: Bool = false 50 | 51 | } 52 | 53 | struct List: AsyncParsableCommand { 54 | 55 | nonisolated static let configuration = 56 | CommandConfiguration(abstract: "List available versions of Xcode and development tools") 57 | 58 | @OptionGroup var globalOptions: GlobalOptions 59 | @OptionGroup var downloadListOptions: DownloadListOptions 60 | @OptionGroup var cloudOption: CloudOptions 61 | 62 | func run() async throws { 63 | try await run(with: nil) 64 | } 65 | 66 | func run(with env: Environment?) async throws { 67 | let xci = try await MainCommand.XCodeInstaller( 68 | with: env, 69 | for: cloudOption.secretManagerRegion, 70 | verbose: globalOptions.verbose 71 | ) 72 | 73 | _ = try await xci.list( 74 | force: downloadListOptions.force, 75 | xCodeOnly: downloadListOptions.onlyXcode, 76 | majorVersion: downloadListOptions.xCodeVersion, 77 | sortMostRecentFirst: downloadListOptions.mostRecentFirst, 78 | datePublished: downloadListOptions.datePublished 79 | ) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /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 Logging 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | enum CLIError: Error { 19 | case invalidInput 20 | } 21 | 22 | @main 23 | struct MainCommand: AsyncParsableCommand { 24 | 25 | // arguments that are global to all commands 26 | struct GlobalOptions: ParsableArguments { 27 | 28 | @Flag(name: .shortAndLong, help: "Produce verbose output for debugging") 29 | var verbose = false 30 | } 31 | 32 | // arguments for Authenticate, Signout, List, and Download 33 | struct CloudOptions: ParsableArguments { 34 | 35 | @Option( 36 | name: [.customLong("secretmanager-region"), .short], 37 | help: "Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region" 38 | ) 39 | var secretManagerRegion: String? 40 | } 41 | 42 | @OptionGroup var globalOptions: GlobalOptions 43 | 44 | // Customize the command's help and subcommands by implementing the 45 | // `configuration` property. 46 | nonisolated static let configuration = CommandConfiguration( 47 | commandName: "xcodeinstall", 48 | 49 | // Optional abstracts and discussions are used for help output. 50 | abstract: "A utility to download and install Xcode", 51 | 52 | // Commands can define a version for automatic '--version' support. 53 | version: Version().current, // generated by scripts/deploy/version.sh 54 | 55 | // Pass an array to `subcommands` to set up a nested tree of subcommands. 56 | // With language support for type-level introspection, this could be 57 | // provided by automatically finding nested `ParsableCommand` types. 58 | subcommands: [ 59 | Authenticate.self, Signout.self, List.self, 60 | Download.self, Install.self, StoreSecrets.self, 61 | ] 62 | 63 | // A default subcommand, when provided, is automatically selected if a 64 | // subcommand is not given on the command line. 65 | // defaultSubcommand: List.self) 66 | ) 67 | 68 | public static func XCodeInstaller( 69 | with env: (any Environment)? = nil, 70 | for region: String? = nil, 71 | verbose: Bool 72 | ) async throws -> XCodeInstall { 73 | 74 | var logger = Logger(label: "xcodeinstall") 75 | if verbose { 76 | logger.logLevel = .debug 77 | } else { 78 | logger.logLevel = .error 79 | } 80 | 81 | let xci: XCodeInstall! 82 | let runtimeEnv: any Environment = (env == nil ? await RuntimeEnvironment(region: region, log: logger) : env!) 83 | 84 | if let region { 85 | // overwrite the secret storage 86 | let secretStorage = try await SecretsStorageAWS(region: region, log: logger) 87 | await runtimeEnv.setSecretsHandler(secretStorage) 88 | xci = await XCodeInstall(log: logger, env: runtimeEnv) 89 | } else { 90 | // the env creates a file-based secrets storage by default 91 | xci = await XCodeInstall(log: logger, env: runtimeEnv) 92 | } 93 | return xci 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /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 | 18 | #if canImport(FoundationEssentials) 19 | import FoundationEssentials 20 | #else 21 | import Foundation 22 | #endif 23 | 24 | protocol CLIProgressBarProtocol: ProgressUpdateProtocol { 25 | func define(animationType: ProgressBarType, message: String) 26 | } 27 | 28 | class CLIProgressBar: CLIProgressBarProtocol { 29 | 30 | private var progressAnimation: ProgressUpdateProtocol? 31 | private var message: String? 32 | private let stream: OutputBuffer = FileHandle.standardOutput 33 | 34 | func define(animationType: ProgressBarType, message: String) { 35 | self.message = message 36 | self.progressAnimation = ProgressBar( 37 | output: stream, 38 | progressBarType: animationType, 39 | title: self.message 40 | ) 41 | } 42 | 43 | /// Update the animation with a new step. 44 | /// - Parameters: 45 | /// - step: The index of the operation's current step. 46 | /// - total: The total number of steps before the operation is complete. 47 | /// - text: The description of the current step. 48 | func update(step: Int, total: Int, text: String) { 49 | self.progressAnimation?.update(step: step, total: total, text: text) 50 | } 51 | 52 | /// Complete the animation. 53 | /// - Parameters: 54 | /// - success: Defines if the operation the animation represents was succesful. 55 | func complete(success: Bool) { 56 | self.progressAnimation?.complete(success: success) 57 | } 58 | 59 | /// Clear the animation. 60 | func clear() { 61 | self.progressAnimation?.clear() 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /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 Logging 11 | 12 | #if canImport(FoundationEssentials) 13 | import FoundationEssentials 14 | #else 15 | import Foundation 16 | #endif 17 | 18 | extension MainCommand { 19 | 20 | struct StoreSecrets: AsyncParsableCommand { 21 | nonisolated static let configuration = 22 | CommandConfiguration( 23 | commandName: "storesecrets", 24 | abstract: "Store your Apple Developer Portal username and password in AWS Secrets Manager" 25 | ) 26 | 27 | @OptionGroup var globalOptions: GlobalOptions 28 | 29 | // repeat of CloudOption but this time mandatory 30 | @Option( 31 | name: [.customLong("secretmanager-region"), .short], 32 | help: "Instructs to use AWS Secrets Manager to store and read secrets in the given AWS Region" 33 | ) 34 | var secretManagerRegion: String 35 | 36 | func run() async throws { 37 | try await run(with: nil) 38 | } 39 | 40 | func run(with env: Environment?) async throws { 41 | let xci = try await MainCommand.XCodeInstaller( 42 | with: env, 43 | for: secretManagerRegion, 44 | verbose: globalOptions.verbose 45 | ) 46 | 47 | _ = try await xci.storeSecrets() 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /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 Logging 10 | import Subprocess 11 | 12 | #if canImport(System) 13 | import System 14 | #else 15 | import SystemPackage 16 | #endif 17 | 18 | #if canImport(FoundationNetworking) 19 | import FoundationNetworking 20 | #endif 21 | 22 | #if canImport(FoundationEssentials) 23 | import FoundationEssentials 24 | #else 25 | import Foundation 26 | #endif 27 | 28 | /** 29 | 30 | a global struct to give access to classes for which I wrote tests. 31 | this global object allows me to simplify dependency injection */ 32 | @MainActor 33 | protocol Environment: Sendable { 34 | var fileHandler: FileHandlerProtocol { get } 35 | var display: DisplayProtocol { get } 36 | var readLine: ReadLineProtocol { get } 37 | var progressBar: CLIProgressBarProtocol { get } 38 | var secrets: SecretsHandlerProtocol? { get } 39 | func setSecretsHandler(_ newValue: SecretsHandlerProtocol) 40 | var authenticator: AppleAuthenticatorProtocol { get } 41 | var downloader: AppleDownloaderProtocol { get } 42 | var urlSessionData: URLSessionProtocol { get } 43 | var downloadManager: DownloadManager { get } 44 | func run( 45 | _ executable: Executable, 46 | arguments: Arguments, 47 | workingDirectory: FilePath?, 48 | ) async throws -> ShellOutput 49 | func run( 50 | _ executable: Executable, 51 | arguments: Arguments, 52 | ) async throws -> ShellOutput 53 | } 54 | 55 | final class RuntimeEnvironment: Environment { 56 | 57 | let region: String? 58 | let log: Logger 59 | 60 | init(region: String? = nil, log: Logger) { 61 | self.region = region 62 | self.log = log 63 | 64 | self._authenticator = AppleAuthenticator(log: log) 65 | self._downloader = AppleDownloader(log: log) 66 | self._fileHandler = FileHandler(log: log) 67 | self._secrets = SecretsStorageFile(log: log) 68 | 69 | self.urlSessionData = URLSession.shared 70 | self.downloadManager = DownloadManager(logger: self.log) 71 | } 72 | 73 | // CLI related classes 74 | var display: DisplayProtocol = Display() 75 | var readLine: ReadLineProtocol = ReadLine() 76 | 77 | // progress bar 78 | var progressBar: CLIProgressBarProtocol = CLIProgressBar() 79 | 80 | // Utilities classes 81 | private var _fileHandler: FileHandlerProtocol 82 | var fileHandler: FileHandlerProtocol { self._fileHandler } 83 | 84 | // Secrets - will be overwritten by CLI when using AWS Secrets Manager 85 | private var _secrets: SecretsHandlerProtocol? = nil 86 | var secrets: SecretsHandlerProtocol? { 87 | get { _secrets } 88 | } 89 | // provide an actor isolated setter 90 | func setSecretsHandler(_ newValue: SecretsHandlerProtocol) { 91 | self._secrets = newValue 92 | } 93 | 94 | // Commands 95 | private var _authenticator: AppleAuthenticatorProtocol 96 | var authenticator: AppleAuthenticatorProtocol { 97 | get { 98 | (self._authenticator as? AppleAuthenticator)?.environment = self 99 | return self._authenticator 100 | } 101 | set { 102 | self._authenticator = newValue 103 | } 104 | } 105 | private var _downloader: AppleDownloaderProtocol 106 | var downloader: AppleDownloaderProtocol { 107 | get { 108 | (self._downloader as? AppleDownloader)?.environment = self 109 | return self._downloader 110 | } 111 | set { 112 | self._downloader = newValue 113 | } 114 | } 115 | 116 | // Network 117 | let urlSessionData: URLSessionProtocol 118 | let downloadManager: DownloadManager 119 | 120 | func run( 121 | _ executable: Executable, 122 | arguments: Arguments, 123 | ) async throws -> ShellOutput { 124 | try await run( 125 | executable, 126 | arguments: arguments, 127 | workingDirectory: nil 128 | ) 129 | } 130 | func run( 131 | _ executable: Executable, 132 | arguments: Arguments, 133 | workingDirectory: FilePath?, 134 | ) async throws -> ShellOutput { 135 | try await Subprocess.run( 136 | executable, 137 | arguments: arguments, 138 | environment: .inherit, 139 | workingDirectory: workingDirectory, 140 | platformOptions: PlatformOptions(), 141 | input: .none, 142 | output: .string(limit: 1024, encoding: UTF8.self), 143 | error: .string(limit: 1024, encoding: UTF8.self) 144 | ) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /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/Secrets/SecretsStorageFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecretsStorageFile.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 14/08/2022. 6 | // 7 | 8 | import CLIlib 9 | import Logging 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | #if canImport(FoundationNetworking) 18 | import FoundationNetworking 19 | #endif 20 | 21 | // store secrets on files in $HOME/.xcodeinstaller 22 | struct SecretsStorageFile: SecretsHandlerProtocol { 23 | private let log: Logger 24 | private var fileManager: FileManager 25 | private var baseDirectory: URL 26 | private let cookiesPath: URL 27 | private let sessionPath: URL 28 | private let newCookiesPath: URL 29 | private let newSessionPath: URL 30 | 31 | init(log: Logger) { 32 | self.fileManager = FileManager.default 33 | self.log = log 34 | 35 | baseDirectory = FileHandler(log: self.log).baseFilePath() 36 | 37 | cookiesPath = baseDirectory.appendingPathComponent("cookies") 38 | sessionPath = baseDirectory.appendingPathComponent("session") 39 | 40 | newCookiesPath = cookiesPath.appendingPathExtension("copy") 41 | newSessionPath = sessionPath.appendingPathExtension("copy") 42 | } 43 | 44 | // used when testing to start from a clean place 45 | // func restoreSecrets() { 46 | // 47 | // // remove file 48 | // try? fileManager.removeItem(at: sessionPath) 49 | // 50 | // // copy backup to file 51 | // try? fileManager.copyItem(at: newSessionPath, to: sessionPath) 52 | // 53 | // // remove backup 54 | // try? fileManager.removeItem(at: newSessionPath) 55 | // 56 | // // do it again with cookies file 57 | // 58 | // try? fileManager.removeItem(at: cookiesPath) 59 | // try? fileManager.copyItem(at: newCookiesPath, to: cookiesPath) 60 | // try? fileManager.removeItem(at: newCookiesPath) 61 | // 62 | // } 63 | 64 | // used when testing to start from a clean place 65 | // func clearSecrets(preserve: Bool = false) { 66 | func clearSecrets() async throws { 67 | 68 | // if preserve { 69 | // 70 | // // move files instead of deleting them (if they exist) 71 | // try? fileManager.copyItem(at: cookiesPath, to: newCookiesPath) 72 | // try? fileManager.copyItem(at: sessionPath, to: newSessionPath) 73 | // 74 | // } 75 | 76 | try? fileManager.removeItem(at: cookiesPath) 77 | try? fileManager.removeItem(at: sessionPath) 78 | 79 | } 80 | 81 | // save cookies in an HTTPUrlResponse 82 | // save to ~/.xcodeinstall/cookies 83 | // merge existing cookies into file when file already exists 84 | func saveCookies(_ cookies: String?) async throws -> String? { 85 | 86 | guard let cookieString = cookies else { 87 | return nil 88 | } 89 | 90 | var result: String? = cookieString 91 | 92 | do { 93 | 94 | // if file exists, 95 | if fileManager.fileExists(atPath: cookiesPath.path) { 96 | 97 | // load existing cookies as [HTTPCookie] 98 | let existingCookies = try await self.loadCookies() 99 | 100 | // read it, append the new cookies and save the whole new thing 101 | result = try await mergeCookies(existingCookies: existingCookies, newCookies: cookies) 102 | try result?.data(using: .utf8)!.write(to: cookiesPath) 103 | 104 | } else { 105 | 106 | // otherwise, just save the cookies 107 | try cookieString.data(using: .utf8)!.write(to: cookiesPath) 108 | } 109 | } catch { 110 | log.error("⚠️ can not write cookies file: \(error)") 111 | throw error 112 | } 113 | 114 | return result 115 | 116 | } 117 | 118 | // retrieve cookies 119 | func loadCookies() async throws -> [HTTPCookie] { 120 | 121 | // read the raw file saved on disk 122 | let cookieLongString = try String(contentsOf: cookiesPath, encoding: .utf8) 123 | let result = cookieLongString.cookies() 124 | return result 125 | } 126 | 127 | // save Apple Session values as JSON 128 | func saveSession(_ session: AppleSession) async throws -> AppleSession { 129 | 130 | // save session 131 | try session.data().write(to: sessionPath) 132 | 133 | return session 134 | } 135 | 136 | // load Apple Session from JSON 137 | // returns nil when can not read file 138 | func loadSession() async throws -> AppleSession? { 139 | 140 | // read the raw file saved on disk 141 | let sessionData = try Data(contentsOf: sessionPath) 142 | return try AppleSession(fromData: sessionData) 143 | } 144 | 145 | //MARK: these operations are only valid on SecretsStorageAWS 146 | func retrieveAppleCredentials() async throws -> AppleCredentialsSecret { 147 | throw SecretsStorageAWSError.invalidOperation 148 | } 149 | func storeAppleCredentials(_ credentials: AppleCredentialsSecret) async throws { 150 | throw SecretsStorageAWSError.invalidOperation 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /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 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension Collection where Element: Sendable { 15 | func asyncMap( 16 | _ transform: @Sendable @escaping (Element) async throws -> T 17 | ) async rethrows -> [T] { 18 | try await withThrowingTaskGroup(of: (Int, T).self) { group in 19 | for (index, element) in enumerated() { 20 | group.addTask { 21 | (index, try await transform(element)) 22 | } 23 | } 24 | 25 | var results = Array(repeating: nil, count: count) 26 | for try await (index, value) in group { 27 | results[index] = value 28 | } 29 | 30 | return results.compactMap { $0 } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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 Logging 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | // the methods I want to mock for unit testing 18 | protocol FileHandlerProtocol: Sendable { 19 | nonisolated func move(from src: URL, to dst: URL) throws 20 | func fileExists(file: URL, fileSize: Int) -> Bool 21 | func checkFileSize(file: URL, fileSize: Int) throws -> Bool 22 | func downloadedFiles() throws -> [String] 23 | func downloadFilePath(file: DownloadList.File) async -> String 24 | func downloadFileURL(file: DownloadList.File) async -> URL 25 | func saveDownloadList(list: DownloadList) throws -> DownloadList 26 | func loadDownloadList() throws -> DownloadList 27 | func baseFilePath() -> URL 28 | func baseFilePath() -> String 29 | func downloadDirectory() -> URL 30 | } 31 | 32 | enum FileHandlerError: Error { 33 | case fileDoesNotExist 34 | case noDownloadedList 35 | } 36 | 37 | struct FileHandler: FileHandlerProtocol { 38 | 39 | private let log: Logger 40 | init(log: Logger) { 41 | self.log = log 42 | } 43 | 44 | private static let baseDirectory = FileManager.default.homeDirectoryForCurrentUser 45 | .appendingPathComponent(".xcodeinstall") 46 | func downloadDirectory() -> URL { FileHandler.baseDirectory.appendingPathComponent("download") } 47 | func downloadListPath() -> URL { FileHandler.baseDirectory.appendingPathComponent("downloadList") } 48 | 49 | func baseFilePath() -> String { 50 | baseFilePath().path 51 | } 52 | func baseFilePath() -> URL { 53 | 54 | // if base directory does not exist, create it 55 | let fm = FileManager.default // swiftlint:disable:this identifier_name 56 | if !fm.fileExists(atPath: FileHandler.baseDirectory.path) { 57 | do { 58 | try FileManager.default.createDirectory(at: downloadDirectory(), withIntermediateDirectories: true) 59 | } catch { 60 | log.error("🛑 Can not create base directory : \(FileHandler.baseDirectory.path)\n\(error)") 61 | } 62 | } 63 | 64 | return FileHandler.baseDirectory 65 | } 66 | 67 | nonisolated func move(from src: URL, to dst: URL) throws { 68 | do { 69 | if FileManager.default.fileExists(atPath: dst.path) { 70 | log.debug("⚠️ File \(dst) exists, I am overwriting it") 71 | try FileManager.default.removeItem(atPath: dst.path) 72 | } 73 | 74 | let dstUrl = URL(fileURLWithPath: dst.path) 75 | try FileManager.default.moveItem(at: src, to: dstUrl) 76 | 77 | } catch { 78 | log.error("🛑 Can not move file : \(error)") 79 | throw error 80 | } 81 | } 82 | 83 | func downloadFilePath(file: DownloadList.File) async -> String { 84 | await downloadFileURL(file: file).path 85 | } 86 | func downloadFileURL(file: DownloadList.File) async -> URL { 87 | 88 | // if download directory does not exist, create it 89 | if !FileManager.default.fileExists(atPath: downloadDirectory().path) { 90 | do { 91 | try FileManager.default.createDirectory(at: downloadDirectory(), withIntermediateDirectories: true) 92 | } catch { 93 | log.error( 94 | "🛑 Can not create base directory : \(downloadDirectory().path)\n\(error)" 95 | ) 96 | } 97 | } 98 | return downloadDirectory().appendingPathComponent(file.filename) 99 | 100 | } 101 | 102 | /// Check if file exists and has correct size 103 | /// - Parameters: 104 | /// - filePath the path of the file to verify 105 | /// - fileSize the expected size of the file (in bytes). 106 | /// - Returns : true when the file exists and has the given size, false otherwise 107 | /// - Throws: 108 | /// - FileHandlerError.FileDoesNotExist when the file does not exists 109 | func checkFileSize(file: URL, fileSize: Int) throws -> Bool { 110 | 111 | let filePath = file.path 112 | 113 | // file exists ? 114 | let exists = FileManager.default.fileExists(atPath: filePath) 115 | if !exists { throw FileHandlerError.fileDoesNotExist } 116 | 117 | // file size ? 118 | let attributes = try? FileManager.default.attributesOfItem(atPath: filePath) 119 | let actualSize = attributes?[.size] as? Int 120 | 121 | // at this stage, we know the file exists, just check size now 122 | return actualSize == fileSize 123 | } 124 | 125 | /// Check if file exists and has correct size 126 | /// - Parameters: 127 | /// - filePath the path of the file to verify 128 | /// - fileSize the expected size of the file (in bytes). 129 | /// when omited, file size is not checked 130 | func fileExists(file: URL, fileSize: Int = 0) -> Bool { 131 | 132 | let filePath = file.path 133 | 134 | let fileExists = FileManager.default.fileExists(atPath: filePath) 135 | // does the file exists ? 136 | if !fileExists { 137 | return false 138 | } 139 | 140 | // is the file complete ? 141 | // use try! because I verified if file exists already 142 | let fileComplete = try? self.checkFileSize(file: file, fileSize: fileSize) 143 | 144 | return (fileSize > 0 ? fileComplete ?? false : fileExists) 145 | } 146 | 147 | func downloadedFiles() throws -> [String] { 148 | do { 149 | return try FileManager.default.contentsOfDirectory(atPath: downloadDirectory().path) 150 | } catch { 151 | log.debug("\(error)") 152 | throw FileHandlerError.noDownloadedList 153 | } 154 | } 155 | 156 | func saveDownloadList(list: DownloadList) throws -> DownloadList { 157 | 158 | // save list 159 | let data = try JSONEncoder().encode(list) 160 | try data.write(to: downloadListPath()) 161 | 162 | return list 163 | 164 | } 165 | 166 | func loadDownloadList() throws -> DownloadList { 167 | 168 | // read the raw file saved on disk 169 | let listData = try Data(contentsOf: downloadListPath()) 170 | 171 | return try JSONDecoder().decode(DownloadList.self, from: listData) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Utilities/HexEncoding.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Soto for AWS open source project 4 | // 5 | // Copyright (c) 2025 the Soto project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Soto project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | #if canImport(FoundationEssentials) 16 | import FoundationEssentials 17 | #else 18 | import protocol Foundation.ContiguousBytes 19 | #endif 20 | 21 | @usableFromInline 22 | package struct HexEncoding where Base.Element == UInt8 { 23 | @usableFromInline 24 | var base: Base 25 | 26 | @inlinable 27 | package init(_ base: Base) { 28 | self.base = base 29 | } 30 | } 31 | 32 | extension HexEncoding: Sequence { 33 | @usableFromInline 34 | package typealias Element = UInt8 35 | 36 | @usableFromInline 37 | package struct Iterator: IteratorProtocol { 38 | @usableFromInline 39 | package typealias Element = UInt8 40 | 41 | @usableFromInline 42 | var base: Base.Iterator 43 | @usableFromInline 44 | var _next: UInt8? 45 | 46 | @inlinable 47 | init(base: Base.Iterator) { 48 | self.base = base 49 | self._next = nil 50 | } 51 | 52 | @inlinable 53 | package mutating func next() -> UInt8? { 54 | switch self._next { 55 | case .none: 56 | guard let underlying = self.base.next() else { 57 | return nil 58 | } 59 | let first = underlying >> 4 60 | let second = underlying & 0x0F 61 | self._next = second.makeBase16Ascii() 62 | return first.makeBase16Ascii() 63 | 64 | case .some(let next): 65 | self._next = nil 66 | return next 67 | } 68 | } 69 | } 70 | 71 | @inlinable 72 | package func makeIterator() -> Iterator { 73 | Iterator(base: self.base.makeIterator()) 74 | } 75 | } 76 | 77 | extension HexEncoding: Collection where Base: Collection { 78 | @usableFromInline 79 | package struct Index: Comparable { 80 | @inlinable 81 | init(base: Base.Index, first: Bool) { 82 | self.base = base 83 | self.first = first 84 | } 85 | 86 | @inlinable 87 | package static func < (lhs: HexEncoding.Index, rhs: HexEncoding.Index) -> Bool { 88 | if lhs.base < rhs.base { 89 | return true 90 | } else if lhs.base > rhs.base { 91 | return false 92 | } else if lhs.first && !rhs.first { 93 | return true 94 | } else { 95 | return false 96 | } 97 | } 98 | 99 | @usableFromInline 100 | var base: Base.Index 101 | @usableFromInline 102 | var first: Bool 103 | } 104 | 105 | @inlinable 106 | package var startIndex: Index { 107 | Index(base: self.base.startIndex, first: true) 108 | } 109 | 110 | @inlinable 111 | package var endIndex: Index { 112 | Index(base: self.base.endIndex, first: true) 113 | } 114 | 115 | @inlinable 116 | package func index(after i: Index) -> Index { 117 | if i.first { 118 | return Index(base: i.base, first: false) 119 | } else { 120 | return Index(base: self.base.index(after: i.base), first: true) 121 | } 122 | } 123 | 124 | @inlinable 125 | package subscript(position: Index) -> UInt8 { 126 | let value = self.base[position.base] 127 | let base16 = position.first ? value >> 4 : value & 0x0F 128 | return base16.makeBase16Ascii() 129 | } 130 | } 131 | 132 | extension UInt8 { 133 | @inlinable 134 | func makeBase16Ascii() -> UInt8 { 135 | assert(self < 16) 136 | if self < 10 { 137 | return self + UInt8(ascii: "0") 138 | } else { 139 | return self - 10 + UInt8(ascii: "a") 140 | } 141 | } 142 | } 143 | 144 | extension ContiguousBytes { 145 | /// return a hexEncoded string buffer from an array of bytes 146 | @_disfavoredOverload 147 | @inlinable 148 | public func hexDigest() -> String { 149 | self.withUnsafeBytes { ptr in 150 | ptr.hexDigest() 151 | } 152 | } 153 | } 154 | 155 | extension Collection { 156 | /// return a hexEncoded string buffer from an array of bytes 157 | @inlinable 158 | public func hexDigest() -> String { 159 | String(decoding: HexEncoding(self), as: Unicode.UTF8.self) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /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 | 10 | #if canImport(System) 11 | import System 12 | #else 13 | import SystemPackage 14 | #endif 15 | 16 | typealias ShellOutput = CollectedResult, StringOutput> 17 | 18 | extension Executable { 19 | public static func path(_ path: String) -> Self { 20 | Executable.path(FilePath(path)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/Version.swift: -------------------------------------------------------------------------------- 1 | // Generated by: scripts/deploy/version.sh 2 | struct Version { 3 | let current = "0.14.0" 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 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension XCodeInstall { 15 | 16 | func authenticate(with authenticationMethod: AuthenticationMethod) async throws { 17 | 18 | let auth = self.env.authenticator 19 | 20 | do { 21 | 22 | // delete previous session, if any 23 | try await self.env.secrets!.clearSecrets() 24 | let appleCredentials = try await retrieveAppleCredentials() 25 | 26 | if authenticationMethod == .usernamePassword { 27 | display("Authenticating with username and password (likely to fail) ...") 28 | } else { 29 | display("Authenticating...") 30 | } 31 | try await auth.startAuthentication( 32 | with: authenticationMethod, 33 | username: appleCredentials.username, 34 | password: appleCredentials.password 35 | ) 36 | display("✅ Authenticated.") 37 | 38 | } catch AuthenticationError.invalidUsernamePassword { 39 | 40 | // handle invalid username or password 41 | display("🛑 Invalid username or password.") 42 | 43 | } catch AuthenticationError.requires2FA { 44 | 45 | // handle two factors authentication 46 | try await startMFAFlow() 47 | 48 | } catch AuthenticationError.serviceUnavailable { 49 | 50 | // service unavailable means that the authentication method requested is not available 51 | display("🛑 Requested authentication method is not available. Try with SRP.") 52 | 53 | } catch AuthenticationError.unableToRetrieveAppleServiceKey(let error) { 54 | 55 | // handle connection errors 56 | display( 57 | "🛑 Can not connect to Apple Developer Portal.\nOriginal error : \(error?.localizedDescription ?? "nil")" 58 | ) 59 | 60 | } catch AuthenticationError.notImplemented(let feature) { 61 | 62 | // handle not yet implemented errors 63 | display( 64 | "🛑 \(feature) is not yet implemented. Try the next version of xcodeinstall when it will be available." 65 | ) 66 | 67 | } catch { 68 | display("🛑 Unexpected Error : \(error)") 69 | } 70 | } 71 | 72 | // retrieve apple developer portal credentials. 73 | // either from AWS Secrets Manager, either interactively 74 | private func retrieveAppleCredentials() async throws -> AppleCredentialsSecret { 75 | 76 | var appleCredentials: AppleCredentialsSecret 77 | do { 78 | // first try on AWS Secrets Manager 79 | display("Retrieving Apple Developer Portal credentials...") 80 | appleCredentials = try await self.env.secrets!.retrieveAppleCredentials() 81 | 82 | } catch SecretsStorageAWSError.invalidOperation { 83 | 84 | // we have a file secrets handler, prompt for credentials interactively 85 | appleCredentials = try promptForCredentials() 86 | 87 | } catch { 88 | 89 | // unexpected errors, do not handle here 90 | throw error 91 | } 92 | 93 | return appleCredentials 94 | } 95 | 96 | // prompt user for apple developer portal credentials interactively 97 | private func promptForCredentials() throws -> AppleCredentialsSecret { 98 | display( 99 | """ 100 | ⚠️⚠️ We prompt you for your Apple ID username, password, and two factors authentication code. 101 | These values are not stored anywhere. They are used to get an Apple session ID. ⚠️⚠️ 102 | 103 | Alternatively, you may store your credentials on AWS Secrets Manager 104 | """ 105 | ) 106 | 107 | guard 108 | let username = self.env.readLine.readLine( 109 | prompt: "⌨️ Enter your Apple ID username: ", 110 | silent: false 111 | ) 112 | else { 113 | throw CLIError.invalidInput 114 | } 115 | 116 | guard 117 | let password = self.env.readLine.readLine( 118 | prompt: "⌨️ Enter your Apple ID password: ", 119 | silent: true 120 | ) 121 | else { 122 | throw CLIError.invalidInput 123 | } 124 | 125 | return AppleCredentialsSecret(username: username, password: password) 126 | } 127 | 128 | // manage the MFA authentication sequence 129 | private func startMFAFlow() async throws { 130 | 131 | let auth: any AppleAuthenticatorProtocol = self.env.authenticator 132 | 133 | do { 134 | 135 | let codeLength = try await auth.handleTwoFactorAuthentication() 136 | assert(codeLength > 0) 137 | 138 | let prompt = "🔐 Two factors authentication is enabled, enter your 2FA code: " 139 | guard let pinCode = self.env.readLine.readLine(prompt: prompt, silent: false) else { 140 | throw CLIError.invalidInput 141 | } 142 | try await auth.twoFactorAuthentication(pin: pinCode) 143 | display("✅ Authenticated with MFA.") 144 | 145 | } catch AuthenticationError.requires2FATrustedPhoneNumber { 146 | 147 | display( 148 | """ 149 | 🔐 Two factors authentication is enabled, with 4 digits code and trusted phone numbers. 150 | This tool does not support SMS MFA at the moment. Please enable 2 factors authentication 151 | with trusted devices as described here: https://support.apple.com/en-us/HT204915 152 | """ 153 | ) 154 | 155 | } 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/DownloadCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension XCodeInstall { 15 | 16 | // swiftlint:disable: function_parameter_count 17 | func download( 18 | fileName: String?, 19 | force: Bool, 20 | xCodeOnly: Bool, 21 | majorVersion: String, 22 | sortMostRecentFirst: Bool, 23 | datePublished: Bool 24 | ) async throws { 25 | 26 | let download = self.env.downloader 27 | var fileToDownload: DownloadList.File 28 | do { 29 | 30 | // when filename was given by user 31 | if fileName != nil { 32 | 33 | // search matching filename in the download list cache 34 | let list = try await download.list(force: force) 35 | if let result = list.find(fileName: fileName!) { 36 | fileToDownload = result 37 | } else { 38 | throw DownloadError.unknownFile(file: fileName!) 39 | } 40 | 41 | } else { 42 | 43 | // when no file was given, ask user 44 | fileToDownload = try await self.askFile( 45 | force: force, 46 | xCodeOnly: xCodeOnly, 47 | majorVersion: majorVersion, 48 | sortMostRecentFirst: sortMostRecentFirst, 49 | datePublished: datePublished 50 | ) 51 | } 52 | 53 | // now we have a filename, let's proceed with download 54 | let progressBar = self.env.progressBar 55 | progressBar.define( 56 | animationType: .percentProgressAnimation, 57 | message: "Downloading \(fileToDownload.displayName ?? fileToDownload.filename)" 58 | ) 59 | 60 | for try await progress in try await download.download(file: fileToDownload) { 61 | var text = "\(progress.bytesWritten/1024/1024) MB" 62 | text += String(format: " / %.2f MBs", progress.bandwidth) 63 | progressBar.update( 64 | step: Int(progress.bytesWritten / 1024), 65 | total: Int(progress.totalBytes / 1024), 66 | text: text 67 | ) 68 | } 69 | progressBar.complete(success: true) 70 | 71 | // check if the downloaded file is complete 72 | let fh = self.env.fileHandler 73 | let file: URL = await fh.downloadFileURL(file: fileToDownload) 74 | let complete = try? self.env.fileHandler.checkFileSize( 75 | file: file, 76 | fileSize: fileToDownload.fileSize 77 | ) 78 | if !(complete ?? false) { 79 | display("🛑 Downloaded file has incorrect size, it might be incomplete or corrupted") 80 | } else { 81 | display("✅ \(fileName ?? "file") downloaded") 82 | } 83 | } catch DownloadError.authenticationRequired { 84 | display("🛑 Session expired, you neeed to re-authenticate.") 85 | display("You can authenticate with the command: xcodeinstall authenticate") 86 | } catch CLIError.invalidInput { 87 | display("🛑 Invalid input") 88 | } catch DownloadError.unknownFile(let fileName) { 89 | display("🛑 Unknown file name : \(fileName)") 90 | } catch { 91 | display("🛑 Unexpected error : \(error)") 92 | } 93 | } 94 | 95 | func askFile( 96 | force: Bool, 97 | xCodeOnly: Bool, 98 | majorVersion: String, 99 | sortMostRecentFirst: Bool, 100 | datePublished: Bool 101 | ) async throws -> DownloadList.File { 102 | 103 | let parsedList = try await self.list( 104 | force: force, 105 | xCodeOnly: xCodeOnly, 106 | majorVersion: majorVersion, 107 | sortMostRecentFirst: sortMostRecentFirst, 108 | datePublished: datePublished 109 | ) 110 | 111 | // this is used when debugging 112 | // return parsedList[31].files[1] 113 | 114 | let num = try askUser(prompt: "⌨️ Which one do you want to download? ") 115 | 116 | if parsedList[num].files.count == 1 { 117 | return parsedList[num].files[0] 118 | } else { 119 | // there is more than one file for this download, ask the user which one to download 120 | var line = "\nThere is more than one file for this download:\n" 121 | 122 | parsedList[num].files.enumerated().forEach { index, file in 123 | line += " |__ [\(String(format: "%02d", index))] \(file.filename) (\(file.fileSize/1024/1024) Mb)\n" 124 | } 125 | line += "\n ⌨️ Which one do you want to download? " 126 | 127 | let fileNum = try askUser(prompt: line) 128 | return parsedList[num].files[fileNum] 129 | } 130 | } 131 | 132 | private func askUser(prompt: String) throws -> Int { 133 | let response: String? = self.env.readLine.readLine( 134 | prompt: prompt, 135 | silent: false 136 | ) 137 | guard let number = response, 138 | let num = Int(number) 139 | else { 140 | 141 | if (response ?? "") == "" { 142 | exit(0) 143 | } 144 | throw CLIError.invalidInput 145 | } 146 | return num 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/DownloadListParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadListParser.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 24/07/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | struct DownloadListParser { 15 | 16 | let env: Environment 17 | let xCodeOnly: Bool 18 | let majorVersion: String 19 | let sortMostRecentFirst: Bool 20 | 21 | init(env: Environment, xCodeOnly: Bool = true, majorVersion: String = "13", sortMostRecentFirst: Bool = false) { 22 | self.env = env 23 | self.xCodeOnly = xCodeOnly 24 | self.majorVersion = majorVersion 25 | self.sortMostRecentFirst = sortMostRecentFirst 26 | } 27 | 28 | func parse(list: DownloadList?) throws -> [DownloadList.Download] { 29 | 30 | guard let list = list?.downloads else { 31 | throw DownloadError.noDownloadsInDownloadList 32 | } 33 | 34 | // filter on items having Xcode in their name 35 | let listOfXcode = list.filter { download in 36 | if xCodeOnly { 37 | return download.name.starts(with: "Xcode \(majorVersion)") 38 | } else { 39 | return download.name.contains("Xcode \(majorVersion)") 40 | } 41 | } 42 | 43 | // sort by date (most recent last) 44 | let sortedList = listOfXcode.sorted { (downloadA, downloadB) in 45 | 46 | var dateA: String 47 | var dateB: String 48 | 49 | // select a non nil-date, either Published or Created. 50 | if let pubDateA = downloadA.datePublished, 51 | let pubDateB = downloadB.datePublished 52 | { 53 | dateA = pubDateA 54 | dateB = pubDateB 55 | } else { 56 | dateA = downloadA.dateCreated 57 | dateB = downloadB.dateCreated 58 | } 59 | 60 | // parse the string and return a date 61 | if let aAsDate = dateA.toDate(), 62 | let bAsDate = dateB.toDate() 63 | { 64 | return self.sortMostRecentFirst ? aAsDate > bAsDate : aAsDate < bAsDate 65 | } else { 66 | // I don't know what to do when we can not parse the date 67 | return false 68 | } 69 | } 70 | 71 | return sortedList 72 | } 73 | 74 | /// Enrich the list of available downloads. 75 | /// It adds a flag for each file in the list to indicate if the file is already downloaded and available in cache 76 | func enrich(list: [DownloadList.Download]) async -> [DownloadList.Download] { 77 | 78 | await list.asyncMap { download in 79 | let fileHandler = await self.env.fileHandler 80 | 81 | // swiftlint:disable identifier_name 82 | let file = download.files[0] 83 | 84 | let fileCopy = file 85 | let downloadFile: URL = await fileHandler.downloadFileURL(file: fileCopy) 86 | let exists = await fileHandler.fileExists(file: downloadFile, fileSize: file.fileSize) 87 | 88 | // create a copy of the file to be used in the list 89 | let newFile = await DownloadList.File.init(from: file, existInCache: exists) 90 | 91 | // create a copy of the download to be used in the list 92 | let newDownload = await DownloadList.Download( 93 | from: download, 94 | replaceWith: newFile 95 | ) 96 | 97 | return newDownload 98 | 99 | } 100 | } 101 | 102 | func prettyPrint(list: [DownloadList.Download], withDate: Bool = true) -> String { 103 | 104 | // var result = "" 105 | 106 | // map returns a [String] each containing a line to display 107 | let result: String = list.enumerated().map { (index, download) in 108 | var line: String = "" 109 | 110 | if download.files.count == 1 { 111 | let file = download.files[0] 112 | // swiftlint:disable line_length 113 | line += 114 | "[\(String(format: "%02d", index))] \(download.name) [\"\(file.filename)\" (\(file.fileSize/1024/1024) Mb)] \(file.existInCache ? "(*)" : "")" 115 | 116 | if withDate { 117 | if let date = download.datePublished { 118 | let das = date.toDate() 119 | line += " (published on \(das?.formatted(date: .numeric, time: .omitted) ?? ""))" 120 | } else { 121 | let das = download.dateCreated.toDate() 122 | line += " (created on \(das?.formatted(date: .numeric, time: .omitted) ?? ""))" 123 | } 124 | } 125 | } else { 126 | line += "[\(String(format: "%02d", index))] \(download.name)" 127 | 128 | download.files.forEach { file in 129 | line += "\n |__ \(file.filename) (\(file.fileSize/1024/1024) Mb) \(file.existInCache ? "(*)" : "")" 130 | } 131 | 132 | } 133 | 134 | return line 135 | } 136 | // join all strings in [] with a \n 137 | .joined(separator: "\n") 138 | 139 | return result 140 | } 141 | } 142 | 143 | extension String { 144 | 145 | func toDate() -> Date? { 146 | 147 | let appleDownloadDateFormatter = DateFormatter() 148 | appleDownloadDateFormatter.locale = Locale(identifier: "en_US_POSIX") 149 | appleDownloadDateFormatter.dateFormat = "MM-dd-yy HH:mm" 150 | // appleDownloadDateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // assume GMT timezone 151 | 152 | return appleDownloadDateFormatter.date(from: self) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /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 | #if canImport(FoundationEssentials) 10 | import FoundationEssentials 11 | #else 12 | import Foundation 13 | #endif 14 | 15 | extension XCodeInstall { 16 | 17 | func install(file: String?) async throws { 18 | 19 | let installer = ShellInstaller(env: &self.env, log: self.log) 20 | 21 | // progress bar to report progress feedback 22 | let progressBar = self.env.progressBar 23 | progressBar.define( 24 | animationType: .countingProgressAnimationMultiLine, 25 | message: "Installing..." 26 | ) 27 | 28 | var fileToInstall: URL? 29 | do { 30 | // when no file is specified, prompt user to select one 31 | if nil == file { 32 | fileToInstall = try promptForFile() 33 | } else { 34 | fileToInstall = FileHandler(log: self.log).downloadDirectory().appendingPathComponent(file!) 35 | } 36 | log.debug("Going to attemp to install \(fileToInstall!.path)") 37 | 38 | try await installer.install(file: fileToInstall!) 39 | self.env.progressBar.complete(success: true) 40 | display("✅ \(fileToInstall!) installed") 41 | } catch CLIError.invalidInput { 42 | display("🛑 Invalid input") 43 | self.env.progressBar.complete(success: false) 44 | } catch FileHandlerError.noDownloadedList { 45 | display("⚠️ There is no downloaded file to be installed") 46 | self.env.progressBar.complete(success: false) 47 | } catch InstallerError.xCodeXIPInstallationError { 48 | display("🛑 Can not expand XIP file. Is there enough space on / ? (16GiB required)") 49 | self.env.progressBar.complete(success: false) 50 | } catch InstallerError.xCodeMoveInstallationError { 51 | display("🛑 Can not move Xcode to /Applications") 52 | self.env.progressBar.complete(success: false) 53 | } catch InstallerError.xCodePKGInstallationError { 54 | display( 55 | "🛑 Can not install additional packages." 56 | ) 57 | self.env.progressBar.complete(success: false) 58 | } catch InstallerError.unsupportedInstallation { 59 | display( 60 | "🛑 Unsupported installation type. (We support Xcode XIP files and Command Line Tools PKG)" 61 | ) 62 | self.env.progressBar.complete(success: false) 63 | } catch { 64 | display("🛑 Error while installing \(String(describing: fileToInstall!))") 65 | log.debug("\(error)") 66 | self.env.progressBar.complete(success: false) 67 | } 68 | } 69 | 70 | func promptForFile() throws -> URL { 71 | 72 | // list files ready to install 73 | let installableFiles = try self.env.fileHandler.downloadedFiles().filter({ fileName in 74 | return fileName.hasSuffix(".xip") || fileName.hasSuffix(".dmg") 75 | }) 76 | 77 | display("") 78 | display("👉 Here is the list of available files to install:") 79 | display("") 80 | let printableList = installableFiles.enumerated().map({ (index, fileName) in 81 | return "[\(String(format: "%02d", index))] \(fileName)" 82 | }).joined(separator: "\n") 83 | display(printableList) 84 | display("\(installableFiles.count) items") 85 | 86 | let response: String? = self.env.readLine.readLine( 87 | prompt: "⌨️ Which one do you want to install? ", 88 | silent: false 89 | ) 90 | guard let number = response, 91 | let num = Int(number) 92 | else { 93 | 94 | if (response ?? "") == "" { 95 | exit(0) 96 | } 97 | throw CLIError.invalidInput 98 | } 99 | 100 | return FileHandler(log: self.log).downloadDirectory().appendingPathComponent(installableFiles[num]) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/ListCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension XCodeInstall { 15 | 16 | func list( 17 | force: Bool, 18 | xCodeOnly: Bool, 19 | majorVersion: String, 20 | sortMostRecentFirst: Bool, 21 | datePublished: Bool 22 | ) async throws -> [DownloadList.Download] { 23 | 24 | let download = self.env.downloader 25 | 26 | display("Loading list of available downloads ", terminator: "") 27 | display( 28 | "\(force ? "forced download from Apple Developer Portal" : "fetched from cache in \(self.env.fileHandler.baseFilePath())")" 29 | ) // swiftlint:disable:this line_length 30 | 31 | do { 32 | let list = try await download.list(force: force) 33 | display("✅ Done") 34 | 35 | let parser = DownloadListParser( 36 | env: self.env, 37 | xCodeOnly: xCodeOnly, 38 | majorVersion: majorVersion, 39 | sortMostRecentFirst: sortMostRecentFirst 40 | ) 41 | let parsedList = try parser.parse(list: list) 42 | 43 | // enrich the list to flag files already downloaded 44 | let enrichedList = await parser.enrich(list: parsedList) 45 | 46 | display("") 47 | display("👉 Here is the list of available downloads:") 48 | display("Files marked with (*) are already downloaded in \(self.env.fileHandler.baseFilePath()) ") 49 | display("") 50 | let string = parser.prettyPrint(list: enrichedList, withDate: datePublished) 51 | display(string) 52 | display("\(enrichedList.count) items") 53 | 54 | return enrichedList 55 | 56 | } catch let error as DownloadError { 57 | switch error { 58 | case .authenticationRequired: 59 | display("🛑 Session expired, you neeed to re-authenticate.") 60 | display("You can authenticate with the command: xcodeinstall authenticate") 61 | throw error 62 | case .accountneedUpgrade(let code, let message): 63 | display("🛑 \(message) (Apple Portal error code : \(code))") 64 | throw error 65 | case .needToAcceptTermsAndCondition: 66 | display( 67 | """ 68 | 🛑 This is a new Apple account, you need first to accept the developer terms of service. 69 | Open a session at https://developer.apple.com/register/agree/ 70 | Read and accept the ToS and try again. 71 | """ 72 | ) 73 | throw error 74 | case .unknownError(let code, let message): 75 | display("🛑 \(message) (Unhandled download error : \(code))") 76 | display( 77 | "Please file an error report at https://github.com/sebsto/xcodeinstall/issues/new?assignees=&labels=&template=bug_report.md&title=" 78 | ) 79 | throw error 80 | default: 81 | display("🛑 Unknown download error : \(error)") 82 | display( 83 | "Please file an error report at https://github.com/sebsto/xcodeinstall/issues/new?assignees=&labels=&template=bug_report.md&title=" 84 | ) 85 | throw error 86 | } 87 | } catch { 88 | display("🛑 Unexpected error : \(error)") 89 | display( 90 | "Please file an error repor at https://github.com/sebsto/xcodeinstall/issues/new?assignees=&labels=&template=bug_report.md&title=" 91 | ) 92 | throw error 93 | } 94 | 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/SignOutCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOutCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 16/08/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension XCodeInstall { 15 | 16 | func signout() async throws { 17 | 18 | let auth = self.env.authenticator 19 | 20 | display("Signing out...") 21 | try await auth.signout() 22 | display("✅ Signed out.") 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/xcodeinstall/xcodeInstall/StoreSecretsCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreSecretsCommand.swift 3 | // xcodeinstall 4 | // 5 | // Created by Stormacq, Sebastien on 05/09/2022. 6 | // 7 | 8 | #if canImport(FoundationEssentials) 9 | import FoundationEssentials 10 | #else 11 | import Foundation 12 | #endif 13 | 14 | extension XCodeInstall { 15 | 16 | func storeSecrets() async throws { 17 | 18 | let secretsHandler = self.env.secrets! 19 | do { 20 | // separate func for testability 21 | let input = try promptForCredentials() 22 | let credentials = AppleCredentialsSecret(username: input[0], password: input[1]) 23 | 24 | try await secretsHandler.storeAppleCredentials(credentials) 25 | display("✅ Credentials are securely stored") 26 | 27 | } catch { 28 | display("🛑 Unexpected error : \(error)") 29 | throw error 30 | } 31 | 32 | } 33 | 34 | func promptForCredentials() throws -> [String] { 35 | display( 36 | """ 37 | 38 | This command captures your Apple ID username and password and store them securely in AWS Secrets Manager. 39 | It allows this command to authenticate automatically, as long as no MFA is prompted. 40 | 41 | """ 42 | ) 43 | 44 | guard 45 | let username = self.env.readLine.readLine( 46 | prompt: "⌨️ Enter your Apple ID username: ", 47 | silent: false 48 | ) 49 | else { 50 | throw CLIError.invalidInput 51 | } 52 | 53 | guard 54 | let password = self.env.readLine.readLine( 55 | prompt: "⌨️ Enter your Apple ID password: ", 56 | silent: true 57 | ) 58 | else { 59 | throw CLIError.invalidInput 60 | } 61 | 62 | return [username, password] 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /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 Logging 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #else 14 | import Foundation 15 | #endif 16 | 17 | final class XCodeInstall { 18 | 19 | let log: Logger 20 | var env: Environment 21 | 22 | public init(log: Logger, env: Environment) { 23 | self.log = log 24 | self.env = env 25 | } 26 | 27 | // display a message to the user 28 | // avoid having to replicate the \n torough the code 29 | func display(_ msg: String, terminator: String = "\n") { 30 | self.env.display.display(msg, terminator: terminator) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Tests/coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | Coverage - (65.3%)% 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 | 65% 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/DownloadManagerTest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import xcodeinstall 5 | struct MockDownloadManager: DownloadManagerProtocol { 6 | var mockProgress: [DownloadProgress] = [] 7 | var shouldFail = false 8 | 9 | func download(from url: URL) -> AsyncThrowingStream { 10 | AsyncThrowingStream { continuation in 11 | if shouldFail { 12 | continuation.finish() //FIXME: throw an error 13 | return 14 | } 15 | 16 | for progress in mockProgress { 17 | continuation.yield(progress) 18 | // try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s delay 19 | } 20 | continuation.finish() 21 | } 22 | } 23 | } 24 | 25 | @Suite("DownloadManager") 26 | struct DownloadManagerTest { 27 | @Test("Test Download Manager progress") 28 | func testDownloadManager() async throws { 29 | var mockManager = MockDownloadManager() 30 | let now = Date() 31 | mockManager.mockProgress = [ 32 | DownloadProgress(bytesWritten: 25, totalBytes: 100, startTime: now), 33 | DownloadProgress(bytesWritten: 50, totalBytes: 100, startTime: now), 34 | DownloadProgress(bytesWritten: 100, totalBytes: 100, startTime: now), 35 | ] 36 | 37 | var receivedProgress: [DownloadProgress] = [] 38 | for try await progress in mockManager.download(from: URL(string: "test")!) { 39 | receivedProgress.append(progress) 40 | } 41 | 42 | #expect(receivedProgress.count == 3) 43 | #expect(receivedProgress.last?.percentage == 1.0) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/DownloadTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import Testing 4 | 5 | @testable import xcodeinstall 6 | 7 | #if canImport(FoundationNetworking) 8 | import FoundationNetworking 9 | #endif 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #endif 14 | 15 | // MARK: - Download Tests 16 | @Suite("DownloadTests") 17 | final class DownloadTests { 18 | 19 | // MARK: - Test Environment 20 | let log = Logger(label: "DownloadTests") 21 | var env: MockedEnvironment 22 | 23 | init() async throws { 24 | // Setup environment for each test 25 | self.env = MockedEnvironment() 26 | self.env.secrets = MockedSecretsHandler(env: &self.env) 27 | try await env.secrets!.clearSecrets() 28 | } 29 | 30 | // MARK: - Helper Methods 31 | func getDownloadManager() -> DownloadManager { 32 | let dm = DownloadManager(env: env, logger: log) 33 | return dm 34 | } 35 | 36 | func setSessionData(data: Data?, response: HTTPURLResponse?) { 37 | #expect(self.env.urlSessionData as? MockedURLSession != nil) 38 | (self.env.urlSessionData as? MockedURLSession)?.nextData = data 39 | (self.env.urlSessionData as? MockedURLSession)?.nextResponse = response 40 | } 41 | } 42 | 43 | // MARK: - Test Cases 44 | extension DownloadTests { 45 | 46 | @Test("Test Download Manager Creation") 47 | func testDownloadManagerCreation() { 48 | // Given & When 49 | let dm = getDownloadManager() 50 | 51 | // Then 52 | #expect(dm.env != nil) 53 | } 54 | 55 | @Test("Test Request Building") 56 | func testRequestBuilding() { 57 | // Given 58 | let dm = getDownloadManager() 59 | let testURL = "https://example.com/test.xip" 60 | let headers = ["Authorization": "Bearer token"] 61 | 62 | // When 63 | let request = dm.request(for: testURL, withHeaders: headers) 64 | 65 | // Then 66 | #expect(request.url?.absoluteString == testURL) 67 | #expect(request.httpMethod == "GET") 68 | #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token") 69 | } 70 | 71 | @Test("Test Download Target Configuration") 72 | func testDownloadTargetConfiguration() { 73 | // Given 74 | let dm = getDownloadManager() 75 | let dstPath = URL(fileURLWithPath: "/tmp/test.xip") 76 | let target = DownloadTarget(totalFileSize: 1000, dstFilePath: dstPath) 77 | 78 | // When 79 | dm.downloadTarget = target 80 | 81 | // Then 82 | #expect(dm.downloadTarget?.totalFileSize == 1000) 83 | #expect(dm.downloadTarget?.dstFilePath == dstPath) 84 | } 85 | } 86 | 87 | // MARK: - Mock Extensions 88 | extension MockedEnvironment { 89 | var downloadManager: DownloadManager { 90 | DownloadManager(env: self, logger: Logger(label: "MockedDownloadManager")) 91 | } 92 | } -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/API/HTTPClientTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import Testing 4 | 5 | @testable import xcodeinstall 6 | 7 | #if canImport(FoundationNetworking) 8 | import FoundationNetworking 9 | #endif 10 | 11 | #if canImport(FoundationEssentials) 12 | import FoundationEssentials 13 | #endif 14 | 15 | // MARK: - Test Suite Setup 16 | struct HTTPClientTests { 17 | 18 | // MARK: - Test Environment 19 | var sessionData: MockedURLSession! 20 | var sessionDownload: MockedURLSession! 21 | var client: HTTPClient! 22 | var delegate: DownloadDelegate! 23 | var env: MockedEnvironment 24 | var log = Logger(label: "HTTPClientTests") 25 | 26 | init() async throws { 27 | // Setup environment for each test 28 | self.env = MockedEnvironment() 29 | self.env.secrets = MockedSecretsHandler(env: &self.env) 30 | self.sessionData = env.urlSessionData as? MockedURLSession 31 | self.sessionDownload = env.urlSessionDownload as? MockedURLSession 32 | self.client = HTTPClient(log: log) 33 | self.client.environment = 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 | extension HTTPClientTests { 50 | 51 | @Test("Test HTTP Request Creation") 52 | func testRequest() async throws { 53 | let url = "https://test.com/path" 54 | let username = "username" 55 | let password = "password" 56 | 57 | let headers = [ 58 | "Header1": "value1", 59 | "Header2": "value2", 60 | ] 61 | let body = try JSONEncoder().encode(User(accountName: username, password: password)) 62 | let request = client.request( 63 | for: url, 64 | method: .POST, 65 | withBody: body, 66 | withHeaders: headers 67 | ) 68 | 69 | // Test URL 70 | #expect(request.url?.debugDescription == url) 71 | 72 | // Test method 73 | #expect(request.httpMethod == "POST") 74 | 75 | // Test body 76 | #expect(request.httpBody != nil) 77 | let user = try JSONDecoder().decode(User.self, from: request.httpBody!) 78 | #expect(user.accountName == username) 79 | #expect(user.password == password) 80 | 81 | // Test headers 82 | #expect(request.allHTTPHeaderFields != nil) 83 | #expect(request.allHTTPHeaderFields?.count == 2) 84 | #expect(request.allHTTPHeaderFields?["Header1"] == "value1") 85 | #expect(request.allHTTPHeaderFields?["Header2"] == "value2") 86 | } 87 | 88 | @Test("Test Password Obfuscation in Logs") 89 | func testPasswordObfuscation() async throws { 90 | // Given 91 | let username = "username" 92 | let password = "myComplexPassw0rd!" 93 | let body = try JSONEncoder().encode(User(accountName: username, password: password)) 94 | let str = String(data: body, encoding: .utf8) 95 | #expect(str != nil) 96 | 97 | // When 98 | let obfuscated = _filterPassword(str!) 99 | 100 | // Then 101 | #expect(str != obfuscated) 102 | #expect(!obfuscated.contains(password)) 103 | } 104 | 105 | @Test("Test Data Request URL") 106 | func testDataRequestsTheURL() async throws { 107 | // Given 108 | let url = "http://dummy" 109 | 110 | self.sessionData.nextData = Data() 111 | // Create a mock URLResponse that works on both platforms 112 | self.sessionData.nextResponse = URLResponse( 113 | url: URL(string: "http://dummy")!, 114 | mimeType: nil, 115 | expectedContentLength: 0, 116 | textEncodingName: nil 117 | ) 118 | 119 | // When 120 | let request = client.request(for: url) 121 | _ = try await self.sessionData.data(for: request, delegate: nil) 122 | 123 | // Then 124 | #expect(self.sessionData.lastURL?.debugDescription == url) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /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 | import Logging 11 | 12 | @testable import xcodeinstall 13 | 14 | #if canImport(FoundationNetworking) 15 | import FoundationNetworking 16 | #endif 17 | 18 | // mocked URLSession to be used during test 19 | @MainActor 20 | final class MockedURLSession: URLSessionProtocol { 21 | 22 | let log = Logger(label: "MockedURLSession") 23 | private(set) var lastURL: URL? 24 | private(set) var lastRequest: URLRequest? 25 | 26 | var nextData: Data? 27 | var nextError: Error? 28 | var nextResponse: URLResponse? 29 | 30 | func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) { 31 | 32 | guard let data = nextData, 33 | let response = nextResponse 34 | else { 35 | throw MockError.invalidMockData 36 | } 37 | 38 | lastURL = request.url 39 | lastRequest = request 40 | 41 | if nextError != nil { 42 | throw nextError! 43 | } 44 | 45 | return (data, response) 46 | } 47 | 48 | } 49 | 50 | @MainActor 51 | final class MockedAppleAuthentication: AppleAuthenticatorProtocol { 52 | 53 | var nextError: AuthenticationError? 54 | var nextMFAError: AuthenticationError? 55 | var session: AppleSession? 56 | 57 | func startAuthentication( 58 | with authenticationMethod: AuthenticationMethod, 59 | username: String, 60 | password: String 61 | ) async throws { 62 | 63 | if let nextError { 64 | throw nextError 65 | } 66 | 67 | } 68 | func signout() async throws {} 69 | func handleTwoFactorAuthentication() async throws -> Int { 70 | if let nextMFAError { 71 | throw nextMFAError 72 | } 73 | return 6 74 | } 75 | func twoFactorAuthentication(pin: String) async throws {} 76 | } 77 | 78 | @MainActor 79 | final class MockedAppleDownloader: AppleDownloaderProtocol { 80 | var environment: Environment? 81 | 82 | func list(force: Bool) async throws -> DownloadList { 83 | if !force { 84 | let listData = try loadTestData(file: .downloadList) 85 | let list: DownloadList = try JSONDecoder().decode(DownloadList.self, from: listData) 86 | return list 87 | } 88 | 89 | // For forced list, check mocked URLSession data 90 | guard let env = environment, 91 | let mockedSession = env.urlSessionData as? MockedURLSession, 92 | let data = mockedSession.nextData, 93 | let response = mockedSession.nextResponse as? HTTPURLResponse 94 | else { 95 | throw MockError.invalidMockData 96 | } 97 | 98 | // Check response status code first 99 | guard response.statusCode == 200 else { 100 | throw DownloadError.invalidResponse 101 | } 102 | 103 | // Check for cookies (except for specific test cases) 104 | let hasCookies = response.value(forHTTPHeaderField: "Set-Cookie") != nil 105 | 106 | // Try to decode the response first to check if it's valid JSON 107 | let downloadList: DownloadList 108 | do { 109 | downloadList = try JSONDecoder().decode(DownloadList.self, from: data) 110 | } catch { 111 | // If JSON parsing fails, throw parsing error 112 | throw DownloadError.parsingError(error: nil) 113 | } 114 | 115 | // Now check for cookies after successful JSON parsing 116 | if !hasCookies && downloadList.resultCode == 0 { 117 | throw DownloadError.invalidResponse 118 | } 119 | 120 | // Check result code for various error conditions 121 | switch downloadList.resultCode { 122 | case 0: 123 | return downloadList 124 | case 1100: 125 | throw DownloadError.authenticationRequired 126 | case 2170: 127 | throw DownloadError.accountneedUpgrade( 128 | errorCode: downloadList.resultCode, 129 | errorMessage: downloadList.userString ?? "Your developer account needs to be updated" 130 | ) 131 | default: 132 | throw DownloadError.unknownError( 133 | errorCode: downloadList.resultCode, 134 | errorMessage: downloadList.userString ?? "Unknown error" 135 | ) 136 | } 137 | } 138 | 139 | func download(file: DownloadList.File) async throws -> AsyncThrowingStream { 140 | let dm = MockDownloadManager() 141 | return dm.download(from: URL(string: file.filename)!) 142 | 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /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 | nonisolated final class DateFormatterMock: DateFormatter, @unchecked Sendable { 131 | override func string(from: Date) -> String { 132 | "20230223170600" 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /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 Foundation 9 | import Logging 10 | import Testing 11 | 12 | @testable import xcodeinstall 13 | 14 | #if canImport(FoundationNetworking) 15 | import FoundationNetworking 16 | #endif 17 | 18 | struct URLRequestCurlTest { 19 | 20 | let log = Logger(label: "URLRequestCurlTest") 21 | var agent: HTTPClient! 22 | 23 | init() throws { 24 | self.agent = HTTPClient(log: log) 25 | self.agent.environment = MockedEnvironment() 26 | } 27 | 28 | @Test("Test URLRequest to cURL") 29 | func testRequestToCurl() throws { 30 | 31 | //given 32 | let url = "https://dummy.com" 33 | var headers = [ 34 | "Header1": "value1", 35 | "Header2": "value2", 36 | ] 37 | let data = "test data".data(using: .utf8) 38 | let cookie = HTTPCookie(properties: [ 39 | .name: "cookieName", .value: "cookieValue", .path: "/", .originURL: URL(string: url)!, 40 | ]) 41 | if let cookie { 42 | headers.merge(HTTPCookie.requestHeaderFields(with: [cookie])) { (current, _) in current } 43 | } 44 | 45 | // when 46 | let request = agent.request(for: url, method: .GET, withBody: data, withHeaders: headers) 47 | let curl = request.cURL(pretty: false) 48 | 49 | // then 50 | #expect(curl.starts(with: "curl ")) 51 | #expect(curl.contains("-H 'Header1: value1'")) 52 | #expect(curl.contains("-H 'Header2: value2'")) 53 | #expect(curl.contains("-H 'Cookie: cookieName=cookieValue'")) 54 | #expect(curl.contains("-X GET 'https://dummy.com'")) 55 | #expect(curl.contains("--data 'test data'")) 56 | 57 | } 58 | 59 | @Test("Test URLRequest to cURL pretty print") 60 | func testRequestToCurlPrettyPrint() throws { 61 | 62 | //given 63 | let url = "https://dummy.com" 64 | var headers = [ 65 | "Header1": "value1", 66 | "Header2": "value2", 67 | ] 68 | let data = "test data".data(using: .utf8) 69 | let cookie = HTTPCookie(properties: [ 70 | .name: "cookieName", .value: "cookieValue", .path: "/", .originURL: URL(string: url)!, 71 | ]) 72 | if let cookie { 73 | headers.merge(HTTPCookie.requestHeaderFields(with: [cookie])) { (current, _) in current } 74 | } 75 | 76 | // when 77 | let request = agent.request(for: url, method: .GET, withBody: data, withHeaders: headers) 78 | let curl = request.cURL(pretty: true) 79 | 80 | // then 81 | #expect(curl.starts(with: "curl ")) 82 | #expect(curl.contains("--header 'Header1: value1'")) 83 | #expect(curl.contains("--header 'Header2: value2'")) 84 | #expect(curl.contains("--header 'Cookie: cookieName=cookieValue'")) 85 | #expect(curl.contains("--request GET")) 86 | #expect(curl.contains("--url 'https://dummy.com'")) 87 | #expect(curl.contains("--data 'test data'")) 88 | 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /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 | extension CLITests { 14 | 15 | @Test("Test Download") 16 | func testDownload() async throws { 17 | 18 | // given 19 | 20 | let mockedRL = MockedReadLine(["0"]) 21 | let mockedFH = MockedFileHandler() 22 | mockedFH.nextFileCorrect = true 23 | let mockedPB = MockedProgressBar() 24 | let env = MockedEnvironment(fileHandler: mockedFH, readLine: mockedRL, progressBar: mockedPB) 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 | // test parsing of commandline arguments 40 | #expect(download.globalOptions.verbose) 41 | #expect(download.downloadListOptions.force) 42 | #expect(download.downloadListOptions.onlyXcode) 43 | #expect(download.downloadListOptions.xCodeVersion == "14") 44 | #expect(download.downloadListOptions.mostRecentFirst) 45 | #expect(download.downloadListOptions.datePublished) 46 | 47 | // when 48 | await #expect(throws: Never.self) { 49 | let xci = XCodeInstall(log: log, env: env) 50 | try await xci.download( 51 | fileName: nil, 52 | force: false, 53 | xCodeOnly: true, 54 | majorVersion: "14", 55 | sortMostRecentFirst: true, 56 | datePublished: true 57 | ) 58 | } 59 | 60 | // verify if progressbar define() was called 61 | if let progress = env.progressBar as? MockedProgressBar { 62 | #expect(progress.defineCalled()) 63 | } else { 64 | Issue.record("Error in test implementation, the env.progressBar must be a MockedProgressBar") 65 | } 66 | 67 | // mocked list succeeded 68 | assertDisplay(env: env, "✅ file downloaded") 69 | 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 | extension CLITests { 13 | 14 | @Test("Test Install Command") 15 | func testInstall() async throws { 16 | 17 | // given 18 | let env: MockedEnvironment = MockedEnvironment(progressBar: MockedProgressBar()) 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(log: log, 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 | extension CLITests { 13 | 14 | @Test("Test List Command") 15 | func testList() async throws { 16 | 17 | // given 18 | let list = try parse( 19 | MainCommand.List.self, 20 | [ 21 | "list", 22 | "--verbose", 23 | "--only-xcode", 24 | "--xcode-version", 25 | "14", 26 | "--most-recent-first", 27 | "--date-published", 28 | ] 29 | ) 30 | 31 | // when 32 | 33 | await #expect(throws: Never.self) { try await list.run(with: env) } 34 | 35 | // test parsing of commandline arguments 36 | #expect(list.globalOptions.verbose) 37 | #expect(list.downloadListOptions.onlyXcode) 38 | #expect(list.downloadListOptions.xCodeVersion == "14") 39 | #expect(list.downloadListOptions.mostRecentFirst) 40 | #expect(list.downloadListOptions.datePublished) 41 | 42 | // mocked list succeeded 43 | assertDisplay("16 items") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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 Foundation 9 | import SotoCore 10 | import Testing 11 | 12 | @testable import xcodeinstall 13 | 14 | extension CLITests { 15 | 16 | // on CI Linux, there is no AWS credentials configured 17 | // this test throws "No credential provider found" of type CredentialProviderError 18 | #if os(macOS) 19 | // fails on CI CD, disable temporarily 20 | @Test("Test Store Secrets", .enabled(if: ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == nil)) 21 | func testStoreSecrets() async throws { 22 | 23 | // given 24 | let mockedRL = MockedReadLine(["username", "password"]) 25 | let env: Environment = MockedEnvironment(readLine: mockedRL) 26 | // use the real AWS Secrets Handler, but with a mocked SDK 27 | let mockedSDK = try MockedSecretsStorageAWSSDK.forRegion("us-east-1", log: log) 28 | let secretsHandler = try SecretsStorageAWS(sdk: mockedSDK, log: log) 29 | env.setSecretsHandler(secretsHandler) 30 | 31 | let storeSecrets = try parse( 32 | MainCommand.StoreSecrets.self, 33 | [ 34 | "storesecrets", 35 | "-s", "eu-central-1", 36 | "--verbose", 37 | ] 38 | ) 39 | 40 | // when 41 | do { 42 | try await storeSecrets.run(with: env) 43 | } catch _ as CredentialProviderError { 44 | // ignore 45 | // it allows to run the test on machines not configured for AWS 46 | } catch { 47 | Issue.record("unexpected exception : \(error)") 48 | } 49 | 50 | // test parsing of commandline arguments 51 | #expect(storeSecrets.globalOptions.verbose) 52 | 53 | //FIXME : can't do that here - because the mocked secret handler just has a copy of the env, 54 | //it can not modify the env we have here 55 | 56 | // did we call setRegion on the SDK class ? 57 | #expect((secretsHandler.awsSDK as? MockedSecretsStorageAWSSDK)?.regionSet() ?? false) 58 | } 59 | #endif 60 | 61 | func testPromptForCredentials() { 62 | 63 | // given 64 | let mockedRL = MockedReadLine(["username", "password"]) 65 | let env = MockedEnvironment(readLine: mockedRL) 66 | let xci = XCodeInstall(log: log, env: env) 67 | 68 | // when 69 | do { 70 | let result = try xci.promptForCredentials() 71 | 72 | // then 73 | #expect(result.count == 2) 74 | #expect(result[0] == "username") 75 | #expect(result[1] == "password") 76 | 77 | } catch { 78 | // then 79 | Issue.record("unexpected exception : \(error)") 80 | } 81 | 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/CLI/CLITests.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import CLIlib 3 | import Foundation 4 | import Logging 5 | import Testing 6 | 7 | @testable import xcodeinstall 8 | 9 | // MARK: - CLI Tests Base 10 | @MainActor 11 | @Suite("CLI Tests") 12 | final class CLITests { 13 | 14 | // MARK: - Test Environment 15 | // some tests might override the environment with more specialized mocks. 16 | var env = MockedEnvironment() 17 | var secretsHandler: SecretsHandlerProtocol! 18 | let log = Logger(label: "CLITests") 19 | 20 | init() async throws { 21 | self.secretsHandler = MockedSecretsHandler(env: &env) 22 | try await self.secretsHandler.clearSecrets() 23 | } 24 | 25 | // MARK: - Helper Methods 26 | func parse(_ type: A.Type, _ arguments: [String]) throws -> A where A: AsyncParsableCommand { 27 | try MainCommand.parseAsRoot(arguments) as! A 28 | } 29 | 30 | func assertDisplay(env: Environment, _ msg: String) { 31 | let actual = (env.display as! MockedDisplay).string 32 | #expect(actual == "\(msg)\n") 33 | } 34 | 35 | func assertDisplay(_ msg: String) { 36 | assertDisplay(env: self.env, msg) 37 | } 38 | 39 | func assertDisplayStartsWith(env: Environment, _ msg: String) { 40 | let actual = (env.display as! MockedDisplay).string 41 | #expect(actual.starts(with: msg)) 42 | } 43 | 44 | func assertDisplayStartsWith(_ msg: String) { 45 | assertDisplayStartsWith(env: self.env, msg) 46 | } 47 | } 48 | 49 | // MARK: - Basic CLI Tests 50 | extension CLITests { 51 | 52 | @Test("Test CLI Display Assertion") 53 | func testDisplayAssertion() { 54 | // Given 55 | let testMessage = "Test message" 56 | 57 | // When 58 | (env.display as! MockedDisplay).string = "\(testMessage)\n" 59 | 60 | // Then 61 | assertDisplay(testMessage) 62 | } 63 | 64 | @Test("Test CLI Display Starts With Assertion") 65 | func testDisplayStartsWithAssertion() { 66 | // Given 67 | let testPrefix = "Test prefix" 68 | let fullMessage = "Test prefix with additional content" 69 | 70 | // When 71 | (env.display as! MockedDisplay).string = fullMessage 72 | 73 | // Then 74 | assertDisplayStartsWith(testPrefix) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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 | final class MockedDisplay: DisplayProtocol { 19 | var string: String = "" 20 | 21 | func display(_ msg: String, terminator: String) { 22 | self.string = msg + terminator 23 | } 24 | } 25 | 26 | // mocked read line 27 | final class MockedReadLine: ReadLineProtocol { 28 | 29 | var input: [String] = [] 30 | 31 | init(_ input: [String]) { 32 | self.input = input.reversed() 33 | } 34 | 35 | func readLine(prompt: String, silent: Bool = false) -> String? { 36 | guard input.count > 0 else { 37 | fatalError("mocked not correctly initialized") 38 | } 39 | return input.popLast() 40 | } 41 | } 42 | 43 | enum MockError: Error { 44 | case invalidMockData 45 | } 46 | -------------------------------------------------------------------------------- /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 | 11 | @testable import Subprocess // to be able to call internal init() functions 12 | @testable import xcodeinstall 13 | 14 | #if canImport(System) 15 | import System 16 | #else 17 | import SystemPackage 18 | #endif 19 | 20 | final class MockedEnvironment: xcodeinstall.Environment { 21 | 22 | init( 23 | fileHandler: FileHandlerProtocol = MockedFileHandler(), 24 | readLine: ReadLineProtocol = MockedReadLine([]), 25 | progressBar: CLIProgressBarProtocol = MockedProgressBar() 26 | ) { 27 | self.fileHandler = fileHandler 28 | self.readLine = readLine 29 | self.progressBar = progressBar 30 | } 31 | 32 | var fileHandler: FileHandlerProtocol = MockedFileHandler() 33 | 34 | var display: DisplayProtocol = MockedDisplay() 35 | var readLine: ReadLineProtocol = MockedReadLine([]) 36 | var progressBar: CLIProgressBarProtocol = MockedProgressBar() 37 | 38 | // this has to be injected by the caller (it contains a reference to the env 39 | var secrets: SecretsHandlerProtocol? = nil 40 | func setSecretsHandler(_ newValue: SecretsHandlerProtocol) { 41 | self.secrets = newValue 42 | } 43 | 44 | var awsSDK: SecretsStorageAWSSDKProtocol? = nil 45 | 46 | var authenticator: AppleAuthenticatorProtocol = MockedAppleAuthentication() 47 | var downloader: AppleDownloaderProtocol { 48 | let mockedDownloader = MockedAppleDownloader() 49 | mockedDownloader.environment = self 50 | return mockedDownloader 51 | } 52 | 53 | var urlSessionData: URLSessionProtocol = MockedURLSession() 54 | 55 | var urlSessionDownload: URLSessionProtocol { 56 | self.urlSessionData 57 | } 58 | } 59 | 60 | struct MockedRunRecorder: InputProtocol, OutputProtocol { 61 | func write(with writer: Subprocess.StandardInputWriter) async throws { 62 | 63 | } 64 | 65 | var lastExecutable: Executable? 66 | var lastArguments: Arguments = [] 67 | 68 | func containsExecutable(_ command: String) -> Bool { 69 | lastExecutable?.description.contains(command) ?? false 70 | } 71 | func containsArgument(_ argument: String) -> Bool { 72 | lastArguments.description.contains(argument) 73 | } 74 | func isEmpty() -> Bool { 75 | // print(lastExecutable?.description) 76 | lastExecutable == nil || lastExecutable?.description.isEmpty == true 77 | } 78 | 79 | } 80 | 81 | extension MockedEnvironment { 82 | static var runRecorder = MockedRunRecorder() 83 | 84 | func run( 85 | _ executable: Executable, 86 | arguments: Arguments, 87 | ) async throws -> ShellOutput { 88 | try await run( 89 | executable, 90 | arguments: arguments, 91 | workingDirectory: nil 92 | ) 93 | } 94 | func run( 95 | _ executable: Executable, 96 | arguments: Arguments, 97 | workingDirectory: FilePath?, 98 | ) async throws -> ShellOutput { 99 | 100 | MockedEnvironment.runRecorder.lastExecutable = executable 101 | MockedEnvironment.runRecorder.lastArguments = arguments 102 | 103 | // Return a dummy CollectedResult 104 | return CollectedResult( 105 | processIdentifier: ProcessIdentifier(value: 9999), 106 | terminationStatus: TerminationStatus.exited(0), 107 | standardOutput: "mocked output", 108 | standardError: "mocked error", 109 | ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/xcodeinstallTests/Secrets/AWSSecretsHandlerSotoTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecretsStorageAWSSotoTest.swift 3 | // xcodeinstallTests 4 | // 5 | // Created by Stormacq, Sebastien on 16/09/2022. 6 | // 7 | 8 | import Foundation 9 | import Logging 10 | import SotoCore 11 | import SotoSecretsManager 12 | import Testing 13 | 14 | @testable import xcodeinstall 15 | @Suite("Secrets Storage AWS Soto") 16 | struct SecretsStorageAWSSotoTest { 17 | 18 | var secretHandler: SecretsStorageAWSSoto? 19 | let log = Logger(label: "SecretsStorageAWSSotoTest") 20 | 21 | init() throws { 22 | // given 23 | let region = "eu-central-1" 24 | 25 | // when 26 | do { 27 | let awsClient = AWSClient( 28 | credentialProvider: TestEnvironment.credentialProvider, 29 | httpClientProvider: .createNew 30 | ) 31 | let smClient = SecretsManager( 32 | client: awsClient, 33 | endpoint: TestEnvironment.getEndPoint() 34 | ) 35 | 36 | secretHandler = 37 | try SecretsStorageAWSSoto.forRegion(region, awsClient: awsClient, smClient: smClient, log: log) 38 | as? SecretsStorageAWSSoto 39 | #expect(secretHandler != nil) 40 | 41 | if TestEnvironment.isUsingLocalstack { 42 | print("Connecting to Localstack") 43 | } else { 44 | print("Connecting to AWS") 45 | } 46 | 47 | // then 48 | // no error 49 | 50 | } catch SecretsStorageAWSError.invalidRegion(let error) { 51 | #expect(region == error) 52 | } catch { 53 | Issue.record("unexpected error : \(error)") 54 | } 55 | 56 | } 57 | 58 | @Test("Test Init With Correct Region") 59 | func testInitWithCorrectRegion() { 60 | 61 | // given 62 | let region = "eu-central-1" 63 | 64 | // when 65 | let _ = #expect(throws: Never.self) { 66 | let _ = try SecretsStorageAWSSoto.forRegion(region, log: log) 67 | } 68 | } 69 | 70 | @Test("Test Init With Incorrect Region") 71 | func testInitWithIncorrectRegion() { 72 | 73 | // given 74 | let region = "invalid" 75 | 76 | // when 77 | let error = #expect(throws: SecretsStorageAWSError.self) { 78 | let _ = try SecretsStorageAWSSoto.forRegion(region, log: log) 79 | } 80 | if case let .invalidRegion(errorRegion) = error { 81 | #expect(region == errorRegion) 82 | } else { 83 | Issue.record("Expected invalidRegion error") 84 | } 85 | } 86 | 87 | #if os(macOS) 88 | // [CI] on Linux fails because there is no AWS credentials provider configured 89 | @Test("Test Create Secret") 90 | func testCreateSecret() async { 91 | 92 | // given 93 | #expect(secretHandler != nil) 94 | let credentials = AppleCredentialsSecret(username: "username", password: "password") 95 | 96 | // when 97 | do { 98 | try await secretHandler!.updateSecret(secretId: .appleCredentials, newValue: credentials) 99 | } catch _ as CredentialProviderError { 100 | // ignore 101 | // it allows to run the test on machines not configured for AWS 102 | } catch { 103 | Issue.record("unexpected exception : \(error)") 104 | } 105 | 106 | } 107 | #endif 108 | } 109 | -------------------------------------------------------------------------------- /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 Logging 9 | import Testing 10 | 11 | @testable import xcodeinstall 12 | struct SecretsStorageAWSTest { 13 | 14 | var secretHandlerTest: SecretsHandlerTestsBase? = nil 15 | let log = Logger(label: "SecretsStorageAWSTest") 16 | 17 | init() async throws { 18 | 19 | secretHandlerTest = SecretsHandlerTestsBase() 20 | 21 | let AWS_REGION = "us-east-1" 22 | 23 | let mockedSDK = try MockedSecretsStorageAWSSDK.forRegion(AWS_REGION, log: log) 24 | secretHandlerTest!.secrets = try SecretsStorageAWS(sdk: mockedSDK, log: log) 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 Testing 9 | 10 | @testable import xcodeinstall 11 | 12 | #if canImport(FoundationNetworking) 13 | import FoundationNetworking 14 | #endif 15 | struct AppleSessionSecretTest { 16 | 17 | @Test("Test From String") 18 | func testFromString() { 19 | 20 | // given and when 21 | let ass = try? AppleSessionSecret( 22 | fromString: 23 | """ 24 | { 25 | "session": { 26 | "scnt":"scnt12345", 27 | "itcServiceKey": { 28 | "authServiceKey":"authServiceKey", 29 | "authServiceUrl":"authServiceUrl" 30 | }, 31 | "xAppleIdSessionId":"sessionid" 32 | }, 33 | "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" 34 | } 35 | """ 36 | ) 37 | 38 | // then 39 | #expect(ass != nil) 40 | 41 | let c = ass?.cookies() 42 | #expect(c?.count == 2) 43 | 44 | let s = ass?.session 45 | #expect(s != nil) 46 | } 47 | 48 | @Test("Test From Object") 49 | func testFromObject() { 50 | 51 | // given 52 | let cookies = 53 | "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" 54 | let session = AppleSession( 55 | itcServiceKey: AppleServiceKey(authServiceUrl: "authServiceUrl", authServiceKey: "authServiceKey"), 56 | xAppleIdSessionId: "sessionid", 57 | scnt: "scnt12345" 58 | ) 59 | 60 | // when 61 | let ass = AppleSessionSecret(cookies: cookies, session: session) 62 | 63 | // then 64 | let c = ass.cookies() 65 | #expect(c.count == 2) 66 | 67 | let _ = #expect(throws: Never.self) { 68 | try ass.string() 69 | } 70 | if let a = try? ass.string() { 71 | #expect(a.contains("scnt12345")) 72 | } else { 73 | Issue.record("Failed to get string representation") 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /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 Logging 9 | import Testing 10 | 11 | @testable import xcodeinstall 12 | 13 | @Suite("SecretsStorageFileTest", .serialized) 14 | struct SecretsStorageFileTest { 15 | 16 | var log = Logger(label: "SecretsStorageFileTest") 17 | var secretHandlerTest: SecretsHandlerTestsBase? 18 | 19 | init() async throws { 20 | secretHandlerTest = SecretsHandlerTestsBase() 21 | 22 | secretHandlerTest!.secrets = SecretsStorageFile(log: log) 23 | try await secretHandlerTest!.secrets!.clearSecrets() 24 | } 25 | 26 | @Test("Test Merge Cookies No Conflict") 27 | func testMergeCookiesNoConflict() async throws { 28 | try await secretHandlerTest!.testMergeCookiesNoConflict() 29 | } 30 | 31 | @Test("Test Merge Cookies One Conflict") 32 | func testMergeCookiesOneConflict() async throws { 33 | try await secretHandlerTest!.testMergeCookiesOneConflict() 34 | } 35 | 36 | @Test("Test Load and Save Session") 37 | func testLoadAndSaveSession() async throws { 38 | try await secretHandlerTest!.testLoadAndSaveSession() 39 | } 40 | 41 | @Test("Test Load and Save Cookies") 42 | func testLoadAndSaveCookies() async throws { 43 | try await secretHandlerTest!.testLoadAndSaveCookies() 44 | } 45 | 46 | @Test("Test Load Session No Exist") 47 | func testLoadSessionNoExist() async { 48 | await secretHandlerTest!.testLoadSessionNoExist() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 Logging 10 | import Synchronization 11 | 12 | @testable import xcodeinstall 13 | 14 | #if canImport(FoundationNetworking) 15 | import FoundationNetworking 16 | #endif 17 | 18 | @MainActor 19 | final class MockedSecretsHandler: SecretsHandlerProtocol { 20 | var nextError: SecretsStorageAWSError? 21 | var env: Environment 22 | public init(env: inout MockedEnvironment, nextError: SecretsStorageAWSError? = nil) { 23 | self.nextError = nextError 24 | self.env = env 25 | } 26 | 27 | func clearSecrets() async throws { 28 | 29 | } 30 | 31 | func saveCookies(_ cookies: String?) async throws -> String? { 32 | "" 33 | } 34 | 35 | func loadCookies() async throws -> [HTTPCookie] { 36 | [] 37 | } 38 | 39 | func saveSession(_ session: AppleSession) async throws -> AppleSession { 40 | session 41 | } 42 | 43 | func loadSession() async throws -> AppleSession? { 44 | nil 45 | } 46 | 47 | func retrieveAppleCredentials() async throws -> AppleCredentialsSecret { 48 | if let nextError = nextError { 49 | throw nextError 50 | } 51 | guard let rl = env.readLine as? MockedReadLine else { 52 | fatalError("Invalid Mocked Environment") 53 | } 54 | 55 | return AppleCredentialsSecret(username: rl.readLine(prompt: "")!, password: rl.readLine(prompt: "")!) 56 | } 57 | func storeAppleCredentials(_ credentials: xcodeinstall.AppleCredentialsSecret) async throws { 58 | //TODO: how can we set region on env.awsSDK ? We just have a copy of the env here 59 | // print("set region !!!") 60 | } 61 | 62 | } 63 | 64 | final class MockedSecretsStorageAWSSDK: SecretsStorageAWSSDKProtocol { 65 | 66 | private let _regionSet: Mutex = .init(false) 67 | let appleSession: Mutex 68 | let appleCredentials: Mutex 69 | 70 | private init() throws { 71 | appleSession = try .init(AppleSessionSecret(fromString: "{}")) 72 | appleCredentials = .init(AppleCredentialsSecret(username: "", password: "")) 73 | } 74 | 75 | static func forRegion(_ region: String, log: Logger) throws -> any xcodeinstall.SecretsStorageAWSSDKProtocol { 76 | let mock = try MockedSecretsStorageAWSSDK() 77 | mock._regionSet.withLock { $0 = true } 78 | return mock 79 | } 80 | 81 | func regionSet() -> Bool { 82 | _regionSet.withLock { $0 } 83 | } 84 | 85 | func saveSecret(secretId: AWSSecretsName, secret: T) async throws where T: Secrets { 86 | switch secretId { 87 | case .appleCredentials: 88 | appleCredentials.withLock { $0 = secret as! AppleCredentialsSecret } 89 | case .appleSessionToken: 90 | appleSession.withLock { $0 = secret as! AppleSessionSecret } 91 | } 92 | } 93 | 94 | func updateSecret(secretId: AWSSecretsName, newValue: T) async throws where T: Secrets { 95 | switch secretId { 96 | case .appleCredentials: 97 | 98 | appleCredentials.withLock { $0 = newValue as! AppleCredentialsSecret } 99 | case .appleSessionToken: 100 | appleSession.withLock { $0 = newValue as! AppleSessionSecret } 101 | } 102 | } 103 | 104 | func retrieveSecret(secretId: AWSSecretsName) async throws -> T where T: Secrets { 105 | switch secretId { 106 | case .appleCredentials: 107 | return appleCredentials.withLock { $0 as! T } 108 | case .appleSessionToken: 109 | return appleSession.withLock { $0 as! T } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /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 | 12 | /// Provide various test environment variables 13 | struct TestEnvironment { 14 | /// 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 15 | static var isUsingLocalstack: Bool { 16 | // return Environment["AWS_DISABLE_LOCALSTACK"] != "true" || 17 | // (Environment["GITHUB_ACTIONS"] == "true" && Environment["AWS_ACCESS_KEY_ID"] == "") 18 | false 19 | } 20 | 21 | static var credentialProvider: CredentialProviderFactory { 22 | isUsingLocalstack 23 | ? .static(accessKeyId: "foo", secretAccessKey: "bar") : .selector(.configFile(), .environment, .ec2) 24 | } 25 | 26 | /// current list of middleware 27 | // static var middlewares: [AWSServiceMiddleware] { 28 | // // return (Environment["AWS_ENABLE_LOGGING"] == "true") ? [AWSLoggingMiddleware()] : [] 29 | // [] 30 | // } 31 | 32 | /// return endpoint 33 | static func getEndPoint(environment: String = "") -> String? { 34 | guard self.isUsingLocalstack == true else { return nil } 35 | // return Environment[environment] ?? "http://localhost:4566" 36 | return "http://localhost:4566" 37 | } 38 | 39 | /// get name to use for AWS resource 40 | // static func generateResourceName(_ function: String = #function) -> String { 41 | // let prefix = Environment["AWS_TEST_RESOURCE_PREFIX"] ?? "" 42 | // return "soto-" + (prefix + function).filter { $0.isLetter || $0.isNumber }.lowercased() 43 | // } 44 | 45 | // public static var logger: Logger = { 46 | // if let loggingLevel = Environment["AWS_LOG_LEVEL"] { 47 | // if let logLevel = Logger.Level(rawValue: loggingLevel.lowercased()) { 48 | // var logger = Logger(label: "soto") 49 | // logger.logLevel = logLevel 50 | // return logger 51 | // } 52 | // } 53 | // return AWSClient.loggingDisabled 54 | // }() 55 | } 56 | -------------------------------------------------------------------------------- /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 | func setSession(on authenticator: AppleAuthenticator, session: AppleSession) { 10 | authenticator.session = session 11 | } 12 | 13 | /// Helper to modify session properties (handles MainActor isolation) 14 | func modifySession(on authenticator: AppleAuthenticator, modifier: (inout AppleSession) -> Void) { 15 | var session = authenticator.session 16 | modifier(&session) 17 | authenticator.session = session 18 | } 19 | 20 | /// Helper to access session properties safely (handles MainActor isolation) 21 | func getSessionProperty(from authenticator: AppleAuthenticator, accessor: (AppleSession) -> T) -> T { 22 | accessor(authenticator.session) 23 | } 24 | -------------------------------------------------------------------------------- /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 | final class MockedFileHandler: FileHandlerProtocol, @unchecked Sendable { 15 | 16 | var moveSrc: URL? = nil 17 | var moveDst: URL? = nil 18 | var nextFileExist: Bool? = nil 19 | var nextFileCorrect: Bool? = nil 20 | 21 | func move(from src: URL, to dst: URL) throws { 22 | moveSrc = src 23 | moveDst = dst 24 | } 25 | func fileExists(file: URL, fileSize: Int) -> Bool { 26 | if let nextFileExist { 27 | return nextFileExist 28 | } else { 29 | return true 30 | } 31 | } 32 | func downloadedFiles() throws -> [String] { 33 | ["name.pkg", "name.dmg"] 34 | } 35 | 36 | func downloadDirectory() -> URL { 37 | baseFilePath() 38 | } 39 | 40 | func checkFileSize(file: URL, fileSize: Int) throws -> Bool { 41 | if let nextFileCorrect { 42 | return nextFileCorrect 43 | } else { 44 | return true 45 | } 46 | } 47 | 48 | func downloadFileURL(file: DownloadList.File) -> URL { 49 | URL(fileURLWithPath: downloadFilePath(file: file)) 50 | } 51 | 52 | func downloadFilePath(file: DownloadList.File) -> String { 53 | "/download/\(file.filename)" 54 | } 55 | 56 | func saveDownloadList(list: DownloadList) throws -> DownloadList { 57 | let listData = try loadTestData(file: .downloadList) 58 | return try JSONDecoder().decode(DownloadList.self, from: listData) 59 | } 60 | 61 | func loadDownloadList() throws -> DownloadList { 62 | let listData = try loadTestData(file: .downloadList) 63 | return try JSONDecoder().decode(DownloadList.self, from: listData) 64 | } 65 | 66 | func baseFilePath() -> URL { 67 | URL(string: "file:///tmp")! 68 | } 69 | 70 | func baseFilePath() -> String { 71 | "/tmp" 72 | } 73 | } 74 | 75 | class MockedProgressBar: CLIProgressBarProtocol { 76 | 77 | var isComplete = false 78 | var isClear = false 79 | var step = 0 80 | var total = 0 81 | var text = "" 82 | private var _defineCalled = false 83 | 84 | func define(animationType: CLIlib.ProgressBarType, message: String) { 85 | _defineCalled = true 86 | } 87 | func defineCalled() -> Bool { 88 | let called = _defineCalled 89 | _defineCalled = false 90 | return called 91 | } 92 | 93 | func update(step: Int, total: Int, text: String) { 94 | self.step = step 95 | self.total = total 96 | self.text = text 97 | } 98 | 99 | func complete(success: Bool) { 100 | isComplete = success 101 | } 102 | 103 | func clear() { 104 | isClear = true 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /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 Logging 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 | func createDownloadList() throws { 34 | let fm = FileManager.default 35 | let log = Logger(label: "TEST createDownloadList") 36 | 37 | // delete file at destination if it exists 38 | let downloadListPath = FileHandler(log: log).downloadListPath() 39 | if fm.fileExists(atPath: downloadListPath.path) { 40 | try fm.removeItem(at: downloadListPath) 41 | } 42 | 43 | // get the source URL and copy to destination 44 | let testFilePath = try urlForTestData(file: .downloadList) 45 | try fm.copyItem(at: testFilePath, to: downloadListPath) 46 | } 47 | 48 | func deleteDownloadList() throws { 49 | let fm = FileManager.default 50 | let log = Logger(label: "TEST deleteDownloadList") 51 | 52 | // remove test file from destination 53 | let downloadListPath = FileHandler(log: log).downloadListPath() 54 | if fm.fileExists(atPath: downloadListPath.path) { 55 | try fm.removeItem(at: downloadListPath) 56 | } 57 | } 58 | 59 | // https://stackoverflow.com/questions/47177036/use-resources-in-unit-tests-with-swift-package-manager 60 | //#if XCODE_BUILD - also defined in swiftpm, I use a custom flag defined in Package.swift instead 61 | // #if !SWIFTPM_COMPILATION 62 | // extension Foundation.Bundle { 63 | 64 | // /// Returns resource bundle as a `Bundle`. 65 | // /// Requires Xcode copy phase to locate files into `ExecutableName.bundle`; 66 | // /// or `ExecutableNameTests.bundle` for test resources 67 | // static var module: Bundle = { 68 | // var thisModuleName = "xcodeinstall" 69 | // var url = Bundle.main.bundleURL 70 | 71 | // for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 72 | // url = bundle.bundleURL.deletingLastPathComponent() 73 | // thisModuleName = thisModuleName.appending("Tests") 74 | // } 75 | 76 | // url = url.appendingPathComponent("\(thisModuleName).xctest") 77 | 78 | // guard let bundle = Bundle(url: url) else { 79 | // fatalError("Foundation.Bundle.module could not load resource bundle: \(url.path)") 80 | // } 81 | 82 | // return bundle 83 | // }() 84 | 85 | // /// Directory containing resource bundle 86 | // static var moduleDir: URL = { 87 | // var url = Bundle.main.bundleURL 88 | // for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 89 | // // remove 'ExecutableNameTests.xctest' path component 90 | // url = bundle.bundleURL.deletingLastPathComponent() 91 | // } 92 | // return url 93 | // }() 94 | // } 95 | // #endif 96 | -------------------------------------------------------------------------------- /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.14.0 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/a69a28c7414878fe943c93889752cda5d39b2114/img/download.png -------------------------------------------------------------------------------- /img/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/a69a28c7414878fe943c93889752cda5d39b2114/img/install.png -------------------------------------------------------------------------------- /img/mfa-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/a69a28c7414878fe943c93889752cda5d39b2114/img/mfa-01.png -------------------------------------------------------------------------------- /img/mfa-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/a69a28c7414878fe943c93889752cda5d39b2114/img/mfa-02.png -------------------------------------------------------------------------------- /img/xcodeinstall-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebsto/xcodeinstall/a69a28c7414878fe943c93889752cda5d39b2114/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 | ## Currently fails 2 | 3 | https://github.com/orgs/Homebrew/discussions/59 4 | 5 | 6 | ## TODO 7 | 8 | Consider using a github action for this 9 | https://github.com/Homebrew/actions 10 | https://github.com/ipatch/homebrew-freecad-pg13/blob/main/.github/workflows/publish.yml 11 | https://github.com/marketplace/actions/homebrew-bump-cask 12 | 13 | ## To release a new version. 14 | 15 | 1. Commit all other changes and push them 16 | 17 | 2. Update version number in `scripts/deploy/release-sources.sh` 18 | 19 | 3. `./scripts/deploy/release_sources.sh` 20 | 21 | This script 22 | - creates a new version 23 | - tags the branch and push the tag 24 | - creates a GitHub release 25 | - creates a brew formula with the new release 26 | 27 | 4. `./scripts/deploy/bottle.sh` 28 | 29 | This script 30 | - creates the brew bottles TAR file and the code to add to the formula 31 | 32 | 5. `./scripts/deploy/release_binaries.sh` 33 | 34 | This script 35 | - uploads the bottles to the GitHub Release 36 | - update the brew formula with the bootle definition 37 | 38 | ## To undo a release 39 | 40 | While testing this procedure, it is useful to undo a release. 41 | 42 | !! Destructive actions !! 43 | 44 | 1. `./scripts/deploy/delete_release.sh` 45 | 46 | 2. `git reset HEAD~1` 47 | 48 | 3. Reset Version file 49 | 50 | ```zsh 51 | SOURCE_FILE="Sources/xcodeinstall/Version.swift" 52 | 53 | cat <"$SOURCE_FILE" 54 | // Generated by: scripts/deploy/version 55 | enum Version { 56 | static let version = "" 57 | } 58 | EOF 59 | ``` 60 | 61 | 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/deploy/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/deploy/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/deploy/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 arm64_sonoma arm64_sequoia ventura sonoma 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} == "13" ]]; then 45 | if [[ "x86_64" == "$(uname -m)" ]]; then 46 | CURRENT_PLATFORM=ventura 47 | else 48 | CURRENT_PLATFORM=arm64_ventura 49 | fi 50 | elif [[ ${CURRENT_OS_VERSION_MAJOR} == "14" ]]; then 51 | if [[ "x86_64" == "$(uname -m)" ]]; then 52 | CURRENT_PLATFORM=sonoma 53 | else 54 | CURRENT_PLATFORM=arm64_sonoma 55 | fi 56 | elif [[ ${CURRENT_OS_VERSION_MAJOR} == "15" ]]; then 57 | if [[ "x86_64" == "$(uname -m)" ]]; then 58 | CURRENT_PLATFORM=sequoia 59 | else 60 | CURRENT_PLATFORM=arm64_sequoia 61 | fi 62 | elif [[ ${CURRENT_OS_VERSION_MAJOR} == "26" ]]; then 63 | if [[ "x86_64" == "$(uname -m)" ]]; then 64 | CURRENT_PLATFORM=tahoe 65 | else 66 | CURRENT_PLATFORM=arm64_tahoe 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/deploy/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/deploy/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 --no-verify origin --delete $TAG 9 | git reset HEAD~1 -------------------------------------------------------------------------------- /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/deploy/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/deploy/xcodeinstall.rb ]; then 17 | echo "Brew formula file does not exist. (./scripts/deploy/xcodeinstall.rb)" 18 | echo "It is created by 'scripts/deploy/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/deploy/bottle.sh" 24 | exit -1 25 | fi 26 | sed -i .bak -E -e "/ # insert bottle definition here/r BOTTLE_BLOCK" ./scripts/deploy/xcodeinstall.rb 27 | rm ./scripts/deploy/xcodeinstall.rb.bak 28 | 29 | echo "\n🍺 Pushing new formula\n" 30 | cp ./scripts/deploy/xcodeinstall.rb ../homebrew-macos 31 | pushd ../homebrew-macos 32 | git add xcodeinstall.rb 33 | git commit --quiet -m "update for $TAG" 34 | git push --no-verify --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.12.0" 9 | ###################### 10 | 11 | echo $VERSION > VERSION 12 | TAG=v$VERSION 13 | 14 | echo "\n➕ Add new version to source code\n" 15 | scripts/deploy/version.sh 16 | 17 | echo "\n🏷 Tagging GitHub\n" 18 | git tag $TAG 19 | git push --no-verify --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/deploy/xcodeinstall.template > scripts/deploy/xcodeinstall.rb 38 | 39 | echo "\n🍺 Pushing new formula\n" 40 | pushd ../homebrew-macos 41 | git pull 42 | cp ../xcodeinstall/scripts/deploy/xcodeinstall.rb . 43 | git add xcodeinstall.rb 44 | git commit --quiet -m "update for $TAG" 45 | git push --no-verify --quiet > /dev/null 2>&1 46 | popd 47 | 48 | 49 | -------------------------------------------------------------------------------- /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/deploy/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/deploy/version.sh 18 | struct Version { 19 | let current = "${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 --no-verify --quiet > /dev/null 2>&1 26 | -------------------------------------------------------------------------------- /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.12.0.tar.gz" 9 | sha256 "efb4296ddd339ca917b1a91ec7ac251c479b1c0c5dbbb2860f65cbc99fd1e570" 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.12.0" 15 | sha256 cellar: :any_skip_relocation, arm64_ventura: "c9a434261572cfabb7c9606c5316a19f0e01a26874f5de2d40faf97ccd1dde43" 16 | sha256 cellar: :any_skip_relocation, arm64_sonoma: "c9a434261572cfabb7c9606c5316a19f0e01a26874f5de2d40faf97ccd1dde43" 17 | sha256 cellar: :any_skip_relocation, arm64_sequoia: "c9a434261572cfabb7c9606c5316a19f0e01a26874f5de2d40faf97ccd1dde43" 18 | sha256 cellar: :any_skip_relocation, ventura: "c9a434261572cfabb7c9606c5316a19f0e01a26874f5de2d40faf97ccd1dde43" 19 | sha256 cellar: :any_skip_relocation, sonoma: "c9a434261572cfabb7c9606c5316a19f0e01a26874f5de2d40faf97ccd1dde43" 20 | sha256 cellar: :any_skip_relocation, sequoia: "c9a434261572cfabb7c9606c5316a19f0e01a26874f5de2d40faf97ccd1dde43" 21 | end 22 | 23 | def install 24 | system "./scripts/deploy/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/deploy/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 | -------------------------------------------------------------------------------- /scripts/restoreSession.sh: -------------------------------------------------------------------------------- 1 | cp ~/.xcodeinstall/cookies.seb ~/.xcodeinstall/cookies 2 | cp ~/.xcodeinstall/session.seb ~/.xcodeinstall/session 3 | --------------------------------------------------------------------------------