diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index a80af43..cfbea90 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; + CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; }; CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; @@ -40,6 +41,7 @@ CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; + CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = ""; }; @@ -92,6 +94,7 @@ children = ( CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */, CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */, + CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */, CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */, CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */, ); @@ -272,6 +275,7 @@ CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, + CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Xcodes/SignIn/PinCodeTextView.swift b/Xcodes/SignIn/PinCodeTextView.swift new file mode 100644 index 0000000..3476054 --- /dev/null +++ b/Xcodes/SignIn/PinCodeTextView.swift @@ -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.. 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.. 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 + } +} diff --git a/Xcodes/SignIn/SignIn2FAView.swift b/Xcodes/SignIn/SignIn2FAView.swift index ede4e16..e64e2e5 100644 --- a/Xcodes/SignIn/SignIn2FAView.swift +++ b/Xcodes/SignIn/SignIn2FAView.swift @@ -6,20 +6,28 @@ struct SignIn2FAView: View { @Binding var isPresented: Bool @State private var code: String = "" let sessionData: AppleSessionData + // TODO: dynamic number of digits + let numberOfDigits = 6 var body: some View { VStack(alignment: .leading) { Text("Enter the \(6) digit code from one of your trusted devices:") HStack { - TextField("\(6) digit code", text: $code) + Spacer() + PinCodeTextField(code: $code, numberOfDigits: numberOfDigits) + Spacer() } + .padding() HStack { Button("Cancel", action: { isPresented = false }) + .keyboardShortcut(.cancelAction) Button("Send SMS", action: {}) Spacer() Button("Continue", action: { appState.submit2FACode(code, sessionData: sessionData) }) + .keyboardShortcut(.defaultAction) + .disabled(code.count != numberOfDigits) } } .padding()