gh-XcodesOrg-XcodesApp/Xcodes/Frontend/SignIn/PinCodeTextView.swift
William Laverty 35fee30355
Fix SMS autofill by using single text field
The previous implementation used 6 separate text fields for the SMS
verification code, which broke macOS's built-in SMS autofill functionality.
When the system detects an SMS code and offers to autofill it, it expects
a single text field to receive the entire code.

Changes:
- Replaced 6 individual PinCodeCharacterTextField instances with a single
  NSTextField
- Added .oneTimeCode content type to enable SMS autofill on macOS 11+
- Simplified the code structure while maintaining the same functionality
- Added input validation to ensure only digits are accepted

Now when users receive an SMS verification code, macOS can properly
autofill the entire code with a single click, rather than filling only
the first digit and deleting the message.

Closes #788
2026-02-04 04:09:25 -08:00

156 lines
4.6 KiB
Swift

import Cocoa
import SwiftUI
struct PinCodeTextField: NSViewRepresentable {
typealias NSViewType = PinCodeTextView
@Binding var code: String
let numberOfDigits: Int
let complete: (String) -> Void
func makeNSView(context: Context) -> NSViewType {
let view = PinCodeTextView(numberOfDigits: numberOfDigits)
view.codeDidChange = { c in code = c }
view.codeDidComplete = { complete($0) }
return view
}
func updateNSView(_ nsView: NSViewType, context: Context) {
if nsView.code != code {
nsView.code = code
}
}
}
struct PinCodeTextField_Previews: PreviewProvider {
struct PreviewContainer: View {
@State private var code = ""
var body: some View {
PinCodeTextField(code: $code, numberOfDigits: 6) {
print("Input is complete \($0)")
}.padding()
}
}
static var previews: some View {
Group {
PreviewContainer()
}
}
}
// MARK: - PinCodeTextView
/// A single text field that accepts a verification code.
/// Uses a single NSTextField to enable macOS SMS autofill functionality.
class PinCodeTextView: NSControl, NSTextFieldDelegate {
var code: String = "" {
didSet {
guard code != oldValue else { return }
// Ensure code only contains digits and is within length limit
let filteredCode = String(code.filter { $0.isNumber }.prefix(numberOfDigits))
if filteredCode != code {
code = filteredCode
return
}
if textField.stringValue != code {
textField.stringValue = code
}
codeDidChange?(code)
if code.count == numberOfDigits {
codeDidComplete?(code)
}
}
}
var codeDidChange: ((String) -> Void)? = nil
var codeDidComplete: ((String) -> Void)? = nil
private let numberOfDigits: Int
private let textField: NSTextField
// MARK: - Initializers
init(numberOfDigits: Int) {
self.numberOfDigits = numberOfDigits
self.textField = NSTextField(frame: .zero)
super.init(frame: .zero)
setupTextField()
setupLayout()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupTextField() {
textField.translatesAutoresizingMaskIntoConstraints = false
textField.delegate = self
textField.alignment = .center
textField.font = .monospacedDigitSystemFont(ofSize: 32, weight: .medium)
textField.placeholderString = String(repeating: "", count: numberOfDigits)
// Enable one-time code autofill
if #available(macOS 11.0, *) {
textField.contentType = .oneTimeCode
}
// Configure for numeric input
textField.allowsEditingTextAttributes = false
// Add letter spacing for better readability
let formatter = NumberFormatter()
formatter.allowsFloats = false
formatter.minimum = 0
formatter.maximum = NSNumber(value: Int(String(repeating: "9", count: numberOfDigits))!)
addSubview(textField)
}
private func setupLayout() {
NSLayoutConstraint.activate([
textField.topAnchor.constraint(equalTo: topAnchor),
textField.bottomAnchor.constraint(equalTo: bottomAnchor),
textField.leadingAnchor.constraint(equalTo: leadingAnchor),
textField.trailingAnchor.constraint(equalTo: trailingAnchor),
textField.widthAnchor.constraint(greaterThanOrEqualToConstant: CGFloat(numberOfDigits * 30 + 40)),
textField.heightAnchor.constraint(greaterThanOrEqualToConstant: 50)
])
}
// MARK: NSTextFieldDelegate
func controlTextDidChange(_ obj: Notification) {
guard
let field = obj.object as? NSTextField,
field === textField,
isEnabled
else { return }
let newText = field.stringValue
// Filter to only digits
let filteredText = String(newText.filter { $0.isNumber }.prefix(numberOfDigits))
if filteredText != newText {
field.stringValue = filteredText
}
code = filteredText
}
// MARK: NSResponder
override var acceptsFirstResponder: Bool {
true
}
override func becomeFirstResponder() -> Bool {
textField.becomeFirstResponder()
}
}