1

I'm writing a command-line tool in Swift and I want to get some user input. I'm using readLine() for this. But I would like to add a timeout to select the default option if the user didn't respond within the time frame. Is this possible?

> Do you want to proceed? [y/n, will continue automatically in 2:00]: _

(Bonus points for actually updating the remaining time in the prompt. )

Frank Rupprecht
  • 9,191
  • 31
  • 56

1 Answers1

2

Not the prettiest Swift code, but it does the job. aio (asynchronous input output) low level system API in raw terminal mode https://stackoverflow.com/a/59795707/5329717 is used to read user input without pressing enter.

We're doing multiple reads using aio_read (paired by following aio_return) because the user may be inputting keys we don't want.

Since Xcode debug console isn't a standard console please run this in a standalone terminal.

The only caveat I've run into with this code is in the scenario when time runs out aio_read sets terminal standard input into still expecting user input (e.g. enter key for the shell to appear again). I'll be trying to circumvent this problem.

import Foundation

// https://stackoverflow.com/a/59000106/5329717
extension TimeInterval{
    func stringFromTimeInterval() -> String {
        let time = NSInteger(self)
        let seconds = time % 60
        let minutes = (time / 60) % 60
        let hours = (time / 3600)
        var formatString = ""
        if hours == 0 {
            if(minutes < 10) {
                formatString = "%2d:%0.2d"
            } else {
                formatString = "%0.2d:%0.2d"
            }
            return String(format: formatString,minutes,seconds)
        } else {
            formatString = "%2d:%0.2d:%0.2d"
            return String(format: formatString,hours,minutes,seconds)
        }
    }
}

// https://stackoverflow.com/a/59795707/5329717
extension FileHandle {
    func enableRawMode() -> termios {
        var raw = termios()
        tcgetattr(self.fileDescriptor, &raw)
        
        let original = raw
        raw.c_lflag &= ~UInt(ECHO | ICANON)
        tcsetattr(self.fileDescriptor, TCSADRAIN, &raw)
        return original
    }
    
    func restoreRawMode(originalTerm: termios) {
        var term = originalTerm
        tcsetattr(self.fileDescriptor, TCSADRAIN, &term)
    }
}

let bufferForReadSize = 100
var bufferForRead: UnsafeMutableRawPointer = UnsafeMutableRawPointer.allocate(byteCount: bufferForReadSize, alignment: 1)

//Give the user slightly bit more than 2 minutes so that the 2:00 countdown initial value can be seen 
let endTime = Date().addingTimeInterval(TimeInterval(exactly: 120.5)!)
//struct for using aio_ calls
var aio: aiocb = aiocb(aio_fildes: FileHandle.standardInput.fileDescriptor,
                       aio_offset: 0,
                       aio_buf: bufferForRead,
                       aio_nbytes: bufferForReadSize,
                       aio_reqprio: 0,
                       aio_sigevent: sigevent(),
                       aio_lio_opcode: 0)
var userChoice: Bool?
print()
let originalTermios = FileHandle.standardInput.enableRawMode()
withUnsafeMutablePointer(to: &aio) {
    while userChoice == nil {
        let timeLeft = endTime.timeIntervalSince(Date())
        print("\u{1B}[A" + //rewind to previous line +
            "Hello, World? (y/n)" + timeLeft.stringFromTimeInterval())

        let inputString = String(cString: bufferForRead.bindMemory(to: Int8.self, capacity: bufferForReadSize))
        if inputString.starts(with: "y") || inputString.starts(with: "Y") {
            userChoice = true
            break
        } else if inputString.starts(with: "n") || inputString.starts(with: "N") {
            userChoice = false
            break
        }
        
        if timeLeft <= 0 {
            userChoice = true
            break
        } else {
            //Async IO read
            aio_read($0)
            CFRunLoopRunInMode(CFRunLoopMode.defaultMode,
                               0.5, //choose the interval value depending on the fps you need
                               false)
            //Async IO return
            aio_return($0)
        }
    }
}
FileHandle.standardInput.restoreRawMode(originalTerm: originalTermios)
userChoice! ? print("Thanks for choosing YES. Bye") : print("Thanks for choosing NO. Bye")
Kamil.S
  • 5,205
  • 2
  • 22
  • 51