diff --git a/Cargo.lock b/Cargo.lock index 17b51df..9868c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,7 @@ dependencies = [ name = "bluetui" version = "0.7.2" dependencies = [ + "anyhow", "async-channel", "bluer", "clap", @@ -185,6 +186,7 @@ dependencies = [ "serde", "tokio", "toml", + "tui-big-text", "tui-input", ] @@ -509,6 +511,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.108", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -684,6 +717,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + [[package]] name = "futures" version = "0.3.31" @@ -1995,6 +2034,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +[[package]] +name = "tui-big-text" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7876e22ef305de349de2ef40197455a84980f1597277ce7fb2008989b19c572" +dependencies = [ + "derive_builder", + "font8x8", + "itertools 0.14.0", + "ratatui 0.29.0", +] + [[package]] name = "tui-input" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index 35357be..7244773 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ toml = "0.9" serde = { version = "1", features = ["derive"] } clap = { version = "4", features = ["derive", "cargo"] } tui-input = "0.12" +tui-big-text = "0.7" +anyhow = "1" [profile.release] strip = true diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..f650723 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,187 @@ +use async_channel::{Receiver, Sender}; +use tokio::sync::mpsc::UnboundedSender; + +use bluer::agent::{ + DisplayPasskey, DisplayPinCode, ReqError, ReqResult, RequestConfirmation, RequestPasskey, + RequestPinCode, +}; + +use crate::{ + event::Event, + requests::{ + confirmation::Confirmation, enter_passkey::EnterPasskey, enter_pin_code::EnterPinCode, + }, +}; + +#[derive(Debug, Clone)] +pub struct AuthAgent { + pub event_sender: UnboundedSender, + pub tx_cancel: Sender<()>, + pub rx_cancel: Receiver<()>, + pub tx_pin_code: Sender, + pub rx_pin_code: Receiver, + pub tx_display_pin_code: Sender<()>, + pub rx_display_pin_code: Receiver<()>, + pub tx_display_passkey: Sender<()>, + pub rx_display_passkey: Receiver<()>, + pub tx_passkey: Sender, + pub rx_passkey: Receiver, + pub tx_request_confirmation: Sender, + pub rx_request_confirmation: Receiver, +} + +impl AuthAgent { + pub fn new(sender: UnboundedSender) -> Self { + let (tx_passkey, rx_passkey) = async_channel::unbounded(); + let (tx_display_passkey, rx_display_passkey) = async_channel::unbounded(); + + let (tx_pin_code, rx_pin_code) = async_channel::unbounded(); + let (tx_display_pin_code, rx_display_pin_code) = async_channel::unbounded(); + + let (tx_request_confirmation, rx_request_confirmation) = async_channel::unbounded(); + let (tx_cancel, rx_cancel) = async_channel::unbounded(); + + Self { + event_sender: sender, + tx_cancel, + rx_cancel, + tx_pin_code, + rx_pin_code, + tx_display_pin_code, + rx_display_pin_code, + tx_display_passkey, + rx_display_passkey, + tx_passkey, + rx_passkey, + tx_request_confirmation, + rx_request_confirmation, + } + } +} + +pub async fn request_confirmation(request: RequestConfirmation, agent: AuthAgent) -> ReqResult<()> { + agent + .event_sender + .send(Event::RequestConfirmation(Confirmation::new( + request.adapter, + request.device, + request.passkey, + ))) + .unwrap(); + + tokio::select! { + r = agent.rx_request_confirmation.recv() => { + match r { + Ok(v) => { + match v { + true => Ok(()), + false => Err(ReqError::Rejected) + } + } + Err(_) => { + Err(ReqError::Canceled) + } + } + + } + + _ = agent.rx_cancel.recv() => { + Err(ReqError::Canceled) + } + + } +} + +pub async fn request_pin_code(request: RequestPinCode, agent: AuthAgent) -> ReqResult { + agent + .event_sender + .send(Event::RequestEnterPinCode(EnterPinCode::new( + request.adapter, + request.device, + ))) + .unwrap(); + + tokio::select! { + r = agent.rx_pin_code.recv() => { + match r { + Ok(v) => Ok(v), + Err(_) => Err(ReqError::Canceled) + } + + } + + _ = agent.rx_cancel.recv() => { + Err(ReqError::Canceled) + } + + } +} + +pub async fn request_passkey(request: RequestPasskey, agent: AuthAgent) -> ReqResult { + agent + .event_sender + .send(Event::RequestEnterPasskey(EnterPasskey::new( + request.adapter, + request.device, + ))) + .unwrap(); + + tokio::select! { + r = agent.rx_passkey.recv() => { + match r { + Ok(v) => Ok(v), + Err(_) => Err(ReqError::Canceled) + } + } + + _ = agent.rx_cancel.recv() => { + Err(ReqError::Canceled) + } + + } +} + +pub async fn display_pin_code(request: DisplayPinCode, agent: AuthAgent) -> ReqResult<()> { + agent + .event_sender + .send(Event::RequestDisplayPinCode( + crate::requests::display_pin_code::DisplayPinCode::new( + request.adapter, + request.device, + request.pincode, + ), + )) + .unwrap(); + + tokio::select! { + _ = agent.rx_display_pin_code.recv() => { + Ok(()) + } + + _ = agent.rx_cancel.recv() => { + Err(ReqError::Canceled) + } + } +} + +pub async fn display_passkey(request: DisplayPasskey, agent: AuthAgent) -> ReqResult<()> { + let _ = agent.event_sender.send(Event::RequestDisplayPasskey( + crate::requests::display_passkey::DisplayPasskey::new( + request.adapter, + request.device, + request.passkey, + request.entered, + ), + )); + + tokio::select! { + _ = agent.rx_display_passkey.recv() => { + Ok(()) + } + + _ = agent.rx_cancel.recv() => { + let _ = agent.event_sender.send(Event::DisplayPasskeyCanceled); + Err(ReqError::Canceled) + } + } +} diff --git a/src/app.rs b/src/app.rs index 5972ed7..0bc5a16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,10 @@ +use crate::{ + agent::{ + display_passkey, display_pin_code, request_confirmation, request_passkey, request_pin_code, + }, + event::Event, + help::Help, +}; use bluer::{ Session, agent::{Agent, AgentHandle}, @@ -15,27 +22,31 @@ use ratatui::{ }; use tui_input::Input; +use tokio::sync::mpsc::UnboundedSender; + use crate::{ - bluetooth::{Controller, request_confirmation}, + agent::AuthAgent, + bluetooth::Controller, config::{Config, Width}, - confirmation::PairingConfirmation, notification::Notification, + requests::Requests, spinner::Spinner, }; -use std::{ - error, - sync::{Arc, atomic::Ordering}, -}; +use std::sync::{Arc, atomic::Ordering}; -pub type AppResult = std::result::Result>; +pub type AppResult = anyhow::Result; #[derive(Debug, Clone, Copy, PartialEq)] pub enum FocusedBlock { Adapter, PairedDevices, NewDevices, - PassKeyConfirmation, SetDeviceAliasBox, + RequestConfirmation, + EnterPinCode, + EnterPasskey, + DisplayPinCode, + DisplayPasskey, } #[derive(Debug)] @@ -50,33 +61,39 @@ pub struct App { pub paired_devices_state: TableState, pub new_devices_state: TableState, pub focused_block: FocusedBlock, - pub pairing_confirmation: PairingConfirmation, pub new_alias: Input, pub config: Arc, + pub requests: Requests, + pub auth_agent: AuthAgent, } impl App { - pub async fn new(config: Arc) -> AppResult { + pub async fn new(config: Arc, sender: UnboundedSender) -> AppResult { let session = Arc::new(bluer::Session::new().await?); - let pairing_confirmation = PairingConfirmation::new(); - - let user_confirmation_receiver = pairing_confirmation.user_confirmation_receiver.clone(); - - let confirmation_message_sender = pairing_confirmation.confirmation_message_sender.clone(); - - let confirmation_display = pairing_confirmation.display.clone(); + let auth_agent = AuthAgent::new(sender.clone()); let agent = Agent { request_default: false, - request_confirmation: Some(Box::new(move |req| { - request_confirmation( - req, - confirmation_display.clone(), - user_confirmation_receiver.clone(), - confirmation_message_sender.clone(), - ) - .boxed() + request_confirmation: Some(Box::new({ + let auth_agent = auth_agent.clone(); + move |request| request_confirmation(request, auth_agent.clone()).boxed() + })), + request_pin_code: Some(Box::new({ + let auth_agent = auth_agent.clone(); + move |request| request_pin_code(request, auth_agent.clone()).boxed() + })), + request_passkey: Some(Box::new({ + let auth_agent = auth_agent.clone(); + move |request| request_passkey(request, auth_agent.clone()).boxed() + })), + display_pin_code: Some(Box::new({ + let auth_agent = auth_agent.clone(); + move |request| display_pin_code(request, auth_agent.clone()).boxed() + })), + display_passkey: Some(Box::new({ + let auth_agent = auth_agent.clone(); + move |request| display_passkey(request, auth_agent.clone()).boxed() })), ..Default::default() }; @@ -102,9 +119,10 @@ impl App { paired_devices_state: TableState::default(), new_devices_state: TableState::default(), focused_block: FocusedBlock::PairedDevices, - pairing_confirmation, new_alias: Input::default(), config, + requests: Requests::default(), + auth_agent, }) } @@ -675,126 +693,45 @@ impl App { } // Help - let help = match self.focused_block { - FocusedBlock::PairedDevices => { - if self.area(frame).width > 103 { - vec![Line::from(vec![ - Span::from("k,").bold(), - Span::from(" Up"), - Span::from(" | "), - Span::from("j,").bold(), - Span::from(" Down"), - Span::from(" | "), - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), - Span::from(self.config.paired_device.unpair.to_string()).bold(), - Span::from(" Unpair"), - Span::from(" | "), - Span::from("󱁐 or ↵ ").bold(), - Span::from(" Dis/Connect"), - Span::from(" | "), - Span::from(self.config.paired_device.toggle_trust.to_string()).bold(), - Span::from(" Un/Trust"), - Span::from(" | "), - Span::from(self.config.paired_device.rename.to_string()).bold(), - Span::from(" Rename"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ])] - } else { - vec![ - Line::from(vec![ - Span::from("󱁐 or ↵ ").bold(), - Span::from(" Dis/Connect"), - Span::from(" | "), - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), - Span::from(self.config.paired_device.unpair.to_string()).bold(), - Span::from(" Unpair"), - ]), - Line::from(vec![ - Span::from(self.config.paired_device.toggle_trust.to_string()) - .bold(), - Span::from(" Un/Trust"), - Span::from(" | "), - Span::from(self.config.paired_device.rename.to_string()).bold(), - Span::from(" Rename"), - Span::from(" | "), - Span::from("k,").bold(), - Span::from(" Up"), - Span::from(" | "), - Span::from("j,").bold(), - Span::from(" Down"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ]), - ] - } - } - FocusedBlock::NewDevices => vec![Line::from(vec![ - Span::from("k,").bold(), - Span::from(" Up"), - Span::from(" | "), - Span::from("j,").bold(), - Span::from(" Down"), - Span::from(" | "), - Span::from("󱁐 or ↵ ").bold(), - Span::from(" Pair"), - Span::from(" | "), - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ])], - FocusedBlock::Adapter => vec![Line::from(vec![ - Span::from("s").bold(), - Span::from(" Scan on/off"), - Span::from(" | "), - Span::from(self.config.adapter.toggle_pairing.to_string()).bold(), - Span::from(" Pairing on/off"), - Span::from(" | "), - Span::from(self.config.adapter.toggle_power.to_string()).bold(), - Span::from(" Power on/off"), - Span::from(" | "), - Span::from(self.config.adapter.toggle_discovery.to_string()).bold(), - Span::from(" Discovery on/off"), - Span::from(" | "), - Span::from("⇄").bold(), - Span::from(" Nav"), - ])], - FocusedBlock::SetDeviceAliasBox => { - vec![Line::from(vec![ - Span::from("󱊷 ").bold(), - Span::from(" Discard"), - ])] - } - FocusedBlock::PassKeyConfirmation => { - vec![Line::from(vec![ - Span::from("󱊷 ").bold(), - Span::from(" Discard"), - ])] - } - }; - - let help = Paragraph::new(help).centered().blue(); - frame.render_widget(help, help_block); + Help::render( + frame, + self.area(frame), + self.focused_block, + help_block, + self.config.clone(), + ); // Pairing confirmation - if self.pairing_confirmation.display.load(Ordering::Relaxed) { - self.focused_block = FocusedBlock::PassKeyConfirmation; - self.pairing_confirmation.render(frame, self.area(frame)); - return; - } - // Set alias popup if self.focused_block == FocusedBlock::SetDeviceAliasBox { - self.render_set_alias(frame) + self.render_set_alias(frame); + } + + // Request Confirmation + if let Some(req) = &self.requests.confirmation { + req.render(frame); + } + + // Request to enter pin code + + if let Some(req) = &self.requests.enter_pin_code { + req.render(frame); + } + + // Request passkey + if let Some(req) = &self.requests.enter_passkey { + req.render(frame); + } + + // Display Pin Code + if let Some(req) = &self.requests.display_pin_code { + req.render(frame); + } + + // Display Passkey + if let Some(req) = &self.requests.display_passkey { + req.render(frame); } } } @@ -811,12 +748,6 @@ impl App { } pub async fn refresh(&mut self) -> AppResult<()> { - if !self.pairing_confirmation.display.load(Ordering::Relaxed) - & self.pairing_confirmation.message.is_some() - { - self.pairing_confirmation.message = None; - } - let refreshed_controllers = Controller::get_all(self.session.clone()).await?; let names = { diff --git a/src/bluetooth.rs b/src/bluetooth.rs index 5bc40d6..e564662 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -1,15 +1,9 @@ -use std::sync::{Arc, atomic::AtomicBool, mpsc::Sender}; +use std::sync::{Arc, atomic::AtomicBool}; -use async_channel::Receiver; -use bluer::{ - Adapter, Address, Session, - agent::{ReqError, ReqResult, RequestConfirmation}, -}; +use bluer::{Adapter, Address, Session}; use bluer::Device as BTDevice; -use tokio::sync::oneshot; - use crate::app::AppResult; #[derive(Debug, Clone)] @@ -149,38 +143,3 @@ fn is_mac_addr(s: &str) -> bool { let s: String = s.chars().filter(|&c| c != '-').collect(); s.len() == 12 && s.chars().all(|c| c.is_ascii_hexdigit()) } - -pub async fn request_confirmation( - req: RequestConfirmation, - display_confirmation_popup: Arc, - rx: Receiver, - sender: Sender, -) -> ReqResult<()> { - display_confirmation_popup.store(true, std::sync::atomic::Ordering::Relaxed); - - sender - .send(format!( - "Is passkey \"{:06}\" correct for device {} on {}?", - req.passkey, &req.device, &req.adapter - )) - .unwrap(); - - // request cancel - let (_done_tx, done_rx) = oneshot::channel::<()>(); - tokio::spawn(async move { - if done_rx.await.is_err() { - display_confirmation_popup.store(false, std::sync::atomic::Ordering::Relaxed); - } - }); - match rx.recv().await { - Ok(v) => { - // false: reject the confirmation - if !v { - return Err(ReqError::Rejected); - } - } - Err(_) => return Err(ReqError::Rejected), - } - - Ok(()) -} diff --git a/src/confirmation.rs b/src/confirmation.rs deleted file mode 100644 index 09b9621..0000000 --- a/src/confirmation.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::sync::mpsc::channel; -use std::sync::{Arc, atomic::AtomicBool}; - -use ratatui::Frame; -use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Style}; -use ratatui::text::{Span, Text}; -use ratatui::widgets::{Block, BorderType, Borders, Clear}; - -#[derive(Debug)] -pub struct PairingConfirmation { - pub confirmed: bool, - pub display: Arc, - pub message: Option, - pub user_confirmation_sender: async_channel::Sender, - pub user_confirmation_receiver: async_channel::Receiver, - pub confirmation_message_sender: std::sync::mpsc::Sender, - pub confirmation_message_receiver: std::sync::mpsc::Receiver, -} - -impl Default for PairingConfirmation { - fn default() -> Self { - Self::new() - } -} - -impl PairingConfirmation { - pub fn new() -> Self { - let (user_confirmation_sender, user_confirmation_receiver) = async_channel::unbounded(); - - let (confirmation_message_sender, confirmation_message_receiver) = channel::(); - Self { - confirmed: true, - display: Arc::new(AtomicBool::new(false)), - message: None, - user_confirmation_sender, - user_confirmation_receiver, - confirmation_message_sender, - confirmation_message_receiver, - } - } - - pub fn render(&mut self, frame: &mut Frame, area: Rect) { - if self.message.is_none() { - let msg = self.confirmation_message_receiver.recv().unwrap(); - self.message = Some(msg); - } - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Fill(1), - Constraint::Length(5), - Constraint::Fill(1), - ]) - .split(area); - - let block = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Fill(1), - Constraint::Max(80), - Constraint::Fill(1), - ]) - .split(layout[1])[1]; - - let (text_area, choices_area) = { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ] - .as_ref(), - ) - .split(block); - - (chunks[1], chunks[3]) - }; - - let (yes_area, no_area) = { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(30), - Constraint::Length(5), - Constraint::Min(1), - Constraint::Length(5), - Constraint::Percentage(30), - ] - .as_ref(), - ) - .split(choices_area); - - (chunks[1], chunks[3]) - }; - - let text = Text::from(self.message.clone().unwrap_or_default()) - .style(Style::default().fg(Color::White)); - - let (yes, no) = { - if self.confirmed { - let no = Span::from("[No]").style(Style::default()); - let yes = Span::from("[Yes]").style(Style::default().bg(Color::DarkGray)); - (yes, no) - } else { - let no = Span::from("[No]").style(Style::default().bg(Color::DarkGray)); - let yes = Span::from("[Yes]").style(Style::default()); - (yes, no) - } - }; - - frame.render_widget(Clear, block); - - frame.render_widget( - Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Thick) - .border_style(Style::default().fg(Color::Green)), - block, - ); - frame.render_widget(text.alignment(Alignment::Center), text_area); - frame.render_widget(yes, yes_area); - frame.render_widget(no, no_area); - } -} diff --git a/src/event.rs b/src/event.rs index fc10fa2..6faf211 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,10 +1,20 @@ +use anyhow::anyhow; +use bluer::Address; use std::time::Duration; use crossterm::event::{Event as CrosstermEvent, KeyEvent, MouseEvent}; use futures::{FutureExt, StreamExt}; use tokio::sync::mpsc; -use crate::{app::AppResult, notification::Notification}; +use crate::{ + app::AppResult, + notification::Notification, + requests::{ + confirmation::Confirmation, display_passkey::DisplayPasskey, + display_pin_code::DisplayPinCode, enter_passkey::EnterPasskey, + enter_pin_code::EnterPinCode, + }, +}; #[derive(Clone, Debug)] pub enum Event { @@ -13,7 +23,18 @@ pub enum Event { Mouse(MouseEvent), Resize(u16, u16), Notification(Notification), - NewPairedDevice, + NewPairedDevice(Address), + FailedPairing(Address), + RequestConfirmation(Confirmation), + ConfirmationSubmitted, + RequestEnterPinCode(EnterPinCode), + PinCodeSumitted, + RequestEnterPasskey(EnterPasskey), + RequestDisplayPinCode(DisplayPinCode), + DisplayPinCodeSeen, + PasskeySumitted, + RequestDisplayPasskey(DisplayPasskey), + DisplayPasskeyCanceled, } #[allow(dead_code)] @@ -74,6 +95,6 @@ impl EventHandler { self.receiver .recv() .await - .ok_or(Box::new(std::io::Error::other("This is an IO error"))) + .ok_or(anyhow!("This is an IO error")) } } diff --git a/src/handler.rs b/src/handler.rs index b81f43c..fb71e72 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -100,7 +100,7 @@ async fn pair(app: &mut App, sender: UnboundedSender) { sender.clone(), ); - let _ = sender.send(Event::NewPairedDevice); + let _ = sender.send(Event::NewPairedDevice(device.address())); match device.set_trusted(true).await { Ok(_) => { let _ = Notification::send( @@ -140,6 +140,7 @@ async fn pair(app: &mut App, sender: UnboundedSender) { NotificationLevel::Error, sender.clone(), ); + let _ = sender.send(Event::FailedPairing(device.address())); } } }); @@ -205,6 +206,66 @@ pub async fn handle_key_events( .handle_event(&crossterm::event::Event::Key(key_event)); } }, + FocusedBlock::RequestConfirmation => match key_event.code { + KeyCode::Tab => { + if let Some(confirmation) = &mut app.requests.confirmation { + confirmation.toggle_select(); + } + } + KeyCode::Esc => { + if let Some(confirmation) = &mut app.requests.confirmation { + confirmation.cancel(&app.auth_agent).await?; + } + } + KeyCode::Enter => { + if let Some(confirmation) = &mut app.requests.confirmation { + confirmation.submit(&app.auth_agent).await?; + } + } + + _ => {} + }, + FocusedBlock::EnterPinCode => { + if let Some(req) = &mut app.requests.enter_pin_code { + match key_event.code { + KeyCode::Esc => { + req.cancel(&app.auth_agent).await?; + } + + _ => { + req.handle_key_events(key_event, &app.auth_agent).await?; + } + } + } + } + FocusedBlock::EnterPasskey => { + if let Some(req) = &mut app.requests.enter_passkey { + match key_event.code { + KeyCode::Esc => { + req.cancel(&app.auth_agent).await?; + } + + _ => { + req.handle_key_events(key_event, &app.auth_agent).await?; + } + } + } + } + FocusedBlock::DisplayPinCode => { + if let Some(req) = &mut app.requests.display_pin_code + && let KeyCode::Esc | KeyCode::Enter = key_event.code + { + req.submit(&app.auth_agent).await?; + } + } + FocusedBlock::DisplayPasskey => { + if let Some(req) = &mut app.requests.display_passkey + && key_event.code == KeyCode::Esc + { + req.cancel(&app.auth_agent).await?; + } + } + _ => { match key_event.code { // Exit the app @@ -751,33 +812,6 @@ pub async fn handle_key_events( } } - FocusedBlock::PassKeyConfirmation => match key_event.code { - KeyCode::Left | KeyCode::Char('h') => { - if !app.pairing_confirmation.confirmed { - app.pairing_confirmation.confirmed = true; - } - } - KeyCode::Right | KeyCode::Char('l') => { - if app.pairing_confirmation.confirmed { - app.pairing_confirmation.confirmed = false; - } - } - - KeyCode::Enter => { - app.pairing_confirmation - .user_confirmation_sender - .send(app.pairing_confirmation.confirmed) - .await?; - app.pairing_confirmation - .display - .store(false, Ordering::Relaxed); - app.focused_block = FocusedBlock::PairedDevices; - app.pairing_confirmation.message = None; - } - - _ => {} - }, - _ => {} } } diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..a7c859f --- /dev/null +++ b/src/help.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; + +use ratatui::{ + Frame, + layout::Rect, + style::Stylize, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::{app::FocusedBlock, config::Config}; + +pub struct Help; + +impl Help { + pub fn render( + frame: &mut Frame, + area: Rect, + focused_block: FocusedBlock, + rendering_block: Rect, + config: Arc, + ) { + let help = match focused_block { + FocusedBlock::PairedDevices => { + if area.width > 103 { + vec![Line::from(vec![ + Span::from("k,").bold(), + Span::from(" Up"), + Span::from(" | "), + Span::from("j,").bold(), + Span::from(" Down"), + Span::from(" | "), + Span::from("s").bold(), + Span::from(" Scan on/off"), + Span::from(" | "), + Span::from(config.paired_device.unpair.to_string()).bold(), + Span::from(" Unpair"), + Span::from(" | "), + Span::from("󱁐 or ↵ ").bold(), + Span::from(" Dis/Connect"), + Span::from(" | "), + Span::from(config.paired_device.toggle_trust.to_string()).bold(), + Span::from(" Un/Trust"), + Span::from(" | "), + Span::from(config.paired_device.rename.to_string()).bold(), + Span::from(" Rename"), + Span::from(" | "), + Span::from("⇄").bold(), + Span::from(" Nav"), + ])] + } else { + vec![ + Line::from(vec![ + Span::from("󱁐 or ↵ ").bold(), + Span::from(" Dis/Connect"), + Span::from(" | "), + Span::from("s").bold(), + Span::from(" Scan on/off"), + Span::from(" | "), + Span::from(config.paired_device.unpair.to_string()).bold(), + Span::from(" Unpair"), + ]), + Line::from(vec![ + Span::from(config.paired_device.toggle_trust.to_string()).bold(), + Span::from(" Un/Trust"), + Span::from(" | "), + Span::from(config.paired_device.rename.to_string()).bold(), + Span::from(" Rename"), + Span::from(" | "), + Span::from("k,").bold(), + Span::from(" Up"), + Span::from(" | "), + Span::from("j,").bold(), + Span::from(" Down"), + Span::from(" | "), + Span::from("⇄").bold(), + Span::from(" Nav"), + ]), + ] + } + } + FocusedBlock::NewDevices => vec![Line::from(vec![ + Span::from("k,").bold(), + Span::from(" Up"), + Span::from(" | "), + Span::from("j,").bold(), + Span::from(" Down"), + Span::from(" | "), + Span::from("󱁐 or ↵ ").bold(), + Span::from(" Pair"), + Span::from(" | "), + Span::from("s").bold(), + Span::from(" Scan on/off"), + Span::from(" | "), + Span::from("⇄").bold(), + Span::from(" Nav"), + ])], + FocusedBlock::Adapter => vec![Line::from(vec![ + Span::from("s").bold(), + Span::from(" Scan on/off"), + Span::from(" | "), + Span::from(config.adapter.toggle_pairing.to_string()).bold(), + Span::from(" Pairing on/off"), + Span::from(" | "), + Span::from(config.adapter.toggle_power.to_string()).bold(), + Span::from(" Power on/off"), + Span::from(" | "), + Span::from(config.adapter.toggle_discovery.to_string()).bold(), + Span::from(" Discovery on/off"), + Span::from(" | "), + Span::from("⇄").bold(), + Span::from(" Nav"), + ])], + FocusedBlock::SetDeviceAliasBox => { + vec![Line::from(vec![ + Span::from("󱊷 ").bold(), + Span::from(" Discard"), + ])] + } + FocusedBlock::RequestConfirmation => { + vec![Line::from(vec![ + Span::from("↵ ").bold(), + Span::from(" Ok"), + Span::from(" | "), + Span::from("󱊷 ").bold(), + Span::from(" Discard"), + Span::from(" | "), + Span::from("⇄").bold(), + Span::from(" Nav"), + ])] + } + FocusedBlock::EnterPinCode | FocusedBlock::EnterPasskey => { + vec![Line::from(vec![ + Span::from("󱊷 ").bold(), + Span::from(" Discard"), + Span::from(" | "), + Span::from("⇄").bold(), + Span::from(" Nav"), + Span::from(" | "), + Span::from("↵ ").bold(), + Span::from(" Submit"), + ])] + } + FocusedBlock::DisplayPinCode => { + vec![Line::from(vec![ + Span::from(" 󱊷 or ↵ ").bold(), + Span::from(" Ok"), + ])] + } + FocusedBlock::DisplayPasskey => { + vec![Line::from(vec![ + Span::from(" 󱊷 ").bold(), + Span::from(" Discard"), + ])] + } + }; + let help = Paragraph::new(help).centered().blue(); + frame.render_widget(help, rendering_block); + } +} diff --git a/src/lib.rs b/src/lib.rs index 40740e3..25ec8d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,13 @@ +pub mod agent; pub mod app; pub mod bluetooth; pub mod cli; pub mod config; -pub mod confirmation; pub mod event; pub mod handler; +mod help; pub mod notification; +pub mod requests; pub mod rfkill; pub mod spinner; pub mod tui; diff --git a/src/main.rs b/src/main.rs index 3740862..00d8250 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,13 +29,15 @@ async fn main() -> AppResult<()> { let config = Arc::new(Config::new(config_file_path)); - let mut app = App::new(config.clone()).await?; let backend = CrosstermBackend::new(io::stdout()); let terminal = Terminal::new(backend)?; - let events = EventHandler::new(2_000); + let events = EventHandler::new(1_000); let mut tui = Tui::new(terminal, events); + tui.init()?; + let mut app = App::new(config.clone(), tui.events.sender.clone()).await?; + while app.running { tui.draw(&mut app)?; match tui.events.next().await? { @@ -52,10 +54,76 @@ async fn main() -> AppResult<()> { Event::Notification(notification) => { app.notifications.push(notification); } - Event::NewPairedDevice => { + Event::NewPairedDevice(address) => { + if let Some(req) = &app.requests.display_passkey + && req.device == address + { + app.requests.display_passkey = None; + } + + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; + } + Event::RequestConfirmation(request) => { + app.requests.init_confirmation(request); + app.focused_block = bluetui::app::FocusedBlock::RequestConfirmation; + } + + Event::ConfirmationSubmitted => { + app.requests.confirmation = None; + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; + } + + Event::RequestEnterPinCode(request) => { + app.requests.init_enter_pin_code(request); + app.focused_block = bluetui::app::FocusedBlock::EnterPinCode; + } + + Event::PinCodeSumitted => { + app.requests.enter_pin_code = None; + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; + } + + Event::RequestEnterPasskey(request) => { + app.requests.init_enter_passkey(request); + app.focused_block = bluetui::app::FocusedBlock::EnterPasskey; + } + + Event::PasskeySumitted => { + app.requests.enter_passkey = None; + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; + } + + Event::RequestDisplayPinCode(request) => { + app.requests.init_display_pin_code(request); + app.focused_block = bluetui::app::FocusedBlock::DisplayPinCode; + } + + Event::DisplayPinCodeSeen => { + app.requests.display_pin_code = None; + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; + } + + Event::RequestDisplayPasskey(request) => { + app.requests.init_display_passkey(request); + app.focused_block = bluetui::app::FocusedBlock::DisplayPasskey; + } + + Event::DisplayPasskeyCanceled => { + app.requests.display_passkey = None; + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; + } + + Event::FailedPairing(address) => { + if let Some(req) = &app.requests.display_passkey + && req.device == address + { + app.requests.display_passkey = None; + } + app.focused_block = bluetui::app::FocusedBlock::PairedDevices; } - _ => {} + + Event::Mouse(_) | Event::Resize(_, _) => {} } } diff --git a/src/notification.rs b/src/notification.rs index 7cd9824..2fec818 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -64,7 +64,7 @@ impl Notification { let notif = Notification { message, level, - ttl: 1, + ttl: 2, }; sender.send(Event::Notification(notif))?; diff --git a/src/requests.rs b/src/requests.rs new file mode 100644 index 0000000..f5b7229 --- /dev/null +++ b/src/requests.rs @@ -0,0 +1,46 @@ +use crate::requests::{ + confirmation::Confirmation, display_passkey::DisplayPasskey, display_pin_code::DisplayPinCode, + enter_passkey::EnterPasskey, enter_pin_code::EnterPinCode, +}; + +pub mod confirmation; +pub mod display_passkey; +pub mod display_pin_code; +pub mod enter_passkey; +pub mod enter_pin_code; + +#[derive(Debug, Default)] +pub struct Requests { + pub confirmation: Option, + pub enter_pin_code: Option, + pub enter_passkey: Option, + pub display_pin_code: Option, + pub display_passkey: Option, +} + +impl Requests { + pub fn init_confirmation(&mut self, req: Confirmation) { + self.confirmation = Some(req); + } + pub fn init_enter_pin_code(&mut self, req: EnterPinCode) { + self.enter_pin_code = Some(req); + } + pub fn init_enter_passkey(&mut self, req: EnterPasskey) { + self.enter_passkey = Some(req); + } + pub fn init_display_pin_code(&mut self, req: DisplayPinCode) { + self.display_pin_code = Some(req); + } + pub fn init_display_passkey(&mut self, req: DisplayPasskey) { + self.display_passkey = Some(req); + } +} + +fn pad_string(input: &str, length: usize) -> String { + let current_length = input.chars().count(); + if current_length >= length { + input.to_string() + } else { + format!("{: Self { + Self { + adapter, + device, + passkey, + confirmed: true, + } + } + + pub async fn submit(&mut self, agent: &AuthAgent) -> AppResult<()> { + agent.tx_request_confirmation.send(self.confirmed).await?; + agent + .event_sender + .send(crate::event::Event::ConfirmationSubmitted)?; + Ok(()) + } + + pub async fn cancel(&mut self, agent: &AuthAgent) -> AppResult<()> { + agent.tx_cancel.send(()).await?; + agent + .event_sender + .send(crate::event::Event::ConfirmationSubmitted)?; + Ok(()) + } + + pub fn toggle_select(&mut self) { + self.confirmed = !self.confirmed; + } + + pub fn render(&self, frame: &mut Frame) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(5), + Constraint::Fill(1), + ]) + .split(frame.area()); + + let block = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Max(80), + Constraint::Fill(1), + ]) + .split(layout[1])[1]; + + let (message_area, choices_area) = { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(block); + + (chunks[1], chunks[3]) + }; + + let message = Text::from(format!( + "Is Passkey {:06} correct for the device {} ?", + self.passkey, self.device, + )) + .centered(); + + let choice = { + if self.confirmed { + Line::from(vec![ + Span::from("[No]").style(Style::default()), + Span::from(" "), + Span::from("[Yes]").style(Style::default().bg(Color::DarkGray)), + ]) + } else { + Line::from(vec![ + Span::from("[No]").style(Style::default().bg(Color::DarkGray)), + Span::from(" "), + Span::from("[Yes]").style(Style::default()), + ]) + } + }; + + frame.render_widget(Clear, block); + + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(Color::Green)), + block, + ); + frame.render_widget(message, message_area); + frame.render_widget(choice.centered(), choices_area); + } +} diff --git a/src/requests/display_passkey.rs b/src/requests/display_passkey.rs new file mode 100644 index 0000000..171f12b --- /dev/null +++ b/src/requests/display_passkey.rs @@ -0,0 +1,102 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Margin}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, +}; + +use bluer::Address; + +use crate::{agent::AuthAgent, app::AppResult}; + +#[derive(Debug, Clone)] +pub struct DisplayPasskey { + pub adapter: String, + pub device: Address, + pub passkey: u32, + pub entered: u16, +} + +impl DisplayPasskey { + pub fn new(adapter: String, device: Address, passkey: u32, entered: u16) -> Self { + Self { + adapter, + device, + passkey, + entered, + } + } + + pub async fn cancel(&mut self, agent: &AuthAgent) -> AppResult<()> { + agent.tx_cancel.send(()).await?; + agent + .event_sender + .send(crate::event::Event::DisplayPasskeyCanceled)?; + Ok(()) + } + + pub fn render(&self, frame: &mut Frame) { + let block = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(12), + Constraint::Fill(1), + ]) + .margin(2) + .split(frame.area())[1]; + + let block = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Max(70), + Constraint::Fill(1), + ]) + .margin(1) + .split(block)[1]; + + let message = vec![ + Line::from(format!("Authentication for the device {}", self.device)).centered(), + Line::from(""), + Line::from(vec![ + Span::from("Enter the following passkey on the remote device: "), + Span::styled(self.passkey.to_string(), Style::new().bold()), + ]) + .centered(), + Line::from(""), + Line::from({ + match self.entered { + 0 => ". - . - . - . - . - .", + 1 => "* - . - . - . - . - .", + 2 => "* - * - . - . - . - .", + 3 => "* - * - * - . - . - .", + 4 => "* - * - * - * - . - .", + 5 => "* - * - * - * - * - .", + _ => "* - * - * - * - * - *", + } + }) + .centered(), + ]; + + let message = Paragraph::new(message).centered(); + + frame.render_widget(Clear, block); + + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(Color::Green)), + block, + ); + frame.render_widget( + message, + block.inner(Margin { + horizontal: 0, + vertical: 2, + }), + ); + } +} diff --git a/src/requests/display_pin_code.rs b/src/requests/display_pin_code.rs new file mode 100644 index 0000000..43ee960 --- /dev/null +++ b/src/requests/display_pin_code.rs @@ -0,0 +1,86 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Margin}, + style::{Color, Style, Stylize}, + text::Line, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, +}; + +use bluer::Address; + +use crate::{agent::AuthAgent, app::AppResult}; + +#[derive(Debug, Clone)] +pub struct DisplayPinCode { + pub adapter: String, + pub device: Address, + pub pin_code: String, +} + +impl DisplayPinCode { + pub fn new(adapter: String, device: Address, pin_code: String) -> Self { + Self { + adapter, + device, + pin_code, + } + } + + pub async fn submit(&mut self, agent: &AuthAgent) -> AppResult<()> { + agent.tx_display_pin_code.send(()).await?; + agent + .event_sender + .send(crate::event::Event::DisplayPinCodeSeen)?; + Ok(()) + } + + pub fn render(&self, frame: &mut Frame) { + let block = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Fill(1), + ]) + .margin(2) + .split(frame.area())[1]; + + let block = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Max(60), + Constraint::Fill(1), + ]) + .margin(1) + .split(block)[1]; + + let message = vec![ + Line::from(format!("Pin Code for the device {} ", self.device)).centered(), + Line::from(""), + Line::from(self.pin_code.clone()) + .centered() + .bold() + .bg(Color::DarkGray), + ]; + + let message = Paragraph::new(message).centered(); + + frame.render_widget(Clear, block); + + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(Color::Green)), + block, + ); + frame.render_widget( + message, + block.inner(Margin { + horizontal: 0, + vertical: 2, + }), + ); + } +} diff --git a/src/requests/enter_passkey.rs b/src/requests/enter_passkey.rs new file mode 100644 index 0000000..235b2f7 --- /dev/null +++ b/src/requests/enter_passkey.rs @@ -0,0 +1,218 @@ +use crossterm::event::{KeyCode, KeyEvent}; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Borders, Clear, List}, +}; + +use bluer::Address; +use tui_input::{Input, backend::crossterm::EventHandler}; + +use crate::{agent::AuthAgent, app::AppResult, event::Event, requests::pad_string}; + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum FocusedSection { + #[default] + Input, + Submit, +} + +#[derive(Debug, Clone)] +pub struct EnterPasskey { + pub adapter: String, + pub device: Address, + focused_section: FocusedSection, + passkey: UserInputField, +} + +#[derive(Debug, Clone, Default)] +struct UserInputField { + field: Input, + error: Option, +} + +impl EnterPasskey { + pub fn new(adapter: String, device: Address) -> Self { + Self { + adapter, + device, + focused_section: FocusedSection::default(), + passkey: UserInputField::default(), + } + } + + pub async fn submit(&mut self, agent: &AuthAgent) -> AppResult<()> { + self.validate(); + + if self.passkey.error.is_some() { + return Ok(()); + } + + agent + .tx_passkey + .send(self.passkey.field.value().parse::().unwrap()) + .await?; + + agent.event_sender.send(Event::PasskeySumitted)?; + Ok(()) + } + + pub async fn cancel(&mut self, agent: &AuthAgent) -> AppResult<()> { + agent.tx_cancel.send(()).await?; + agent.event_sender.send(Event::PasskeySumitted)?; + Ok(()) + } + + pub fn validate(&mut self) { + self.passkey.error = None; + if self.passkey.field.value().is_empty() { + self.passkey.error = Some("Required field.".to_string()); + return; + } + + if self.passkey.field.value().len() > 6 { + self.passkey.error = + Some("Passkey should be a numeric value between 0-999999".to_string()); + return; + } + + if self.passkey.field.value().parse::().is_err() { + self.passkey.error = + Some("Passkey should be a numeric value between 0-999999".to_string()); + } + } + + pub async fn handle_key_events( + &mut self, + key_event: KeyEvent, + agent: &AuthAgent, + ) -> AppResult<()> { + match key_event.code { + KeyCode::Tab | KeyCode::BackTab => { + if self.focused_section == FocusedSection::Input { + self.focused_section = FocusedSection::Submit; + } else { + self.focused_section = FocusedSection::Input; + } + } + _ => match self.focused_section { + FocusedSection::Submit => { + if let KeyCode::Enter = key_event.code { + self.submit(agent).await?; + } + } + + _ => { + self.passkey + .field + .handle_event(&crossterm::event::Event::Key(key_event)); + } + }, + } + + Ok(()) + } + + pub fn render(&self, frame: &mut Frame) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(8), + Constraint::Fill(1), + ]) + .split(frame.area()); + + let block = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Max(70), + Constraint::Fill(1), + ]) + .split(layout[1])[1]; + + let (message_block, input_block, submit_block) = { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Length(1), // message + Constraint::Length(1), + Constraint::Length(3), // input + Constraint::Length(1), + Constraint::Length(1), // enter + Constraint::Length(1), + ] + .as_ref(), + ) + .split(block); + + (chunks[1], chunks[3], chunks[5]) + }; + + let input_block = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Max(5), Constraint::Fill(1), Constraint::Max(5)].as_ref()) + .flex(ratatui::layout::Flex::Center) + .split(input_block)[1]; + + let message = Text::from(format!( + "Enter the Passkey for the device {} on {}", + self.device, self.adapter, + )) + .centered(); + + let items = vec![ + Line::from(vec![ + { + if self.focused_section == FocusedSection::Input { + Span::from("Passkey").green().bold() + } else { + Span::from("Passkey") + } + }, + Span::from(" "), + Span::from(pad_string( + format!(" {}", self.passkey.field.value()).as_str(), + 60, + )) + .bg(Color::DarkGray), + ]), + Line::from(vec![Span::from(pad_string(" ", 9)), { + if let Some(error) = &self.passkey.error { + Span::from(pad_string(error, 60)) + } else { + Span::from("") + } + }]) + .red(), + ]; + + let user_input = List::new(items); + + let submit = if self.focused_section == FocusedSection::Submit { + Text::from("Submit").centered().bold().green() + } else { + Text::from("Submit").centered() + }; + + frame.render_widget(Clear, block); + + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(Color::Green)), + block, + ); + + frame.render_widget(message, message_block); + frame.render_widget(user_input, input_block); + frame.render_widget(submit, submit_block); + } +} diff --git a/src/requests/enter_pin_code.rs b/src/requests/enter_pin_code.rs new file mode 100644 index 0000000..a7f29c5 --- /dev/null +++ b/src/requests/enter_pin_code.rs @@ -0,0 +1,212 @@ +use crossterm::event::{KeyCode, KeyEvent}; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Borders, Clear, List}, +}; + +use bluer::Address; +use tui_input::{Input, backend::crossterm::EventHandler}; + +use crate::{agent::AuthAgent, app::AppResult, event::Event, requests::pad_string}; + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum FocusedSection { + #[default] + Input, + Submit, +} + +#[derive(Debug, Clone)] +pub struct EnterPinCode { + pub adapter: String, + pub device: Address, + focused_section: FocusedSection, + pin_code: UserInputField, +} + +#[derive(Debug, Clone, Default)] +struct UserInputField { + field: Input, + error: Option, +} + +impl EnterPinCode { + pub fn new(adapter: String, device: Address) -> Self { + Self { + adapter, + device, + focused_section: FocusedSection::default(), + pin_code: UserInputField::default(), + } + } + + pub async fn submit(&mut self, agent: &AuthAgent) -> AppResult<()> { + self.validate(); + + if self.pin_code.error.is_some() { + return Ok(()); + } + + agent + .tx_pin_code + .send(self.pin_code.field.value().to_string()) + .await?; + + agent.event_sender.send(Event::PinCodeSumitted)?; + Ok(()) + } + + pub async fn cancel(&mut self, agent: &AuthAgent) -> AppResult<()> { + agent.tx_cancel.send(()).await?; + agent.event_sender.send(Event::PinCodeSumitted)?; + Ok(()) + } + + pub fn validate(&mut self) { + self.pin_code.error = None; + if self.pin_code.field.value().is_empty() { + self.pin_code.error = Some("Required field.".to_string()); + return; + } + + if self.pin_code.field.value().len() > 16 { + self.pin_code.error = + Some("Pin Code should be a string of 1-16 characters length".to_string()); + } + } + + pub async fn handle_key_events( + &mut self, + key_event: KeyEvent, + agent: &AuthAgent, + ) -> AppResult<()> { + match key_event.code { + KeyCode::Tab | KeyCode::BackTab => { + if self.focused_section == FocusedSection::Input { + self.focused_section = FocusedSection::Submit; + } else { + self.focused_section = FocusedSection::Input; + } + } + _ => match self.focused_section { + FocusedSection::Submit => { + if let KeyCode::Enter = key_event.code { + self.submit(agent).await?; + } + } + + _ => { + self.pin_code + .field + .handle_event(&crossterm::event::Event::Key(key_event)); + } + }, + } + + Ok(()) + } + + pub fn render(&self, frame: &mut Frame) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(8), + Constraint::Fill(1), + ]) + .split(frame.area()); + + let block = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Max(70), + Constraint::Fill(1), + ]) + .split(layout[1])[1]; + + let (message_block, input_block, submit_block) = { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Length(1), // message + Constraint::Length(1), + Constraint::Length(3), // input + Constraint::Length(1), + Constraint::Length(1), // enter + Constraint::Length(1), + ] + .as_ref(), + ) + .split(block); + + (chunks[1], chunks[3], chunks[5]) + }; + + let input_block = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Max(5), Constraint::Fill(1), Constraint::Max(5)].as_ref()) + .flex(ratatui::layout::Flex::Center) + .split(input_block)[1]; + + let message = Text::from(format!( + "Enter the PIN Code for the device {} on {}", + self.device, self.adapter, + )) + .centered(); + + let items = vec![ + Line::from(vec![ + { + if self.focused_section == FocusedSection::Input { + Span::from("Pin Code").green().bold() + } else { + Span::from("Pin Code") + } + }, + Span::from(" "), + Span::from(pad_string( + format!(" {}", self.pin_code.field.value()).as_str(), + 60, + )) + .bg(Color::DarkGray), + ]), + Line::from(vec![Span::from(pad_string(" ", 10)), { + if let Some(error) = &self.pin_code.error { + Span::from(pad_string(error, 60)) + } else { + Span::from("") + } + }]) + .red(), + ]; + + let user_input = List::new(items); + + let submit = if self.focused_section == FocusedSection::Submit { + Text::from("Submit").centered().bold().green() + } else { + Text::from("Submit").centered() + }; + + frame.render_widget(Clear, block); + + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(Color::Green)), + block, + ); + + frame.render_widget(message, message_block); + frame.render_widget(user_input, input_block); + frame.render_widget(submit, submit_block); + } +}