Repository: XcodesOrg/XcodesApp
Branch: main
Commit: 1a0d3353b9a1
Files: 213
Total size: 1.4 MB
Directory structure:
gitextract_lwe5ux5j/
├── .github/
│ ├── CODEOWNERS
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── dependabot.yml
│ ├── release-drafter.yml
│ └── workflows/
│ ├── appcast.yml
│ ├── ci.yml
│ ├── release-drafter.yml
│ └── xcstrings.yml
├── .gitignore
├── AppCast/
│ ├── .gitignore
│ ├── Gemfile
│ ├── _config.yml
│ ├── _includes/
│ │ └── appcast.inc
│ ├── _plugins/
│ │ └── signature_filter.rb
│ ├── appcast.xml
│ └── appcast_pre.xml
├── CONTRIBUTING.md
├── DECISIONS.md
├── HelperXPCShared/
│ └── HelperXPCShared.swift
├── LICENSE
├── README.md
├── Scripts/
│ ├── export_options.plist
│ ├── fix_libfido2_framework.sh
│ ├── increment_build_number.sh
│ ├── notarize.sh
│ ├── package_release.sh
│ ├── sign_update
│ └── uninstall_privileged_helper.sh
├── Xcodes/
│ ├── AcknowledgementsGenerator/
│ │ ├── .gitignore
│ │ ├── .swiftpm/
│ │ │ └── xcode/
│ │ │ └── package.xcworkspace/
│ │ │ └── contents.xcworkspacedata
│ │ ├── Package.swift
│ │ ├── README.md
│ │ ├── Sources/
│ │ │ └── AcknowledgementsGenerator/
│ │ │ ├── Extensions/
│ │ │ │ ├── CollectionExtensions.swift
│ │ │ │ └── StringExtensions.swift
│ │ │ ├── Tools/
│ │ │ │ └── Xcode.swift
│ │ │ └── main.swift
│ │ └── spm-licenses.LICENSE
│ ├── AppleAPI/
│ │ ├── .gitignore
│ │ ├── Package.swift
│ │ ├── README.md
│ │ ├── Sources/
│ │ │ └── AppleAPI/
│ │ │ ├── Client.swift
│ │ │ ├── Environment.swift
│ │ │ ├── Hashcash.swift
│ │ │ └── URLRequest+Apple.swift
│ │ └── Tests/
│ │ ├── AppleAPITests/
│ │ │ ├── AppleAPITests.swift
│ │ │ └── XCTestManifests.swift
│ │ └── LinuxMain.swift
│ ├── Backend/
│ │ ├── AppState+Install.swift
│ │ ├── AppState+Runtimes.swift
│ │ ├── AppState+Update.swift
│ │ ├── AppState.swift
│ │ ├── Aria2CError.swift
│ │ ├── AvailableXcode.swift
│ │ ├── Bundle+InfoPlistValues.swift
│ │ ├── Collection+.swift
│ │ ├── Configure.swift
│ │ ├── DataSource.swift
│ │ ├── DateFormatter+.swift
│ │ ├── Downloader.swift
│ │ ├── Downloads.swift
│ │ ├── Entry+.swift
│ │ ├── Environment.swift
│ │ ├── FileError.swift
│ │ ├── FileManager+.swift
│ │ ├── FocusedValues.swift
│ │ ├── Foundation.swift
│ │ ├── Hardware.swift
│ │ ├── HelperClient.swift
│ │ ├── HelperInstallState.swift
│ │ ├── InstalledXcode.swift
│ │ ├── IsTesting.swift
│ │ ├── NotificationManager.swift
│ │ ├── Optional+IsNotNil.swift
│ │ ├── Path+.swift
│ │ ├── Process.swift
│ │ ├── Progress+.swift
│ │ ├── Publisher+Resumable.swift
│ │ ├── SDKs+Xcode.swift
│ │ ├── SelectedActionType.swift
│ │ ├── SelectedXcode.swift
│ │ ├── URLRequest+Apple.swift
│ │ ├── URLSession+DownloadTaskPublisher.swift
│ │ ├── Version+.swift
│ │ ├── Version+Xcode.swift
│ │ ├── Version+XcodeReleases.swift
│ │ ├── Xcode.swift
│ │ ├── XcodeCommands.swift
│ │ └── XcodeInstallState.swift
│ ├── Frontend/
│ │ ├── About/
│ │ │ ├── AboutView.swift
│ │ │ ├── AcknowledgementsView.swift
│ │ │ └── ScrollingTextView.swift
│ │ ├── Common/
│ │ │ ├── NavigationSplitViewWrapper.swift
│ │ │ ├── ObservingProgressIndicator.swift
│ │ │ ├── ProgressButton.swift
│ │ │ ├── ProgressIndicator.swift
│ │ │ ├── TagView.swift
│ │ │ ├── TrailingIconLabelStyle.swift
│ │ │ ├── XcodesAlert.swift
│ │ │ └── XcodesSheet.swift
│ │ ├── InfoPane/
│ │ │ ├── CompatibilityView.swift
│ │ │ ├── CompilersView.swift
│ │ │ ├── CornerRadiusModifier.swift
│ │ │ ├── IconView.swift
│ │ │ ├── IdenticalBuildView.swift
│ │ │ ├── InfoPane.swift
│ │ │ ├── InfoPaneControls.swift
│ │ │ ├── InstallationStepDetailView.swift
│ │ │ ├── InstalledStateButtons.swift
│ │ │ ├── NotInstalledStateButtons.swift
│ │ │ ├── PlatformsView.swift
│ │ │ ├── ReleaseDateView.swift
│ │ │ ├── ReleaseNotesView.swift
│ │ │ ├── RuntimeInstallationStepDetailView.swift
│ │ │ ├── SDKsView.swift
│ │ │ └── UnselectedView.swift
│ │ ├── MainWindow.swift
│ │ ├── Preferences/
│ │ │ ├── AdvancedPreferencePane.swift
│ │ │ ├── DownloadPreferencePane.swift
│ │ │ ├── ExperiementsPreferencePane.swift
│ │ │ ├── GeneralPreferencePane.swift
│ │ │ ├── NotificationsView.swift
│ │ │ ├── PlatformsListView.swift
│ │ │ ├── PreferencesView.swift
│ │ │ └── UpdatesPreferencePane.swift
│ │ ├── SignIn/
│ │ │ ├── AttributedText.swift
│ │ │ ├── NSAttributedString+.swift
│ │ │ ├── PinCodeTextView.swift
│ │ │ ├── SignIn2FAView.swift
│ │ │ ├── SignInCredentialsView.swift
│ │ │ ├── SignInPhoneListView.swift
│ │ │ ├── SignInSMSView.swift
│ │ │ ├── SignInSecurityKeyPinView.swift
│ │ │ ├── SignInSecurityKeyTouchView.swift
│ │ │ └── SignedInView.swift
│ │ ├── View+Conditional.swift
│ │ ├── View+IsHidden.swift
│ │ └── XcodeList/
│ │ ├── AppStoreButtonStyle.swift
│ │ ├── BottomStatusBar.swift
│ │ ├── InstallationStepRowView.swift
│ │ ├── MainToolbar.swift
│ │ ├── Tag.swift
│ │ ├── XcodeListCategory.swift
│ │ ├── XcodeListView.swift
│ │ └── XcodeListViewRow.swift
│ ├── Preview Content/
│ │ └── Preview Assets.xcassets/
│ │ └── Contents.json
│ ├── Resources/
│ │ ├── Assets.xcassets/
│ │ │ ├── AppIcon.appiconset/
│ │ │ │ └── Contents.json
│ │ │ ├── Contents.json
│ │ │ ├── Icons/
│ │ │ │ └── Contents.json
│ │ │ ├── install.imageset/
│ │ │ │ └── Contents.json
│ │ │ ├── xcode-beta.imageset/
│ │ │ │ └── Contents.json
│ │ │ └── xcode.imageset/
│ │ │ └── Contents.json
│ │ ├── Info.plist
│ │ ├── Licenses.rtf
│ │ ├── Localizable.xcstrings
│ │ ├── Xcodes.entitlements
│ │ ├── XcodesIcon.icon/
│ │ │ └── icon.json
│ │ ├── XcodesTest.entitlements
│ │ ├── aria2c
│ │ ├── aria2c.LICENSE
│ │ └── unxip
│ ├── XcodesApp.swift
│ └── XcodesKit/
│ ├── .gitignore
│ ├── Package.swift
│ ├── README.md
│ ├── Sources/
│ │ └── XcodesKit/
│ │ ├── Extensions/
│ │ │ ├── Foundation.swift
│ │ │ └── Logger.swift
│ │ ├── Models/
│ │ │ ├── Runtimes/
│ │ │ │ ├── CoreSimulatorImage.swift
│ │ │ │ ├── RuntimeInstallState.swift
│ │ │ │ ├── RuntimeInstallationStep.swift
│ │ │ │ └── Runtimes.swift
│ │ │ ├── XcodeInstallState.swift
│ │ │ ├── XcodeInstallationStep.swift
│ │ │ └── XcodeReleases/
│ │ │ ├── Architecture.swift
│ │ │ ├── Checksums.swift
│ │ │ ├── Compilers.swift
│ │ │ ├── Link.swift
│ │ │ ├── Release.swift
│ │ │ ├── SDKs.swift
│ │ │ ├── XcodeRelease.swift
│ │ │ ├── XcodeVersion.swift
│ │ │ └── YMD.swift
│ │ ├── Services/
│ │ │ └── RuntimeService.swift
│ │ ├── Shell/
│ │ │ ├── Process.swift
│ │ │ └── XcodesShell.swift
│ │ └── XcodesKitEnvironment.swift
│ └── Tests/
│ └── XcodesKitTests/
│ └── XcodesKitTests.swift
├── Xcodes.xcodeproj/
│ ├── ._project.xcworkspace
│ ├── project.pbxproj
│ ├── project.xcworkspace/
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata/
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm/
│ │ └── Package.resolved
│ └── xcshareddata/
│ └── xcschemes/
│ ├── Xcodes.xcscheme
│ └── com.robotsandpencils.XcodesApp.Helper.xcscheme
├── XcodesTests/
│ ├── AppStateTests.swift
│ ├── AppStateUpdateTests.swift
│ ├── Bundle+XcodesTests.swift
│ ├── Environment+Mock.swift
│ ├── Fixtures/
│ │ ├── Stub-0.0.0.Info.plist
│ │ └── Stub-version.plist
│ └── Info.plist
├── com.xcodesorg.xcodesapp.Helper/
│ ├── AuditTokenHack.h
│ ├── AuditTokenHack.m
│ ├── ConnectionVerifier.swift
│ ├── Info.plist
│ ├── Logger.swift
│ ├── SimpleXPCApp.LICENSE
│ ├── XPCDelegate.swift
│ ├── com.xcodesorg.xcodesapp.Helper-Bridging-Header.h
│ ├── com.xcodesorg.xcodesapp.HelperTest.entitlements
│ ├── launchd.plist
│ └── main.swift
└── nextstep.md
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/CODEOWNERS
================================================
* @RobotsAndPencils/xcodes
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Version**
- OS:
- Xcodes:
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Tell us how we can improve Xcodes**
**Is your feature request related to a problem? Please describe.**
**What would you like to see? How would you like it to work?**
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
================================================
FILE: .github/release-drafter.yml
================================================
categories:
- title: '🚀 Enhancements'
labels:
- 'enhancement'
- title: '🐛 Bug Fixes'
labels:
- 'bugfix'
- title: '🌎 Localization'
labels:
- 'localization'
- title: '🧰 Maintenance'
label:
- 'chore'
- 'documentation'
- 'dependencies'
template: |
Install Xcodes using one of the methods listed [here](https://github.com/RobotsAndPencils/XcodesApp#installation).
Update Xcodes by selecting Check for Updates... in the Xcodes menu in the menu bar.
## Changes
$CHANGES
================================================
FILE: .github/workflows/appcast.yml
================================================
name: Build and publish a new appcast file
on:
workflow_dispatch:
release:
jobs:
jekyll:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎
uses: actions/checkout@v4
with:
# If you're using actions/checkout@v4 you must set persist-credentials to false in most cases for the deployment to work correctly.
persist-credentials: false
# - name: Cache 📦
# uses: actions/cache@v4.1.1
# with:
# path: AppCast/vendor/bundle
# key: ${{ runner.os }}-gems-v1.0-${{ hashFiles('AppCast/Gemfile') }}
# restore-keys: |
# ${{ runner.os }}-gems-
- name: Setup Ruby, JRuby and TruffleRuby
uses: ruby/setup-ruby@v1.197.0
with:
ruby-version: '3.0'
- name: Bundler 💎
working-directory: AppCast
env:
BUNDLE_PATH: vendor/bundle
run: |
gem install bundler
bundle install
- name: Build 🛠
working-directory: AppCast
env:
BUNDLE_PATH: vendor/bundle
JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bundle exec jekyll build
- name: Publish 🚀
uses: JamesIves/github-pages-deploy-action@releases/v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: gh-pages
folder: AppCast/_site
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Run tests
env:
DEVELOPER_DIR: /Applications/Xcode_16.4.app
run: xcodebuild test -scheme Xcodes
================================================
FILE: .github/workflows/release-drafter.yml
================================================
name: Release Drafter
on:
# Allow running it manually in case we forget to label a PR before merging
workflow_dispatch:
push:
branches:
- main
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/xcstrings.yml
================================================
name: XCStrings Validation
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: macos-13
steps:
- uses: actions/checkout@v4
- name: Clone SwiftPolyglot
run: git clone --branch 0.3.1 -- https://github.com/appdecostudio/SwiftPolyglot.git ../SwiftPolyglot
- name: validate translations
run: |
swift build --package-path ../SwiftPolyglot --configuration release
swift run --package-path ../SwiftPolyglot swiftpolyglot "ca,de,el,es,fi,fr,hi,it,ja,ko,nl,pl,pt-BR,ru,tr,uk,zh-Hans,zh-Hant" --errorOnMissing
================================================
FILE: .gitignore
================================================
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
Archive/*
Product/*
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Jetbrains IDEA
.idea
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
.DS_Store
================================================
FILE: AppCast/.gitignore
================================================
_site
.sass-cache
.jekyll-metadata
================================================
FILE: AppCast/Gemfile
================================================
source "https://rubygems.org"
# Hello! This is where you manage which Jekyll version is used to run.
# When you want to use a different version, change it below, save the
# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
#
# bundle exec jekyll serve
#
# This will help ensure the proper Jekyll version is running.
# Happy Jekylling!
gem "jekyll", "~> 4.4.1"
gem "jekyll-github-metadata", group: :jekyll_plugins
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
# and associated library.
install_if -> { RUBY_PLATFORM =~ %r!mingw|mswin|java! } do
gem "tzinfo", "~> 1.2"
gem "tzinfo-data"
end
# Performance-booster for watching directories on Windows
gem "wdm", "~> 0.1.0", :install_if => Gem.win_platform?
# kramdown v2 ships without the gfm parser by default. If you're using
# kramdown v1, comment out this line.
gem "kramdown-parser-gfm"
================================================
FILE: AppCast/_config.yml
================================================
# Welcome to Jekyll!
#
# This config file is meant for settings that affect your whole blog, values
# which you are expected to set up once and rarely edit after that. If you find
# yourself editing this file very often, consider using Jekyll's data files
# feature for the data you need to update frequently.
#
# For technical reasons, this file is *NOT* reloaded automatically when you use
# 'bundle exec jekyll serve'. If you change this file, please restart the server process.
# Site settings
# These are used to personalize your new site. If you look in the HTML files,
# you will see them accessed via {{ site.title }}, {{ site.email }}, and so on.
# You can create any custom variable you would like, and they will be accessible
# in the templates via {{ site.myvariable }}.
title: Xcodes.app
description: >- # this means to ignore newlines until "baseurl:"
baseurl: "" # the subpath of your site, e.g. /blog
url: "" # the base hostname & protocol for your site, e.g. http://example.com
# Build settings
markdown: kramdown
plugins:
- "jekyll-github-metadata"
# Exclude from processing.
# The following items will not be processed, by default. Create a custom list
# to override the default setting.
# exclude:
# - Gemfile
# - Gemfile.lock
# - node_modules
# - vendor/bundle/
# - vendor/cache/
# - vendor/gems/
# - vendor/ruby/
================================================
FILE: AppCast/_includes/appcast.inc
================================================
{{ site.github.project_title }}
Most recent changes with links to updates.
en
{% for release in site.github.releases %}
{% unless release.draft %}
{% unless release.prerelease and page.release_only %}
-
{{ release.name }}
{{ release.published_at | date_to_rfc822 }}
{% for asset in release.assets limit:1 %}
{% assign signature = release.body | sparkle_signature %}
{% assign build_nums = release.tag_name | replace_first:'v','' | replace_first:'b',',' | split:',' %}
{% if build_nums.size == 2 %}
{% assign version_number = build_nums[0] %}
{% assign build_number = build_nums[1] %}
{% else %}
{% assign version = release.tag_name | remove_first:'v' %}
{% endif %}
{% endfor %}
{% endunless %}
{% endunless %}
{% endfor %}
================================================
FILE: AppCast/_plugins/signature_filter.rb
================================================
module Jekyll
module SignatureFilter
def sparkle_signature(release_body)
regex = //m
signature = release_body.match(regex).named_captures["signature"]
raise "Didn't find a signature in the release body." if signature.empty?
signature
end
end
end
Liquid::Template.register_filter(Jekyll::SignatureFilter)
================================================
FILE: AppCast/appcast.xml
================================================
---
release_only: true
---
{%include appcast.inc %}
================================================
FILE: AppCast/appcast_pre.xml
================================================
---
release_only: false
---
{%include appcast.inc %}
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Xcodes
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code
- Submitting a fix
- Proposing new features
- Becoming a maintainer
## We Develop with GitHub
We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
## All Code Changes Happen Through Pull Requests
Pull requests are the best way to propose changes to the codebase We actively welcome your pull requests:
1. Fork the repo and create your branch from `main`.
2. If you've added code that should be tested, add tests.
3. If you've added new functionality, add documentation
4. Ensure the test suite passes.
5. Make sure your code lints.
6. Issue that pull request!
## Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using GitHub [issues](https://github.com/robotsandpencils/xcodesapp/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy!
## Write bug reports with detail, background, and sample code
**Great Bug Reports** tend to have:
- A quick summary and/or background
- Steps to reproduce
- Be specific!
- What you expected would happen
- What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
People *love* thorough bug reports.
## License
By contributing, you agree that your contributions will be licensed under its MIT License.
================================================
FILE: DECISIONS.md
================================================
# Decisions
This file exists to provide a historical record of the motivation for important technical decisions in the project. It's inspired by Architectural Decision Records, but the implementation is intentionally simpler than usual. When a new decision is made, append it to the end of the file with a header. Decisions can be changed later. This is a reflection of real life, not a contract that has to be followed.
## Why Make Xcodes.app?
[xcodes](https://github.com/RobotsAndPencils/xcodes) has been well-received within and outside of Robots and Pencils as an easy way to manage Xcode versions. A command line tool can have a familiar interface for developers, and is also easier to automate than most GUI apps.
Not everyone wants to use a command line tool though, and there's an opportunity to create an even better developer experience with an app. This is also an opportunity for contributors to get more familiar with SwiftUI and Combine on macOS.
## Code Organization
To begin, we will intentionally not attempt to share code between xcodes and Xcodes.app. In the future, once we have a better idea of the two tools' functionality, we can revisit this decision. An example of code that could be shared are the two AppleAPI libraries which will likely be very similar.
While the intent of xcodes' XcodesKit library was to potentially reuse it in a GUI context, it still makes a lot of assumptions about how the UI works that would prevent that happening immediately. As we reuse that code (by copying and pasting) and tweak it to work in Xcodes.app, we may end up with something that can work in both contexts.
## Asynchrony
Xcodes.app uses Combine to model asynchronous work. This is different than xcodes, which uses PromiseKit because it began prior to Combine's existence. This means that there is a migration of the existing code that has to happen, but the result is easier to use with a SwiftUI app.
## Dependency Injection
xcodes used Point Free's Environment type, and I'm happy with how that turned out. It looks a lot simpler to implement and grow with a codebase, but still allows setting up test double for tests.
- https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy
- https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable
- https://vimeo.com/291588126
## State Management
While I'm curious and eager to try Point Free's [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture), I'm going to avoid it at first in favour of a simpler AppState ObservableObject. My motivation for this is to try to have something more familiar to a contributor that was also new to SwiftUI, so that the codebase doesn't have too many new or unfamiliar things. If we run into performance or correctness issues in the future I think TCA should be a candidate to reconsider.
## Privilege Escalation
Unlike [xcodes](https://github.com/RobotsAndPencils/xcodes/blob/master/DECISIONS.md#privilege-escalation), there is a better option than running sudo in a Process when we need to escalate privileges in Xcodes.app, namely a privileged helper.
A separate, bundle executable is installed as a privileged helper using SMJobBless and communicates with the main app (the client) over XPC. This helper performs the post-install and xcode-select tasks that would require sudo from the command line. The helper and main app validate each other's bundle ID, version and code signing certificate chain. Validation of the connection is done using the private audit token API. An alternative is to validate the code signature of the client based on the PID from a first "handshake" message. DTS [seems to say](https://developer.apple.com/forums/thread/72881#420409022) that this would also be safe against an attacker PID-wrapping. Because the SMJobBless + XPC examples I found online all use the audit token instead, I decided to go with that. The tradeoff is that this is private API.
Uninstallation is not provided yet. I had this partially implemented (one attempt was based on [DoNotDisturb's approach](https://github.com/objective-see/DoNotDisturb/blob/237b19800fa356f830d1c02715a9a75be08b8924/configure/Helper/HelperInterface.m#L123)) but an issue that I kept hitting was that despite the helper not being installed or running I was able to get a remote object proxy over the connection. Adding a timeout to getVersion might be sufficient as a workaround, as it should return the string immediately.
- [Apple Developer: Creating XPC Services](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html)
- [Objective Development: The Story Behind CVE-2019-13013](https://blog.obdev.at/what-we-have-learned-from-a-vulnerability/)
- [Apple Developer Forums: How to and When to uninstall a privileged helper](https://developer.apple.com/forums/thread/66821)
- [Apple Developer Forums: XPC restricted to processes with the same code signing?](https://developer.apple.com/forums/thread/72881#419817)
- [Wojciech Reguła: Learn XPC exploitation - Part 1: Broken cryptography](https://wojciechregula.blog/post/learn-xpc-exploitation-part-1-broken-cryptography/)
- [Wojciech Reguła: Learn XPC exploitation - Part 2: Say no to the PID!](https://wojciechregula.blog/post/learn-xpc-exploitation-part-2-say-no-to-the-pid/)
- [Wojciech Reguła: Learn XPC exploitation - Part 3: Code injections](https://wojciechregula.blog/post/learn-xpc-exploitation-part-3-code-injections/)
- [Apple Developer: EvenBetterAuthorizationSample](https://developer.apple.com/library/archive/samplecode/EvenBetterAuthorizationSample/Introduction/Intro.html)
- [erikberglund/SwiftPrivilegedHelper](https://github.com/erikberglund/SwiftPrivilegedHelper)
- [aronskaya/smjobbless](https://github.com/aronskaya/smjobbless)
- [securing/SimpleXPCApp](https://github.com/securing/SimpleXPCApp)
## Selecting the active version of Xcode
This isn't a technical decision, but we spent enough time talking about this that it's probably worth sharing. When a user has more than one version of Xcode installed, a specific version of the developer tools can be selected with the `xcode-select` tool. The selected version of tools like xcodebuild or xcrun will be used unless the DEVELOPER_DIR environment variable has been set to a different path. You can read more about this in the `xcode-select` man pages. Notably, the man pages and [some notarization documentation](https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution) use the term "active" to indicate the Xcode version that's been selected. [This](https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-HOW_DO_I_SELECT_THE_DEFAULT_VERSION_OF_XCODE_TO_USE_FOR_MY_COMMAND_LINE_TOOLS_) older tech note uses the term "default". And of course, the `xcode-select` tool has the term "select" in its name. xcodes used the terms "select" and "selected" for this functionality, intending to match the xcode-select tool.
Here are the descriptions of these terms from [Apple's Style Guide](https://books.apple.com/ca/book/apple-style-guide/id1161855204):
> active: Use to refer to the app or window currently being used. Preferred to in front.
> default: OK to use to describe the state of settings before the user changes them. See also preset.
> preset: Use to refer to a group of customized settings an app provides or the user saves for reuse.
> select: Use select, not choose, to refer to the action users perform when they select among multiple objects.
Xcodes.app has this same functionality as xcodes, which still uses `xcode-select` under the hood, but because the main UI is a list of selectable rows, there _may_ be some ambiguity about the meaning of "selected". "Default" has a less clear connection to `xcode-select`'s name, but does accurately describe the behaviour that results. In Xcode 11 Launch Services also uses the selected Xcode version when opening a (GUI) developer tool bundled with Xcode, like Instruments. We could also try to follow Apple's lead by using the term "active" from the `xcode-select` man pages and notarization documentation. According to the style guide "active" already has a clear meaning in a GUI context.
Ultimately, we've decided to align with Apple's usage of "active" and "make active" in this specific context, despite possible confusion with the definition in the style guide.
## Software Updates
We're familiar with using GitHub releases to distribute pre-built, code signed and notarized versions of `xcodes` via direct download and Homebrew. Ideally we could use GitHub releases here too with an update mechanism more suitable for an app bundle. For distribution outside the Mac App Store, the most popular choice for updates is [Sparkle](https://sparkle-project.org). The v2 branch has been in beta for a long time, but since Xcodes.app isn't (currently) sandboxed, we can use the production-ready v1 releases.
Based on [this blog post](https://yiqiu.me/2015/11/19/sparkle-update-on-github/), we can use GitHub Pages to generate the appcast for Sparkle to point at releases in our repo. We've made a few changes, like putting the source for the Jekyll site on the main branch, and including the EdDSA signature in the appcast. Generating the appcast file manually would be more straightforward, but we can always edit the files on the gh_pages branch manually if we need to, and it's one less step for a release manager to perform when they're already creating the release in the repo.
We're deliberately not capturing system profile data with Sparkle right now, because we don't want it and because it would require additional infrastructure.
We also considered https://github.com/mxcl/AppUpdater, but decided against it because it seemed less battle-tested than Sparkle and currently lacks an open source license.
================================================
FILE: HelperXPCShared/HelperXPCShared.swift
================================================
import Foundation
let machServiceName = "com.xcodesorg.xcodesapp.Helper"
let clientBundleID = "com.xcodesorg.xcodesapp"
let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String
@objc(HelperXPCProtocol)
protocol HelperXPCProtocol {
func getVersion(completion: @escaping (String) -> Void)
func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> Void)
func devToolsSecurityEnable(completion: @escaping (Error?) -> Void)
func addStaffToDevelopersGroup(completion: @escaping (Error?) -> Void)
func acceptXcodeLicense(absoluteXcodePath: String, completion: @escaping (Error?) -> Void)
func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void)
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2019-2021 Robots and Pencils
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
Xcodes.app
The easiest way to install and switch between multiple versions of Xcode.
_If you're looking for a command-line version of Xcodes.app, try [`xcodes`](https://github.com/XcodesOrg/xcodes)._



### :tada: Announcement
XcodesApp is now part of the `XcodesOrg` - [read more here](nextstep.md)
## Features
- List all available Xcode versions from [Xcode Releases'](https://xcodereleases.com) data or the Apple Developer website.
- Install any Xcode version, **fully automated** from start to finish. Xcodes uses [`aria2`](https://aria2.github.io), which uses up to 16 connections to download 3-5x faster than URLSession.
- Automatically resumes installs if network errors.
- Apple ID required to download Xcode versions.
- Just click a button to make a version active with `xcode-select`.
- View release notes, OS compatibility, included SDKs and compilers from [Xcode Releases](https://xcodereleases.com).
- Dark/Light Mode supported
- Security Key Authentication supported
- Support installing Platforms/Runtimes
- Support installing Apple Silicon variants
## Platforms/Runtimes
- Xcodes supports downloading the Apple runtimes via the app. Simply click on the Platform, and Xcodes will install automatically for you.
**Note: iOS 18+, tvOS 18+, watchOS 11+, visionOS 2+ requires that Xcode 16.1 Beta 3+ be installed and active.**
## Apple Silicon Variants
As of Xcode 26, Apple provides Apple Silicon as well as Universal variants for Xcode versions as well as each runtime. Simply tap on which variant you want installed. To install the Apple Silicon runtime variant Xcode 26 is required to be active.
## Experiments
- Thanks to the wonderful work of [https://github.com/saagarjha/unxip](https://github.com/saagarjha/unxip), turn on the experiment to increase your unxipping time by up to 70%! More can be found on his repo, but bugs, high memory may occur if used.


## Localization
Xcodes supports localization in several languages.
The following languages are supported because of the following community users!
|||||
|-|-|-|-|
|French 🇫🇷 |[@dompepin](https://github.com/dompepin)|Italian 🇮🇹 |[gualtierofrigerio](https://github.com/gualtierofrigerio)|
|Spanish 🇪🇸🇲 |[@cesartru88](https://github.com/cesartru88)|Korean 🇰🇷 |[@ryan-son](https://github.com/ryan-son)|
|Russian 🇷🇺 |[@alexmazlov](https://github.com/alexmazlov)|Turkish 🇹🇷 |[@egesucu](https://github.com/egesucu)|
|Hindi 🇮🇳 |[@KGurpreet](https://github.com/KGurpreet)|Chinese-Simplified 🇨🇳|[@megabitsenmzq](https://github.com/megabitsenmzq)|
|Finnish 🇫🇮 |[@marcusziade](https://github.com/marcusziade)|Chinese-Traditional 🇹🇼|[@itszero](https://github.com/itszero)|
|Ukranian 🇺🇦 |[@gelosi](https://github.com/gelosi)|Japanese 🇯🇵|[@tatsuz0u](https://github.com/tatsuz0u)|
|German 🇩🇪|[@drct](https://github.com/drct)|Dutch 🇳🇱|[@jfversluis](https://github/com/jfversluis)|
|Brazilian Portuguese 🇧🇷|[@brunomunizaf](https://github.com/brunomunizaf)|Polish 🇵🇱|[@jakex7](https://github.com/jakex7)|
|Catalan|[@ferranabello](https://github.com/ferranabello)|Greek 🇬🇷|[@alladinian](https://github.com/alladinian)
|Thai 🇹🇭|[@neetrath](https://github.com/neetrath)|
Want to add more languages? Simply create a PR with the updated strings file.
## Installation
v1.X - requires macOS 11 or newer
v2.X - requires macOS 13
v3.X - requires macOS 13 - architecture variants and updated icon.
### Install with Homebrew
Developer ID-signed and notarized release builds are available on Homebrew. These don't require Xcode to already be installed in order to use.
```sh
brew install --cask xcodes
```
### Manually install
1. Download the latest version [here](https://github.com/XcodesOrg/XcodesApp/releases/latest) using the **Xcodes.zip** asset. These are Developer ID-signed and notarized release builds and don't require Xcode to already be installed in order to use.
2. Move the unzipped `Xcodes.app` to your `/Applications` directory
## Support
Xcodes.app and CLI is updated, maintained with contributors like yourself. Even open source libraries and tools come with expenses. If you would like to support Xcodes or donate to the development and maintenance of the tool, it would be greatly appreciated. There is absolutely no obligation!
## Development
You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app.
`Unxip` and `aria2` must be compiled as a universal binary
```
# compile for Intel
swiftc -parse-as-library -O -target x86_64-apple-macos11 unxip.swift
# compile for M1
swiftc -parse-as-library -O -target arm64-apple-macos11 unxip.swift
# combine for universal binary
lipo -create -output unxip unxip_intel unxip_m1
# check it
lipo -archs unxip
```
[`xcode-install`](https://github.com/xcpretty/xcode-install) and [fastlane/spaceship](https://github.com/fastlane/fastlane/tree/master/spaceship) both deserve credit for figuring out the hard parts of what makes this possible.
Releasing a new version
Follow the steps below to build and release a new version of Xcodes.app. For any of the git steps, you can use your preferred tool, but please sign the tag.
```sh
# Update the version number in Xcode and commit the change, if necessary
# Question: Did anything in XPCHelper change?
# - com.xcodesorg.xcodesapp.Helper folder and HelperXPCShared
# - if so, bump the version number in com.xcodesorg.xcodesapp.Helper target.
# Note: you do not have to bump the version number if nothing has changed.
# Note2: If you do bump the version, the end user, must re-install the XPCHelper and give permission again.
# Increment the build number
scripts/increment_build_number.sh
# Commit the change
git add Xcodes/Resources/Info.plist
git commit -asm "Increment build number"
# Tag the latest commit
# Replace $VERSION and $BUILD below with the latest real values
git tag -asm "v$VERSIONb$BUILD" "v$VERSIONb$BUILD"
# Push to origin
git push --follow-tags
# Build the app
# Make sure you have the Xcode Selected you want to build with
scripts/package_release.sh
# Notarize the app
# Do this from the Product directory so the app is zipped without being nested inside Product
# Create a app specific password on appleid.apple.com if you haven't already
# xcrun notarytool store-credentials "AC_PASSWORD" \
# --apple-id "test@example.com" \
# --team-id "teamid" \
# --password "app specific password"
pushd Product
../scripts/notarize.sh Xcodes.zip
# Sign the .zip for Sparkle, note the signature in the output for later
# If you're warned about the signing key not being found, see the Xcodes 1Password vault for the key and installation instructions.
../scripts/sign_update Xcodes.zip
popd
# Go to https://github.com/XcodesOrg/XcodesApp/releases
# If there are uncategorized PRs, add the appropriate label and run the Release Drafter action manually
# Edit the latest draft release
# Set its tag to the tag you just pushed
# Set its title to a string with the format "$VERSION ($BUILD)"
# Polish the draft release notes, if necessary
# Add the signature to the bottom of the release notes in a comment, like:
# Attach the zip that was created in the Product directory to the release
# Publish the release
shasum -a 256 xcodes.zip
# Update the [Homebrew Cask](https://github.com/XcodesOrg/homebrew-cask/blob/master/Casks/x/xcodes.rb).
```
## Maintainers
[Matt Kiazyk](https://github.com/mattkiazyk) - [Twitter](https://www.twitter.com/mattkiazyk)
[Twitter](https://twitter.com/xcodesApp) | [GitHub](https://github.com/xcodesOrg) | [Mastadon](https://iosdev.space/@XcodesApp) |
================================================
FILE: Scripts/export_options.plist
================================================
method
developer-id
================================================
FILE: Scripts/fix_libfido2_framework.sh
================================================
#!/bin/sh
# Fix libfido2.framework structure for macOS validation
# If this script is not run, the build will fail because xcodebuild is expecting the library in a specific structure
FRAMEWORK_PATH="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/libfido2.framework"
if [ -d "$FRAMEWORK_PATH" ] && [ -f "$FRAMEWORK_PATH/Info.plist" ] && [ ! -d "$FRAMEWORK_PATH/Versions" ]; then
echo "Fixing libfido2.framework bundle structure..."
# Create proper bundle structure
mkdir -p "$FRAMEWORK_PATH/Versions/A/Resources"
# Move files to proper locations
mv "$FRAMEWORK_PATH/Info.plist" "$FRAMEWORK_PATH/Versions/A/Resources/"
mv "$FRAMEWORK_PATH/libfido2" "$FRAMEWORK_PATH/Versions/A/"
if [ -f "$FRAMEWORK_PATH/LICENSE" ]; then
mv "$FRAMEWORK_PATH/LICENSE" "$FRAMEWORK_PATH/Versions/A/"
fi
# Create symbolic links
ln -sf A "$FRAMEWORK_PATH/Versions/Current"
ln -sf Versions/Current/libfido2 "$FRAMEWORK_PATH/libfido2"
ln -sf Versions/Current/Resources "$FRAMEWORK_PATH/Resources"
echo "libfido2.framework structure fixed"
fi
================================================
FILE: Scripts/increment_build_number.sh
================================================
#!/bin/sh
#
# Increment build number
#
# This will get the latest build number from git tags, add 1, then set it in the Info.plist.
# Assumes that build numbers are monotonically increasing positive integers, across version numbers.
# Tags must be named v$version_numberb$build_number, e.g. v1.2.3b456
infoplist_file="$(pwd)/Xcodes/Resources/Info.plist"
# Get latest tag hash matching pattern
hash=$(git rev-list --tags="v*" --max-count=1)
# Get latest tag at hash that matches the same pattern as a prefix in order to support commits with multiple tags
last_tag=$(git describe --tags --match "v*" "$hash")
# Get build number from last component of tag name
last_build_number=$(echo "$last_tag" | grep -o "b.*" | cut -c 2-)
build_number=$(($last_build_number + 1))
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build_number" "${infoplist_file}"
================================================
FILE: Scripts/notarize.sh
================================================
#!/bin/sh
#
# Notarize
#
# Uploads to Apple's notarization service, polls until it completes, staples the ticket to the built app, then creates a new zip.
#
# Requires four arguments:
# - Apple ID username
# - Apple ID app-specific password (store this in your Keychain and use the @keychain:$NAME syntax to prevent your password from being added to your shell history)
# - App Store Connect provider name
# - Path to .app to upload
#
# Assumes that there's a .app beside the .zip with the same name so it can be stapled and re-zipped.
#
# E.g. notarize.sh "test@example.com" "@keychain:altool" MyOrg Xcodes.zip
#
# https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow
# Adapted from https://github.com/keybase/client/blob/46f5df0aa64ff19198ba7b044bbb7cd907c0be9f/packaging/desktop/package_darwin.sh
file="$1"
team_id="$2"
echo "Uploading to notarization service"
result=$(xcrun notarytool submit "$file" \
--keychain-profile "AC_PASSWORD" \
--team-id "$team_id" \
--wait)
# echo "done1"
echo $result
# My grep/awk is bad and I can't figure out how to get the UUID out properly
# uuid=$("$result" | \
# grep 'id:' | tail -n1 | \
# cut -d":" -f2-)
echo "Successfully uploaded to notarization service, polling for result: $uuid"
# we should check here using the info (or notarytool log) to check the results and log
#
# fullstatus=$(xcrun notarytool info "$uuid" \
# --keychain-profile "AC_PASSWORD" 2>&1)
# status=$(echo "$fullstatus" | grep 'status\:' | awk '{ print $2 }')
# if [ "$status" = "Accepted" ]; then
# echo "Notarization success"
# exit 0
# else
# echo "Notarization failed, full status below"
# echo "$fullstatus"
# exit 1
# fi
# Remove .zip
rm $file
# Staple ticket to .app
app_path="$(basename -s ".zip" "$file").app"
xcrun stapler staple "$app_path"
# Zip the stapled app for distribution
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$file"
================================================
FILE: Scripts/package_release.sh
================================================
#!/bin/bash
#
# Package release
#
# This will build and archive the app and then compress it in a .zip file at Product/Xcodes.zip
# You must already have all required code signing assets installed on your computer
PROJECT_NAME=Xcodes
PROJECT_DIR=$(pwd)/$PROJECT_NAME/Resources
SCRIPTS_DIR=$(pwd)/Scripts
INFOPLIST_FILE="Info.plist"
# If needed ensure that the unxip binary is signed with a hardened runtime so we can notarize
# codesign --force --options runtime --sign "Developer ID Application: Robots and Pencils Inc." $PROJECT_DIR/unxip
# Ensure a clean build
rm -rf Archive/*
rm -rf Product/*
xcodebuild clean -project $PROJECT_NAME.xcodeproj -configuration Release -alltargets
# Archive the app and export for release distribution
xcodebuild archive -project $PROJECT_NAME.xcodeproj -scheme $PROJECT_NAME -archivePath Archive/$PROJECT_NAME.xcarchive
xcodebuild -archivePath Archive/$PROJECT_NAME.xcarchive -exportArchive -exportPath Product/$PROJECT_NAME -exportOptionsPlist "${SCRIPTS_DIR}/export_options.plist"
cp -a "Product/$PROJECT_NAME/$PROJECT_NAME.app" "Product/$PROJECT_NAME.app"
# Create a ZIP archive suitable for altool.
/usr/bin/ditto -c -k --keepParent "Product/$PROJECT_NAME.app" "Product/$PROJECT_NAME.zip"
================================================
FILE: Scripts/uninstall_privileged_helper.sh
================================================
#!/bin/bash
PRIVILEGED_HELPER_LABEL=com.xcodesorg.xcodesapp.Helper
sudo rm /Library/PrivilegedHelperTools/$PRIVILEGED_HELPER_LABEL
sudo rm /Library/LaunchDaemons/$PRIVILEGED_HELPER_LABEL.plist
sudo launchctl bootout system/$PRIVILEGED_HELPER_LABEL #'Boot-out failed: 36: Operation now in progress' is OK output
echo "Querying launchd..."
LAUNCHD_OUTPUT=$(sudo launchctl list | grep $PRIVILEGED_HELPER_LABEL)
if [ -z "$LAUNCHD_OUTPUT" ]
then
echo "Finished successfully."
else
echo "WARNING: $PRIVILEGED_HELPER_LABEL is not removed"
fi
================================================
FILE: Xcodes/AcknowledgementsGenerator/.gitignore
================================================
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
================================================
FILE: Xcodes/AcknowledgementsGenerator/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
================================================
================================================
FILE: Xcodes/AcknowledgementsGenerator/Package.swift
================================================
// swift-tools-version:5.4
import PackageDescription
let package = Package(
name: "AcknowledgementsGenerator",
platforms: [.macOS(.v11)],
products: [
.executable(
name: "AcknowledgementsGenerator",
targets: ["AcknowledgementsGenerator"]
),
],
dependencies: [
],
targets: [
.executableTarget(
name: "AcknowledgementsGenerator",
dependencies: []
),
]
)
================================================
FILE: Xcodes/AcknowledgementsGenerator/README.md
================================================
# AcknowledgementsGenerator
Scans an Xcode project's checked-out SPM packages for license files, then combines them into a single RTF file.
Based on https://github.com/MacPaw/spm-licenses.
================================================
FILE: Xcodes/AcknowledgementsGenerator/Sources/AcknowledgementsGenerator/Extensions/CollectionExtensions.swift
================================================
//
// CollectionExtensions.swift
// spm-licenses
//
// Created by Sergii Kryvoblotskyi on 11/11/19.
// Copyright © 2019 MacPaw. All rights reserved.
//
import Foundation
public extension Collection {
/// Returns the element at the specified index iff it is within bounds, otherwise nil.
subscript (safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}
================================================
FILE: Xcodes/AcknowledgementsGenerator/Sources/AcknowledgementsGenerator/Extensions/StringExtensions.swift
================================================
//
// StringExtensions.swift
// spm-licenses
//
// Created by Sergii Kryvoblotskyi on 11/11/19.
// Copyright © 2019 MacPaw. All rights reserved.
//
import Foundation
public extension String {
var nsString: NSString {
(self as NSString)
}
var pathExtension: String {
return nsString.pathExtension
}
var lastPathComponent: String {
return nsString.lastPathComponent
}
var deletingLastPathComponent: String {
return nsString.deletingLastPathComponent
}
var stringByDeletingPathExtension: String {
return nsString.deletingPathExtension
}
var expandingTildeInPath: String {
return nsString.expandingTildeInPath
}
func appendingPathComponent(_ component: String) -> String {
return nsString.appendingPathComponent(component)
}
}
================================================
FILE: Xcodes/AcknowledgementsGenerator/Sources/AcknowledgementsGenerator/Tools/Xcode.swift
================================================
//
// Xcode.swift
// spm-licenses
//
// Created by Sergii Kryvoblotskyi on 11/11/19.
// Copyright © 2019 MacPaw. All rights reserved.
//
import Foundation
struct Xcode {
static var derivedDataURL: URL {
if let overridenPath = readOverridenDerivedDataPath() {
return URL(fileURLWithPath: overridenPath.expandingTildeInPath)
}
let defaultPath = "~/Library/Developer/Xcode/DerivedData/".expandingTildeInPath
return URL(fileURLWithPath: defaultPath)
}
}
//defaults read com.apple.dt.Xcode.plist IDECustomDerivedDataLocation
//If the line returns
//
//The domain/default pair of (com.apple.dt.Xcode.plist, IDECustomDerivedDataLocation) does not exist
//it's the default path ~/Library/Developer/Xcode/DerivedData/ otherwise the custom path.
private extension Xcode {
static func readOverridenDerivedDataPath() -> String? {
let task = Process()
let pipe = Pipe()
task.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
task.arguments = ["read","com.apple.dt.Xcode.plist", "IDECustomDerivedDataLocation"]
task.standardOutput = pipe
try? task.run()
let handle = pipe.fileHandleForReading
let data = handle.readDataToEndOfFile()
let path = String(data: data, encoding: String.Encoding.utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return (path?.isEmpty ?? true) ? nil : path
}
}
extension Xcode {
struct Project {
let url: URL
let info: [String: Any]
var workspacePath: String? {
return info["WorkspacePath"] as? String
}
}
}
extension Xcode.Project {
struct License {
let url: URL
let name: String
}
}
extension Xcode.Project.License {
func makeRepresentation() throws -> [String: String] {
let data = try Data(contentsOf: url)
let text = String(data: data, encoding: .utf8) ?? ""
return [
"Title": name,
"Type": "PSGroupSpecifier",
"FooterText": text
]
}
}
================================================
FILE: Xcodes/AcknowledgementsGenerator/Sources/AcknowledgementsGenerator/main.swift
================================================
//
// main.swift
// spm-licenses
//
// Created by Sergii Kryvoblotskyi on 11/11/19.
// Copyright © 2019 MacPaw. All rights reserved.
//
import Foundation
import AppKit
let arguments = CommandLine.arguments
guard let projectIndex = arguments.firstIndex(of: "-p"), let projectPath = arguments[safe: projectIndex + 1] else {
print("Project path is missing. Specify -p.")
exit(EXIT_FAILURE)
}
guard let outputIndex = arguments.firstIndex(of: "-o"), let outputPath = arguments[safe: outputIndex + 1] else {
print("Output path is missing. Specify -o.")
exit(EXIT_FAILURE)
}
let fileManager = FileManager.default
let projectURL = URL(fileURLWithPath: projectPath.expandingTildeInPath)
if !fileManager.fileExists(atPath: projectURL.path) {
print("xcodeproj not found at \(projectURL)")
exit(EXIT_FAILURE)
}
let packageURL = projectURL.appendingPathComponent("project.xcworkspace/xcshareddata/swiftpm/Package.resolved")
if !fileManager.fileExists(atPath: packageURL.path) {
print("Package.resolved not found at \(packageURL)")
exit(EXIT_FAILURE)
}
let packageData = try Data(contentsOf: packageURL)
let packageInfo = try JSONSerialization.jsonObject(with: packageData, options: .allowFragments)
guard let package = packageInfo as? [String: Any] else {
print("Invalid package format")
exit(EXIT_FAILURE)
}
guard let object = package["object"] as? [String: Any] else {
print("Invalid obejct format")
exit(EXIT_FAILURE)
}
guard let pins = object["pins"] as? [[String: Any]] else {
print("Invalid pins format")
exit(EXIT_FAILURE)
}
let projectsURL = Xcode.derivedDataURL
func projectsInfo(at url: URL) throws -> [Xcode.Project] {
try fileManager
.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
.map { $0.appendingPathComponent("info.plist") }
.compactMap {
guard let info = NSDictionary(contentsOf: $0) as? [String: Any] else { return nil }
return Xcode.Project(url: $0, info: info)
}
}
let projects = try projectsInfo(at: projectsURL)
// Despite the naming, if the project only has an xcodeproj and not an xcworkspace, the WorkspacePath value will be the path to the xcodeproj
guard let currentProject = projects.first(where: ({ $0.workspacePath == projectPath.expandingTildeInPath })) else {
print("Derived data missing for project")
exit(EXIT_FAILURE)
}
let checkouts = currentProject.url.deletingLastPathComponent().appendingPathComponent("SourcePackages/checkouts")
let checkedDependencies = try fileManager.contentsOfDirectory(at: checkouts, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
let spmLicences: [Xcode.Project.License] = checkedDependencies.compactMap {
let supportedFilenames = ["LICENSE", "LICENSE.txt", "LICENSE.md"]
for filename in supportedFilenames {
let licenseURL = $0.appendingPathComponent(filename)
if fileManager.fileExists(atPath: licenseURL.path) {
return Xcode.Project.License(url: licenseURL, name: $0.lastPathComponent)
}
}
return nil
}
var manualLicenses: [Xcode.Project.License] = []
let enumerator = fileManager.enumerator(at: projectURL.deletingLastPathComponent(), includingPropertiesForKeys: [URLResourceKey.nameKey], options: .skipsHiddenFiles)!
for case let url as URL in enumerator where url.lastPathComponent.hasSuffix(".LICENSE") {
manualLicenses.append(
Xcode.Project.License(
url: url,
name: url.lastPathComponent.replacingOccurrences(of: ".LICENSE", with: "")
)
)
}
let licences = spmLicences + manualLicenses
let acknowledgementsAttributedString = NSMutableAttributedString()
for licence in licences {
acknowledgementsAttributedString.append(NSAttributedString(string: licence.name + "\n\n", attributes: [.font: NSFont.preferredFont(forTextStyle: .title2)]))
let licenseContents = try String(contentsOf: licence.url)
acknowledgementsAttributedString.append(NSAttributedString(string: licenseContents + "\n\n", attributes: [.font: NSFont.preferredFont(forTextStyle: .body)]))
}
guard let data = acknowledgementsAttributedString.rtf(from: NSRange(location: 0, length: acknowledgementsAttributedString.length), documentAttributes: [:]) else {
print("Failed to create RTF data")
exit(EXIT_FAILURE)
}
try data.write(to: URL(fileURLWithPath: outputPath.expandingTildeInPath))
print("Licenses have been saved to \(outputPath)")
================================================
FILE: Xcodes/AcknowledgementsGenerator/spm-licenses.LICENSE
================================================
MIT License
Copyright (c) 2019 MacPaw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Xcodes/AppleAPI/.gitignore
================================================
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
================================================
FILE: Xcodes/AppleAPI/Package.swift
================================================
// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "AppleAPI",
platforms: [.macOS(.v11)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "AppleAPI",
targets: ["AppleAPI"]),
],
dependencies: [
.package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "AppleAPI",
dependencies: [.product(name: "SRP", package: "swift-srp")]),
.testTarget(
name: "AppleAPITests",
dependencies: ["AppleAPI"]),
]
)
================================================
FILE: Xcodes/AppleAPI/README.md
================================================
# AppleAPI
A description of this package.
================================================
FILE: Xcodes/AppleAPI/Sources/AppleAPI/Client.swift
================================================
import Foundation
import Combine
import SRP
import Crypto
import CommonCrypto
public class Client {
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
public init() {}
// MARK: - Login
public func srpLogin(accountName: String, password: String) -> AnyPublisher {
var serviceKey: String!
let client = SRPClient(configuration: SRPConfiguration(.N2048))
let clientKeys = client.generateKeys()
let a = clientKeys.public
return Current.network.dataTask(with: URLRequest.itcServiceKey)
.map(\.data)
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
.flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in
serviceKey = serviceKeyResponse.authServiceKey
// Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360
// On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow
// Without this addition, Apple ID's would get set to locked
return self.loadHashcash(accountName: accountName, serviceKey: serviceKey)
.map { return (serviceKey, $0)}
.eraseToAnyPublisher()
}
.flatMap { (serviceKey, hashcash) -> AnyPublisher<(String, String, ServerSRPInitResponse), Swift.Error> in
return Current.network.dataTask(with: URLRequest.SRPInit(serviceKey: serviceKey, a: Data(a.bytes).base64EncodedString(), accountName: accountName))
.map(\.data)
.decode(type: ServerSRPInitResponse.self, decoder: JSONDecoder())
.map { return (serviceKey, hashcash, $0) }
.eraseToAnyPublisher()
}
.flatMap { (serviceKey, hashcash, srpInit) -> AnyPublisher in
guard let decodedB = Data(base64Encoded: srpInit.b) else {
return Fail(error: AuthenticationError.srpInvalidPublicKey)
.eraseToAnyPublisher()
}
guard let decodedSalt = Data(base64Encoded: srpInit.salt) else {
return Fail(error: AuthenticationError.srpInvalidPublicKey)
.eraseToAnyPublisher()
}
let iterations = srpInit.iteration
do {
guard let encryptedPassword = self.pbkdf2(password: password, saltData: decodedSalt, keyByteCount: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), rounds: iterations, protocol: srpInit.protocol) else {
return Fail(error: AuthenticationError.srpInvalidPublicKey)
.eraseToAnyPublisher()
}
let sharedSecret = try client.calculateSharedSecret(password: encryptedPassword, salt: [UInt8](decodedSalt), clientKeys: clientKeys, serverPublicKey: .init([UInt8](decodedB)))
let m1 = client.calculateClientProof(username: accountName, salt: [UInt8](decodedSalt), clientPublicKey: a, serverPublicKey: .init([UInt8](decodedB)), sharedSecret: .init(sharedSecret.bytes))
let m2 = client.calculateServerProof(clientPublicKey: a, clientProof: m1, sharedSecret: .init([UInt8](sharedSecret.bytes)))
return Current.network.dataTask(with: URLRequest.SRPComplete(serviceKey: serviceKey, hashcash: hashcash, accountName: accountName, c: srpInit.c, m1: Data(m1).base64EncodedString(), m2: Data(m2).base64EncodedString()))
.mapError { $0 as Swift.Error }
.eraseToAnyPublisher()
} catch {
return Fail(error: AuthenticationError.srpInvalidPublicKey)
.eraseToAnyPublisher()
}
}
.flatMap { result -> AnyPublisher in
let (data, response) = result
return Just(data)
.decode(type: SignInResponse.self, decoder: JSONDecoder())
.flatMap { responseBody -> AnyPublisher in
let httpResponse = response as! HTTPURLResponse
switch httpResponse.statusCode {
case 200:
return Current.network.dataTask(with: URLRequest.olympusSession)
.map { _ in AuthenticationState.authenticated }
.mapError { $0 as Swift.Error }
.eraseToAnyPublisher()
case 401:
return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName))
.eraseToAnyPublisher()
case 403:
let errorMessage = responseBody.serviceErrors?.first?.description.replacingOccurrences(of: "-20209: ", with: "") ?? ""
return Fail(error: AuthenticationError.accountLocked(errorMessage))
.eraseToAnyPublisher()
case 409:
return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired)
.eraseToAnyPublisher()
default:
return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode,
message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")))
.eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()
}
.mapError { $0 as Swift.Error }
.eraseToAnyPublisher()
}
func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher {
Result {
try URLRequest.federate(account: accountName, serviceKey: serviceKey)
}
.publisher
.flatMap { request in
Current.network.dataTask(with: request)
.mapError { $0 as Error }
.tryMap { (data, response) throws -> (String) in
guard let urlResponse = response as? HTTPURLResponse else {
throw AuthenticationError.invalidSession
}
switch urlResponse.statusCode {
case 200..<300:
let httpResponse = response as! HTTPURLResponse
guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else {
throw AuthenticationError.invalidHashcash
}
guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else {
throw AuthenticationError.invalidHashcash
}
guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else {
throw AuthenticationError.invalidHashcash
}
return (hashcash)
case 400, 401:
throw AuthenticationError.invalidHashcash
case let code:
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
}
}
}
.eraseToAnyPublisher()
}
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher {
let httpResponse = response as! HTTPURLResponse
let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String)
let scnt = (httpResponse.allHeaderFields["scnt"] as! String)
return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.map(\.data)
.decode(type: AuthOptionsResponse.self, decoder: JSONDecoder())
.flatMap { authOptions -> AnyPublisher in
switch authOptions.kind {
case .twoStep:
return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
.eraseToAnyPublisher()
case .twoFactor, .securityKey:
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
.eraseToAnyPublisher()
case .unknown:
let possibleResponseString = String(data: data, encoding: .utf8)
return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString))
.eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()
}
func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher {
let option: TwoFactorOption
// SMS was sent automatically
if authOptions.smsAutomaticallySent {
option = .smsSent(authOptions.trustedPhoneNumbers!.first!)
// SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS {
option = .smsPendingChoice
// Code is shown on trusted devices
} else if authOptions.fsaChallenge != nil {
option = .securityKey
// User needs to use a physical security key to respond to the challenge
} else {
option = .codeSent
}
let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
return Just(AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
// MARK: - Continue 2FA
public func requestSMSSecurityCode(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) -> AnyPublisher {
Result {
try URLRequest.requestSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, trustedPhoneID: trustedPhoneNumber.id)
}
.publisher
.flatMap { request in
Current.network.dataTask(with: request)
.mapError { $0 as Error }
}
.map { _ in AuthenticationState.waitingForSecondFactor(.smsSent(trustedPhoneNumber), authOptions, sessionData) }
.eraseToAnyPublisher()
}
public func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) -> AnyPublisher {
Result {
try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: code)
}
.publisher
.flatMap { request in
Current.network.dataTask(with: request)
.mapError { $0 as Error }
.tryMap { (data, response) throws -> (Data, URLResponse) in
guard let urlResponse = response as? HTTPURLResponse else { return (data, response) }
switch urlResponse.statusCode {
case 200..<300:
return (data, urlResponse)
case 400, 401:
throw AuthenticationError.incorrectSecurityCode
case 412:
throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired
case let code:
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
}
}
.flatMap { (data, response) -> AnyPublisher in
self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
}
}
.eraseToAnyPublisher()
}
public func submitChallenge(response: Data, sessionData: AppleSessionData) -> AnyPublisher {
Result {
URLRequest.respondToChallenge(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, response: response)
}
.publisher
.flatMap { request in
Current.network.dataTask(with: request)
.mapError { $0 as Error }
.tryMap { (data, response) throws -> (Data, URLResponse) in
guard let urlResponse = response as? HTTPURLResponse else { return (data, response) }
switch urlResponse.statusCode {
case 200..<300:
return (data, urlResponse)
case 400, 401:
throw AuthenticationError.incorrectSecurityCode
case 412:
throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired
case let code:
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
}
}
.flatMap { (data, response) -> AnyPublisher in
self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
}
}.eraseToAnyPublisher()
}
// MARK: - Session
/// Use the olympus session endpoint to see if the existing session is still valid
public func validateSession() -> AnyPublisher {
return Current.network.dataTask(with: URLRequest.olympusSession)
.tryMap { result -> Data in
let httpResponse = result.response as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
return result.data
}
.decode(type: AppleSession.self, decoder: JSONDecoder())
.tryMap { session in
// A user that is a non-paid Apple Developer will have a provider == nil
// Those users can still download Xcode.
// Non Apple Developers will get caught in the download as invalid
// if session.provider == nil {
// throw AuthenticationError.notDeveloperAppleId
// }
}
.eraseToAnyPublisher()
}
func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher {
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.flatMap { (data, response) in
Current.network.dataTask(with: URLRequest.olympusSession)
.map { _ in AuthenticationState.authenticated }
}
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
func sha256(data : Data) -> Data {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
}
return Data(hash)
}
private func pbkdf2(password: String, saltData: Data, keyByteCount: Int, prf: CCPseudoRandomAlgorithm, rounds: Int, protocol srpProtocol: SRPProtocol) -> Data? {
guard let passwordData = password.data(using: .utf8) else { return nil }
let hashedPasswordDataRaw = sha256(data: passwordData)
let hashedPasswordData = switch srpProtocol {
case .s2k: hashedPasswordDataRaw
// the legacy s2k_fo protocol requires hex-encoding the digest before performing PBKDF2.
case .s2k_fo: Data(hashedPasswordDataRaw.hexEncodedString().lowercased().utf8)
}
var derivedKeyData = Data(repeating: 0, count: keyByteCount)
let derivedCount = derivedKeyData.count
let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in
let keyBuffer: UnsafeMutablePointer =
derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
return saltData.withUnsafeBytes { saltBytes -> Int32 in
let saltBuffer: UnsafePointer = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
return hashedPasswordData.withUnsafeBytes { hashedPasswordBytes -> Int32 in
let passwordBuffer: UnsafePointer = hashedPasswordBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
return CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBuffer,
hashedPasswordData.count,
saltBuffer,
saltData.count,
prf,
UInt32(rounds),
keyBuffer,
derivedCount)
}
}
}
return derivationStatus == kCCSuccess ? derivedKeyData : nil
}
}
// MARK: - Types
public enum AuthenticationState: Equatable {
case unauthenticated
case waitingForSecondFactor(TwoFactorOption, AuthOptionsResponse, AppleSessionData)
case authenticated
case notAppleDeveloper
}
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
case invalidSession
case invalidHashcash
case invalidUsernameOrPassword(username: String)
case incorrectSecurityCode
case unexpectedSignInResponse(statusCode: Int, message: String?)
case appleIDAndPrivacyAcknowledgementRequired
case accountUsesTwoStepAuthentication
case accountUsesUnknownAuthenticationKind(String?)
case accountLocked(String)
case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse)
case notDeveloperAppleId
case notAuthorized
case invalidResult(resultString: String?)
case srpInvalidPublicKey
public var errorDescription: String? {
switch self {
case .invalidSession:
return "Your authentication session is invalid. Try signing in again."
case .invalidHashcash:
return "Could not create a hashcash for the session."
case .invalidUsernameOrPassword:
return "Invalid username and password combination."
case .incorrectSecurityCode:
return "The code that was entered is incorrect."
case let .unexpectedSignInResponse(statusCode, message):
return """
Received an unexpected sign in response. If you continue to have problems, please submit a bug report in the Help menu and include the following information:
Status code: \(statusCode)
\(message != nil ? ("Message: " + message!) : "")
"""
case .appleIDAndPrivacyAcknowledgementRequired:
return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement."
case .accountUsesTwoStepAuthentication:
return "Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or explain why this isn't an option for you by making a new feature request in the Help menu."
case .accountUsesUnknownAuthenticationKind:
return "Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response. If you continue to have problems, please submit a bug report in the Help menu."
case let .accountLocked(message):
return message
case let .badStatusCode(statusCode, _, _):
return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu."
case .notDeveloperAppleId:
return "You are not registered as an Apple Developer. Please visit Apple Developer Registration. https://developer.apple.com/register/"
case .notAuthorized:
return "You are not authorized. Please Sign in with your Apple ID first."
case let .invalidResult(resultString):
return resultString ?? "If you continue to have problems, please submit a bug report in the Help menu."
case .srpInvalidPublicKey:
return "Invalid Key"
}
}
}
public struct AppleSessionData: Equatable, Identifiable {
public let serviceKey: String
public let sessionID: String
public let scnt: String
public var id: String { sessionID }
public init(serviceKey: String, sessionID: String, scnt: String) {
self.serviceKey = serviceKey
self.sessionID = sessionID
self.scnt = scnt
}
}
struct ServiceKeyResponse: Decodable {
let authServiceKey: String
}
struct SignInResponse: Decodable {
let authType: String?
let serviceErrors: [ServiceError]?
struct ServiceError: Decodable, CustomStringConvertible {
let code: String
let message: String
var description: String {
return "\(code): \(message)"
}
}
}
public enum TwoFactorOption: Equatable {
case smsSent(AuthOptionsResponse.TrustedPhoneNumber)
case codeSent
case smsPendingChoice
case securityKey
}
public struct FSAChallenge: Equatable, Decodable {
public let challenge: String
public let keyHandles: [String]
public let allowedCredentials: String
}
public struct AuthOptionsResponse: Equatable, Decodable {
public let trustedPhoneNumbers: [TrustedPhoneNumber]?
public let trustedDevices: [TrustedDevice]?
public let securityCode: SecurityCodeInfo?
public let noTrustedDevices: Bool?
public let serviceErrors: [ServiceError]?
public let fsaChallenge: FSAChallenge?
public init(
trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?,
trustedDevices: [AuthOptionsResponse.TrustedDevice]?,
securityCode: AuthOptionsResponse.SecurityCodeInfo,
noTrustedDevices: Bool? = nil,
serviceErrors: [ServiceError]? = nil,
fsaChallenge: FSAChallenge? = nil
) {
self.trustedPhoneNumbers = trustedPhoneNumbers
self.trustedDevices = trustedDevices
self.securityCode = securityCode
self.noTrustedDevices = noTrustedDevices
self.serviceErrors = serviceErrors
self.fsaChallenge = fsaChallenge
}
public var kind: Kind {
if trustedDevices != nil {
return .twoStep
} else if trustedPhoneNumbers != nil {
return .twoFactor
} else if fsaChallenge != nil {
return .securityKey
} else {
return .unknown
}
}
// One time with a new testing account I had a response where noTrustedDevices was nil, but the account didn't have any trusted devices.
// This should have been a situation where an SMS security code was sent automatically.
// This resolved itself either after some time passed, or by signing into appleid.apple.com with the account.
// Not sure if it's worth explicitly handling this case or if it'll be really rare.
public var canFallBackToSMS: Bool {
noTrustedDevices == true
}
public var smsAutomaticallySent: Bool {
trustedPhoneNumbers?.count == 1 && canFallBackToSMS
}
public struct TrustedPhoneNumber: Equatable, Decodable, Identifiable {
public let id: Int
public let numberWithDialCode: String
public init(id: Int, numberWithDialCode: String) {
self.id = id
self.numberWithDialCode = numberWithDialCode
}
}
public struct TrustedDevice: Equatable, Decodable {
public let id: String
public let name: String
public let modelName: String
public init(id: String, name: String, modelName: String) {
self.id = id
self.name = name
self.modelName = modelName
}
}
public struct SecurityCodeInfo: Equatable, Decodable {
public let length: Int
public let tooManyCodesSent: Bool
public let tooManyCodesValidated: Bool
public let securityCodeLocked: Bool
public let securityCodeCooldown: Bool
public init(
length: Int,
tooManyCodesSent: Bool = false,
tooManyCodesValidated: Bool = false,
securityCodeLocked: Bool = false,
securityCodeCooldown: Bool = false
) {
self.length = length
self.tooManyCodesSent = tooManyCodesSent
self.tooManyCodesValidated = tooManyCodesValidated
self.securityCodeLocked = securityCodeLocked
self.securityCodeCooldown = securityCodeCooldown
}
}
public enum Kind: Equatable {
case twoStep, twoFactor, securityKey, unknown
}
}
public struct ServiceError: Decodable, Equatable {
let code: String
let message: String
}
public enum SecurityCode {
case device(code: String)
case sms(code: String, phoneNumberId: Int)
var urlPathComponent: String {
switch self {
case .device: return "trusteddevice"
case .sms: return "phone"
}
}
}
/// Object returned from olympus/v1/session
/// Used to check Provider, and show name
/// If Provider is nil, we can assume the Apple User is NOT an Apple Developer and can't download Xcode.
public struct AppleSession: Decodable, Equatable {
public let user: AppleUser
public let provider: AppleProvider?
}
public struct AppleProvider: Decodable, Equatable {
public let providerId: Int
public let name: String
}
public struct AppleUser: Decodable, Equatable {
public let fullName: String
}
public struct ServerSRPInitResponse: Decodable {
let iteration: Int
let salt: String
let b: String
let c: String
let `protocol`: SRPProtocol
}
extension String {
func base64ToU8Array() -> Data {
return Data(base64Encoded: self) ?? Data()
}
}
extension Data {
func hexEncodedString() -> String {
return map { String(format: "%02hhx", $0) }.joined()
}
}
================================================
FILE: Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift
================================================
import Foundation
import Combine
/**
Lightweight dependency injection using global mutable state :P
- SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy
- SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable
- SeeAlso: https://vimeo.com/291588126
*/
public struct Environment {
public var network = Network()
}
public var Current = Environment()
public struct Network {
public var session = URLSession.shared
public var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { Current.network.session.dataTaskPublisher(for: $0) }
public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher {
dataTask(request)
}
}
================================================
FILE: Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift
================================================
//
// Hashcash.swift
//
//
// Created by Matt Kiazyk on 2023-02-23.
//
import Foundation
import CryptoKit
import CommonCrypto
/*
# This App Store Connect hashcash spec was generously donated by...
#
# __ _
# __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___
# / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __|
# | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \
# \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/
# |_| |_| |___/
#
#
*/
public struct Hashcash {
/// A function to returned a minted hash, using a bit and resource string
///
/**
X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373
^ ^ ^ ^ ^
| | | | +-- Counter
| | | +-- Resource
| | +-- Date YYMMDD[hhmm[ss]]
| +-- Bits (number of leading zeros)
+-- Version
We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention.
1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all.
2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation.
Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits
We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits
*/
/// - Parameters:
/// - resource: a string to be used for minting
/// - bits: grabbed from `X-Apple-HC-Bits` header
/// - date: Default uses Date() otherwise used for testing to check.
/// - Returns: A String hash to use in `X-Apple-HC` header on /signin
public func mint(resource: String,
bits: UInt = 10,
date: String? = nil) -> String? {
let ver = "1"
var ts: String
if let date = date {
ts = date
} else {
let formatter = DateFormatter()
formatter.dateFormat = "yyMMddHHmmss"
ts = formatter.string(from: Date())
}
let challenge = "\(ver):\(bits):\(ts):\(resource):"
var counter = 0
while true {
guard let digest = ("\(challenge):\(counter)").sha1 else {
print("ERROR: Can't generate SHA1 digest")
return nil
}
if digest == bits {
return "\(challenge):\(counter)"
}
counter += 1
}
}
}
extension String {
var sha1: Int? {
let data = Data(self.utf8)
var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
}
let bigEndianValue = digest.withUnsafeBufferPointer {
($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 })
}.pointee
let value = UInt32(bigEndian: bigEndianValue)
return value.leadingZeroBitCount
}
}
================================================
FILE: Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift
================================================
import Foundation
public extension URL {
static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")!
static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")!
static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")!
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")!
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")!
static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")!
static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")!
}
public extension URLRequest {
static var itcServiceKey: URLRequest {
return URLRequest(url: .itcServiceKey)
}
static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest {
struct Body: Encodable {
let accountName: String
let password: String
let rememberMe = true
}
var request = URLRequest(url: .signIn)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
return request
}
static func authOptions(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
var request = URLRequest(url: .authOptions)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["accept"] = "application/json"
return request
}
static func requestSecurityCode(serviceKey: String, sessionID: String, scnt: String, trustedPhoneID: Int) throws -> URLRequest {
struct Body: Encodable {
let phoneNumber: PhoneNumber
let mode = "sms"
struct PhoneNumber: Encodable {
let id: Int
}
}
var request = URLRequest(url: .requestSecurityCode)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["accept"] = "application/json"
request.httpMethod = "PUT"
request.httpBody = try JSONEncoder().encode(Body(phoneNumber: .init(id: trustedPhoneID)))
return request
}
static func submitSecurityCode(serviceKey: String, sessionID: String, scnt: String, code: SecurityCode) throws -> URLRequest {
struct DeviceSecurityCodeRequest: Encodable {
let securityCode: SecurityCode
struct SecurityCode: Encodable {
let code: String
}
}
struct SMSSecurityCodeRequest: Encodable {
let securityCode: SecurityCode
let phoneNumber: PhoneNumber
let mode = "sms"
struct SecurityCode: Encodable {
let code: String
}
struct PhoneNumber: Encodable {
let id: Int
}
}
var request = URLRequest(url: .submitSecurityCode(code))
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "POST"
switch code {
case .device(let code):
request.httpBody = try JSONEncoder().encode(DeviceSecurityCodeRequest(securityCode: .init(code: code)))
case .sms(let code, let phoneNumberId):
request.httpBody = try JSONEncoder().encode(SMSSecurityCodeRequest(securityCode: .init(code: code), phoneNumber: .init(id: phoneNumberId)))
}
return request
}
static func respondToChallenge(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest {
var request = URLRequest(url: .keyAuth)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "POST"
request.httpBody = response
return request
}
static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
var request = URLRequest(url: .trust)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["Accept"] = "application/json"
return request
}
static var olympusSession: URLRequest {
return URLRequest(url: .olympusSession)
}
static func federate(account: String, serviceKey: String) throws -> URLRequest {
struct FederateRequest: Encodable {
let accountName: String
let rememberMe: Bool
}
var request = URLRequest(url: .signIn)
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "GET"
// let encoder = JSONEncoder()
// encoder.outputFormatting = .withoutEscapingSlashes
// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true))
return request
}
static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest {
struct ServerSRPInitRequest: Encodable {
public let a: String
public let accountName: String
public let protocols: [SRPProtocol]
}
var request = URLRequest(url: .srpInit)
request.httpMethod = "POST"
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.httpBody = try? JSONEncoder().encode(ServerSRPInitRequest(a: a, accountName: accountName, protocols: [.s2k, .s2k_fo]))
return request
}
static func SRPComplete(serviceKey: String, hashcash: String, accountName: String, c: String, m1: String, m2: String) -> URLRequest {
struct ServerSRPCompleteRequest: Encodable {
let accountName: String
let c: String
let m1: String
let m2: String
let rememberMe: Bool
}
var request = URLRequest(url: .srpComplete)
request.httpMethod = "POST"
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
request.httpBody = try? JSONEncoder().encode(ServerSRPCompleteRequest(accountName: accountName, c: c, m1: m1, m2: m2, rememberMe: false))
return request
}
}
public enum SRPProtocol: String, Codable {
case s2k, s2k_fo
}
================================================
FILE: Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift
================================================
import XCTest
@testable import AppleAPI
final class AppleAPITests: XCTestCase {
func testValidHashCashMint() {
let bits: UInt = 11
let resource = "4d74fb15eb23f465f1f6fcbf534e5877"
let testDate = "20230223170600"
let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate)
XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373")
}
func testValidHashCashMint2() {
let bits: UInt = 10
let resource = "bb63edf88d2f9c39f23eb4d6f0281158"
let testDate = "20230224001754"
let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate)
XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866")
}
static var allTests = [
("testValidHashCashMint", testValidHashCashMint),
("testValidHashCashMint2", testValidHashCashMint2),
]
}
================================================
FILE: Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift
================================================
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(AppleAPITests.allTests),
]
}
#endif
================================================
FILE: Xcodes/AppleAPI/Tests/LinuxMain.swift
================================================
import XCTest
import AppleAPITests
var tests = [XCTestCaseEntry]()
tests += AppleAPITests.allTests()
XCTMain(tests)
================================================
FILE: Xcodes/Backend/AppState+Install.swift
================================================
import Combine
import Foundation
import Path
import AppleAPI
import Version
import LegibleError
import os.log
import DockProgress
import XcodesKit
/// Downloads and installs Xcodes
extension AppState {
// check to see if we should auto install for the user
public func autoInstallIfNeeded() {
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
if autoInstallType == .none { return }
// get newest xcode version
guard let newestXcode = allXcodes.first, newestXcode.installState == .notInstalled else {
Logger.appState.info("User has latest Xcode already installed")
return
}
if autoInstallType == .newestBeta {
Logger.appState.info("Auto installing newest Xcode Beta")
// install it, as user doesn't have it installed and it's either latest beta or latest release
checkMinVersionAndInstall(id: newestXcode.id)
} else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease {
Logger.appState.info("Auto installing newest Xcode")
checkMinVersionAndInstall(id: newestXcode.id)
} else {
Logger.appState.info("No new Xcodes version found to auto install")
}
}
public func install(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher {
install(installationType, downloader: downloader, attemptNumber: 0)
.map { _ in Void() }
.eraseToAnyPublisher()
}
private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher {
Logger.appState.info("Using \(downloader) downloader")
setupDockProgress()
return validateSession()
.flatMap { _ in
self.getXcodeArchive(installationType, downloader: downloader)
}
.flatMap { xcode, url -> AnyPublisher in
self.installArchivedXcode(xcode, at: url)
}
.catch { error -> AnyPublisher in
self.resetDockProgressTracking()
switch error {
case InstallationError.damagedXIP(let damagedXIPURL):
guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() }
switch installationType {
case .version:
// If the XIP was just downloaded, remove it and try to recover.
do {
Logger.appState.error("\(error.legibleLocalizedDescription)")
Logger.appState.info("Removing damaged XIP and re-attempting installation.")
try Current.files.removeItem(at: damagedXIPURL)
return self.install(installationType, downloader: downloader, attemptNumber: attemptNumber + 1)
.eraseToAnyPublisher()
} catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
}
default:
return Fail(error: error)
.eraseToAnyPublisher()
}
}
.handleEvents(receiveOutput: { installedXcode in
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return }
self.allXcodes[index].installState = .installed(installedXcode.path)
}
})
.eraseToAnyPublisher()
}
private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
switch installationType {
case .version(let availableXcode):
if let installedXcode = Current.files.installedXcodes(Path.installDirectory).first(where: { $0.version.isEquivalent(to: availableXcode.version) }) {
return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode))
.eraseToAnyPublisher()
}
return downloadXcode(availableXcode: availableXcode, downloader: downloader)
}
}
private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight)
}
})
.map { return (availableXcode, $0) }
.eraseToAnyPublisher()
}
public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher {
// Check to see if the archive is in the expected path in case it was downloaded but failed to install
let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))"
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2")
var aria2DownloadIsIncomplete = false
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
aria2DownloadIsIncomplete = true
}
if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false {
Logger.appState.info("Found existing archive that will be used for installation at \(expectedArchivePath).")
return Just(expectedArchivePath.url)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
else {
let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))"
switch downloader {
case .aria2:
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
return downloadXcodeWithAria2(
availableXcode,
to: destination,
aria2Path: aria2Path,
progressChanged: progressChanged
)
case .urlSession:
return downloadXcodeWithURLSession(
availableXcode,
to: destination,
progressChanged: progressChanged
)
}
}
}
public func downloadXcodeWithAria2(_ availableXcode: AvailableXcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher {
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: availableXcode.url) ?? []
let (progress, publisher) = Current.shell.downloadWithAria2(
aria2Path,
availableXcode.url,
destination,
cookies
)
progressChanged(progress)
return publisher
.map { _ in destination.url }
.eraseToAnyPublisher()
}
public func downloadXcodeWithURLSession(_ availableXcode: AvailableXcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher {
let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).resumedata"
let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string)
return attemptResumableTask(maximumRetryCount: 3) { resumeData -> AnyPublisher in
let (progress, publisher) = Current.network.downloadTask(with: availableXcode.url,
to: destination.url,
resumingWith: resumeData ?? persistedResumeData)
progressChanged(progress)
return publisher
.map { $0.saveLocation }
.eraseToAnyPublisher()
}
.handleEvents(receiveCompletion: { completion in
self.persistOrCleanUpResumeData(at: resumeDataPath, for: completion)
})
.eraseToAnyPublisher()
}
public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher {
unxipProgress.completedUnitCount = 0
overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight)
do {
let destinationURL = Path.installDirectory.join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension {
case "xip":
return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL)
.tryMap { xcodeURL throws -> InstalledXcode in
guard
let path = Path(url: xcodeURL),
Current.files.fileExists(atPath: path.string),
let installedXcode = InstalledXcode(path: path)
else { throw InstallationError.failedToMoveXcodeToApplications }
return installedXcode
}
.flatMap { installedXcode -> AnyPublisher in
do {
self.setInstallationStep(of: availableXcode.version, to: .trashingArchive)
try Current.files.trashItem(at: archiveURL)
self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity)
return self.verifySecurityAssessment(of: installedXcode)
.combineLatest(self.verifySigningCertificate(of: installedXcode.path.url))
.map { _ in installedXcode }
.eraseToAnyPublisher()
} catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
}
.flatMap { installedXcode -> AnyPublisher in
self.setInstallationStep(of: availableXcode.version, to: .finishing)
return self.performPostInstallSteps(for: installedXcode)
.map { installedXcode }
// Show post-install errors but don't fail because of them
.handleEvents(receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription)
}
resetDockProgressTracking()
})
.catch { _ in
Just(installedXcode)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
case "dmg":
throw InstallationError.unsupportedFileFormat(extension: "dmg")
default:
throw InstallationError.unsupportedFileFormat(extension: archiveURL.pathExtension)
}
} catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
}
func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher {
self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
return unxipOrUnxipExperiment(source)
.catch { error -> AnyPublisher in
if let executionError = error as? ProcessExecutionError {
if executionError.standardError.contains("damaged and can’t be expanded") {
return Fail(error: InstallationError.damagedXIP(url: source))
.eraseToAnyPublisher()
} else if executionError.standardError.contains("can’t be expanded because the selected volume doesn’t have enough free space.") {
return Fail(error: InstallationError.notEnoughFreeSpaceToExpandArchive(archivePath: Path(url: source)!,
version: availableXcode.version))
.eraseToAnyPublisher()
}
}
return Fail(error: error)
.eraseToAnyPublisher()
}
.tryMap { output -> URL in
self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path))
let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app")
let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app")
if Current.files.fileExists(atPath: xcodeURL.path) {
try Current.files.moveItem(at: xcodeURL, to: destination)
}
else if Current.files.fileExists(atPath: xcodeBetaURL.path) {
try Current.files.moveItem(at: xcodeBetaURL, to: destination)
}
return destination
}
.handleEvents(receiveCancel: {
if Current.files.fileExists(atPath: source.path) {
try? Current.files.removeItem(source)
}
if Current.files.fileExists(atPath: destination.path) {
try? Current.files.removeItem(destination)
}
})
.eraseToAnyPublisher()
}
func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher {
if unxipExperiment {
// All hard work done by https://github.com/saagarjha/unxip
// Compiled to binary with `swiftc -parse-as-library -O unxip.swift`
return Current.shell.unxipExperiment(source)
} else {
return Current.shell.unxip(source)
}
}
public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher {
return Current.shell.spctlAssess(xcode.path.url)
.catch { (error: Swift.Error) -> AnyPublisher in
var output = ""
if let executionError = error as? ProcessExecutionError {
output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n")
}
return Fail(error: InstallationError.failedSecurityAssessment(xcode: xcode, output: output))
.eraseToAnyPublisher()
}
.map { _ in Void() }
.eraseToAnyPublisher()
}
func verifySigningCertificate(of url: URL) -> AnyPublisher {
return Current.shell.codesignVerify(url)
.catch { error -> AnyPublisher in
var output = ""
if let executionError = error as? ProcessExecutionError {
output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n")
}
return Fail(error: InstallationError.codesignVerifyFailed(output: output))
.eraseToAnyPublisher()
}
.map { output -> CertificateInfo in
// codesign prints to stderr
return self.parseCertificateInfo(output.err)
}
.tryMap { cert in
guard
cert.teamIdentifier == XcodeTeamIdentifier,
cert.authority == XcodeCertificateAuthority
else { throw InstallationError.unexpectedCodeSigningIdentity(identifier: cert.teamIdentifier, certificateAuthority: cert.authority) }
return Void()
}
.eraseToAnyPublisher()
}
public struct CertificateInfo {
public var authority: [String]
public var teamIdentifier: String
public var bundleIdentifier: String
}
public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo {
var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "")
for part in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) {
if part.hasPrefix("Authority") {
info.authority.append(part.components(separatedBy: "=")[1])
}
if part.hasPrefix("TeamIdentifier") {
info.teamIdentifier = part.components(separatedBy: "=")[1]
}
if part.hasPrefix("Identifier") {
info.bundleIdentifier = part.components(separatedBy: "=")[1]
}
}
return info
}
// MARK: - Post-Install
/// Attemps to install the helper once, then performs all post-install steps
public func performPostInstallSteps(for xcode: InstalledXcode) {
performPostInstallSteps(for: xcode)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription)
}
},
receiveValue: {}
)
.store(in: &cancellables)
}
/// Attemps to install the helper once, then performs all post-install steps
public func performPostInstallSteps(for xcode: InstalledXcode) -> AnyPublisher {
let postInstallPublisher: AnyPublisher =
Deferred { [unowned self] in
self.installHelperIfNecessary()
}
.flatMap { [unowned self] in
self.enableDeveloperMode()
}
.flatMap { [unowned self] in
self.approveLicense(for: xcode)
}
.flatMap { [unowned self] in
self.installComponents(for: xcode)
}
.mapError { [unowned self] error in
Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)")
return InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState)
}
.eraseToAnyPublisher()
guard helperInstallState == .installed else {
// If the helper isn't installed yet then we need to prepare the user for the install prompt,
// and then actually perform the installation,
// and the post-install steps need to wait until that is complete.
// This subject, which completes upon isPreparingUserForActionRequiringHelper being invoked, is used to achieve that.
// This is not the most straightforward code I've ever written...
let helperInstallConsentSubject = PassthroughSubject()
// Need to dispatch this to avoid duplicate alerts,
// the second of which will crash when force-unwrapping isPreparingUserForActionRequiringHelper
DispatchQueue.main.async {
self.isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in
if userConsented {
helperInstallConsentSubject.send()
} else {
Logger.appState.info("User did not consent to installing helper during post-install steps.")
helperInstallConsentSubject.send(
completion: .failure(
InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState)
)
)
}
}
self.presentedAlert = .privilegedHelper
}
unxipProgress.completedUnitCount = AppState.totalProgressUnits
resetDockProgressTracking()
return helperInstallConsentSubject
.flatMap {
postInstallPublisher
}
.eraseToAnyPublisher()
}
return postInstallPublisher
}
private func enableDeveloperMode() -> AnyPublisher {
Current.helper.devToolsSecurityEnable()
.flatMap {
Current.helper.addStaffToDevelopersGroup()
}
.eraseToAnyPublisher()
}
private func approveLicense(for xcode: InstalledXcode) -> AnyPublisher {
Current.helper.acceptXcodeLicense(xcode.path.string)
.eraseToAnyPublisher()
}
private func installComponents(for xcode: InstalledXcode) -> AnyPublisher {
Current.helper.runFirstLaunch(xcode.path.string)
.flatMap {
Current.shell.getUserCacheDir().map { $0.out }
.combineLatest(
Current.shell.buildVersion().map { $0.out },
Current.shell.xcodeBuildVersion(xcode).map { $0.out }
)
}
.flatMap { cacheDirectory, macOSBuildVersion, toolsVersion in
Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion)
}
.map { _ in Void() }
.eraseToAnyPublisher()
}
// MARK: - Dock Progress Tracking
private func setupDockProgress() {
Task { @MainActor in
DockProgress.progressInstance = nil
DockProgress.style = .bar
let progress = Progress(totalUnitCount: AppState.totalProgressUnits)
progress.kind = .file
progress.fileOperationKind = .downloading
overallProgress = progress
DockProgress.progressInstance = overallProgress
}
}
func resetDockProgressTracking() {
Task { @MainActor in
DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete
}
}
// MARK: -
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step)
let xcode = self.allXcodes[index]
Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal)
}
}
func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) {
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installing(step)
if postNotification {
Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
}
}
}
}
extension AppState {
func persistOrCleanUpResumeData(at path: Path, for completion: Subscribers.Completion) {
switch completion {
case .finished:
try? Current.files.removeItem(at: path.url)
case .failure(let error):
guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return }
Current.files.createFile(atPath: path.string, contents: resumeData)
}
}
}
public enum InstallationError: LocalizedError, Equatable {
case damagedXIP(url: URL)
case notEnoughFreeSpaceToExpandArchive(archivePath: Path, version: Version)
case failedToMoveXcodeToApplications
case failedSecurityAssessment(xcode: InstalledXcode, output: String)
case codesignVerifyFailed(output: String)
case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String])
case unsupportedFileFormat(extension: String)
case missingSudoerPassword
case unavailableVersion(Version)
case noNonPrereleaseVersionAvailable
case noPrereleaseVersionAvailable
case missingUsernameOrPassword
case versionAlreadyInstalled(InstalledXcode)
case invalidVersion(String)
case versionNotInstalled(Version)
case postInstallStepsNotPerformed(version: Version, helperInstallState: HelperInstallState)
public var errorDescription: String? {
switch self {
case .damagedXIP(let url):
return String(format: localizeString("InstallationError.DamagedXIP"), url.lastPathComponent)
case let .notEnoughFreeSpaceToExpandArchive(archivePath, version):
return String(format: localizeString("InstallationError.NotEnoughFreeSpaceToExpandArchive"), archivePath.basename(), version.appleDescription)
case .failedToMoveXcodeToApplications:
return String(format: localizeString("InstallationError.FailedToMoveXcodeToApplications"), Path.installDirectory.string)
case .failedSecurityAssessment(let xcode, let output):
return String(format: localizeString("InstallationError.FailedSecurityAssessment"), String(xcode.version), output, xcode.path.string)
case .codesignVerifyFailed(let output):
return String(format: localizeString("InstallationError.CodesignVerifyFailed"), output)
case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority):
return String(format: localizeString("InstallationError.UnexpectedCodeSigningIdentity"), identity, certificateAuthority, XcodeTeamIdentifier, XcodeCertificateAuthority)
case .unsupportedFileFormat(let fileExtension):
return String(format: localizeString("InstallationError.UnsupportedFileFormat"), fileExtension)
case .missingSudoerPassword:
return localizeString("InstallationError.MissingSudoerPassword")
case let .unavailableVersion(version):
return String(format: localizeString("InstallationError.UnavailableVersion"), version.appleDescription)
case .noNonPrereleaseVersionAvailable:
return localizeString("InstallationError.NoNonPrereleaseVersionAvailable")
case .noPrereleaseVersionAvailable:
return localizeString("InstallationError.NoPrereleaseVersionAvailable")
case .missingUsernameOrPassword:
return localizeString("InstallationError.MissingUsernameOrPassword")
case let .versionAlreadyInstalled(installedXcode):
return String(format: localizeString("InstallationError.VersionAlreadyInstalled"), installedXcode.version.appleDescription, installedXcode.path.string)
case let .invalidVersion(version):
return String(format: localizeString("InstallationError.InvalidVersion"), version)
case let .versionNotInstalled(version):
return String(format: localizeString("InstallationError.VersionNotInstalled"), version.appleDescription)
case let .postInstallStepsNotPerformed(version, helperInstallState):
switch helperInstallState {
case .installed:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription)
case .notInstalled, .unknown:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription)
}
}
}
}
public enum InstallationType {
case version(AvailableXcode)
}
public enum AutoInstallationType: Int, Identifiable {
case none = 0
case newestVersion
case newestBeta
public var id: Self { self }
public var isAutoInstalling: Bool {
get {
return self != .none
}
set {
self = newValue ? .newestVersion : .none
}
}
public var isAutoInstallingBeta: Bool {
get {
return self == .newestBeta
}
set {
self = newValue ? .newestBeta : (isAutoInstalling ? .newestVersion : .none)
}
}
}
let XcodeTeamIdentifier = "59GAB85EFG"
let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]
================================================
FILE: Xcodes/Backend/AppState+Runtimes.swift
================================================
import Foundation
import XcodesKit
import OSLog
import Combine
import Path
import AppleAPI
import Version
extension AppState {
func updateDownloadableRuntimes() {
Task {
do {
let downloadableRuntimes = try await self.runtimeService.downloadableRuntimes()
let runtimes = downloadableRuntimes.downloadables.map { runtime in
var updatedRuntime = runtime
// This loops through and matches up the simulatorVersion to the mappings
let simulatorBuildUpdate = downloadableRuntimes.sdkToSimulatorMappings.filter { SDKToSimulatorMapping in
SDKToSimulatorMapping.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate
}
updatedRuntime.sdkBuildUpdate = simulatorBuildUpdate.map { $0.sdkBuildUpdate }
return updatedRuntime
}
DispatchQueue.main.async {
self.downloadableRuntimes = runtimes
}
try? cacheDownloadableRuntimes(runtimes)
} catch {
Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)")
}
}
}
func updateInstalledRuntimes() {
Task {
do {
Logger.appState.info("Loading Installed runtimes")
let runtimes = try await self.runtimeService.localInstalledRuntimes()
DispatchQueue.main.async {
self.installedRuntimes = runtimes
}
} catch {
Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)")
}
}
}
func downloadRuntime(runtime: DownloadableRuntime) {
guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else {
Logger.appState.error("No selected Xcode")
DispatchQueue.main.async {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active")
}
return
}
// new runtimes
if runtime.contentType == .cryptexDiskImage {
// only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version
// only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild
if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) {
if runtime.architectures?.isAppleSilicon ?? false {
// Need Xcode 26 but with some RC/Beta's its simpler to just to greater > 25
if selectedXcode.version > Version(major: 25, minor: 0, patch: 0) {
downloadRuntimeViaXcodeBuild(runtime: runtime)
} else {
// not supported
Logger.appState.error("Trying to download a runtime we can't download")
DispatchQueue.main.async {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode26"))
}
return
}
} else {
downloadRuntimeViaXcodeBuild(runtime: runtime)
}
} else {
// not supported
Logger.appState.error("Trying to download a runtime we can't download")
DispatchQueue.main.async {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode16.1"))
}
return
}
} else {
downloadRuntimeObseleteWay(runtime: runtime)
}
}
func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) {
let downloadRuntimeTask = Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate, runtime.architectures?.isAppleSilicon ?? false ? Architecture.arm64.rawValue : nil)
runtimePublishers[runtime.identifier] = Task { [weak self] in
guard let self = self else { return }
do {
for try await progress in downloadRuntimeTask {
if progress.isIndeterminate {
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .installing, postNotification: false)
}
} else {
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false)
}
}
}
Logger.appState.debug("Done downloading runtime - \(runtime.name)")
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installed
self.update()
}
} catch {
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
DispatchQueue.main.async {
self.error = error
if let error = error as? String {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error)
} else {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
}
}
}
}
}
func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) {
runtimePublishers[runtime.identifier] = Task {
do {
let downloadedURL = try await downloadRunTimeFull(runtime: runtime)
if !Task.isCancelled {
Logger.appState.debug("Installing runtime: \(runtime.name)")
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .installing)
}
switch runtime.contentType {
case .cryptexDiskImage:
// not supported yet (do we need to for old packages?)
throw "Installing via cryptexDiskImage not support - please install manually from \(downloadedURL.description)"
case .package:
// not supported yet (do we need to for old packages?)
throw "Installing via package not support - please install manually from \(downloadedURL.description)"
case .diskImage:
try await self.installFromImage(dmgURL: downloadedURL)
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .trashingArchive)
}
try Current.files.removeItem(at: downloadedURL)
}
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installed
}
updateInstalledRuntimes()
}
}
catch {
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
DispatchQueue.main.async {
self.error = error
if let error = error as? String {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error)
} else {
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
}
}
}
}
}
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws -> URL {
guard let source = runtime.source else {
throw "Invalid runtime source"
}
guard let downloadPath = runtime.downloadPath else {
throw "Invalid runtime downloadPath"
}
// sets a proper cookie for runtimes
try await validateADCSession(path: downloadPath)
let downloader = Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2
let url = URL(string: source)!
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
var aria2DownloadIsIncomplete = false
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
aria2DownloadIsIncomplete = true
}
if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false {
Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).")
return expectedRuntimePath.url
}
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
switch downloader {
case .aria2:
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
for try await progress in downloadRuntimeWithAria2(runtime, to: expectedRuntimePath, aria2Path: aria2Path) {
DispatchQueue.main.async {
self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false)
}
}
Logger.appState.debug("Done downloading runtime")
case .urlSession:
throw "Downloading runtimes with URLSession is not supported. Please use aria2"
}
return expectedRuntimePath.url
}
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path) -> AsyncThrowingStream