CommandLineInterfaceService.kt
package de.pflugradts.passbird.adapter.userinterface
import de.pflugradts.passbird.application.InactivityTerminationRequestedException
import de.pflugradts.passbird.application.SecureInputUnavailableException
import de.pflugradts.passbird.application.StdinTerminationRequestedException
import de.pflugradts.passbird.application.UserInterfaceAdapterPort
import de.pflugradts.passbird.application.configuration.ReadableConfiguration
import de.pflugradts.passbird.application.process.inactivity.InactivityTerminationSignal
import de.pflugradts.passbird.domain.model.shell.PlainShell.Companion.plainShellOf
import de.pflugradts.passbird.domain.model.shell.Shell.Companion.shellOf
import de.pflugradts.passbird.domain.model.transfer.Input
import de.pflugradts.passbird.domain.model.transfer.Input.Companion.inputOf
import de.pflugradts.passbird.domain.model.transfer.Output
import de.pflugradts.passbird.domain.model.transfer.OutputFormatting
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
private const val INPUT_POLL_INTERVAL_IN_MILLIS = 50L
private val STDIN_EOF = Char.MAX_VALUE
class CommandLineInterfaceService constructor(
private val terminalInputGateway: TerminalInputGateway,
private val configuration: ReadableConfiguration,
private val inactivityTerminationSignal: InactivityTerminationSignal,
) : UserInterfaceAdapterPort {
private val inputReaderExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "passbird-input-reader").apply { isDaemon = true }
}
constructor(
terminalInputGateway: TerminalInputGateway,
configuration: ReadableConfiguration,
) : this(terminalInputGateway, configuration, InactivityTerminationSignal())
override fun receive(vararg output: Output) = output.forEach { sendWithoutLineBreak(it) }.run { receivePlain() }
private fun receivePlain(): Input {
val bytes = ArrayList<Byte>()
while (true) {
val next = readCharFromVisibleStdin()
if (isEndOfInput(next)) {
return bytes.toInputOrThrow()
}
if (isLinebreak(next)) {
return inputOf(shellOf(bytes))
}
if (!isCarriageReturn(next)) bytes.add(next.code.toByte())
}
}
private fun stdin(): Char = terminalInputGateway.readCharFromStdin()
private fun isLinebreak(chr: Char) = chr == '\n'
private fun isCarriageReturn(chr: Char) = chr == '\r'
private fun isEndOfInput(chr: Char) = chr == STDIN_EOF
private fun readCharFromVisibleStdin(): Char = readWithInactivityCheck { runCatching(::stdin).getOrDefault(STDIN_EOF) }
private fun readPasswordFromConsole(): CharArray = readWithInactivityCheck { terminalInputGateway.readPasswordFromConsole() }
private fun <T> readWithInactivityCheck(read: () -> T): T {
if (inactivityTerminationSignal.isRequested()) {
throw InactivityTerminationRequestedException()
}
val readTask = inputReaderExecutor.submit<T> { read() }
while (true) {
try {
return readTask.get(INPUT_POLL_INTERVAL_IN_MILLIS, TimeUnit.MILLISECONDS)
} catch (ex: ExecutionException) {
throw ex.cause ?: ex
} catch (_: TimeoutException) {
if (inactivityTerminationSignal.isRequested()) {
readTask.cancel(true)
throw InactivityTerminationRequestedException()
}
}
}
}
private fun List<Byte>.toInputOrThrow(): Input = takeIf { it.isNotEmpty() }?.let { inputOf(shellOf(it)) }
?: throw StdinTerminationRequestedException()
override fun receiveSecurely(output: Output): Input {
sendWithoutLineBreak(output)
return when {
!configuration.adapter.userInterface.secureInput -> receivePlain()
terminalInputGateway.isConsoleAvailable -> readPasswordFromConsole().toInput()
else -> throw SecureInputUnavailableException()
}
}
override fun send(vararg output: Output) = output.forEach { sendWithoutLineBreak(it) }.also { sendChar('\n') }
private fun sendWithoutLineBreak(vararg output: Output) = output.forEach {
it.formatting?.also { formatting -> if (escapeCodesEnabled) beginEscape(formatting) }
val renderedShell = it.shell.copy()
try {
for (index in 0 until renderedShell.size) {
sendChar(renderedShell.getChar(index))
}
} finally {
renderedShell.scramble()
}
it.formatting?.also { if (escapeCodesEnabled) endEscape() }
it.formatting?.let { formatting -> if (formatting == OutputFormatting.OPERATION_ABORTED) warningSound() }
}
private fun CharArray.toInput(): Input = try {
inputOf(plainShellOf(this).toShell())
} finally {
fill(Char.MIN_VALUE)
}
private fun sendChar(chr: Char) = print(chr)
override fun warningSound() {
if (audibleBell) sendChar('\u0007')
}
private val escapeCodesEnabled: Boolean get() = configuration.adapter.userInterface.ansiEscapeCodes.enabled
private val audibleBell: Boolean get() = configuration.adapter.userInterface.audibleBell
}