mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-04-26 14:57:37 +00:00
Merge pull request #11 from RobotsAndPencils/auth
Implement authentication
This commit is contained in:
commit
99ba4d83cb
17 changed files with 943 additions and 387 deletions
|
|
@ -10,6 +10,9 @@
|
||||||
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
|
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
|
||||||
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
|
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
|
||||||
CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; };
|
CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; };
|
||||||
|
CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; };
|
||||||
|
CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.swift */; };
|
||||||
|
CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */; };
|
||||||
CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; };
|
CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; };
|
||||||
CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; };
|
CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; };
|
||||||
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; };
|
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; };
|
||||||
|
|
@ -40,6 +43,9 @@
|
||||||
CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||||
CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = "<group>"; };
|
CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = "<group>"; };
|
||||||
CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = "<group>"; };
|
CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = "<group>"; };
|
||||||
|
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = "<group>"; };
|
||||||
|
CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = "<group>"; };
|
||||||
|
CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+.swift"; sourceTree = "<group>"; };
|
||||||
CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
|
CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
|
||||||
CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = "<group>"; };
|
CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = "<group>"; };
|
||||||
CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = "<group>"; };
|
CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = "<group>"; };
|
||||||
|
|
@ -92,6 +98,7 @@
|
||||||
children = (
|
children = (
|
||||||
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */,
|
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */,
|
||||||
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */,
|
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */,
|
||||||
|
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */,
|
||||||
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */,
|
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */,
|
||||||
CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */,
|
CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -130,6 +137,8 @@
|
||||||
CAA1CB50255A5D16003FD669 /* SignIn */,
|
CAA1CB50255A5D16003FD669 /* SignIn */,
|
||||||
CA378F982466567600A58CE0 /* AppState.swift */,
|
CA378F982466567600A58CE0 /* AppState.swift */,
|
||||||
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */,
|
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */,
|
||||||
|
CA735108257BF96D00EA9CF8 /* AttributedText.swift */,
|
||||||
|
CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */,
|
||||||
CA44901E2463AD34003D8213 /* Tag.swift */,
|
CA44901E2463AD34003D8213 /* Tag.swift */,
|
||||||
CAD2E7A52449575000113D76 /* Assets.xcassets */,
|
CAD2E7A52449575000113D76 /* Assets.xcassets */,
|
||||||
CAD2E7AA2449575000113D76 /* Main.storyboard */,
|
CAD2E7AA2449575000113D76 /* Main.storyboard */,
|
||||||
|
|
@ -264,14 +273,17 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */,
|
||||||
CA44901F2463AD34003D8213 /* Tag.swift in Sources */,
|
CA44901F2463AD34003D8213 /* Tag.swift in Sources */,
|
||||||
CA378F992466567600A58CE0 /* AppState.swift in Sources */,
|
CA378F992466567600A58CE0 /* AppState.swift in Sources */,
|
||||||
CAD2E7A42449574E00113D76 /* ContentView.swift in Sources */,
|
CAD2E7A42449574E00113D76 /* ContentView.swift in Sources */,
|
||||||
CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */,
|
CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */,
|
||||||
CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */,
|
CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */,
|
||||||
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */,
|
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */,
|
||||||
|
CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */,
|
||||||
CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */,
|
CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */,
|
||||||
CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */,
|
CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */,
|
||||||
|
CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */,
|
||||||
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */,
|
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
<Workspace
|
<Workspace
|
||||||
version = "1.0">
|
version = "1.0">
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "self:/Users/brandon/Projects/XcodesApp/Xcodes.xcodeproj">
|
location = "self:">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ class AppState: ObservableObject {
|
||||||
case installing(Progress)
|
case installing(Progress)
|
||||||
case installed
|
case installed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var authenticationState: AuthenticationState = .unauthenticated
|
||||||
@Published var allVersions: [XcodeVersion] = []
|
@Published var allVersions: [XcodeVersion] = []
|
||||||
|
|
||||||
struct AlertContent: Identifiable {
|
struct AlertContent: Identifiable {
|
||||||
|
|
@ -32,53 +34,175 @@ class AppState: ObservableObject {
|
||||||
@Published var error: AlertContent?
|
@Published var error: AlertContent?
|
||||||
|
|
||||||
@Published var presentingSignInAlert = false
|
@Published var presentingSignInAlert = false
|
||||||
|
@Published var secondFactorData: SecondFactorData?
|
||||||
|
|
||||||
|
struct SecondFactorData {
|
||||||
|
let option: TwoFactorOption
|
||||||
|
let authOptions: AuthOptionsResponse
|
||||||
|
let sessionData: AppleSessionData
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
let client = AppleAPI.Client()
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
// if list.shouldUpdate {
|
// if list.shouldUpdate {
|
||||||
|
// Treat this implementation as a placeholder that can be thrown away.
|
||||||
|
// It's only here to make it easy to see that auth works.
|
||||||
update()
|
update()
|
||||||
.done { _ in
|
.sink(
|
||||||
self.updateAllVersions()
|
receiveCompletion: { completion in
|
||||||
}
|
dump(completion)
|
||||||
.catch { error in
|
},
|
||||||
self.error = AlertContent(title: "Error",
|
receiveValue: { xcodes in
|
||||||
message: error.localizedDescription)
|
let installedXcodes = Current.files.installedXcodes(Path.root/"Applications")
|
||||||
}
|
var allXcodeVersions = xcodes.map { $0.version }
|
||||||
// }
|
for installedXcode in installedXcodes {
|
||||||
|
// If an installed version isn't listed online, add the installed version
|
||||||
|
if !allXcodeVersions.contains(where: { version in
|
||||||
|
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version)
|
||||||
|
}) {
|
||||||
|
allXcodeVersions.append(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 with build metadata
|
||||||
|
else if let index = allXcodeVersions.firstIndex(where: { version in
|
||||||
|
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) &&
|
||||||
|
version.buildMetadataIdentifiers.isEmpty
|
||||||
|
}) {
|
||||||
|
allXcodeVersions[index] = installedXcode.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.allVersions = allXcodeVersions
|
||||||
|
.sorted(by: >)
|
||||||
|
.map { xcodeVersion in
|
||||||
|
let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) })
|
||||||
|
return XcodeVersion(
|
||||||
|
title: xcodeVersion.xcodeDescription,
|
||||||
|
installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled,
|
||||||
|
selected: installedXcode?.path.string.contains("12.2") == true,
|
||||||
|
path: installedXcode?.path.string
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
// .done { _ in
|
||||||
|
// self.updateAllVersions()
|
||||||
|
// }
|
||||||
|
// .catch { error in
|
||||||
|
// self.error = AlertContent(title: "Error",
|
||||||
|
// message: error.localizedDescription)
|
||||||
|
// }
|
||||||
|
//// }
|
||||||
// else {
|
// else {
|
||||||
// updateAllVersions()
|
// updateAllVersions()
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSession() -> Promise<Void> {
|
// MARK: - Authentication
|
||||||
return firstly { () -> Promise<Void> in
|
|
||||||
return Current.network.validateSession()
|
func validateSession() -> AnyPublisher<Void, Error> {
|
||||||
}
|
return client.validateSession()
|
||||||
.recover { _ in
|
.handleEvents(receiveCompletion: { completion in
|
||||||
self.presentingSignInAlert = true
|
if case .failure = completion {
|
||||||
}
|
self.authenticationState = .unauthenticated
|
||||||
|
self.presentingSignInAlert = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func continueLogin(username: String, password: String) -> Promise<Void> {
|
func login(username: String, password: String) {
|
||||||
firstly { () -> Promise<Void> in
|
client.login(accountName: username, password: password)
|
||||||
self.installer.login(username, password: password)
|
.receive(on: DispatchQueue.main)
|
||||||
}
|
.sink(
|
||||||
.recover { error -> Promise<Void> in
|
receiveCompletion: { completion in
|
||||||
XcodesKit.Current.logging.log(error.legibleLocalizedDescription)
|
self.handleAuthenticationFlowCompletion(completion)
|
||||||
|
},
|
||||||
|
receiveValue: { authenticationState in
|
||||||
|
self.authenticationState = authenticationState
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) {
|
||||||
|
self.presentingSignInAlert = false
|
||||||
|
self.secondFactorData = SecondFactorData(
|
||||||
|
option: option,
|
||||||
|
authOptions: authOptions,
|
||||||
|
sessionData: AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if case Client.Error.invalidUsernameOrPassword = error {
|
func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
|
||||||
self.presentingSignInAlert = true
|
client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData)
|
||||||
|
.sink(
|
||||||
|
receiveCompletion: { completion in
|
||||||
|
self.handleAuthenticationFlowCompletion(completion)
|
||||||
|
},
|
||||||
|
receiveValue: { authenticationState in
|
||||||
|
self.authenticationState = authenticationState
|
||||||
|
if case let AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData) = authenticationState {
|
||||||
|
self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
|
||||||
|
secondFactorData = SecondFactorData(option: .smsPendingChoice, authOptions: authOptions, sessionData: sessionData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) {
|
||||||
|
client.submitSecurityCode(code, sessionData: sessionData)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink(
|
||||||
|
receiveCompletion: { completion in
|
||||||
|
self.handleAuthenticationFlowCompletion(completion)
|
||||||
|
},
|
||||||
|
receiveValue: { authenticationState in
|
||||||
|
self.authenticationState = authenticationState
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion<Error>) {
|
||||||
|
switch completion {
|
||||||
|
case let .failure(error):
|
||||||
|
self.error = AlertContent(title: "Error signing in", message: error.legibleLocalizedDescription)
|
||||||
|
case .finished:
|
||||||
|
switch self.authenticationState {
|
||||||
|
case .authenticated, .unauthenticated:
|
||||||
|
self.presentingSignInAlert = false
|
||||||
|
self.secondFactorData = nil
|
||||||
|
case let .waitingForSecondFactor(option, authOptions, sessionData):
|
||||||
|
self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
|
||||||
}
|
}
|
||||||
return Promise(error: error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update() -> Promise<[Xcode]> {
|
// MARK: -
|
||||||
return firstly { () -> Promise<Void> in
|
|
||||||
validateSession()
|
public func update() -> AnyPublisher<[Xcode], Error> {
|
||||||
}
|
// return firstly { () -> Promise<Void> in
|
||||||
.then { () -> Promise<[Xcode]> in
|
// validateSession()
|
||||||
self.list.update()
|
// }
|
||||||
|
// .then { () -> Promise<[Xcode]> in
|
||||||
|
// self.list.update()
|
||||||
|
// }
|
||||||
|
// Wrap the Promise API in a Publisher for now
|
||||||
|
return Deferred {
|
||||||
|
Future { promise in
|
||||||
|
self.list.update()
|
||||||
|
.done { promise(.success($0)) }
|
||||||
|
.catch { promise(.failure($0)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAllVersions() {
|
private func updateAllVersions() {
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,20 @@ import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "AppleAPI",
|
name: "AppleAPI",
|
||||||
platforms: [.macOS(.v10_13)],
|
platforms: [.macOS(.v10_15)],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
.library(
|
.library(
|
||||||
name: "AppleAPI",
|
name: "AppleAPI",
|
||||||
targets: ["AppleAPI"]),
|
targets: ["AppleAPI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [],
|
||||||
// Dependencies declare other packages that this package depends on.
|
|
||||||
.package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")),
|
|
||||||
.package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")),
|
|
||||||
],
|
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "AppleAPI",
|
name: "AppleAPI",
|
||||||
dependencies: ["PromiseKit", "PMKFoundation"]),
|
dependencies: []),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "AppleAPITests",
|
name: "AppleAPITests",
|
||||||
dependencies: ["AppleAPI"]),
|
dependencies: ["AppleAPI"]),
|
||||||
|
|
|
||||||
|
|
@ -1,269 +1,266 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import PromiseKit
|
import Combine
|
||||||
import PMKFoundation
|
|
||||||
|
|
||||||
public class Client {
|
public class Client {
|
||||||
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
|
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public enum Error: Swift.Error, LocalizedError, Equatable {
|
// MARK: - Login
|
||||||
case invalidSession
|
|
||||||
case invalidUsernameOrPassword(username: String)
|
|
||||||
case invalidPhoneNumberIndex(min: Int, max: Int, given: String?)
|
|
||||||
case incorrectSecurityCode
|
|
||||||
case unexpectedSignInResponse(statusCode: Int, message: String?)
|
|
||||||
case appleIDAndPrivacyAcknowledgementRequired
|
|
||||||
case noTrustedPhoneNumbers
|
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public func login(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
|
||||||
switch self {
|
|
||||||
case .invalidUsernameOrPassword(let username):
|
|
||||||
return "Invalid username and password combination. Attempted to sign in with username \(username)."
|
|
||||||
case .appleIDAndPrivacyAcknowledgementRequired:
|
|
||||||
return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement."
|
|
||||||
case .invalidPhoneNumberIndex(let min, let max, let given):
|
|
||||||
return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")."
|
|
||||||
case .noTrustedPhoneNumbers:
|
|
||||||
return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915."
|
|
||||||
default:
|
|
||||||
return String(describing: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Use the olympus session endpoint to see if the existing session is still valid
|
|
||||||
public func validateSession() -> Promise<Void> {
|
|
||||||
return Current.network.dataTask(with: URLRequest.olympusSession)
|
|
||||||
.done { data, response in
|
|
||||||
guard
|
|
||||||
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
||||||
jsonObject["provider"] != nil
|
|
||||||
else { throw Error.invalidSession }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func login(accountName: String, password: String) -> Promise<Void> {
|
|
||||||
var serviceKey: String!
|
var serviceKey: String!
|
||||||
|
|
||||||
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
|
return Current.network.dataTask(with: URLRequest.itcServiceKey)
|
||||||
Current.network.dataTask(with: URLRequest.itcServiceKey)
|
.map(\.data)
|
||||||
}
|
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
|
||||||
.then { (data, _) -> Promise<(data: Data, response: URLResponse)> in
|
.flatMap { serviceKeyResponse -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
|
||||||
struct ServiceKeyResponse: Decodable {
|
serviceKey = serviceKeyResponse.authServiceKey
|
||||||
let authServiceKey: String
|
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
|
||||||
|
.mapError { $0 as Swift.Error }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
.flatMap { result -> AnyPublisher<AuthenticationState, Swift.Error> in
|
||||||
let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data)
|
let (data, response) = result
|
||||||
serviceKey = response.authServiceKey
|
return Just(data)
|
||||||
|
.decode(type: SignInResponse.self, decoder: JSONDecoder())
|
||||||
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
|
.flatMap { responseBody -> AnyPublisher<AuthenticationState, Swift.Error> in
|
||||||
}
|
let httpResponse = response as! HTTPURLResponse
|
||||||
.then { (data, response) -> Promise<Void> in
|
|
||||||
struct SignInResponse: Decodable {
|
switch httpResponse.statusCode {
|
||||||
let authType: String?
|
case 200:
|
||||||
let serviceErrors: [ServiceError]?
|
return Current.network.dataTask(with: URLRequest.olympusSession)
|
||||||
|
.map { _ in AuthenticationState.authenticated }
|
||||||
struct ServiceError: Decodable, CustomStringConvertible {
|
.mapError { $0 as Swift.Error }
|
||||||
let code: String
|
.eraseToAnyPublisher()
|
||||||
let message: String
|
case 401:
|
||||||
|
return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName))
|
||||||
var description: String {
|
.eraseToAnyPublisher()
|
||||||
return "\(code): \(message)"
|
case 409:
|
||||||
|
return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
|
||||||
|
case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
|
||||||
|
return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
default:
|
||||||
|
return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode,
|
||||||
|
message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")))
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
.mapError { $0 as Swift.Error }
|
||||||
let httpResponse = response as! HTTPURLResponse
|
.eraseToAnyPublisher()
|
||||||
let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data)
|
|
||||||
|
|
||||||
switch httpResponse.statusCode {
|
|
||||||
case 200:
|
|
||||||
return Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
|
|
||||||
case 401:
|
|
||||||
throw Error.invalidUsernameOrPassword(username: accountName)
|
|
||||||
case 409:
|
|
||||||
return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
|
|
||||||
case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
|
|
||||||
throw Error.appleIDAndPrivacyAcknowledgementRequired
|
|
||||||
default:
|
|
||||||
throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode,
|
|
||||||
message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise<Void> {
|
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
|
||||||
let httpResponse = response as! HTTPURLResponse
|
let httpResponse = response as! HTTPURLResponse
|
||||||
let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String)
|
let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String)
|
||||||
let scnt = (httpResponse.allHeaderFields["scnt"] as! String)
|
let scnt = (httpResponse.allHeaderFields["scnt"] as! String)
|
||||||
|
|
||||||
return firstly { () -> Promise<AuthOptionsResponse> in
|
return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
|
||||||
return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
|
.map(\.data)
|
||||||
.map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) }
|
.decode(type: AuthOptionsResponse.self, decoder: JSONDecoder())
|
||||||
}
|
.flatMap { authOptions -> AnyPublisher<AuthenticationState, Error> in
|
||||||
.then { authOptions -> Promise<Void> in
|
switch authOptions.kind {
|
||||||
switch authOptions.kind {
|
case .twoStep:
|
||||||
case .twoStep:
|
return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
|
||||||
Current.logging.log("Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new")
|
.eraseToAnyPublisher()
|
||||||
return Promise.value(())
|
case .twoFactor:
|
||||||
case .twoFactor:
|
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
|
||||||
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
|
.eraseToAnyPublisher()
|
||||||
case .unknown:
|
case .unknown:
|
||||||
Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:")
|
let possibleResponseString = String(data: data, encoding: .utf8)
|
||||||
String(data: data, encoding: .utf8).map { Current.logging.log($0) }
|
return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString))
|
||||||
return Promise.value(())
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise<Void> {
|
func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher<AuthenticationState, Error> {
|
||||||
let option: TwoFactorOption
|
let option: TwoFactorOption
|
||||||
|
|
||||||
// SMS was sent automatically
|
// SMS was sent automatically
|
||||||
if authOptions.smsAutomaticallySent {
|
if authOptions.smsAutomaticallySent {
|
||||||
option = .smsSent(authOptions.securityCode.length, authOptions.trustedPhoneNumbers!.first!)
|
option = .smsSent(authOptions.trustedPhoneNumbers!.first!)
|
||||||
// SMS wasn't sent automatically because user needs to choose a phone to send to
|
// SMS wasn't sent automatically because user needs to choose a phone to send to
|
||||||
} else if authOptions.canFallBackToSMS {
|
} else if authOptions.canFallBackToSMS {
|
||||||
option = .smsPendingChoice(authOptions.securityCode.length, authOptions.trustedPhoneNumbers ?? [])
|
option = .smsPendingChoice
|
||||||
// Code is shown on trusted devices
|
// Code is shown on trusted devices
|
||||||
} else {
|
} else {
|
||||||
option = .codeSent(authOptions.securityCode.length)
|
option = .codeSent
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
||||||
|
return Just(AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData))
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
|
// MARK: - Continue 2FA
|
||||||
Current.logging.log("Two-factor authentication is enabled for this account.\n")
|
|
||||||
switch option {
|
public func requestSMSSecurityCode(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
|
||||||
case let .smsSent(codeLength, phoneNumber):
|
Result {
|
||||||
return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in
|
try URLRequest.requestSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, trustedPhoneID: trustedPhoneNumber.id)
|
||||||
let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber)
|
|
||||||
return Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
|
|
||||||
.validateSecurityCodeResponse()
|
|
||||||
}
|
|
||||||
.then { (data, response) -> Promise<Void> in
|
|
||||||
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
|
||||||
}
|
|
||||||
case let .smsPendingChoice(codeLength, trustedPhoneNumbers):
|
|
||||||
return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
|
||||||
case let .codeSent(codeLength):
|
|
||||||
let code = Current.shell.readLine("""
|
|
||||||
Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to.
|
|
||||||
Enter the \(codeLength) digit code from one of your trusted devices:
|
|
||||||
""") ?? ""
|
|
||||||
|
|
||||||
if code == "sms" {
|
|
||||||
// return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return firstly {
|
|
||||||
Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code)))
|
|
||||||
.validateSecurityCodeResponse()
|
|
||||||
|
|
||||||
}
|
|
||||||
.then { (data, response) -> Promise<Void> in
|
|
||||||
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.publisher
|
||||||
|
.flatMap { request in
|
||||||
|
Current.network.dataTask(with: request)
|
||||||
|
.mapError { $0 as Error }
|
||||||
|
}
|
||||||
|
.map { _ in AuthenticationState.waitingForSecondFactor(.smsSent(trustedPhoneNumber), authOptions, sessionData) }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
|
public func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
|
||||||
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
|
Result {
|
||||||
.then { (data, response) -> Promise<Void> in
|
try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: code)
|
||||||
Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise<AuthOptionsResponse.TrustedPhoneNumber> {
|
|
||||||
return firstly { () throws -> Guarantee<AuthOptionsResponse.TrustedPhoneNumber> in
|
|
||||||
Current.logging.log("Trusted phone numbers:")
|
|
||||||
trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in
|
|
||||||
Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ")
|
|
||||||
guard
|
|
||||||
let selectionNumberString = possibleSelectionNumberString,
|
|
||||||
let selectionNumber = Int(selectionNumberString) ,
|
|
||||||
trustedPhoneNumbers.indices.contains(selectionNumber - 1)
|
|
||||||
else {
|
|
||||||
throw Error.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString)
|
|
||||||
}
|
|
||||||
|
|
||||||
return .value(trustedPhoneNumbers[selectionNumber - 1])
|
|
||||||
}
|
}
|
||||||
.recover { error throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> in
|
.publisher
|
||||||
guard case Error.invalidPhoneNumberIndex = error else { throw error }
|
.flatMap { request in
|
||||||
Current.logging.log("\(error.localizedDescription)\n")
|
Current.network.dataTask(with: request)
|
||||||
return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers)
|
.mapError { $0 as Error }
|
||||||
}
|
.tryMap { (data, response) throws -> (Data, URLResponse) in
|
||||||
}
|
guard let urlResponse = response as? HTTPURLResponse else { return (data, response) }
|
||||||
|
switch urlResponse.statusCode {
|
||||||
func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode {
|
case 200..<300:
|
||||||
let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? ""
|
return (data, urlResponse)
|
||||||
return .sms(code: code, phoneNumberId: trustedPhoneNumber.id)
|
case 401:
|
||||||
}
|
throw AuthenticationError.incorrectSecurityCode
|
||||||
|
case let code:
|
||||||
func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
|
throw AuthenticationError.badStatusCode(code, data, urlResponse)
|
||||||
return firstly { () throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> in
|
|
||||||
// I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number,
|
|
||||||
// but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing.
|
|
||||||
guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else {
|
|
||||||
throw Error.noTrustedPhoneNumbers
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectPhoneNumberInteractively(from: trustedPhoneNumbers)
|
|
||||||
}
|
|
||||||
.then { trustedPhoneNumber in
|
|
||||||
Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id))
|
|
||||||
.map { _ in
|
|
||||||
self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.then { code in
|
|
||||||
Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
|
|
||||||
.validateSecurityCodeResponse()
|
|
||||||
}
|
|
||||||
.then { (data, response) -> Promise<Void> in
|
|
||||||
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TwoFactorOption {
|
|
||||||
case smsSent(Int, AuthOptionsResponse.TrustedPhoneNumber)
|
|
||||||
case codeSent(Int)
|
|
||||||
case smsPendingChoice(Int, [AuthOptionsResponse.TrustedPhoneNumber])
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Promise where T == (data: Data, response: URLResponse) {
|
|
||||||
func validateSecurityCodeResponse() -> Promise<T> {
|
|
||||||
validate()
|
|
||||||
.recover { error -> Promise<(data: Data, response: URLResponse)> in
|
|
||||||
switch error {
|
|
||||||
case PMKHTTPError.badStatusCode(let code, _, _):
|
|
||||||
if code == 401 {
|
|
||||||
throw Client.Error.incorrectSecurityCode
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
.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
|
||||||
|
public func validateSession() -> AnyPublisher<Void, Error> {
|
||||||
|
return Current.network.dataTask(with: URLRequest.olympusSession)
|
||||||
|
.tryMap { (data, response) in
|
||||||
|
guard
|
||||||
|
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||||
|
jsonObject["provider"] != nil
|
||||||
|
else { throw AuthenticationError.invalidSession }
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher<AuthenticationState, Error> {
|
||||||
|
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
|
||||||
|
.flatMap { (data, response) in
|
||||||
|
Current.network.dataTask(with: URLRequest.olympusSession)
|
||||||
|
.map { _ in AuthenticationState.authenticated }
|
||||||
|
}
|
||||||
|
.mapError { $0 as Error }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AuthOptionsResponse: Decodable {
|
// MARK: - Types
|
||||||
let trustedPhoneNumbers: [TrustedPhoneNumber]?
|
|
||||||
let trustedDevices: [TrustedDevice]?
|
public enum AuthenticationState: Equatable {
|
||||||
let securityCode: SecurityCodeInfo
|
case unauthenticated
|
||||||
let noTrustedDevices: Bool?
|
case waitingForSecondFactor(TwoFactorOption, AuthOptionsResponse, AppleSessionData)
|
||||||
|
case authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
|
||||||
|
case invalidSession
|
||||||
|
case invalidUsernameOrPassword(username: String)
|
||||||
|
case invalidPhoneNumberIndex(min: Int, max: Int, given: String?)
|
||||||
|
case incorrectSecurityCode
|
||||||
|
case unexpectedSignInResponse(statusCode: Int, message: String?)
|
||||||
|
case appleIDAndPrivacyAcknowledgementRequired
|
||||||
|
case accountUsesTwoStepAuthentication
|
||||||
|
case accountUsesUnknownAuthenticationKind(String?)
|
||||||
|
case badStatusCode(Int, Data, HTTPURLResponse)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidUsernameOrPassword(let username):
|
||||||
|
return "Invalid username and password combination. Attempted to sign in with username \(username)."
|
||||||
|
case .appleIDAndPrivacyAcknowledgementRequired:
|
||||||
|
return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement."
|
||||||
|
case .invalidPhoneNumberIndex(let min, let max, let given):
|
||||||
|
return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")."
|
||||||
|
case .accountUsesTwoStepAuthentication:
|
||||||
|
return "Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new"
|
||||||
|
case .accountUsesUnknownAuthenticationKind:
|
||||||
|
return "Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:"
|
||||||
|
default:
|
||||||
|
return String(describing: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AppleSessionData: Equatable, Identifiable {
|
||||||
|
public let serviceKey: String
|
||||||
|
public let sessionID: String
|
||||||
|
public let scnt: String
|
||||||
|
|
||||||
|
public var id: String { sessionID }
|
||||||
|
|
||||||
|
public init(serviceKey: String, sessionID: String, scnt: String) {
|
||||||
|
self.serviceKey = serviceKey
|
||||||
|
self.sessionID = sessionID
|
||||||
|
self.scnt = scnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServiceKeyResponse: Decodable {
|
||||||
|
let authServiceKey: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SignInResponse: Decodable {
|
||||||
|
let authType: String?
|
||||||
let serviceErrors: [ServiceError]?
|
let serviceErrors: [ServiceError]?
|
||||||
|
|
||||||
var kind: Kind {
|
struct ServiceError: Decodable, CustomStringConvertible {
|
||||||
|
let code: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
return "\(code): \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TwoFactorOption: Equatable {
|
||||||
|
case smsSent(AuthOptionsResponse.TrustedPhoneNumber)
|
||||||
|
case codeSent
|
||||||
|
case smsPendingChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AuthOptionsResponse: Equatable, Decodable {
|
||||||
|
public let trustedPhoneNumbers: [TrustedPhoneNumber]?
|
||||||
|
public let trustedDevices: [TrustedDevice]?
|
||||||
|
public let securityCode: SecurityCodeInfo
|
||||||
|
public let noTrustedDevices: Bool?
|
||||||
|
public let serviceErrors: [ServiceError]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?,
|
||||||
|
trustedDevices: [AuthOptionsResponse.TrustedDevice]?,
|
||||||
|
securityCode: AuthOptionsResponse.SecurityCodeInfo,
|
||||||
|
noTrustedDevices: Bool? = nil,
|
||||||
|
serviceErrors: [ServiceError]? = nil
|
||||||
|
) {
|
||||||
|
self.trustedPhoneNumbers = trustedPhoneNumbers
|
||||||
|
self.trustedDevices = trustedDevices
|
||||||
|
self.securityCode = securityCode
|
||||||
|
self.noTrustedDevices = noTrustedDevices
|
||||||
|
self.serviceErrors = serviceErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
public var kind: Kind {
|
||||||
if trustedDevices != nil {
|
if trustedDevices != nil {
|
||||||
return .twoStep
|
return .twoStep
|
||||||
} else if trustedPhoneNumbers != nil {
|
} else if trustedPhoneNumbers != nil {
|
||||||
|
|
@ -277,34 +274,59 @@ struct AuthOptionsResponse: Decodable {
|
||||||
// This should have been a situation where an SMS security code was sent automatically.
|
// This should have been a situation where an SMS security code was sent automatically.
|
||||||
// This resolved itself either after some time passed, or by signing into appleid.apple.com with the account.
|
// This resolved itself either after some time passed, or by signing into appleid.apple.com with the account.
|
||||||
// Not sure if it's worth explicitly handling this case or if it'll be really rare.
|
// Not sure if it's worth explicitly handling this case or if it'll be really rare.
|
||||||
var canFallBackToSMS: Bool {
|
public var canFallBackToSMS: Bool {
|
||||||
noTrustedDevices == true
|
noTrustedDevices == true
|
||||||
}
|
}
|
||||||
|
|
||||||
var smsAutomaticallySent: Bool {
|
public var smsAutomaticallySent: Bool {
|
||||||
trustedPhoneNumbers?.count == 1 && canFallBackToSMS
|
trustedPhoneNumbers?.count == 1 && canFallBackToSMS
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TrustedPhoneNumber: Decodable {
|
public struct TrustedPhoneNumber: Equatable, Decodable, Identifiable {
|
||||||
let id: Int
|
public let id: Int
|
||||||
let numberWithDialCode: String
|
public let numberWithDialCode: String
|
||||||
|
|
||||||
|
public init(id: Int, numberWithDialCode: String) {
|
||||||
|
self.id = id
|
||||||
|
self.numberWithDialCode = numberWithDialCode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TrustedDevice: Decodable {
|
public struct TrustedDevice: Equatable, Decodable {
|
||||||
let id: String
|
public let id: String
|
||||||
let name: String
|
public let name: String
|
||||||
let modelName: String
|
public let modelName: String
|
||||||
|
|
||||||
|
public init(id: String, name: String, modelName: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.modelName = modelName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SecurityCodeInfo: Decodable {
|
public struct SecurityCodeInfo: Equatable, Decodable {
|
||||||
let length: Int
|
public let length: Int
|
||||||
let tooManyCodesSent: Bool
|
public let tooManyCodesSent: Bool
|
||||||
let tooManyCodesValidated: Bool
|
public let tooManyCodesValidated: Bool
|
||||||
let securityCodeLocked: Bool
|
public let securityCodeLocked: Bool
|
||||||
let securityCodeCooldown: Bool
|
public let securityCodeCooldown: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
length: Int,
|
||||||
|
tooManyCodesSent: Bool = false,
|
||||||
|
tooManyCodesValidated: Bool = false,
|
||||||
|
securityCodeLocked: Bool = false,
|
||||||
|
securityCodeCooldown: Bool = false
|
||||||
|
) {
|
||||||
|
self.length = length
|
||||||
|
self.tooManyCodesSent = tooManyCodesSent
|
||||||
|
self.tooManyCodesValidated = tooManyCodesValidated
|
||||||
|
self.securityCodeLocked = securityCodeLocked
|
||||||
|
self.securityCodeCooldown = securityCodeCooldown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Kind {
|
public enum Kind: Equatable {
|
||||||
case twoStep, twoFactor, unknown
|
case twoStep, twoFactor, unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +336,7 @@ public struct ServiceError: Decodable, Equatable {
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SecurityCode {
|
public enum SecurityCode {
|
||||||
case device(code: String)
|
case device(code: String)
|
||||||
case sms(code: String, phoneNumberId: Int)
|
case sms(code: String, phoneNumberId: Int)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import PromiseKit
|
import Combine
|
||||||
import PMKFoundation
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Lightweight dependency injection using global mutable state :P
|
Lightweight dependency injection using global mutable state :P
|
||||||
|
|
@ -10,29 +9,18 @@ import PMKFoundation
|
||||||
- SeeAlso: https://vimeo.com/291588126
|
- SeeAlso: https://vimeo.com/291588126
|
||||||
*/
|
*/
|
||||||
public struct Environment {
|
public struct Environment {
|
||||||
public var shell = Shell()
|
|
||||||
public var network = Network()
|
public var network = Network()
|
||||||
public var logging = Logging()
|
public var logging = Logging()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var Current = Environment()
|
public var Current = Environment()
|
||||||
|
|
||||||
public struct Shell {
|
|
||||||
public var readLine: (String) -> String? = { prompt in
|
|
||||||
print(prompt, terminator: "")
|
|
||||||
return Swift.readLine()
|
|
||||||
}
|
|
||||||
public func readLine(prompt: String) -> String? {
|
|
||||||
readLine(prompt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Network {
|
public struct Network {
|
||||||
public var session = URLSession.shared
|
public var session = URLSession.shared
|
||||||
|
|
||||||
public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { Current.network.session.dataTask(.promise, with: $0) }
|
public var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { Current.network.session.dataTaskPublisher(for: $0) }
|
||||||
public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> {
|
public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher {
|
||||||
dataTask(convertible)
|
dataTask(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
73
Xcodes/AttributedText.swift
Normal file
73
Xcodes/AttributedText.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A text view that supports NSAttributedStrings, based on NSTextView.
|
||||||
|
public struct AttributedText: View {
|
||||||
|
private let attributedString: NSAttributedString
|
||||||
|
private let linkTextAttributes: [NSAttributedString.Key: Any]?
|
||||||
|
@State private var actualSize: CGSize = .zero
|
||||||
|
|
||||||
|
public init(_ attributedString: NSAttributedString, linkTextAttributes: [NSAttributedString.Key: Any]? = nil) {
|
||||||
|
self.attributedString = attributedString
|
||||||
|
self.linkTextAttributes = linkTextAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
InnerAttributedStringText(
|
||||||
|
attributedString: self.attributedString,
|
||||||
|
actualSize: $actualSize
|
||||||
|
)
|
||||||
|
// Limit the height to what's needed for the text
|
||||||
|
.frame(height: actualSize.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: InnerAttributedStringText
|
||||||
|
|
||||||
|
fileprivate struct InnerAttributedStringText: NSViewRepresentable {
|
||||||
|
private let attributedString: NSAttributedString
|
||||||
|
@Binding var actualSize: CGSize
|
||||||
|
|
||||||
|
internal init(attributedString: NSAttributedString, actualSize: Binding<CGSize>) {
|
||||||
|
self.attributedString = attributedString
|
||||||
|
self._actualSize = actualSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: NSViewRepresentableContext<Self>) -> NSTextView {
|
||||||
|
let textView = NSTextView()
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.textContainer?.lineFragmentPadding = 0
|
||||||
|
textView.textContainerInset = .zero
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
textView.isSelectable = true
|
||||||
|
|
||||||
|
return textView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ label: NSTextView, context _: NSViewRepresentableContext<Self>) {
|
||||||
|
// This must happen on the next run loop so that we don't update the view hierarchy while already in the middle of an update
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
label.textStorage?.setAttributedString(attributedString)
|
||||||
|
// Calculates the height based on the current frame
|
||||||
|
label.layoutManager?.ensureLayout(for: label.textContainer!)
|
||||||
|
actualSize = label.layoutManager!.usedRect(for: label.textContainer!).size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
struct AttributedText_Previews: PreviewProvider {
|
||||||
|
static var linkExample: NSAttributedString {
|
||||||
|
let string = "The next word is a link. This is some more text to test how this wraps when it's too long."
|
||||||
|
let s = NSMutableAttributedString(string: string)
|
||||||
|
s.addAttribute(.link, value: URL(string: "https://robotsandpencils.com")!, range: NSRange(string.range(of: "link")!, in: string))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
static var previews: some SwiftUI.View {
|
||||||
|
Group {
|
||||||
|
// Previews don't work unless they're running, because detecting and setting the size happens on the next run loop
|
||||||
|
AttributedText(linkExample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,8 +67,13 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
Button(action: { self.appState.update() }) {
|
Button("Login", action: { self.appState.presentingSignInAlert = true })
|
||||||
|
.sheet(isPresented: $appState.presentingSignInAlert) {
|
||||||
|
SignInCredentialsView(isPresented: $appState.presentingSignInAlert)
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
|
Button(action: { self.appState.load() }) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.keyboardShortcut(KeyEquivalent("r"))
|
.keyboardShortcut(KeyEquivalent("r"))
|
||||||
|
|
@ -102,11 +107,23 @@ struct ContentView: View {
|
||||||
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }),
|
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }),
|
||||||
secondaryButton: .cancel(Text("Cancel")))
|
secondaryButton: .cancel(Text("Cancel")))
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $appState.presentingSignInAlert) {
|
.sheet(isPresented: $appState.secondFactorData.isNotNil) {
|
||||||
SignInCredentialsView(isPresented: $appState.presentingSignInAlert)
|
secondFactorView(appState.secondFactorData!)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func secondFactorView(_ secondFactorData: AppState.SecondFactorData) -> some View {
|
||||||
|
switch secondFactorData.option {
|
||||||
|
case .codeSent:
|
||||||
|
SignIn2FAView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||||
|
case .smsSent(let trustedPhoneNumber):
|
||||||
|
SignInSMSView(isPresented: $appState.secondFactorData.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||||
|
case .smsPendingChoice:
|
||||||
|
SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
|
@ -127,3 +144,11 @@ struct ContentView_Previews: PreviewProvider {
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Optional {
|
||||||
|
/// Note that this is lossy when setting, so you can really only set it to nil, but this is sufficient for mapping `Binding<Item?>` to `Binding<Bool>` for Alerts, Popovers, etc.
|
||||||
|
var isNotNil: Bool {
|
||||||
|
get { self != nil }
|
||||||
|
set { self = newValue ? self : nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
28
Xcodes/NSAttributedString+.swift
Normal file
28
Xcodes/NSAttributedString+.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension NSAttributedString {
|
||||||
|
func addingAttribute(_ attribute: NSAttributedString.Key, value: Any, range: NSRange) -> NSAttributedString {
|
||||||
|
let copy = mutableCopy() as! NSMutableAttributedString
|
||||||
|
copy.addAttribute(attribute, value: value, range: range)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func addingAttribute(_ attribute: NSAttributedString.Key, value: Any) -> NSAttributedString {
|
||||||
|
addingAttribute(attribute, value: value, range: NSRange(string.startIndex ..< string.endIndex, in: string))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects URLs and adds a NSAttributedString.Key.link attribute with the URL value
|
||||||
|
func convertingURLsToLinkAttributes() -> NSAttributedString {
|
||||||
|
guard
|
||||||
|
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue),
|
||||||
|
let copy = self.mutableCopy() as? NSMutableAttributedString
|
||||||
|
else { return self }
|
||||||
|
|
||||||
|
let matches = detector.matches(in: self.string, options: [], range: NSRange(string.startIndex..<string.endIndex, in: string))
|
||||||
|
for match in matches where match.url != nil {
|
||||||
|
copy.addAttribute(.link, value: match.url!, range: match.range)
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
222
Xcodes/SignIn/PinCodeTextView.swift
Normal file
222
Xcodes/SignIn/PinCodeTextView.swift
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import Cocoa
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PinCodeTextField: NSViewRepresentable {
|
||||||
|
typealias NSViewType = PinCodeTextView
|
||||||
|
|
||||||
|
@Binding var code: String
|
||||||
|
let numberOfDigits: Int
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSViewType {
|
||||||
|
let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10)
|
||||||
|
view.codeDidChange = { c in code = c }
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSViewType, context: Context) {
|
||||||
|
nsView.code = (0..<numberOfDigits).map { index in
|
||||||
|
if index < code.count {
|
||||||
|
let codeIndex = code.index(code.startIndex, offsetBy: index)
|
||||||
|
return code[codeIndex]
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PinCodeTextField_Previews: PreviewProvider {
|
||||||
|
struct PreviewContainer: View {
|
||||||
|
@State private var code = "123"
|
||||||
|
var body: some View {
|
||||||
|
PinCodeTextField(code: $code, numberOfDigits: 6)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
PreviewContainer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PinCodeTextView
|
||||||
|
|
||||||
|
class PinCodeTextView: NSControl, NSTextFieldDelegate {
|
||||||
|
var code: [Character?] = [] {
|
||||||
|
didSet {
|
||||||
|
guard code != oldValue else { return }
|
||||||
|
|
||||||
|
if let handler = codeDidChange {
|
||||||
|
handler(String(code.compactMap { $0 }))
|
||||||
|
}
|
||||||
|
updateText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var codeDidChange: ((String) -> Void)? = nil
|
||||||
|
|
||||||
|
private let numberOfDigits: Int
|
||||||
|
private let stackView: NSStackView = .init(frame: .zero)
|
||||||
|
private var characterViews: [PinCodeCharacterTextField] = []
|
||||||
|
|
||||||
|
// MARK: - Initializers
|
||||||
|
|
||||||
|
init(
|
||||||
|
numberOfDigits: Int,
|
||||||
|
itemSpacing: CGFloat
|
||||||
|
) {
|
||||||
|
self.numberOfDigits = numberOfDigits
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stackView.spacing = itemSpacing
|
||||||
|
stackView.orientation = .horizontal
|
||||||
|
stackView.distribution = .fillEqually
|
||||||
|
stackView.alignment = .centerY
|
||||||
|
addSubview(stackView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stackView.topAnchor.constraint(equalTo: self.topAnchor),
|
||||||
|
stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
stackView.leadingAnchor.constraint(greaterThanOrEqualTo: self.leadingAnchor),
|
||||||
|
stackView.trailingAnchor.constraint(greaterThanOrEqualTo: self.trailingAnchor),
|
||||||
|
stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
self.code = (0..<numberOfDigits).map { _ in nil }
|
||||||
|
self.characterViews = (0..<numberOfDigits).map { _ in
|
||||||
|
let view = PinCodeCharacterTextField()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.delegate = self
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
characterViews.forEach {
|
||||||
|
stackView.addArrangedSubview($0)
|
||||||
|
stackView.heightAnchor.constraint(equalTo: $0.heightAnchor).isActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateText() {
|
||||||
|
characterViews.enumerated().forEach { (index, item) in
|
||||||
|
if (0..<code.count).contains(index) {
|
||||||
|
let _index = code.index(code.startIndex, offsetBy: index)
|
||||||
|
item.character = code[_index]
|
||||||
|
} else {
|
||||||
|
item.character = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: NSTextFieldDelegate
|
||||||
|
|
||||||
|
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||||
|
if commandSelector == #selector(deleteBackward(_:)) {
|
||||||
|
// If empty, move to previous or first character view
|
||||||
|
if textView.string.isEmpty {
|
||||||
|
if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) {
|
||||||
|
window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter])
|
||||||
|
} else {
|
||||||
|
window?.makeFirstResponder(characterViews[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform default behaviour
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlTextDidChange(_ obj: Notification) {
|
||||||
|
guard
|
||||||
|
let field = obj.object as? NSTextField,
|
||||||
|
isEnabled,
|
||||||
|
let fieldIndex = characterViews.firstIndex(where: { $0 === field })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let newFieldText = field.stringValue
|
||||||
|
|
||||||
|
let lastCharacter: Character?
|
||||||
|
if newFieldText.isEmpty {
|
||||||
|
lastCharacter = nil
|
||||||
|
} else {
|
||||||
|
lastCharacter = newFieldText[newFieldText.index(before: newFieldText.endIndex)]
|
||||||
|
}
|
||||||
|
|
||||||
|
code[fieldIndex] = lastCharacter
|
||||||
|
|
||||||
|
if lastCharacter != nil {
|
||||||
|
if fieldIndex >= characterViews.count - 1 {
|
||||||
|
resignFirstResponder()
|
||||||
|
} else {
|
||||||
|
window?.makeFirstResponder(characterViews[fieldIndex + 1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) {
|
||||||
|
window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter])
|
||||||
|
} else {
|
||||||
|
window?.makeFirstResponder(characterViews[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: NSResponder
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
characterViews.first?.becomeFirstResponder() ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PinCodeCharacterTextField
|
||||||
|
|
||||||
|
class PinCodeCharacterTextField: NSTextField {
|
||||||
|
var character: Character? = nil {
|
||||||
|
didSet {
|
||||||
|
stringValue = character.map(String.init) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var lastSize: NSSize?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
wantsLayer = true
|
||||||
|
alignment = .center
|
||||||
|
maximumNumberOfLines = 1
|
||||||
|
font = .boldSystemFont(ofSize: 48)
|
||||||
|
|
||||||
|
setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func textDidChange(_ notification: Notification) {
|
||||||
|
super.textDidChange(notification)
|
||||||
|
self.invalidateIntrinsicContentSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is kinda cheating
|
||||||
|
// Assuming that 0 is the widest and tallest character in 0-9
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
var size = NSAttributedString(
|
||||||
|
string: "0",
|
||||||
|
attributes: [ .font : self.font! ]
|
||||||
|
)
|
||||||
|
.size()
|
||||||
|
// I guess the cell should probably be doing this sizing in order to take into account everything outside of simply the text's frame, but for some reason I can't find a way to do that which works...
|
||||||
|
size.width += 8
|
||||||
|
size.height += 8
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,20 +5,28 @@ struct SignIn2FAView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@State private var code: String = ""
|
@State private var code: String = ""
|
||||||
|
let authOptions: AuthOptionsResponse
|
||||||
|
let sessionData: AppleSessionData
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("Enter the \(6) digit code from one of your trusted devices:")
|
Text("Enter the \(authOptions.securityCode.length) digit code from one of your trusted devices:")
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
TextField("\(6) digit code", text: $code)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button("Cancel", action: { isPresented = false })
|
|
||||||
Button("Send SMS", action: {})
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Continue", action: {})
|
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel", action: { isPresented = false })
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button("Send SMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) })
|
||||||
|
Spacer()
|
||||||
|
Button("Continue", action: { appState.submitSecurityCode(.device(code: code), sessionData: sessionData) })
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(code.count != authOptions.securityCode.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -27,7 +35,15 @@ struct SignIn2FAView: View {
|
||||||
|
|
||||||
struct SignIn2FAView_Previews: PreviewProvider {
|
struct SignIn2FAView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
SignIn2FAView(isPresented: .constant(true))
|
SignIn2FAView(
|
||||||
|
isPresented: .constant(true),
|
||||||
|
authOptions: AuthOptionsResponse(
|
||||||
|
trustedPhoneNumbers: nil,
|
||||||
|
trustedDevices: nil,
|
||||||
|
securityCode: .init(length: 6)
|
||||||
|
),
|
||||||
|
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
|
||||||
|
)
|
||||||
.environmentObject(AppState())
|
.environmentObject(AppState())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ struct SignInCredentialsView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Cancel") { isPresented = false }
|
Button("Cancel") { isPresented = false }
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
Button("Next") { appState.continueLogin(username: username, password: password) }
|
Button("Next") { appState.login(username: username, password: password) }
|
||||||
.disabled(username.isEmpty)
|
.disabled(username.isEmpty)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppleAPI
|
||||||
|
|
||||||
struct SignInPhoneListView: View {
|
struct SignInPhoneListView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
var phoneNumbers: [String]
|
@State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID?
|
||||||
|
let authOptions: AuthOptionsResponse
|
||||||
|
let sessionData: AppleSessionData
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("Select a trusted phone number to receive a code via SMS: ")
|
if let phoneNumbers = authOptions.trustedPhoneNumbers, !phoneNumbers.isEmpty {
|
||||||
|
Text("Select a trusted phone number to receive a \(authOptions.securityCode.length) digit code via SMS:")
|
||||||
List(phoneNumbers, id: \.self) {
|
|
||||||
Text($0)
|
List(phoneNumbers, selection: $selectedPhoneNumberID) {
|
||||||
|
Text($0.numberWithDialCode)
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
} else {
|
||||||
|
AttributedText(
|
||||||
|
NSAttributedString(string: "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915.")
|
||||||
|
.convertingURLsToLinkAttributes()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.frame(height: 200)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel", action: { isPresented = false })
|
Button("Cancel", action: { isPresented = false })
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Continue", action: {})
|
Button("Continue", action: { appState.requestSMS(to: authOptions.trustedPhoneNumbers!.first { $0.id == selectedPhoneNumberID }!, authOptions: authOptions, sessionData: sessionData) })
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(selectedPhoneNumberID == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -26,6 +39,24 @@ struct SignInPhoneListView: View {
|
||||||
|
|
||||||
struct SignInPhoneListView_Previews: PreviewProvider {
|
struct SignInPhoneListView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
SignInPhoneListView(isPresented: .constant(true), phoneNumbers: ["123-456-7890"])
|
Group {
|
||||||
|
SignInPhoneListView(
|
||||||
|
isPresented: .constant(true),
|
||||||
|
authOptions: AuthOptionsResponse(
|
||||||
|
trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")],
|
||||||
|
trustedDevices: nil,
|
||||||
|
securityCode: .init(length: 6)),
|
||||||
|
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
|
||||||
|
)
|
||||||
|
|
||||||
|
SignInPhoneListView(
|
||||||
|
isPresented: .constant(true),
|
||||||
|
authOptions: AuthOptionsResponse(
|
||||||
|
trustedPhoneNumbers: [],
|
||||||
|
trustedDevices: nil,
|
||||||
|
securityCode: .init(length: 6)),
|
||||||
|
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppleAPI
|
||||||
|
|
||||||
struct SignInSMSView: View {
|
struct SignInSMSView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@State private var code: String = ""
|
@State private var code: String = ""
|
||||||
|
let trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber
|
||||||
|
let authOptions: AuthOptionsResponse
|
||||||
|
let sessionData: AppleSessionData
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("Enter the \(6) digit code sent to \("phone number"): ")
|
Text("Enter the \(authOptions.securityCode.length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ")
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
TextField("\(6) digit code", text: $code)
|
Spacer()
|
||||||
|
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel", action: { isPresented = false })
|
Button("Cancel", action: { isPresented = false })
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Continue", action: {})
|
Button("Continue", action: { appState.submitSecurityCode(.sms(code: code, phoneNumberId: trustedPhoneNumber.id), sessionData: sessionData) })
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(code.count != authOptions.securityCode.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -25,7 +35,16 @@ struct SignInSMSView: View {
|
||||||
|
|
||||||
struct SignInSMSView_Previews: PreviewProvider {
|
struct SignInSMSView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
SignInSMSView(isPresented: .constant(true))
|
SignInSMSView(
|
||||||
.environmentObject(AppState())
|
isPresented: .constant(true),
|
||||||
|
trustedPhoneNumber: .init(id: 0, numberWithDialCode: "(•••) •••-••90"),
|
||||||
|
authOptions: AuthOptionsResponse(
|
||||||
|
trustedPhoneNumbers: nil,
|
||||||
|
trustedDevices: nil,
|
||||||
|
securityCode: .init(length: 6)
|
||||||
|
),
|
||||||
|
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
|
||||||
|
)
|
||||||
|
.environmentObject(AppState())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "XcodesKit",
|
name: "XcodesKit",
|
||||||
platforms: [.macOS(.v10_13)],
|
platforms: [.macOS(.v10_15)],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
.library(
|
.library(
|
||||||
|
|
|
||||||
|
|
@ -246,12 +246,12 @@ public struct Network {
|
||||||
return downloadTask(convertible, saveLocation, resumeData)
|
return downloadTask(convertible, saveLocation, resumeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var validateSession: () -> Promise<Void> = client.validateSession
|
// public var validateSession: () -> Promise<Void> = client.validateSession
|
||||||
|
//
|
||||||
public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
|
// public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
|
||||||
public func login(accountName: String, password: String) -> Promise<Void> {
|
// public func login(accountName: String, password: String) -> Promise<Void> {
|
||||||
login(accountName, password)
|
// login(accountName, password)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Logging {
|
public struct Logging {
|
||||||
|
|
|
||||||
|
|
@ -248,9 +248,9 @@ public final class XcodeInstaller {
|
||||||
|
|
||||||
private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> {
|
private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> {
|
||||||
return firstly { () -> Promise<Version> in
|
return firstly { () -> Promise<Version> in
|
||||||
loginIfNeeded().map { version }
|
// loginIfNeeded().map { version }
|
||||||
}
|
// }
|
||||||
.then { version -> Promise<Version> in
|
// .then { version -> Promise<Version> in
|
||||||
if self.xcodeList.shouldUpdate {
|
if self.xcodeList.shouldUpdate {
|
||||||
return self.xcodeList.update().map { _ in version }
|
return self.xcodeList.update().map { _ in version }
|
||||||
}
|
}
|
||||||
|
|
@ -282,60 +282,60 @@ public final class XcodeInstaller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise<Void> {
|
// func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise<Void> {
|
||||||
return firstly { () -> Promise<Void> in
|
// return firstly { () -> Promise<Void> in
|
||||||
return Current.network.validateSession()
|
// return Current.network.validateSession()
|
||||||
}
|
// }
|
||||||
.recover { error -> Promise<Void> in
|
// .recover { error -> Promise<Void> in
|
||||||
guard
|
// guard
|
||||||
let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "),
|
// let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "),
|
||||||
let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ")
|
// let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ")
|
||||||
else { throw Error.missingUsernameOrPassword }
|
// else { throw Error.missingUsernameOrPassword }
|
||||||
|
//
|
||||||
return firstly { () -> Promise<Void> in
|
// return firstly { () -> Promise<Void> in
|
||||||
self.login(username, password: password)
|
// self.login(username, password: password)
|
||||||
}
|
// }
|
||||||
.recover { error -> Promise<Void> in
|
// .recover { error -> Promise<Void> in
|
||||||
Current.logging.log(error.legibleLocalizedDescription)
|
// Current.logging.log(error.legibleLocalizedDescription)
|
||||||
|
//
|
||||||
if case Client.Error.invalidUsernameOrPassword = error {
|
// if case Client.AuthenticationErrro.invalidUsernameOrPassword = error {
|
||||||
Current.logging.log("Try entering your password again")
|
// Current.logging.log("Try entering your password again")
|
||||||
return self.loginIfNeeded(withUsername: username)
|
// return self.loginIfNeeded(withUsername: username)
|
||||||
}
|
// }
|
||||||
else {
|
// else {
|
||||||
return Promise(error: error)
|
// return Promise(error: error)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public func login(_ username: String, password: String) -> Promise<Void> {
|
// public func login(_ username: String, password: String) -> Promise<Void> {
|
||||||
return firstly { () -> Promise<Void> in
|
// return firstly { () -> Promise<Void> in
|
||||||
Current.network.login(accountName: username, password: password)
|
// Current.network.login(accountName: username, password: password)
|
||||||
}
|
// }
|
||||||
.recover { error -> Promise<Void> in
|
// .recover { error -> Promise<Void> in
|
||||||
|
//
|
||||||
if let error = error as? Client.Error {
|
// if let error = error as? Client.AuthenticationErrro {
|
||||||
switch error {
|
// switch error {
|
||||||
case .invalidUsernameOrPassword(_):
|
// case .invalidUsernameOrPassword(_):
|
||||||
// remove any keychain password if we fail to log with an invalid username or password so it doesn't try again.
|
// // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again.
|
||||||
try? Current.keychain.remove(username)
|
// try? Current.keychain.remove(username)
|
||||||
default:
|
// default:
|
||||||
break
|
// break
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
return Promise(error: error)
|
// return Promise(error: error)
|
||||||
}
|
// }
|
||||||
.done { _ in
|
// .done { _ in
|
||||||
try? Current.keychain.set(password, key: username)
|
// try? Current.keychain.set(password, key: username)
|
||||||
|
//
|
||||||
if self.configuration.defaultUsername != username {
|
// if self.configuration.defaultUsername != username {
|
||||||
self.configuration.defaultUsername = username
|
// self.configuration.defaultUsername = username
|
||||||
try? self.configuration.save()
|
// try? self.configuration.save()
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
let xcodesUsername = "XCODES_USERNAME"
|
let xcodesUsername = "XCODES_USERNAME"
|
||||||
let xcodesPassword = "XCODES_PASSWORD"
|
let xcodesPassword = "XCODES_PASSWORD"
|
||||||
|
|
@ -511,12 +511,12 @@ public final class XcodeInstaller {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update() -> Promise<[Xcode]> {
|
public func update() -> Promise<[Xcode]> {
|
||||||
return firstly { () -> Promise<Void> in
|
// return firstly { () -> Promise<Void> in
|
||||||
loginIfNeeded()
|
// loginIfNeeded()
|
||||||
}
|
// }
|
||||||
.then { () -> Promise<[Xcode]> in
|
// .then { () -> Promise<[Xcode]> in
|
||||||
self.xcodeList.update()
|
self.xcodeList.update()
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateAndPrint(destination: Path) -> Promise<Void> {
|
public func updateAndPrint(destination: Path) -> Promise<Void> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue