diff --git a/Cargo.lock b/Cargo.lock index 57c4b80..7ad02a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.0", + "filedescriptor", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", +] + [[package]] name = "crunchy" version = "0.2.2" @@ -687,6 +702,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0870c84016d4b481be5c9f323c24f65e31e901ae618f0e80f4308fb00de1d2d" +[[package]] +name = "filedescriptor" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1068,6 +1094,14 @@ dependencies = [ "smex", ] +[[package]] +name = "keyfork-prompt" +version = "0.1.0" +dependencies = [ + "crossterm", + "thiserror", +] + [[package]] name = "keyfork-shard" version = "0.1.0" @@ -1281,6 +1315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -2033,6 +2068,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2203,18 +2259,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.50" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.50" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 49bfed1..8b20f82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "keyfork-frame", "keyfork-mnemonic-util", "keyfork-pinentry", + "keyfork-prompt", "keyfork-plumbing", "keyfork-shard", "keyfork-slip10-test-data", diff --git a/keyfork-prompt/Cargo.toml b/keyfork-prompt/Cargo.toml new file mode 100644 index 0000000..bb542e3 --- /dev/null +++ b/keyfork-prompt/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "keyfork-prompt" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +crossterm = { version = "0.27.0", default-features = false, features = ["use-dev-tty", "events"] } +thiserror = "1.0.51" diff --git a/keyfork-prompt/src/alternate_screen.rs b/keyfork-prompt/src/alternate_screen.rs new file mode 100644 index 0000000..eb4e486 --- /dev/null +++ b/keyfork-prompt/src/alternate_screen.rs @@ -0,0 +1,88 @@ +use std::{ + io::Write, + os::fd::AsRawFd, + sync::{Arc, Mutex}, +}; + +use crossterm::{ + cursor::MoveTo, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; + +use crate::Result; + +pub(crate) struct AlternateScreen +where + W: Write + AsRawFd + Sized, +{ + write: Arc>, +} + +impl AlternateScreen +where + W: Write + AsRawFd + Sized, +{ + pub(crate) fn new(write_handle: Arc>) -> Result { + let mut write = write_handle.lock().unwrap(); + write.execute(EnterAlternateScreen)?.execute(MoveTo(0, 0))?; + drop(write); + Ok(Self { + write: write_handle, + }) + } + + pub(crate) fn arc_mutex(self) -> Arc> { + Arc::new(Mutex::new(self)) + } +} + +impl Write for AlternateScreen +where + W: Write + AsRawFd + Sized, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.write.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.write.lock().unwrap().flush() + } +} + +impl AsRawFd for AlternateScreen +where + W: Write + AsRawFd + Sized, +{ + fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.write.lock().unwrap().as_raw_fd() + } +} + +/* +impl ExecutableCommand for AlternateScreen +where + W: Write + AsRawFd + Sized, +{ + fn execute(&mut self, command: impl crossterm::Command) -> std::io::Result<&mut Self> { + let mut write = self.write.lock().unwrap(); + match write.execute(command) { + Ok(_) => { + drop(write); + Ok(self) + } + Err(e) => Err(e), + } + } +} +*/ + +impl Drop for AlternateScreen +where + W: Write + AsRawFd + Sized, +{ + fn drop(&mut self) { + let mut write_handle = self.write.lock().unwrap(); + write_handle.execute(LeaveAlternateScreen).unwrap(); + } +} diff --git a/keyfork-prompt/src/bin/test-basic-prompt.rs b/keyfork-prompt/src/bin/test-basic-prompt.rs new file mode 100644 index 0000000..5fbc900 --- /dev/null +++ b/keyfork-prompt/src/bin/test-basic-prompt.rs @@ -0,0 +1,12 @@ +use std::io::{stdin, stdout}; + +use keyfork_prompt::*; + +pub fn main() -> Result<()> { + let mut mgr = PromptManager::new(stdin(), stdout())?; + let line = mgr.prompt_input("Mnemonic: ")?; + dbg!(&line); + let line = mgr.prompt_passphrase("Passphrase: ")?; + dbg!(&line); + Ok(()) +} diff --git a/keyfork-prompt/src/lib.rs b/keyfork-prompt/src/lib.rs new file mode 100644 index 0000000..124bdda --- /dev/null +++ b/keyfork-prompt/src/lib.rs @@ -0,0 +1,92 @@ +use std::{ + io::{BufRead, BufReader, Read, Write}, + os::fd::AsRawFd, + sync::{Arc, Mutex}, +}; + +use crossterm::{ + event::{read, Event, KeyCode}, + style::Print, + terminal, + tty::IsTty, + ExecutableCommand, +}; + +mod alternate_screen; +mod raw_mode; +use alternate_screen::*; +use raw_mode::*; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The given handler is not a TTY")] + NotATTY, + + #[error("IO Error: {0}")] + IO(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +pub struct PromptManager { + read: Arc>>, + write: Arc>, +} + +impl PromptManager +where + R: Read + Sized, + W: Write + AsRawFd + Sized, +{ + pub fn new(read_handle: R, write_handle: W) -> Result { + if !write_handle.is_tty() { + return Err(Error::NotATTY); + } + Ok(Self { + read: Arc::new(Mutex::new(BufReader::new(read_handle))), + write: Arc::new(Mutex::new(write_handle)), + }) + } + + fn alt_screen(&self) -> Result> { + AlternateScreen::new(self.write.clone()) + } + + pub fn prompt_input(&mut self, prompt: &str) -> Result { + let mut alt_screen = self.alt_screen()?; + alt_screen + .execute(terminal::Clear(terminal::ClearType::All))? + .execute(Print(prompt))?; + let mut line = String::new(); + self.read.lock().unwrap().read_line(&mut line)?; + Ok(line) + } + + // TODO: return secrecy::Secret + // TODO: write a guard drop system for raw mode + pub fn prompt_passphrase(&mut self, prompt: &str) -> Result { + let write = AlternateScreen::new(self.write.clone())?; + let mut write = RawMode::new(write.arc_mutex())?; + write + .execute(terminal::Clear(terminal::ClearType::All))? + .execute(Print(prompt))?; + let mut passphrase = String::new(); + loop { + match read()? { + Event::Key(k) => match k.code { + KeyCode::Enter => { + passphrase.push('\n'); + break; + } + KeyCode::Char(c) => { + write.execute(Print("*"))?; + passphrase.push(c); + } + _ => (), + }, + _ => (), + } + } + Ok(passphrase) + } +} diff --git a/keyfork-prompt/src/raw_mode.rs b/keyfork-prompt/src/raw_mode.rs new file mode 100644 index 0000000..7cfdde9 --- /dev/null +++ b/keyfork-prompt/src/raw_mode.rs @@ -0,0 +1,82 @@ +use std::{ + io::Write, + os::fd::AsRawFd, + sync::{Arc, Mutex}, +}; + +use crossterm::{terminal, ExecutableCommand}; + +use crate::Result; + +pub(crate) struct RawMode +where + W: Write + AsRawFd + Sized, +{ + write: Arc>, +} + +// TODO: fork crossterm to allow using FD from as_raw_fd() +impl RawMode +where + W: Write + AsRawFd + Sized, +{ + pub(crate) fn new(write_handle: Arc>) -> Result { + terminal::enable_raw_mode()?; + Ok(Self { + write: write_handle, + }) + } + + pub(crate) fn arc_mutex(self) -> Arc> { + Arc::new(Mutex::new(self)) + } +} + +impl Write for RawMode +where + W: Write + AsRawFd + Sized, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.write.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.write.lock().unwrap().flush() + } +} + +impl AsRawFd for RawMode +where + W: Write + AsRawFd + Sized, +{ + fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.write.lock().unwrap().as_raw_fd() + } +} + +/* +impl ExecutableCommand for RawMode +where + W: Write + AsRawFd + Sized, +{ + fn execute(&mut self, command: impl crossterm::Command) -> std::io::Result<&mut Self> { + let mut write = self.write.lock().unwrap(); + match write.execute(command) { + Ok(_) => { + drop(write); + Ok(self) + } + Err(e) => Err(e), + } + } +} +*/ + +impl Drop for RawMode +where + W: Write + AsRawFd + Sized, +{ + fn drop(&mut self) { + terminal::disable_raw_mode().unwrap(); + } +}