diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 6fdb3076..9e424ba0 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -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 */; }; @@ -208,6 +209,7 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; + CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsViewTests.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -594,6 +596,7 @@ CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, + CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -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; }; diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6897bb20..0bb303cf 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -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, diff --git a/Xcodes/Frontend/SignIn/PinCodeTextView.swift b/Xcodes/Frontend/SignIn/PinCodeTextView.swift index 6140cdd1..1f29a5de 100644 --- a/Xcodes/Frontend/SignIn/PinCodeTextView.swift +++ b/Xcodes/Frontend/SignIn/PinCodeTextView.swift @@ -135,25 +135,25 @@ 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() { @@ -161,10 +161,10 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { 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 { @@ -172,10 +172,10 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { } else { resignFirstResponder() } - + return } - + // Handle single character input let lastCharacter: Character? if newFieldText.isEmpty { @@ -185,7 +185,7 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { } code[fieldIndex] = lastCharacter - + if lastCharacter != nil { if fieldIndex >= characterViews.count - 1 { resignFirstResponder() @@ -227,6 +227,7 @@ class PinCodeCharacterTextField: NSTextField { wantsLayer = true alignment = .center + contentType = .oneTimeCode maximumNumberOfLines = 1 font = .boldSystemFont(ofSize: 48) diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index 8102e61e..15f2ac21 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -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 { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index e31ee200..e52ef9c0 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1,4 +1,5 @@ import Combine +import Cocoa @preconcurrency import Path import Version import XCTest @@ -24,6 +25,51 @@ private final class TestLockedBox: 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(ofType type: T.Type) -> [T] { + subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) } + } +} + @MainActor class AppStateTests: XCTestCase { var subject: AppState! @@ -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 = """ diff --git a/XcodesTests/SignInCredentialsViewTests.swift b/XcodesTests/SignInCredentialsViewTests.swift new file mode 100644 index 00000000..f7c3b0e2 --- /dev/null +++ b/XcodesTests/SignInCredentialsViewTests.swift @@ -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(ofType type: T.Type) -> [T] { + subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) } + } +}