keyfork-crossterm: add FdTerminal struct to manage non-default terminals
This commit is contained in:
parent
6825ac9cea
commit
f6b41fce5f
|
@ -1427,7 +1427,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyfork-crossterm"
|
name = "keyfork-crossterm"
|
||||||
version = "0.27.0"
|
version = "0.27.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-std",
|
"async-std",
|
||||||
"bitflags 2.4.1",
|
"bitflags 2.4.1",
|
||||||
|
|
|
@ -83,7 +83,7 @@
|
||||||
//!
|
//!
|
||||||
//! For manual execution control check out [crossterm::queue](../macro.queue.html).
|
//! For manual execution control check out [crossterm::queue](../macro.queue.html).
|
||||||
|
|
||||||
use std::{fmt, io};
|
use std::{fmt, io, os};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use crossterm_winapi::{ConsoleMode, Handle, ScreenBuffer};
|
use crossterm_winapi::{ConsoleMode, Handle, ScreenBuffer};
|
||||||
|
@ -92,6 +92,57 @@ use serde::{Deserialize, Serialize};
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use winapi::um::wincon::ENABLE_WRAP_AT_EOL_OUTPUT;
|
use winapi::um::wincon::ENABLE_WRAP_AT_EOL_OUTPUT;
|
||||||
|
|
||||||
|
pub trait TerminalIoctl {
|
||||||
|
fn enable_raw_mode(&mut self) -> io::Result<()>;
|
||||||
|
|
||||||
|
fn disable_raw_mode(&mut self) -> io::Result<()>;
|
||||||
|
|
||||||
|
fn size(&self) -> io::Result<(u16, u16)>;
|
||||||
|
|
||||||
|
fn window_size(&self) -> io::Result<WindowSize>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub struct FdTerminal {
|
||||||
|
fd: i32,
|
||||||
|
stored_termios: Option<libc::termios>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<T> for FdTerminal where T: os::fd::AsRawFd {
|
||||||
|
fn from(value: T) -> Self {
|
||||||
|
Self {
|
||||||
|
fd: value.as_raw_fd(),
|
||||||
|
stored_termios: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
impl TerminalIoctl for FdTerminal {
|
||||||
|
fn enable_raw_mode(&mut self) -> io::Result<()> {
|
||||||
|
if self.stored_termios.is_none() {
|
||||||
|
let termios = sys::fd_enable_raw_mode(self.fd)?;
|
||||||
|
let _ = self.stored_termios.insert(termios);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_raw_mode(&mut self) -> io::Result<()> {
|
||||||
|
if let Some(termios) = self.stored_termios.take() {
|
||||||
|
sys::fd_disable_raw_mode(self.fd, termios)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> io::Result<(u16, u16)> {
|
||||||
|
sys::fd_size(self.fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_size(&self) -> io::Result<WindowSize> {
|
||||||
|
sys::fd_window_size(self.fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[doc(no_inline)]
|
#[doc(no_inline)]
|
||||||
use crate::Command;
|
use crate::Command;
|
||||||
use crate::{csi, impl_display};
|
use crate::{csi, impl_display};
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
pub use self::unix::supports_keyboard_enhancement;
|
pub use self::unix::supports_keyboard_enhancement;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub(crate) use self::unix::{
|
pub(crate) use self::unix::{
|
||||||
disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, size, window_size,
|
disable_raw_mode, enable_raw_mode, fd_disable_raw_mode, fd_enable_raw_mode, fd_size,
|
||||||
|
fd_window_size, is_raw_mode_enabled, size, window_size,
|
||||||
};
|
};
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[cfg(feature = "events")]
|
#[cfg(feature = "events")]
|
||||||
|
|
|
@ -34,6 +34,21 @@ impl From<winsize> for WindowSize {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fd_window_size(fd: i32) -> io::Result<WindowSize> {
|
||||||
|
let mut size = winsize {
|
||||||
|
ws_row: 0,
|
||||||
|
ws_col: 0,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if wrap_with_result(unsafe { ioctl(fd, TIOCGWINSZ.into(), &mut size) }).is_ok() {
|
||||||
|
return Ok(size.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(std::io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::useless_conversion)]
|
#[allow(clippy::useless_conversion)]
|
||||||
pub(crate) fn window_size() -> io::Result<WindowSize> {
|
pub(crate) fn window_size() -> io::Result<WindowSize> {
|
||||||
// http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc
|
// http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc
|
||||||
|
@ -59,6 +74,10 @@ pub(crate) fn window_size() -> io::Result<WindowSize> {
|
||||||
Err(std::io::Error::last_os_error().into())
|
Err(std::io::Error::last_os_error().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fd_size(fd: i32) -> io::Result<(u16, u16)> {
|
||||||
|
fd_window_size(fd).map(|WindowSize { rows, columns, .. }| (columns, rows))
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::useless_conversion)]
|
#[allow(clippy::useless_conversion)]
|
||||||
pub(crate) fn size() -> io::Result<(u16, u16)> {
|
pub(crate) fn size() -> io::Result<(u16, u16)> {
|
||||||
if let Ok(window_size) = window_size() {
|
if let Ok(window_size) = window_size() {
|
||||||
|
@ -68,6 +87,15 @@ pub(crate) fn size() -> io::Result<(u16, u16)> {
|
||||||
tput_size().ok_or_else(|| std::io::Error::last_os_error().into())
|
tput_size().ok_or_else(|| std::io::Error::last_os_error().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fd_enable_raw_mode(fd: i32) -> io::Result<Termios> {
|
||||||
|
let mut ios = get_terminal_attr(fd)?;
|
||||||
|
let original_mode_ios = ios;
|
||||||
|
|
||||||
|
raw_terminal_attr(&mut ios);
|
||||||
|
set_terminal_attr(fd, &ios)?;
|
||||||
|
Ok(original_mode_ios)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn enable_raw_mode() -> io::Result<()> {
|
pub(crate) fn enable_raw_mode() -> io::Result<()> {
|
||||||
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock();
|
let mut original_mode = TERMINAL_MODE_PRIOR_RAW_MODE.lock();
|
||||||
|
|
||||||
|
@ -89,6 +117,11 @@ pub(crate) fn enable_raw_mode() -> io::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fd_disable_raw_mode(fd: i32, termios: Termios) -> io::Result<()> {
|
||||||
|
set_terminal_attr(fd, &termios)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset the raw mode.
|
/// Reset the raw mode.
|
||||||
///
|
///
|
||||||
/// More precisely, reset the whole termios mode to what it was before the first call
|
/// More precisely, reset the whole termios mode to what it was before the first call
|
||||||
|
|
|
@ -2,11 +2,11 @@ use std::io::{stdin, stdout};
|
||||||
|
|
||||||
use keyfork_prompt::{
|
use keyfork_prompt::{
|
||||||
validators::{mnemonic, Validator},
|
validators::{mnemonic, Validator},
|
||||||
PromptManager,
|
Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut mgr = PromptManager::new(stdin(), stdout())?;
|
let mut mgr = Terminal::new(stdin(), stdout())?;
|
||||||
let transport_validator = mnemonic::MnemonicSetValidator {
|
let transport_validator = mnemonic::MnemonicSetValidator {
|
||||||
word_lengths: [9, 24],
|
word_lengths: [9, 24],
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,7 +10,7 @@ use keyfork_crossterm::{
|
||||||
cursor,
|
cursor,
|
||||||
event::{read, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyModifiers},
|
event::{read, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyModifiers},
|
||||||
style::{Print, PrintStyledContent, Stylize},
|
style::{Print, PrintStyledContent, Stylize},
|
||||||
terminal,
|
terminal::{self, TerminalIoctl, FdTerminal},
|
||||||
tty::IsTty,
|
tty::IsTty,
|
||||||
QueueableCommand,
|
QueueableCommand,
|
||||||
};
|
};
|
||||||
|
@ -44,12 +44,13 @@ pub enum Message {
|
||||||
Data(String),
|
Data(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PromptManager<R, W> {
|
pub struct Terminal<R, W> {
|
||||||
read: BufReader<R>,
|
read: BufReader<R>,
|
||||||
write: W,
|
write: W,
|
||||||
|
terminal: FdTerminal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R, W> PromptManager<R, W>
|
impl<R, W> Terminal<R, W>
|
||||||
where
|
where
|
||||||
R: Read + Sized,
|
R: Read + Sized,
|
||||||
W: Write + AsRawFd + Sized,
|
W: Write + AsRawFd + Sized,
|
||||||
|
@ -60,6 +61,7 @@ where
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
read: BufReader::new(read_handle),
|
read: BufReader::new(read_handle),
|
||||||
|
terminal: FdTerminal::from(write_handle.as_raw_fd()),
|
||||||
write: write_handle,
|
write: write_handle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -140,7 +142,7 @@ where
|
||||||
}
|
}
|
||||||
terminal.flush()?;
|
terminal.flush()?;
|
||||||
|
|
||||||
let (mut cols, mut _rows) = terminal::size()?;
|
let (mut cols, mut _rows) = self.terminal.size()?;
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
@ -290,7 +292,7 @@ where
|
||||||
}
|
}
|
||||||
terminal.flush()?;
|
terminal.flush()?;
|
||||||
|
|
||||||
let (mut cols, mut _rows) = terminal::size()?;
|
let (mut cols, mut _rows) = self.terminal.size()?;
|
||||||
|
|
||||||
let mut passphrase = String::new();
|
let mut passphrase = String::new();
|
||||||
loop {
|
loop {
|
||||||
|
@ -334,7 +336,7 @@ where
|
||||||
let mut terminal = RawMode::new(&mut terminal)?;
|
let mut terminal = RawMode::new(&mut terminal)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (cols, rows) = terminal::size()?;
|
let (cols, rows) = self.terminal.size()?;
|
||||||
|
|
||||||
terminal
|
terminal
|
||||||
.queue(terminal::Clear(terminal::ClearType::All))?
|
.queue(terminal::Clear(terminal::ClearType::All))?
|
||||||
|
@ -402,8 +404,8 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type DefaultPromptManager = PromptManager<Stdin, Stderr>;
|
pub type DefaultTerminal = Terminal<Stdin, Stderr>;
|
||||||
|
|
||||||
pub fn default_prompt_manager() -> Result<DefaultPromptManager> {
|
pub fn default_terminal() -> Result<DefaultTerminal> {
|
||||||
PromptManager::new(stdin(), stderr())
|
Terminal::new(stdin(), stderr())
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ use std::{
|
||||||
os::fd::AsRawFd,
|
os::fd::AsRawFd,
|
||||||
};
|
};
|
||||||
|
|
||||||
use keyfork_crossterm::terminal;
|
use keyfork_crossterm::terminal::{FdTerminal, TerminalIoctl};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
|
@ -12,16 +12,18 @@ where
|
||||||
W: Write + AsRawFd + Sized,
|
W: Write + AsRawFd + Sized,
|
||||||
{
|
{
|
||||||
write: &'a mut W,
|
write: &'a mut W,
|
||||||
|
terminal: FdTerminal,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: fork crossterm to allow using FD from as_raw_fd()
|
|
||||||
impl<'a, W> RawMode<'a, W>
|
impl<'a, W> RawMode<'a, W>
|
||||||
where
|
where
|
||||||
W: Write + AsRawFd + Sized,
|
W: Write + AsRawFd + Sized,
|
||||||
{
|
{
|
||||||
pub(crate) fn new(write_handle: &'a mut W) -> Result<Self> {
|
pub(crate) fn new(write_handle: &'a mut W) -> Result<Self> {
|
||||||
terminal::enable_raw_mode()?;
|
let mut terminal = FdTerminal::from(write_handle.as_raw_fd());
|
||||||
|
terminal.enable_raw_mode()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
terminal,
|
||||||
write: write_handle,
|
write: write_handle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -72,6 +74,6 @@ where
|
||||||
W: Write + AsRawFd + Sized,
|
W: Write + AsRawFd + Sized,
|
||||||
{
|
{
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
terminal::disable_raw_mode().unwrap();
|
self.terminal.disable_raw_mode().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ use keyfork_mnemonic_util::{Mnemonic, Wordlist};
|
||||||
use keyfork_prompt::{
|
use keyfork_prompt::{
|
||||||
qrencode,
|
qrencode,
|
||||||
validators::{mnemonic::MnemonicSetValidator, Validator},
|
validators::{mnemonic::MnemonicSetValidator, Validator},
|
||||||
Message as PromptMessage, PromptManager,
|
Message as PromptMessage, Terminal,
|
||||||
};
|
};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use sharks::{Share, Sharks};
|
use sharks::{Share, Sharks};
|
||||||
|
@ -43,7 +43,7 @@ pub(crate) const HUNK_OFFSET: usize = 2;
|
||||||
/// The function may panic if it is given payloads generated using a version of Keyfork that is
|
/// The function may panic if it is given payloads generated using a version of Keyfork that is
|
||||||
/// incompatible with the currently running version.
|
/// incompatible with the currently running version.
|
||||||
pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut pm = PromptManager::new(stdin(), stdout())?;
|
let mut pm = Terminal::new(stdin(), stdout())?;
|
||||||
let wordlist = Wordlist::default();
|
let wordlist = Wordlist::default();
|
||||||
|
|
||||||
let mut iter_count = None;
|
let mut iter_count = None;
|
||||||
|
|
|
@ -19,7 +19,7 @@ use keyfork_mnemonic_util::{Mnemonic, MnemonicFromStrError, MnemonicGenerationEr
|
||||||
use keyfork_prompt::{
|
use keyfork_prompt::{
|
||||||
qrencode,
|
qrencode,
|
||||||
validators::{mnemonic::MnemonicSetValidator, Validator},
|
validators::{mnemonic::MnemonicSetValidator, Validator},
|
||||||
Error as PromptError, Message as PromptMessage, PromptManager,
|
Error as PromptError, Message as PromptMessage, Terminal,
|
||||||
};
|
};
|
||||||
use openpgp::{
|
use openpgp::{
|
||||||
armor::{Kind, Writer},
|
armor::{Kind, Writer},
|
||||||
|
@ -409,7 +409,7 @@ pub fn decrypt(
|
||||||
metadata: &EncryptedMessage,
|
metadata: &EncryptedMessage,
|
||||||
encrypted_messages: &[EncryptedMessage],
|
encrypted_messages: &[EncryptedMessage],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut pm = PromptManager::new(stdin(), stdout())?;
|
let mut pm = Terminal::new(stdin(), stdout())?;
|
||||||
let wordlist = Wordlist::default();
|
let wordlist = Wordlist::default();
|
||||||
let validator = MnemonicSetValidator {
|
let validator = MnemonicSetValidator {
|
||||||
word_lengths: [9, 24],
|
word_lengths: [9, 24],
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use keyfork_prompt::{Error as PromptError, DefaultPromptManager, default_prompt_manager};
|
use keyfork_prompt::{Error as PromptError, DefaultTerminal, default_terminal};
|
||||||
|
|
||||||
use super::openpgp::{
|
use super::openpgp::{
|
||||||
self,
|
self,
|
||||||
|
@ -25,7 +25,7 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||||
pub struct Keyring {
|
pub struct Keyring {
|
||||||
full_certs: Vec<Cert>,
|
full_certs: Vec<Cert>,
|
||||||
root: Option<Cert>,
|
root: Option<Cert>,
|
||||||
pm: DefaultPromptManager,
|
pm: DefaultTerminal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Keyring {
|
impl Keyring {
|
||||||
|
@ -33,7 +33,7 @@ impl Keyring {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
full_certs: certs.as_ref().to_vec(),
|
full_certs: certs.as_ref().to_vec(),
|
||||||
root: Default::default(),
|
root: Default::default(),
|
||||||
pm: default_prompt_manager()?,
|
pm: default_terminal()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use keyfork_prompt::{
|
use keyfork_prompt::{
|
||||||
default_prompt_manager,
|
default_terminal,
|
||||||
validators::{PinValidator, Validator},
|
validators::{PinValidator, Validator},
|
||||||
DefaultPromptManager, Error as PromptError, Message,
|
DefaultTerminal, Error as PromptError, Message,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::openpgp::{
|
use super::openpgp::{
|
||||||
|
@ -69,7 +69,7 @@ fn format_name(input: impl AsRef<str>) -> String {
|
||||||
pub struct SmartcardManager {
|
pub struct SmartcardManager {
|
||||||
current_card: Option<Card<Open>>,
|
current_card: Option<Card<Open>>,
|
||||||
root: Option<Cert>,
|
root: Option<Cert>,
|
||||||
pm: DefaultPromptManager,
|
pm: DefaultTerminal,
|
||||||
pin_cache: HashMap<Fingerprint, String>,
|
pin_cache: HashMap<Fingerprint, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ impl SmartcardManager {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
current_card: None,
|
current_card: None,
|
||||||
root: None,
|
root: None,
|
||||||
pm: default_prompt_manager()?,
|
pm: default_terminal()?,
|
||||||
pin_cache: Default::default(),
|
pin_cache: Default::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -99,9 +99,8 @@ impl SmartcardManager {
|
||||||
if let Some(c) = PcscBackend::cards(None)?.next().transpose()? {
|
if let Some(c) = PcscBackend::cards(None)?.next().transpose()? {
|
||||||
break c;
|
break c;
|
||||||
}
|
}
|
||||||
self.pm.prompt_message(&Message::Text(
|
self.pm
|
||||||
"No smart card was found".to_string(),
|
.prompt_message(&Message::Text("No smart card was found".to_string()))?;
|
||||||
))?;
|
|
||||||
};
|
};
|
||||||
let mut card = Card::<Open>::new(card_backend).map_err(Error::OpenSmartCard)?;
|
let mut card = Card::<Open>::new(card_backend).map_err(Error::OpenSmartCard)?;
|
||||||
let transaction = card.transaction().map_err(Error::Transaction)?;
|
let transaction = card.transaction().map_err(Error::Transaction)?;
|
||||||
|
|
|
@ -12,7 +12,7 @@ use keyfork_derive_util::{
|
||||||
};
|
};
|
||||||
use keyfork_prompt::{
|
use keyfork_prompt::{
|
||||||
validators::{PinValidator, Validator},
|
validators::{PinValidator, Validator},
|
||||||
Message, PromptManager,
|
Message, Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
@ -102,7 +102,7 @@ fn factory_reset_current_card(
|
||||||
|
|
||||||
fn generate_shard_secret(threshold: u8, max: u8, keys_per_shard: u8) -> Result<()> {
|
fn generate_shard_secret(threshold: u8, max: u8, keys_per_shard: u8) -> Result<()> {
|
||||||
let seed = keyfork_entropy::generate_entropy_of_size(256 / 8)?;
|
let seed = keyfork_entropy::generate_entropy_of_size(256 / 8)?;
|
||||||
let mut pm = PromptManager::new(std::io::stdin(), std::io::stderr())?;
|
let mut pm = Terminal::new(std::io::stdin(), std::io::stderr())?;
|
||||||
let mut certs = vec![];
|
let mut certs = vec![];
|
||||||
let mut seen_cards: HashSet<String> = HashSet::new();
|
let mut seen_cards: HashSet<String> = HashSet::new();
|
||||||
let stdout = std::io::stdout();
|
let stdout = std::io::stdout();
|
||||||
|
|
Loading…
Reference in New Issue