Compare commits

..

No commits in common. "main" and "v1.2.0b8" have entirely different histories.

170 changed files with 1225 additions and 32126 deletions

View file

@ -5,9 +5,6 @@ categories:
- title: '🐛 Bug Fixes' - title: '🐛 Bug Fixes'
labels: labels:
- 'bugfix' - 'bugfix'
- title: '🌎 Localization'
labels:
- 'localization'
- title: '🧰 Maintenance' - title: '🧰 Maintenance'
label: label:
- 'chore' - 'chore'

View file

@ -9,23 +9,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout 🛎 - name: Checkout 🛎
uses: actions/checkout@v4 uses: actions/checkout@v2.3.5
with: with:
# If you're using actions/checkout@v4 you must set persist-credentials to false in most cases for the deployment to work correctly. # If you're using actions/checkout@v2.3.5 you must set persist-credentials to false in most cases for the deployment to work correctly.
persist-credentials: false persist-credentials: false
# - name: Cache 📦 - name: Cache 📦
# uses: actions/cache@v4.1.1 uses: actions/cache@v2.1.6
# 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: with:
ruby-version: '3.0' path: AppCast/vendor/bundle
key: ${{ runner.os }}-gems-v1.0-${{ hashFiles('AppCast/Gemfile') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Ruby ♦️
uses: actions/setup-ruby@v1.1.3
with:
ruby-version: '2.7'
- name: Bundler 💎 - name: Bundler 💎
working-directory: AppCast working-directory: AppCast
@ -43,8 +43,8 @@ jobs:
run: bundle exec jekyll build run: bundle exec jekyll build
- name: Publish 🚀 - name: Publish 🚀
uses: JamesIves/github-pages-deploy-action@releases/v4 uses: JamesIves/github-pages-deploy-action@releases/v3
with: with:
token: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
branch: gh-pages BRANCH: gh-pages
folder: AppCast/_site FOLDER: AppCast/_site

View file

@ -8,10 +8,10 @@ on:
jobs: jobs:
test: test:
runs-on: macos-15 runs-on: macOS-11.0
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2.3.5
- name: Run tests - name: Run tests
env: env:
DEVELOPER_DIR: /Applications/Xcode_16.4.app DEVELOPER_DIR: /Applications/Xcode_12.5.1.app
run: xcodebuild test -scheme Xcodes run: xcodebuild test -scheme Xcodes

View file

@ -11,6 +11,6 @@ jobs:
update_release_draft: update_release_draft:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: release-drafter/release-drafter@v6 - uses: release-drafter/release-drafter@v5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,22 +0,0 @@
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

3
.gitignore vendored
View file

@ -36,9 +36,6 @@ Product/*
timeline.xctimeline timeline.xctimeline
playground.xcworkspace playground.xcworkspace
# Jetbrains IDEA
.idea
# Swift Package Manager # Swift Package Manager
# #
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.

View file

@ -8,7 +8,7 @@ source "https://rubygems.org"
# #
# This will help ensure the proper Jekyll version is running. # This will help ensure the proper Jekyll version is running.
# Happy Jekylling! # Happy Jekylling!
gem "jekyll", "~> 4.4.1" gem "jekyll", "~> 3.9.0"
gem "jekyll-github-metadata", group: :jekyll_plugins gem "jekyll-github-metadata", group: :jekyll_plugins

View file

@ -1,110 +1,88 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
addressable (2.8.7) addressable (2.8.0)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 5.0)
base64 (0.3.0)
bigdecimal (3.2.2)
colorator (1.1.0) colorator (1.1.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.1.7)
csv (3.3.5) em-websocket (0.5.2)
em-websocket (0.5.3)
eventmachine (>= 0.12.9) eventmachine (>= 0.12.9)
http_parser.rb (~> 0) http_parser.rb (~> 0.6.0)
eventmachine (1.2.7) eventmachine (1.2.7)
faraday (1.3.0) faraday (1.3.0)
faraday-net_http (~> 1.0) faraday-net_http (~> 1.0)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
ruby2_keywords ruby2_keywords
faraday-net_http (1.0.1) faraday-net_http (1.0.1)
ffi (1.17.2) ffi (1.14.2)
ffi (1.17.2-x86_64-darwin)
forwardable-extended (2.6.0) forwardable-extended (2.6.0)
google-protobuf (4.31.1) http_parser.rb (0.6.0)
bigdecimal i18n (0.9.5)
rake (>= 13)
google-protobuf (4.31.1-x86_64-darwin)
bigdecimal
rake (>= 13)
http_parser.rb (0.8.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jekyll (4.4.1) jekyll (3.9.0)
addressable (~> 2.4) addressable (~> 2.4)
base64 (~> 0.2)
colorator (~> 1.0) colorator (~> 1.0)
csv (~> 3.0)
em-websocket (~> 0.5) em-websocket (~> 0.5)
i18n (~> 1.0) i18n (~> 0.7)
jekyll-sass-converter (>= 2.0, < 4.0) jekyll-sass-converter (~> 1.0)
jekyll-watch (~> 2.0) jekyll-watch (~> 2.0)
json (~> 2.6) kramdown (>= 1.17, < 3)
kramdown (~> 2.3, >= 2.3.1)
kramdown-parser-gfm (~> 1.0)
liquid (~> 4.0) liquid (~> 4.0)
mercenary (~> 0.3, >= 0.3.6) mercenary (~> 0.3.3)
pathutil (~> 0.9) pathutil (~> 0.9)
rouge (>= 3.0, < 5.0) rouge (>= 1.7, < 4)
safe_yaml (~> 1.0) safe_yaml (~> 1.0)
terminal-table (>= 1.8, < 4.0)
webrick (~> 1.7)
jekyll-github-metadata (2.13.0) jekyll-github-metadata (2.13.0)
jekyll (>= 3.4, < 5.0) jekyll (>= 3.4, < 5.0)
octokit (~> 4.0, != 4.4.0) octokit (~> 4.0, != 4.4.0)
jekyll-sass-converter (3.1.0) jekyll-sass-converter (1.5.2)
sass-embedded (~> 1.75) sass (~> 3.4)
jekyll-watch (2.2.1) jekyll-watch (2.2.1)
listen (~> 3.0) listen (~> 3.0)
json (2.12.2) kramdown (2.3.1)
kramdown (2.5.1) rexml
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
liquid (4.0.4) liquid (4.0.3)
listen (3.9.0) listen (3.4.1)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
mercenary (0.4.0) mercenary (0.3.6)
multipart-post (2.1.1) multipart-post (2.1.1)
octokit (4.20.0) octokit (4.20.0)
faraday (>= 0.9) faraday (>= 0.9)
sawyer (~> 0.8.0, >= 0.5.3) sawyer (~> 0.8.0, >= 0.5.3)
pathutil (0.16.2) pathutil (0.16.2)
forwardable-extended (~> 2.6) forwardable-extended (~> 2.6)
public_suffix (6.0.2) public_suffix (4.0.6)
rake (13.3.0) rb-fsevent (0.10.4)
rb-fsevent (0.11.2) rb-inotify (0.10.1)
rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
rexml (3.4.1) rexml (3.2.5)
rouge (4.5.2) rouge (3.26.0)
ruby2_keywords (0.0.2) ruby2_keywords (0.0.2)
safe_yaml (1.0.5) safe_yaml (1.0.5)
sass-embedded (1.89.2) sass (3.7.4)
google-protobuf (~> 4.31) sass-listen (~> 4.0.0)
rake (>= 13) sass-listen (4.0.0)
sass-embedded (1.89.2-x86_64-darwin) rb-fsevent (~> 0.9, >= 0.9.4)
google-protobuf (~> 4.31) rb-inotify (~> 0.9, >= 0.9.7)
sawyer (0.8.2) sawyer (0.8.2)
addressable (>= 2.3.5) addressable (>= 2.3.5)
faraday (> 0.8, < 2.0) faraday (> 0.8, < 2.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thread_safe (0.3.6) thread_safe (0.3.6)
tzinfo (1.2.10) tzinfo (1.2.9)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2020.6) tzinfo-data (1.2020.6)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
unicode-display_width (2.6.0)
wdm (0.1.1) wdm (0.1.1)
webrick (1.9.1)
PLATFORMS PLATFORMS
ruby ruby
x86_64-darwin-20 x86_64-darwin-20
DEPENDENCIES DEPENDENCIES
jekyll (~> 4.4.1) jekyll (~> 3.9.0)
jekyll-github-metadata jekyll-github-metadata
kramdown-parser-gfm kramdown-parser-gfm
tzinfo (~> 1.2) tzinfo (~> 1.2)
@ -112,4 +90,4 @@ DEPENDENCIES
wdm (~> 0.1.0) wdm (~> 0.1.0)
BUNDLED WITH BUNDLED WITH
2.6.9 2.2.5

View file

@ -1,43 +0,0 @@
# 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.

View file

@ -73,4 +73,4 @@ Based on [this blog post](https://yiqiu.me/2015/11/19/sparkle-update-on-github/)
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'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. We also considered https://github.com/mxcl/AppUpdater, but decided aganist it because it seemed less battle-tested than Sparkle and currently lacks an open source license.

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
let machServiceName = "com.xcodesorg.xcodesapp.Helper" let machServiceName = "com.robotsandpencils.XcodesApp.Helper"
let clientBundleID = "com.xcodesorg.xcodesapp" let clientBundleID = "com.robotsandpencils.XcodesApp"
let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String
@objc(HelperXPCProtocol) @objc(HelperXPCProtocol)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

123
README.md
View file

@ -1,116 +1,47 @@
<h1><img src="icon.png" align="center" width=50 height=50 /> <img src="IconDark.png" align="center" width=50 height=50 /> <img src="IconMono.png" align="center" width=50 height=50 /> Xcodes.app</h1> <h1><img src="icon.png" align="center" width=50 height=50 /> Xcodes.app</h1>
The easiest way to install and switch between multiple versions of Xcode. 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)._ _If you're looking for a command-line version of Xcodes.app, try [`xcodes`](https://github.com/RobotsAndPencils/xcodes)._
![CI](https://github.com/XcodesOrg/XcodesApp/workflows/CI/badge.svg) ![CI](https://github.com/RobotsAndPencils/Xcodes.app/workflows/CI/badge.svg)
![](screenshot_light.png#gh-light-mode-only) ![](screenshot.png)
![](screenshot_dark.png#gh-dark-mode-only)
### :tada: Announcement
XcodesApp is now part of the `XcodesOrg` - [read more here](nextstep.md)
## Features ## Features
- List all available Xcode versions from [Xcode Releases'](https://xcodereleases.com) data or the Apple Developer website. - 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. - 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`. - 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). - 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.
![](experiment_light.png#gh-light-mode-only)
![](experiment_dark.png#gh-dark-mode-only)
## 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 ## 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 Xcodes.app runs on macOS Big Sur 11.0 or later.
Developer ID-signed and notarized release builds are available on Homebrew. These don't require Xcode to already be installed in order to use. ### Homebrew Cask
```sh ```sh
brew install --cask xcodes brew install --cask xcodes
# These are Developer ID-signed and notarized release builds and don't require Xcode to already be installed in order to use.
``` ```
### Manually install ### Download a release
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. 1. Download the latest version [here](https://github.com/RobotsAndPencils/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 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!
<a href="https://opencollective.com/xcodesapp" target="_blank">
<img src="https://opencollective.com/xcodesapp/donate/button@2x.png?color=blue" class="buymeacoffee" width=200 />
</a>
## Development ## Development
You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app. You'll need macOS 11 Big Sur and Xcode 12 in order to build and run Xcodes.app.
`Unxip` and `aria2` must be compiled as a universal binary If you aren't a Robots and Pencils employee you'll need to change the CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT build setting to your Apple Developer team ID in order for code signing validation to succeed between the main app and the privileged helper.
```
# 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
```
Notable design decisions are recorded in [DECISIONS.md](./DECISIONS.md). The Apple authentication flow is described in [Apple.paw](./Apple.paw), which will allow you to play with the API endpoints that are involved using the [Paw](https://paw.cloud) app.
[`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. [`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.
<details> <details>
<summary>Releasing a new version</summary> <summary>Releasing a new version</summary>
@ -119,9 +50,9 @@ Follow the steps below to build and release a new version of Xcodes.app. For any
```sh ```sh
# Update the version number in Xcode and commit the change, if necessary # Update the version number in Xcode and commit the change, if necessary
# Question: Did anything in XPCHelper change? # Question: Did anything in XPCHelper change?
# - com.xcodesorg.xcodesapp.Helper folder and HelperXPCShared # - com.robotsandpencils.XcodesApp.Helper folder and HelperXPCShared
# - if so, bump the version number in com.xcodesorg.xcodesapp.Helper target. # - if so, bump the version number in com.robotsandpencils.XcodesApp.Helper target.
# Note: you do not have to bump the version number if nothing has changed. # 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. # Note2: If you do bump the version, the end user, must re-install the XPCHelper and give permission again.
@ -146,20 +77,17 @@ scripts/package_release.sh
# Notarize the app # Notarize the app
# Do this from the Product directory so the app is zipped without being nested inside Product # 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 # Create a app specific password on appleid.apple.com if you haven't already
# xcrun notarytool store-credentials "AC_PASSWORD" \ # % xcrun altool --store-password-in-keychain-item "AC_PASSWORD" -u "<appleiduseremail>" -p <app_specific_secret>
# --apple-id "test@example.com" \
# --team-id "teamid" \
# --password "app specific password"
pushd Product pushd Product
../scripts/notarize.sh Xcodes.zip <MYORG> ../scripts/notarize.sh "test@example.com" "@keychain:AC_PASSWORD" <MyOrg> Xcodes.zip
# Sign the .zip for Sparkle, note the signature in the output for later # 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. # 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 ../scripts/sign_update Xcodes.zip
popd popd
# Go to https://github.com/XcodesOrg/XcodesApp/releases # Go to https://github.com/RobotsAndPencils/XcodesApp/releases
# If there are uncategorized PRs, add the appropriate label and run the Release Drafter action manually # If there are uncategorized PRs, add the appropriate label and run the Release Drafter action manually
# Edit the latest draft release # Edit the latest draft release
# Set its tag to the tag you just pushed # Set its tag to the tag you just pushed
@ -170,13 +98,14 @@ popd
# Attach the zip that was created in the Product directory to the release # Attach the zip that was created in the Product directory to the release
# Publish the release # Publish the release
shasum -a 256 xcodes.zip # Update the [Homebrew Cask](https://github.com/RobotsAndPencils/homebrew-cask/blob/master/Casks/xcodes.rb).
# Update the [Homebrew Cask](https://github.com/XcodesOrg/homebrew-cask/blob/master/Casks/x/xcodes.rb).
``` ```
</details> </details>
## Maintainers ## Contact
[Matt Kiazyk](https://github.com/mattkiazyk) - [Twitter](https://www.twitter.com/mattkiazyk) <a href="http://www.robotsandpencils.com"><img src="R&PLogo.png" width="153" height="74" /></a>
[Twitter](https://twitter.com/xcodesApp) | [GitHub](https://github.com/xcodesOrg) | [Mastadon](https://iosdev.space/@XcodesApp) | Made with ❤️ by [Robots & Pencils](http://www.robotsandpencils.com)
[Twitter](https://twitter.com/robotsNpencils) | [GitHub](https://github.com/robotsandpencils)

View file

@ -1,26 +0,0 @@
#!/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

View file

@ -17,39 +17,46 @@
# https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow # 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 # Adapted from https://github.com/keybase/client/blob/46f5df0aa64ff19198ba7b044bbb7cd907c0be9f/packaging/desktop/package_darwin.sh
file="$1" username="$1"
team_id="$2" password="$2"
asc_provider="$3"
file="$4"
echo "Uploading to notarization service" echo "Uploading to notarization service"
result=$(xcrun notarytool submit "$file" \ uuid=$(xcrun altool \
--keychain-profile "AC_PASSWORD" \ --notarize-app \
--team-id "$team_id" \ --primary-bundle-id "com.robotsandpencils.XcodesApp.zip" \
--wait) --username "$username" \
# echo "done1" --password "$password" \
echo $result --asc-provider "$asc_provider" \
--file "$file" 2>&1 | \
# My grep/awk is bad and I can't figure out how to get the UUID out properly grep 'RequestUUID' | \
# uuid=$("$result" | \ awk '{ print $3 }')
# grep 'id:' | tail -n1 | \
# cut -d":" -f2-)
echo "Successfully uploaded to notarization service, polling for result: $uuid" 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 sleep 15
# while :
do
# fullstatus=$(xcrun notarytool info "$uuid" \ fullstatus=$(xcrun altool \
# --keychain-profile "AC_PASSWORD" 2>&1) --notarization-info "$uuid" \
# status=$(echo "$fullstatus" | grep 'status\:' | awk '{ print $2 }') --username "$username" \
# if [ "$status" = "Accepted" ]; then --password "$password" \
# echo "Notarization success" --asc-provider "$asc_provider" 2>&1)
# exit 0 status=$(echo "$fullstatus" | grep 'Status\:' | awk '{ print $2 }')
# else if [ "$status" = "success" ]; then
# echo "Notarization failed, full status below" echo "Notarization success"
# echo "$fullstatus" exit 0
# exit 1 elif [ "$status" = "in" ]; then
# fi echo "Notarization still in progress, sleeping for 15 seconds and trying again"
sleep 15
else
echo "Notarization failed, full status below"
echo "$fullstatus"
exit 1
fi
done
# Remove .zip # Remove .zip
rm $file rm $file
@ -59,4 +66,4 @@ app_path="$(basename -s ".zip" "$file").app"
xcrun stapler staple "$app_path" xcrun stapler staple "$app_path"
# Zip the stapled app for distribution # Zip the stapled app for distribution
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$file" ditto -c -k --sequesterRsrc --keepParent "$file" "$app_path"

View file

@ -10,9 +10,6 @@ PROJECT_DIR=$(pwd)/$PROJECT_NAME/Resources
SCRIPTS_DIR=$(pwd)/Scripts SCRIPTS_DIR=$(pwd)/Scripts
INFOPLIST_FILE="Info.plist" 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 # Ensure a clean build
rm -rf Archive/* rm -rf Archive/*
rm -rf Product/* rm -rf Product/*

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
PRIVILEGED_HELPER_LABEL=com.xcodesorg.xcodesapp.Helper PRIVILEGED_HELPER_LABEL=com.robotsandpencils.XcodesApp.Helper
sudo rm /Library/PrivilegedHelperTools/$PRIVILEGED_HELPER_LABEL sudo rm /Library/PrivilegedHelperTools/$PRIVILEGED_HELPER_LABEL
sudo rm /Library/LaunchDaemons/$PRIVILEGED_HELPER_LABEL.plist sudo rm /Library/LaunchDaemons/$PRIVILEGED_HELPER_LABEL.plist

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,6 @@
{ {
"object": { "object": {
"pins": [ "pins": [
{
"package": "AsyncNetworkService",
"repositoryURL": "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService",
"state": {
"branch": "main",
"revision": "97770856c4e429f880d4b4dd68cfaf286dc00c30",
"version": null
}
},
{
"package": "big-num",
"repositoryURL": "https://github.com/adam-fowler/big-num",
"state": {
"branch": null,
"revision": "5c5511ad06aeb2b97d0868f7394e14a624bfb1c7",
"version": "2.0.2"
}
},
{ {
"package": "CombineExpectations", "package": "CombineExpectations",
"repositoryURL": "https://github.com/groue/CombineExpectations", "repositoryURL": "https://github.com/groue/CombineExpectations",
@ -29,12 +11,12 @@
} }
}, },
{ {
"package": "DockProgress", "package": "XcodeReleases",
"repositoryURL": "https://github.com/sindresorhus/DockProgress", "repositoryURL": "https://github.com/xcodereleases/data",
"state": { "state": {
"branch": null, "branch": null,
"revision": "d4f23b5a8f5ca0fac393eb7ba78c2fe3e32e52da", "revision": "b47228c688b608e34b3b84079ab6052a24c7a981",
"version": "4.3.1" "version": null
} }
}, },
{ {
@ -64,22 +46,13 @@
"version": "1.0.4" "version": "1.0.4"
} }
}, },
{
"package": "LibFido2Swift",
"repositoryURL": "https://github.com/kinoroy/LibFido2Swift",
"state": {
"branch": "main",
"revision": "b87a93300c5b35307c9f26ae490963196bd927f1",
"version": null
}
},
{ {
"package": "Path.swift", "package": "Path.swift",
"repositoryURL": "https://github.com/mxcl/Path.swift", "repositoryURL": "https://github.com/mxcl/Path.swift",
"state": { "state": {
"branch": null, "branch": null,
"revision": "8e355c28e9393c42e58b18c54cace2c42c98a616", "revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5",
"version": "1.4.1" "version": "0.16.3"
} }
}, },
{ {
@ -87,35 +60,8 @@
"repositoryURL": "https://github.com/sparkle-project/Sparkle/", "repositoryURL": "https://github.com/sparkle-project/Sparkle/",
"state": { "state": {
"branch": null, "branch": null,
"revision": "0ef1ee0220239b3776f433314515fd849025673f", "revision": "891afd44c7075e699924ed9b81d8dc94a5111dfd",
"version": "2.6.4" "version": "1.24.0-spm"
}
},
{
"package": "swift-collections",
"repositoryURL": "https://github.com/apple/swift-collections.git",
"state": {
"branch": null,
"revision": "a902f1823a7ff3c9ab2fba0f992396b948eda307",
"version": "1.0.5"
}
},
{
"package": "swift-crypto",
"repositoryURL": "https://github.com/apple/swift-crypto",
"state": {
"branch": null,
"revision": "ddb07e896a2a8af79512543b1c7eb9797f8898a5",
"version": "1.1.7"
}
},
{
"package": "swift-srp",
"repositoryURL": "https://github.com/xcodesOrg/swift-srp",
"state": {
"branch": "main",
"revision": "543aa0122a0257b992f6c7d62d18a26e3dffb8fe",
"version": null
} }
}, },
{ {

View file

@ -44,7 +44,6 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "en"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"

View file

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CA9FF8AD2595967A00E47BAF"
BuildableName = "com.xcodesorg.xcodesapp.Helper"
BlueprintName = "com.xcodesorg.xcodesapp.Helper"
ReferencedContainer = "container:Xcodes.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
viewDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CA9FF8AD2595967A00E47BAF"
BuildableName = "com.xcodesorg.xcodesapp.Helper"
BlueprintName = "com.xcodesorg.xcodesapp.Helper"
ReferencedContainer = "container:Xcodes.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CA9FF8AD2595967A00E47BAF"
BuildableName = "com.xcodesorg.xcodesapp.Helper"
BlueprintName = "com.xcodesorg.xcodesapp.Helper"
ReferencedContainer = "container:Xcodes.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -1,4 +1,4 @@
// swift-tools-version:5.4 // swift-tools-version:5.3
import PackageDescription import PackageDescription
@ -14,7 +14,7 @@ let package = Package(
dependencies: [ dependencies: [
], ],
targets: [ targets: [
.executableTarget( .target(
name: "AcknowledgementsGenerator", name: "AcknowledgementsGenerator",
dependencies: [] dependencies: []
), ),

View file

@ -35,7 +35,7 @@ private extension Xcode {
try? task.run() try? task.run()
let handle = pipe.fileHandleForReading let handle = pipe.fileHandleForReading
let data = handle.readDataToEndOfFile() let data = handle.readDataToEndOfFile()
let path = String(data: data, encoding: String.Encoding.utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) let path = String(data: data, encoding: String.Encoding.utf8)
return (path?.isEmpty ?? true) ? nil : path return (path?.isEmpty ?? true) ? nil : path
} }
} }

View file

@ -1,26 +1,24 @@
// swift-tools-version:5.7 // swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "AppleAPI", name: "AppleAPI",
platforms: [.macOS(.v11)], platforms: [.macOS(.v10_15)],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
.library( .library(
name: "AppleAPI", name: "AppleAPI",
targets: ["AppleAPI"]), targets: ["AppleAPI"]),
], ],
dependencies: [ dependencies: [],
.package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main")
],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // 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. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "AppleAPI", name: "AppleAPI",
dependencies: [.product(name: "SRP", package: "swift-srp")]), dependencies: []),
.testTarget( .testTarget(
name: "AppleAPITests", name: "AppleAPITests",
dependencies: ["AppleAPI"]), dependencies: ["AppleAPI"]),

View file

@ -1,9 +1,5 @@
import Foundation import Foundation
import Combine import Combine
import SRP
import Crypto
import CommonCrypto
public class Client { public class Client {
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
@ -12,66 +8,18 @@ public class Client {
// MARK: - Login // MARK: - Login
public func srpLogin(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> { public func login(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
var serviceKey: String! var serviceKey: String!
let client = SRPClient(configuration: SRPConfiguration<SHA256>(.N2048))
let clientKeys = client.generateKeys()
let a = clientKeys.public
return Current.network.dataTask(with: URLRequest.itcServiceKey) return Current.network.dataTask(with: URLRequest.itcServiceKey)
.map(\.data) .map(\.data)
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder()) .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
.flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in .flatMap { serviceKeyResponse -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
serviceKey = serviceKeyResponse.authServiceKey serviceKey = serviceKeyResponse.authServiceKey
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
// Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 .mapError { $0 as Swift.Error }
// 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() .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<URLSession.DataTaskPublisher.Output, Swift.Error> 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<AuthenticationState, Swift.Error> in .flatMap { result -> AnyPublisher<AuthenticationState, Swift.Error> in
let (data, response) = result let (data, response) = result
return Just(data) return Just(data)
@ -108,44 +56,6 @@ public class Client {
.mapError { $0 as Swift.Error } .mapError { $0 as Swift.Error }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher<String, Swift.Error> {
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<AuthenticationState, Swift.Error> { func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
let httpResponse = response as! HTTPURLResponse let httpResponse = response as! HTTPURLResponse
@ -160,7 +70,7 @@ public class Client {
case .twoStep: case .twoStep:
return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication) return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
.eraseToAnyPublisher() .eraseToAnyPublisher()
case .twoFactor, .securityKey: case .twoFactor:
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
.eraseToAnyPublisher() .eraseToAnyPublisher()
case .unknown: case .unknown:
@ -181,10 +91,7 @@ public class Client {
// SMS wasn't sent automatically because user needs to choose a phone to send to // SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS { } else if authOptions.canFallBackToSMS {
option = .smsPendingChoice option = .smsPendingChoice
// Code is shown on trusted devices // 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 { } else {
option = .codeSent option = .codeSent
} }
@ -225,8 +132,6 @@ public class Client {
return (data, urlResponse) return (data, urlResponse)
case 400, 401: case 400, 401:
throw AuthenticationError.incorrectSecurityCode throw AuthenticationError.incorrectSecurityCode
case 412:
throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired
case let code: case let code:
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
} }
@ -238,54 +143,17 @@ public class Client {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
public func submitChallenge(response: Data, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
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<AuthenticationState, Error> in
self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
}
}.eraseToAnyPublisher()
}
// MARK: - Session // MARK: - Session
/// Use the olympus session endpoint to see if the existing session is still valid /// Use the olympus session endpoint to see if the existing session is still valid
public func validateSession() -> AnyPublisher<Void, Error> { public func validateSession() -> AnyPublisher<Void, Error> {
return Current.network.dataTask(with: URLRequest.olympusSession) return Current.network.dataTask(with: URLRequest.olympusSession)
.tryMap { result -> Data in .map(\.data)
let httpResponse = result.response as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
return result.data
}
.decode(type: AppleSession.self, decoder: JSONDecoder()) .decode(type: AppleSession.self, decoder: JSONDecoder())
.tryMap { session in .tryMap { session in
// A user that is a non-paid Apple Developer will have a provider == nil if session.provider == nil {
// Those users can still download Xcode. throw AuthenticationError.notDeveloperAppleId
// Non Apple Developers will get caught in the download as invalid }
// if session.provider == nil {
// throw AuthenticationError.notDeveloperAppleId
// }
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -299,49 +167,6 @@ public class Client {
.mapError { $0 as Error } .mapError { $0 as Error }
.eraseToAnyPublisher() .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<UInt8> =
derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
return saltData.withUnsafeBytes { saltBytes -> Int32 in
let saltBuffer: UnsafePointer<UInt8> = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
return hashedPasswordData.withUnsafeBytes { hashedPasswordBytes -> Int32 in
let passwordBuffer: UnsafePointer<UInt8> = 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 // MARK: - Types
@ -355,7 +180,6 @@ public enum AuthenticationState: Equatable {
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
case invalidSession case invalidSession
case invalidHashcash
case invalidUsernameOrPassword(username: String) case invalidUsernameOrPassword(username: String)
case incorrectSecurityCode case incorrectSecurityCode
case unexpectedSignInResponse(statusCode: Int, message: String?) case unexpectedSignInResponse(statusCode: Int, message: String?)
@ -365,16 +189,11 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
case accountLocked(String) case accountLocked(String)
case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse) case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse)
case notDeveloperAppleId case notDeveloperAppleId
case notAuthorized
case invalidResult(resultString: String?)
case srpInvalidPublicKey
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .invalidSession: case .invalidSession:
return "Your authentication session is invalid. Try signing in again." return "Your authentication session is invalid. Try signing in again."
case .invalidHashcash:
return "Could not create a hashcash for the session."
case .invalidUsernameOrPassword: case .invalidUsernameOrPassword:
return "Invalid username and password combination." return "Invalid username and password combination."
case .incorrectSecurityCode: case .incorrectSecurityCode:
@ -398,12 +217,6 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu." return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu."
case .notDeveloperAppleId: case .notDeveloperAppleId:
return "You are not registered as an Apple Developer. Please visit Apple Developer Registration. https://developer.apple.com/register/" 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"
} }
} }
} }
@ -444,37 +257,27 @@ public enum TwoFactorOption: Equatable {
case smsSent(AuthOptionsResponse.TrustedPhoneNumber) case smsSent(AuthOptionsResponse.TrustedPhoneNumber)
case codeSent case codeSent
case smsPendingChoice 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 struct AuthOptionsResponse: Equatable, Decodable {
public let trustedPhoneNumbers: [TrustedPhoneNumber]? public let trustedPhoneNumbers: [TrustedPhoneNumber]?
public let trustedDevices: [TrustedDevice]? public let trustedDevices: [TrustedDevice]?
public let securityCode: SecurityCodeInfo? public let securityCode: SecurityCodeInfo
public let noTrustedDevices: Bool? public let noTrustedDevices: Bool?
public let serviceErrors: [ServiceError]? public let serviceErrors: [ServiceError]?
public let fsaChallenge: FSAChallenge?
public init( public init(
trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?,
trustedDevices: [AuthOptionsResponse.TrustedDevice]?, trustedDevices: [AuthOptionsResponse.TrustedDevice]?,
securityCode: AuthOptionsResponse.SecurityCodeInfo, securityCode: AuthOptionsResponse.SecurityCodeInfo,
noTrustedDevices: Bool? = nil, noTrustedDevices: Bool? = nil,
serviceErrors: [ServiceError]? = nil, serviceErrors: [ServiceError]? = nil
fsaChallenge: FSAChallenge? = nil
) { ) {
self.trustedPhoneNumbers = trustedPhoneNumbers self.trustedPhoneNumbers = trustedPhoneNumbers
self.trustedDevices = trustedDevices self.trustedDevices = trustedDevices
self.securityCode = securityCode self.securityCode = securityCode
self.noTrustedDevices = noTrustedDevices self.noTrustedDevices = noTrustedDevices
self.serviceErrors = serviceErrors self.serviceErrors = serviceErrors
self.fsaChallenge = fsaChallenge
} }
public var kind: Kind { public var kind: Kind {
@ -482,8 +285,6 @@ public struct AuthOptionsResponse: Equatable, Decodable {
return .twoStep return .twoStep
} else if trustedPhoneNumbers != nil { } else if trustedPhoneNumbers != nil {
return .twoFactor return .twoFactor
} else if fsaChallenge != nil {
return .securityKey
} else { } else {
return .unknown return .unknown
} }
@ -546,7 +347,7 @@ public struct AuthOptionsResponse: Equatable, Decodable {
} }
public enum Kind: Equatable { public enum Kind: Equatable {
case twoStep, twoFactor, securityKey, unknown case twoStep, twoFactor, unknown
} }
} }
@ -583,24 +384,3 @@ public struct AppleProvider: Decodable, Equatable {
public struct AppleUser: Decodable, Equatable { public struct AppleUser: Decodable, Equatable {
public let fullName: String 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()
}
}

View file

@ -1,96 +0,0 @@
//
// 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
}
}

View file

@ -7,13 +7,7 @@ public extension URL {
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")! 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 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 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 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 { public extension URLRequest {
@ -21,7 +15,7 @@ public extension URLRequest {
return URLRequest(url: .itcServiceKey) return URLRequest(url: .itcServiceKey)
} }
static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest {
struct Body: Encodable { struct Body: Encodable {
let accountName: String let accountName: String
let password: String let password: String
@ -33,7 +27,6 @@ public extension URLRequest {
request.allHTTPHeaderFields?["Content-Type"] = "application/json" request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript" request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
request.httpMethod = "POST" request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password)) request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
@ -110,19 +103,6 @@ public extension URLRequest {
} }
return request 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 { static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
var request = URLRequest(url: .trust) var request = URLRequest(url: .trust)
@ -137,68 +117,4 @@ public extension URLRequest {
static var olympusSession: URLRequest { static var olympusSession: URLRequest {
return URLRequest(url: .olympusSession) 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
}

View file

@ -2,26 +2,14 @@ import XCTest
@testable import AppleAPI @testable import AppleAPI
final class AppleAPITests: XCTestCase { final class AppleAPITests: XCTestCase {
func testExample() {
func testValidHashCashMint() { // This is an example of a functional test case.
let bits: UInt = 11 // Use XCTAssert and related functions to verify your tests produce the correct
let resource = "4d74fb15eb23f465f1f6fcbf534e5877" // results.
let testDate = "20230223170600" XCTAssertEqual(AppleAPI().text, "Hello, World!")
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 = [ static var allTests = [
("testValidHashCashMint", testValidHashCashMint), ("testExample", testExample),
("testValidHashCashMint2", testValidHashCashMint2),
] ]
} }

View file

@ -5,16 +5,14 @@ import AppleAPI
import Version import Version
import LegibleError import LegibleError
import os.log import os.log
import DockProgress
import XcodesKit
/// Downloads and installs Xcodes /// Downloads and installs Xcodes
extension AppState { extension AppState {
// check to see if we should auto install for the user // check to see if we should auto install for the user
public func autoInstallIfNeeded() { public func autoInstallIfNeeded() {
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } guard let storageValue = UserDefaults.standard.object(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
if autoInstallType == .none { return } if autoInstallType == .none { return }
// get newest xcode version // get newest xcode version
@ -26,10 +24,10 @@ extension AppState {
if autoInstallType == .newestBeta { if autoInstallType == .newestBeta {
Logger.appState.info("Auto installing newest Xcode Beta") 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 // install it, as user doesn't have it installed and it's either latest beta or latest release
checkMinVersionAndInstall(id: newestXcode.id) install(id: newestXcode.id)
} else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease { } else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease {
Logger.appState.info("Auto installing newest Xcode") Logger.appState.info("Auto installing newest Xcode")
checkMinVersionAndInstall(id: newestXcode.id) install(id: newestXcode.id)
} else { } else {
Logger.appState.info("No new Xcodes version found to auto install") Logger.appState.info("No new Xcodes version found to auto install")
} }
@ -42,11 +40,11 @@ extension AppState {
} }
private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher<InstalledXcode, Error> { private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher<InstalledXcode, Error> {
// We need to check if the Apple ID that is logged in is an Apple Developer
// Since users can use xcodereleases, we don't check for Apple ID on a xcode list refresh
// If the Apple Id is not a developer, the download action will try and download a xip that is invalid, causing a `xcode13.0.xip is damaged and can't be expanded.`
Logger.appState.info("Using \(downloader) downloader") Logger.appState.info("Using \(downloader) downloader")
setupDockProgress()
return validateSession() return validateSession()
.flatMap { _ in .flatMap { _ in
self.getXcodeArchive(installationType, downloader: downloader) self.getXcodeArchive(installationType, downloader: downloader)
@ -55,8 +53,6 @@ extension AppState {
self.installArchivedXcode(xcode, at: url) self.installArchivedXcode(xcode, at: url)
} }
.catch { error -> AnyPublisher<InstalledXcode, Swift.Error> in .catch { error -> AnyPublisher<InstalledXcode, Swift.Error> in
self.resetDockProgressTracking()
switch error { switch error {
case InstallationError.damagedXIP(let damagedXIPURL): case InstallationError.damagedXIP(let damagedXIPURL):
guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() } guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() }
@ -92,24 +88,22 @@ extension AppState {
private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
switch installationType { switch installationType {
case .version(let availableXcode): case .version(let availableXcode):
if let installedXcode = Current.files.installedXcodes(Path.installDirectory).first(where: { $0.version.isEquivalent(to: availableXcode.version) }) { if let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.isEquivalent(to: availableXcode.version) }) {
return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode)) return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode))
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
return downloadXcode(availableXcode: availableXcode, downloader: downloader) return downloadXcode(availableXcode: availableXcode, downloader: downloader)
} }
} }
private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async { DispatchQueue.main.async {
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight) }
} })
}) .map { return (availableXcode, $0) }
.map { return (availableXcode, $0) } .eraseToAnyPublisher()
.eraseToAnyPublisher()
} }
public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> { public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
@ -158,7 +152,6 @@ extension AppState {
cookies cookies
) )
progressChanged(progress) progressChanged(progress)
return publisher return publisher
.map { _ in destination.url } .map { _ in destination.url }
.eraseToAnyPublisher() .eraseToAnyPublisher()
@ -173,7 +166,6 @@ extension AppState {
to: destination.url, to: destination.url,
resumingWith: resumeData ?? persistedResumeData) resumingWith: resumeData ?? persistedResumeData)
progressChanged(progress) progressChanged(progress)
return publisher return publisher
.map { $0.saveLocation } .map { $0.saveLocation }
.eraseToAnyPublisher() .eraseToAnyPublisher()
@ -185,11 +177,8 @@ extension AppState {
} }
public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher<InstalledXcode, Error> { public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher<InstalledXcode, Error> {
unxipProgress.completedUnitCount = 0
overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight)
do { do {
let destinationURL = Path.installDirectory.join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url let destinationURL = Path.root.join("Applications").join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension { switch archiveURL.pathExtension {
case "xip": case "xip":
return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL) return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL)
@ -225,9 +214,8 @@ extension AppState {
.handleEvents(receiveCompletion: { [unowned self] completion in .handleEvents(receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion { if case let .failure(error) = completion {
self.error = error self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) self.presentedAlert = .generic(title: "Unable to install archived Xcode", message: error.legibleLocalizedDescription)
} }
resetDockProgressTracking()
}) })
.catch { _ in .catch { _ in
Just(installedXcode) Just(installedXcode)
@ -250,8 +238,8 @@ extension AppState {
func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher<URL, Swift.Error> { func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher<URL, Swift.Error> {
self.setInstallationStep(of: availableXcode.version, to: .unarchiving) self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
return unxipOrUnxipExperiment(source) return Current.shell.unxip(source)
.catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in .catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in
if let executionError = error as? ProcessExecutionError { if let executionError = error as? ProcessExecutionError {
if executionError.standardError.contains("damaged and cant be expanded") { if executionError.standardError.contains("damaged and cant be expanded") {
@ -290,16 +278,6 @@ extension AppState {
}) })
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher<ProcessOutput, Error> {
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<Void, Error> { public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher<Void, Error> {
return Current.shell.spctlAssess(xcode.path.url) return Current.shell.spctlAssess(xcode.path.url)
@ -373,7 +351,7 @@ extension AppState {
receiveCompletion: { completion in receiveCompletion: { completion in
if case let .failure(error) = completion { if case let .failure(error) = completion {
self.error = error self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription) self.presentedAlert = .generic(title: "Unable to perform post install steps", message: error.legibleLocalizedDescription)
} }
}, },
receiveValue: {} receiveValue: {}
@ -428,9 +406,6 @@ extension AppState {
} }
self.presentedAlert = .privilegedHelper self.presentedAlert = .privilegedHelper
} }
unxipProgress.completedUnitCount = AppState.totalProgressUnits
resetDockProgressTracking()
return helperInstallConsentSubject return helperInstallConsentSubject
.flatMap { .flatMap {
@ -471,48 +446,15 @@ extension AppState {
.eraseToAnyPublisher() .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: - // MARK: -
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) { func setInstallationStep(of version: Version, to step: InstallationStep) {
DispatchQueue.main.async { DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step) self.allXcodes[index].installState = .installing(step)
let xcode = self.allXcodes[index] let xcode = self.allXcodes[index]
Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) Current.notificationManager.scheduleNotification(title: xcode.id.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)
}
} }
} }
} }
@ -550,41 +492,60 @@ public enum InstallationError: LocalizedError, Equatable {
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .damagedXIP(let url): case .damagedXIP(let url):
return String(format: localizeString("InstallationError.DamagedXIP"), url.lastPathComponent) return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded."
case let .notEnoughFreeSpaceToExpandArchive(archivePath, version): case let .notEnoughFreeSpaceToExpandArchive(archivePath, version):
return String(format: localizeString("InstallationError.NotEnoughFreeSpaceToExpandArchive"), archivePath.basename(), version.appleDescription) return """
The archive \(archivePath.basename()) cant be expanded because the current volume doesnt have enough free space.
Make more space available to expand the archive and then install Xcode \(version.appleDescription) again to start installation from where it left off.
"""
case .failedToMoveXcodeToApplications: case .failedToMoveXcodeToApplications:
return String(format: localizeString("InstallationError.FailedToMoveXcodeToApplications"), Path.installDirectory.string) return "Failed to move Xcode to the /Applications directory."
case .failedSecurityAssessment(let xcode, let output): case .failedSecurityAssessment(let xcode, let output):
return String(format: localizeString("InstallationError.FailedSecurityAssessment"), String(xcode.version), output, xcode.path.string) return """
Xcode \(xcode.version) failed its security assessment with the following output:
\(output)
It remains installed at \(xcode.path) if you wish to use it anyways.
"""
case .codesignVerifyFailed(let output): case .codesignVerifyFailed(let output):
return String(format: localizeString("InstallationError.CodesignVerifyFailed"), output) return """
The downloaded Xcode failed code signing verification with the following output:
\(output)
"""
case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority): case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority):
return String(format: localizeString("InstallationError.UnexpectedCodeSigningIdentity"), identity, certificateAuthority, XcodeTeamIdentifier, XcodeCertificateAuthority) return """
The downloaded Xcode doesn't have the expected code signing identity.
Got:
\(identity)
\(certificateAuthority)
Expected:
\(XcodeTeamIdentifier)
\(XcodeCertificateAuthority)
"""
case .unsupportedFileFormat(let fileExtension): case .unsupportedFileFormat(let fileExtension):
return String(format: localizeString("InstallationError.UnsupportedFileFormat"), fileExtension) return "xcodes doesn't (yet) support installing Xcode from the \(fileExtension) file format."
case .missingSudoerPassword: case .missingSudoerPassword:
return localizeString("InstallationError.MissingSudoerPassword") return "Missing password. Please try again."
case let .unavailableVersion(version): case let .unavailableVersion(version):
return String(format: localizeString("InstallationError.UnavailableVersion"), version.appleDescription) return "Could not find version \(version.appleDescription)."
case .noNonPrereleaseVersionAvailable: case .noNonPrereleaseVersionAvailable:
return localizeString("InstallationError.NoNonPrereleaseVersionAvailable") return "No non-prerelease versions available."
case .noPrereleaseVersionAvailable: case .noPrereleaseVersionAvailable:
return localizeString("InstallationError.NoPrereleaseVersionAvailable") return "No prerelease versions available."
case .missingUsernameOrPassword: case .missingUsernameOrPassword:
return localizeString("InstallationError.MissingUsernameOrPassword") return "Missing username or a password. Please try again."
case let .versionAlreadyInstalled(installedXcode): case let .versionAlreadyInstalled(installedXcode):
return String(format: localizeString("InstallationError.VersionAlreadyInstalled"), installedXcode.version.appleDescription, installedXcode.path.string) return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)"
case let .invalidVersion(version): case let .invalidVersion(version):
return String(format: localizeString("InstallationError.InvalidVersion"), version) return "\(version) is not a valid version number."
case let .versionNotInstalled(version): case let .versionNotInstalled(version):
return String(format: localizeString("InstallationError.VersionNotInstalled"), version.appleDescription) return "\(version.appleDescription) is not installed."
case let .postInstallStepsNotPerformed(version, helperInstallState): case let .postInstallStepsNotPerformed(version, helperInstallState):
switch helperInstallState { switch helperInstallState {
case .installed: case .installed:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription) return "Installation was completed, but some post-install steps weren't performed automatically. These will be performed when you first launch Xcode \(version.appleDescription)."
case .notInstalled, .unknown: case .notInstalled, .unknown:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription) return "Installation was completed, but some post-install steps weren't performed automatically. Xcodes performs these steps with a privileged helper, which appears to not be installed. You can install it from Preferences > Advanced.\n\nThese steps will be performed when you first launch Xcode \(version.appleDescription)."
} }
} }
} }

View file

@ -1,356 +0,0 @@
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<Progress, Error> {
guard let url = runtime.url else {
return AsyncThrowingStream<Progress, Error> { continuation in
continuation.finish(throwing: "Invalid or non existant runtime url")
}
}
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? []
return Current.shell.downloadWithAria2Async(aria2Path, url, destination, cookies)
}
public func installFromImage(dmgURL: URL) async throws {
try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
}
func cancelRuntimeInstall(runtime: DownloadableRuntime) {
// Cancel the publisher
runtimePublishers[runtime.identifier]?.cancel()
runtimePublishers[runtime.identifier] = nil
// If the download is cancelled by the user, clean up the download files that aria2 creates.
guard let source = runtime.source else {
return
}
let url = URL(string: source)!
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
try? Current.files.removeItem(at: expectedRuntimePath.url)
try? Current.files.removeItem(at: aria2DownloadMetadataPath.url)
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .notInstalled
updateInstalledRuntimes()
}
func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? {
if let coreSimulatorInfo = coreSimulatorInfo(runtime: runtime) {
let urlString = coreSimulatorInfo.path["relative"]!
// app was not allowed to open up file:// url's so remove
let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "")
let url = URL(fileURLWithPath: fileRemovedString)
return Path(url: url)!
}
return nil
}
func coreSimulatorInfo(runtime: DownloadableRuntime) -> CoreSimulatorImage? {
return installedRuntimes.filter({
$0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate &&
((runtime.architectures ?? []).isEmpty ? true :
$0.runtimeInfo.supportedArchitectures == runtime.architectures )}).first
}
func deleteRuntime(runtime: DownloadableRuntime) async throws {
if let info = coreSimulatorInfo(runtime: runtime) {
try await runtimeService.deleteRuntime(identifier: info.uuid)
// give it some time to actually finish deleting before updating
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.updateInstalledRuntimes()
}
} else {
throw "No simulator found with \(runtime.identifier)"
}
}
}
extension AnyPublisher {
func async() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = first()
.sink { result in
switch result {
case .finished:
break
case let .failure(error):
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(with: .success(value))
}
}
}
}
extension AnyPublisher where Failure: Error {
struct Subscriber {
fileprivate let send: (Output) -> Void
fileprivate let complete: (Subscribers.Completion<Failure>) -> Void
func send(_ value: Output) { self.send(value) }
func send(completion: Subscribers.Completion<Failure>) { self.complete(completion) }
}
init(_ closure: (Subscriber) -> AnyCancellable) {
let subject = PassthroughSubject<Output, Failure>()
let subscriber = Subscriber(
send: subject.send,
complete: subject.send(completion:)
)
let cancel = closure(subscriber)
self = subject
.handleEvents(receiveCancel: cancel.cancel)
.eraseToAnyPublisher()
}
}
extension AnyPublisher where Failure == Error {
init(taskPriority: TaskPriority? = nil, asyncFunc: @escaping () async throws -> Output) {
self.init { subscriber in
let task = Task(priority: taskPriority) {
do {
subscriber.send(try await asyncFunc())
subscriber.send(completion: .finished)
} catch {
subscriber.send(completion: .failure(error))
}
}
return AnyCancellable { task.cancel() }
}
}
}

View file

@ -3,8 +3,8 @@ import Foundation
import Path import Path
import Version import Version
import SwiftSoup import SwiftSoup
import struct XCModel.Xcode
import AppleAPI import AppleAPI
import XcodesKit
extension AppState { extension AppState {
@ -36,8 +36,6 @@ extension AppState {
func update() { func update() {
guard !isUpdating else { return } guard !isUpdating else { return }
updateDownloadableRuntimes()
updateInstalledRuntimes()
updatePublisher = updateSelectedXcodePath() updatePublisher = updateSelectedXcodePath()
.flatMap { _ in .flatMap { _ in
self.updateAvailableXcodes(from: self.dataSource) self.updateAvailableXcodes(from: self.dataSource)
@ -49,7 +47,7 @@ extension AppState {
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
if error as? AuthenticationError != .invalidSession { if error as? AuthenticationError != .invalidSession {
self.error = error self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) self.presentedAlert = .generic(title: "Unable to update selected Xcode", message: error.legibleLocalizedDescription)
} }
case .finished: case .finished:
Current.defaults.setDate(Current.date(), forKey: "lastUpdated") Current.defaults.setDate(Current.date(), forKey: "lastUpdated")
@ -127,40 +125,20 @@ extension AppState {
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: Path.cacheFile.url) try data.write(to: Path.cacheFile.url)
} }
// MARK: Runtime Cache
func loadCacheDownloadableRuntimes() throws {
guard let data = Current.files.contents(atPath: Path.runtimeCacheFile.string) else { return }
let runtimes = try JSONDecoder().decode([DownloadableRuntime].self, from: data)
self.downloadableRuntimes = runtimes
}
func cacheDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws {
let data = try JSONEncoder().encode(runtimes)
try FileManager.default.createDirectory(at: Path.runtimeCacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: Path.runtimeCacheFile.url)
}
} }
extension AppState { extension AppState {
// MARK: - Apple // MARK: - Apple
private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Swift.Error> { private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Error> {
Current.network.dataTask(with: URLRequest.downloads) Current.network.dataTask(with: URLRequest.downloads)
.map(\.data) .map(\.data)
.decode(type: Downloads.self, decoder: configure(JSONDecoder()) { .decode(type: Downloads.self, decoder: configure(JSONDecoder()) {
$0.dateDecodingStrategy = .formatted(.downloadsDateModified) $0.dateDecodingStrategy = .formatted(.downloadsDateModified)
}) })
.tryMap { downloads -> [AvailableXcode] in .map { downloads -> [AvailableXcode] in
if downloads.hasError { let xcodes = downloads
throw AuthenticationError.invalidResult(resultString: downloads.resultsString) .downloads
}
guard let downloadList = downloads.downloads else {
throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError"))
}
let xcodes = downloadList
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil } .filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
.compactMap { download -> AvailableXcode? in .compactMap { download -> AvailableXcode? in
let urlPrefix = URL(string: "https://download.developer.apple.com/")! let urlPrefix = URL(string: "https://download.developer.apple.com/")!
@ -210,8 +188,8 @@ extension AppState {
private func xcodeReleases() -> AnyPublisher<[AvailableXcode], Error> { private func xcodeReleases() -> AnyPublisher<[AvailableXcode], Error> {
Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!))
.map(\.data) .map(\.data)
.decode(type: [XcodeRelease].self, decoder: JSONDecoder()) .decode(type: [XCModel.Xcode].self, decoder: JSONDecoder())
.map { xcReleasesXcodes in .map { xcReleasesXcodes in
let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in
guard guard
let downloadURL = xcReleasesXcode.links?.download?.url, let downloadURL = xcReleasesXcode.links?.download?.url,
@ -232,8 +210,7 @@ extension AppState {
requiredMacOSVersion: xcReleasesXcode.requires, requiredMacOSVersion: xcReleasesXcode.requires,
releaseNotesURL: xcReleasesXcode.links?.notes?.url, releaseNotesURL: xcReleasesXcode.links?.notes?.url,
sdks: xcReleasesXcode.sdks, sdks: xcReleasesXcode.sdks,
compilers: xcReleasesXcode.compilers, compilers: xcReleasesXcode.compilers
architectures: xcReleasesXcode.architectures
) )
} }
return xcodes return xcodes

View file

@ -7,33 +7,9 @@ import KeychainAccess
import Path import Path
import Version import Version
import os.log import os.log
import DockProgress
import XcodesKit
import LibFido2Swift
enum PreferenceKey: String {
case installPath
case localPath
case unxipExperiment
case createSymLinkOnSelect
case onSelectActionType
case showOpenInRosettaOption
case autoInstallation
case SUEnableAutomaticChecks
case includePrereleaseVersions
case downloader
case dataSource
case xcodeListCategory
case allowedMajorVersions
case hideSupportXcodes
case xcodeListArchitectures
func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) }
}
class AppState: ObservableObject { class AppState: ObservableObject {
private let client = AppleAPI.Client() private let client = AppleAPI.Client()
internal let runtimeService = RuntimeService()
// MARK: - Published Properties // MARK: - Published Properties
@ -41,11 +17,11 @@ class AppState: ObservableObject {
@Published var availableXcodes: [AvailableXcode] = [] { @Published var availableXcodes: [AvailableXcode] = [] {
willSet { willSet {
if newValue.count > availableXcodes.count && availableXcodes.count != 0 { if newValue.count > availableXcodes.count && availableXcodes.count != 0 {
Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) Current.notificationManager.scheduleNotification(title: "New Xcode versions", body: "New Xcode versions are available to download.", category: .normal)
} }
updateAllXcodes( updateAllXcodes(
availableXcodes: newValue, availableXcodes: newValue,
installedXcodes: Current.files.installedXcodes(Path.installDirectory), installedXcodes: Current.files.installedXcodes(Path.root/"Applications"),
selectedXcodePath: selectedXcodePath selectedXcodePath: selectedXcodePath
) )
} }
@ -58,7 +34,7 @@ class AppState: ObservableObject {
willSet { willSet {
updateAllXcodes( updateAllXcodes(
availableXcodes: availableXcodes, availableXcodes: availableXcodes,
installedXcodes: Current.files.installedXcodes(Path.installDirectory), installedXcodes: Current.files.installedXcodes(Path.root/"Applications"),
selectedXcodePath: newValue selectedXcodePath: newValue
) )
} }
@ -67,9 +43,9 @@ class AppState: ObservableObject {
var isUpdating: Bool { updatePublisher != nil } var isUpdating: Bool { updatePublisher != nil }
@Published var presentedSheet: XcodesSheet? = nil @Published var presentedSheet: XcodesSheet? = nil
@Published var isProcessingAuthRequest = false @Published var isProcessingAuthRequest = false
@Published var secondFactorData: SecondFactorData?
@Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var xcodeBeingConfirmedForUninstallation: Xcode?
@Published var presentedAlert: XcodesAlert? @Published var presentedAlert: XcodesAlert?
@Published var presentedPreferenceAlert: XcodesPreferencesAlert?
@Published var helperInstallState: HelperInstallState = .notInstalled @Published var helperInstallState: HelperInstallState = .notInstalled
/// Whether the user is being prepared for the helper installation alert with an explanation. /// Whether the user is being prepared for the helper installation alert with an explanation.
/// This closure will be performed after the user chooses whether or not to proceed. /// This closure will be performed after the user chooses whether or not to proceed.
@ -86,85 +62,14 @@ class AppState: ObservableObject {
Current.defaults.set(localPath, forKey: "localPath") Current.defaults.set(localPath, forKey: "localPath")
} }
} }
var disableLocalPathChange: Bool { PreferenceKey.localPath.isManaged() }
@Published var installPath = "" {
didSet {
Current.defaults.set(installPath, forKey: "installPath")
}
}
var disableInstallPathChange: Bool { PreferenceKey.installPath.isManaged() }
@Published var unxipExperiment = false {
didSet {
Current.defaults.set(unxipExperiment, forKey: "unxipExperiment")
}
}
var disableUnxipExperiment: Bool { PreferenceKey.unxipExperiment.isManaged() }
@Published var createSymLinkOnSelect = false {
didSet {
Current.defaults.set(createSymLinkOnSelect, forKey: "createSymLinkOnSelect")
}
}
var createSymLinkOnSelectDisabled: Bool {
return onSelectActionType == .rename || PreferenceKey.createSymLinkOnSelect.isManaged()
}
@Published var onSelectActionType = SelectedActionType.none {
didSet {
Current.defaults.set(onSelectActionType.rawValue, forKey: "onSelectActionType")
if onSelectActionType == .rename {
createSymLinkOnSelect = false
}
}
}
var onSelectActionTypeDisabled: Bool { PreferenceKey.onSelectActionType.isManaged() }
@Published var showOpenInRosettaOption = false {
didSet {
Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption")
}
}
@Published var terminateAfterLastWindowClosed = false {
didSet {
Current.defaults.set(terminateAfterLastWindowClosed, forKey: "terminateAfterLastWindowClosed")
}
}
// MARK: - Runtimes
@Published var downloadableRuntimes: [DownloadableRuntime] = []
@Published var installedRuntimes: [CoreSimulatorImage] = []
// MARK: - Publisher Cancellables // MARK: - Publisher Cancellables
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
private var installationPublishers: [XcodeID: AnyCancellable] = [:] private var installationPublishers: [Version: AnyCancellable] = [:]
internal var runtimePublishers: [String: Task<(), any Error>] = [:]
private var selectPublisher: AnyCancellable? private var selectPublisher: AnyCancellable?
private var uninstallPublisher: AnyCancellable? private var uninstallPublisher: AnyCancellable?
private var autoInstallTimer: Timer? private var autoInstallTimer: Timer?
// MARK: - Dock Progress Tracking
public static let totalProgressUnits = Int64(10)
public static let unxipProgressWeight = Int64(1)
var overallProgress = Progress()
var unxipProgress = {
let progress = Progress(totalUnitCount: totalProgressUnits)
progress.kind = .file
progress.fileOperationKind = .copying
return progress
}()
// MARK: - // MARK: -
var dataSource: DataSource { var dataSource: DataSource {
@ -179,23 +84,11 @@ class AppState: ObservableObject {
savedUsername != nil savedUsername != nil
} }
var bottomStatusBarMessage: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd/MM/yyyy"
let finishDate = formatter.date(from: "11/06/2022")
if Date().compare(finishDate!) == .orderedAscending {
return String(format: localizeString("WWDC.Message"), "2022")
}
return ""
}
// MARK: - Init // MARK: - Init
init() { init() {
guard !isTesting else { return } guard !isTesting else { return }
try? loadCachedAvailableXcodes() try? loadCachedAvailableXcodes()
try? loadCacheDownloadableRuntimes()
checkIfHelperIsInstalled() checkIfHelperIsInstalled()
setupAutoInstallTimer() setupAutoInstallTimer()
setupDefaults() setupDefaults()
@ -203,19 +96,13 @@ class AppState: ObservableObject {
func setupDefaults() { func setupDefaults() {
localPath = Current.defaults.string(forKey: "localPath") ?? Path.defaultXcodesApplicationSupport.string localPath = Current.defaults.string(forKey: "localPath") ?? Path.defaultXcodesApplicationSupport.string
unxipExperiment = Current.defaults.bool(forKey: "unxipExperiment") ?? false
createSymLinkOnSelect = Current.defaults.bool(forKey: "createSymLinkOnSelect") ?? false
onSelectActionType = SelectedActionType(rawValue: Current.defaults.string(forKey: "onSelectActionType") ?? "none") ?? .none
installPath = Current.defaults.string(forKey: "installPath") ?? Path.defaultInstallDirectory.string
showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false
terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false
} }
// MARK: Timer // MARK: Timer
/// Runs a timer every 6 hours when app is open to check if it needs to auto install any xcodes /// Runs a timer every 6 hours when app is open to check if it needs to auto install any xcodes
func setupAutoInstallTimer() { func setupAutoInstallTimer() {
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } guard let storageValue = UserDefaults.standard.object(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
if autoInstallType == .none { return } if autoInstallType == .none { return }
autoInstallTimer = Timer.scheduledTimer(withTimeInterval: 60*60*6, repeats: true) { [weak self] _ in autoInstallTimer = Timer.scheduledTimer(withTimeInterval: 60*60*6, repeats: true) { [weak self] _ in
@ -224,28 +111,7 @@ class AppState: ObservableObject {
} }
// MARK: - Authentication // MARK: - Authentication
func validateADCSession(path: String) -> AnyPublisher<Void, Error> {
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path))
.receive(on: DispatchQueue.main)
.tryMap { result -> Void in
let httpResponse = result.response as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
}
.eraseToAnyPublisher()
}
func validateADCSession(path: String) async throws {
let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path))
let httpResponse = result.1 as! HTTPURLResponse
if httpResponse.statusCode == 401 {
throw AuthenticationError.notAuthorized
}
}
func validateSession() -> AnyPublisher<Void, Error> { func validateSession() -> AnyPublisher<Void, Error> {
return Current.network.validateSession() return Current.network.validateSession()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.handleEvents(receiveCompletion: { completion in .handleEvents(receiveCompletion: { completion in
@ -278,7 +144,7 @@ class AppState: ObservableObject {
func signIn(username: String, password: String) { func signIn(username: String, password: String) {
authError = nil authError = nil
signIn(username: username.lowercased(), password: password) signIn(username: username, password: password)
.sink( .sink(
receiveCompletion: { _ in }, receiveCompletion: { _ in },
receiveValue: { _ in } receiveValue: { _ in }
@ -291,7 +157,7 @@ class AppState: ObservableObject {
Current.defaults.set(username, forKey: "username") Current.defaults.set(username, forKey: "username")
isProcessingAuthRequest = true isProcessingAuthRequest = true
return client.srpLogin(accountName: username, password: password) return client.login(accountName: username, password: password)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.handleEvents( .handleEvents(
receiveOutput: { authenticationState in receiveOutput: { authenticationState in
@ -306,17 +172,12 @@ class AppState: ObservableObject {
} }
func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) { func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) {
let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) self.secondFactorData = SecondFactorData(
option: option,
if option == .securityKey, fido2DeviceIsPresent() && !fido2DeviceNeedsPin() { authOptions: authOptions,
createAndSubmitSecurityKeyAssertationWithPinCode(nil, sessionData: sessionData, authOptions: authOptions) sessionData: AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
} else { )
self.presentedSheet = .twoFactor(.init( self.presentedSheet = .twoFactor
option: option,
authOptions: authOptions,
sessionData: sessionData
))
}
} }
func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
@ -339,11 +200,7 @@ class AppState: ObservableObject {
} }
func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
self.presentedSheet = .twoFactor(.init( secondFactorData = SecondFactorData(option: .smsPendingChoice, authOptions: authOptions, sessionData: sessionData)
option: .smsPendingChoice,
authOptions: authOptions,
sessionData: sessionData
))
} }
func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) { func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) {
@ -362,83 +219,6 @@ class AppState: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
private lazy var fido2 = FIDO2()
func createAndSubmitSecurityKeyAssertationWithPinCode(_ pinCode: String?, sessionData: AppleSessionData, authOptions: AuthOptionsResponse) {
self.presentedSheet = .securityKeyTouchToConfirm
guard let fsaChallenge = authOptions.fsaChallenge else {
// This shouldn't happen
// we shouldn't have called this method without setting the fsaChallenge
// so this is an assertionFailure
assertionFailure()
self.authError = "Something went wrong. Please file a bug report"
return
}
// The challenge is encoded in Base64URL encoding
let challengeUrl = fsaChallenge.challenge
let challenge = FIDO2.base64urlToBase64(base64url: challengeUrl)
let origin = "https://idmsa.apple.com"
let rpId = "apple.com"
// Allowed creds is sent as a comma separated string
let validCreds = fsaChallenge.allowedCredentials.split(separator: ",").map(String.init)
Task {
do {
let response = try fido2.respondToChallenge(args: ChallengeArgs(rpId: rpId, validCredentials: validCreds, devPin: pinCode, challenge: challenge, origin: origin))
Task { @MainActor in
self.isProcessingAuthRequest = true
}
let respData = try JSONEncoder().encode(response)
client.submitChallenge(response: respData, sessionData: AppleSessionData(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt))
.receive(on: DispatchQueue.main)
.handleEvents(
receiveOutput: { authenticationState in
self.authenticationState = authenticationState
},
receiveCompletion: { completion in
self.handleAuthenticationFlowCompletion(completion)
self.isProcessingAuthRequest = false
}
).sink(
receiveCompletion: { _ in },
receiveValue: { _ in }
).store(in: &cancellables)
} catch FIDO2Error.canceledByUser {
// User cancelled the auth flow
// we don't have to show an error
// because the sheet will already be dismissed
} catch {
Task { @MainActor in
authError = error
}
}
}
}
func fido2DeviceIsPresent() -> Bool {
fido2.hasDeviceAttached()
}
func fido2DeviceNeedsPin() -> Bool {
do {
return try fido2.deviceHasPin()
} catch {
Task { @MainActor in
authError = error
}
return true
}
}
func cancelSecurityKeyAssertationRequest() {
self.fido2.cancel()
}
private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion<Error>) { private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion<Error>) {
switch completion { switch completion {
case let .failure(error): case let .failure(error):
@ -450,6 +230,7 @@ class AppState: ObservableObject {
switch self.authenticationState { switch self.authenticationState {
case .authenticated, .unauthenticated, .notAppleDeveloper: case .authenticated, .unauthenticated, .notAppleDeveloper:
self.presentedSheet = nil self.presentedSheet = nil
self.secondFactorData = nil
case let .waitingForSecondFactor(option, authOptions, sessionData): case let .waitingForSecondFactor(option, authOptions, sessionData):
self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
} }
@ -489,7 +270,7 @@ class AppState: ObservableObject {
receiveCompletion: { [unowned self] completion in receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion { if case let .failure(error) = completion {
self.error = error self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.PrivilegedHelper.Error.Title"), message: error.legibleLocalizedDescription) self.presentedAlert = .generic(title: "Unable to install helper", message: error.legibleLocalizedDescription)
} }
}, },
receiveValue: {} receiveValue: {}
@ -524,42 +305,10 @@ class AppState: ObservableObject {
// MARK: - Install // MARK: - Install
func checkMinVersionAndInstall(id: XcodeID) { func install(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
// Check to see if users macOS is supported
if let requiredMacOSVersion = availableXcode.requiredMacOSVersion {
if hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) {
// prompt
self.presentedAlert = .checkMinSupportedVersion(xcode: availableXcode, macOS: ProcessInfo.processInfo.operatingSystemVersion.versionString())
return
}
}
switch self.dataSource {
case .apple:
install(id: id)
case .xcodeReleases:
install(id: id)
}
}
func hasMinSupportedOS(requiredMacOSVersion: String) -> Bool {
let split = requiredMacOSVersion.components(separatedBy: ".").compactMap { Int($0) }
let xcodeMinimumMacOSVersion = OperatingSystemVersion(majorVersion: split[safe: 0] ?? 0, minorVersion: split[safe: 1] ?? 0, patchVersion: split[safe: 2] ?? 0)
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(xcodeMinimumMacOSVersion)
}
func install(id: XcodeID) {
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
installationPublishers[id] = signInIfNeeded() installationPublishers[id] = signInIfNeeded()
.handleEvents(
receiveSubscription: { [unowned self] _ in
self.setInstallationStep(of: availableXcode.version, to: .authenticating)
}
)
.flatMap { [unowned self] in .flatMap { [unowned self] in
// signInIfNeeded might finish before the user actually authenticates if UI is involved. // signInIfNeeded might finish before the user actually authenticates if UI is involved.
// This publisher will wait for the @Published authentication state to change to authenticated or unauthenticated before finishing, // This publisher will wait for the @Published authentication state to change to authenticated or unauthenticated before finishing,
@ -588,22 +337,12 @@ class AppState: ObservableObject {
// We need the cookies from its response in order to download Xcodes though, // We need the cookies from its response in order to download Xcodes though,
// so perform it here first just to be sure. // so perform it here first just to be sure.
Current.network.dataTask(with: URLRequest.downloads) Current.network.dataTask(with: URLRequest.downloads)
.map(\.data) .receive(on: DispatchQueue.main)
.decode(type: Downloads.self, decoder: configure(JSONDecoder()) { .map { _ in Void() }
$0.dateDecodingStrategy = .formatted(.downloadsDateModified)
})
.tryMap { downloads -> Void in
if downloads.hasError {
throw AuthenticationError.invalidResult(resultString: downloads.resultsString)
}
if downloads.downloads == nil {
throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError"))
}
}
.mapError { $0 as Error } .mapError { $0 as Error }
} }
.flatMap { [unowned self] in .flatMap { [unowned self] in
self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2) self.install(.version(availableXcode), downloader: Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2)
} }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink( .sink(
@ -611,13 +350,9 @@ class AppState: ObservableObject {
self.installationPublishers[id] = nil self.installationPublishers[id] = nil
if case let .failure(error) = completion { if case let .failure(error) = completion {
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
if let error = error as? AuthenticationError, case .notAuthorized = error { if error as? AuthenticationError != .invalidSession {
self.error = error self.error = error
self.presentedAlert = .unauthenticated self.presentedAlert = .generic(title: "Unable to install Xcode", message: error.legibleLocalizedDescription)
} else if error as? AuthenticationError != .invalidSession {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
} }
if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
self.allXcodes[index].installState = .notInstalled self.allXcodes[index].installState = .notInstalled
@ -628,38 +363,11 @@ class AppState: ObservableObject {
) )
} }
/// Skips using the username/password to log in to Apple, and simply gets a Auth Cookie used in downloading
/// As of Nov 2022 this was returning a 403 forbidden
func installWithoutLogin(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
installationPublishers[id] = self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [unowned self] completion in
self.installationPublishers[id] = nil
if case let .failure(error) = completion {
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
if error as? AuthenticationError != .invalidSession {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
}
if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
self.allXcodes[index].installState = .notInstalled
}
}
},
receiveValue: { _ in }
)
}
func cancelInstall(id: Xcode.ID) { func cancelInstall(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
// Cancel the publisher // Cancel the publisher
installationPublishers[id] = nil installationPublishers[id] = nil
resetDockProgressTracking()
// If the download is cancelled by the user, clean up the download files that aria2 creates. // If the download is cancelled by the user, clean up the download files that aria2 creates.
// This isn't done as part of the publisher with handleEvents(receiveCancel:) because it shouldn't happen when e.g. the app quits. // This isn't done as part of the publisher with handleEvents(receiveCancel:) because it shouldn't happen when e.g. the app quits.
@ -688,7 +396,7 @@ class AppState: ObservableObject {
receiveCompletion: { [unowned self] completion in receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion { if case let .failure(error) = completion {
self.error = error self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) self.presentedAlert = .generic(title: "Unable to uninstall Xcode", message: error.legibleLocalizedDescription)
} }
self.uninstallPublisher = nil self.uninstallPublisher = nil
}, },
@ -696,10 +404,10 @@ class AppState: ObservableObject {
) )
} }
func reveal(_ path: Path?) { func reveal(xcode: Xcode) {
// TODO: show error if not // TODO: show error if not
guard let path = path else { return } guard let installedXcodePath = xcode.installedPath else { return }
NSWorkspace.shared.activateFileViewerSelecting([path.url]) NSWorkspace.shared.activateFileViewerSelecting([installedXcodePath.url])
} }
func reveal(path: String) { func reveal(path: String) {
@ -730,15 +438,10 @@ class AppState: ObservableObject {
} }
guard guard
var installedXcodePath = xcode.installedPath, let installedXcodePath = xcode.installedPath,
selectPublisher == nil selectPublisher == nil
else { return } else { return }
if onSelectActionType == .rename {
guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return }
installedXcodePath = newDestinationXcodePath
}
selectPublisher = installHelperIfNecessary() selectPublisher = installHelperIfNecessary()
.flatMap { .flatMap {
Current.helper.switchXcodePath(installedXcodePath.string) Current.helper.switchXcodePath(installedXcodePath.string)
@ -750,11 +453,7 @@ class AppState: ObservableObject {
receiveCompletion: { [unowned self] completion in receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion { if case let .failure(error) = completion {
self.error = error self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription) self.presentedAlert = .generic(title: "Unable to select Xcode", message: error.legibleLocalizedDescription)
} else {
if self.createSymLinkOnSelect {
createSymbolicLink(xcode: xcode)
}
} }
self.selectPublisher = nil self.selectPublisher = nil
}, },
@ -762,18 +461,13 @@ class AppState: ObservableObject {
) )
} }
func open(xcode: Xcode, openInRosetta: Bool? = false) { func open(xcode: Xcode) {
switch xcode.installState { switch xcode.installState {
case let .installed(path): case let .installed(path):
let config = NSWorkspace.OpenConfiguration.init() NSWorkspace.shared.openApplication(at: path.url, configuration: .init())
if (openInRosetta ?? false) { default:
config.architecture = CPU_TYPE_X86_64 Logger.appState.error("\(xcode.id) is not installed")
} return
config.allowsRunningApplicationSubstitution = false
NSWorkspace.shared.openApplication(at: path.url, configuration: config)
default:
Logger.appState.error("\(xcode.id.version) is not installed")
return
} }
} }
@ -785,79 +479,6 @@ class AppState: ObservableObject {
NSPasteboard.general.setString(installedXcodePath.string, forType: .string) NSPasteboard.general.setString(installedXcodePath.string, forType: .string)
} }
func copyReleaseNote(from url: URL?) {
guard let url = url else { return }
NSPasteboard.general.declareTypes([.URL, .string], owner: nil)
NSPasteboard.general.writeObjects([url as NSURL])
NSPasteboard.general.setString(url.absoluteString, forType: .string)
}
func createSymbolicLink(xcode: Xcode, isBeta: Bool = false) {
guard let installedXcodePath = xcode.installedPath else { return }
let destinationPath: Path = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app"
// does an Xcode.app file exist?
if FileManager.default.fileExists(atPath: destinationPath.string) {
do {
// if it's not a symlink, error because we don't want to delete an actual xcode.app file
let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destinationPath.string)
if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink {
try FileManager.default.removeItem(atPath: destinationPath.string)
Logger.appState.info("Successfully deleted old symlink")
} else {
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: localizeString("Alert.SymLink.Message"))
return
}
} catch {
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.localizedDescription)
}
}
do {
try FileManager.default.createSymbolicLink(atPath: destinationPath.string, withDestinationPath: installedXcodePath.string)
Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app")
} catch {
Logger.appState.error("Unable to create symbolic Link")
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription)
}
}
func renameToXcode(xcode: Xcode) -> Path? {
guard let installedXcodePath = xcode.installedPath else { return nil }
let destinationPath: Path = Path.installDirectory/"Xcode.app"
// rename any old named `Xcode.app` to the Xcodes versioned named files
if FileManager.default.fileExists(atPath: destinationPath.string) {
if let originalXcode = Current.files.installedXcode(destination: destinationPath) {
let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app"
Logger.appState.debug("Found Xcode.app - renaming back to \(newName)")
do {
try destinationPath.rename(to: newName)
} catch {
Logger.appState.error("Unable to create rename Xcode.app back to original")
self.error = error
// TODO UPDATE MY ERROR STRING
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription)
}
}
}
// rename passed in xcode to xcode.app
Logger.appState.debug("Found Xcode.app - renaming back to Xcode.app")
do {
return try installedXcodePath.rename(to: "Xcode.app")
} catch {
Logger.appState.error("Unable to create rename Xcode.app back to original")
self.error = error
// TODO UPDATE MY ERROR STRING
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription)
}
return nil
}
func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) { func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) {
var adjustedAvailableXcodes = availableXcodes var adjustedAvailableXcodes = availableXcodes
@ -868,7 +489,7 @@ class AppState: ObservableObject {
// If build metadata matches exactly, replace the available version with the installed version. // If build metadata matches exactly, replace the available version with the installed version.
// This should handle Apple versions from /downloads/more which don't have build metadata identifiers. // This should handle Apple versions from /downloads/more which don't have build metadata identifiers.
if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) {
adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID adjustedAvailableXcodes[index].version = installedXcode.version
} }
// If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version
// Not all prerelease Apple versions available online include build metadata // Not all prerelease Apple versions available online include build metadata
@ -876,7 +497,7 @@ class AppState: ObservableObject {
availableXcode.version.isEquivalent(to: installedXcode.version) && availableXcode.version.isEquivalent(to: installedXcode.version) &&
availableXcode.version.buildMetadataIdentifiers.isEmpty availableXcode.version.buildMetadataIdentifiers.isEmpty
}) { }) {
adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID adjustedAvailableXcodes[index].version = installedXcode.version
} }
} }
} }
@ -893,21 +514,14 @@ class AppState: ObservableObject {
// Include this version if there's only one with this build identifier // Include this version if there's only one with this build identifier
return availableXcodesWithIdenticalBuildIdentifiers.count == 1 || return availableXcodesWithIdenticalBuildIdentifiers.count == 1 ||
// Or if there's more than one with this build identifier and this is the release version // Or if there's more than one with this build identifier and this is the release version
availableXcodesWithIdenticalBuildIdentifiers.count > 1 && availableXcode.version.prereleaseIdentifiers.isEmpty
availableXcodesWithIdenticalBuildIdentifiers.count > 1 && (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.count ?? 0 != 0) }
}
.map { availableXcode -> Xcode in .map { availableXcode -> Xcode in
let installedXcode = installedXcodes.first(where: { installedXcode in let installedXcode = installedXcodes.first(where: { installedXcode in
// if we want to have only specific Xcodes as selected instead of the Architecture Equivalent. availableXcode.version.isEquivalent(to: installedXcode.version)
// if availableXcode.architectures == nil {
// return availableXcode.version.isEquivalent(to: installedXcode.version)
// } else {
// return availableXcode.xcodeID == installedXcode.xcodeID
// }
return availableXcode.version.isEquivalent(to: installedXcode.version)
}) })
let identicalBuilds: [XcodeID] let identicalBuilds: [Version]
let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes
.filter { .filter {
return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers && return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers &&
@ -917,13 +531,13 @@ class AppState: ObservableObject {
} }
// If this is the release version, add the identical builds to it // If this is the release version, add the identical builds to it
if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty { if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty {
identicalBuilds = [availableXcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID) identicalBuilds = [availableXcode.version] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.version)
} else { } else {
identicalBuilds = [] identicalBuilds = []
} }
// If the existing install state is "installing", keep it // If the existing install state is "installing", keep it
let existingXcodeInstallState = allXcodes.first { $0.id == availableXcode.xcodeID && $0.installState.installing }?.installState let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installState.installing }?.installState
// Otherwise, determine it from whether there's an installed Xcode // Otherwise, determine it from whether there's an installed Xcode
let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled
@ -938,8 +552,7 @@ class AppState: ObservableObject {
releaseDate: availableXcode.releaseDate, releaseDate: availableXcode.releaseDate,
sdks: availableXcode.sdks, sdks: availableXcode.sdks,
compilers: availableXcode.compilers, compilers: availableXcode.compilers,
downloadFileSize: availableXcode.fileSize, downloadFileSize: availableXcode.fileSize
architectures: availableXcode.architectures
) )
} }
@ -961,7 +574,6 @@ class AppState: ObservableObject {
self.allXcodes = newAllXcodes.sorted { $0.version > $1.version } self.allXcodes = newAllXcodes.sorted { $0.version > $1.version }
} }
// MARK: - Private // MARK: - Private
@ -995,10 +607,10 @@ class AppState: ObservableObject {
var message: String var message: String
var id: String { title + message } var id: String { title + message }
} }
}
extension OperatingSystemVersion { struct SecondFactorData {
func versionString() -> String { let option: TwoFactorOption
return String(majorVersion) + "." + String(minorVersion) + "." + String(patchVersion) let authOptions: AuthOptionsResponse
let sessionData: AppleSessionData
} }
} }

View file

@ -1,12 +1,11 @@
import Foundation import Foundation
import Version import Version
import XcodesKit import struct XCModel.SDKs
import struct XCModel.Compilers
/// A version of Xcode that's available for installation /// A version of Xcode that's available for installation
public struct AvailableXcode: Codable { public struct AvailableXcode: Codable {
public var version: Version { public var version: Version
return xcodeID.version
}
public let url: URL public let url: URL
public let filename: String public let filename: String
public let releaseDate: Date? public let releaseDate: Date?
@ -15,11 +14,6 @@ public struct AvailableXcode: Codable {
public let sdks: SDKs? public let sdks: SDKs?
public let compilers: Compilers? public let compilers: Compilers?
public let fileSize: Int64? public let fileSize: Int64?
public let architectures: [Architecture]?
public var downloadPath: String {
return url.path
}
public var xcodeID: XcodeID
public init( public init(
version: Version, version: Version,
@ -30,9 +24,9 @@ public struct AvailableXcode: Codable {
releaseNotesURL: URL? = nil, releaseNotesURL: URL? = nil,
sdks: SDKs? = nil, sdks: SDKs? = nil,
compilers: Compilers? = nil, compilers: Compilers? = nil,
fileSize: Int64? = nil, fileSize: Int64? = nil
architectures: [Architecture]? = nil
) { ) {
self.version = version
self.url = url self.url = url
self.filename = filename self.filename = filename
self.releaseDate = releaseDate self.releaseDate = releaseDate
@ -41,7 +35,5 @@ public struct AvailableXcode: Codable {
self.sdks = sdks self.sdks = sdks
self.compilers = compilers self.compilers = compilers
self.fileSize = fileSize self.fileSize = fileSize
self.architectures = architectures
self.xcodeID = XcodeID(version: version, architectures: architectures)
} }
} }

View file

@ -1,17 +0,0 @@
//
// Collection+.swift
// Xcodes
//
// Created by Matt Kiazyk on 2022-04-11.
// Copyright © 2022 Robots and Pencils. 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
}
}

View file

@ -14,6 +14,4 @@ public enum DataSource: String, CaseIterable, Identifiable, CustomStringConverti
case .xcodeReleases: return "Xcode Releases" case .xcodeReleases: return "Xcode Releases"
} }
} }
var isManaged: Bool { PreferenceKey.dataSource.isManaged() }
} }

View file

@ -5,8 +5,6 @@ extension DateFormatter {
static let downloadsDateModified: DateFormatter = { static let downloadsDateModified: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yy HH:mm" formatter.dateFormat = "MM/dd/yy HH:mm"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = .init(identifier: .iso8601)
return formatter return formatter
}() }()

View file

@ -13,6 +13,4 @@ public enum Downloader: String, CaseIterable, Identifiable, CustomStringConverti
case .aria2: return "aria2" case .aria2: return "aria2"
} }
} }
var isManaged: Bool { PreferenceKey.downloader.isManaged() }
} }

View file

@ -3,13 +3,7 @@ import Path
import Version import Version
struct Downloads: Codable { struct Downloads: Codable {
let resultCode: Int let downloads: [Download]
let resultsString: String?
let downloads: [Download]?
var hasError: Bool {
return resultCode != 0
}
} }
// Set to Int64 as ByteCountFormatter uses it. // Set to Int64 as ByteCountFormatter uses it.

View file

@ -1,13 +1,14 @@
import Foundation import Foundation
import Path import Path
extension Path { extension Entry {
static func isAppBundle(path: Path) -> Bool { var isAppBundle: Bool {
path.isDirectory && kind == .directory &&
path.extension == "app" && path.extension == "app" &&
!path.isSymlink !path.isSymlink
} }
static func infoPlist(path: Path) -> InfoPlist? {
var infoPlist: InfoPlist? {
let infoPlistPath = path.join("Contents").join("Info.plist") let infoPlistPath = path.join("Contents").join("Info.plist")
guard guard
let infoPlistData = try? Data(contentsOf: infoPlistPath.url), let infoPlistData = try? Data(contentsOf: infoPlistPath.url),
@ -16,12 +17,4 @@ extension Path {
return infoPlist return infoPlist
} }
var isAppBundle: Bool {
Path.isAppBundle(path: self)
}
var infoPlist: InfoPlist? {
Path.infoPlist(path: self)
}
} }

View file

@ -3,7 +3,7 @@ import Foundation
import Path import Path
import AppleAPI import AppleAPI
import KeychainAccess import KeychainAccess
import XcodesKit
/** /**
Lightweight dependency injection using global mutable state :P Lightweight dependency injection using global mutable state :P
@ -43,7 +43,7 @@ public struct Shell {
"--max-connection-per-server=16", "--max-connection-per-server=16",
"--split=16", "--split=16",
"--summary-interval=1", "--summary-interval=1",
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)",
"--dir=\(destination.parent.string)", "--dir=\(destination.parent.string)",
"--out=\(destination.basename())", "--out=\(destination.basename())",
"--human-readable=false", // sets the output to use bytes instead of formatting "--human-readable=false", // sets the output to use bytes instead of formatting
@ -111,169 +111,6 @@ public struct Shell {
return (progress, publisher) return (progress, publisher)
} }
public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream<Progress, Error> = { aria2Path, url, destination, cookies in
return AsyncThrowingStream<Progress, Error> { continuation in
Task {
// Assume progress will not have data races, so we manually opt-out isolation checks.
nonisolated(unsafe) var progress = Progress()
progress.kind = .file
progress.fileOperationKind = .downloading
let process = Process()
process.executableURL = aria2Path.url
process.arguments = [
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
"--max-connection-per-server=16",
"--split=16",
"--summary-interval=1",
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process
"--dir=\(destination.parent.string)",
"--out=\(destination.basename())",
"--human-readable=false", // sets the output to use bytes instead of formatting
url.absoluteString,
]
let stdOutPipe = Pipe()
process.standardOutput = stdOutPipe
let stdErrPipe = Pipe()
process.standardError = stdErrPipe
let observer = NotificationCenter.default.addObserver(
forName: .NSFileHandleDataAvailable,
object: nil,
queue: OperationQueue.main
) { note in
guard
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
let handle = note.object as? FileHandle,
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
else { return }
defer { handle.waitForDataInBackgroundAndNotify() }
let string = String(decoding: handle.availableData, as: UTF8.self)
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
progress.updateFromAria2(string: string)
continuation.yield(progress)
}
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
continuation.onTermination = { @Sendable _ in
process.terminate()
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
}
do {
try process.run()
} catch {
continuation.finish(throwing: error)
}
process.waitUntilExit()
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) {
continuation.finish(throwing: aria2cError)
} else {
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
}
return
}
continuation.finish()
}
}
}
public var unxipExperiment: (URL) -> AnyPublisher<ProcessOutput, Error> = { url in
let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)!
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"])
}
public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream<Progress, Error> = { platform, version, architecture in
return AsyncThrowingStream<Progress, Error> { continuation in
Task {
// Assume progress will not have data races, so we manually opt-out isolation checks.
nonisolated(unsafe) var progress = Progress()
progress.kind = .file
progress.fileOperationKind = .downloading
var process = Process()
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url
process.executableURL = xcodeBuildPath
process.arguments = [
"-downloadPlatform",
"\(platform)",
"-buildVersion",
"\(version)"
]
if let architecture {
process.arguments?.append(contentsOf: [
"-architectureVariant",
"\(architecture)"
])
}
let stdOutPipe = Pipe()
process.standardOutput = stdOutPipe
let stdErrPipe = Pipe()
process.standardError = stdErrPipe
let observer = NotificationCenter.default.addObserver(
forName: .NSFileHandleDataAvailable,
object: nil,
queue: OperationQueue.main
) { note in
guard
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
let handle = note.object as? FileHandle,
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
else { return }
defer { handle.waitForDataInBackgroundAndNotify() }
let string = String(decoding: handle.availableData, as: UTF8.self)
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
progress.updateFromXcodebuild(text: string)
continuation.yield(progress)
}
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
continuation.onTermination = { @Sendable _ in
process.terminate()
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
}
do {
try process.run()
} catch {
continuation.finish(throwing: error)
}
process.waitUntilExit()
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
return
}
continuation.finish()
}
}
}
} }
public struct Files { public struct Files {
@ -321,26 +158,11 @@ public struct Files {
} }
public var installedXcodes = _installedXcodes public var installedXcodes = _installedXcodes
public func installedXcode(destination: Path) -> InstalledXcode? {
if Path.isAppBundle(path: destination) && Path.infoPlist(path: destination)?.bundleID == "com.apple.dt.Xcode" {
return InstalledXcode.init(path: destination)
} else {
return nil
}
}
public var write: (Data, URL) throws -> Void = { try $0.write(to: $1) }
public func write(_ data: Data, to url: URL) throws {
try write(data, url)
}
} }
private func _installedXcodes(destination: Path) -> [InstalledXcode] { private func _installedXcodes(destination: Path) -> [InstalledXcode] {
destination.ls() ((try? destination.ls()) ?? [])
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
.map { $0 } .map { $0.path }
.compactMap(InstalledXcode.init) .compactMap(InstalledXcode.init)
} }
@ -352,15 +174,10 @@ public struct Network {
.mapError { $0 as Error } .mapError { $0 as Error }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
public func dataTask(with request: URLRequest) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Error> { public func dataTask(with request: URLRequest) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Error> {
dataTask(request) dataTask(request)
} }
public func dataTaskAsync(with request: URLRequest) async throws -> (Data, URLResponse) {
return try await AppleAPI.Current.network.session.data(for: request)
}
public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) }
public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) {
@ -421,11 +238,6 @@ public struct Defaults {
public func get(forKey key: String) -> Any? { public func get(forKey key: String) -> Any? {
get(key) get(key)
} }
public var bool: (String) -> Bool? = { UserDefaults.standard.bool(forKey: $0) }
public func bool(forKey key: String) -> Bool? {
bool(key)
}
} }
private let helperClient = HelperClient() private let helperClient = HelperClient()

View file

@ -1,23 +0,0 @@
//
// FileError.swift
// Xcodes
//
// Created by Leon Wolf on 06.10.22.
// Copyright © 2022 Robots and Pencils. All rights reserved.
//
import Foundation
import LegibleError
enum FileError: LocalizedError{
case fileNotFound(_ fileName: String)
}
extension FileError {
var errorDescription: String? {
switch self {
case .fileNotFound(let fileName):
return String(format: localizeString("Alert.Uninstall.Error.Message.FileNotFound"), fileName)
}
}
}

View file

@ -11,11 +11,7 @@ extension FileManager {
@discardableResult @discardableResult
func trashItem(at url: URL) throws -> URL { func trashItem(at url: URL) throws -> URL {
var resultingItemURL: NSURL! var resultingItemURL: NSURL!
if fileExists(atPath: url.path) { try trashItem(at: url, resultingItemURL: &resultingItemURL)
try trashItem(at: url, resultingItemURL: &resultingItemURL)
} else {
throw FileError.fileNotFound(url.lastPathComponent)
}
return resultingItemURL as URL return resultingItemURL as URL
} }
} }

View file

@ -1,28 +0,0 @@
import Foundation
struct Hardware {
///
/// Determines the architecture of the Mac on which we're running. Returns `arm64` for Apple Silicon
/// and `x86_64` for Intel-based Macs or `nil` if the system call fails.
static func getMachineHardwareName() -> String?
{
var sysInfo = utsname()
let retVal = uname(&sysInfo)
var finalString: String? = nil
if retVal == EXIT_SUCCESS
{
let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN))
finalString = String(data: bytes, encoding: .utf8)
}
// _SYS_NAMELEN will include a billion null-terminators. Clear those out so string comparisons work as you expect.
return finalString?.trimmingCharacters(in: CharacterSet(charactersIn: "\0"))
}
static func isAppleSilicon() -> Bool {
return Hardware.getMachineHardwareName() == "arm64"
}
}

View file

@ -361,7 +361,7 @@ enum HelperClientError: LocalizedError {
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .failedToCreateRemoteObjectProxy: case .failedToCreateRemoteObjectProxy:
return localizeString("HelperClient.error") return "Unable to communicate with privileged helper."
case let .message(message): case let .message(message):
return message return message
} }

View file

@ -0,0 +1,45 @@
import Foundation
/// A numbered step
enum InstallationStep: Equatable, CustomStringConvertible {
case downloading(progress: Progress)
case unarchiving
case moving(destination: String)
case trashingArchive
case checkingSecurity
case finishing
var description: String {
"(\(stepNumber)/\(stepCount)) \(message)"
}
var message: String {
switch self {
case .downloading:
return "Downloading"
case .unarchiving:
return "Unarchiving (This can take a while)"
case .moving(let destination):
return "Moving to \(destination)"
case .trashingArchive:
return "Moving archive to the Trash"
case .checkingSecurity:
return "Security verification"
case .finishing:
return "Finishing"
}
}
var stepNumber: Int {
switch self {
case .downloading: return 1
case .unarchiving: return 2
case .moving: return 3
case .trashingArchive: return 4
case .checkingSecurity: return 5
case .finishing: return 6
}
}
var stepCount: Int { 6 }
}

View file

@ -1,17 +1,12 @@
import Foundation import Foundation
import Version import Version
import Path import Path
import XcodesKit
/// A version of Xcode that's already installed /// A version of Xcode that's already installed
public struct InstalledXcode: Equatable { public struct InstalledXcode: Equatable {
public let path: Path public let path: Path
public let xcodeID: XcodeID
/// Composed of the bundle short version from Info.plist and the product build version from version.plist /// Composed of the bundle short version from Info.plist and the product build version from version.plist
public var version: Version { public let version: Version
return xcodeID.version
}
public init?(path: Path) { public init?(path: Path) {
self.path = path self.path = path
@ -36,21 +31,12 @@ public struct InstalledXcode: Equatable {
else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") { else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") {
prereleaseIdentifiers = ["beta"] prereleaseIdentifiers = ["beta"]
} }
let archsString = try? XcodesKit.Current.shell.archs(path.url.appending(path: "Contents/MacOS/Xcode")).out self.version = Version(major: bundleVersion.major,
let architectures = archsString?
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: " ")
.compactMap { Architecture(rawValue: String($0)) }
let version = Version(major: bundleVersion.major,
minor: bundleVersion.minor, minor: bundleVersion.minor,
patch: bundleVersion.patch, patch: bundleVersion.patch,
prereleaseIdentifiers: prereleaseIdentifiers, prereleaseIdentifiers: prereleaseIdentifiers,
buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 }) buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 })
self.xcodeID = XcodeID(version: version, architectures: architectures)
} }
} }

View file

@ -0,0 +1,10 @@
import Foundation
import os.log
extension Logger {
private static var subsystem = Bundle.main.bundleIdentifier!
static let appState = Logger(subsystem: subsystem, category: "appState")
static let helperClient = Logger(subsystem: subsystem, category: "helperClient")
static let subprocess = Logger(subsystem: subsystem, category: "subprocess")
}

View file

@ -22,9 +22,9 @@ public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomSt
public var description: String { public var description: String {
switch self { switch self {
case .newVersionAvailable: case .newVersionAvailable:
return localizeString("Notification.NewVersionAvailable") return "New version is available"
case .finishedInstalling: case .finishedInstalling:
return localizeString("Notification.FinishedInstalling") return "Finished installing"
} }
} }
} }

View file

@ -16,31 +16,4 @@ extension Path {
static var cacheFile: Path { static var cacheFile: Path {
return xcodesApplicationSupport/"available-xcodes.json" return xcodesApplicationSupport/"available-xcodes.json"
} }
static let defaultInstallDirectory = Path.root/"Applications"
static var installDirectory: Path {
guard let savedInstallDirectory = Current.defaults.string(forKey: "installPath") else {
return defaultInstallDirectory
}
guard let path = Path(savedInstallDirectory) else {
return defaultInstallDirectory
}
return path
}
static var runtimeCacheFile: Path {
return xcodesApplicationSupport/"downloadable-runtimes.json"
}
static var xcodesCaches: Path {
return caches/"com.xcodesorg.xcodesapp"
}
@discardableResult
func setCurrentUserAsOwner() -> Path {
let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName()
try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string)
return self
}
} }

View file

@ -2,13 +2,12 @@ import Combine
import Foundation import Foundation
import os.log import os.log
import Path import Path
import XcodesKit
public typealias ProcessOutput = (status: Int32, out: String, err: String) public typealias ProcessOutput = (status: Int32, out: String, err: String)
extension Process { extension Process {
@discardableResult @discardableResult
static func run(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher<ProcessOutput, Error> { static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher<ProcessOutput, Error> {
return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) return run(executable.url, workingDirectory: workingDirectory, input: input, arguments)
} }
@ -68,3 +67,9 @@ extension Process {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
struct ProcessExecutionError: Error {
let process: Process
let standardOutput: String
let standardError: String
}

View file

@ -70,38 +70,5 @@ extension Progress {
} }
} }
func updateFromXcodebuild(text: String) {
self.totalUnitCount = 100
self.completedUnitCount = 0
self.localizedAdditionalDescription = "" // to not show the addtional
do {
let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
let downloadRegex = try NSRegularExpression(pattern: downloadPattern)
// Search for matches in the text
if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) {
// Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) {
let percent = Int64(percentDouble.rounded())
self.completedUnitCount = percent
}
}
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
if text.range(of: "Installing") != nil {
// sets the progress to indeterminite to show animating progress
self.totalUnitCount = 0
self.completedUnitCount = 0
}
} catch {
Logger.appState.error("Invalid regular expression")
}
}
} }

View file

@ -1,57 +0,0 @@
//
// SDKs+Xcode.swift
// Xcodes
//
// Created by Matt Kiazyk on 2023-06-05.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import Foundation
import XcodesKit
import SwiftUI
extension SDKs {
/// Loops through all SDK's and returns an array of buildNumbers (to be used to correlate runtimes)
func allBuilds() -> [String] {
var buildNumbers: [String] = []
if let iOS = self.iOS?.compactMap({ $0.build }) {
buildNumbers += iOS
}
if let tvOS = self.tvOS?.compactMap({ $0.build }) {
buildNumbers += tvOS
}
if let macOS = self.macOS?.compactMap({ $0.build }) {
buildNumbers += macOS
}
if let watchOS = self.watchOS?.compactMap({ $0.build }) {
buildNumbers += watchOS
}
if let visionOS = self.visionOS?.compactMap({ $0.build }) {
buildNumbers += visionOS
}
return buildNumbers
}
}
extension DownloadableRuntime {
func icon() -> Image {
switch self.platform {
case .iOS:
return Image(systemName: "iphone")
case .macOS:
return Image(systemName: "macwindow")
case .watchOS:
return Image(systemName: "applewatch")
case .tvOS:
return Image(systemName: "appletv")
case .visionOS:
if #available(macOS 14, *) {
return Image(systemName: "visionpro")
} else {
return Image(systemName: "eyeglasses")
}
}
}
}

View file

@ -1,31 +0,0 @@
//
// SelectedActionType.swift
// Xcodes
//
// Created by Matt Kiazyk on 2022-07-24.
// Copyright © 2022 Robots and Pencils. All rights reserved.
//
import Foundation
public enum SelectedActionType: String, CaseIterable, Identifiable, CustomStringConvertible {
case none
case rename
public var id: Self { self }
public static var `default` = SelectedActionType.none
public var description: String {
switch self {
case .none: return localizeString("OnSelectDoNothing")
case .rename: return localizeString("OnSelectRenameXcode")
}
}
public var detailedDescription: String {
switch self {
case .none: return localizeString("OnSelectDoNothingDescription")
case .rename: return localizeString("OnSelectRenameXcodeDescription")
}
}
}

View file

@ -4,7 +4,6 @@ extension URL {
static let download = URL(string: "https://developer.apple.com/download")! static let download = URL(string: "https://developer.apple.com/download")!
static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")! static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")!
static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")! static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")!
static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")!
} }
extension URLRequest { extension URLRequest {
@ -26,13 +25,4 @@ extension URLRequest {
request.allHTTPHeaderFields?["Accept"] = "*/*" request.allHTTPHeaderFields?["Accept"] = "*/*"
return request return request
} }
static func downloadADCAuth(path: String) -> URLRequest {
var components = URLComponents(url: .downloadADCAuth, resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "path", value: path)]
var request = URLRequest(url: components.url!)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Accept"] = "*/*"
return request
}
} }

View file

@ -1,11 +1,11 @@
import Version import Version
import XcodesKit import struct XCModel.Xcode
extension Version { extension Version {
/// Initialize a Version from an XcodeReleases' XCModel.Xcode /// Initialize a Version from an XcodeReleases' XCModel.Xcode
/// ///
/// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead. /// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead.
init?(xcReleasesXcode: XcodeRelease) { init?(xcReleasesXcode: XCModel.Xcode) {
var versionString = xcReleasesXcode.version.number ?? "" var versionString = xcReleasesXcode.version.number ?? ""
// Append trailing ".0" in order to get a fully-specified version string // Append trailing ".0" in order to get a fully-specified version string

View file

@ -1,30 +1,14 @@
import AppKit import AppKit
import Foundation import Foundation
import Version import Version
import struct XCModel.SDKs
import struct XCModel.Compilers
import Path import Path
import XcodesKit
public struct XcodeID: Codable, Hashable, Identifiable {
public let version: Version
public let architectures: [Architecture]?
public var id: String {
let architectures = architectures?.map { $0.rawValue}.joined() ?? ""
return version.description + architectures
}
public init(version: Version, architectures: [Architecture]? = nil) {
self.version = version
self.architectures = architectures
}
}
struct Xcode: Identifiable, CustomStringConvertible { struct Xcode: Identifiable, CustomStringConvertible {
var version: Version { let version: Version
return id.version
}
/// Other Xcode versions that have the same build identifier /// Other Xcode versions that have the same build identifier
let identicalBuilds: [XcodeID] let identicalBuilds: [Version]
var installState: XcodeInstallState var installState: XcodeInstallState
let selected: Bool let selected: Bool
let icon: NSImage? let icon: NSImage?
@ -34,12 +18,10 @@ struct Xcode: Identifiable, CustomStringConvertible {
let sdks: SDKs? let sdks: SDKs?
let compilers: Compilers? let compilers: Compilers?
let downloadFileSize: Int64? let downloadFileSize: Int64?
let architectures: [Architecture]?
let id: XcodeID
init( init(
version: Version, version: Version,
identicalBuilds: [XcodeID] = [], identicalBuilds: [Version] = [],
installState: XcodeInstallState, installState: XcodeInstallState,
selected: Bool, selected: Bool,
icon: NSImage?, icon: NSImage?,
@ -48,9 +30,9 @@ struct Xcode: Identifiable, CustomStringConvertible {
releaseDate: Date? = nil, releaseDate: Date? = nil,
sdks: SDKs? = nil, sdks: SDKs? = nil,
compilers: Compilers? = nil, compilers: Compilers? = nil,
downloadFileSize: Int64? = nil, downloadFileSize: Int64? = nil
architectures: [Architecture]? = nil
) { ) {
self.version = version
self.identicalBuilds = identicalBuilds self.identicalBuilds = identicalBuilds
self.installState = installState self.installState = installState
self.selected = selected self.selected = selected
@ -61,10 +43,10 @@ struct Xcode: Identifiable, CustomStringConvertible {
self.sdks = sdks self.sdks = sdks
self.compilers = compilers self.compilers = compilers
self.downloadFileSize = downloadFileSize self.downloadFileSize = downloadFileSize
self.architectures = architectures
self.id = XcodeID(version: version, architectures: architectures)
} }
var id: Version { version }
var description: String { var description: String {
version.appleDescription version.appleDescription
} }
@ -85,5 +67,4 @@ struct Xcode: Identifiable, CustomStringConvertible {
return nil return nil
} }
} }
} }

View file

@ -1,5 +1,4 @@
import SwiftUI import SwiftUI
import XcodesKit
// MARK: - CommandMenu // MARK: - CommandMenu
@ -19,7 +18,6 @@ struct XcodeCommands: Commands {
OpenCommand() OpenCommand()
RevealCommand() RevealCommand()
CopyPathCommand() CopyPathCommand()
CreateSymbolicLinkCommand()
Divider() Divider()
@ -35,21 +33,18 @@ struct XcodeCommands: Commands {
struct InstallButton: View { struct InstallButton: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
let xcode: Xcode? let xcode: Xcode?
var body: some View { var body: some View {
Button { Button(action: install) {
install()
} label: {
Text("Install") Text("Install")
.help("InstallDescription") .help("Install")
} }
} }
private func install() { private func install() {
guard let xcode = xcode else { return } guard let xcode = xcode else { return }
appState.checkMinVersionAndInstall(id: xcode.id) appState.install(id: xcode.id)
} }
} }
@ -59,10 +54,9 @@ struct CancelInstallButton: View {
var body: some View { var body: some View {
Button(action: cancelInstall) { Button(action: cancelInstall) {
Label("Cancel", systemImage: "xmark") Text("Cancel")
.help("Stop installation")
} }
.help(localizeString("StopInstallation"))
.buttonStyle(.plain)
} }
private func cancelInstall() { private func cancelInstall() {
@ -71,23 +65,6 @@ struct CancelInstallButton: View {
} }
} }
struct CancelRuntimeInstallButton: View {
@EnvironmentObject var appState: AppState
let runtime: DownloadableRuntime?
var body: some View {
Button(action: cancelInstall) {
Image(systemName: "xmark.circle.fill")
}.help(localizeString("StopInstallation"))
.buttonStyle(.plain)
}
private func cancelInstall() {
guard let runtime = runtime else { return }
appState.presentedAlert = .cancelRuntimeInstall(runtime: runtime)
}
}
struct SelectButton: View { struct SelectButton: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
let xcode: Xcode? let xcode: Xcode?
@ -97,7 +74,7 @@ struct SelectButton: View {
if xcode?.selected == true { if xcode?.selected == true {
Text("Active") Text("Active")
} else { } else {
Text("MakeActive") Text("Make active")
} }
} }
.disabled(xcode?.selected != false) .disabled(xcode?.selected != false)
@ -114,34 +91,16 @@ struct OpenButton: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
let xcode: Xcode? let xcode: Xcode?
var openInRosetta: Bool {
appState.showOpenInRosettaOption && Hardware.isAppleSilicon()
}
var body: some View { var body: some View {
if openInRosetta { Button(action: open) {
Menu("Open") { Text("Open")
Button(action: open) {
Text("Open")
}
.help("Open")
Button(action: open) {
Text("Open In Rosetta")
}
.help("Open In Rosetta")
}
} else {
Button(action: open) {
Text("Open")
}
.help("Open")
} }
.help("Open")
} }
private func open() { private func open() {
guard let xcode = xcode else { return } guard let xcode = xcode else { return }
appState.open(xcode: xcode, openInRosetta: openInRosetta) appState.open(xcode: xcode)
} }
} }
@ -166,14 +125,14 @@ struct RevealButton: View {
var body: some View { var body: some View {
Button(action: reveal) { Button(action: reveal) {
Text("RevealInFinder") Text("Reveal in Finder")
} }
.help("RevealInFinder") .help("Reveal in Finder")
} }
private func reveal() { private func reveal() {
guard let xcode = xcode else { return } guard let xcode = xcode else { return }
appState.reveal(xcode.installedPath) appState.reveal(xcode: xcode)
} }
} }
@ -183,9 +142,9 @@ struct CopyPathButton: View {
var body: some View { var body: some View {
Button(action: copyPath) { Button(action: copyPath) {
Text("CopyPath") Text("Copy Path")
} }
.help("CopyPath") .help("Copy path")
} }
private func copyPath() { private func copyPath() {
@ -194,76 +153,6 @@ struct CopyPathButton: View {
} }
} }
struct CopyReleaseNoteButton: View {
let url: URL?
@EnvironmentObject var appState: AppState
var body: some View {
Button(action: copyReleaseNote) {
Text("CopyReleaseNoteURL")
}
.help("CopyReleaseNoteURL")
}
private func copyReleaseNote() {
guard let url = url else { return }
appState.copyReleaseNote(from: url)
}
}
struct CreateSymbolicLinkButton: View {
@EnvironmentObject var appState: AppState
let xcode: Xcode?
var body: some View {
Button(action: createSymbolicLink) {
Text("CreateSymLink")
}
.help("CreateSymLink")
}
private func createSymbolicLink() {
guard let xcode = xcode else { return }
appState.createSymbolicLink(xcode: xcode)
}
}
struct DownloadRuntimeButton: View {
@EnvironmentObject var appState: AppState
let runtime: DownloadableRuntime?
var body: some View {
Button(action: install) {
Text("Install")
.help("Install")
}
}
private func install() {
guard let runtime = runtime else { return }
appState.downloadRuntime(runtime: runtime)
}
}
struct CreateSymbolicBetaLinkButton: View {
@EnvironmentObject var appState: AppState
let xcode: Xcode?
var body: some View {
Button(action: createSymbolicBetaLink) {
Text("CreateSymLinkBeta")
}
.help("CreateSymLinkBeta")
}
private func createSymbolicBetaLink() {
guard let xcode = xcode else { return }
appState.createSymbolicLink(xcode: xcode, isBeta: true)
}
}
// MARK: - Commands // MARK: - Commands
struct InstallCommand: View { struct InstallCommand: View {
@ -336,15 +225,3 @@ struct UninstallCommand: View {
.disabled(selectedXcode.unwrapped?.installState.installed != true) .disabled(selectedXcode.unwrapped?.installState.installed != true)
} }
} }
struct CreateSymbolicLinkCommand: View {
@EnvironmentObject var appState: AppState
@FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode?
var body: some View {
CreateSymbolicLinkButton(xcode: selectedXcode.unwrapped)
.keyboardShortcut("s", modifiers: [.command, .option])
.disabled(selectedXcode.unwrapped?.installState.installed != true)
}
}

View file

@ -1,10 +1,9 @@
import Foundation import Foundation
import Path import Path
import XcodesKit
enum XcodeInstallState: Equatable { enum XcodeInstallState: Equatable {
case notInstalled case notInstalled
case installing(XcodeInstallationStep) case installing(InstallationStep)
case installed(Path) case installed(Path)
var notInstalled: Bool { var notInstalled: Bool {

View file

@ -12,13 +12,16 @@ struct AboutView: View {
Text(Bundle.main.bundleName!) Text(Bundle.main.bundleName!)
.font(.largeTitle) .font(.largeTitle)
Text(String(format: localizeString("VersionWithBuild"), Bundle.main.shortVersion!, Bundle.main.version!)) Text("Version \(Bundle.main.shortVersion!) (\(Bundle.main.version!))")
Color.clear
.frame(width: 300, height: 16)
HStack(spacing: 32) { HStack(spacing: 32) {
Button(action: { Button(action: {
openURL(URL(string: "https://github.com/RobotsAndPencils/XcodesApp/")!) openURL(URL(string: "https://github.com/RobotsAndPencils/XcodesApp/")!)
}) { }) {
Label("GithubRepo", systemImage: "link") Label("GitHub Repo", systemImage: "link")
} }
.buttonStyle(LinkButtonStyle()) .buttonStyle(LinkButtonStyle())
@ -27,36 +30,9 @@ struct AboutView: View {
} }
.buttonStyle(LinkButtonStyle()) .buttonStyle(LinkButtonStyle())
} }
Color.clear
.frame(width: 300, height: 0) Text(Bundle.main.humanReadableCopyright!)
Label("UnxipExperiment", systemImage: "lightbulb") .font(.footnote)
HStack(spacing: 32) {
Button(action: {
openURL(URL(string: "https://github.com/saagarjha/unxip/")!)
}) {
Label("GithubRepo", systemImage: "link")
}
.buttonStyle(LinkButtonStyle())
Button(action: {
openURL(URL(string: "https://github.com/saagarjha/unxip/blob/main/LICENSE")!)
}) {
Label("License", systemImage: "link")
}
.buttonStyle(LinkButtonStyle())
}
HStack {
Text(Bundle.main.humanReadableCopyright!)
.font(.footnote)
Button(action: {
openURL(URL(string: "https://opencollective.com/xcodesapp")!)
}) {
HStack {
Image(systemName: "heart.circle")
Text("Support.Xcodes")
}
}
}
} }
} }
.padding() .padding()

View file

@ -10,7 +10,7 @@ struct AcknowledgmentsView: View {
)! )!
.addingAttribute(.foregroundColor, value: NSColor.labelColor) .addingAttribute(.foregroundColor, value: NSColor.labelColor)
) )
.frame(minWidth: 600, minHeight: 500) .frame(minWidth: 500, minHeight: 500)
} }
} }

View file

@ -1,35 +0,0 @@
//
// NavigationSplitViewWrapper.swift
// Xcodes
//
// Created by Matt Kiazyk on 2023-12-12.
//
import SwiftUI
struct NavigationSplitViewWrapper<Sidebar, Detail>: View where Sidebar: View, Detail: View {
private var sidebar: Sidebar
private var detail: Detail
init(
@ViewBuilder sidebar: () -> Sidebar,
@ViewBuilder detail: () -> Detail
) {
self.sidebar = sidebar()
self.detail = detail()
}
var body: some View {
NavigationSplitView {
if #available(macOS 14, *) {
sidebar
.navigationSplitViewColumnWidth(min: 290, ideal: 290)
} else {
sidebar
}
} detail: {
detail
}
.navigationSplitViewStyle(.balanced)
}
}

View file

@ -31,7 +31,6 @@ public struct ObservingProgressIndicator: View {
self.progress = progress self.progress = progress
cancellable = progress.publisher(for: \.fractionCompleted) cancellable = progress.publisher(for: \.fractionCompleted)
.combineLatest(progress.publisher(for: \.localizedAdditionalDescription)) .combineLatest(progress.publisher(for: \.localizedAdditionalDescription))
.combineLatest(progress.publisher(for: \.isIndeterminate))
.throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in self?.objectWillChange.send() } .sink { [weak self] _ in self?.objectWillChange.send() }
} }
@ -47,11 +46,11 @@ public struct ObservingProgressIndicator: View {
isIndeterminate: progress.progress.isIndeterminate, isIndeterminate: progress.progress.isIndeterminate,
style: style style: style
) )
.help(String(format: localizeString("DownloadingPercentDescription"), Int((progress.progress.fractionCompleted * 100)))) .help("Downloading: \(Int((progress.progress.fractionCompleted * 100)))% complete")
if showsAdditionalDescription, progress.progress.xcodesLocalizedDescription.isEmpty == false { if showsAdditionalDescription, progress.progress.xcodesLocalizedDescription.isEmpty == false {
Text(progress.progress.xcodesLocalizedDescription) Text(progress.progress.xcodesLocalizedDescription)
.font(.subheadline.monospacedDigit()) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
@ -83,18 +82,6 @@ struct ObservingProgressBar_Previews: PreviewProvider {
style: .bar, style: .bar,
showsAdditionalDescription: true showsAdditionalDescription: true
) )
ObservingProgressIndicator(
configure(Progress()) {
$0.kind = .file
$0.fileOperationKind = .downloading
$0.totalUnitCount = 0
$0.completedUnitCount = 0
},
controlSize: .regular,
style: .bar,
showsAdditionalDescription: true
)
} }
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
} }

View file

@ -22,10 +22,7 @@ struct ProgressIndicator: NSViewRepresentable {
nsView.doubleValue = doubleValue nsView.doubleValue = doubleValue
nsView.controlSize = controlSize nsView.controlSize = controlSize
nsView.isIndeterminate = isIndeterminate nsView.isIndeterminate = isIndeterminate
nsView.usesThreadedAnimation = true
nsView.style = style nsView.style = style
nsView.startAnimation(nil)
} }
} }

View file

@ -1,24 +0,0 @@
//
// TagView.swift
// Xcodes
//
// Created by Matt Kiazyk on 2025-06-25.//
import SwiftUI
struct TagView: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 10))
.foregroundColor(.primary)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
Capsule()
.fill(.quaternary)
)
}
}

View file

@ -1,22 +0,0 @@
//
// TrailingIconLabelStyle.swift
// Xcodes
//
// Created by Daniel Chick on 3/11/24.
// Copyright © 2024 Robots and Pencils. All rights reserved.
//
import SwiftUI
struct TrailingIconLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.title
configuration.icon
}
}
}
extension LabelStyle where Self == TrailingIconLabelStyle {
static var trailingIcon: Self { Self() }
}

View file

@ -1,36 +1,15 @@
import Foundation import Foundation
import XcodesKit
enum XcodesAlert: Identifiable { enum XcodesAlert: Identifiable {
case cancelInstall(xcode: Xcode) case cancelInstall(xcode: Xcode)
case cancelRuntimeInstall(runtime: DownloadableRuntime)
case privilegedHelper case privilegedHelper
case generic(title: String, message: String) case generic(title: String, message: String)
case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String)
case unauthenticated
var id: Int { var id: Int {
switch self { switch self {
case .cancelInstall: return 1 case .cancelInstall: return 1
case .privilegedHelper: return 2 case .privilegedHelper: return 2
case .generic: return 3 case .generic: return 3
case .checkMinSupportedVersion: return 4
case .cancelRuntimeInstall: return 5
case .unauthenticated: return 6
}
}
}
// Splitting out alerts that are shown on the preference screen as by default we are showing on the MainWindow()
// and users awkwardly switch screens, sometimes losing the preference screen
enum XcodesPreferencesAlert: Identifiable {
case deletePlatform(runtime: DownloadableRuntime)
case generic(title: String, message: String)
var id: Int {
switch self {
case .deletePlatform: return 1
case .generic: return 2
} }
} }
} }

View file

@ -1,43 +1,8 @@
import Foundation import Foundation
import AppleAPI
enum XcodesSheet: Identifiable { enum XcodesSheet: Identifiable {
case signIn case signIn
case twoFactor(SecondFactorData) case twoFactor
case securityKeyTouchToConfirm
var id: Int { Kind(self).hashValue } var id: Int { hashValue }
struct SecondFactorData {
let option: TwoFactorOption
let authOptions: AuthOptionsResponse
let sessionData: AppleSessionData
}
}
extension XcodesSheet {
private enum Kind: Hashable {
case signIn, twoFactor(TwoFactorOption), securityKeyTouchToConfirm
enum TwoFactorOption {
case smsSent
case codeSent
case smsPendingChoice
case securityKeyPin
}
init(_ sheet: XcodesSheet) {
switch sheet {
case .signIn: self = .signIn
case .twoFactor(let data):
switch data.option {
case .smsSent: self = .twoFactor(.smsSent)
case .smsPendingChoice: self = .twoFactor(.smsPendingChoice)
case .codeSent: self = .twoFactor(.codeSent)
case .securityKey: self = .twoFactor(.securityKeyPin)
}
case .securityKeyTouchToConfirm: self = .securityKeyTouchToConfirm
}
}
}
} }

View file

@ -1,43 +0,0 @@
//
// CompatibilityView.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
struct CompatibilityView: View {
@EnvironmentObject var appState: AppState
let requiredMacOSVersion: String?
var body: some View {
if let requiredMacOSVersion = requiredMacOSVersion {
HStack(alignment: .top){
VStack(alignment: .leading) {
Text("Compatibility")
.font(.headline)
Text(String(format: localizeString("MacOSRequirement"), requiredMacOSVersion))
.font(.subheadline)
.foregroundColor(appState.hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) ? .red : .primary)
}
Spacer()
if appState.hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
}
}
.xcodesBackground()
} else {
EmptyView()
}
}
}
#Preview {
CompatibilityView(requiredMacOSVersion: "10.15.4")
.padding()
.environmentObject(AppState())
}

View file

@ -1,60 +0,0 @@
//
// CompilersView.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
import XcodesKit
struct CompilersView: View {
let compilers: Compilers?
var body: some View {
if let compilers = compilers {
VStack(alignment: .leading) {
Text("Compilers").font(.headline)
Text(Self.content(from: compilers))
.font(.subheadline)
.textSelection(.enabled)
}
} else {
EmptyView()
}
}
static func content(from compilers: Compilers) -> String {
[ ("Swift", compilers.swift),
("Clang", compilers.clang),
("LLVM", compilers.llvm),
("LLVM GCC", compilers.llvm_gcc),
("GCC", compilers.gcc)
].compactMap { // remove nil compiler
guard $0.1 != nil, // has version array
!$0.1!.isEmpty // has at least 1 version
else { return nil }
let numbers = $0.1!.compactMap { $0.number } // remove nil number
guard !numbers.isEmpty // has at least 1 number
else { return nil }
// description for each type of compilers
return "\($0.0): \(numbers.joined(separator: ", "))"
}.joined(separator: "\n")
}
}
#Preview {
let compilers = Compilers(
gcc: .init(number: "4"),
llvm_gcc: .init(number: "213"),
llvm: .init(number: "2.3"),
clang: .init(number: "7.3"),
swift: .init(number: "5.3.2")
)
return CompilersView(compilers: compilers)
.padding()
}

View file

@ -1,35 +0,0 @@
//
// CornerRadiusModifier.swift
// Xcodes
//
// Created by Matt Kiazyk on 2023-12-19.
//
import Foundation
import SwiftUI
struct CornerRadiusModifier: ViewModifier {
func body(content: Content) -> some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
}
}
extension View {
func xcodesBackground() -> some View {
self.modifier(
CornerRadiusModifier()
)
}
}
struct Previews_CornerRadius_Previews: PreviewProvider {
static var previews: some View {
HStack {
Text(verbatim: "XCODES RULES!")
}.xcodesBackground()
}
}

View file

@ -1,44 +0,0 @@
//
// IconView.swift
// Xcodes
//
// Created by Duong Thai on 11/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import Path
import SwiftUI
import Version
struct IconView: View {
let xcode: Xcode
var body: some View {
if case let .installed(path) = xcode.installState {
Image(nsImage: NSWorkspace.shared.icon(forFile: path.string))
} else {
Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.secondary)
}
}
}
#Preview("Installed") {
IconView(xcode: Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil))
.frame(width: 300, height: 100)
.padding()
}
#Preview("Installed") {
IconView(xcode: Xcode(version: Version("12.3.0")!, installState: .notInstalled, selected: true, icon: nil))
.frame(width: 300, height: 100)
.padding()
}
#Preview("Not Installed") {
IconView(xcode: Xcode(version: Version("12.0.0-1234A")!, installState: .notInstalled, selected: false, icon: nil))
.frame(width: 300, height: 100)
.padding()
}

View file

@ -1,63 +0,0 @@
//
// IdenticalBuildView.swift
// Xcodes
//
// Created by Duong Thai on 11/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
import Version
struct IdenticalBuildsView: View {
let builds: [Version]
private let isEmpty: Bool
private let accessibilityDescription: String
var body: some View {
if isEmpty {
EmptyView()
} else {
VStack(alignment: .leading) {
HStack {
Text("IdenticalBuilds")
Image(systemName: "square.fill.on.square.fill")
.foregroundColor(.secondary)
.accessibility(hidden: true)
.help("IdenticalBuilds.help")
}
.font(.headline)
ForEach(builds, id: \.description) { version in
Text(verbatim: "\(version.appleDescription)")
.font(.subheadline)
}
}
.xcodesBackground()
.accessibilityElement()
.accessibility(label: Text("IdenticalBuilds"))
.accessibility(value: Text(accessibilityDescription))
.accessibility(hint: Text("IdenticalBuilds.help"))
}
}
init(builds: [Version]) {
self.builds = builds
self.isEmpty = builds.isEmpty
self.accessibilityDescription = builds
.map(\.appleDescription)
.joined(separator: ", ")
}
}
let builds: [Version] = [.init(xcodeVersion: "15.0")!, .init(xcodeVersion: "15.1")!]
#Preview("Has Some Builds") {
IdenticalBuildsView(builds: builds)
.padding()
}
#Preview("No Build") {
IdenticalBuildsView(builds: [])
.padding()
}

View file

@ -1,210 +1,364 @@
import AppKit import AppKit
import XcodesKit
import Path import Path
import SwiftUI import SwiftUI
import Version import Version
import struct XCModel.SDKs
import struct XCModel.Compilers
struct InfoPane: View { struct InfoPane: View {
let xcode: Xcode @EnvironmentObject var appState: AppState
var body: some View { let selectedXcodeID: Xcode.ID?
if #available(macOS 14.0, *) { @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction
mainContent
.contentMargins(10, for: .scrollContent)
} else {
mainContent
.padding()
}
}
private var mainContent: some View { var body: some View {
ScrollView(.vertical) { if let xcode = appState.allXcodes.first(where: { $0.id == selectedXcodeID }) {
HStack(alignment: .top) { ScrollView {
VStack { VStack(alignment: .leading, spacing: 16) {
VStack(spacing: 5) { icon(for: xcode)
HStack { .frame(maxWidth: .infinity, alignment: .center)
IconView(xcode: xcode)
Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)")
Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") .font(.title)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
InfoPaneControls(xcode: xcode)
}
.xcodesBackground()
PlatformsView(xcode: xcode) switch xcode.installState {
case .notInstalled:
InstallButton(xcode: xcode)
downloadFileSize(for: xcode)
case .installing(let installationStep):
InstallationStepDetailView(installationStep: installationStep)
.fixedSize(horizontal: false, vertical: true)
CancelInstallButton(xcode: xcode)
case let .installed(path):
HStack {
Text(path.string)
Button(action: { appState.reveal(xcode: xcode) }) {
Image(systemName: "arrow.right.circle.fill")
}
.buttonStyle(PlainButtonStyle())
.help("Reveal in Finder")
}
HStack {
SelectButton(xcode: xcode)
.disabled(xcode.selected)
.help("Selected")
OpenButton(xcode: xcode)
.help("Open")
Spacer()
UninstallButton(xcode: xcode)
}
}
Divider()
Group{
releaseNotes(for: xcode)
releaseDate(for: xcode)
identicalBuilds(for: xcode)
compatibility(for: xcode)
sdks(for: xcode)
compilers(for: xcode)
}
Spacer()
} }
.frame(minWidth: 380) .padding()
VStack(alignment: .leading) {
ReleaseDateView(date: xcode.releaseDate, url: xcode.releaseNotesURL)
CompatibilityView(requiredMacOSVersion: xcode.requiredMacOSVersion)
IdenticalBuildsView(builds: xcode.identicalBuilds.map { $0.version })
SDKandCompilers
}
.frame(width: 200)
} }
.frame(minWidth: 200, maxWidth: .infinity)
} else {
empty
.frame(minWidth: 200, maxWidth: .infinity)
} }
} }
@ViewBuilder @ViewBuilder
var SDKandCompilers: some View { private func icon(for xcode: Xcode) -> some View {
VStack(alignment: .leading, spacing: 16) { if case let .installed(path) = xcode.installState {
SDKsView(sdks: xcode.sdks) Image(nsImage: NSWorkspace.shared.icon(forFile: path.string))
CompilersView(compilers: xcode.compilers) } else {
Image(systemName: "app.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) }
.padding()
.background(.background) @ViewBuilder
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) private func identicalBuilds(for xcode: Xcode) -> some View {
if !xcode.identicalBuilds.isEmpty {
VStack(alignment: .leading) {
HStack {
Text("Identical Builds")
Image(systemName: "square.fill.on.square.fill")
.foregroundColor(.secondary)
.accessibility(hidden: true)
.help("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together.")
}
.font(.headline)
ForEach(xcode.identicalBuilds, id: \.description) { version in
Text("\(version.appleDescription)")
.font(.subheadline)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement()
.accessibility(label: Text("Identical Builds"))
.accessibility(value: Text(xcode.identicalBuilds.map(\.appleDescription).joined(separator: ", ")))
.accessibility(hint: Text("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together."))
} else {
EmptyView()
}
}
@ViewBuilder
private func releaseDate(for xcode: Xcode) -> some View {
if let releaseDate = xcode.releaseDate {
VStack(alignment: .leading) {
Text("Release Date")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text(DateFormatter.downloadsReleaseDate.string(from: releaseDate))
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
EmptyView()
}
}
@ViewBuilder
private func releaseNotes(for xcode: Xcode) -> some View {
if let releaseNotesURL = xcode.releaseNotesURL {
Button(action: { openURL(releaseNotesURL) }) {
Label("Release Notes", systemImage: "link")
}
.buttonStyle(LinkButtonStyle())
.frame(maxWidth: .infinity, alignment: .leading)
.help("View Release Notes")
} else {
EmptyView()
}
}
@ViewBuilder
private func compatibility(for xcode: Xcode) -> some View {
if let requiredMacOSVersion = xcode.requiredMacOSVersion {
VStack(alignment: .leading) {
Text("Compatibility")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Requires macOS \(requiredMacOSVersion) or later")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
EmptyView()
}
}
@ViewBuilder
private func sdks(for xcode: Xcode) -> some View {
if let sdks = xcode.sdks {
VStack(alignment: .leading) {
Text("SDKs")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach([
("macOS", \SDKs.macOS),
("iOS", \.iOS),
("watchOS", \.watchOS),
("tvOS", \.tvOS),
], id: \.0) { row in
if let sdk = sdks[keyPath: row.1] {
Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
} else {
EmptyView()
}
}
@ViewBuilder
private func compilers(for xcode: Xcode) -> some View {
if let compilers = xcode.compilers {
VStack(alignment: .leading) {
Text("Compilers")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach([
("Swift", \Compilers.swift),
("Clang", \.clang),
("LLVM", \.llvm),
("LLVM GCC", \.llvm_gcc),
("GCC", \.gcc),
], id: \.0) { row in
if let sdk = compilers[keyPath: row.1] {
Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
} else {
EmptyView()
}
}
@ViewBuilder
private func downloadFileSize(for xcode: Xcode) -> some View {
// if we've downloaded it no need to show the download size
if let downloadFileSize = xcode.downloadFileSizeString, case .notInstalled = xcode.installState {
VStack(alignment: .leading) {
Text("Download Size")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(downloadFileSize)")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
EmptyView()
}
}
@ViewBuilder
private var empty: some View {
Text("No Xcode Selected")
.font(.title)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} }
} }
#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) } struct InfoPane_Previews: PreviewProvider {
#Preview(XcodePreviewName.allCases[1].rawValue) { makePreviewContent(for: 1) } static var previews: some View {
#Preview(XcodePreviewName.allCases[2].rawValue) { makePreviewContent(for: 2) } Group {
#Preview(XcodePreviewName.allCases[3].rawValue) { makePreviewContent(for: 3) } InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
#Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) } .environmentObject(configure(AppState()) {
#Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) } $0.allXcodes = [
.init(
version: Version(major: 12, minor: 3, patch: 0),
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
selected: true,
icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"),
requiredMacOSVersion: "10.15.4",
releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!,
releaseDate: Date(),
sdks: SDKs(
macOS: .init(number: "11.1"),
iOS: .init(number: "14.3"),
watchOS: .init(number: "7.3"),
tvOS: .init(number: "14.3")
),
compilers: Compilers(
gcc: .init(number: "4"),
llvm_gcc: .init(number: "213"),
llvm: .init(number: "2.3"),
clang: .init(number: "7.3"),
swift: .init(number: "5.3.2")
),
downloadFileSize: 242342424
)
]
})
.previewDisplayName("Populated, Installed, Selected")
private func makePreviewContent(for index: Int) -> some View { InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
let name = XcodePreviewName.allCases[index] .environmentObject(configure(AppState()) {
return InfoPane(xcode: xcodeDict[name]!) $0.allXcodes = [
.environmentObject(configure(AppState()) { .init(
$0.allXcodes = [xcodeDict[name]!] version: Version(major: 12, minor: 3, patch: 0),
}) installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
.frame(width: 600, height: 400) selected: false,
.padding() icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"),
sdks: SDKs(
macOS: .init(number: "11.1"),
iOS: .init(number: "14.3"),
watchOS: .init(number: "7.3"),
tvOS: .init(number: "14.3")
),
compilers: Compilers(
gcc: .init(number: "4"),
llvm_gcc: .init(number: "213"),
llvm: .init(number: "2.3"),
clang: .init(number: "7.3"),
swift: .init(number: "5.3.2")
),
downloadFileSize: 242342424)
]
})
.previewDisplayName("Populated, Installed, Unselected")
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
.environmentObject(configure(AppState()) {
$0.allXcodes = [
.init(
version: Version(major: 12, minor: 3, patch: 0),
installState: .notInstalled,
selected: false,
icon: nil,
sdks: SDKs(
macOS: .init(number: "11.1"),
iOS: .init(number: "14.3"),
watchOS: .init(number: "7.3"),
tvOS: .init(number: "14.3")
),
compilers: Compilers(
gcc: .init(number: "4"),
llvm_gcc: .init(number: "213"),
llvm: .init(number: "2.3"),
clang: .init(number: "7.3"),
swift: .init(number: "5.3.2")
),
downloadFileSize: 242342424)
]
})
.previewDisplayName("Populated, Uninstalled")
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]))
.environmentObject(configure(AppState()) {
$0.allXcodes = [
.init(
version: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]),
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
selected: false,
icon: nil,
sdks: nil,
compilers: nil)
]
})
.previewDisplayName("Basic, installed")
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]))
.environmentObject(configure(AppState()) {
$0.allXcodes = [
.init(
version: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]),
installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40; $0.throughput = 232323232; $0.fileCompletedCount = 2323004; $0.fileTotalCount = 1193939393 })),
selected: false,
icon: nil,
sdks: nil,
compilers: nil)
]
})
.previewDisplayName("Basic, installing")
InfoPane(selectedXcodeID: nil)
.environmentObject(configure(AppState()) {
$0.allXcodes = [
]
})
.previewDisplayName("Empty")
}
.frame(maxWidth: 300)
}
} }
enum XcodePreviewName: String, CaseIterable, Identifiable {
case Populated_Installed_Selected
case Populated_Installed_Unselected
case Populated_Uninstalled
case Basic_Installed
case Basic_Installing
case Basic_Unarchiving
var id: XcodePreviewName { self }
}
var xcodeDict: [XcodePreviewName: Xcode] = [
.Populated_Installed_Selected: .init(
version: _versionNoMeta,
installState: .installed(Path(_path)!),
selected: true,
icon: NSWorkspace.shared.icon(forFile: _path),
requiredMacOSVersion: _requiredMacOSVersion,
releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!,
releaseDate: Date(),
sdks: _sdks,
compilers: _compilers,
downloadFileSize: _downloadFileSize
),
.Populated_Installed_Unselected: .init(
version: _versionNoMeta,
installState: .installed(Path(_path)!),
selected: false,
icon: NSWorkspace.shared.icon(forFile: _path),
sdks: _sdks,
compilers: _compilers,
downloadFileSize: _downloadFileSize
),
.Populated_Uninstalled: .init(
version: Version(major: 12, minor: 3, patch: 0),
installState: .notInstalled,
selected: false,
icon: nil,
sdks: _sdks,
compilers: _compilers,
downloadFileSize: _downloadFileSize
),
.Basic_Installed: .init(
version: _versionWithMeta,
installState: .installed(Path(_path)!),
selected: false,
icon: nil,
sdks: nil,
compilers: nil
),
.Basic_Installing: .init(
version: _versionWithMeta,
installState: .installing(.downloading(
progress: configure(Progress()) {
$0.kind = .file
$0.fileOperationKind = .downloading
$0.estimatedTimeRemaining = 123
$0.totalUnitCount = 11_944_848_484
$0.completedUnitCount = 848_444_920
$0.throughput = 9_211_681
}
)),
selected: false,
icon: nil,
sdks: nil,
compilers: nil
),
.Basic_Unarchiving: .init(
version: _versionWithMeta,
installState: .installing(.unarchiving),
selected: false,
icon: nil,
sdks: nil,
compilers: nil
),
]
var downloadableRuntimes: [DownloadableRuntime] = {
var runtimes = try! JSONDecoder().decode([DownloadableRuntime].self, from: Current.files.contents(atPath: Path.runtimeCacheFile.string)!)
// set iOS to installed
let iOSIndex = 0//runtimes.firstIndex { $0.sdkBuildUpdate.contains == "19E239" }!
var iOSRuntime = runtimes[iOSIndex]
iOSRuntime.installState = .installed
runtimes[iOSIndex] = iOSRuntime
let watchOSIndex = 0//runtimes.firstIndex { $0.sdkBuildUpdate.first == "20R362" }!
var runtime = runtimes[watchOSIndex]
runtime.installState = .installing(
RuntimeInstallationStep.downloading(
progress:configure(Progress()) {
$0.kind = .file
$0.fileOperationKind = .downloading
$0.estimatedTimeRemaining = 123
$0.totalUnitCount = 11_944_848_484
$0.completedUnitCount = 848_444_920
$0.throughput = 9_211_681
}
)
)
runtimes[watchOSIndex] = runtime
return runtimes
}()
var installedRuntimes: [CoreSimulatorImage] = {
[CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7475", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "19E240")),
CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7473", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "21N5233f"))]
}()
private let _versionNoMeta = Version(major: 12, minor: 3, patch: 0)
private let _versionWithMeta = Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"])
private let _path = "/Applications/Xcode-12.3.0.app"
private let _requiredMacOSVersion = "10.15.4"
private let _sdks = SDKs(
macOS: .init(number: "11.1"),
iOS: .init(number: "15.4", "19E239"),
watchOS: .init(number: "7.3", "20R362"),
tvOS: .init(number: "14.3", "20K67"),
visionOS: .init(number: "1.0", "21N5233e")
)
private let _compilers = Compilers(
gcc: .init(number: "4"),
llvm_gcc: .init(number: "213"),
llvm: .init(number: "2.3"),
clang: .init(number: "7.3"),
swift: .init(number: "5.3.2")
)
private let _downloadFileSize: Int64 = 242_342_424

View file

@ -1,54 +0,0 @@
//
// InfoPaneControls.swift
// Xcodes
//
// Created by Duong Thai on 14/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
struct InfoPaneControls: View {
let xcode: Xcode
var body: some View {
VStack (alignment: .leading) {
switch xcode.installState {
case .notInstalled:
HStack {
Spacer()
NotInstalledStateButtons(
downloadFileSizeString: xcode.downloadFileSizeString,
id: xcode.id)
}
case .installing(let installationStep):
HStack(alignment: .top) {
InstallationStepDetailView(installationStep: installationStep)
.frame(maxWidth: .infinity, alignment: .leading)
CancelInstallButton(xcode: xcode)
}
case .installed(_):
InstalledStateButtons(xcode: xcode)
}
}
}
}
#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) }
#Preview(XcodePreviewName.allCases[1].rawValue) { makePreviewContent(for: 1) }
#Preview(XcodePreviewName.allCases[2].rawValue) { makePreviewContent(for: 2) }
#Preview(XcodePreviewName.allCases[3].rawValue) { makePreviewContent(for: 3) }
#Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) }
#Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) }
private func makePreviewContent(for index: Int) -> some View {
let name = XcodePreviewName.allCases[index]
return InfoPaneControls(xcode: xcodeDict[name]!)
.environmentObject(configure(AppState()) {
$0.allXcodes = [xcodeDict[name]!]
})
.frame(width: 500)
.padding()
}

View file

@ -1,12 +1,11 @@
import SwiftUI import SwiftUI
import XcodesKit
struct InstallationStepDetailView: View { struct InstallationStepDetailView: View {
let installationStep: XcodeInstallationStep let installationStep: InstallationStep
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message)) Text("Step \(installationStep.stepNumber) of \(installationStep.stepCount): \(installationStep.message)")
switch installationStep { switch installationStep {
case let .downloading(progress): case let .downloading(progress):
@ -17,7 +16,7 @@ struct InstallationStepDetailView: View {
showsAdditionalDescription: true showsAdditionalDescription: true
) )
case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing: case .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
ProgressView() ProgressView()
.scaleEffect(0.5) .scaleEffect(0.5)
} }
@ -25,25 +24,25 @@ struct InstallationStepDetailView: View {
} }
} }
#Preview("Downloading") { struct InstallDetailView_Previews: PreviewProvider {
InstallationStepDetailView( static var previews: some View {
installationStep: .downloading( Group {
progress: configure(Progress()) { InstallationStepDetailView(
$0.kind = .file installationStep: .downloading(
$0.fileOperationKind = .downloading progress: configure(Progress()) {
$0.estimatedTimeRemaining = 123 $0.kind = .file
$0.totalUnitCount = 11944848484 $0.fileOperationKind = .downloading
$0.completedUnitCount = 848444920 $0.estimatedTimeRemaining = 123
$0.throughput = 9211681 $0.totalUnitCount = 11944848484
} $0.completedUnitCount = 848444920
) $0.throughput = 9211681
) }
.padding() )
} )
#Preview("Unarchiving") { InstallationStepDetailView(
InstallationStepDetailView( installationStep: .unarchiving
installationStep: .unarchiving )
) }
.padding() }
} }

View file

@ -1,76 +0,0 @@
//
// InstallingStateButtons.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
import Version
import XcodesKit
import Path
struct InstalledStateButtons: View {
let xcode: Xcode
@EnvironmentObject var appState: AppState
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(xcode.installedPath?.string ?? "")
Button(action: { appState.reveal(xcode.installedPath) }) {
Image(systemName: "arrow.right.circle.fill")
}
.buttonStyle(PlainButtonStyle())
.help("RevealInFinder")
}
HStack {
SelectButton(xcode: xcode)
.disabled(xcode.selected)
.help("Selected")
OpenButton(xcode: xcode)
.help("Open")
Spacer()
UninstallButton(xcode: xcode)
}
}
}
}
#Preview {
InstalledStateButtons(xcode: xcode)
.environmentObject(configure(AppState()) {
$0.allXcodes = [xcode]
})
.padding()
.frame(width: 300)
}
private let xcode = Xcode(
version: Version(major: 12, minor: 3, patch: 0),
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
selected: true,
icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"),
requiredMacOSVersion: "10.15.4",
releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!,
releaseDate: Date(),
sdks: SDKs(
macOS: .init(number: "11.1"),
iOS: .init(number: "14.3"),
watchOS: .init(number: "7.3"),
tvOS: .init(number: "14.3")
),
compilers: Compilers(
gcc: .init(number: "4"),
llvm_gcc: .init(number: "213"),
llvm: .init(number: "2.3"),
clang: .init(number: "7.3"),
swift: .init(number: "5.3.2")
),
downloadFileSize: 242342424
)

View file

@ -1,48 +0,0 @@
//
// NotInstalledStateButtonsView.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
import Version
struct NotInstalledStateButtons: View {
let downloadFileSizeString: String?
let id: XcodeID
@EnvironmentObject var appState: AppState
var body: some View {
VStack(alignment: .leading) {
Button {
appState.checkMinVersionAndInstall(id: id)
} label: {
if id.architectures?.isAppleSilicon ?? false {
Text("Install Apple Silicon").help("Install")
} else {
Text("Install Universal").help("Install")
}
}
if let size = downloadFileSizeString {
Text("DownloadSize")
.font(.headline)
Text(size)
.font(.subheadline)
} else {
EmptyView()
}
}
}
}
#Preview {
NotInstalledStateButtons(
downloadFileSizeString: "1,19 GB",
id: XcodeID(version: Version(major: 12, minor: 3, patch: 0), architectures: nil)
)
.padding()
}

View file

@ -1,142 +0,0 @@
//
// PlatformsView.swift
// Xcodes
//
// Created by Matt Kiazyk on 2023-12-18.
//
import Foundation
import SwiftUI
import XcodesKit
struct PlatformsView: View {
@EnvironmentObject var appState: AppState
@AppStorage("selectedRuntimeArchitecture") private var selectedVariant: ArchitectureVariant = .universal
let xcode: Xcode
var body: some View {
let builds = xcode.sdks?.allBuilds()
let runtimes = builds?.flatMap { sdkBuild in
appState.downloadableRuntimes.filter {
$0.sdkBuildUpdate?.contains(sdkBuild) ?? false &&
($0.architectures?.isEmpty ?? true ||
($0.architectures?.isUniversal ?? false && selectedVariant == .universal) ||
($0.architectures?.isAppleSilicon ?? false && selectedVariant == .appleSilicon)
)
}
}
let architectures = Set((runtimes ?? []).flatMap { $0.architectures ?? [] })
VStack {
HStack {
Text("Platforms")
.font(.title3)
.frame(maxWidth: .infinity, alignment: .leading)
if !architectures.isEmpty {
Spacer()
Picker("Architecture", selection: $selectedVariant) {
ForEach(ArchitectureVariant.allCases, id: \.self) { arch in
Label(arch.displayString, systemImage: arch.iconName)
.tag(arch)
}
.labelStyle(.trailingIcon)
}
.pickerStyle(.menu)
.menuStyle(.button)
.buttonStyle(.borderless)
.fixedSize()
.labelsHidden()
}
}
ForEach(runtimes ?? [], id: \.identifier) { runtime in
runtimeView(runtime: runtime)
.frame(minWidth: 200)
.padding()
.background(.quinary)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
}
}
.xcodesBackground()
}
@ViewBuilder
func runtimeView(runtime: DownloadableRuntime) -> some View {
VStack(spacing: 10) {
HStack {
runtime.icon()
Text("\(runtime.visibleIdentifier)")
.font(.headline)
ForEach(runtime.architectures ?? [], id: \.self) { architecture in
TagView(text: architecture.displayString)
}
pathIfAvailable(xcode: xcode, runtime: runtime)
if runtime.installState == .notInstalled {
// TODO: Update the downloadableRuntimes with the appropriate installState so we don't have to check path awkwardly
if appState.runtimeInstallPath(xcode: xcode, runtime: runtime) != nil {
EmptyView()
} else {
HStack {
Spacer()
DownloadRuntimeButton(runtime: runtime)
}
}
}
Spacer()
Text(runtime.downloadFileSizeString)
.font(.subheadline)
.frame(width: 70, alignment: .trailing)
}
if case let .installing(installationStep) = runtime.installState {
HStack(alignment: .top, spacing: 5){
RuntimeInstallationStepDetailView(installationStep: installationStep)
.fixedSize(horizontal: false, vertical: true)
Spacer()
CancelRuntimeInstallButton(runtime: runtime)
}
}
}
}
@ViewBuilder
func pathIfAvailable(xcode: Xcode, runtime: DownloadableRuntime) -> some View {
if let path = appState.runtimeInstallPath(xcode: xcode, runtime: runtime) {
Button(action: { appState.reveal(path: path.string) }) {
Image(systemName: "arrow.right.circle.fill")
}
.buttonStyle(PlainButtonStyle())
.help("RevealInFinder")
} else {
EmptyView()
}
}
}
#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) }
private func makePreviewContent(for index: Int) -> some View {
let name = XcodePreviewName.allCases[index]
let runtimes = downloadableRuntimes
return PlatformsView(xcode: xcodeDict[name]!)
.environmentObject({ () -> AppState in
let a = AppState()
a.allXcodes = [xcodeDict[name]!]
a.installedRuntimes = installedRuntimes
a.downloadableRuntimes = runtimes
return a
}())
.frame(width: 300)
.padding()
}

View file

@ -1,46 +0,0 @@
//
// ReleaseDateView.swift
// Xcodes
//
// Created by Duong Thai on 11/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
struct ReleaseDateView: View {
let date: Date?
let url: URL?
var body: some View {
if let date = date {
VStack(alignment: .leading) {
HStack {
Text("ReleaseDate")
.font(.headline)
Spacer()
if let url {
ReleaseNotesView(url: url)
}
}
Text("\(date, style: .date)")
.font(.subheadline)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
} else {
EmptyView()
}
}
}
#Preview {
ReleaseDateView(date: Date(), url: URL(string: "https://www.xcodes.app")!)
.padding()
}

View file

@ -1,38 +0,0 @@
//
// ReleaseNotesView.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
struct ReleaseNotesView: View {
let url: URL?
@SwiftUI.Environment(\.openURL) var openURL: OpenURLAction
var body: some View {
if let url = url {
Button(action: { openURL(url) }) {
Image(systemName: "link.circle.fill")
.font(.title)
}
.buttonStyle(.plain)
.contextMenu(menuItems: {
CopyReleaseNoteButton(url: url)
})
.help("ReleaseNotes.help")
} else {
EmptyView()
}
}
}
#Preview {
let url = URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!
return ReleaseNotesView(url: url)
.padding()
}

View file

@ -1,57 +0,0 @@
//
// RuntimeInstallationStepDetailView.swift
// Xcodes
//
// Created by Matt Kiazyk on 2023-11-23.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
import XcodesKit
struct RuntimeInstallationStepDetailView: View {
let installationStep: RuntimeInstallationStep
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message))
switch installationStep {
case let .downloading(progress):
ObservingProgressIndicator(
progress,
controlSize: .regular,
style: .bar,
showsAdditionalDescription: true
)
case .installing, .trashingArchive:
ObservingProgressIndicator(
Progress(),
controlSize: .regular,
style: .bar,
showsAdditionalDescription: false
)
}
}
}
}
#Preview("Downloading") {
RuntimeInstallationStepDetailView(
installationStep: .downloading(
progress: configure(Progress()) {
$0.kind = .file
$0.fileOperationKind = .downloading
$0.estimatedTimeRemaining = 123
$0.totalUnitCount = 11944848484
$0.completedUnitCount = 848444920
$0.throughput = 9211681
}
))
}
#Preview("Installing") {
RuntimeInstallationStepDetailView(
installationStep: .installing
)
}

View file

@ -1,70 +0,0 @@
//
// SDKsView.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
import XcodesKit
struct SDKsView: View {
let content: String
var body: some View {
if content.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading) {
Text("SDKs").font(.headline)
Text(content)
.font(.subheadline)
.textSelection(.enabled)
}
}
}
init(sdks: SDKs?) {
guard let sdks = sdks else {
self.content = ""
return
}
let content = Self.content(from: sdks)
self.content = content
}
static private func content(from sdks: SDKs) -> String {
let content: String = [
("macOS", sdks.macOS),
("iOS", sdks.iOS),
("watchOS", sdks.watchOS),
("tvOS", sdks.tvOS)
].compactMap { // remove nil compiler
guard $0.1 != nil, // has version array
!$0.1!.isEmpty // has at least 1 version
else { return nil }
let numbers = $0.1!.compactMap { $0.number } // remove nil number
guard !numbers.isEmpty // has at least 1 number
else { return nil }
// description for each type of compilers
return "\($0.0): \(numbers.joined(separator: ", "))"
}.joined(separator: "\n")
.trimmingCharacters(in: .whitespaces)
return content
}
}
#Preview {
let sdks = SDKs(
macOS: .init(number: "11.1"),
iOS: .init(number: "14.3"),
watchOS: .init(number: "7.3"),
tvOS: .init(number: "14.3"))
return SDKsView(sdks: sdks)
.padding()
}

View file

@ -1,26 +0,0 @@
//
// UnselectedView.swift
// Xcodes
//
// Created by Duong Thai on 13/10/2023.
// Copyright © 2023 Robots and Pencils. All rights reserved.
//
import SwiftUI
struct UnselectedView: View {
var body: some View {
VStack {
Spacer()
Text("NoXcodeSelected")
.font(.title)
.foregroundColor(.secondary)
Spacer()
}
}
}
#Preview {
UnselectedView()
.padding()
}

View file

@ -1,8 +1,5 @@
import ErrorHandling import ErrorHandling
import SwiftUI import SwiftUI
import XcodesKit
import Path
import Version
struct MainWindow: View { struct MainWindow: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@ -15,58 +12,31 @@ struct MainWindow: View {
// FB8979533 SceneStorage doesn't restore value after app is quit by user // FB8979533 SceneStorage doesn't restore value after app is quit by user
@AppStorage("isShowingInfoPane") private var isShowingInfoPane = false @AppStorage("isShowingInfoPane") private var isShowingInfoPane = false
@AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all @AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all
@AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .universal
@AppStorage("isInstalledOnly") private var isInstalledOnly = false @AppStorage("isInstalledOnly") private var isInstalledOnly = false
var body: some View { var body: some View {
NavigationSplitViewWrapper { HSplitView {
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly, architecture: architecture) XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly)
.frame(minWidth: 300)
.layoutPriority(1) .layoutPriority(1)
.alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in .alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in
Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)), Alert(title: Text("Uninstall Xcode \(xcode.description)?"),
message: Text("Alert.Uninstall.Message"), message: Text("It will be moved to the Trash, but won't be emptied."),
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }), primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }),
secondaryButton: .cancel(Text("Cancel"))) secondaryButton: .cancel(Text("Cancel")))
} }
.searchable(text: $searchText, placement: .sidebar)
.mainToolbar( if isShowingInfoPane {
category: $category, InfoPane(selectedXcodeID: selectedXcodeID)
isInstalledOnly: $isInstalledOnly, .frame(minWidth: 300, maxWidth: .infinity)
isShowingInfoPane: $isShowingInfoPane,
architecture: $architecture
)
} detail: {
Group {
if let xcode = xcode {
InfoPane(xcode: xcode)
} else {
UnselectedView()
}
}
.toolbar {
ToolbarItemGroup {
Button(action: { appState.presentedSheet = .signIn }, label: {
Label("Login", systemImage: "person.circle")
})
.help("LoginDescription")
if #available(macOS 14, *) {
SettingsLink(label: {
Label("Preferences", systemImage: "gearshape")
})
.help("PreferencesDescription")
} else {
Button(action: {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}, label: {
Label("Preferences", systemImage: "gearshape")
})
.help("PreferencesDescription")
}
}
} }
} }
.bottomStatusBar() .mainToolbar(
.padding([.top], 0) category: $category,
isInstalledOnly: $isInstalledOnly,
isShowingInfoPane: $isShowingInfoPane,
searchText: $searchText
)
.navigationSubtitle(subtitleText) .navigationSubtitle(subtitleText)
.frame(minWidth: 600, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity) .frame(minWidth: 600, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
.emittingError($appState.error, recoveryHandler: { _ in }) .emittingError($appState.error, recoveryHandler: { _ in })
@ -75,11 +45,8 @@ struct MainWindow: View {
case .signIn: case .signIn:
signInView() signInView()
.environmentObject(appState) .environmentObject(appState)
case .twoFactor(let secondFactorData): case .twoFactor:
secondFactorView(secondFactorData) secondFactorView(appState.secondFactorData!)
.environmentObject(appState)
case .securityKeyTouchToConfirm:
SignInSecurityKeyTouchView(isPresented: $appState.presentedSheet.isNotNil)
.environmentObject(appState) .environmentObject(appState)
} }
} }
@ -90,30 +57,24 @@ struct MainWindow: View {
// FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue // FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue
.focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == selectedXcodeID })) .focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == selectedXcodeID }))
} }
private var xcode: Xcode? {
appState.allXcodes.first(where: { $0.id == selectedXcodeID })
}
private var subtitleText: Text { private var subtitleText: Text {
if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) { if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) {
return Text("\(localizeString("UpdatedAt")) \(lastUpdated, style: .date) \(lastUpdated, style: .time)") return Text("Updated at \(lastUpdated, style: .date) \(lastUpdated, style: .time)")
} else { } else {
return Text("") return Text("")
} }
} }
@ViewBuilder @ViewBuilder
private func secondFactorView(_ secondFactorData: XcodesSheet.SecondFactorData) -> some View { private func secondFactorView(_ secondFactorData: AppState.SecondFactorData) -> some View {
switch secondFactorData.option { switch secondFactorData.option {
case .codeSent: case .codeSent:
SignIn2FAView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) SignIn2FAView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .smsSent(let trustedPhoneNumber): case .smsSent(let trustedPhoneNumber):
SignInSMSView(isPresented: $appState.presentedSheet.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) SignInSMSView(isPresented: $appState.secondFactorData.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .smsPendingChoice: case .smsPendingChoice:
SignInPhoneListView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .securityKey:
SignInSecurityKeyPinView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
} }
} }
@ -140,10 +101,10 @@ struct MainWindow: View {
switch alertType { switch alertType {
case let .cancelInstall(xcode): case let .cancelInstall(xcode):
return Alert( return Alert(
title: Text(String(format: localizeString("Alert.CancelInstall.Title"), xcode.description)), title: Text("Are you sure you want to stop the installation of Xcode \(xcode.description)?"),
message: Text("Alert.CancelInstall.Message"), message: Text("Any progress will be discarded."),
primaryButton: .destructive( primaryButton: .destructive(
Text("Alert.CancelInstall.PrimaryButton"), Text("Stop Installation"),
action: { action: {
self.appState.cancelInstall(id: xcode.id) self.appState.cancelInstall(id: xcode.id)
} }
@ -152,8 +113,8 @@ struct MainWindow: View {
) )
case .privilegedHelper: case .privilegedHelper:
return Alert( return Alert(
title: Text("Alert.PrivilegedHelper.Title"), title: Text("Privileged Helper"),
message: Text("Alert.PrivilegedHelper.Message"), message: Text("Xcodes uses a separate privileged helper to perform tasks as root. These are things that would require sudo on the command line, including post-install steps and switching Xcode versions with xcode-select.\n\nYou'll be prompted for your macOS account password to install it."),
primaryButton: .default(Text("Install"), action: { primaryButton: .default(Text("Install"), action: {
// The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed. // The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed.
// We need to capture it to be invoked after that happens. // We need to capture it to be invoked after that happens.
@ -186,66 +147,16 @@ struct MainWindow: View {
title: Text(title), title: Text(title),
message: Text(message), message: Text(message),
dismissButton: .default( dismissButton: .default(
Text("OK"), Text("Ok"),
action: { appState.presentedAlert = nil } action: { appState.presentedAlert = nil }
) )
) )
case .unauthenticated:
return Alert(
title: Text("Alert.Install.Error.Title"),
message: Text("Alert.Install.AuthError.Message"),
primaryButton: .default(
Text("OK"),
action: {
appState.presentedSheet = .signIn
}
),
secondaryButton: .cancel(
Text("Cancel")
)
)
case let .checkMinSupportedVersion(xcode, deviceVersion):
return Alert(
title: Text("Alert.MinSupported.Title"),
message: Text(String(format: localizeString("Alert.MinSupported.Message"), xcode.xcodeID.version.descriptionWithoutBuildMetadata, xcode.requiredMacOSVersion ?? "", deviceVersion)),
primaryButton: .default(
Text("Install"),
action: {
self.appState.install(id: xcode.xcodeID)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
case let .cancelRuntimeInstall(runtime):
return Alert(
title: Text(String(format: localizeString("Alert.CancelInstall.Runtimes.Title"), runtime.name)),
message: Text("Alert.CancelInstall.Message"),
primaryButton: .destructive(
Text("Alert.CancelInstall.PrimaryButton"),
action: {
self.appState.cancelRuntimeInstall(runtime: runtime)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
} }
} }
} }
struct MainWindow_Previews: PreviewProvider { struct MainWindow_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
MainWindow().environmentObject({ () -> AppState in MainWindow()
let a = AppState()
a.allXcodes = [
Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [XcodeID(version: Version("12.0.0+1234A")!), XcodeID(version: Version("12.0.0-RC+1234A")!)], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil),
Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil),
Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil),
Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
]
return a
}())
} }
} }

View file

@ -4,58 +4,24 @@ import Path
struct AdvancedPreferencePane: View { struct AdvancedPreferencePane: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases
@AppStorage("downloader") var downloader: Downloader = .aria2
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
GroupBox(label: Text("InstallDirectory")) { GroupBox(label: Text("Local Cache Path")) {
VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 5) {
Text(appState.installPath).font(.footnote)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
Button(action: { appState.reveal(path: appState.installPath) }) {
Image(systemName: "arrow.right.circle.fill")
}
.buttonStyle(PlainButtonStyle())
.help("RevealInFinder")
.fixedSize()
}
Button("Change") {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowedContentTypes = [.folder]
panel.directoryURL = URL(fileURLWithPath: appState.installPath)
if panel.runModal() == .OK {
guard let pathURL = panel.url, let path = Path(url: pathURL) else { return }
self.appState.installPath = path.string
}
}
.disabled(appState.disableInstallPathChange)
Text("InstallPathDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.groupBoxStyle(PreferencesGroupBoxStyle())
GroupBox(label: Text("LocalCachePath")) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 5) { HStack(alignment: .top, spacing: 5) {
Text(appState.localPath).font(.footnote) Text(appState.localPath).font(.footnote)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(2) .lineLimit(2)
Button(action: { appState.reveal(path: appState.localPath) }) { Button(action: { appState.reveal(path: appState.localPath) }) {
Image(systemName: "arrow.right.circle.fill") Image(systemName: "arrow.right.circle.fill")
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.help("RevealInFinder") .help("Reveal in Finder")
.fixedSize() .fixedSize()
} }
Button("Change") { Button("Change") {
@ -68,86 +34,70 @@ struct AdvancedPreferencePane: View {
panel.directoryURL = URL(fileURLWithPath: appState.localPath) panel.directoryURL = URL(fileURLWithPath: appState.localPath)
if panel.runModal() == .OK { if panel.runModal() == .OK {
guard let pathURL = panel.url, let path = Path(url: pathURL) else { return } guard let pathURL = panel.url, let path = Path(url: pathURL) else { return }
self.appState.localPath = path.string self.appState.localPath = path.string
} }
} }
.disabled(appState.disableLocalPathChange) Text("Xcodes caches available Xcode versions and temporary downloads new versions to a directory")
Text("LocalCachePathDescription")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
.groupBoxStyle(PreferencesGroupBoxStyle()) .groupBoxStyle(PreferencesGroupBoxStyle())
GroupBox(label: Text("Active/Select")) { GroupBox(label: Text("Data Source")) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Picker(selection: $appState.onSelectActionType) { Picker("Data Source", selection: $dataSource) {
ForEach(DataSource.allCases) { dataSource in
Text(SelectedActionType.none.description) Text(dataSource.description)
.tag(SelectedActionType.none) .tag(dataSource)
Text(SelectedActionType.rename.description) }
.tag(SelectedActionType.rename)
} label: {
Text(verbatim: "OnSelect")
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.inline)
.disabled(appState.onSelectActionTypeDisabled)
Text(appState.onSelectActionType.detailedDescription)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
.frame(height: 20)
Toggle("AutomaticallyCreateSymbolicLink", isOn: $appState.createSymLinkOnSelect) AttributedText(dataSourceFootnote)
.disabled(appState.createSymLinkOnSelectDisabled)
Text("AutomaticallyCreateSymbolicLinkDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
.fixedSize(horizontal: false, vertical: true)
} }
.groupBoxStyle(PreferencesGroupBoxStyle()) .groupBoxStyle(PreferencesGroupBoxStyle())
if Hardware.isAppleSilicon() { GroupBox(label: Text("Downloader")) {
GroupBox(label: Text("Apple Silicon")) { VStack(alignment: .leading) {
Toggle("ShowOpenInRosetta", isOn: $appState.showOpenInRosettaOption) Picker("Downloader", selection: $downloader) {
.disabled(appState.createSymLinkOnSelectDisabled) ForEach(Downloader.allCases) { downloader in
Text("ShowOpenInRosettaDescription") Text(downloader.description)
.font(.footnote) .tag(downloader)
.foregroundStyle(.secondary) }
.fixedSize(horizontal: false, vertical: true) }
.labelsHidden()
AttributedText(downloaderFootnote)
} }
.groupBoxStyle(PreferencesGroupBoxStyle())
} }
.groupBoxStyle(PreferencesGroupBoxStyle())
GroupBox(label: Text("PrivilegedHelper")) { GroupBox(label: Text("Privileged Helper")) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
switch appState.helperInstallState { switch appState.helperInstallState {
case .unknown: case .unknown:
ProgressView() ProgressView()
.scaleEffect(0.5, anchor: .center) .scaleEffect(0.5, anchor: .center)
case .installed: case .installed:
Text("HelperInstalled") Text("Helper is installed")
case .notInstalled: case .notInstalled:
VStack(alignment: .leading) { HStack {
Button("InstallHelper") { Text("Helper is not installed")
Button("Install helper") {
appState.installHelperIfNecessary() appState.installHelperIfNecessary()
} }
Text("HelperNotInstalled")
.font(.footnote)
} }
} }
Text("PrivilegedHelperDescription") Text("Xcodes uses a separate privileged helper to perform tasks as root. These are things that would require sudo on the command line, including post-install steps and switching Xcode versions with xcode-select.\n\nYou'll be prompted for your macOS account password to install it.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Spacer() Spacer()
@ -155,6 +105,41 @@ struct AdvancedPreferencePane: View {
} }
.groupBoxStyle(PreferencesGroupBoxStyle()) .groupBoxStyle(PreferencesGroupBoxStyle())
} }
.frame(width: 500)
}
private var dataSourceFootnote: NSAttributedString {
let string = """
The Apple data source scrapes the Apple Developer website. It will always show the latest releases that are available, but is more fragile.
Xcode Releases is an unofficial list of Xcode releases. It's provided as well-formed data, contains extra information that is not readily available from Apple, and is less likely to break if Apple redesigns their developer website.
"""
let attributedString = NSMutableAttributedString(
string: string,
attributes: [
.font: NSFont.preferredFont(forTextStyle: .footnote, options: [:]),
.foregroundColor: NSColor.labelColor
]
)
attributedString.addAttribute(.link, value: URL(string: "https://xcodereleases.com")!, range: NSRange(string.range(of: "Xcode Releases")!, in: string))
return attributedString
}
private var downloaderFootnote: NSAttributedString {
let string = """
aria2 uses up to 16 connections to download Xcode 3-5x faster than URLSession. It's bundled as an executable along with its source code within Xcodes to comply with its GPLv2 license.
URLSession is the default Apple API for making URL requests.
"""
let attributedString = NSMutableAttributedString(
string: string,
attributes: [
.font: NSFont.preferredFont(forTextStyle: .footnote, options: [:]),
.foregroundColor: NSColor.labelColor
]
)
attributedString.addAttribute(.link, value: URL(string: "https://github.com/aria2/aria2")!, range: NSRange(string.range(of: "aria2")!, in: string))
return attributedString
} }
} }
@ -163,9 +148,7 @@ struct AdvancedPreferencePane_Previews: PreviewProvider {
Group { Group {
AdvancedPreferencePane() AdvancedPreferencePane()
.environmentObject(AppState()) .environmentObject(AppState())
.frame(maxWidth: 600)
} }
.frame(width: 600, height: 700, alignment: .center)
} }
} }
@ -173,8 +156,11 @@ struct AdvancedPreferencePane_Previews: PreviewProvider {
struct PreferencesGroupBoxStyle: GroupBoxStyle { struct PreferencesGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .top, spacing: 20) { HStack(alignment: .top, spacing: 20) {
configuration.label HStack {
.frame(width: 180, alignment: .trailing) Spacer()
configuration.label
}
.frame(width: 120)
VStack(alignment: .leading) { VStack(alignment: .leading) {
configuration.content configuration.content

View file

@ -1,64 +0,0 @@
import AppleAPI
import SwiftUI
struct DownloadPreferencePane: View {
@EnvironmentObject var appState: AppState
@AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases
@AppStorage("downloader") var downloader: Downloader = .aria2
var body: some View {
VStack(alignment: .leading) {
GroupBox(label: Text("DataSource")) {
VStack(alignment: .leading) {
Picker("DataSource", selection: $dataSource) {
ForEach(DataSource.allCases) { dataSource in
Text(dataSource.description)
.tag(dataSource)
}
}
.labelsHidden()
.fixedSize()
Text("DataSourceDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.groupBoxStyle(PreferencesGroupBoxStyle())
.disabled(dataSource.isManaged)
GroupBox(label: Text("Downloader")) {
VStack(alignment: .leading) {
Picker("Downloader", selection: $downloader) {
ForEach(Downloader.allCases) { downloader in
Text(downloader.description)
.tag(downloader)
}
}
.labelsHidden()
.fixedSize()
Text("DownloaderDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.groupBoxStyle(PreferencesGroupBoxStyle())
.disabled(downloader.isManaged)
}
}
}
struct DownloadPreferencePane_Previews: PreviewProvider {
static var previews: some View {
Group {
DownloadPreferencePane()
.environmentObject(AppState())
.frame(maxWidth: 600)
.frame(minHeight: 300)
}
}
}

View file

@ -1,37 +0,0 @@
import AppleAPI
import Path
import SwiftUI
struct ExperimentsPreferencePane: View {
@EnvironmentObject var appState: AppState
var body: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox(label: Text("FasterUnxip")) {
VStack(alignment: .leading) {
Toggle(
"UseUnxipExperiment",
isOn: $appState.unxipExperiment
)
.disabled(appState.disableUnxipExperiment)
Text("FasterUnxipDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.fixedSize(horizontal: false, vertical: true)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
}
}
}
struct ExperimentsPreferencePane_Previews: PreviewProvider {
static var previews: some View {
Group {
ExperimentsPreferencePane()
.environmentObject(AppState())
.frame(maxWidth: 600)
}
}
}

View file

@ -6,11 +6,11 @@ struct GeneralPreferencePane: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
GroupBox(label: Text("AppleID")) { GroupBox(label: Text("Apple ID")) {
if appState.authenticationState == .authenticated { if appState.authenticationState == .authenticated {
SignedInView() SignedInView()
} else { } else {
Button("SignIn", action: { self.appState.presentedSheet = .signIn }) Button("Sign In", action: { self.appState.presentedSheet = .signIn })
} }
} }
.groupBoxStyle(PreferencesGroupBoxStyle()) .groupBoxStyle(PreferencesGroupBoxStyle())
@ -20,13 +20,8 @@ struct GeneralPreferencePane: View {
NotificationsView().environmentObject(appState) NotificationsView().environmentObject(appState)
} }
.groupBoxStyle(PreferencesGroupBoxStyle()) .groupBoxStyle(PreferencesGroupBoxStyle())
Divider()
GroupBox(label: Text("Misc")) {
Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
} }
.frame(width: 500)
} }
} }
@ -35,7 +30,6 @@ struct GeneralPreferencePane_Previews: PreviewProvider {
Group { Group {
GeneralPreferencePane() GeneralPreferencePane()
.environmentObject(AppState()) .environmentObject(AppState())
.frame(maxWidth: 600)
} }
} }
} }

View file

@ -8,17 +8,17 @@ struct NotificationsView: View {
switch Current.notificationManager.notificationStatus { switch Current.notificationManager.notificationStatus {
case .shownAndAccepted: case .shownAndAccepted:
Text("AccessGranted") Text("Access Granted. You will receive notifications from Xcodes.")
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
case .shownAndDenied: case .shownAndDenied:
Text("AccessDenied") Text("⚠️ Access Denied ⚠️\n\nPlease open your Notification Settings and select Xcodes if you wish to allow access.")
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Button("NotificationSettings", action: { Button("Notification Settings", action: {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!) NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!)
}) })
default: default:
Button("EnableNotifications", action: { Button("Enable Notifications", action: {
Current.notificationManager.requestAccess() Current.notificationManager.requestAccess()
}) })
} }

View file

@ -1,88 +0,0 @@
//
// PlatformsListView.swift
// Xcodes
//
// Created by Matt Kiazyk on 2023-12-20.
//
import Foundation
import SwiftUI
import Path
import XcodesKit
import OrderedCollections
struct PlatformsListView: View {
@EnvironmentObject var appState: AppState
@State private var runtimes: OrderedDictionary<DownloadableRuntime.Platform, [DownloadableRuntime]> = [:]
@State private var selectedRuntime: DownloadableRuntime?
var body: some View {
List(selection: $selectedRuntime) {
Text("PlatformsList.Title")
.font(.body)
ForEach(runtimes.elements.sorted(\.key.order), id: \.key) { platform, runtimeList in
Section {
ForEach(runtimeList, id: \.self) { runtime in
HStack {
Text(runtime.name)
Spacer()
Text(runtime.downloadFileSizeString)
Button {
deleteRuntime(runtime: runtime)
} label: {
Image(systemName: "trash")
}
.foregroundStyle(.red)
.buttonStyle(.plain)
}
.frame(height: 30)
}
} header: {
HStack {
runtimeList.first!.icon()
.aspectRatio(contentMode: .fit)
.frame(width: 20)
Text(platform.shortName)
.font(.headline)
}
} footer: {
EmptyView()
}
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
.task {
loadRuntimes()
}
.onChange(of: appState.installedRuntimes) { _ in
loadRuntimes()
}
}
func loadRuntimes() {
let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in
appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate
}
}
runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.platform })
}
func deleteRuntime(runtime: DownloadableRuntime) {
appState.presentedPreferenceAlert = .deletePlatform(runtime: runtime)
}
}
#Preview {
PlatformsListView()
.environmentObject({ () -> AppState in
let a = AppState()
a.installedRuntimes = installedRuntimes
a.downloadableRuntimes = downloadableRuntimes
return a
}())
}

View file

@ -2,10 +2,9 @@ import SwiftUI
struct PreferencesView: View { struct PreferencesView: View {
private enum Tabs: Hashable { private enum Tabs: Hashable {
case general, updates, advanced, experiment case general, updates, advanced
} }
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var updater: ObservableUpdater
var body: some View { var body: some View {
TabView { TabView {
@ -16,29 +15,17 @@ struct PreferencesView: View {
} }
.tag(Tabs.general) .tag(Tabs.general)
UpdatesPreferencePane() UpdatesPreferencePane()
.environmentObject(updater)
.tabItem { .tabItem {
Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle") Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle")
} }
.tag(Tabs.updates) .tag(Tabs.updates)
DownloadPreferencePane()
.environmentObject(appState)
.tabItem {
Label("Downloads", systemImage: "icloud.and.arrow.down")
}
AdvancedPreferencePane() AdvancedPreferencePane()
.environmentObject(appState) .environmentObject(appState)
.tabItem { .tabItem {
Label("Advanced", systemImage: "gearshape.2") Label("Advanced", systemImage: "gearshape.2")
} }
.tag(Tabs.advanced) .tag(Tabs.advanced)
ExperimentsPreferencePane()
.tabItem {
Label("Experiments", systemImage: "lightbulb")
}
.tag(Tabs.experiment)
} }
.padding(20) .padding(20)
.frame(width: 600)
} }
} }

View file

@ -3,7 +3,7 @@ import Sparkle
import SwiftUI import SwiftUI
struct UpdatesPreferencePane: View { struct UpdatesPreferencePane: View {
@EnvironmentObject var updater: ObservableUpdater @StateObject var updater = ObservableUpdater()
@AppStorage("autoInstallation") var autoInstallationType: AutoInstallationType = .none @AppStorage("autoInstallation") var autoInstallationType: AutoInstallationType = .none
@ -12,16 +12,14 @@ struct UpdatesPreferencePane: View {
GroupBox(label: Text("Versions")) { GroupBox(label: Text("Versions")) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Toggle( Toggle(
"AutomaticInstallNewVersion", "Automatically install new versions of Xcode",
isOn: $autoInstallationType.isAutoInstalling isOn: $autoInstallationType.isAutoInstalling
) )
.disabled(updater.disableAutoInstallNewVersions)
Toggle( Toggle(
"IncludePreRelease", "Include prerelease/beta versions",
isOn: $autoInstallationType.isAutoInstallingBeta isOn: $autoInstallationType.isAutoInstallingBeta
) )
.disabled(updater.disableIncludePrereleaseVersions)
} }
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@ -29,42 +27,37 @@ struct UpdatesPreferencePane: View {
Divider() Divider()
GroupBox(label: Text("AppUpdates")) { GroupBox(label: Text("Xcodes.app Updates")) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Toggle( Toggle(
"CheckForAppUpdates", "Automatically check for app updates",
isOn: $updater.automaticallyChecksForUpdates isOn: $updater.automaticallyChecksForUpdates
) )
.fixedSize(horizontal: true, vertical: false)
.disabled(updater.disableAutoUpdateXcodesApp)
Toggle( Toggle(
"IncludePreRelease", "Include prerelease app versions",
isOn: $updater.includePrereleaseVersions isOn: $updater.includePrereleaseVersions
) )
.disabled(updater.disableAutoUpdateXcodesAppPrereleaseVersions)
Button("Check Now") {
Button("CheckNow") { SUUpdater.shared()?.checkForUpdates(nil)
updater.checkForUpdates()
} }
.padding(.top)
.disabled(updater.disableAutoUpdateXcodesApp) Text("Last checked: \(lastUpdatedString)")
Text(String(format: localizeString("LastChecked"), lastUpdatedString))
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.groupBoxStyle(PreferencesGroupBoxStyle()) .groupBoxStyle(PreferencesGroupBoxStyle())
} }
.frame(width: 500)
} }
private var lastUpdatedString: String { private var lastUpdatedString: String {
if let lastUpdatedDate = updater.lastUpdateCheckDate { if let lastUpdatedDate = updater.lastUpdateCheckDate {
return Self.formatter.string(from: lastUpdatedDate) return Self.formatter.string(from: lastUpdatedDate)
} else { } else {
return localizeString("Never") return "Never"
} }
} }
@ -75,12 +68,9 @@ struct UpdatesPreferencePane: View {
} }
class ObservableUpdater: ObservableObject { class ObservableUpdater: ObservableObject {
private let updater: SPUUpdater
private let updaterDelegate = UpdaterDelegate()
@Published var automaticallyChecksForUpdates = false { @Published var automaticallyChecksForUpdates = false {
didSet { didSet {
updater.automaticallyChecksForUpdates = automaticallyChecksForUpdates SUUpdater.shared()?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
} }
} }
private var automaticallyChecksForUpdatesObservation: NSKeyValueObservation? private var automaticallyChecksForUpdatesObservation: NSKeyValueObservation?
@ -88,26 +78,18 @@ class ObservableUpdater: ObservableObject {
private var lastUpdateCheckDateObservation: NSKeyValueObservation? private var lastUpdateCheckDateObservation: NSKeyValueObservation?
@Published var includePrereleaseVersions = false { @Published var includePrereleaseVersions = false {
didSet { didSet {
Current.defaults.set(includePrereleaseVersions, forKey: "includePrereleaseVersions") UserDefaults.standard.setValue(includePrereleaseVersions, forKey: "includePrereleaseVersions")
updaterDelegate.includePrereleaseVersions = includePrereleaseVersions if includePrereleaseVersions {
SUUpdater.shared()?.feedURL = .prereleaseAppcast
} else {
SUUpdater.shared()?.feedURL = .appcast
}
} }
} }
var disableAutoInstallNewVersions: Bool { PreferenceKey.autoInstallation.isManaged() }
var disableIncludePrereleaseVersions: Bool { PreferenceKey.autoInstallation.isManaged() }
var disableAutoUpdateXcodesApp: Bool { PreferenceKey.SUEnableAutomaticChecks.isManaged() }
var disableAutoUpdateXcodesAppPrereleaseVersions: Bool { PreferenceKey.includePrereleaseVersions.isManaged() }
init() { init() {
updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: updaterDelegate, userDriverDelegate: nil).updater automaticallyChecksForUpdatesObservation = SUUpdater.shared()?.observe(
// upgrade from an old sparkle version which set feeds via the updater
// now it uses the `updaterDelegate`
updater.clearFeedURLFromUserDefaults()
automaticallyChecksForUpdatesObservation = updater.observe(
\.automaticallyChecksForUpdates, \.automaticallyChecksForUpdates,
options: [.initial, .new, .old], options: [.initial, .new, .old],
changeHandler: { [unowned self] updater, change in changeHandler: { [unowned self] updater, change in
@ -115,37 +97,20 @@ class ObservableUpdater: ObservableObject {
self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates
} }
) )
lastUpdateCheckDateObservation = updater.observe( lastUpdateCheckDateObservation = SUUpdater.shared()?.observe(
\.lastUpdateCheckDate, \.lastUpdateCheckDate,
options: [.initial, .new, .old], options: [.initial, .new, .old],
changeHandler: { [unowned self] updater, change in changeHandler: { [unowned self] updater, change in
self.lastUpdateCheckDate = updater.lastUpdateCheckDate self.lastUpdateCheckDate = updater.lastUpdateCheckDate
} }
) )
includePrereleaseVersions = Current.defaults.bool(forKey: "includePrereleaseVersions") ?? false includePrereleaseVersions = UserDefaults.standard.bool(forKey: "includePrereleaseVersions")
}
func checkForUpdates() {
updater.checkForUpdates()
} }
} }
class UpdaterDelegate: NSObject, SPUUpdaterDelegate { extension URL {
var includePrereleaseVersions: Bool = false static let appcast = URL(string: "https://robotsandpencils.github.io/XcodesApp/appcast.xml")!
static let prereleaseAppcast = URL(string: "https://robotsandpencils.github.io/XcodesApp/appcast_pre.xml")!
func feedURLString(for updater: SPUUpdater) -> String? {
if includePrereleaseVersions {
return .prereleaseAppcast
} else {
return .appcast
}
}
}
extension String {
static let appcast = "https://www.xcodes.app/appcast.xml"
static let prereleaseAppcast = "https://www.xcodes.app/appcast_pre.xml"
} }
struct UpdatesPreferencePane_Previews: PreviewProvider { struct UpdatesPreferencePane_Previews: PreviewProvider {
@ -153,9 +118,6 @@ struct UpdatesPreferencePane_Previews: PreviewProvider {
Group { Group {
UpdatesPreferencePane() UpdatesPreferencePane()
.environmentObject(AppState()) .environmentObject(AppState())
.environmentObject(ObservableUpdater())
.frame(maxWidth: 600)
.frame(minHeight: 300)
} }
} }
} }

View file

@ -6,12 +6,10 @@ struct PinCodeTextField: NSViewRepresentable {
@Binding var code: String @Binding var code: String
let numberOfDigits: Int let numberOfDigits: Int
let complete: (String) -> Void
func makeNSView(context: Context) -> NSViewType { func makeNSView(context: Context) -> NSViewType {
let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10) let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10)
view.codeDidChange = { c in code = c } view.codeDidChange = { c in code = c }
view.codeDidComplete = { complete($0) }
return view return view
} }
@ -31,9 +29,8 @@ struct PinCodeTextField_Previews: PreviewProvider {
struct PreviewContainer: View { struct PreviewContainer: View {
@State private var code = "1234567890" @State private var code = "1234567890"
var body: some View { var body: some View {
PinCodeTextField(code: $code, numberOfDigits: 11) { PinCodeTextField(code: $code, numberOfDigits: 11)
print("Input is complete \($0)") .padding()
}.padding()
} }
} }
@ -55,16 +52,10 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate {
handler(String(code.compactMap { $0 })) handler(String(code.compactMap { $0 }))
} }
updateText() updateText()
if code.compactMap({ $0 }).count == numberOfDigits,
let handler = codeDidComplete {
handler(String(code.compactMap { $0 }))
}
} }
} }
var codeDidChange: ((String) -> Void)? = nil var codeDidChange: ((String) -> Void)? = nil
var codeDidComplete: ((String) -> Void)? = nil
private let numberOfDigits: Int private let numberOfDigits: Int
private let stackView: NSStackView = .init(frame: .zero) private let stackView: NSStackView = .init(frame: .zero)
private var characterViews: [PinCodeCharacterTextField] = [] private var characterViews: [PinCodeCharacterTextField] = []

View file

@ -10,14 +10,11 @@ struct SignIn2FAView: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(String(format: localizeString("DigitCodeDescription"), authOptions.securityCode!.length)) Text("Enter the \(authOptions.securityCode.length) digit code from one of your trusted devices:")
.fixedSize(horizontal: true, vertical: false)
HStack { HStack {
Spacer() Spacer()
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) { PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
appState.submitSecurityCode(.device(code: $0), sessionData: sessionData)
}
Spacer() Spacer()
} }
.padding() .padding()
@ -25,14 +22,14 @@ struct SignIn2FAView: View {
HStack { HStack {
Button("Cancel", action: { isPresented = false }) Button("Cancel", action: { isPresented = false })
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
Button("SendSMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) }) Button("Send SMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) })
Spacer() Spacer()
ProgressButton(isInProgress: appState.isProcessingAuthRequest, ProgressButton(isInProgress: appState.isProcessingAuthRequest,
action: { appState.submitSecurityCode(.device(code: code), sessionData: sessionData) }) { action: { appState.submitSecurityCode(.device(code: code), sessionData: sessionData) }) {
Text("Continue") Text("Continue")
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(code.count != authOptions.securityCode!.length) .disabled(code.count != authOptions.securityCode.length)
} }
.frame(height: 25) .frame(height: 25)
} }

View file

@ -1,33 +1,24 @@
import SwiftUI import SwiftUI
struct SignInCredentialsView: View { struct SignInCredentialsView: View {
private enum FocusedField {
case username, password
}
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var username: String = "" @State private var username: String = ""
@State private var password: String = "" @State private var password: String = ""
@FocusState private var focusedField: FocusedField?
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("SignInWithApple") Text("Sign in with your Apple ID.")
.bold() .bold()
.padding(.vertical) .padding(.vertical)
HStack { HStack {
Text("AppleID") Text("Apple ID:")
.frame(minWidth: 100, alignment: .trailing) .frame(minWidth: 100, alignment: .trailing)
TextField(text: $username) { TextField("example@icloud.com", text: $username)
Text(verbatim: "example@icloud.com")
}
.focused($focusedField, equals: .username)
} }
HStack { HStack {
Text("Password") Text("Password:")
.frame(minWidth: 100, alignment: .trailing) .frame(minWidth: 100, alignment: .trailing)
SecureField("Required", text: $password) SecureField("Required", text: $password)
.focused($focusedField, equals: .password)
} }
if appState.authError != nil { if appState.authError != nil {
HStack { HStack {

View file

@ -1,5 +1,5 @@
import AppleAPI
import SwiftUI import SwiftUI
import AppleAPI
struct SignInPhoneListView: View { struct SignInPhoneListView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@ -7,33 +7,29 @@ struct SignInPhoneListView: View {
@State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID? @State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID?
let authOptions: AuthOptionsResponse let authOptions: AuthOptionsResponse
let sessionData: AppleSessionData let sessionData: AppleSessionData
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let phoneNumbers = authOptions.trustedPhoneNumbers, !phoneNumbers.isEmpty { if let phoneNumbers = authOptions.trustedPhoneNumbers, !phoneNumbers.isEmpty {
Text(String(format: localizeString("SelectTrustedPhone"), authOptions.securityCode!.length)) Text("Select a trusted phone number to receive a \(authOptions.securityCode.length) digit code via SMS:")
List(phoneNumbers, selection: $selectedPhoneNumberID) { List(phoneNumbers, selection: $selectedPhoneNumberID) {
Text($0.numberWithDialCode) Text($0.numberWithDialCode)
} }
.onAppear {
if phoneNumbers.count == 1 {
selectedPhoneNumberID = phoneNumbers.first?.id
}
}
} else { } else {
Text("NoTrustedPhones") AttributedText(
.font(.callout) NSAttributedString(string: "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication.\n\nSee https://support.apple.com/en-ca/HT204915.")
.convertingURLsToLinkAttributes()
)
Spacer() Spacer()
} }
HStack { HStack {
Button("Cancel", action: { isPresented = false }) Button("Cancel", action: { isPresented = false })
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
Spacer() Spacer()
ProgressButton(isInProgress: appState.isProcessingAuthRequest, ProgressButton(isInProgress: appState.isProcessingAuthRequest,
action: { appState.requestSMS(to: authOptions.trustedPhoneNumbers!.first { $0.id == selectedPhoneNumberID }!, authOptions: authOptions, sessionData: sessionData) }) action: { appState.requestSMS(to: authOptions.trustedPhoneNumbers!.first { $0.id == selectedPhoneNumberID }!, authOptions: authOptions, sessionData: sessionData) }) {
{
Text("Continue") Text("Continue")
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@ -53,10 +49,9 @@ struct SignInPhoneListView_Previews: PreviewProvider {
SignInPhoneListView( SignInPhoneListView(
isPresented: .constant(true), isPresented: .constant(true),
authOptions: AuthOptionsResponse( authOptions: AuthOptionsResponse(
trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")], trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")],
trustedDevices: nil, trustedDevices: nil,
securityCode: .init(length: 6) securityCode: .init(length: 6)),
),
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "") sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
) )
.environmentObject(AppState()) .environmentObject(AppState())
@ -64,10 +59,9 @@ struct SignInPhoneListView_Previews: PreviewProvider {
SignInPhoneListView( SignInPhoneListView(
isPresented: .constant(true), isPresented: .constant(true),
authOptions: AuthOptionsResponse( authOptions: AuthOptionsResponse(
trustedPhoneNumbers: [], trustedPhoneNumbers: [],
trustedDevices: nil, trustedDevices: nil,
securityCode: .init(length: 6) securityCode: .init(length: 6)),
),
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "") sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
) )
.environmentObject(AppState()) .environmentObject(AppState())

View file

@ -11,13 +11,11 @@ struct SignInSMSView: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(String(format: localizeString("EnterDigitCodeDescription"), authOptions.securityCode!.length, trustedPhoneNumber.numberWithDialCode)) Text("Enter the \(authOptions.securityCode.length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ")
HStack { HStack {
Spacer() Spacer()
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) { PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
appState.submitSecurityCode(.sms(code: $0, phoneNumberId: trustedPhoneNumber.id), sessionData: sessionData)
}
Spacer() Spacer()
} }
.padding() .padding()
@ -31,7 +29,7 @@ struct SignInSMSView: View {
Text("Continue") Text("Continue")
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(code.count != authOptions.securityCode!.length) .disabled(code.count != authOptions.securityCode.length)
} }
.frame(height: 25) .frame(height: 25)
} }

View file

@ -1,70 +0,0 @@
//
// SignInSecurityKeyPin.swift
// Xcodes
//
// Created by Kino on 2024-09-26.
// Copyright © 2024 Robots and Pencils. All rights reserved.
//
import SwiftUI
import AppleAPI
struct SignInSecurityKeyPinView: View {
@EnvironmentObject var appState: AppState
@Binding var isPresented: Bool
@State private var pin: String = ""
let authOptions: AuthOptionsResponse
let sessionData: AppleSessionData
var body: some View {
VStack(alignment: .leading) {
Text(localizeString("SecurityKeyPinDescription"))
.fixedSize(horizontal: true, vertical: false)
HStack {
Spacer()
SecureField("PIN", text: $pin)
Spacer()
}
.padding()
HStack {
Button("Cancel", action: { isPresented = false })
.keyboardShortcut(.cancelAction)
Spacer()
Button("PIN not set", action: submitWithoutPinCode)
ProgressButton(isInProgress: appState.isProcessingAuthRequest,
action: submitPinCode) {
Text("Continue")
}
.keyboardShortcut(.defaultAction)
// FIDO2 device pin codes must be at least 4 code points
// https://docs.yubico.com/yesdk/users-manual/application-fido2/fido2-pin.html
.disabled(pin.count < 4)
}
.frame(height: 25)
}
.padding()
.emittingError($appState.authError, recoveryHandler: { _ in })
}
func submitPinCode() {
appState.createAndSubmitSecurityKeyAssertationWithPinCode(pin, sessionData: sessionData, authOptions: authOptions)
}
func submitWithoutPinCode() {
appState.createAndSubmitSecurityKeyAssertationWithPinCode(nil, sessionData: sessionData, authOptions: authOptions)
}
}
#Preview {
SignInSecurityKeyPinView(isPresented: .constant(true),
authOptions: AuthOptionsResponse(
trustedPhoneNumbers: nil,
trustedDevices: nil,
securityCode: .init(length: 6)
), sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: ""))
.environmentObject(AppState())
}

Some files were not shown because too many files have changed in this diff Show more