Compare commits

..

No commits in common. "main" and "v1.7.0b15" have entirely different histories.

173 changed files with 4256 additions and 31359 deletions

View file

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

View file

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

View file

@ -8,10 +8,10 @@ on:
jobs:
test:
runs-on: macos-15
runs-on: macos-12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Run tests
env:
DEVELOPER_DIR: /Applications/Xcode_16.4.app
DEVELOPER_DIR: /Applications/Xcode_13.2.1.app
run: xcodebuild test -scheme Xcodes

View file

@ -11,6 +11,6 @@ jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v5
env:
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
playground.xcworkspace
# Jetbrains IDEA
.idea
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.

View file

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

View file

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,98 +1,66 @@
<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.
_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_dark.png#gh-dark-mode-only)
### :tada: Announcement
XcodesApp is now part of the `XcodesOrg` - [read more here](nextstep.md)
## Features
- List all available Xcode versions from [Xcode Releases'](https://xcodereleases.com) data or the Apple Developer website.
- Install any Xcode version, **fully automated** from start to finish. Xcodes uses [`aria2`](https://aria2.github.io), which uses up to 16 connections to download 3-5x faster than URLSession.
- Automatically resumes installs if network errors.
- Apple ID required to download Xcode versions.
- 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 install new releases!
- Just click a button to make a version active with `xcode-select`.
- View release notes, OS compatibility, included SDKs and compilers from [Xcode Releases](https://xcodereleases.com).
- Dark/Light Mode supported
- Security Key Authentication supported
- Support installing Platforms/Runtimes
- Support installing Apple Silicon variants
## Platforms/Runtimes
- Xcodes supports downloading the Apple runtimes via the app. Simply click on the Platform, and Xcodes will install automatically for you.
**Note: iOS 18+, tvOS 18+, watchOS 11+, visionOS 2+ requires that Xcode 16.1 Beta 3+ be installed and active.**
## Apple Silicon Variants
As of Xcode 26, Apple provides Apple Silicon as well as Universal variants for Xcode versions as well as each runtime. Simply tap on which variant you want installed. To install the Apple Silicon runtime variant Xcode 26 is required to be active.
## Experiments
- Thanks to the wonderful work of [https://github.com/saagarjha/unxip](https://github.com/saagarjha/unxip), turn on the experiment to increase your unxipping time by up to 70%! More can be found on his repo, but bugs, high memory may occur if used.
- 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)
![](experiment_light.jpg#gh-light-mode-only)
![](experiment_dark.jpg#gh-dark-mode-only)
## Localization
Xcodes supports localization in several languages.
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)|
|Spanish 🇲🇽 |[@cesartru88](https://github.com/cesartru88)|Korean 🇰🇷 |[@ryan-son](https://github.com/ryan-son)|
|Russian 🇷🇺 |[@alexmazlov](https://github.com/alexmazlov)|Turkish 🇹🇷 |[@egrimo](https://github.com/egrimo)|
|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.
Want to add more languages? Simply create a PR with the updated strings file.
## Installation
v1.X - requires macOS 11 or newer
v2.X - requires macOS 13
v3.X - requires macOS 13 - architecture variants and updated icon.
### Install with Homebrew
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
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
## 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
You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app.
You'll need macOS 12 Big Sur and Xcode 13 in order to build and run Xcodes.app.
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.
`Unxip` and `aria2` must be compiled as a universal binary
```
@ -104,9 +72,10 @@ You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app
# combine for universal binary
lipo -create -output unxip unxip_intel unxip_m1
# check it
lipo -archs unxip
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.
@ -120,8 +89,8 @@ Follow the steps below to build and release a new version of Xcodes.app. For any
# Update the version number in Xcode and commit the change, if necessary
# Question: Did anything in XPCHelper change?
# - com.xcodesorg.xcodesapp.Helper folder and HelperXPCShared
# - if so, bump the version number in com.xcodesorg.xcodesapp.Helper target.
# - com.robotsandpencils.XcodesApp.Helper folder and HelperXPCShared
# - 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.
# Note2: If you do bump the version, the end user, must re-install the XPCHelper and give permission again.
@ -159,7 +128,7 @@ pushd Product
../scripts/sign_update Xcodes.zip
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
# Edit the latest draft release
# Set its tag to the tag you just pushed
@ -170,13 +139,14 @@ popd
# Attach the zip that was created in the Product directory to the release
# Publish the release
shasum -a 256 xcodes.zip
# Update the [Homebrew Cask](https://github.com/XcodesOrg/homebrew-cask/blob/master/Casks/x/xcodes.rb).
# Update the [Homebrew Cask](https://github.com/RobotsAndPencils/homebrew-cask/blob/master/Casks/xcodes.rb).
```
</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/xcodesApp) | [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

@ -1,6 +1,6 @@
#!/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/LaunchDaemons/$PRIVILEGED_HELPER_LABEL.plist

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,6 @@
{
"object": {
"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",
"repositoryURL": "https://github.com/groue/CombineExpectations",
@ -29,12 +11,12 @@
}
},
{
"package": "DockProgress",
"repositoryURL": "https://github.com/sindresorhus/DockProgress",
"package": "XcodeReleases",
"repositoryURL": "https://github.com/xcodereleases/data",
"state": {
"branch": null,
"revision": "d4f23b5a8f5ca0fac393eb7ba78c2fe3e32e52da",
"version": "4.3.1"
"revision": "b47228c688b608e34b3b84079ab6052a24c7a981",
"version": null
}
},
{
@ -64,22 +46,13 @@
"version": "1.0.4"
}
},
{
"package": "LibFido2Swift",
"repositoryURL": "https://github.com/kinoroy/LibFido2Swift",
"state": {
"branch": "main",
"revision": "b87a93300c5b35307c9f26ae490963196bd927f1",
"version": null
}
},
{
"package": "Path.swift",
"repositoryURL": "https://github.com/mxcl/Path.swift",
"state": {
"branch": null,
"revision": "8e355c28e9393c42e58b18c54cace2c42c98a616",
"version": "1.4.1"
"revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5",
"version": "0.16.3"
}
},
{
@ -87,35 +60,8 @@
"repositoryURL": "https://github.com/sparkle-project/Sparkle/",
"state": {
"branch": null,
"revision": "0ef1ee0220239b3776f433314515fd849025673f",
"version": "2.6.4"
}
},
{
"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
"revision": "286edd1fa22505a9e54d170e9fd07d775ea233f2",
"version": "2.1.0"
}
},
{

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

@ -35,7 +35,7 @@ private extension Xcode {
try? task.run()
let handle = pipe.fileHandleForReading
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
}
}

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

View file

@ -1,9 +1,5 @@
import Foundation
import Combine
import SRP
import Crypto
import CommonCrypto
public class Client {
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
@ -12,66 +8,18 @@ public class Client {
// 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!
let client = SRPClient(configuration: SRPConfiguration<SHA256>(.N2048))
let clientKeys = client.generateKeys()
let a = clientKeys.public
return Current.network.dataTask(with: URLRequest.itcServiceKey)
.map(\.data)
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
.flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in
.flatMap { serviceKeyResponse -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
serviceKey = serviceKeyResponse.authServiceKey
// Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360
// On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow
// Without this addition, Apple ID's would get set to locked
return self.loadHashcash(accountName: accountName, serviceKey: serviceKey)
.map { return (serviceKey, $0)}
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
.mapError { $0 as Swift.Error }
.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
let (data, response) = result
return Just(data)
@ -108,44 +56,6 @@ public class Client {
.mapError { $0 as Swift.Error }
.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> {
let httpResponse = response as! HTTPURLResponse
@ -160,7 +70,7 @@ public class Client {
case .twoStep:
return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
.eraseToAnyPublisher()
case .twoFactor, .securityKey:
case .twoFactor:
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
.eraseToAnyPublisher()
case .unknown:
@ -181,10 +91,7 @@ public class Client {
// SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS {
option = .smsPendingChoice
// Code is shown on trusted devices
} else if authOptions.fsaChallenge != nil {
option = .securityKey
// User needs to use a physical security key to respond to the challenge
// Code is shown on trusted devices
} else {
option = .codeSent
}
@ -225,8 +132,6 @@ public class Client {
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)
}
@ -238,33 +143,6 @@ public class Client {
.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
/// Use the olympus session endpoint to see if the existing session is still valid
@ -299,49 +177,6 @@ public class Client {
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
func sha256(data : Data) -> Data {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
}
return Data(hash)
}
private func pbkdf2(password: String, saltData: Data, keyByteCount: Int, prf: CCPseudoRandomAlgorithm, rounds: Int, protocol srpProtocol: SRPProtocol) -> Data? {
guard let passwordData = password.data(using: .utf8) else { return nil }
let hashedPasswordDataRaw = sha256(data: passwordData)
let hashedPasswordData = switch srpProtocol {
case .s2k: hashedPasswordDataRaw
// the legacy s2k_fo protocol requires hex-encoding the digest before performing PBKDF2.
case .s2k_fo: Data(hashedPasswordDataRaw.hexEncodedString().lowercased().utf8)
}
var derivedKeyData = Data(repeating: 0, count: keyByteCount)
let derivedCount = derivedKeyData.count
let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in
let keyBuffer: UnsafeMutablePointer<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
@ -355,7 +190,6 @@ public enum AuthenticationState: Equatable {
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
case invalidSession
case invalidHashcash
case invalidUsernameOrPassword(username: String)
case incorrectSecurityCode
case unexpectedSignInResponse(statusCode: Int, message: String?)
@ -367,14 +201,11 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
case notDeveloperAppleId
case notAuthorized
case invalidResult(resultString: String?)
case srpInvalidPublicKey
public var errorDescription: String? {
switch self {
case .invalidSession:
return "Your authentication session is invalid. Try signing in again."
case .invalidHashcash:
return "Could not create a hashcash for the session."
case .invalidUsernameOrPassword:
return "Invalid username and password combination."
case .incorrectSecurityCode:
@ -402,8 +233,6 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
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 +273,27 @@ public enum TwoFactorOption: Equatable {
case smsSent(AuthOptionsResponse.TrustedPhoneNumber)
case codeSent
case smsPendingChoice
case securityKey
}
public struct FSAChallenge: Equatable, Decodable {
public let challenge: String
public let keyHandles: [String]
public let allowedCredentials: String
}
public struct AuthOptionsResponse: Equatable, Decodable {
public let trustedPhoneNumbers: [TrustedPhoneNumber]?
public let trustedDevices: [TrustedDevice]?
public let securityCode: SecurityCodeInfo?
public let securityCode: SecurityCodeInfo
public let noTrustedDevices: Bool?
public let serviceErrors: [ServiceError]?
public let fsaChallenge: FSAChallenge?
public init(
trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?,
trustedDevices: [AuthOptionsResponse.TrustedDevice]?,
securityCode: AuthOptionsResponse.SecurityCodeInfo,
noTrustedDevices: Bool? = nil,
serviceErrors: [ServiceError]? = nil,
fsaChallenge: FSAChallenge? = nil
serviceErrors: [ServiceError]? = nil
) {
self.trustedPhoneNumbers = trustedPhoneNumbers
self.trustedDevices = trustedDevices
self.securityCode = securityCode
self.noTrustedDevices = noTrustedDevices
self.serviceErrors = serviceErrors
self.fsaChallenge = fsaChallenge
}
public var kind: Kind {
@ -482,8 +301,6 @@ public struct AuthOptionsResponse: Equatable, Decodable {
return .twoStep
} else if trustedPhoneNumbers != nil {
return .twoFactor
} else if fsaChallenge != nil {
return .securityKey
} else {
return .unknown
}
@ -546,7 +363,7 @@ public struct AuthOptionsResponse: Equatable, Decodable {
}
public enum Kind: Equatable {
case twoStep, twoFactor, securityKey, unknown
case twoStep, twoFactor, unknown
}
}
@ -583,24 +400,3 @@ public struct AppleProvider: Decodable, Equatable {
public struct AppleUser: Decodable, Equatable {
public let fullName: String
}
public struct ServerSRPInitResponse: Decodable {
let iteration: Int
let salt: String
let b: String
let c: String
let `protocol`: SRPProtocol
}
extension String {
func base64ToU8Array() -> Data {
return Data(base64Encoded: self) ?? Data()
}
}
extension Data {
func hexEncodedString() -> String {
return map { String(format: "%02hhx", $0) }.joined()
}
}

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 func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")!
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")!
static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")!
static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")!
}
public extension URLRequest {
@ -21,7 +15,7 @@ public extension URLRequest {
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 {
let accountName: String
let password: String
@ -33,7 +27,6 @@ public extension URLRequest {
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
@ -110,19 +103,6 @@ public extension URLRequest {
}
return request
}
static func respondToChallenge(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest {
var request = URLRequest(url: .keyAuth)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "POST"
request.httpBody = response
return request
}
static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
var request = URLRequest(url: .trust)
@ -137,68 +117,4 @@ public extension URLRequest {
static var olympusSession: URLRequest {
return URLRequest(url: .olympusSession)
}
static func federate(account: String, serviceKey: String) throws -> URLRequest {
struct FederateRequest: Encodable {
let accountName: String
let rememberMe: Bool
}
var request = URLRequest(url: .signIn)
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "GET"
// let encoder = JSONEncoder()
// encoder.outputFormatting = .withoutEscapingSlashes
// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true))
return request
}
static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest {
struct ServerSRPInitRequest: Encodable {
public let a: String
public let accountName: String
public let protocols: [SRPProtocol]
}
var request = URLRequest(url: .srpInit)
request.httpMethod = "POST"
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.httpBody = try? JSONEncoder().encode(ServerSRPInitRequest(a: a, accountName: accountName, protocols: [.s2k, .s2k_fo]))
return request
}
static func SRPComplete(serviceKey: String, hashcash: String, accountName: String, c: String, m1: String, m2: String) -> URLRequest {
struct ServerSRPCompleteRequest: Encodable {
let accountName: String
let c: String
let m1: String
let m2: String
let rememberMe: Bool
}
var request = URLRequest(url: .srpComplete)
request.httpMethod = "POST"
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
request.httpBody = try? JSONEncoder().encode(ServerSRPCompleteRequest(accountName: accountName, c: c, m1: m1, m2: m2, rememberMe: false))
return request
}
}
public enum SRPProtocol: String, Codable {
case s2k, s2k_fo
}

View file

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

View file

@ -5,16 +5,14 @@ import AppleAPI
import Version
import LegibleError
import os.log
import DockProgress
import XcodesKit
/// Downloads and installs Xcodes
extension AppState {
// check to see if we should auto install for the user
public func autoInstallIfNeeded() {
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
guard let storageValue = UserDefaults.standard.object(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
if autoInstallType == .none { return }
// get newest xcode version
@ -42,11 +40,11 @@ extension AppState {
}
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")
setupDockProgress()
return validateSession()
.flatMap { _ in
self.getXcodeArchive(installationType, downloader: downloader)
@ -55,8 +53,6 @@ extension AppState {
self.installArchivedXcode(xcode, at: url)
}
.catch { error -> AnyPublisher<InstalledXcode, Swift.Error> in
self.resetDockProgressTracking()
switch error {
case InstallationError.damagedXIP(let damagedXIPURL):
guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() }
@ -102,14 +98,13 @@ extension AppState {
}
private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight)
}
})
.map { return (availableXcode, $0) }
.eraseToAnyPublisher()
downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
}
})
.map { return (availableXcode, $0) }
.eraseToAnyPublisher()
}
public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
@ -158,7 +153,6 @@ extension AppState {
cookies
)
progressChanged(progress)
return publisher
.map { _ in destination.url }
.eraseToAnyPublisher()
@ -173,7 +167,6 @@ extension AppState {
to: destination.url,
resumingWith: resumeData ?? persistedResumeData)
progressChanged(progress)
return publisher
.map { $0.saveLocation }
.eraseToAnyPublisher()
@ -185,9 +178,6 @@ extension AppState {
}
public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher<InstalledXcode, Error> {
unxipProgress.completedUnitCount = 0
overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight)
do {
let destinationURL = Path.installDirectory.join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension {
@ -227,7 +217,6 @@ extension AppState {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription)
}
resetDockProgressTracking()
})
.catch { _ in
Just(installedXcode)
@ -428,9 +417,6 @@ extension AppState {
}
self.presentedAlert = .privilegedHelper
}
unxipProgress.completedUnitCount = AppState.totalProgressUnits
resetDockProgressTracking()
return helperInstallConsentSubject
.flatMap {
@ -471,48 +457,15 @@ extension AppState {
.eraseToAnyPublisher()
}
// MARK: - Dock Progress Tracking
private func setupDockProgress() {
Task { @MainActor in
DockProgress.progressInstance = nil
DockProgress.style = .bar
let progress = Progress(totalUnitCount: AppState.totalProgressUnits)
progress.kind = .file
progress.fileOperationKind = .downloading
overallProgress = progress
DockProgress.progressInstance = overallProgress
}
}
func resetDockProgressTracking() {
Task { @MainActor in
DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete
}
}
// MARK: -
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
func setInstallationStep(of version: Version, to step: InstallationStep) {
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step)
let xcode = self.allXcodes[index]
Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal)
}
}
func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) {
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installing(step)
if postNotification {
Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
}
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
}
}
}
@ -562,7 +515,7 @@ public enum InstallationError: LocalizedError, Equatable {
case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority):
return String(format: localizeString("InstallationError.UnexpectedCodeSigningIdentity"), identity, certificateAuthority, XcodeTeamIdentifier, XcodeCertificateAuthority)
case .unsupportedFileFormat(let fileExtension):
return String(format: localizeString("InstallationError.UnsupportedFileFormat"), fileExtension)
return String(format: localizeString("InstallationError.UnsuppoawwrtedFileFormat"), fileExtension)
case .missingSudoerPassword:
return localizeString("InstallationError.MissingSudoerPassword")
case let .unavailableVersion(version):

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 Version
import SwiftSoup
import struct XCModel.Xcode
import AppleAPI
import XcodesKit
extension AppState {
@ -36,8 +36,6 @@ extension AppState {
func update() {
guard !isUpdating else { return }
updateDownloadableRuntimes()
updateInstalledRuntimes()
updatePublisher = updateSelectedXcodePath()
.flatMap { _ in
self.updateAvailableXcodes(from: self.dataSource)
@ -127,21 +125,6 @@ extension AppState {
withIntermediateDirectories: true)
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 {
@ -210,8 +193,8 @@ extension AppState {
private func xcodeReleases() -> AnyPublisher<[AvailableXcode], Error> {
Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!))
.map(\.data)
.decode(type: [XcodeRelease].self, decoder: JSONDecoder())
.map { xcReleasesXcodes in
.decode(type: [XCModel.Xcode].self, decoder: JSONDecoder())
.map { xcReleasesXcodes in
let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in
guard
let downloadURL = xcReleasesXcode.links?.download?.url,
@ -232,8 +215,7 @@ extension AppState {
requiredMacOSVersion: xcReleasesXcode.requires,
releaseNotesURL: xcReleasesXcode.links?.notes?.url,
sdks: xcReleasesXcode.sdks,
compilers: xcReleasesXcode.compilers,
architectures: xcReleasesXcode.architectures
compilers: xcReleasesXcode.compilers
)
}
return xcodes

View file

@ -7,33 +7,9 @@ import KeychainAccess
import Path
import Version
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 {
private let client = AppleAPI.Client()
internal let runtimeService = RuntimeService()
// MARK: - Published Properties
@ -69,7 +45,6 @@ class AppState: ObservableObject {
@Published var isProcessingAuthRequest = false
@Published var xcodeBeingConfirmedForUninstallation: Xcode?
@Published var presentedAlert: XcodesAlert?
@Published var presentedPreferenceAlert: XcodesPreferencesAlert?
@Published var helperInstallState: HelperInstallState = .notInstalled
/// 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.
@ -87,24 +62,18 @@ class AppState: ObservableObject {
}
}
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")
@ -112,7 +81,7 @@ class AppState: ObservableObject {
}
var createSymLinkOnSelectDisabled: Bool {
return onSelectActionType == .rename || PreferenceKey.createSymLinkOnSelect.isManaged()
return onSelectActionType == .rename
}
@Published var onSelectActionType = SelectedActionType.none {
@ -124,47 +93,14 @@ class AppState: ObservableObject {
}
}
}
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
var cancellables = Set<AnyCancellable>()
private var installationPublishers: [XcodeID: AnyCancellable] = [:]
internal var runtimePublishers: [String: Task<(), any Error>] = [:]
private var installationPublishers: [Version: AnyCancellable] = [:]
private var selectPublisher: AnyCancellable?
private var uninstallPublisher: AnyCancellable?
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: -
var dataSource: DataSource {
@ -195,7 +131,6 @@ class AppState: ObservableObject {
init() {
guard !isTesting else { return }
try? loadCachedAvailableXcodes()
try? loadCacheDownloadableRuntimes()
checkIfHelperIsInstalled()
setupAutoInstallTimer()
setupDefaults()
@ -207,15 +142,13 @@ class AppState: ObservableObject {
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
/// Runs a timer every 6 hours when app is open to check if it needs to auto install any xcodes
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 }
autoInstallTimer = Timer.scheduledTimer(withTimeInterval: 60*60*6, repeats: true) { [weak self] _ in
@ -224,28 +157,7 @@ class AppState: ObservableObject {
}
// 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> {
return Current.network.validateSession()
.receive(on: DispatchQueue.main)
.handleEvents(receiveCompletion: { completion in
@ -278,7 +190,7 @@ class AppState: ObservableObject {
func signIn(username: String, password: String) {
authError = nil
signIn(username: username.lowercased(), password: password)
signIn(username: username, password: password)
.sink(
receiveCompletion: { _ in },
receiveValue: { _ in }
@ -291,7 +203,7 @@ class AppState: ObservableObject {
Current.defaults.set(username, forKey: "username")
isProcessingAuthRequest = true
return client.srpLogin(accountName: username, password: password)
return client.login(accountName: username, password: password)
.receive(on: DispatchQueue.main)
.handleEvents(
receiveOutput: { authenticationState in
@ -306,17 +218,11 @@ class AppState: ObservableObject {
}
func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) {
let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
if option == .securityKey, fido2DeviceIsPresent() && !fido2DeviceNeedsPin() {
createAndSubmitSecurityKeyAssertationWithPinCode(nil, sessionData: sessionData, authOptions: authOptions)
} else {
self.presentedSheet = .twoFactor(.init(
option: option,
authOptions: authOptions,
sessionData: sessionData
))
}
self.presentedSheet = .twoFactor(.init(
option: option,
authOptions: authOptions,
sessionData: AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
))
}
func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
@ -362,83 +268,6 @@ class AppState: ObservableObject {
.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>) {
switch completion {
case let .failure(error):
@ -524,42 +353,28 @@ class AppState: ObservableObject {
// MARK: - Install
func checkMinVersionAndInstall(id: XcodeID) {
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
func checkMinVersionAndInstall(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
// Check to see if users macOS is supported
// Check to see if users MacOS is supported
if let requiredMacOSVersion = availableXcode.requiredMacOSVersion {
if hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) {
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)
if !ProcessInfo.processInfo.isOperatingSystemAtLeast(xcodeMinimumMacOSVersion) {
// 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)
}
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 }
func install(id: Xcode.ID) {
guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
installationPublishers[id] = signInIfNeeded()
.handleEvents(
receiveSubscription: { [unowned self] _ in
self.setInstallationStep(of: availableXcode.version, to: .authenticating)
}
)
.flatMap { [unowned self] in
// 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,
@ -603,7 +418,7 @@ class AppState: ObservableObject {
.mapError { $0 as Error }
}
.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)
.sink(
@ -611,11 +426,7 @@ class AppState: ObservableObject {
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 let error = error as? AuthenticationError, case .notAuthorized = error {
self.error = error
self.presentedAlert = .unauthenticated
} else if error as? AuthenticationError != .invalidSession {
if error as? AuthenticationError != .invalidSession {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
}
@ -628,38 +439,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) {
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
installationPublishers[id] = nil
resetDockProgressTracking()
// 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.
@ -696,10 +480,10 @@ class AppState: ObservableObject {
)
}
func reveal(_ path: Path?) {
func reveal(xcode: Xcode) {
// TODO: show error if not
guard let path = path else { return }
NSWorkspace.shared.activateFileViewerSelecting([path.url])
guard let installedXcodePath = xcode.installedPath else { return }
NSWorkspace.shared.activateFileViewerSelecting([installedXcodePath.url])
}
func reveal(path: String) {
@ -762,18 +546,13 @@ class AppState: ObservableObject {
)
}
func open(xcode: Xcode, openInRosetta: Bool? = false) {
func open(xcode: Xcode) {
switch xcode.installState {
case let .installed(path):
let config = NSWorkspace.OpenConfiguration.init()
if (openInRosetta ?? false) {
config.architecture = CPU_TYPE_X86_64
}
config.allowsRunningApplicationSubstitution = false
NSWorkspace.shared.openApplication(at: path.url, configuration: config)
default:
Logger.appState.error("\(xcode.id.version) is not installed")
return
case let .installed(path):
NSWorkspace.shared.openApplication(at: path.url, configuration: .init())
default:
Logger.appState.error("\(xcode.id) is not installed")
return
}
}
@ -785,17 +564,17 @@ class AppState: ObservableObject {
NSPasteboard.general.setString(installedXcodePath.string, forType: .string)
}
func copyReleaseNote(from url: URL?) {
guard let url = url else { return }
func copyReleaseNote(xcode: Xcode) {
guard let url = xcode.releaseNotesURL 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) {
func createSymbolicLink(xcode: Xcode) {
guard let installedXcodePath = xcode.installedPath else { return }
let destinationPath: Path = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app"
let destinationPath: Path = Path.installDirectory/"Xcode.app"
// does an Xcode.app file exist?
if FileManager.default.fileExists(atPath: destinationPath.string) {
@ -817,7 +596,7 @@ class AppState: ObservableObject {
do {
try FileManager.default.createSymbolicLink(atPath: destinationPath.string, withDestinationPath: installedXcodePath.string)
Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app")
Logger.appState.info("Successfully created symbolic link with Xcode.app")
} catch {
Logger.appState.error("Unable to create symbolic Link")
self.error = error
@ -868,7 +647,7 @@ class AppState: ObservableObject {
// 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.
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
// Not all prerelease Apple versions available online include build metadata
@ -876,7 +655,7 @@ class AppState: ObservableObject {
availableXcode.version.isEquivalent(to: installedXcode.version) &&
availableXcode.version.buildMetadataIdentifiers.isEmpty
}) {
adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID
adjustedAvailableXcodes[index].version = installedXcode.version
}
}
}
@ -893,21 +672,14 @@ class AppState: ObservableObject {
// Include this version if there's only one with this build identifier
return availableXcodesWithIdenticalBuildIdentifiers.count == 1 ||
// Or if there's more than one with this build identifier and this is the release version
availableXcodesWithIdenticalBuildIdentifiers.count > 1 && (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.count ?? 0 != 0)
}
availableXcodesWithIdenticalBuildIdentifiers.count > 1 && availableXcode.version.prereleaseIdentifiers.isEmpty
}
.map { availableXcode -> Xcode in
let installedXcode = installedXcodes.first(where: { installedXcode in
// if we want to have only specific Xcodes as selected instead of the Architecture Equivalent.
// if availableXcode.architectures == nil {
// return availableXcode.version.isEquivalent(to: installedXcode.version)
// } else {
// return availableXcode.xcodeID == installedXcode.xcodeID
// }
return availableXcode.version.isEquivalent(to: installedXcode.version)
availableXcode.version.isEquivalent(to: installedXcode.version)
})
let identicalBuilds: [XcodeID]
let identicalBuilds: [Version]
let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes
.filter {
return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers &&
@ -917,13 +689,13 @@ class AppState: ObservableObject {
}
// If this is the release version, add the identical builds to it
if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty {
identicalBuilds = [availableXcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID)
identicalBuilds = [availableXcode.version] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.version)
} else {
identicalBuilds = []
}
// 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
let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled
@ -938,8 +710,7 @@ class AppState: ObservableObject {
releaseDate: availableXcode.releaseDate,
sdks: availableXcode.sdks,
compilers: availableXcode.compilers,
downloadFileSize: availableXcode.fileSize,
architectures: availableXcode.architectures
downloadFileSize: availableXcode.fileSize
)
}
@ -961,7 +732,6 @@ class AppState: ObservableObject {
self.allXcodes = newAllXcodes.sorted { $0.version > $1.version }
}
// MARK: - Private

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import Foundation
import Path
import AppleAPI
import KeychainAccess
import XcodesKit
/**
Lightweight dependency injection using global mutable state :P
@ -43,7 +43,7 @@ public struct Shell {
"--max-connection-per-server=16",
"--split=16",
"--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)",
"--out=\(destination.basename())",
"--human-readable=false", // sets the output to use bytes instead of formatting
@ -112,168 +112,11 @@ public struct Shell {
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 {
@ -323,24 +166,18 @@ public struct Files {
public var installedXcodes = _installedXcodes
public func installedXcode(destination: Path) -> InstalledXcode? {
if Path.isAppBundle(path: destination) && Path.infoPlist(path: destination)?.bundleID == "com.apple.dt.Xcode" {
if Entry.isAppBundle(kind: destination.isDirectory ? .directory : .file, path: destination) && Entry.infoPlist(kind: destination.isDirectory ? .directory : .file, 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] {
destination.ls()
((try? destination.ls()) ?? [])
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
.map { $0 }
.map { $0.path }
.compactMap(InstalledXcode.init)
}
@ -352,15 +189,10 @@ public struct Network {
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
public func dataTask(with request: URLRequest) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Error> {
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 func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) {

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
func trashItem(at url: URL) throws -> URL {
var resultingItemURL: NSURL!
if fileExists(atPath: url.path) {
try trashItem(at: url, resultingItemURL: &resultingItemURL)
} else {
throw FileError.fileNotFound(url.lastPathComponent)
}
try trashItem(at: url, resultingItemURL: &resultingItemURL)
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

@ -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 localizeString("Downloading")
case .unarchiving:
return localizeString("Unarchiving")
case .moving(let destination):
return String(format: localizeString("Moving"), destination)
case .trashingArchive:
return localizeString("TrashingArchive")
case .checkingSecurity:
return localizeString("CheckingSecurity")
case .finishing:
return localizeString("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 Version
import Path
import XcodesKit
/// A version of Xcode that's already installed
public struct InstalledXcode: Equatable {
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
public var version: Version {
return xcodeID.version
}
public let version: Version
public init?(path: Path) {
self.path = path
@ -36,21 +31,12 @@ public struct InstalledXcode: Equatable {
else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") {
prereleaseIdentifiers = ["beta"]
}
let archsString = try? XcodesKit.Current.shell.archs(path.url.appending(path: "Contents/MacOS/Xcode")).out
let architectures = archsString?
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: " ")
.compactMap { Architecture(rawValue: String($0)) }
let version = Version(major: bundleVersion.major,
self.version = Version(major: bundleVersion.major,
minor: bundleVersion.minor,
patch: bundleVersion.patch,
prereleaseIdentifiers: prereleaseIdentifiers,
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

@ -28,19 +28,4 @@ extension Path {
}
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 os.log
import Path
import XcodesKit
public typealias ProcessOutput = (status: Int32, out: String, err: String)
extension Process {
@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)
}
@ -68,3 +67,9 @@ extension Process {
.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

@ -4,7 +4,6 @@ extension URL {
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 downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")!
static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")!
}
extension URLRequest {
@ -26,13 +25,4 @@ extension URLRequest {
request.allHTTPHeaderFields?["Accept"] = "*/*"
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 XcodesKit
import struct XCModel.Xcode
extension Version {
/// 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.
init?(xcReleasesXcode: XcodeRelease) {
init?(xcReleasesXcode: XCModel.Xcode) {
var versionString = xcReleasesXcode.version.number ?? ""
// Append trailing ".0" in order to get a fully-specified version string

View file

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

View file

@ -1,5 +1,4 @@
import SwiftUI
import XcodesKit
// MARK: - CommandMenu
@ -35,18 +34,15 @@ struct XcodeCommands: Commands {
struct InstallButton: View {
@EnvironmentObject var appState: AppState
let xcode: Xcode?
var body: some View {
Button {
install()
} label: {
Button(action: install) {
Text("Install")
.help("InstallDescription")
.help("Install")
}
}
private func install() {
guard let xcode = xcode else { return }
appState.checkMinVersionAndInstall(id: xcode.id)
@ -59,10 +55,9 @@ struct CancelInstallButton: View {
var body: some View {
Button(action: cancelInstall) {
Label("Cancel", systemImage: "xmark")
Text("Cancel")
.help(localizeString("StopInstallation"))
}
.help(localizeString("StopInstallation"))
.buttonStyle(.plain)
}
private func cancelInstall() {
@ -71,23 +66,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 {
@EnvironmentObject var appState: AppState
let xcode: Xcode?
@ -114,34 +92,16 @@ struct OpenButton: View {
@EnvironmentObject var appState: AppState
let xcode: Xcode?
var openInRosetta: Bool {
appState.showOpenInRosettaOption && Hardware.isAppleSilicon()
}
var body: some View {
if openInRosetta {
Menu("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")
Button(action: open) {
Text("Open")
}
.help("Open")
}
private func open() {
guard let xcode = xcode else { return }
appState.open(xcode: xcode, openInRosetta: openInRosetta)
appState.open(xcode: xcode)
}
}
@ -173,7 +133,7 @@ struct RevealButton: View {
private func reveal() {
guard let xcode = xcode else { return }
appState.reveal(xcode.installedPath)
appState.reveal(xcode: xcode)
}
}
@ -195,9 +155,8 @@ struct CopyPathButton: View {
}
struct CopyReleaseNoteButton: View {
let url: URL?
@EnvironmentObject var appState: AppState
let xcode: Xcode?
var body: some View {
Button(action: copyReleaseNote) {
@ -207,8 +166,8 @@ struct CopyReleaseNoteButton: View {
}
private func copyReleaseNote() {
guard let url = url else { return }
appState.copyReleaseNote(from: url)
guard let xcode = xcode else { return }
appState.copyReleaseNote(xcode: xcode)
}
}
@ -230,40 +189,6 @@ struct CreateSymbolicLinkButton: View {
}
}
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
struct InstallCommand: View {

View file

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

View file

@ -45,18 +45,9 @@ struct AboutView: View {
}
.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")
}
}
}
Text(Bundle.main.humanReadableCopyright!)
.font(.footnote)
}
}
.padding()

View file

@ -10,7 +10,7 @@ struct AcknowledgmentsView: View {
)!
.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
cancellable = progress.publisher(for: \.fractionCompleted)
.combineLatest(progress.publisher(for: \.localizedAdditionalDescription))
.combineLatest(progress.publisher(for: \.isIndeterminate))
.throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in self?.objectWillChange.send() }
}
@ -83,18 +82,6 @@ struct ObservingProgressBar_Previews: PreviewProvider {
style: .bar,
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)
}

View file

@ -22,10 +22,7 @@ struct ProgressIndicator: NSViewRepresentable {
nsView.doubleValue = doubleValue
nsView.controlSize = controlSize
nsView.isIndeterminate = isIndeterminate
nsView.usesThreadedAnimation = true
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,13 +1,10 @@
import Foundation
import XcodesKit
enum XcodesAlert: Identifiable {
case cancelInstall(xcode: Xcode)
case cancelRuntimeInstall(runtime: DownloadableRuntime)
case privilegedHelper
case generic(title: String, message: String)
case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String)
case unauthenticated
var id: Int {
switch self {
@ -15,22 +12,6 @@ enum XcodesAlert: Identifiable {
case .privilegedHelper: return 2
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

@ -4,7 +4,6 @@ import AppleAPI
enum XcodesSheet: Identifiable {
case signIn
case twoFactor(SecondFactorData)
case securityKeyTouchToConfirm
var id: Int { Kind(self).hashValue }
@ -17,13 +16,12 @@ enum XcodesSheet: Identifiable {
extension XcodesSheet {
private enum Kind: Hashable {
case signIn, twoFactor(TwoFactorOption), securityKeyTouchToConfirm
case signIn, twoFactor(TwoFactorOption)
enum TwoFactorOption {
case smsSent
case codeSent
case smsPendingChoice
case securityKeyPin
}
init(_ sheet: XcodesSheet) {
@ -34,9 +32,7 @@ extension XcodesSheet {
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,372 @@
import AppKit
import XcodesKit
import Path
import SwiftUI
import Version
import struct XCModel.SDKs
import struct XCModel.Compilers
struct InfoPane: View {
let xcode: Xcode
var body: some View {
if #available(macOS 14.0, *) {
mainContent
.contentMargins(10, for: .scrollContent)
} else {
mainContent
.padding()
}
}
@EnvironmentObject var appState: AppState
let selectedXcodeID: Xcode.ID?
@SwiftUI.Environment(\.openURL) var openURL: OpenURLAction
private var mainContent: some View {
ScrollView(.vertical) {
HStack(alignment: .top) {
VStack {
VStack(spacing: 5) {
HStack {
IconView(xcode: xcode)
Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)")
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
InfoPaneControls(xcode: xcode)
}
.xcodesBackground()
var body: some View {
if let xcode = appState.allXcodes.first(where: { $0.id == selectedXcodeID }) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
icon(for: xcode)
.frame(maxWidth: .infinity, alignment: .center)
Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)")
.font(.title)
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("RevealInFinder")
}
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)
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)
.padding()
}
.frame(minWidth: 200, maxWidth: .infinity)
} else {
empty
.frame(minWidth: 200, maxWidth: .infinity)
}
}
@ViewBuilder
var SDKandCompilers: some View {
VStack(alignment: .leading, spacing: 16) {
SDKsView(sdks: xcode.sdks)
CompilersView(compilers: xcode.compilers)
private func icon(for xcode: Xcode) -> some View {
if case let .installed(path) = xcode.installState {
Image(nsImage: NSWorkspace.shared.icon(forFile: path.string))
} else {
Image(systemName: "app.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
}
@ViewBuilder
private func identicalBuilds(for xcode: Xcode) -> some View {
if !xcode.identicalBuilds.isEmpty {
VStack(alignment: .leading) {
HStack {
Text("IdenticalBuilds")
Image(systemName: "square.fill.on.square.fill")
.foregroundColor(.secondary)
.accessibility(hidden: true)
.help("IdenticalBuilds.help")
}
.font(.headline)
ForEach(xcode.identicalBuilds, id: \.description) { version in
Text("\(version.appleDescription)")
.font(.subheadline)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement()
.accessibility(label: Text("IdenticalBuilds"))
.accessibility(value: Text(xcode.identicalBuilds.map(\.appleDescription).joined(separator: ", ")))
.accessibility(hint: Text("IdenticalBuilds.help"))
} else {
EmptyView()
}
}
@ViewBuilder
private func releaseDate(for xcode: Xcode) -> some View {
if let releaseDate = xcode.releaseDate {
VStack(alignment: .leading) {
Text("ReleaseDate")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(releaseDate, style: .date)")
.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("ReleaseNotes", systemImage: "link")
}
.buttonStyle(LinkButtonStyle())
.contextMenu(menuItems: {
releaseNotesMenu(for: xcode)
})
.frame(maxWidth: .infinity, alignment: .leading)
.help("ReleaseNotes.help")
} else {
EmptyView()
}
}
@ViewBuilder
private func releaseNotesMenu(for xcode: Xcode) -> some View {
CopyReleaseNoteButton(xcode: xcode)
}
@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(String(format: localizeString("MacOSRequirement"), requiredMacOSVersion))
.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("DownloadSize")
.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("NoXcodeSelected")
.font(.title)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
#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) }
struct InfoPane_Previews: PreviewProvider {
static var previews: some View {
Group {
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
.environmentObject(configure(AppState()) {
$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 {
let name = XcodePreviewName.allCases[index]
return InfoPane(xcode: xcodeDict[name]!)
.environmentObject(configure(AppState()) {
$0.allXcodes = [xcodeDict[name]!]
})
.frame(width: 600, height: 400)
.padding()
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
.environmentObject(configure(AppState()) {
$0.allXcodes = [
.init(
version: Version(major: 12, minor: 3, patch: 0),
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
selected: false,
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,8 +1,7 @@
import SwiftUI
import XcodesKit
struct InstallationStepDetailView: View {
let installationStep: XcodeInstallationStep
let installationStep: InstallationStep
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@ -17,7 +16,7 @@ struct InstallationStepDetailView: View {
showsAdditionalDescription: true
)
case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
case .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
ProgressView()
.scaleEffect(0.5)
}
@ -25,25 +24,25 @@ struct InstallationStepDetailView: View {
}
}
#Preview("Downloading") {
InstallationStepDetailView(
installationStep: .downloading(
progress: configure(Progress()) {
$0.kind = .file
$0.fileOperationKind = .downloading
$0.estimatedTimeRemaining = 123
$0.totalUnitCount = 11944848484
$0.completedUnitCount = 848444920
$0.throughput = 9211681
}
)
)
.padding()
}
#Preview("Unarchiving") {
InstallationStepDetailView(
installationStep: .unarchiving
)
.padding()
struct InstallDetailView_Previews: PreviewProvider {
static var previews: some View {
Group {
InstallationStepDetailView(
installationStep: .downloading(
progress: configure(Progress()) {
$0.kind = .file
$0.fileOperationKind = .downloading
$0.estimatedTimeRemaining = 123
$0.totalUnitCount = 11944848484
$0.completedUnitCount = 848444920
$0.throughput = 9211681
}
)
)
InstallationStepDetailView(
installationStep: .unarchiving
)
}
}
}

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 SwiftUI
import XcodesKit
import Path
import Version
struct MainWindow: View {
@EnvironmentObject var appState: AppState
@ -15,12 +12,12 @@ struct MainWindow: View {
// FB8979533 SceneStorage doesn't restore value after app is quit by user
@AppStorage("isShowingInfoPane") private var isShowingInfoPane = false
@AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all
@AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .universal
@AppStorage("isInstalledOnly") private var isInstalledOnly = false
var body: some View {
NavigationSplitViewWrapper {
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly, architecture: architecture)
HSplitView {
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly)
.frame(minWidth: 300)
.layoutPriority(1)
.alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in
Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)),
@ -28,43 +25,18 @@ struct MainWindow: View {
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }),
secondaryButton: .cancel(Text("Cancel")))
}
.searchable(text: $searchText, placement: .sidebar)
.mainToolbar(
category: $category,
isInstalledOnly: $isInstalledOnly,
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")
}
}
if isShowingInfoPane {
InfoPane(selectedXcodeID: selectedXcodeID)
.frame(minWidth: 300, maxWidth: .infinity)
}
}
.mainToolbar(
category: $category,
isInstalledOnly: $isInstalledOnly,
isShowingInfoPane: $isShowingInfoPane,
searchText: $searchText
)
.bottomStatusBar()
.padding([.top], 0)
.navigationSubtitle(subtitleText)
@ -78,9 +50,6 @@ struct MainWindow: View {
case .twoFactor(let secondFactorData):
secondFactorView(secondFactorData)
.environmentObject(appState)
case .securityKeyTouchToConfirm:
SignInSecurityKeyTouchView(isPresented: $appState.presentedSheet.isNotNil)
.environmentObject(appState)
}
}
.alert(item: $appState.presentedAlert, content: { presentedAlert in
@ -90,11 +59,7 @@ struct MainWindow: View {
// FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue
.focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == selectedXcodeID }))
}
private var xcode: Xcode? {
appState.allXcodes.first(where: { $0.id == selectedXcodeID })
}
private var subtitleText: Text {
if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) {
return Text("\(localizeString("UpdatedAt")) \(lastUpdated, style: .date) \(lastUpdated, style: .time)")
@ -112,8 +77,6 @@ struct MainWindow: View {
SignInSMSView(isPresented: $appState.presentedSheet.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .smsPendingChoice:
SignInPhoneListView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .securityKey:
SignInSecurityKeyPinView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
}
}
@ -190,62 +153,24 @@ struct MainWindow: View {
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)),
message: Text(String(format: localizeString("Alert.MinSupported.Message"), xcode.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)
self.appState.install(id: xcode.version)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
}
}
}
struct MainWindow_Previews: PreviewProvider {
static var previews: some View {
MainWindow().environmentObject({ () -> AppState in
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
}())
MainWindow()
}
}

View file

@ -4,16 +4,19 @@ import Path
struct AdvancedPreferencePane: View {
@EnvironmentObject var appState: AppState
@AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases
@AppStorage("downloader") var downloader: Downloader = .aria2
var body: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox(label: Text("InstallDirectory")) {
VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 5) {
Text(appState.installPath).font(.footnote)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
Button(action: { appState.reveal(path: appState.installPath) }) {
Image(systemName: "arrow.right.circle.fill")
}
@ -31,15 +34,13 @@ struct AdvancedPreferencePane: View {
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)
}
}
@ -49,8 +50,8 @@ struct AdvancedPreferencePane: View {
VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 5) {
Text(appState.localPath).font(.footnote)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
Button(action: { appState.reveal(path: appState.localPath) }) {
Image(systemName: "arrow.right.circle.fill")
}
@ -68,15 +69,13 @@ struct AdvancedPreferencePane: View {
panel.directoryURL = URL(fileURLWithPath: appState.localPath)
if panel.runModal() == .OK {
guard let pathURL = panel.url, let path = Path(url: pathURL) else { return }
self.appState.localPath = path.string
}
}
.disabled(appState.disableLocalPathChange)
Text("LocalCachePathDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
@ -84,22 +83,18 @@ struct AdvancedPreferencePane: View {
GroupBox(label: Text("Active/Select")) {
VStack(alignment: .leading) {
Picker(selection: $appState.onSelectActionType) {
Picker("OnSelect", selection: $appState.onSelectActionType) {
Text(SelectedActionType.none.description)
.tag(SelectedActionType.none)
Text(SelectedActionType.rename.description)
.tag(SelectedActionType.rename)
} label: {
Text(verbatim: "OnSelect")
}
.labelsHidden()
.pickerStyle(.inline)
.disabled(appState.onSelectActionTypeDisabled)
Text(appState.onSelectActionType.detailedDescription)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
.frame(height: 20)
@ -107,25 +102,44 @@ struct AdvancedPreferencePane: View {
Toggle("AutomaticallyCreateSymbolicLink", isOn: $appState.createSymLinkOnSelect)
.disabled(appState.createSymLinkOnSelectDisabled)
Text("AutomaticallyCreateSymbolicLinkDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
}
.fixedSize(horizontal: false, vertical: true)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
if Hardware.isAppleSilicon() {
GroupBox(label: Text("Apple Silicon")) {
Toggle("ShowOpenInRosetta", isOn: $appState.showOpenInRosettaOption)
.disabled(appState.createSymLinkOnSelectDisabled)
Text("ShowOpenInRosettaDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
GroupBox(label: Text("DataSource")) {
VStack(alignment: .leading) {
Picker("DataSource", selection: $dataSource) {
ForEach(DataSource.allCases) { dataSource in
Text(dataSource.description)
.tag(dataSource)
}
}
.labelsHidden()
AttributedText(dataSourceFootnote)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
}
.groupBoxStyle(PreferencesGroupBoxStyle())
GroupBox(label: Text("Downloader")) {
VStack(alignment: .leading) {
Picker("Downloader", selection: $downloader) {
ForEach(Downloader.allCases) { downloader in
Text(downloader.description)
.tag(downloader)
}
}
.labelsHidden()
AttributedText(downloaderFootnote)
}
}
.groupBoxStyle(PreferencesGroupBoxStyle())
GroupBox(label: Text("PrivilegedHelper")) {
VStack(alignment: .leading, spacing: 8) {
@ -136,18 +150,16 @@ struct AdvancedPreferencePane: View {
case .installed:
Text("HelperInstalled")
case .notInstalled:
VStack(alignment: .leading) {
HStack {
Text("HelperNotInstalled")
Button("InstallHelper") {
appState.installHelperIfNecessary()
}
Text("HelperNotInstalled")
.font(.footnote)
}
}
Text("PrivilegedHelperDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
@ -156,6 +168,32 @@ struct AdvancedPreferencePane: View {
.groupBoxStyle(PreferencesGroupBoxStyle())
}
}
private var dataSourceFootnote: NSAttributedString {
let string = localizeString("DataSourceDescription")
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 = localizeString("DownloaderDescription")
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
}
}
struct AdvancedPreferencePane_Previews: PreviewProvider {
@ -163,9 +201,9 @@ struct AdvancedPreferencePane_Previews: PreviewProvider {
Group {
AdvancedPreferencePane()
.environmentObject(AppState())
.frame(maxWidth: 600)
.frame(maxWidth: 500)
}
.frame(width: 600, height: 700, alignment: .center)
.frame(width: 500, height: 700, alignment: .center)
}
}
@ -173,8 +211,11 @@ struct AdvancedPreferencePane_Previews: PreviewProvider {
struct PreferencesGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .top, spacing: 20) {
configuration.label
.frame(width: 180, alignment: .trailing)
HStack {
Spacer()
configuration.label
}
.frame(width: 120)
VStack(alignment: .leading) {
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,6 +1,6 @@
import AppleAPI
import Path
import SwiftUI
import Path
struct ExperimentsPreferencePane: View {
@EnvironmentObject var appState: AppState
@ -13,17 +13,29 @@ struct ExperimentsPreferencePane: View {
"UseUnxipExperiment",
isOn: $appState.unxipExperiment
)
.disabled(appState.disableUnxipExperiment)
Text("FasterUnxipDescription")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
AttributedText(unxipFootnote)
}
.fixedSize(horizontal: false, vertical: true)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
Divider()
}
}
private var unxipFootnote: NSAttributedString {
let string = localizeString("FasterUnxipDescription")
let attributedString = NSMutableAttributedString(
string: string,
attributes: [
.font: NSFont.preferredFont(forTextStyle: .footnote, options: [:]),
.foregroundColor: NSColor.labelColor
]
)
attributedString.addAttribute(.link, value: URL(string: "https://twitter.com/_saagarjha")!, range: NSRange(string.range(of: "@_saagarjha")!, in: string))
attributedString.addAttribute(.link, value: URL(string: "https://github.com/saagarjha/unxip")!, range: NSRange(string.range(of: "https://github.com/saagarjha/unxip")!, in: string))
return attributedString
}
}
struct ExperimentsPreferencePane_Previews: PreviewProvider {
@ -31,7 +43,7 @@ struct ExperimentsPreferencePane_Previews: PreviewProvider {
Group {
ExperimentsPreferencePane()
.environmentObject(AppState())
.frame(maxWidth: 600)
.frame(maxWidth: 500)
}
}
}

View file

@ -20,12 +20,6 @@ struct GeneralPreferencePane: View {
NotificationsView().environmentObject(appState)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
Divider()
GroupBox(label: Text("Misc")) {
Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed)
}
.groupBoxStyle(PreferencesGroupBoxStyle())
}
}
}
@ -35,7 +29,7 @@ struct GeneralPreferencePane_Previews: PreviewProvider {
Group {
GeneralPreferencePane()
.environmentObject(AppState())
.frame(maxWidth: 600)
.frame(maxWidth: 500)
}
}
}

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

@ -21,11 +21,6 @@ struct PreferencesView: View {
Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle")
}
.tag(Tabs.updates)
DownloadPreferencePane()
.environmentObject(appState)
.tabItem {
Label("Downloads", systemImage: "icloud.and.arrow.down")
}
AdvancedPreferencePane()
.environmentObject(appState)
.tabItem {
@ -39,6 +34,6 @@ struct PreferencesView: View {
.tag(Tabs.experiment)
}
.padding(20)
.frame(width: 600)
.frame(width: 500)
}
}

View file

@ -15,13 +15,11 @@ struct UpdatesPreferencePane: View {
"AutomaticInstallNewVersion",
isOn: $autoInstallationType.isAutoInstalling
)
.disabled(updater.disableAutoInstallNewVersions)
Toggle(
"IncludePreRelease",
isOn: $autoInstallationType.isAutoInstallingBeta
)
.disabled(updater.disableIncludePrereleaseVersions)
}
.fixedSize(horizontal: false, vertical: true)
}
@ -36,23 +34,18 @@ struct UpdatesPreferencePane: View {
isOn: $updater.automaticallyChecksForUpdates
)
.fixedSize(horizontal: true, vertical: false)
.disabled(updater.disableAutoUpdateXcodesApp)
Toggle(
"IncludePreRelease",
isOn: $updater.includePrereleaseVersions
)
.disabled(updater.disableAutoUpdateXcodesAppPrereleaseVersions)
Button("CheckNow") {
updater.checkForUpdates()
}
.padding(.top)
.disabled(updater.disableAutoUpdateXcodesApp)
Text(String(format: localizeString("LastChecked"), lastUpdatedString))
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ -76,8 +69,7 @@ struct UpdatesPreferencePane: View {
class ObservableUpdater: ObservableObject {
private let updater: SPUUpdater
private let updaterDelegate = UpdaterDelegate()
@Published var automaticallyChecksForUpdates = false {
didSet {
updater.automaticallyChecksForUpdates = automaticallyChecksForUpdates
@ -88,24 +80,18 @@ class ObservableUpdater: ObservableObject {
private var lastUpdateCheckDateObservation: NSKeyValueObservation?
@Published var includePrereleaseVersions = false {
didSet {
Current.defaults.set(includePrereleaseVersions, forKey: "includePrereleaseVersions")
UserDefaults.standard.setValue(includePrereleaseVersions, forKey: "includePrereleaseVersions")
updaterDelegate.includePrereleaseVersions = includePrereleaseVersions
if includePrereleaseVersions {
updater.setFeedURL(.prereleaseAppcast)
} else {
updater.setFeedURL(.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() {
updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: updaterDelegate, userDriverDelegate: nil).updater
// upgrade from an old sparkle version which set feeds via the updater
// now it uses the `updaterDelegate`
updater.clearFeedURLFromUserDefaults()
updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil).updater
automaticallyChecksForUpdatesObservation = updater.observe(
\.automaticallyChecksForUpdates,
@ -122,7 +108,7 @@ class ObservableUpdater: ObservableObject {
self.lastUpdateCheckDate = updater.lastUpdateCheckDate
}
)
includePrereleaseVersions = Current.defaults.bool(forKey: "includePrereleaseVersions") ?? false
includePrereleaseVersions = UserDefaults.standard.bool(forKey: "includePrereleaseVersions")
}
func checkForUpdates() {
@ -130,22 +116,9 @@ class ObservableUpdater: ObservableObject {
}
}
class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
var includePrereleaseVersions: Bool = false
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"
extension URL {
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")!
}
struct UpdatesPreferencePane_Previews: PreviewProvider {
@ -153,9 +126,7 @@ struct UpdatesPreferencePane_Previews: PreviewProvider {
Group {
UpdatesPreferencePane()
.environmentObject(AppState())
.environmentObject(ObservableUpdater())
.frame(maxWidth: 600)
.frame(minHeight: 300)
.frame(maxWidth: 500)
}
}
}

View file

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

View file

@ -10,14 +10,12 @@ struct SignIn2FAView: View {
var body: some View {
VStack(alignment: .leading) {
Text(String(format: localizeString("DigitCodeDescription"), authOptions.securityCode!.length))
Text(String(format: localizeString("DigitCodeDescription"), authOptions.securityCode.length))
.fixedSize(horizontal: true, vertical: false)
HStack {
Spacer()
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) {
appState.submitSecurityCode(.device(code: $0), sessionData: sessionData)
}
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
Spacer()
}
.padding()
@ -32,7 +30,7 @@ struct SignIn2FAView: View {
Text("Continue")
}
.keyboardShortcut(.defaultAction)
.disabled(code.count != authOptions.securityCode!.length)
.disabled(code.count != authOptions.securityCode.length)
}
.frame(height: 25)
}

View file

@ -1,14 +1,9 @@
import SwiftUI
struct SignInCredentialsView: View {
private enum FocusedField {
case username, password
}
@EnvironmentObject var appState: AppState
@State private var username: String = ""
@State private var password: String = ""
@FocusState private var focusedField: FocusedField?
var body: some View {
VStack(alignment: .leading) {
@ -18,16 +13,12 @@ struct SignInCredentialsView: View {
HStack {
Text("AppleID")
.frame(minWidth: 100, alignment: .trailing)
TextField(text: $username) {
Text(verbatim: "example@icloud.com")
}
.focused($focusedField, equals: .username)
TextField("example@icloud.com", text: $username)
}
HStack {
Text("Password")
.frame(minWidth: 100, alignment: .trailing)
SecureField("Required", text: $password)
.focused($focusedField, equals: .password)
}
if appState.authError != nil {
HStack {

View file

@ -1,5 +1,5 @@
import AppleAPI
import SwiftUI
import AppleAPI
struct SignInPhoneListView: View {
@EnvironmentObject var appState: AppState
@ -7,33 +7,29 @@ struct SignInPhoneListView: View {
@State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID?
let authOptions: AuthOptionsResponse
let sessionData: AppleSessionData
var body: some View {
VStack(alignment: .leading) {
if let phoneNumbers = authOptions.trustedPhoneNumbers, !phoneNumbers.isEmpty {
Text(String(format: localizeString("SelectTrustedPhone"), authOptions.securityCode!.length))
Text(String(format: localizeString("SelectTrustedPhone"), authOptions.securityCode.length))
List(phoneNumbers, selection: $selectedPhoneNumberID) {
Text($0.numberWithDialCode)
}
.onAppear {
if phoneNumbers.count == 1 {
selectedPhoneNumberID = phoneNumbers.first?.id
}
}
} else {
Text("NoTrustedPhones")
.font(.callout)
AttributedText(
NSAttributedString(string: localizeString("NoTrustedPhones"))
.convertingURLsToLinkAttributes()
)
Spacer()
}
HStack {
Button("Cancel", action: { isPresented = false })
.keyboardShortcut(.cancelAction)
Spacer()
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")
}
.keyboardShortcut(.defaultAction)
@ -53,10 +49,9 @@ struct SignInPhoneListView_Previews: PreviewProvider {
SignInPhoneListView(
isPresented: .constant(true),
authOptions: AuthOptionsResponse(
trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")],
trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")],
trustedDevices: nil,
securityCode: .init(length: 6)
),
securityCode: .init(length: 6)),
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
)
.environmentObject(AppState())
@ -64,10 +59,9 @@ struct SignInPhoneListView_Previews: PreviewProvider {
SignInPhoneListView(
isPresented: .constant(true),
authOptions: AuthOptionsResponse(
trustedPhoneNumbers: [],
trustedPhoneNumbers: [],
trustedDevices: nil,
securityCode: .init(length: 6)
),
securityCode: .init(length: 6)),
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
)
.environmentObject(AppState())

View file

@ -11,13 +11,11 @@ struct SignInSMSView: View {
var body: some View {
VStack(alignment: .leading) {
Text(String(format: localizeString("EnterDigitCodeDescription"), authOptions.securityCode!.length, trustedPhoneNumber.numberWithDialCode))
Text(String(format: localizeString("EnterDigitCodeDescription"), authOptions.securityCode.length, trustedPhoneNumber.numberWithDialCode))
HStack {
Spacer()
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) {
appState.submitSecurityCode(.sms(code: $0, phoneNumberId: trustedPhoneNumber.id), sessionData: sessionData)
}
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
Spacer()
}
.padding()
@ -31,7 +29,7 @@ struct SignInSMSView: View {
Text("Continue")
}
.keyboardShortcut(.defaultAction)
.disabled(code.count != authOptions.securityCode!.length)
.disabled(code.count != authOptions.securityCode.length)
}
.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())
}

View file

@ -1,54 +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 SignInSecurityKeyTouchView: View {
@EnvironmentObject var appState: AppState
@Binding var isPresented: Bool
var body: some View {
VStack(alignment: .center) {
Image(systemName: "key.radiowaves.forward")
.font(.system(size: 32)).bold()
.padding(.bottom)
HStack {
Spacer()
Text(localizeString("SecurityKeyTouchDescription"))
.fixedSize(horizontal: true, vertical: false)
Spacer()
}
HStack {
Button("Cancel", action: self.cancel)
.keyboardShortcut(.cancelAction)
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(x: 0.5, y: 0.5, anchor: .center)
.isHidden(!appState.isProcessingAuthRequest)
.keyboardShortcut(.defaultAction)
}
.frame(height: 25)
}
.padding()
.emittingError($appState.authError, recoveryHandler: { _ in })
}
func cancel() {
appState.cancelSecurityKeyAssertationRequest()
isPresented = false
}
}
#Preview {
SignInSecurityKeyTouchView(isPresented: .constant(true))
.environmentObject(AppState())
}

View file

@ -14,10 +14,10 @@ extension View {
struct View_IsHidden_Previews: PreviewProvider {
static var previews: some View {
Group {
Text(verbatim: "Not Hidden")
Text("Not Hidden")
.isHidden(false)
Text(verbatim: "Hidden")
Text("Hidden")
.isHidden(true)
}
}

View file

@ -97,45 +97,33 @@ struct AppStoreButtonStyle_Previews: PreviewProvider {
Group {
ForEach([ColorScheme.light, .dark], id: \.self) { colorScheme in
Group {
Button{ } label: {
Text(verbatim: "OPEN")
}
Button("OPEN", action: {})
.buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false))
.padding()
.background(Color(.textBackgroundColor))
.previewDisplayName("Primary")
Button{ } label: {
Text(verbatim: "OPEN")
}
Button("OPEN", action: {})
.buttonStyle(AppStoreButtonStyle(primary: true, highlighted: true))
.padding()
.background(Color(.controlAccentColor))
.previewDisplayName("Primary, Highlighted")
Button{ } label: {
Text(verbatim: "OPEN")
}
Button("OPEN", action: {})
.buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false))
.padding()
.disabled(true)
.background(Color(.textBackgroundColor))
.previewDisplayName("Primary, Disabled")
Button{ } label: {
Text(verbatim: "INSTALL")
}
Button("INSTALL", action: {})
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false))
.padding()
.background(Color(.textBackgroundColor))
.previewDisplayName("Secondary")
Button{ } label: {
Text(verbatim: "INSTALL")
}
Button("INSTALL", action: {})
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: true))
.padding()
.background(Color(.controlAccentColor))
.previewDisplayName("Secondary, Highlighted")
Button{ } label: {
Text(verbatim: "INSTALL")
}
Button("INSTALL", action: {})
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false))
.padding()
.disabled(true)

View file

@ -9,40 +9,27 @@
import Foundation
import SwiftUI
struct BottomStatusModifier: ViewModifier {
@EnvironmentObject var appState: AppState
@AppStorage(PreferenceKey.hideSupportXcodes.rawValue) var hideSupportXcodes = false
@SwiftUI.Environment(\.openURL) var openURL: OpenURLAction
func body(content: Content) -> some View {
content
VStack(spacing: 0) {
content
VStack(spacing: 0) {
Divider()
HStack {
Text(appState.bottomStatusBarMessage)
.font(.subheadline)
Spacer()
if !hideSupportXcodes {
Button(action: {
openURL(URL(string: "https://opencollective.com/xcodesapp")!)
}) {
HStack {
Image(systemName: "heart.circle")
Text("Support.Xcodes")
}
}
}
Text(verbatim: "\(Bundle.main.shortVersion!) (\(Bundle.main.version!))")
.font(.subheadline)
}
.frame(maxWidth: .infinity, maxHeight: 30, alignment: .leading)
.padding([.leading, .trailing], 10)
Divider()
HStack {
Text(appState.bottomStatusBarMessage)
.font(.subheadline)
Spacer()
Text(Bundle.main.shortVersion!)
.font(.subheadline)
}
.frame(maxWidth: .infinity, maxHeight: 30, alignment: .leading)
.padding([.leading, .trailing], 10)
}
.frame(maxWidth: .infinity, maxHeight: 30, alignment: .leading)
}
}
extension View {
@ -55,34 +42,8 @@ extension View {
struct Previews_BottomStatusBar_Previews: PreviewProvider {
static var previews: some View {
Group {
HStack {
}
.bottomStatusBar()
.environmentObject({ () -> AppState in
let a = AppState()
return a }()
)
.defaultAppStorage({ () -> UserDefaults in
let d = UserDefaults(suiteName: "hide_support")!
d.set(true, forKey: PreferenceKey.hideSupportXcodes.rawValue)
return d
}())
HStack {
}
.bottomStatusBar()
.environmentObject({ () -> AppState in
let a = AppState()
return a }()
)
.defaultAppStorage({ () -> UserDefaults in
let d = UserDefaults(suiteName: "show_support")!
d.set(false, forKey: PreferenceKey.hideSupportXcodes.rawValue)
return d
}())
}
HStack {
}.bottomStatusBar()
}
}

View file

@ -1,8 +1,7 @@
import SwiftUI
import XcodesKit
struct InstallationStepRowView: View {
let installationStep: XcodeInstallationStep
let installationStep: InstallationStep
let highlighted: Bool
let cancel: () -> Void
@ -18,7 +17,7 @@ struct InstallationStepRowView: View {
controlSize: .small,
style: .spinning
)
case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
case .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
ProgressView()
.scaleEffect(0.5)
}

View file

@ -5,17 +5,22 @@ struct MainToolbarModifier: ViewModifier {
@Binding var category: XcodeListCategory
@Binding var isInstalledOnly: Bool
@Binding var isShowingInfoPane: Bool
@Binding var architectures: XcodeListArchitecture
@Binding var searchText: String
func body(content: Content) -> some View {
content
.toolbar { toolbar }
}
private var toolbar: some ToolbarContent {
ToolbarItemGroup {
ToolbarItemGroup(placement: .status) {
Button(action: { appState.presentedSheet = .signIn }, label: {
Label("Login", systemImage: "person.circle")
})
.help("LoginDescription")
ProgressButton(
isInProgress: appState.isUpdating,
isInProgress: appState.isUpdating,
action: appState.update
) {
Label("Refresh", systemImage: "arrow.clockwise")
@ -23,43 +28,73 @@ struct MainToolbarModifier: ViewModifier {
.keyboardShortcut(KeyEquivalent("r"))
.help("RefreshDescription")
Spacer()
let isFiltering = isInstalledOnly || category != .all || architectures != .universal
Menu("Filter", systemImage: "line.horizontal.3.decrease.circle") {
Section {
Toggle("Installed Only", systemImage: "arrow.down.app", isOn: $isInstalledOnly) .labelStyle(.titleAndIcon)
Button(action: {
switch category {
case .all: category = .release
case .release: category = .beta
case .beta: category = .all
}
.help("FilterInstalledDescription")
Section {
Picker("Category", selection: $category) {
Label("All", systemImage: "line.horizontal.3.decrease.circle")
.tag(XcodeListCategory.all)
}) {
switch category {
case .all:
Label("All", systemImage: "line.horizontal.3.decrease.circle")
case .release:
if #available(macOS 11.3, *) {
Label("ReleaseOnly", systemImage: "line.horizontal.3.decrease.circle.fill")
.tag(XcodeListCategory.release)
Label("BetaOnly", systemImage: "line.horizontal.3.decrease.circle.fill")
.tag(XcodeListCategory.beta)
}
}
.help("FilterAvailableDescription")
.disabled(category.isManaged)
Section {
Picker("Architecture", selection: $architectures) {
Label("Universal", systemImage: "cpu.fill")
.tag(XcodeListArchitecture.universal)
Label("Apple Silicon", systemImage: "m4.button.horizontal")
.labelStyle(TitleAndIconLabelStyle())
.foregroundColor(.accentColor)
} else {
Label("ReleaseOnly", systemImage: "line.horizontal.3.decrease.circle.fill")
.labelStyle(TitleOnlyLabelStyle())
.foregroundColor(.accentColor)
}
case .beta:
if #available(macOS 11.3, *) {
Label("BetaOnly", systemImage: "line.horizontal.3.decrease.circle.fill")
.labelStyle(TitleAndIconLabelStyle())
.foregroundColor(.accentColor)
} else {
Label("BetaOnly", systemImage: "line.horizontal.3.decrease.circle.fill")
.labelStyle(TitleOnlyLabelStyle())
.foregroundColor(.accentColor)
.tag(XcodeListArchitecture.appleSilicon)
}
.help("FilterArchitecturesDescription")
.disabled(architectures.isManaged)
}
.labelStyle(.titleAndIcon)
}
.pickerStyle(.inline)
.symbolVariant(isFiltering ? .fill : .none)
.help("FilterAvailableDescription")
Button(action: {
isInstalledOnly.toggle()
}) {
if isInstalledOnly {
Label("Filter", systemImage: "arrow.down.app.fill")
.foregroundColor(.accentColor)
} else {
Label("Filter", systemImage: "arrow.down.app")
}
}
.help("FilterInstalledDescription")
Button(action: { isShowingInfoPane.toggle() }) {
if isShowingInfoPane {
Label("Info", systemImage: "info.circle.fill")
.foregroundColor(.accentColor)
} else {
Label("Info", systemImage: "info.circle")
}
}
.keyboardShortcut(KeyboardShortcut("i", modifiers: [.command, .option]))
.help("InfoDescription")
Button(action: { NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) }, label: {
Label("Preferences", systemImage: "gearshape")
})
.help("PreferencesDescription")
TextField("Search", text: $searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 200)
.help("SearchDescription")
}
}
}
@ -69,14 +104,14 @@ extension View {
category: Binding<XcodeListCategory>,
isInstalledOnly: Binding<Bool>,
isShowingInfoPane: Binding<Bool>,
architecture: Binding<XcodeListArchitecture>
searchText: Binding<String>
) -> some View {
modifier(
self.modifier(
MainToolbarModifier(
category: category,
isInstalledOnly: isInstalledOnly,
isShowingInfoPane: isShowingInfoPane,
architectures: architecture
searchText: searchText
)
)
}

View file

@ -1,5 +1,4 @@
import Foundation
import XcodesKit
enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConvertible {
case all
@ -15,22 +14,4 @@ enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConverti
case .beta: return localizeString("Beta")
}
}
var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() }
}
enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConvertible {
case universal
case appleSilicon
var id: Self { self }
var description: String {
switch self {
case .universal: return localizeString("Universal")
case .appleSilicon: return localizeString("Apple Silicon")
}
}
var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() }
}

View file

@ -7,16 +7,13 @@ struct XcodeListView: View {
@Binding var selectedXcodeID: Xcode.ID?
private let searchText: String
private let category: XcodeListCategory
private let architecture: XcodeListArchitecture
private let isInstalledOnly: Bool
@AppStorage(PreferenceKey.allowedMajorVersions.rawValue) private var allowedMajorVersions = Int.max
init(selectedXcodeID: Binding<Xcode.ID?>, searchText: String, category: XcodeListCategory, isInstalledOnly: Bool, architecture: XcodeListArchitecture) {
init(selectedXcodeID: Binding<Xcode.ID?>, searchText: String, category: XcodeListCategory, isInstalledOnly: Bool) {
self._selectedXcodeID = selectedXcodeID
self.searchText = searchText
self.category = category
self.isInstalledOnly = isInstalledOnly
self.architecture = architecture
}
var visibleXcodes: [Xcode] {
@ -30,27 +27,6 @@ struct XcodeListView: View {
xcodes = appState.allXcodes.filter { $0.version.isPrerelease }
}
if architecture == .appleSilicon {
xcodes = xcodes.filter { $0.architectures == [.arm64] }
}
let latestMajor = xcodes.sorted(\.version)
.filter { $0.version.isNotPrerelease }
.last?
.version
.major
xcodes = xcodes.filter {
if $0.installState.notInstalled,
let latestMajor = latestMajor,
$0.version.major < (latestMajor - min(latestMajor,allowedMajorVersions)) {
return false
}
return true
}
if !searchText.isEmpty {
xcodes = xcodes.filter { $0.description.contains(searchText) }
}
@ -66,65 +42,21 @@ struct XcodeListView: View {
List(visibleXcodes, selection: $selectedXcodeID) { xcode in
XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id, appState: appState)
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom, spacing: 0) {
PlatformsPocket()
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}
struct PlatformsPocket: View {
@SwiftUI.Environment(\.openWindow) private var openWindow
var body: some View {
Button(action: {
openWindow(id: "platforms")
}
) {
if #available(macOS 26.0, *) {
platformsLabel
.glassEffect(in: .rect(cornerRadius: 8, style: .continuous))
} else {
platformsLabel
.background(.quaternary.opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
.buttonStyle(.plain)
}
var platformsLabel: some View {
HStack(spacing: 5) {
Image(systemName: "square.3.layers.3d")
.font(.title3.weight(.medium))
Text("PlatformsDescription")
Spacer()
}
.font(.body.weight(.medium))
.padding(.horizontal)
.padding(.vertical, 12)
}
}
struct XcodeListView_Previews: PreviewProvider {
static var previews: some View {
Group {
XcodeListView(selectedXcodeID: .constant(nil), searchText: "", category: .all, isInstalledOnly: false, architecture: .appleSilicon)
XcodeListView(selectedXcodeID: .constant(nil), searchText: "", category: .all, isInstalledOnly: false)
.environmentObject({ () -> AppState in
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.0.0+1234A")!, identicalBuilds: [Version("12.0.0+1234A")!, 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),
Xcode(version: Version("10.1.0")!, installState: .notInstalled, selected: false, icon: nil),
Xcode(version: Version("10.0.0")!, installState: .installed(Path("/Applications/Xcode-10.0.0.app")!), selected: false, icon: nil),
Xcode(version: Version("9.0.0")!, installState: .notInstalled, selected: false, icon: nil),
]
return a
}())

View file

@ -6,48 +6,42 @@ struct XcodeListViewRow: View {
let xcode: Xcode
let selected: Bool
let appState: AppState
var body: some View {
HStack {
appIconView(for: xcode)
VStack(alignment: .leading) {
HStack {
Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)")
.font(.body)
if !xcode.identicalBuilds.isEmpty {
Image(systemName: "square.fill.on.square.fill")
.font(.subheadline)
.foregroundColor(.secondary)
.accessibility(label: Text("IdenticalBuilds"))
.accessibility(value: Text(xcode.identicalBuilds.map(\.version.appleDescription).joined(separator: ", ")))
.accessibility(value: Text(xcode.identicalBuilds.map(\.appleDescription).joined(separator: ", ")))
.help("IdenticalBuilds.help")
}
if xcode.architectures?.isAppleSilicon ?? false {
Image(systemName: "m4.button.horizontal")
.font(.subheadline)
.foregroundColor(.secondary)
.accessibility(label: Text("Apple Silicon"))
.help("Apple Silicon")
}
}
if case let .installed(path) = xcode.installState {
Text(verbatim: path.string)
.font(.caption)
.foregroundColor(.secondary)
} else {
Text(verbatim: "")
.font(.caption)
}
}
Spacer()
selectControl(for: xcode)
.padding(.trailing, 16)
installControl(for: xcode)
}
.padding(.vertical, 4)
.contextMenu {
switch xcode.installState {
case .notInstalled:
@ -60,17 +54,14 @@ struct XcodeListViewRow: View {
RevealButton(xcode: xcode)
CopyPathButton(xcode: xcode)
CreateSymbolicLinkButton(xcode: xcode)
if xcode.version.isPrerelease {
CreateSymbolicBetaLinkButton(xcode: xcode)
}
Divider()
UninstallButton(xcode: xcode)
#if DEBUG
Divider()
Button("Perform post-install steps") {
appState.performPostInstallSteps(for: InstalledXcode(path: path)!) as Void
}
Divider()
Button("Perform post-install steps") {
appState.performPostInstallSteps(for: InstalledXcode(path: path)!) as Void
}
#endif
}
}
@ -81,13 +72,12 @@ struct XcodeListViewRow: View {
if let icon = xcode.icon {
Image(nsImage: icon)
} else {
Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode")
.resizable()
Color.clear
.frame(width: 32, height: 32)
.opacity(0.2)
.foregroundColor(.secondary)
}
}
@ViewBuilder
private func selectControl(for xcode: Xcode) -> some View {
if xcode.installState.installed {
@ -107,7 +97,7 @@ struct XcodeListViewRow: View {
EmptyView()
}
}
@ViewBuilder
private func installControl(for xcode: Xcode) -> some View {
switch xcode.installState {
@ -117,9 +107,10 @@ struct XcodeListViewRow: View {
.buttonStyle(AppStoreButtonStyle(primary: true, highlighted: selected))
.help("OpenDescription")
case .notInstalled:
InstallButton(xcode: xcode)
Button("Install") { appState.checkMinVersionAndInstall(id: xcode.id) }
.textCase(.uppercase)
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false))
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: selected))
.help("InstallDescription")
case let .installing(installationStep):
InstallationStepRowView(
installationStep: installationStep,
@ -138,33 +129,33 @@ struct XcodeListViewRow_Previews: PreviewProvider {
selected: false,
appState: AppState()
)
XcodeListViewRow(
xcode: Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil),
selected: false,
appState: AppState()
)
XcodeListViewRow(
xcode: Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil),
selected: false,
appState: AppState()
)
XcodeListViewRow(
xcode: Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
selected: false,
appState: AppState()
)
XcodeListViewRow(
xcode: Xcode(version: Version("12.0.0+1234A")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
selected: false,
appState: AppState()
)
XcodeListViewRow(
xcode: Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [XcodeID(version: Version("12.0.0-RC+1234A")!)], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
xcode: Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [Version("12.0.0-RC+1234A")!], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
selected: false,
appState: AppState()
)

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "Image.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

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