Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; };
CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; };
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; };
CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */; };
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; };
Expand Down Expand Up @@ -208,6 +209,7 @@
BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = "<group>"; };
CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = "<group>"; };
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = "<group>"; };
CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsViewTests.swift; sourceTree = "<group>"; };
CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = "<group>"; };
CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -594,6 +596,7 @@
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */,
CAD2E7B72449575100113D76 /* AppStateTests.swift */,
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */,
CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */,
);
path = XcodesTests;
Expand Down Expand Up @@ -971,6 +974,7 @@
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */,
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */,
CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */,
CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
5 changes: 5 additions & 0 deletions Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@ class AppState: ObservableObject {
}

func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
if authOptions.trustedPhoneNumbers?.count == 1, let trustedPhoneNumber = authOptions.trustedPhoneNumbers?.first {
requestSMS(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData)
return
}

self.presentedSheet = .twoFactor(.init(
option: .smsPendingChoice,
authOptions: authOptions,
Expand Down
23 changes: 12 additions & 11 deletions Xcodes/Frontend/SignIn/PinCodeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,47 +135,47 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate {
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 }
else { return }

let newFieldText = field.stringValue

// Handle pasting multiple characters (e.g., pasting "123456" from clipboard)
if newFieldText.count > 1 {
// Filter to alphanumeric characters only
let validCharacters = newFieldText.filter { $0.isLetter || $0.isNumber }

// Always start from the first field and clear previous content
var newCode = Array(repeating: Character?.none, count: numberOfDigits)
for (offset, character) in validCharacters.enumerated() {
if offset < numberOfDigits {
newCode[offset] = character
}
}

// Update all fields at once to avoid triggering didSet multiple times
code = newCode

// Move focus to next empty field or the last field if all are filled
let nextEmptyIndex = code.firstIndex(where: { $0 == nil }) ?? numberOfDigits - 1
if nextEmptyIndex < characterViews.count {
window?.makeFirstResponder(characterViews[nextEmptyIndex])
} else {
resignFirstResponder()
}

return
}

// Handle single character input
let lastCharacter: Character?
if newFieldText.isEmpty {
Expand All @@ -185,7 +185,7 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate {
}

code[fieldIndex] = lastCharacter

if lastCharacter != nil {
if fieldIndex >= characterViews.count - 1 {
resignFirstResponder()
Expand Down Expand Up @@ -227,6 +227,7 @@ class PinCodeCharacterTextField: NSTextField {

wantsLayer = true
alignment = .center
contentType = .oneTimeCode
maximumNumberOfLines = 1
font = .boldSystemFont(ofSize: 48)

Expand Down
2 changes: 2 additions & 0 deletions Xcodes/Frontend/SignIn/SignInCredentialsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ struct SignInCredentialsView: View {
TextField(text: $username) {
Text(verbatim: "example@icloud.com")
}
.textContentType(.username)
.focused($focusedField, equals: .username)
}
HStack {
Text("Password")
.frame(minWidth: 100, alignment: .trailing)
SecureField("Required", text: $password)
.textContentType(.password)
.focused($focusedField, equals: .password)
}
if appState.authError != nil {
Expand Down
100 changes: 100 additions & 0 deletions XcodesTests/AppStateTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import Cocoa
@preconcurrency import Path
import Version
import XCTest
Expand All @@ -24,6 +25,51 @@ private final class TestLockedBox<Value: Sendable>: Sendable {
}
}

private final class MockURLProtocol: URLProtocol, @unchecked Sendable {
typealias Handler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse)

private nonisolated(unsafe) static var handler: Handler?

static func session(handler: @escaping Handler) -> URLSession {
self.handler = handler
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: configuration)
}

override class func canInit(with request: URLRequest) -> Bool {
true
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}

override func startLoading() {
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}

do {
let (data, response) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}

override func stopLoading() {}
}

private extension NSView {
func recursiveSubviews<T: NSView>(ofType type: T.Type) -> [T] {
subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) }
}
}

@MainActor
class AppStateTests: XCTestCase {
var subject: AppState!
Expand All @@ -33,6 +79,60 @@ class AppStateTests: XCTestCase {
syncXcodesKitMocks()
subject = AppState()
}

func test_PinCodeTextView_MarksDigitFieldsAsOneTimeCode() {
let pinCodeTextView = PinCodeTextView(numberOfDigits: 6, itemSpacing: 10)

let editableTextFields = pinCodeTextView.recursiveSubviews(ofType: NSTextField.self)
.filter(\.isEditable)

XCTAssertEqual(editableTextFields.count, 6)
XCTAssertTrue(editableTextFields.allSatisfy { $0.contentType == .oneTimeCode })
}

func test_PinCodeTextView_PastedCodeIsDistributedAcrossDigitFields() {
let pinCodeTextView = PinCodeTextView(numberOfDigits: 6, itemSpacing: 10)
var changedCodes: [String] = []
pinCodeTextView.codeDidChange = { changedCodes.append($0) }

let inputTextField = pinCodeTextView.recursiveSubviews(ofType: NSTextField.self)
.first { $0.isEditable }!
inputTextField.stringValue = "123 456"

pinCodeTextView.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: inputTextField))

XCTAssertEqual(changedCodes.last, "123456")
}

func test_ChoosePhoneNumberForSMS_WithOneTrustedPhoneNumberRequestsSMS() async throws {
let trustedPhoneNumber = AuthOptionsResponse.TrustedPhoneNumber(id: 7, numberWithDialCode: "(•••) •••-••90")
let authOptions = AuthOptionsResponse(
trustedPhoneNumbers: [trustedPhoneNumber],
trustedDevices: nil,
securityCode: .init(length: 6)
)
let sessionData = AppleSessionData(serviceKey: "service-key", sessionID: "session-id", scnt: "scnt")
Current.network = Network(session: MockURLProtocol.session { request in
XCTAssertEqual(request.url?.absoluteString, "https://idmsa.apple.com/appleauth/auth/verify/phone")
XCTAssertEqual(request.httpMethod, "PUT")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Apple-ID-Session-Id"), "session-id")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Apple-Widget-Key"), "service-key")
XCTAssertEqual(request.value(forHTTPHeaderField: "scnt"), "scnt")
return (Data(), HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!)
})

subject.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData)
for _ in 0..<100 where subject.presentedSheet == nil && subject.authError == nil {
try await Task.sleep(nanoseconds: 10_000_000)
}

XCTAssertNil(subject.authError)
guard case let .twoFactor(secondFactorData) = subject.presentedSheet else {
XCTFail("Expected the SMS code-entry sheet to be presented")
return
}
XCTAssertEqual(secondFactorData.option, .smsSent(trustedPhoneNumber))
}

func test_ParseCertificateInfo_Succeeds() throws {
let sampleRawInfo = """
Expand Down
28 changes: 28 additions & 0 deletions XcodesTests/SignInCredentialsViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Cocoa
import SwiftUI
import XCTest

@testable import Xcodes

@MainActor
final class SignInCredentialsViewTests: XCTestCase {
func test_MarksCredentialFieldsForAutofill() {
let appState = AppState()
let hostingView = NSHostingView(rootView: SignInCredentialsView().environmentObject(appState))
hostingView.frame = NSRect(x: 0, y: 0, width: 420, height: 180)
hostingView.layoutSubtreeIfNeeded()

let editableTextFields = hostingView.recursiveSubviews(ofType: NSTextField.self)
.filter(\.isEditable)
let contentTypes = Set(editableTextFields.compactMap(\.contentType))

XCTAssertTrue(contentTypes.contains(.username))
XCTAssertTrue(contentTypes.contains(.password))
}
}

private extension NSView {
func recursiveSubviews<T: NSView>(ofType type: T.Type) -> [T] {
subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) }
}
}