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