Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Console input/output procedures. #55

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
386 changes: 386 additions & 0 deletions stew/conio.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,386 @@
## Copyright (c) 2020 Status Research & Development GmbH
## Licensed under either of
## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
## * MIT license ([LICENSE-MIT](LICENSE-MIT))
## at your option.
## This file may not be copied, modified, or distributed except according to
## those terms.

## This module implements cross-platform console procedures.
import io2, utf8
export io2

when defined(windows):
proc setConsoleOutputCP(wCodePageID: cuint): int32 {.
importc: "SetConsoleOutputCP", stdcall, dynlib: "kernel32", sideEffect.}
proc setConsoleCP(wCodePageID: cuint): int32 {.
importc: "SetConsoleCP", stdcall, dynlib: "kernel32", sideEffect.}
proc getConsoleCP(): cuint {.
importc: "GetConsoleCP", stdcall, dynlib: "kernel32", sideEffect.}
proc getConsoleOutputCP(): cuint {.
importc: "GetConsoleOutputCP", stdcall, dynlib: "kernel32", sideEffect.}
proc setConsoleMode(hConsoleHandle: uint, dwMode: uint32): int32 {.
importc: "SetConsoleMode", stdcall, dynlib: "kernel32", sideEffect.}
proc getConsoleMode(hConsoleHandle: uint, dwMode: var uint32): int32 {.
importc: "GetConsoleMode", stdcall, dynlib: "kernel32", sideEffect.}
proc readConsole(hConsoleInput: uint, lpBuffer: pointer,
nNumberOfCharsToRead: uint32,
lpNumberOfCharsRead: var uint32,
pInputControl: pointer): int32 {.
importc: "ReadConsoleW", stdcall, dynlib: "kernel32", sideEffect.}
proc readFile(hFile: uint, lpBuffer: pointer,
nNumberOfBytesToRead: uint32,
lpNumberOfBytesRead: var uint32,
lpOverlapped: pointer): int32 {.
importc: "ReadFile", dynlib: "kernel32", stdcall, sideEffect.}
proc writeConsole(hConsoleOutput: uint, lpBuffer: pointer,
nNumberOfCharsToWrite: uint32,
lpNumberOfCharsWritten: var uint32,
lpReserved: pointer): int32 {.
importc: "WriteConsoleW", stdcall, dynlib: "kernel32", sideEffect.}
proc writeFile(hFile: uint, lpBuffer: pointer,
nNumberOfBytesToWrite: uint32,
lpNumberOfBytesWritten: var uint32,
lpOverlapped: pointer): int32 {.
importc: "WriteFile", dynlib: "kernel32", stdcall, sideEffect.}
proc getStdHandle(nStdHandle: uint32): uint {.
importc: "GetStdHandle", stdcall, dynlib: "kernel32", sideEffect.}
proc wideCharToMultiByte(codePage: cuint, dwFlags: uint32,
lpWideCharStr: ptr Utf16Char, cchWideChar: cint,
lpMultiByteStr: ptr char, cbMultiByte: cint,
lpDefaultChar: pointer,
lpUsedDefaultChar: pointer): cint {.
importc: "WideCharToMultiByte", stdcall, dynlib: "kernel32", sideEffect.}
proc getFileType(hFile: uint): uint32 {.
importc: "GetFileType", stdcall, dynlib: "kernel32", sideEffect.}

const
CP_UTF8 = 65001'u32
STD_INPUT_HANDLE = cast[uint32](-10)
STD_OUTPUT_HANDLE = cast[uint32](-11)
INVALID_HANDLE_VALUE = cast[uint](-1)
ENABLE_PROCESSED_INPUT = 0x0001'u32
ENABLE_ECHO_INPUT = 0x0004'u32
FILE_TYPE_CHAR = 0x0002'u32
ERROR_NO_UNICODE_TRANSLATION = 1113'u32

proc isConsoleRedirected*(hConsole: uint): bool =
## Returns ``true`` if console handle was redirected.
let res = getFileType(hConsole)
if res == FILE_TYPE_CHAR:
# The specified handle is a character device, typically an LPT device or a
# console.
false
else:
true

proc readConsoleInput(maxChars: int): IoResult[string] =
let hConsoleInput =
block:
let res = getStdHandle(STD_INPUT_HANDLE)
if res == INVALID_HANDLE_VALUE:
return err(ioLastError())
res

let prevInputCP =
block:
let res = getConsoleCP()
if res == cuint(0):
return err(ioLastError())
res

if isConsoleRedirected(hConsoleInput):
# Console STDIN is redirected, we should use ReadFile(), because
# ReadConsole() is not working for such types of STDIN.
if setConsoleCP(CP_UTF8) == 0'i32:
return err(ioLastError())

# Allocating buffer with size equal to `(maxChars + len(CRLF)) * 4`,
# where 4 is maximum expected size of one character (UTF8 encoding).
var buffer = newString((maxChars + 2) * 4)
let bytesToRead = uint32(len(buffer))
var bytesRead: uint32
let rres = readFile(hConsoleInput, cast[pointer](addr buffer[0]),
bytesToRead, bytesRead, nil)
if rres == 0:
let errCode = ioLastError()
discard setConsoleCP(prevInputCP)
return err(errCode)

if setConsoleCP(prevInputCP) == 0'i32:
return err(ioLastError())

# Truncate additional bytes from buffer.
buffer.setLen(int(bytesRead))

# Trim CR/CRLF from buffer.
if len(buffer) > 0:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like duplicating strutils.stripLineEnd.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem of strutils.stripLineEnd is that it thinks that \v and \f are line end, but this is not a proper markers of end of line.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These characters are quite obscure nowadays, but OK - if this is a general purpose procedure for reading a line of characters from the screen, why should it preserve a page break character at the end of the line, but not a new line character in the same place?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zah we are supposed to read UTF-8 encoded strings are you sure \v and \f could not be part of UTF-8 encoded symbol which was cut because of buffer limits?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, in UTF-8, the first 128 characters in the ASCII table are encoded with a single byte that has a leading zero bit. All other characters are encoded with a sequence of bytes where each byte has a leading bit set to 1. More details here:

https://en.wikipedia.org/wiki/UTF-8#Encoding

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zah you are right, but i still think \v and \f is not a proper condition for line end which we are going to receive from console. When we reading from console the only proper line end is \n and \r\n and nothing else, strutils procedure is incorrect.

if buffer[^1] == char(0x0A):
if len(buffer) > 1:
if buffer[^2] == char(0x0D):
buffer.setLen(len(buffer) - 2)
else:
buffer.setLen(len(buffer) - 1)
else:
buffer.setLen(len(buffer) - 1)
elif buffer[^1] == char(0x0D):
buffer.setLen(len(buffer) - 1)

# Check if buffer is valid UTF-8 encoded string.
if utf8Validate(buffer):
# Cut result buffer to `maxChars` characters.
ok(utf8Substr(buffer, 0, maxChars - 1).get())
else:
err(IoErrorCode(ERROR_NO_UNICODE_TRANSLATION))
else:
let prevMode =
block:
var mode: uint32
let res = getConsoleMode(hConsoleInput, mode)
if res == 0:
return err(ioLastError())
mode

var newMode = prevMode or ENABLE_PROCESSED_INPUT
newMode = newMode and not(ENABLE_ECHO_INPUT)

# Change console CodePage to allow UTF-8 strings input.
if setConsoleCP(CP_UTF8) == 0'i32:
return err(ioLastError())

# Disable local echo output.
let mres = setConsoleMode(hConsoleInput, newMode)
if mres == 0:
let errCode = ioLastError()
discard setConsoleCP(prevInputCP)
return err(errCode)

# Allocating buffer with size equal to `maxChars` + len(CRLF).
var buffer = newSeq[Utf16Char](maxChars + 2)
let charsToRead = uint32(len(buffer))
var charsRead: uint32
let rres = readConsole(hConsoleInput, cast[pointer](addr buffer[0]),
charsToRead, charsRead, nil)
if rres == 0'i32:
let errCode = ioLastError()
discard setConsoleMode(hConsoleInput, prevMode)
discard setConsoleCP(prevInputCP)
return err(errCode)

# Restore local echo output.
if setConsoleMode(hConsoleInput, prevMode) == 0'i32:
let errCode = ioLastError()
discard setConsoleCP(prevInputCP)
return err(errCode)

# Restore previous console CodePage.
if setConsoleCP(prevInputCP) == 0'i32:
return err(ioLastError())

# Truncate additional bytes from buffer.
buffer.setLen(int(min(charsRead, uint32(maxChars))))

# Truncate CRLF in result wide string.
if len(buffer) > 0:
zah marked this conversation as resolved.
Show resolved Hide resolved
if int16(buffer[^1]) == int16(0x0A):
if len(buffer) > 1:
if int16(buffer[^2]) == int16(0x0D):
buffer.setLen(len(buffer) - 2)
else:
buffer.setLen(len(buffer) - 1)
else:
buffer.setLen(len(buffer) - 1)
elif int16(buffer[^1]) == int16(0x0D):
buffer.setLen(len(buffer) - 1)

# Convert Windows UCS-2 encoded string to UTF-8 encoded string.
if len(buffer) > 0:
var pwd = ""
let bytesNeeded = wideCharToMultiByte(CP_UTF8, 0'u32, addr buffer[0],
cint(len(buffer)), nil,
cint(0), nil, nil)
if bytesNeeded <= cint(0):
return err(ioLastError())
pwd.setLen(bytesNeeded)
let cres = wideCharToMultiByte(CP_UTF8, 0'u32, addr buffer[0],
cint(len(buffer)), addr pwd[0],
cint(len(pwd)), nil, nil)
if cres == cint(0):
return err(ioLastError())
ok(pwd)
else:
ok("")

proc writeConsoleOutput(data: string): IoResult[void] =
if len(data) == 0:
return ok()

let hConsoleOutput =
block:
let res = getStdHandle(STD_OUTPUT_HANDLE)
if res == INVALID_HANDLE_VALUE:
return err(ioLastError())
res

let prevOutputCP =
block:
let res = getConsoleOutputCP()
if res == cuint(0):
return err(ioLastError())
res

if isConsoleRedirected(hConsoleOutput):
# If STDOUT is redirected we should use WriteFile() because WriteConsole()
# is not working for such types of STDOUT.
if setConsoleOutputCP(CP_UTF8) == 0'i32:
return err(ioLastError())

let bytesToWrite = uint32(len(data))
var bytesWritten: uint32
let wres = writeFile(hConsoleOutput, cast[pointer](unsafeAddr data[0]),
bytesToWrite, bytesWritten, nil)
if wres == 0'i32:
let errCode = ioLastError()
discard setConsoleOutputCP(prevOutputCP)
return err(errCode)

if setConsoleOutputCP(prevOutputCP) == 0'i32:
return err(ioLastError())
else:
if setConsoleOutputCP(CP_UTF8) == 0'i32:
return err(ioLastError())

let widePrompt = newWideCString(data)
var charsWritten: uint32
let wres = writeConsole(hConsoleOutput, cast[pointer](widePrompt),
uint32(len(widePrompt)), charsWritten, nil)
if wres == 0'i32:
let errCode = ioLastError()
discard setConsoleOutputCP(prevOutputCP)
return err(errCode)

if setConsoleOutputCP(prevOutputCP) == 0'i32:
return err(ioLastError())
ok()

elif defined(posix):
import posix, termios

proc isConsoleRedirected*(consoleFd: cint): bool =
## Returns ``true`` if console handle was redirected.
var mode: Termios
# This is how `isatty()` checks for TTY.
if tcGetAttr(consoleFd, addr mode) != cint(0):
true
else:
false

proc writeConsoleOutput(prompt: string): IoResult[void] =
if len(prompt) == 0:
ok()
else:
let res = posix.write(STDOUT_FILENO, cast[pointer](unsafeAddr prompt[0]),
len(prompt))
if res != len(prompt):
err(ioLastError())
else:
ok()

proc readConsoleInput(maxChars: int): IoResult[string] =
# Allocating buffer with size equal to `(maxChars + len(LF)) * 4`, where
# 4 is maximum expected size of one character (UTF8 encoding).
var buffer = newString((maxChars + 1) * 4)

if isConsoleRedirected(STDIN_FILENO):
let bytesRead =
block:
let res = posix.read(STDIN_FILENO, cast[pointer](addr buffer[0]),
len(buffer))
if res < 0:
return err(ioLastError())
res

# Truncate additional bytes from buffer.
buffer.setLen(bytesRead)

# Trim LF in result string
if len(buffer) > 0:
if buffer[^1] == char(0x0A):
buffer.setLen(len(buffer) - 1)

# Check if buffer is valid UTF-8 encoded string.
if utf8Validate(buffer):
# Cut result buffer to `maxChars` characters.
ok(utf8Substr(buffer, 0, maxChars - 1).get())
else:
err(IoErrorCode(EILSEQ))
else:
let bytesRead =
block:
var cur, old: Termios
if tcGetAttr(STDIN_FILENO, addr cur) != cint(0):
return err(ioLastError())

old = cur
cur.c_lflag = cur.c_lflag and not(Cflag(ECHO))

if tcSetAttr(STDIN_FILENO, TCSADRAIN, addr(cur)) != cint(0):
return err(ioLastError())

let res = read(STDIN_FILENO, cast[pointer](addr buffer[0]),
len(buffer))
if res < 0:
let errCode = ioLastError()
discard tcSetAttr(STDIN_FILENO, TCSADRAIN, addr(old))
return err(errCode)

if tcSetAttr(STDIN_FILENO, TCSADRAIN, addr(old)) != cint(0):
return err(ioLastError())
res

# Truncate additional bytes from buffer.
buffer.setLen(bytesRead)

# Trim LF in result string
if len(buffer) > 0:
if buffer[^1] == char(0x0A):
buffer.setLen(len(buffer) - 1)
buffer.add(char(0x00))

# Conversion of console input into wide characters sequence.
let wres = mbstowcs(uint32, buffer)
if wres.isOk():
# Trim wide character sequence to `maxChars` number of characters.
var wbuffer = wres.get()
if maxChars < len(wbuffer):
wbuffer.setLen(maxChars)
# Conversion of wide characters sequence to UTF-8 encoded string.
let ures = wbuffer.utf32toUtf8()
if ures.isOk():
ok(ures.get())
else:
err(IoErrorCode(EILSEQ))
else:
err(IoErrorCode(EILSEQ))

proc readConsolePassword*(prompt: string,
maxChars = 32768): IoResult[string] =
## Reads a password from stdin without printing it with length in characters
## up to ``maxChars``.
##
## This procedure supports reading of UTF-8 encoded passwords from console or
## redirected pipe.
##
## Before reading password ``prompt`` will be printed.
##
## Please note that ``maxChars`` should be in range (0, 32768].
doAssert(maxChars > 0 and maxChars <= 32768,
"maxChars should be integer in (0, 32768]")
? writeConsoleOutput(prompt)
let res = ? readConsoleInput(maxChars)
# `\p` is platform specific newline: CRLF on Windows, LF on Unix
? writeConsoleOutput("\p")
ok(res)

when isMainModule:
echo readConsolePassword("Enter password: ", 4)
Loading