├── .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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------