diff --git a/logs/log_1771421284.txt b/logs/log_1771421284.txt new file mode 100644 index 0000000..426aaea --- /dev/null +++ b/logs/log_1771421284.txt @@ -0,0 +1,16 @@ +1771421284869 IOTA ID: 0 +1771421284869 User IDS: +1771421284885 Community IDS: +1771421284886 TLS certificate 'certs/cert.pem' not found. +1771421284886 HTTP Server running on 0.0.0.0:1984 +1771421284887 Communities active on ws://10.209.226.34:1984/community/... +1771421284887 Downloading ZIP file... +1771421285166 Failed to download file: Status 404 Not Found +1771421285167 Error downloading file: Failed to download file: Status 404 Not Found +1771421285167 IOTA_REGISTER_NEW +1771421285173 OMIKRON_CONNECTING +1771421285459 OMIKRON_CONNECTION_SUCCESS +1771421297984 Shutdown signal received. +1771421297985 Web Server shutdown complete. +1771421305461 Request timed out after 20 seconds. +1771421305461 Launched diff --git a/logs/log_1771421408.txt b/logs/log_1771421408.txt new file mode 100644 index 0000000..aec1cce --- /dev/null +++ b/logs/log_1771421408.txt @@ -0,0 +1,42 @@ +1771421408357 IOTA ID: 0 +1771421408357 User IDS: +1771421408369 Community IDS: +1771421408369 TLS certificate 'certs/cert.pem' not found. +1771421408369 HTTP Server running on 0.0.0.0:1984 +1771421408370 Communities active on ws://10.209.226.34:1984/community/... +1771421408370 Downloading ZIP file... +1771421408574 Failed to download file: Status 404 Not Found +1771421408574 Error downloading file: Failed to download file: Status 404 Not Found +1771421408574 IOTA_REGISTER_NEW +1771421408581 OMIKRON_CONNECTING +1771421408791 OMIKRON_CONNECTION_SUCCESS +1771421428793 Request timed out after 20 seconds. +1771421428793 Launched +1771421428824 error_internal +1771421428911 Connection closed. +1771421429796 IOTA_REGISTER_NEW +1771421429800 OMIKRON_CONNECTING +1771421430102 OMIKRON_CONNECTION_SUCCESS +1771421450104 Request timed out after 20 seconds. +1771421450106 Launched +1771421450135 error_internal +1771421450205 Connection closed. +1771421451108 IOTA_REGISTER_NEW +1771421451113 OMIKRON_CONNECTING +1771421451310 OMIKRON_CONNECTION_SUCCESS +1771421471312 Request timed out after 20 seconds. +1771421471314 Launched +1771421471354 error_internal +1771421471428 Connection closed. +1771421472315 IOTA_REGISTER_NEW +1771421472319 OMIKRON_CONNECTING +1771421472543 OMIKRON_CONNECTION_SUCCESS +1771421492544 Request timed out after 20 seconds. +1771421492546 Launched +1771421492568 error_internal +1771421492597 Connection closed. +1771421493548 IOTA_REGISTER_NEW +1771421493554 OMIKRON_CONNECTING +1771421493769 OMIKRON_CONNECTION_SUCCESS +1771421493828 Shutdown signal received. +1771421493830 Web Server shutdown complete. diff --git a/logs/log_1771421600.txt b/logs/log_1771421600.txt new file mode 100644 index 0000000..3f9cb8e --- /dev/null +++ b/logs/log_1771421600.txt @@ -0,0 +1,15 @@ +1771421600740 IOTA ID: 0 +1771421600740 User IDS: +1771421600755 Community IDS: +1771421600756 TLS certificate 'certs/cert.pem' not found. +1771421600756 HTTP Server running on 0.0.0.0:1984 +1771421600756 Communities active on ws://10.209.226.34:1984/community/... +1771421600756 Downloading ZIP file... +1771421600982 Failed to download file: Status 404 Not Found +1771421600982 Error downloading file: Failed to download file: Status 404 Not Found +1771421600983 IOTA_REGISTER_NEW +1771421600988 OMIKRON_CONNECTING +1771421601270 OMIKRON_CONNECTION_SUCCESS +1771421606464 Shutdown signal received. +1771421621272 Request timed out after 20 seconds. +1771421621272 Launched diff --git a/logs/log_1771423051.txt b/logs/log_1771423051.txt new file mode 100644 index 0000000..2f3932b --- /dev/null +++ b/logs/log_1771423051.txt @@ -0,0 +1,184 @@ +1771423051538 IOTA ID: 0 +1771423051538 User IDS: +1771423051551 Community IDS: +1771423051551 TLS certificate 'certs/cert.pem' not found. +1771423051551 HTTP Server running on 0.0.0.0:1984 +1771423051552 Communities active on ws://10.209.226.34:1984/community/... +1771423051552 Downloading ZIP file... +1771423051790 Failed to download file: Status 404 Not Found +1771423051790 Error downloading file: Failed to download file: Status 404 Not Found +1771423051790 IOTA_REGISTER_NEW +1771423051793 OMIKRON_CONNECTING +1771423052032 OMIKRON_CONNECTION_SUCCESS +1771423072035 Request timed out after 20 seconds. +1771423072035 Launched +1771423072067 error_internal +1771423072105 Connection closed. +1771423073036 IOTA_REGISTER_NEW +1771423073040 OMIKRON_CONNECTING +1771423073256 OMIKRON_CONNECTION_SUCCESS +1771423093258 Request timed out after 20 seconds. +1771423093262 Launched +1771423093286 error_internal +1771423093322 Connection closed. +1771423094264 IOTA_REGISTER_NEW +1771423094267 OMIKRON_CONNECTING +1771423094484 OMIKRON_CONNECTION_SUCCESS +1771423114491 Request timed out after 20 seconds. +1771423114492 Launched +1771423114520 error_internal +1771423114558 Connection closed. +1771423115495 IOTA_REGISTER_NEW +1771423115501 OMIKRON_CONNECTING +1771423115724 OMIKRON_CONNECTION_SUCCESS +1771423135725 Request timed out after 20 seconds. +1771423135728 Launched +1771423135759 error_internal +1771423135794 Connection closed. +1771423136730 IOTA_REGISTER_NEW +1771423136738 OMIKRON_CONNECTING +1771423136959 OMIKRON_CONNECTION_SUCCESS +1771423156960 Request timed out after 20 seconds. +1771423156963 Launched +1771423156999 error_internal +1771423157078 Connection closed. +1771423157965 IOTA_REGISTER_NEW +1771423157970 OMIKRON_CONNECTING +1771423158169 OMIKRON_CONNECTION_SUCCESS +1771423178171 Request timed out after 20 seconds. +1771423178173 Launched +1771423178200 error_internal +1771423178270 Connection closed. +1771423179174 IOTA_REGISTER_NEW +1771423179178 OMIKRON_CONNECTING +1771423179404 OMIKRON_CONNECTION_SUCCESS +1771423199406 Request timed out after 20 seconds. +1771423199409 Launched +1771423199437 error_internal +1771423199485 Connection closed. +1771423200413 IOTA_REGISTER_NEW +1771423200418 OMIKRON_CONNECTING +1771423200855 OMIKRON_CONNECTION_SUCCESS +1771423220858 Request timed out after 20 seconds. +1771423220861 Launched +1771423220884 error_internal +1771423220913 Connection closed. +1771423221864 IOTA_REGISTER_NEW +1771423221870 OMIKRON_CONNECTING +1771423222087 OMIKRON_CONNECTION_SUCCESS +1771423242092 Request timed out after 20 seconds. +1771423242095 Launched +1771423242115 error_internal +1771423242145 Connection closed. +1771423243096 IOTA_REGISTER_NEW +1771423243101 OMIKRON_CONNECTING +1771423243328 OMIKRON_CONNECTION_SUCCESS +1771423263334 Request timed out after 20 seconds. +1771423263337 Launched +1771423263363 error_internal +1771423263412 Connection closed. +1771423264343 IOTA_REGISTER_NEW +1771423264348 OMIKRON_CONNECTING +1771423264553 OMIKRON_CONNECTION_SUCCESS +1771423284555 Request timed out after 20 seconds. +1771423284558 Launched +1771423284582 error_internal +1771423284609 Connection closed. +1771423285560 IOTA_REGISTER_NEW +1771423285563 OMIKRON_CONNECTING +1771423285770 OMIKRON_CONNECTION_SUCCESS +1771423305772 Request timed out after 20 seconds. +1771423305775 Launched +1771423305801 error_internal +1771423305837 Connection closed. +1771423306776 IOTA_REGISTER_NEW +1771423306786 OMIKRON_CONNECTING +1771423306995 OMIKRON_CONNECTION_SUCCESS +1771423326998 Request timed out after 20 seconds. +1771423327000 Launched +1771423327022 error_internal +1771423327068 Connection closed. +1771423328002 IOTA_REGISTER_NEW +1771423328006 OMIKRON_CONNECTING +1771423328253 OMIKRON_CONNECTION_SUCCESS +1771423348256 Request timed out after 20 seconds. +1771423348259 Launched +1771423348298 error_internal +1771423348335 Connection closed. +1771423349260 IOTA_REGISTER_NEW +1771423349265 OMIKRON_CONNECTING +1771423349504 OMIKRON_CONNECTION_SUCCESS +1771423369505 Request timed out after 20 seconds. +1771423369507 Launched +1771423369535 error_internal +1771423369584 Connection closed. +1771423370508 IOTA_REGISTER_NEW +1771423370512 OMIKRON_CONNECTING +1771423370754 OMIKRON_CONNECTION_SUCCESS +1771423390756 Request timed out after 20 seconds. +1771423390759 Launched +1771423390800 error_internal +1771423390827 Connection closed. +1771423391760 IOTA_REGISTER_NEW +1771423391764 OMIKRON_CONNECTING +1771423392002 OMIKRON_CONNECTION_SUCCESS +1771423412005 Request timed out after 20 seconds. +1771423412008 Launched +1771423412038 error_internal +1771423412065 Connection closed. +1771423413009 IOTA_REGISTER_NEW +1771423413015 OMIKRON_CONNECTING +1771423413208 OMIKRON_CONNECTION_SUCCESS +1771423433210 Request timed out after 20 seconds. +1771423433212 Launched +1771423433234 error_internal +1771423433271 Connection closed. +1771423434215 IOTA_REGISTER_NEW +1771423434221 OMIKRON_CONNECTING +1771423434422 OMIKRON_CONNECTION_SUCCESS +1771423454425 Request timed out after 20 seconds. +1771423454426 Launched +1771423454448 error_internal +1771423454478 Connection closed. +1771423455428 IOTA_REGISTER_NEW +1771423455433 OMIKRON_CONNECTING +1771423455707 OMIKRON_CONNECTION_SUCCESS +1771423475712 Request timed out after 20 seconds. +1771423475716 Launched +1771423475748 error_internal +1771423475779 Connection closed. +1771423476717 IOTA_REGISTER_NEW +1771423476721 OMIKRON_CONNECTING +1771423476935 OMIKRON_CONNECTION_SUCCESS +1771423496936 Request timed out after 20 seconds. +1771423496938 Launched +1771423496970 error_internal +1771423497007 Connection closed. +1771423497939 IOTA_REGISTER_NEW +1771423497945 OMIKRON_CONNECTING +1771423498170 OMIKRON_CONNECTION_SUCCESS +1771423518171 Request timed out after 20 seconds. +1771423518174 Launched +1771423518201 error_internal +1771423518241 Connection closed. +1771423519176 IOTA_REGISTER_NEW +1771423519181 OMIKRON_CONNECTING +1771423519392 OMIKRON_CONNECTION_SUCCESS +1771423539396 Request timed out after 20 seconds. +1771423539398 Launched +1771423539424 error_internal +1771423539455 Connection closed. +1771423540400 IOTA_REGISTER_NEW +1771423540404 OMIKRON_CONNECTING +1771423540643 OMIKRON_CONNECTION_SUCCESS +1771423560644 Request timed out after 20 seconds. +1771423560647 Launched +1771423560687 error_internal +1771423560747 Connection closed. +1771423561649 IOTA_REGISTER_NEW +1771423561653 OMIKRON_CONNECTING +1771423561858 OMIKRON_CONNECTION_SUCCESS +1771423574596 Shutdown signal received. +1771423574597 Web Server shutdown complete. +1771423581860 Request timed out after 20 seconds. +1771423581864 Launched diff --git a/logs/log_1771423595.txt b/logs/log_1771423595.txt new file mode 100644 index 0000000..39b186f --- /dev/null +++ b/logs/log_1771423595.txt @@ -0,0 +1,11 @@ +1771423595203 IOTA ID: 0 +1771423595203 User IDS: +1771423595215 Community IDS: +1771423595216 TLS certificate 'certs/cert.pem' not found. +1771423595216 HTTP Server running on 0.0.0.0:1984 +1771423595217 Communities active on ws://10.209.226.34:1984/community/... +1771423595217 Downloading ZIP file... +1771423608345 Shutdown signal received. +1771423608346 Web Server shutdown complete. +1771423685485 Failed to download file: Status 504 Gateway Timeout +1771423685485 Error downloading file: Failed to download file: Status 504 Gateway Timeout diff --git a/src/gui/elements/log_card.rs b/src/gui/elements/log_card.rs index 85d0fa8..41dc752 100644 --- a/src/gui/elements/log_card.rs +++ b/src/gui/elements/log_card.rs @@ -10,7 +10,6 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Wrap}, }; use std::any::Any; -use std::sync::Arc; #[derive(Clone)] pub struct UiLogEntry { @@ -89,13 +88,13 @@ impl InteractableElement for LogCard { if self.scroll > 0 { self.scroll -= 1; } - InteractionResult::Handeled + InteractionResult::Handled } KeyCode::Down => { self.scroll += 1; - InteractionResult::Handeled + InteractionResult::Handled } - _ => InteractionResult::Unhandeled, + _ => InteractionResult::Unhandled, } } diff --git a/src/gui/input_handler.rs b/src/gui/input_handler.rs new file mode 100644 index 0000000..901ab56 --- /dev/null +++ b/src/gui/input_handler.rs @@ -0,0 +1,66 @@ +use crate::ACTIVE_TASKS; +use crate::gui::ui::UI; +use crate::{RELOAD, SHUTDOWN}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; +use std::sync::Arc; +use std::time::Duration; + +pub fn setup_input_handler(ui: Arc) { + tokio::spawn(async move { + ACTIVE_TASKS + .lock() + .unwrap() + .push("Input Handler".to_string()); + + loop { + { + let should_shutdown = *SHUTDOWN.read().await; + if should_shutdown { + break; + } + } + + let has_event = match poll(Duration::from_millis(100)) { + Ok(true) => true, + Ok(false) => false, + Err(_) => false, + }; + + if has_event { + match read() { + Ok(event) => { + if let Event::Key(key_event) = event { + if key_event.kind == KeyEventKind::Press { + let ui_clone = ui.clone(); + handle_input(key_event, ui_clone).await; + } + } + } + Err(_) => (), + } + } + } + { + let mut tasks = ACTIVE_TASKS.lock().unwrap(); + tasks.retain(|t| t != "Input Handler"); + } + }); +} + +pub async fn handle_input(key: KeyEvent, ui: Arc) { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), KeyModifiers::CONTROL) => { + *SHUTDOWN.write().await = true; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + *SHUTDOWN.write().await = true; + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + *RELOAD.write().await = true; + *SHUTDOWN.write().await = true; + } + _ => { + ui.handle_input(key).await; + } + } +} diff --git a/src/gui/interaction_result.rs b/src/gui/interaction_result.rs index 34baef2..1ec2d23 100644 --- a/src/gui/interaction_result.rs +++ b/src/gui/interaction_result.rs @@ -5,14 +5,15 @@ use std::pin::Pin; use crate::gui::screens::screens::Screen; pub enum InteractionResult { + CloseScreen, OpenScreen { screen: Box, }, OpenFutureScreen { screen: Pin> + Send>>, }, - Handeled, - Unhandeled, + Handled, + Unhandled, } impl Debug for InteractionResult { @@ -20,8 +21,9 @@ impl Debug for InteractionResult { match self { InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), - InteractionResult::Handeled => write!(f, "Handeled"), - InteractionResult::Unhandeled => write!(f, "Unhandeled"), + InteractionResult::CloseScreen => write!(f, "CloseScreen"), + InteractionResult::Handled => write!(f, "Handled"), + InteractionResult::Unhandled => write!(f, "Unhandled"), } } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index d94ecdf..fc4bc6b 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -4,7 +4,10 @@ pub mod elements { } pub mod screens { pub mod screens; + pub mod terms_checker; + pub mod terms_updater; } pub mod app_state; +pub mod input_handler; pub mod interaction_result; pub mod ui; diff --git a/src/gui/screens/terms_checker.rs b/src/gui/screens/terms_checker.rs new file mode 100644 index 0000000..804b6bc --- /dev/null +++ b/src/gui/screens/terms_checker.rs @@ -0,0 +1,353 @@ +use crate::{ + gui::{interaction_result::InteractionResult, screens::screens::Screen, ui::UI}, + terms::{ + buttons::{checkbox, draw_buttons}, + consent_state::UserChoice, + focus::Focus, + md_viewer::FileViewer, + terms_getter::{Type, get_link, get_terms}, + }, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph}, +}; +use std::{any::Any, pin::Pin, sync::Arc}; +use tokio::sync::oneshot; + +pub struct TermsCheckerScreen { + ui: Arc, + sender: Option>, + + eula: bool, + tos: bool, + pp: bool, + + focus: Focus, +} + +impl TermsCheckerScreen { + pub fn new(ui: Arc, sender: Option>) -> Self { + Self { + ui, + sender, + eula: false, + tos: false, + pp: false, + focus: Focus::Eula, + } + } +} + +impl Screen for TermsCheckerScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn get_ui(&self) -> &Arc { + &self.ui + } + + fn render(&self, f: &mut Frame, size: Rect) { + let mut needed_height = 5; + + if size.height < 6 || size.width < 27 { + f.render_widget( + Line::from(format!("too small {}/63 by {}/13", size.width, size.height)), + size, + ); + return; + } + + let max_width = 150; + let max_height = 26; + + let content_width = if max_width < size.width { + max_width + } else { + size.width + }; + let content_height = if max_height < size.height { + max_height + } else { + size.height + }; + + let horizontal_margin = (size.width.saturating_sub(content_width)) / 2; + let vertical_margin = (size.height.saturating_sub(content_height)) / 2; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(7), Constraint::Length(3)]) + .split(Rect { + x: horizontal_margin, + y: vertical_margin, + width: content_width, + height: content_height, + }); + let eula_text = if size.width < 70 { + "EULA ¹ (https://legal.tensamin.net/eula/)" + } else { + "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)" + }; + let tos_text = if size.width < 72 { + "ToS ² (https://legal.tensamin.net/terms-of-service/)" + } else { + "Terms of Service ² (https://legal.tensamin.net/terms-of-service/)" + }; + let pp_text = if size.width < 68 { + "PP ² (https://legal.tensamin.net/privacy-policy/)" + } else { + "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)" + }; + + let (mut optional_lines, agree_lines): (Vec, Vec<&str>) = if size.width > 143 { + ( + vec![8, 3, 8, 5], + vec![ + "", + "By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service.", + "", + "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", + "", + "While having a document selected press O to view in this UI or press L to open as a link.", + ], + ) + } else if size.width > 92 { + ( + vec![9, 3, 9, 5], + vec![ + "", + "By selecting Continue, you confirm that you agree to the End User License", + "Agreement and applicable Terms of Service.", + "", + "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", + "", + "While having a document selected press O to view in this UI or press L to open as a link.", + ], + ) + } else if size.width > 73 { + ( + vec![9, 3, 9, 5], + vec![ + "", + "By selecting Continue, you confirm that you agree to the", + "End User License Agreement and applicable Terms of Service.", + "", + "Tensamin services require acceptance of the ToS and Privacy Policy.", + "", + "While having a document selected press O to view in this UI or press L", + "to open as a link.", + ], + ) + } else { + ( + vec![10, 3, 11, 5], + vec![ + "", + "By selecting Continue, you confirm that you agree", + "to the End User License Agreement and", + "applicable Terms of Service.", + "", + "Tensamin services require acceptance of the", + "Terms of Service and Privacy Policy.", + "", + "While having a document selected press O to view", + "in this UI or press L to open as a link.", + ], + ) + }; + let mut text_lines = vec![ + checkbox(eula_text, self.eula, self.focus == Focus::Eula, true), + checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula), + checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula), + Line::from(""), + Line::from("¹ Necessary– required to run the program"), + Line::from("² Optional – required only for Tensamin services"), + ]; + for line in agree_lines { + text_lines.insert(text_lines.len(), Line::from(line)); + } + + while size.height - 5 < text_lines.len() as u16 { + if optional_lines.len() == 0 { + break; + } + text_lines.remove(optional_lines[0] as usize); + optional_lines.remove(0); + } + needed_height += text_lines.len(); + + if size.width < 60 || size.height < needed_height as u16 { + let width_style = if size.width > 76 { + Style::default().fg(Color::Green) + } else if size.width >= 60 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Red) + }; + + let height_style = if size.height > 19 { + Style::default().fg(Color::Green) + } else if size.height >= 13 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Red) + }; + + let warning_text = Text::from(vec![ + Line::from(vec![ + Span::raw("Width: "), + Span::styled(format!("{}", size.width), width_style), + Span::raw(" / 60"), + ]), + Line::from(vec![ + Span::raw("Height: "), + Span::styled(format!("{}", size.height), height_style), + Span::raw(format!(" / 13")), + ]), + ]); + + let warning = Paragraph::new(warning_text) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("UI Too Small [Q to Quit]")), + ); + f.render_widget(warning, size); + return; + } + + let consent_block = Paragraph::new(Text::from(text_lines)).block( + Block::default() + .title(format!(" Tensamin User Consent [Q to Quit] ",)) + .borders(Borders::ALL), + ); + f.render_widget(consent_block, chunks[0]); + + draw_buttons( + f, + chunks[1], + self.focus, + (self.eula, self.tos && self.pp), + true, + false, + true, + ); + } + + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; + + if self.eula { + possible_states.push(Focus::Continue); + if self.tos && self.pp { + possible_states.push(Focus::ContinueAll); + } + } + + match event.code { + KeyCode::Esc => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::Deny); + } + InteractionResult::CloseScreen + } + KeyCode::Up | KeyCode::Left => { + self.focus.prev(&possible_states); + InteractionResult::Handled + } + KeyCode::Down | KeyCode::Right | KeyCode::Tab => { + self.focus.next(&possible_states); + InteractionResult::Handled + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::Deny); + } + InteractionResult::CloseScreen + } + KeyCode::Char('o') | KeyCode::Char('O') => { + let terms_type = match self.focus { + Focus::Eula => Some(Type::EULA), + Focus::Tos => Some(Type::TOS), + Focus::Pp => Some(Type::PP), + _ => None, + }; + if let Some(terms_type) = terms_type { + let ui = self.ui.clone(); + let fut: Pin> + Send>> = + Box::pin(async move { + let content = get_terms(terms_type.clone()).await.unwrap(); + let screen: FileViewer = + FileViewer::new(ui.clone(), terms_type.to_string(), &content); + Box::new(screen) as Box + }); + InteractionResult::OpenFutureScreen { screen: fut } + } else { + InteractionResult::Unhandled + } + } + KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { + Focus::Eula => { + let _ = open::that(get_link(Type::EULA)); + InteractionResult::Handled + } + Focus::Tos => { + let _ = open::that(get_link(Type::TOS)); + InteractionResult::Handled + } + Focus::Pp => { + let _ = open::that(get_link(Type::PP)); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + }, + KeyCode::Enter | KeyCode::Char(' ') => match self.focus { + Focus::Eula => { + self.eula = !self.eula; + self.tos = false; + self.pp = false; + InteractionResult::Handled + } + Focus::Tos if self.eula => { + self.tos = !self.tos; + InteractionResult::Handled + } + Focus::Pp if self.eula => { + self.pp = !self.pp; + InteractionResult::Handled + } + Focus::Cancel => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::Deny); + } + InteractionResult::CloseScreen + } + Focus::Continue if self.eula => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::AcceptEULA); + } + InteractionResult::CloseScreen + } + Focus::ContinueAll if self.eula && self.tos && self.pp => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::AcceptAll); + } + InteractionResult::CloseScreen + } + _ => InteractionResult::Unhandled, + }, + + _ => InteractionResult::Unhandled, + } + } +} diff --git a/src/gui/screens/terms_updater.rs b/src/gui/screens/terms_updater.rs new file mode 100644 index 0000000..4e553af --- /dev/null +++ b/src/gui/screens/terms_updater.rs @@ -0,0 +1,701 @@ +use crate::{ + gui::{interaction_result::InteractionResult, screens::screens::Screen, ui::UI}, + terms::{ + buttons::{checkbox, draw_buttons}, + consent_state::{UpdateDecision, UserChoice}, + doc::Doc, + focus::Focus, + md_viewer::FileViewer, + terms_getter::{Type, get_link, get_terms}, + }, +}; +use chrono::{Local, TimeZone, Utc}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph}, +}; +use std::any::Any; +use std::sync::Arc; +use tokio::sync::oneshot; + +pub struct TermsUpdaterScreen { + ui: Arc, + sender: Option>, + + eula_needed: bool, + tos_needed: bool, + pp_needed: bool, + + eula_future: bool, + tos_future: bool, + pp_future: bool, + + eula_for_future: Option, + tos_for_future: Option, + pp_for_future: Option, + + update_needed: bool, + + eula: bool, + tos: bool, + pp: bool, + + focus: Focus, +} +impl TermsUpdaterScreen { + pub fn new( + ui: Arc, + consent_eula: UpdateDecision, + consent_tos: UpdateDecision, + consent_pp: UpdateDecision, + sender: Option>, + ) -> Self { + let (eula_needed, eula_future, eula_for_future): (bool, bool, Option) = + match consent_eula { + UpdateDecision::NoChange => (false, false, None), + UpdateDecision::Future { newest } => (true, true, Some(newest)), + UpdateDecision::Forced(doc) => (true, false, Some(doc)), + }; + let (tos_needed, tos_future, tos_for_future): (bool, bool, Option) = match consent_tos + { + UpdateDecision::NoChange => (false, false, None), + UpdateDecision::Future { newest } => (true, true, Some(newest)), + UpdateDecision::Forced(doc) => (true, false, Some(doc)), + }; + let (pp_needed, pp_future, pp_for_future): (bool, bool, Option) = match consent_pp { + UpdateDecision::NoChange => (false, false, None), + UpdateDecision::Future { newest } => (true, true, Some(newest)), + UpdateDecision::Forced(doc) => (true, false, Some(doc)), + }; + + let focus = if eula_needed { + Focus::Eula + } else if tos_needed { + Focus::Tos + } else if pp_needed { + Focus::Pp + } else { + Focus::Cancel + }; + + let update_needed = (eula_needed && !eula_future) + || (tos_needed && !tos_future) + || (pp_needed && !pp_future); + + Self { + ui, + sender, + + eula_needed, + tos_needed, + pp_needed, + + eula_for_future, + tos_for_future, + pp_for_future, + + eula_future, + tos_future, + pp_future, + + update_needed, + + eula: !eula_needed, + tos: !tos_needed, + pp: !pp_needed, + + focus, + } + } +} +impl Screen for TermsUpdaterScreen { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn get_ui(&self) -> &Arc { + &self.ui + } + + fn render(&self, f: &mut Frame, size: Rect) { + let mut needed_height = 5; + + if size.height < 6 || size.width < 27 { + f.render_widget( + Line::from(format!("too small {}/63 by {}/15", size.width, size.height)), + size, + ); + return; + } + + let max_width = 150; + let max_height = 30; + + let content_width = if max_width < size.width { + max_width + } else { + size.width + }; + let content_height = if max_height < size.height { + max_height + } else { + size.height + }; + + let horizontal_margin = (size.width.saturating_sub(content_width)) / 2; + let vertical_margin = (size.height.saturating_sub(content_height)) / 2; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(7), Constraint::Length(3)]) + .split(Rect { + x: horizontal_margin, + y: vertical_margin, + width: content_width, + height: content_height, + }); + + let mut text_lines: Vec = Vec::new(); + let mut header_lines = 0; + let separator = if size.width > 72 { + header_lines += 3; + text_lines.push(Line::from( + "You previously accepted earlier versions of Tensamin’s legal documents.", + )); + text_lines.push(Line::from( + "Some of them have been updated and are listed below for your review.", + )); + text_lines.push(Line::from("")); + 2 + } else { + header_lines += 5; + text_lines.push(Line::from("You previously accepted earlier ")); + text_lines.push(Line::from("versions of Tensamin’s legal documents.")); + text_lines.push(Line::from("Some of them have been updated and")); + text_lines.push(Line::from("are listed below for your review.")); + text_lines.push(Line::from("")); + 4 + }; + + if self.eula_needed { + header_lines += 1; + if self.eula_future { + if size.width < 80 { + text_lines.push(checkbox( + "EULA ¹³ (https://legal.tensamin.net/eula/newest/)", + self.eula, + self.focus == Focus::Eula, + true, + )); + + header_lines += 1; + let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64; + let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); + let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); + text_lines.push(Line::from(format!(" Goes into effect on {}", date))); + } else { + text_lines.push(checkbox( + "End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)", + self.eula, + self.focus == Focus::Eula, + true, + )); + + header_lines += 1; + let unix_timestamp = self.eula_for_future.clone().unwrap().get_time() as i64; + let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); + let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); + text_lines.push(Line::from(format!(" Goes into effect on {}", date))); + } + } else { + if size.width < 80 { + text_lines.push(checkbox( + "EULA ¹ (https://legal.tensamin.net/eula/newest/)", + self.eula, + self.focus == Focus::Eula, + true, + )); + } else { + text_lines.push(checkbox( + "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)", + self.eula, + self.focus == Focus::Eula, + true, + )); + } + } + } + + if self.tos_needed { + header_lines += 1; + if self.tos_future { + if size.width < 80 { + text_lines.push(checkbox( + "ToS ²³ (https://legal.tensamin.net/tos/newest/)", + self.tos, + self.focus == Focus::Tos, + self.eula, + )); + + header_lines += 1; + let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64; + let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); + let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); + text_lines.push(Line::from(format!(" Goes into effect on {}", date))); + } else { + text_lines.push(checkbox( + "Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)", + self.tos, + self.focus == Focus::Tos, + self.eula, + )); + + header_lines += 1; + let unix_timestamp = self.tos_for_future.clone().unwrap().get_time() as i64; + let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); + let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); + text_lines.push(Line::from(format!(" Goes into effect on {}", date))); + } + } else { + if size.width < 80 { + text_lines.push(checkbox( + "ToS ² (https://legal.tensamin.net/tos/newest/)", + self.tos, + self.focus == Focus::Tos, + self.eula, + )); + } else { + text_lines.push(checkbox( + "Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)", + self.tos, + self.focus == Focus::Tos, + self.eula, + )); + } + } + } + + if self.pp_needed { + header_lines += 1; + if self.pp_future { + if size.width < 80 { + text_lines.push(checkbox( + "PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)", + self.pp, + self.focus == Focus::Pp, + self.eula, + )); + + header_lines += 1; + let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64; + let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); + let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); + text_lines.push(Line::from(format!(" Goes into effect on {}", date))); + } else { + text_lines.push(checkbox( + "Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)", + self.pp, + self.focus == Focus::Pp, + self.eula, + )); + + header_lines += 1; + let unix_timestamp = self.pp_for_future.clone().unwrap().get_time() as i64; + let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); + let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); + text_lines.push(Line::from(format!(" Goes into effect on {}", date))); + } + } else { + if size.width < 80 { + text_lines.push(checkbox( + "PP ² (https://legal.tensamin.net/privacy-policy/newest/)", + self.pp, + self.focus == Focus::Pp, + self.eula, + )); + } else { + text_lines.push(checkbox( + "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)", + self.pp, + self.focus == Focus::Pp, + self.eula, + )); + } + } + } + + text_lines.push(Line::from("")); + text_lines.push(Line::from("¹ Necessary to run the program")); + text_lines.push(Line::from("² Optional - only for Tensamin services")); + if size.width < 100 { + text_lines.push(Line::from( + "³ Future version - consent stored now, takes effect later", + )); + } else { + text_lines.push(Line::from("³ Future version - You’ll continue using this version, automatically updated when changes apply.")); + } + + text_lines.push(Line::from("")); + + let mut optional_lines: Vec = if size.width > 143 { + if self.tos_needed || self.pp_needed { + text_lines.push(Line::from("By selecting Downgrade, you confirm that you agree to the End User License Agreement and applicable Terms of Service.")); + text_lines.push(Line::from( + "On Downgrade: Tensamin Services will deactivate once these changes apply.", + )); + } else { + text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service.")); + } + text_lines.push(Line::from("")); + text_lines.push(Line::from( + "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", + )); + text_lines.push(Line::from("")); + text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link.")); + + if self.tos_needed || self.pp_needed { + vec![ + header_lines + 7, + header_lines, + header_lines + 7, + separator, + header_lines + 2, + ] + } else { + vec![ + header_lines + 6, + header_lines, + header_lines + 6, + separator, + header_lines + 2, + ] + } + } else if size.width > 92 { + if self.tos_needed || self.pp_needed { + text_lines.push(Line::from( + "By selecting Continue, you confirm that you agree to the End User", + )); + text_lines.push(Line::from( + "License Agreement and applicable Terms of Service.", + )); + text_lines.push(Line::from( + "On Downgrade: Tensamin Services will deactivate once these changes apply.", + )); + } else { + text_lines.push(Line::from( + "By selecting Continue, you confirm that you agree to the End User", + )); + text_lines.push(Line::from( + "License Agreement and applicable Terms of Service.", + )); + } + + text_lines.push(Line::from("")); + text_lines.push(Line::from( + "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", + )); + text_lines.push(Line::from("")); + text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link.")); + + if self.tos_needed || self.pp_needed { + vec![ + header_lines + 8, + header_lines, + header_lines + 8, + separator, + header_lines + 2, + ] + } else { + vec![ + header_lines + 7, + header_lines, + header_lines + 7, + separator, + header_lines + 2, + ] + } + } else if size.width > 73 { + if self.tos_needed || self.pp_needed { + text_lines.push(Line::from( + "By selecting Continue, you confirm that you read, understood and", + )); + text_lines.push(Line::from( + "agree to the End User License Agreement and applicable Terms of Service.", + )); + text_lines.push(Line::from( + "On Downgrade: Tensamin Services will deactivate once these changes apply.", + )); + } else { + text_lines.push(Line::from( + "By selecting Continue, you confirm that you read, understood and", + )); + text_lines.push(Line::from( + "agree to the End User License Agreement and applicable Terms of Service.", + )); + } + text_lines.push(Line::from("")); + text_lines.push(Line::from( + "Tensamin services require acceptance of the ToS and Privacy Policy.", + )); + text_lines.push(Line::from("")); + text_lines.push(Line::from( + "While having a document selected press O to view in this UI or press L", + )); + text_lines.push(Line::from("to open as a link.")); + + if self.tos_needed || self.pp_needed { + vec![ + header_lines + 8, + header_lines, + header_lines + 8, + separator, + header_lines + 2, + ] + } else { + vec![ + header_lines + 7, + header_lines, + header_lines + 7, + separator, + header_lines + 2, + ] + } + } else { + if self.tos_needed || self.pp_needed { + text_lines.push(Line::from("By selecting Continue, you confirm that you")); + text_lines.push(Line::from("agree to the End User License")); + text_lines.push(Line::from("Agreement and applicable Terms of Service.")); + text_lines.push(Line::from( + "On Downgrade: Tensamin Services will deactivate", + )); + text_lines.push(Line::from("once these changes apply.")); + } else { + text_lines.push(Line::from("By selecting Continue, you confirm that you")); + text_lines.push(Line::from("agree to the End User License")); + text_lines.push(Line::from("Agreement and applicable Terms of Service.")); + } + text_lines.push(Line::from("")); + text_lines.push(Line::from("Tensamin services require acceptance of the")); + text_lines.push(Line::from("Terms of Service and Privacy Policy.")); + text_lines.push(Line::from("")); + text_lines.push(Line::from( + "While having a document selected press O to view", + )); + text_lines.push(Line::from("in this UI or press L to open as a link.")); + if self.tos_needed || self.pp_needed { + vec![ + header_lines + 10, + header_lines, + header_lines + 11, + separator, + header_lines + 2, + ] + } else { + vec![ + header_lines + 8, + header_lines, + header_lines + 9, + separator, + header_lines + 2, + ] + } + }; + + while size.height - 5 < text_lines.len() as u16 { + if optional_lines.len() == 0 { + break; + } + text_lines.remove(optional_lines[0] as usize); + optional_lines.remove(0); + } + needed_height += text_lines.len(); + + let q_informer = if self.update_needed { + "Q to Exit" + } else { + "Q to Cancel" + }; + if size.width < 60 || size.height < needed_height as u16 { + let width_style = if size.width > 76 { + Style::default().fg(Color::Green) + } else if size.width >= 60 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Red) + }; + + let height_style = if size.height > 20 { + Style::default().fg(Color::Green) + } else if size.height >= (header_lines as u16 + 10) { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Red) + }; + + let warning_text = Text::from(vec![ + Line::from(vec![ + Span::raw("Width: "), + Span::styled(format!("{}", size.width), width_style), + Span::raw(" / 60"), + ]), + Line::from(vec![ + Span::raw("Height: "), + Span::styled(format!("{}", size.height), height_style), + Span::raw(format!(" / {}", header_lines + 10)), + ]), + ]); + + let warning = Paragraph::new(warning_text) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("UI Too Small [{}]", q_informer)), + ); + + f.render_widget(warning, size); + return; + } + + let consent_block = Paragraph::new(Text::from(text_lines)).block( + Block::default() + .title(format!(" Update Tensamin User Consent [{}] ", q_informer)) + .borders(Borders::ALL), + ); + f.render_widget(consent_block, chunks[0]); + + let downgrade_scenario = self.tos_needed || self.pp_needed; + draw_buttons( + f, + chunks[1], + self.focus, + (self.eula, self.tos && self.pp), + self.update_needed, + downgrade_scenario, + self.pp_needed || self.tos_needed, + ); + } + + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + let mut possible_states = Vec::new(); + + if self.eula_needed { + possible_states.push(Focus::Eula); + } + if self.tos_needed { + possible_states.push(Focus::Tos); + } + if self.pp_needed { + possible_states.push(Focus::Pp); + } + + possible_states.push(Focus::Cancel); + + if self.eula { + possible_states.push(Focus::Continue); + if self.tos && self.pp { + possible_states.push(Focus::ContinueAll); + } + } + + match event.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::Deny); + } + return InteractionResult::CloseScreen; + } + KeyCode::Enter | KeyCode::Char(' ') => match self.focus { + Focus::Eula => { + self.eula = !self.eula; + self.tos = !self.tos_needed; + self.pp = !self.pp_needed; + InteractionResult::Handled + } + Focus::Tos if self.eula => { + self.tos = !self.tos; + InteractionResult::Handled + } + Focus::Pp if self.eula => { + self.pp = !self.pp; + InteractionResult::Handled + } + Focus::Cancel => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::Deny); + } + InteractionResult::CloseScreen + } + Focus::Continue if self.eula => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::AcceptEULA); + } + InteractionResult::CloseScreen + } + Focus::ContinueAll if self.eula && self.tos && self.pp => { + if let Some(sender) = self.sender.take() { + let _ = sender.send(UserChoice::AcceptAll); + } + InteractionResult::CloseScreen + } + _ => InteractionResult::Unhandled, + }, + KeyCode::Char('o') | KeyCode::Char('O') => { + let terms_type = match self.focus { + Focus::Eula => Some(Type::EULA), + Focus::Tos => Some(Type::TOS), + Focus::Pp => Some(Type::PP), + _ => None, + }; + + if let Some(terms_type) = terms_type { + let ui = self.ui.clone(); + let fut = Box::pin(async move { + let content = get_terms(terms_type.clone()).await.unwrap(); + Box::new(FileViewer::new( + ui.clone(), + terms_type.to_string(), + &content, + )) as Box + }); + + return InteractionResult::OpenFutureScreen { screen: fut }; + } else { + InteractionResult::Unhandled + } + } + KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { + Focus::Eula => { + let _ = open::that(get_link(Type::EULA)); + InteractionResult::Handled + } + Focus::Tos => { + let _ = open::that(get_link(Type::TOS)); + InteractionResult::Handled + } + Focus::Pp => { + let _ = open::that(get_link(Type::PP)); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + }, + KeyCode::Up | KeyCode::Left => { + self.focus.prev(&possible_states); + InteractionResult::Handled + } + KeyCode::Down | KeyCode::Right | KeyCode::Tab => { + self.focus.next(&possible_states); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } +} diff --git a/src/gui/ui.rs b/src/gui/ui.rs index 810d68a..bccde68 100644 --- a/src/gui/ui.rs +++ b/src/gui/ui.rs @@ -1,197 +1,64 @@ -use crossterm::{ - cursor::MoveTo, - execute, - terminal::{Clear, ClearType, EnterAlternateScreen}, -}; -use ratatui::{ - Terminal, - backend::{Backend, CrosstermBackend}, - layout::Size, -}; -use std::{ - io::{self, Error}, - sync::{Arc, Mutex}, -}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; - -struct ResizableBackend<'a> { - backend: CrosstermBackend<&'a mut Vec>, - size: Size, -} - -impl<'a> ResizableBackend<'a> { - fn new(buf: &'a mut Vec, width: u16, height: u16) -> Self { - Self { - backend: CrosstermBackend::new(buf), - size: Size::new(width, height), - } - } -} - -impl<'a> Backend for ResizableBackend<'a> { - type Error = Error; - - fn draw<'b, I>(&mut self, content: I) -> io::Result<()> - where - I: Iterator, - { - self.backend.draw(content) - } - - fn hide_cursor(&mut self) -> io::Result<()> { - self.backend.hide_cursor() - } - - fn show_cursor(&mut self) -> io::Result<()> { - self.backend.show_cursor() - } - - #[allow(deprecated)] - fn get_cursor(&mut self) -> io::Result<(u16, u16)> { - self.backend.get_cursor() - } - - #[allow(deprecated)] - fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { - self.backend.set_cursor(x, y) - } - - fn clear(&mut self) -> io::Result<()> { - self.backend.clear() - } - - fn clear_region(&mut self, region: ratatui::backend::ClearType) -> io::Result<()> { - self.backend.clear_region(region) - } - - fn size(&self) -> Result { - Ok(self.size) - } - - fn flush(&mut self) -> io::Result<()> { - self.backend.flush() - } - - fn get_cursor_position(&mut self) -> Result { - todo!() - } - - fn set_cursor_position>( - &mut self, - _position: P, - ) -> Result<(), Self::Error> { - todo!() - } - - fn window_size(&mut self) -> Result { - todo!() - } -} +use crossterm::event::KeyEvent; +use ratatui::{Terminal, backend::CrosstermBackend, init}; +use std::sync::{Arc, Mutex}; +use tokio::sync::RwLock; /// UI state and rendering - pub struct UI { - pub cols: Arc>, - pub rows: Arc>, - screen: Arc>>>, - - cached_render: Arc>>, + pub terminal: Arc>>>, + screen: Arc>>>, } impl UI { - pub fn new(cols: u16, rows: u16) -> Self { + pub fn new() -> Self { + let terminal = init(); Self { - cols: Arc::new(Mutex::new(cols)), - rows: Arc::new(Mutex::new(rows)), - screen: Arc::new(Mutex::new(None)), - cached_render: Arc::new(Mutex::new(Vec::new())), + terminal: Arc::new(Mutex::new(terminal)), + screen: Arc::new(RwLock::new(None)), } } - pub fn set_screen(&self, screen: Box) { - *self.screen.lock().unwrap() = Some(screen); + pub async fn set_screen(&self, screen: Box) { + *self.screen.write().await = Some(screen); } - pub fn resize(&self, cols: u32, rows: u32) { - *self.cols.lock().unwrap() = cols as u16; - *self.rows.lock().unwrap() = rows as u16; - } - - pub async fn handle_input(&self, input: &[u8]) { - let key_event = if input.len() == 1 { - let c = input[0] as char; - if c.is_ascii() { - Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) + pub async fn handle_input(self: Arc, key_event: KeyEvent) { + let result = { + let mut guard = self.screen.write().await; + if let Some(screen) = guard.as_mut() { + screen.handle_input(key_event) } else { - None + return; } - } else { - None }; - - let event = match input { - b"\x1b[A" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), - b"\x1b[B" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), - b"\x1b[C" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), - b"\x1b[D" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), - b"\r" | b"\n" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), - b"\x7f" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), - _ => key_event, - }; - - if let Some(event) = event { - let result = { - let mut guard = self.screen.lock().unwrap(); - if let Some(screen) = guard.as_mut() { - screen.handle_input(event) - } else { - return; - } - }; - match result { - InteractionResult::OpenScreen { screen } => { - self.set_screen(screen); - } - InteractionResult::OpenFutureScreen { screen: fut } => { - let ui = self.clone(); - let screen = fut.await; - ui.set_screen(screen); - } - _ => {} + match result { + InteractionResult::OpenScreen { screen } => { + self.set_screen(screen).await; } + InteractionResult::OpenFutureScreen { screen: fut } => { + let ui = self.clone(); + let screen = fut.await; + ui.set_screen(screen).await; + } + InteractionResult::CloseScreen => { + /* TODO + self.set_screen(); + */ + } + InteractionResult::Handled => {} + InteractionResult::Unhandled => {} } } - pub fn render(&self) -> Vec { - let mut buf = Vec::new(); - - execute!( - &mut buf, - EnterAlternateScreen, - Clear(ClearType::All), - MoveTo(0, 0) - ) - .unwrap(); - - { - let backend = ResizableBackend::new( - &mut buf, - *self.cols.lock().unwrap(), - *self.rows.lock().unwrap(), - ); - - let mut terminal = Terminal::new(backend).unwrap(); - - if let Some(screen) = self.screen.lock().unwrap().as_ref() { - let _ = terminal.draw(|f| screen.render(f, f.area())); - } + pub async fn render(&self) { + if let Some(screen) = self.screen.read().await.as_ref() { + let mut terminal = self.terminal.lock().unwrap(); + terminal + .draw(|f| { + screen.render(f, f.area()); + }) + .unwrap(); } - - *self.cached_render.lock().unwrap() = buf.clone(); - - buf } } diff --git a/src/main.rs b/src/main.rs index 0c14d7c..102880f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,11 +20,12 @@ mod util; use crate::communities::community_manager; use crate::communities::interactables::registry; use crate::gui::app_state::AppState; -use crate::gui::ui; +use crate::gui::input_handler::setup_input_handler; +use crate::gui::ui::UI; use crate::langu::language_creator; use crate::omikron::omikron_connection::{OMIKRON_CONNECTION, OmikronConnection}; use crate::server::server::start; -use crate::terms::consent_state; +use crate::terms::consent_state::ConsentManager; use crate::users::user_manager; use crate::util::config_util::CONFIG; use crate::util::file_util::download_and_extract_zip; @@ -45,17 +46,29 @@ async fn main() { *RELOAD.write().await = false; *SHUTDOWN.write().await = false; - let tui = ui::UI::new(100, 100); - tokio::spawn(async move { tui.render() }); + let ui = Arc::new(UI::new()); + setup_input_handler(ui.clone()); - // EULA - let (tos, pp) = consent_state::ConsentManager::check().await; - if !tos { + let ui_clone = ui.clone(); + tokio::spawn(async move { + loop { + ui_clone.render().await; + sleep(Duration::from_millis(16)).await; + } + }); + + let (eula, tos_pp) = ConsentManager::check(ui.clone()).await; + + if !eula { + ui.terminal.lock().unwrap().clear(); + ui.terminal.lock().unwrap().flush(); println!("You need to accept our End User Licence Agreement before launching!"); println!("You can find this at 'agreements'!"); return; } - if !pp { + if !tos_pp { + ui.terminal.lock().unwrap().clear(); + ui.terminal.lock().unwrap().flush(); println!( "Please accept our Privacy Policy & Terms of Serivce before using Tensamin Services!" ); @@ -171,5 +184,7 @@ async fn main() { community_manager::clear(); *APP_STATE.lock().unwrap() = AppState::new(); } + ui.terminal.lock().unwrap().clear(); + ui.terminal.lock().unwrap().flush(); } } diff --git a/src/terms/consent_state.rs b/src/terms/consent_state.rs index ae2fec1..8464df6 100644 --- a/src/terms/consent_state.rs +++ b/src/terms/consent_state.rs @@ -1,66 +1,137 @@ +use tokio::sync::oneshot; + use crate::{ + gui::{ + screens::{terms_checker::TermsCheckerScreen, terms_updater::TermsUpdaterScreen}, + ui::UI, + }, terms::{ doc::Doc, - terms_checker, terms_getter::{Type, get_current_docs, get_newest_docs}, - terms_updater, }, util::file_util::{load_file, save_file}, }; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; pub struct ConsentManager; impl ConsentManager { - pub async fn check() -> (bool, bool) { + pub async fn check(ui: Arc) -> (bool, bool) { + let (tx, rx) = oneshot::channel(); + let file = load_file("", "agreements"); - let (mut file_state, changed) = ConsentState::from_str(&file).sanitize(); + let mut file_state = ConsentState::from_str(&file).sanitize(); + ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx)))) + .await; - if changed { - save_file("", "agreements", &file_state.to_string()); + let result = rx.await.unwrap_or(UserChoice::Deny); + + match result { + UserChoice::AcceptEULA | UserChoice::AcceptAll => { + if let Some((current_eula, current_tos, current_privacy)) = get_current_docs().await + { + file_state.accepted_eula = true; + file_state.eula = Some(current_eula); + + if matches!(result, UserChoice::AcceptAll) { + file_state.accepted_tos = true; + file_state.accepted_pp = true; + file_state.tos = Some(current_tos); + file_state.privacy = Some(current_privacy); + } + } + } + UserChoice::Deny => { + return (false, false); + } } - if !file_state.accepted_eula { - file_state = terms_checker::run_consent_ui(file_state).await; - - save_file("", "agreements", &file_state.to_string()); - } - - if !file_state.accepted_eula { - return (false, false); - } + save_file("", "agreements", &file_state.to_string()); if let Some((eula_update, tos_update, privacy_update)) = Self::get_updates().await { let _is_forced = matches!(eula_update, UpdateDecision::Forced(_)) || matches!(tos_update, UpdateDecision::Forced(_)) || matches!(privacy_update, UpdateDecision::Forced(_)); - let updater_logic = async move { - let (state_to_update, _) = terms_updater::run_consent_ui( - file_state, - eula_update, - tos_update, - privacy_update, - ) - .await - .sanitize(); + if let Some((eula_update, tos_update, privacy_update)) = Self::get_updates().await { + let is_forced = matches!(eula_update, UpdateDecision::Forced(_)) + || matches!(tos_update, UpdateDecision::Forced(_)) + || matches!(privacy_update, UpdateDecision::Forced(_)); - save_file("", "agreements", &state_to_update.to_string()); - }; - // ======================================================== // - // TODO: Implement UI Drawing order, draw update As PoPup // - // ======================================================== // - //if is_forced { - updater_logic.await; - //} else { - //task::spawn(updater_logic); - //} + let (tx, rx) = oneshot::channel(); + + ui.set_screen(Box::new(TermsUpdaterScreen::new( + ui.clone(), + eula_update.clone(), + tos_update.clone(), + privacy_update.clone(), + Some(tx), + ))) + .await; + + if is_forced { + let result = rx.await.unwrap_or(UserChoice::Deny); + + let file = load_file("", "agreements"); + let mut state = ConsentState::from_str(&file).sanitize(); + + match result { + UserChoice::AcceptAll => { + state.accepted_eula = true; + state.accepted_tos = true; + state.accepted_pp = true; + } + UserChoice::AcceptEULA => { + state.accepted_eula = true; + } + UserChoice::Deny => { + return (false, false); + } + } + + let state = state.sanitize(); + save_file("", "agreements", &state.to_string()); + } else { + tokio::spawn(async move { + let result = rx.await.unwrap_or(UserChoice::Deny); + + let file = load_file("", "agreements"); + let mut state = ConsentState::from_str(&file).sanitize(); + + match result { + UserChoice::AcceptAll => { + state.future_eula = match eula_update { + UpdateDecision::Future { newest } => Some(newest), + _ => state.future_eula, + }; + state.future_tos = match tos_update { + UpdateDecision::Future { newest } => Some(newest), + _ => state.future_tos, + }; + state.future_privacy = match privacy_update { + UpdateDecision::Future { newest } => Some(newest), + _ => state.future_privacy, + }; + } + UserChoice::AcceptEULA => { + if let UpdateDecision::Future { newest } = eula_update { + state.future_eula = Some(newest); + } + } + UserChoice::Deny => {} + } + + let state = state.sanitize(); + save_file("", "agreements", &state.to_string()); + }); + } + } } let file = load_file("", "agreements"); - let (final_state, changed) = ConsentState::from_str(&file).sanitize(); - if changed { - save_file("", "agreements", &final_state.to_string()); - } + let final_state = ConsentState::from_str(&file).sanitize(); + + save_file("", "agreements", &final_state.to_string()); ( final_state.accepted_eula, final_state.accepted_tos && final_state.accepted_pp, @@ -76,10 +147,9 @@ impl ConsentManager { UpdateDecision, )> { let file = load_file("", "agreements"); - let (accepted_state, changed) = ConsentState::from_str(&file).sanitize(); - if changed { - save_file("", "agreements", &accepted_state.to_string()); - } + let accepted_state = ConsentState::from_str(&file).sanitize(); + save_file("", "agreements", &accepted_state.to_string()); + if let ( Some((current_eula, current_tos, current_privacy)), Some((newest_eula, newest_tos, newest_privacy)), @@ -186,8 +256,7 @@ pub struct ConsentState { } impl ConsentState { - fn sanitize(mut self) -> (Self, bool) { - let mut changed = false; + fn sanitize(mut self) -> Self { let current_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -197,30 +266,26 @@ impl ConsentState { if future_eula.get_time() < current_secs { self.eula = Some(future_eula); self.future_eula = None; - changed = true; } } if let Some(future_tos) = self.future_tos.clone() { if future_tos.get_time() < current_secs { self.tos = Some(future_tos); self.future_tos = None; - changed = true; } } if let Some(future_privacy) = self.future_privacy.clone() { if future_privacy.get_time() < current_secs { self.privacy = Some(future_privacy); self.future_privacy = None; - changed = true; } } if !self.accepted_eula { self.accepted_tos = false; self.accepted_pp = false; - changed = true; } - (self, changed) + self } pub fn to_string(&self) -> String { @@ -356,7 +421,7 @@ impl ConsentState { pp_version = v.to_string(); } else if let Some(v) = line.strip_prefix("Privacy-Policy-HASH=") { pp_hash = v.to_string(); - } else if let Some(v) = line.strip_prefix("UNIX=") { + } else if let Some(v) = line.strip_prefix("UNIX-SECOND=") { unix = v.to_string(); } else if let Some(v) = line.strip_prefix("FUTURE-EULA-VERSION=") { future_eula_version = v.to_string(); @@ -382,7 +447,7 @@ impl ConsentState { let future_eula_time = future_eula_time.parse::().unwrap_or(0); let future_tos_time = future_tos_time.parse::().unwrap_or(0); let future_pp_time = future_pp_time.parse::().unwrap_or(0); - let (state, _) = Self { + let state = Self { accepted_eula: eula, eula: Some(Doc::new(eula_version, eula_hash, Type::EULA, unix)), accepted_pp: pp, diff --git a/src/terms/md_viewer.rs b/src/terms/md_viewer.rs index 33fed9d..5a4adf8 100644 --- a/src/terms/md_viewer.rs +++ b/src/terms/md_viewer.rs @@ -1,23 +1,62 @@ -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; use ratatui::{ DefaultTerminal, prelude::*, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; -use std::time::Duration; +use std::{any::Any, sync::Arc, time::Duration}; + +use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen, ui::UI}; -#[derive(Default)] pub struct FileViewer { + ui: Arc, title: String, text: Vec, scroll: u16, scroll_x: u16, } +impl Screen for FileViewer { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn get_ui(&self) -> &Arc { + &self.ui + } + + fn render(&self, f: &mut Frame, rect: Rect) { + self.draw(f, rect); + } + + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + match event.code { + KeyCode::Char('q') | KeyCode::Esc => { + return InteractionResult::CloseScreen; + } + + KeyCode::Down => self.scroll = self.scroll.saturating_add(1), + KeyCode::Up => self.scroll = self.scroll.saturating_sub(1), + KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10), + KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10), + KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2), + KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2), + + _ => {} + } + + InteractionResult::Unhandled + } +} + impl FileViewer { - pub fn new(title: String, content: &str) -> Self { + pub fn new(ui: Arc, title: String, content: &str) -> Self { Self { + ui, title, text: parse_document(content.to_owned()), scroll: 0, diff --git a/src/terms/mod.rs b/src/terms/mod.rs index 16d5f82..b65db75 100644 --- a/src/terms/mod.rs +++ b/src/terms/mod.rs @@ -3,6 +3,4 @@ pub mod consent_state; pub mod doc; pub mod focus; pub mod md_viewer; -pub mod terms_checker; pub mod terms_getter; -pub mod terms_updater; diff --git a/src/terms/terms_checker.rs b/src/terms/terms_checker.rs deleted file mode 100644 index 033dc46..0000000 --- a/src/terms/terms_checker.rs +++ /dev/null @@ -1,311 +0,0 @@ -use crate::terms::{ - buttons::{checkbox, draw_buttons}, - consent_state::{ConsentState, UserChoice}, - focus::Focus, - md_viewer::FileViewer, - terms_getter::{Type, get_current_docs, get_link, get_terms}, -}; -use crossterm::event::{self, Event, KeyCode}; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Paragraph}, -}; -use std::time::Duration; - -pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState { - let mut terminal = ratatui::init(); - - let (mut eula, mut tos, mut pp) = (false, false, false); - let mut focus = Focus::Eula; - - let result = loop { - let mut too_small = false; - terminal - .draw(|f| { - let mut needed_height = 5; - - let size = f.area(); - - if size.height < 6 - || size.width < 27 { - f.render_widget(Line::from(format!("too small {}/63 by {}/13", size.width, size.height)), size); - return; - } - - let max_width = 150; - let max_height = 26; - - let content_width = if max_width < size.width { max_width } else { size.width} ; - let content_height = if max_height < size.height { max_height } else { size.height} ; - - let horizontal_margin = (size.width.saturating_sub(content_width)) / 2; - let vertical_margin = (size.height.saturating_sub(content_height)) / 2; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(7), Constraint::Length(3)]) - .split(Rect { - x: horizontal_margin, - y: vertical_margin, - width: content_width, - height: content_height, - }); - let eula_text = if size.width < 70 { - "EULA ¹ (https://legal.tensamin.net/eula/)" - } else { - "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)" - }; - let tos_text = if size.width < 72 { - "ToS ² (https://legal.tensamin.net/terms-of-service/)" - } else { - "Terms of Service ² (https://legal.tensamin.net/terms-of-service/)" - }; - let pp_text = if size.width < 68 { - "PP ² (https://legal.tensamin.net/privacy-policy/)" - } else { - "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)" - }; - - let (mut optional_lines, agree_lines): (Vec, Vec<&str>) = - if size.width > 143 { - ( - vec![8, 3, 8, 5], - vec![ - "", - "By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service.", - "", - "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", - "", - "While having a document selected press O to view in this UI or press L to open as a link.", - ] - ) - } else if size.width > 92 { - ( - vec![9, 3, 9, 5], - vec![ - "", - "By selecting Continue, you confirm that you agree to the End User License", - "Agreement and applicable Terms of Service.", - "", - "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", - "", - "While having a document selected press O to view in this UI or press L to open as a link.", - ] - ) - } else if size.width > 73 { - ( - vec![9, 3, 9, 5], - vec![ - "", - "By selecting Continue, you confirm that you agree to the", - "End User License Agreement and applicable Terms of Service.", - "", - "Tensamin services require acceptance of the ToS and Privacy Policy.", - "", - "While having a document selected press O to view in this UI or press L", - "to open as a link.", - ] - ) - } else { - ( - vec![10, 3, 11, 5], - vec![ - "", - "By selecting Continue, you confirm that you agree", - "to the End User License Agreement and", - "applicable Terms of Service.", - "", - "Tensamin services require acceptance of the", - "Terms of Service and Privacy Policy.", - "", - "While having a document selected press O to view", - "in this UI or press L to open as a link.", - ] - ) - }; - let mut text_lines = vec![ - checkbox(eula_text, eula, focus == Focus::Eula, true), - checkbox(tos_text, tos, focus == Focus::Tos, eula), - checkbox(pp_text, pp, focus == Focus::Pp, eula), - Line::from(""), - Line::from("¹ Necessary– required to run the program"), - Line::from("² Optional – required only for Tensamin services") - ]; - for line in agree_lines { - text_lines.insert(text_lines.len(), Line::from(line)); - } - - while size.height - 5 < text_lines.len() as u16 { - if optional_lines.len() == 0 { - break; - } - text_lines.remove(optional_lines[0] as usize); - optional_lines.remove(0); - } - needed_height += text_lines.len(); - - - - if size.width < 60 || size.height < needed_height as u16 { - let width_style = if size.width > 76 { - Style::default().fg(Color::Green) - } else if size.width >= 60 { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::Red) - }; - - let height_style = if size.height > 19 { - Style::default().fg(Color::Green) - } else if size.height >= 13 { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::Red) - }; - - let warning_text = Text::from(vec![ - Line::from(vec![ - Span::raw("Width: "), - Span::styled(format!("{}", size.width), width_style), - Span::raw(" / 60"), - ]), - Line::from(vec![ - Span::raw("Height: "), - Span::styled(format!("{}", size.height), height_style), - Span::raw(format!(" / 13")), - ]), - ]); - - let warning = Paragraph::new(warning_text) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!("UI Too Small [Q to Quit]")), - ); - - too_small = true; - f.render_widget(warning, size); - return; - } - - let consent_block = Paragraph::new(Text::from(text_lines)) - .block(Block::default().title(" Tensamin User Consent [Q to Quit] ").borders(Borders::ALL)); - f.render_widget(consent_block, chunks[0]); - - draw_buttons(f, chunks[1], focus, (eula, tos && pp), true, false, true); - }) - .unwrap(); - - if event::poll(Duration::from_millis(200)).unwrap() { - if let Event::Key(key) = event::read().unwrap() { - if too_small { - if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) { - break UserChoice::Deny; - } else { - continue; - } - } - let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; - if eula { - possible_states.push(Focus::Continue); - if tos && pp { - possible_states.push(Focus::ContinueAll); - } - } - match key.code { - KeyCode::Esc => break UserChoice::Deny, - KeyCode::Up | KeyCode::Left => focus.prev(&possible_states), - KeyCode::Down | KeyCode::Right | KeyCode::Tab => focus.next(&possible_states), - KeyCode::Char('q') | KeyCode::Char('Q') => break UserChoice::Deny, - KeyCode::Char('o') | KeyCode::Char('O') => { - let terms_type = match focus { - Focus::Eula => Type::EULA, - Focus::Tos => Type::TOS, - Focus::Pp => Type::PP, - _ => continue, - }; - - if let Some(eula) = get_terms(terms_type.clone()).await { - terminal = FileViewer::new(terms_type.to_string(), &eula) - .force_popup(terminal); - } else { - terminal = FileViewer::new( - terms_type.to_string(), - "### A loading error occured\ - \nTry reloading this site or retry in a moment.", - ) - .force_popup(terminal); - } - } - KeyCode::Char('l') | KeyCode::Char('L') => { - let terms_type = match focus { - Focus::Eula => Type::EULA, - Focus::Tos => Type::TOS, - Focus::Pp => Type::PP, - _ => continue, - }; - - let _ = open::that(get_link(terms_type)); - } - KeyCode::Char(' ') => match focus { - Focus::Eula => { - eula = !eula; - tos = false; - pp = false; - } - Focus::Tos => { - if eula { - tos = !tos - } - } - Focus::Pp => { - if eula { - pp = !pp - } - } - _ => {} - }, - KeyCode::Enter => match focus { - Focus::Cancel => break UserChoice::Deny, - Focus::Continue if eula => break UserChoice::AcceptEULA, - Focus::ContinueAll if eula && tos && pp => { - break UserChoice::AcceptAll; - } - _ => {} - }, - _ => {} - } - } - } - }; - ratatui::restore(); - - if let Some((eula, tos, privacy)) = get_current_docs().await { - consent.eula = Some(eula); - consent.tos = Some(tos); - consent.privacy = Some(privacy); - } - - match result { - UserChoice::AcceptAll => { - consent.accepted_eula = true; - consent.accepted_tos = true; - consent.accepted_pp = true; - } - UserChoice::AcceptEULA => { - consent.accepted_eula = true; - consent.accepted_tos = false; - consent.accepted_pp = false; - } - UserChoice::Deny => { - consent.accepted_eula = false; - consent.accepted_tos = false; - consent.accepted_pp = false; - } - }; - - consent -} diff --git a/src/terms/terms_updater.rs b/src/terms/terms_updater.rs deleted file mode 100644 index 57ae7c3..0000000 --- a/src/terms/terms_updater.rs +++ /dev/null @@ -1,480 +0,0 @@ -use crate::terms::{ - buttons::{checkbox, draw_buttons}, - consent_state::{ConsentState, UpdateDecision, UserChoice}, - doc::Doc, - focus::Focus, - md_viewer::FileViewer, - terms_getter::{Type, get_newest_link, get_terms}, -}; -use chrono::{Local, TimeZone, Utc}; -use crossterm::event::{self, Event, KeyCode}; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Paragraph}, -}; -use std::time::Duration; -pub async fn run_consent_ui( - mut consent: ConsentState, - consent_eula: UpdateDecision, - consent_tos: UpdateDecision, - consent_pp: UpdateDecision, -) -> ConsentState { - let mut terminal = ratatui::init(); - - let (eula_needed, eula_future, eula_for_future): (bool, bool, Option) = match consent_eula - { - UpdateDecision::NoChange => (false, false, None), - UpdateDecision::Future { newest } => (true, true, Some(newest)), - UpdateDecision::Forced(doc) => (true, false, Some(doc)), - }; - let (tos_needed, tos_future, tos_for_future): (bool, bool, Option) = match consent_tos { - UpdateDecision::NoChange => (false, false, None), - UpdateDecision::Future { newest } => (true, true, Some(newest)), - UpdateDecision::Forced(doc) => (true, false, Some(doc)), - }; - let (pp_needed, pp_future, pp_for_future): (bool, bool, Option) = match consent_pp { - UpdateDecision::NoChange => (false, false, None), - UpdateDecision::Future { newest } => (true, true, Some(newest)), - UpdateDecision::Forced(doc) => (true, false, Some(doc)), - }; - let update_needed = - (eula_needed && !eula_future) || (tos_needed && !tos_future) || (pp_needed && !pp_future); - let (mut eula, mut tos, mut pp) = (!eula_needed, !tos_needed, !pp_needed); - let mut focus = if eula_needed { - Focus::Eula - } else if tos_needed { - Focus::Tos - } else if pp_needed { - Focus::Pp - } else { - Focus::Cancel - }; - - let result = loop { - let mut too_small = false; - terminal - .draw(|f| { - let mut needed_height = 5; - - let size = f.area(); - - if size.height < 6 - || size.width < 27 { - f.render_widget(Line::from(format!("too small {}/63 by {}/15", size.width, size.height)), size); - return; - } - - let max_width = 150; - let max_height = 30; - - let content_width = if max_width < size.width { max_width } else { size.width} ; - let content_height = if max_height < size.height { max_height } else { size.height} ; - - let horizontal_margin = (size.width.saturating_sub(content_width)) / 2; - let vertical_margin = (size.height.saturating_sub(content_height)) / 2; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(7), Constraint::Length(3)]) - .split(Rect { - x: horizontal_margin, - y: vertical_margin, - width: content_width, - height: content_height, - }); - - - let mut text_lines: Vec = Vec::new(); - let mut header_lines = 0; - let seperator = if size.width > 72 { - header_lines += 3; - text_lines.push(Line::from("You previously accepted earlier versions of Tensamin’s legal documents.")); - text_lines.push(Line::from("Some of them have been updated and are listed below for your review.")); - text_lines.push(Line::from("")); - 2 - } else { - header_lines += 5; - text_lines.push(Line::from("You previously accepted earlier ")); - text_lines.push(Line::from("versions of Tensamin’s legal documents.")); - text_lines.push(Line::from("Some of them have been updated and")); - text_lines.push(Line::from("are listed below for your review.")); - text_lines.push(Line::from("")); - 4 - }; - - if eula_needed { - header_lines += 1; - if eula_future { - if size.width < 80 { - text_lines.push(checkbox("EULA ¹³ (https://legal.tensamin.net/eula/newest/)", eula, focus == Focus::Eula, true)); - - header_lines += 1; - let unix_timestamp = eula_for_future.clone().unwrap().get_time() as i64; - let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); - let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); - text_lines.push(Line::from(format!(" Goes into effect on {}", date))); - } else { - text_lines.push(checkbox("End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)", eula, focus == Focus::Eula, true)); - - header_lines += 1; - let unix_timestamp = eula_for_future.clone().unwrap().get_time() as i64; - let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); - let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); - text_lines.push(Line::from(format!(" Goes into effect on {}", date))); - } - } else { - if size.width < 80 { - text_lines.push(checkbox("EULA ¹ (https://legal.tensamin.net/eula/newest/)", eula, focus == Focus::Eula, true)); - } else { - text_lines.push(checkbox("End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)", eula, focus == Focus::Eula, true)); - } - } - } - - if tos_needed { - header_lines += 1; - if tos_future { - if size.width < 80 { - text_lines.push(checkbox("ToS ²³ (https://legal.tensamin.net/tos/newest/)", tos, focus == Focus::Tos, eula)); - - header_lines += 1; - let unix_timestamp = tos_for_future.clone().unwrap().get_time() as i64; - let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); - let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); - text_lines.push(Line::from(format!(" Goes into effect on {}", date))); - } else { - text_lines.push(checkbox("Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)", tos, focus == Focus::Tos, eula)); - - header_lines += 1; - let unix_timestamp = tos_for_future.clone().unwrap().get_time() as i64; - let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); - let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); - text_lines.push(Line::from(format!(" Goes into effect on {}", date))); - } - } else { - if size.width < 80 { - text_lines.push(checkbox("ToS ² (https://legal.tensamin.net/tos/newest/)", tos, focus == Focus::Tos, eula)); - } else { - text_lines.push(checkbox("Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)", tos, focus == Focus::Tos, eula)); - } - } - } - - if pp_needed { - header_lines += 1; - if pp_future { - if size.width < 80 { - text_lines.push(checkbox("PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, eula)); - - header_lines += 1; - let unix_timestamp = pp_for_future.clone().unwrap().get_time() as i64; - let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); - let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); - text_lines.push(Line::from(format!(" Goes into effect on {}", date))); - } else { - text_lines.push(checkbox("Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, eula)); - - header_lines += 1; - let unix_timestamp = pp_for_future.clone().unwrap().get_time() as i64; - let datetime = Utc.timestamp_opt(unix_timestamp, 0).single().unwrap(); - let date = datetime.with_timezone(&Local).format("%Y-%m-%d at %H:%M"); - text_lines.push(Line::from(format!(" Goes into effect on {}", date))); - } - } else { - if size.width < 80 { - text_lines.push(checkbox("PP ² (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, eula)); - } else { - text_lines.push(checkbox("Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, eula)); - } - } - } - - text_lines.push(Line::from("")); - text_lines.push(Line::from("¹ Necessary to run the program")); - text_lines.push(Line::from("² Optional - only for Tensamin services")); - if size.width < 100 { - text_lines.push(Line::from("³ Future version - consent stored now, takes effect later")); - } else { - text_lines.push(Line::from("³ Future version - You’ll continue using this version, automatically updated when changes apply.")); - } - - text_lines.push(Line::from("")); - - let mut optional_lines: Vec = - if size.width > 143 { - if tos_needed || pp_needed { - text_lines.push(Line::from("By selecting Downgrade, you confirm that you agree to the End User License Agreement and applicable Terms of Service.")); - text_lines.push(Line::from("On Downgrade: Tensamin Services will deactivate once these changes apply.")); - } else { - text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User License Agreement and applicable Terms of Service.")); - } - text_lines.push(Line::from("")); - text_lines.push(Line::from("Tensamin services require acceptance of the Terms of Service and Privacy Policy.")); - text_lines.push(Line::from("")); - text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link.")); - - if tos_needed || pp_needed { - vec![header_lines + 7, header_lines, header_lines + 7, seperator, header_lines + 2] - } else { - vec![header_lines + 6, header_lines, header_lines + 6, seperator, header_lines + 2] - } - } else if size.width > 92 { - if tos_needed || pp_needed { - text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User")); - text_lines.push(Line::from("License Agreement and applicable Terms of Service.")); - text_lines.push(Line::from("On Downgrade: Tensamin Services will deactivate once these changes apply.")); - } else { - text_lines.push(Line::from("By selecting Continue, you confirm that you agree to the End User")); - text_lines.push(Line::from("License Agreement and applicable Terms of Service.")); - } - - text_lines.push(Line::from("")); - text_lines.push(Line::from("Tensamin services require acceptance of the Terms of Service and Privacy Policy.")); - text_lines.push(Line::from("")); - text_lines.push(Line::from("While having a document selected press O to view in this UI or press L to open as a link.")); - - if tos_needed || pp_needed { - vec![header_lines + 8, header_lines, header_lines + 8, seperator, header_lines + 2] - } else { - vec![header_lines + 7, header_lines, header_lines + 7, seperator, header_lines + 2] - } - } else if size.width > 73 { - if tos_needed || pp_needed { - text_lines.push(Line::from("By selecting Continue, you confirm that you read, understood and")); - text_lines.push(Line::from("agree to the End User License Agreement and applicable Terms of Service.")); - text_lines.push(Line::from("On Downgrade: Tensamin Services will deactivate once these changes apply.")); - } else { - text_lines.push(Line::from("By selecting Continue, you confirm that you read, understood and")); - text_lines.push(Line::from("agree to the End User License Agreement and applicable Terms of Service.")); - } - text_lines.push(Line::from("",)); - text_lines.push(Line::from("Tensamin services require acceptance of the ToS and Privacy Policy.",)); - text_lines.push(Line::from("",)); - text_lines.push(Line::from("While having a document selected press O to view in this UI or press L",)); - text_lines.push(Line::from("to open as a link.",)); - - if tos_needed || pp_needed { - vec![header_lines + 8, header_lines, header_lines + 8, seperator, header_lines + 2] - } else { - vec![header_lines + 7, header_lines, header_lines + 7, seperator, header_lines + 2] - } - } else { - if tos_needed || pp_needed { - text_lines.push(Line::from("By selecting Continue, you confirm that you",)); - text_lines.push(Line::from("agree to the End User License",)); - text_lines.push(Line::from("Agreement and applicable Terms of Service.",)); - text_lines.push(Line::from("On Downgrade: Tensamin Services will deactivate")); - text_lines.push(Line::from("once these changes apply.")); - } else { - text_lines.push(Line::from("By selecting Continue, you confirm that you",)); - text_lines.push(Line::from("agree to the End User License",)); - text_lines.push(Line::from("Agreement and applicable Terms of Service.",)); - } - text_lines.push(Line::from("",)); - text_lines.push(Line::from("Tensamin services require acceptance of the",)); - text_lines.push(Line::from("Terms of Service and Privacy Policy.",)); - text_lines.push(Line::from("",)); - text_lines.push(Line::from("While having a document selected press O to view",)); - text_lines.push(Line::from("in this UI or press L to open as a link.",)); - if tos_needed || pp_needed { - vec![header_lines + 10, header_lines, header_lines + 11, seperator, header_lines + 2] - } else { - vec![header_lines + 8, header_lines, header_lines + 9, seperator, header_lines + 2] - } - }; - - - while size.height - 5 < text_lines.len() as u16 { - if optional_lines.len() == 0 { - break; - } - text_lines.remove(optional_lines[0] as usize); - optional_lines.remove(0); - } - needed_height += text_lines.len(); - - - let q_informer = if update_needed { - "Q to Exit" - } else { - "Q to Cancel" - }; - if size.width < 60 || size.height < needed_height as u16 { - let width_style = if size.width > 76 { - Style::default().fg(Color::Green) - } else if size.width >= 60 { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::Red) - }; - - let height_style = if size.height > 20 { - Style::default().fg(Color::Green) - } else if size.height >= (header_lines as u16 + 10) { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::Red) - }; - - let warning_text = Text::from(vec![ - Line::from(vec![ - Span::raw("Width: "), - Span::styled(format!("{}", size.width), width_style), - Span::raw(" / 60"), - ]), - Line::from(vec![ - Span::raw("Height: "), - Span::styled(format!("{}", size.height), height_style), - Span::raw(format!(" / {}", header_lines + 10)), - ]), - ]); - - - let warning = Paragraph::new(warning_text) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!("UI Too Small [{}]", q_informer)), - ); - - too_small = true; - f.render_widget(warning, size); - return; - } - - let consent_block = Paragraph::new(Text::from(text_lines)) - .block(Block::default().title(format!(" Update Tensamin User Consent [{}] ", q_informer)).borders(Borders::ALL)); - f.render_widget(consent_block, chunks[0]); - - let downgrade_scenario = tos_needed || pp_needed; - draw_buttons( - f, - chunks[1], - focus, - (eula, tos && pp), - update_needed, - downgrade_scenario, - pp_needed || tos_needed - ); - }) - .unwrap(); - - if event::poll(Duration::from_millis(200)).unwrap() { - if let Event::Key(key) = event::read().unwrap() { - if too_small { - if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) { - break UserChoice::Deny; - } else { - continue; - } - } - let mut possible_states = vec![Focus::Eula]; - - if tos_needed { - possible_states.push(Focus::Tos); - } - if pp_needed { - possible_states.push(Focus::Pp); - } - - possible_states.push(Focus::Cancel); - if eula { - possible_states.push(Focus::Continue); - if tos && pp && (pp_needed || tos_needed) { - possible_states.push(Focus::ContinueAll); - } - } - match key.code { - KeyCode::Esc => break UserChoice::Deny, - KeyCode::Up | KeyCode::Left => focus.prev(&possible_states), - KeyCode::Down | KeyCode::Right | KeyCode::Tab => focus.next(&possible_states), - KeyCode::Char('q') | KeyCode::Char('Q') => break UserChoice::Deny, - KeyCode::Char('o') | KeyCode::Char('O') => { - let terms_type = match focus { - Focus::Eula => Type::EULA, - Focus::Tos => Type::TOS, - Focus::Pp => Type::PP, - _ => continue, - }; - - if let Some(eula) = get_terms(terms_type.clone()).await { - terminal = FileViewer::new(terms_type.to_string(), &eula) - .force_popup(terminal); - } else { - terminal = FileViewer::new( - terms_type.to_string(), - "### A loading error occured\ - \nTry reloading this site or retry in a moment.", - ) - .force_popup(terminal); - } - } - KeyCode::Char('l') | KeyCode::Char('L') => { - let terms_type = match focus { - Focus::Eula => Type::EULA, - Focus::Tos => Type::TOS, - Focus::Pp => Type::PP, - _ => continue, - }; - - let _ = open::that(get_newest_link(terms_type)); - } - KeyCode::Char(' ') | KeyCode::Enter => match focus { - Focus::Eula => { - eula = !eula; - tos = !tos_needed; - pp = !pp_needed; - } - Focus::Tos => { - if eula { - tos = !tos - } - } - Focus::Pp => { - if eula { - pp = !pp - } - } - Focus::Cancel => break UserChoice::Deny, - Focus::Continue if eula => break UserChoice::AcceptEULA, - Focus::ContinueAll if eula && tos && pp => { - break UserChoice::AcceptAll; - } - _ => {} - }, - _ => {} - } - } - } - }; - ratatui::restore(); - - match result { - UserChoice::AcceptAll => { - consent.accepted_eula = true; - consent.accepted_tos = true; - consent.accepted_pp = true; - if let Some(_) = eula_for_future { - consent.future_eula = eula_for_future; - } - if let Some(_) = tos_for_future { - consent.future_tos = tos_for_future; - } - if let Some(_) = pp_for_future { - consent.future_privacy = pp_for_future; - } - } - UserChoice::AcceptEULA => { - consent.accepted_eula = true; - if let Some(_) = eula_for_future { - consent.future_eula = eula_for_future; - } - } - UserChoice::Deny => {} - } - - consent -}