[Add] Terms Updater

This commit is contained in:
Alex-Emmet 2026-02-07 18:46:07 +01:00
commit bfefa46839
9 changed files with 507 additions and 468 deletions

5
Cargo.lock generated
View file

@ -351,9 +351,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
@ -1552,6 +1552,7 @@ dependencies = [
"axum",
"base64",
"bytes",
"chrono",
"cmake",
"color-eyre",
"crossterm 0.29.0",

View file

@ -59,3 +59,4 @@ strum_macros = "0.27.2"
ratatui = "0.30.0"
ratatui_input = "0.1.3"
open = "5.3.3"
chrono = "0.4.43"

175
src/terms/buttons.rs Normal file
View file

@ -0,0 +1,175 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::terms::focus::Focus;
#[allow(mismatched_lifetime_syntaxes)]
pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line {
let box_char = if checked { "[x]" } else { "[ ]" };
let (box_style, text_style) = if active {
if allowed {
(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
}
} else {
(Style::default(), Style::default())
};
Line::from(vec![
Span::styled(box_char, box_style),
Span::raw(" "),
Span::styled(label, text_style),
])
}
pub fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
let p = Paragraph::new(Span::styled(label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
pub fn draw_buttons(
f: &mut ratatui::Frame,
area: Rect,
current_focus: Focus,
state: (bool, bool),
update_needed: bool,
downgrade_scenario: bool,
tos_or_privacy: bool,
) {
let cancel_text = if update_needed {
"[Q] Quit"
} else {
"[Q] Not now"
};
let continue_text = if downgrade_scenario {
"Downgrade"
} else {
"Continue"
};
let mut buttons = vec![
(cancel_text, Focus::Cancel),
(continue_text, Focus::Continue),
];
if tos_or_privacy {
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
}
let padding = 2;
let min_widths: Vec<u16> = buttons
.iter()
.map(|(label, _)| label.len() as u16 + padding)
.collect();
let widths = compute_widths(area.width, &min_widths);
let mut x = area.x;
for ((label, focus), width) in buttons.iter().zip(widths) {
let chunk = Rect {
x,
y: area.y,
width,
height: area.height,
};
x += width;
let is_focused = current_focus == *focus;
let style = match focus {
Focus::Cancel => {
if is_focused {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
}
}
Focus::Continue => {
if is_focused && state.0 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.0 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
Focus::ContinueAll => {
if is_focused && state.1 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.1 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
_ => Style::default().fg(Color::DarkGray),
};
draw_button(f, chunk, label, style);
}
}
pub fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec<u16> {
let mut widths = vec![0; min_widths.len()];
let mut remaining: Vec<usize> = (0..min_widths.len()).collect();
let mut remaining_width = area_width;
while !remaining.is_empty() {
let count = remaining.len() as u16;
let equal = remaining_width / count;
let mut clamped = Vec::new();
for &i in &remaining {
if min_widths[i] > equal {
widths[i] = min_widths[i];
remaining_width -= min_widths[i];
clamped.push(i);
}
}
if clamped.is_empty() {
let mut remainder = remaining_width % count;
for &i in &remaining {
widths[i] = equal
+ if remainder > 0 {
remainder -= 1;
1
} else {
0
};
}
break;
}
remaining.retain(|i| !clamped.contains(i));
}
widths
}

View file

@ -13,14 +13,15 @@ pub struct ConsentManager;
impl ConsentManager {
pub async fn check() -> (bool, bool) {
let file = load_file("", "agreements");
let mut file_state = ConsentState::from_str(&file).sanitize();
let (mut file_state, changed) = ConsentState::from_str(&file).sanitize();
if changed {
save_file("", "agreements", &file_state.to_string());
}
if !file_state.accepted_eula {
file_state = terms_checker::run_consent_ui(file_state).await;
println!("{}", &file_state.clone().sanitize().to_string());
println!("-----------------------");
save_file("", "agreements", &file_state.to_string());
}
@ -34,17 +35,16 @@ impl ConsentManager {
|| matches!(privacy_update, UpdateDecision::Forced(_));
let updater_logic = async move {
let state_to_update = terms_updater::run_consent_ui(
let (state_to_update, _) = terms_updater::run_consent_ui(
file_state,
eula_update,
tos_update,
privacy_update,
)
.await;
.await
.sanitize();
println!("{}", &state_to_update.clone().sanitize().to_string());
save_file("", "agreements", &state_to_update.sanitize().to_string());
save_file("", "agreements", &state_to_update.to_string());
};
// ======================================================== //
// TODO: Implement UI Drawing order, draw update As PoPup //
@ -56,7 +56,11 @@ impl ConsentManager {
//}
}
let final_state = ConsentState::from_str(&load_file("", "agreements")).sanitize();
let file = load_file("", "agreements");
let (final_state, changed) = ConsentState::from_str(&file).sanitize();
if changed {
save_file("", "agreements", &final_state.to_string());
}
(
final_state.accepted_eula,
final_state.accepted_tos && final_state.accepted_pp,
@ -72,8 +76,10 @@ impl ConsentManager {
UpdateDecision,
)> {
let file = load_file("", "agreements");
let accepted_state = ConsentState::from_str(&file).sanitize();
let (accepted_state, changed) = ConsentState::from_str(&file).sanitize();
if changed {
save_file("", "agreements", &accepted_state.to_string());
}
if let (
Some((current_eula, current_tos, current_privacy)),
Some((newest_eula, newest_tos, newest_privacy)),
@ -83,8 +89,12 @@ impl ConsentManager {
if current_eula.equals(&newest_eula) {
UpdateDecision::NoChange
} else {
UpdateDecision::Future {
newest: newest_eula,
if newest_eula.equals_some(&accepted_state.future_eula) {
UpdateDecision::NoChange
} else {
UpdateDecision::Future {
newest: newest_eula,
}
}
}
} else if newest_eula.equals_some(&accepted_state.eula) {
@ -93,35 +103,53 @@ impl ConsentManager {
UpdateDecision::Forced(current_eula)
};
let tos_update: UpdateDecision = if newest_tos.equals_some(&accepted_state.tos) {
let tos_update: UpdateDecision = if !accepted_state.accepted_tos
|| newest_tos.equals_some(&accepted_state.tos)
{
UpdateDecision::NoChange
} else if accepted_state.accepted_tos && current_tos.equals_some(&accepted_state.tos) {
if current_tos.equals(&newest_tos) {
UpdateDecision::NoChange
} else {
UpdateDecision::Future { newest: newest_tos }
if newest_tos.equals_some(&accepted_state.future_tos) {
UpdateDecision::NoChange
} else {
UpdateDecision::Future { newest: newest_tos }
}
}
} else {
UpdateDecision::Forced(current_tos)
};
let privacy_update: UpdateDecision =
if newest_privacy.equals_some(&accepted_state.privacy) {
let privacy_update: UpdateDecision = if !accepted_state.accepted_pp
|| newest_privacy.equals_some(&accepted_state.privacy)
{
UpdateDecision::NoChange
} else if accepted_state.accepted_pp
&& current_privacy.equals_some(&accepted_state.privacy)
{
if current_privacy.equals(&newest_privacy) {
UpdateDecision::NoChange
} else if accepted_state.accepted_pp
&& current_privacy.equals_some(&accepted_state.privacy)
{
if current_privacy.equals(&newest_privacy) {
} else {
if newest_privacy.equals_some(&accepted_state.future_privacy) {
UpdateDecision::NoChange
} else {
UpdateDecision::Future {
newest: newest_privacy,
}
}
} else {
UpdateDecision::Forced(current_privacy)
};
Some((eula_update, tos_update, privacy_update))
}
} else {
UpdateDecision::Forced(current_privacy)
};
match (&eula_update, &tos_update, &privacy_update) {
(
&UpdateDecision::NoChange,
&UpdateDecision::NoChange,
&UpdateDecision::NoChange,
) => None,
_ => Some((eula_update, tos_update, privacy_update)),
}
} else {
None
}
@ -158,12 +186,41 @@ pub struct ConsentState {
}
impl ConsentState {
fn sanitize(mut self) -> Self {
fn sanitize(mut self) -> (Self, bool) {
let mut changed = false;
let current_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if let Some(future_eula) = self.future_eula.clone() {
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
(self, changed)
}
pub fn to_string(&self) -> String {
@ -325,7 +382,7 @@ impl ConsentState {
let future_eula_time = future_eula_time.parse::<u64>().unwrap_or(0);
let future_tos_time = future_tos_time.parse::<u64>().unwrap_or(0);
let future_pp_time = future_pp_time.parse::<u64>().unwrap_or(0);
Self {
let (state, _) = Self {
accepted_eula: eula,
eula: Some(Doc::new(eula_version, eula_hash, Type::EULA, unix)),
accepted_pp: pp,
@ -363,6 +420,8 @@ impl ConsentState {
None
},
}
.sanitize()
.sanitize();
state
}
}

View file

@ -98,7 +98,7 @@ impl FileViewer {
.block(
Block::default()
.borders(Borders::ALL)
.title(self.title.as_str()),
.title(format!("{} - [Q to close]", self.title.as_str(),)),
)
.wrap(Wrap { trim: false })
.scroll((self.scroll, 0));

View file

@ -1,3 +1,4 @@
pub mod buttons;
pub mod consent_state;
pub mod doc;
pub mod focus;

View file

@ -1,4 +1,5 @@
use crate::terms::{
buttons::{checkbox, draw_buttons},
consent_state::{ConsentState, UserChoice},
focus::Focus,
md_viewer::FileViewer,
@ -7,7 +8,7 @@ use crate::terms::{
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
@ -29,12 +30,12 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
if size.height < 6
|| size.width < 27 {
f.render_widget(Line::from(format!("too small {}/63 by {}/12", size.width, size.height)), size);
f.render_widget(Line::from(format!("too small {}/63 by {}/13", size.width, size.height)), size);
return;
}
let max_width = 150;
let max_height = 20;
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} ;
@ -52,28 +53,28 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
height: content_height,
});
let eula_text = if size.width < 70 {
"EULA ¹ (https://legal.tensamin.net/eula/newest/)"
"EULA ¹ (https://legal.tensamin.net/eula/)"
} else {
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)"
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)"
};
let tos_text = if size.width < 72 {
"ToS ² (https://legal.tensamin.net/terms-of-service/newest/)"
"ToS ² (https://legal.tensamin.net/terms-of-service/)"
} else {
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)"
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/)"
};
let pp_text = if size.width < 68 {
"PP ² (https://legal.tensamin.net/privacy-policy/newest/)"
"PP ² (https://legal.tensamin.net/privacy-policy/)"
} else {
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)"
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)"
};
let (mut optional_lines, agree_lines): (Vec<i16>, Vec<&str>) =
if size.width > 143 {
(
vec![7, 3, 7, 4],
vec![8, 3, 8, 5],
vec![
"",
"By selecting Continue, you confirm that you have read, understood and agree to the End User License Agreement and applicable Terms of Service.",
"By selecting Continue, you confirm that you have agree to the End User License Agreement and applicable Terms of Service.",
"",
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
"",
@ -82,10 +83,10 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
)
} else if size.width > 92 {
(
vec![8, 3, 8, 4],
vec![9, 3, 9, 5],
vec![
"",
"By selecting Continue, you confirm that you have read, understood and agree to the End User",
"By selecting Continue, you confirm that you have agree to the End User",
"License Agreement and applicable Terms of Service.",
"",
"Tensamin services require acceptance of the Terms of Service and Privacy Policy.",
@ -95,7 +96,7 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
)
} else if size.width > 73 {
(
vec![8, 3, 8, 4],
vec![9, 3, 9, 5],
vec![
"",
"By selecting Continue, you confirm that you have read, understood and",
@ -109,11 +110,11 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
)
} else {
(
vec![9, 3, 10, 4],
vec![10, 3, 11, 5],
vec![
"",
"By selecting Continue, you confirm that you have",
"read, understood and agree to the End User License",
"agree to the End User License",
"Agreement and applicable Terms of Service.",
"",
"Tensamin services require acceptance of the",
@ -129,9 +130,9 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
checkbox(tos_text, tos, focus == Focus::Tos, eula),
checkbox(pp_text, pp, focus == Focus::Pp, eula),
Line::from(""),
Line::from("¹ Necessary, ² Optional"),
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));
}
@ -156,12 +157,12 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
Style::default().fg(Color::Red)
};
let height_style = if size.height < 12 {
Style::default().fg(Color::Red)
} else if size.height > 19 {
let height_style = if size.height > 19 {
Style::default().fg(Color::Green)
} else {
} else if size.height >= 13 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Red)
};
let warning_text = Text::from(vec![
@ -173,7 +174,7 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
Line::from(vec![
Span::raw("Height: "),
Span::styled(format!("{}", size.height), height_style),
Span::raw(format!(" / 12")),
Span::raw(format!(" / 13")),
]),
]);
@ -182,7 +183,7 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
.block(
Block::default()
.borders(Borders::ALL)
.title("UI Too Small (Q to Quit)"),
.title(format!("UI Too Small [Q to Quit]")),
);
too_small = true;
@ -194,7 +195,7 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
.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));
draw_buttons(f, chunks[1], focus, (eula, tos && pp), true, false, true);
})
.unwrap();
@ -296,8 +297,8 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
}
UserChoice::AcceptEULA => {
consent.accepted_eula = true;
consent.accepted_tos = true;
consent.accepted_pp = true;
consent.accepted_tos = false;
consent.accepted_pp = false;
}
UserChoice::Deny => {
consent.accepted_eula = false;
@ -308,155 +309,3 @@ pub async fn run_consent_ui(mut consent: ConsentState) -> ConsentState {
consent
}
#[allow(mismatched_lifetime_syntaxes)]
fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line {
let box_char = if checked { "[x]" } else { "[ ]" };
let (box_style, text_style) = if active {
if allowed {
(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
}
} else {
(Style::default(), Style::default())
};
Line::from(vec![
Span::styled(box_char, box_style),
Span::raw(" "),
Span::styled(label, text_style),
])
}
fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
let p = Paragraph::new(Span::styled(label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
fn draw_buttons(
f: &mut ratatui::Frame,
area: Rect,
current_focus: Focus,
state: (bool, bool, bool),
) {
let buttons = vec![
("[Q] Cancel", Focus::Cancel),
("Continue", Focus::Continue),
("Continue with Tensamin Services", Focus::ContinueAll),
];
let padding = 2;
let min_widths: Vec<u16> = buttons
.iter()
.map(|(label, _)| label.len() as u16 + padding)
.collect();
let widths = compute_widths(area.width, &min_widths);
let mut x = area.x;
for ((label, focus), width) in buttons.iter().zip(widths) {
let chunk = Rect {
x,
y: area.y,
width,
height: area.height,
};
x += width;
let is_focused = current_focus == *focus;
let style = match focus {
Focus::Cancel => {
if is_focused {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
}
}
Focus::Continue => {
if is_focused && state.0 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.0 {
Style::default().fg(Color::Green) // enabled but not focused
} else {
Style::default().fg(Color::DarkGray)
}
}
Focus::ContinueAll => {
if is_focused && state.0 && state.1 && state.2 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.0 && state.1 && state.2 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
_ => Style::default().fg(Color::DarkGray),
};
draw_button(f, chunk, label, style);
}
}
fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec<u16> {
let mut widths = vec![0; min_widths.len()];
let mut remaining: Vec<usize> = (0..min_widths.len()).collect();
let mut remaining_width = area_width;
while !remaining.is_empty() {
let count = remaining.len() as u16;
let equal = remaining_width / count;
let mut clamped = Vec::new();
for &i in &remaining {
if min_widths[i] > equal {
widths[i] = min_widths[i];
remaining_width -= min_widths[i];
clamped.push(i);
}
}
if clamped.is_empty() {
let mut remainder = remaining_width % count;
for &i in &remaining {
widths[i] = equal
+ if remainder > 0 {
remainder -= 1;
1
} else {
0
};
}
break;
}
remaining.retain(|i| !clamped.contains(i));
}
widths
}

View file

@ -29,6 +29,9 @@ impl Type {
pub fn get_link(terms_type: Type) -> String {
format!("https://legal.tensamin.net/{}/", terms_type.to_str())
}
pub fn get_newest_link(terms_type: Type) -> String {
format!("https://legal.tensamin.net/{}/newest/", terms_type.to_str())
}
pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> {
let body = reqwest::get("https://legal.tensamin.net/api/current/")

View file

@ -1,19 +1,20 @@
use crate::terms::{
buttons::{checkbox, draw_buttons},
consent_state::{ConsentState, UpdateDecision, UserChoice},
doc::Doc,
focus::Focus,
md_viewer::FileViewer,
terms_getter::{Type, get_link, get_terms},
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, Modifier, Style},
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,
@ -38,9 +39,18 @@ pub async fn run_consent_ui(
UpdateDecision::Future { newest } => (true, true, Some(newest)),
UpdateDecision::Forced(doc) => (true, false, Some(doc)),
};
let update_needed = eula_needed || tos_needed || pp_needed;
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 = Focus::Eula;
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;
@ -52,12 +62,12 @@ pub async fn run_consent_ui(
if size.height < 6
|| size.width < 27 {
f.render_widget(Line::from(format!("too small {}/63 by {}/12", size.width, size.height)), size);
f.render_widget(Line::from(format!("too small {}/63 by {}/15", size.width, size.height)), size);
return;
}
let max_width = 150;
let max_height = 20;
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} ;
@ -76,18 +86,46 @@ pub async fn run_consent_ui(
});
let mut text_lines = Vec::new();
let mut text_lines: Vec<Line> = 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 Tensamins 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 Tensamins 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 < 70 {
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 < 70 {
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));
@ -96,15 +134,28 @@ pub async fn run_consent_ui(
}
if tos_needed {
header_lines += 1;
if tos_future {
if size.width < 70 {
text_lines.push(checkbox("ToS ²³ (https://legal.tensamin.net/terms-of-service/newest/)", tos, focus == Focus::Tos, eula));
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 < 70 {
text_lines.push(checkbox("ToS ² (https://legal.tensamin.net/terms-of-service/newest/)", tos, focus == Focus::Tos, eula));
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));
}
@ -112,15 +163,28 @@ pub async fn run_consent_ui(
}
if pp_needed {
header_lines += 1;
if pp_future {
if size.width < 70 {
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 < 70 {
text_lines.push(checkbox("PP ² (https://legal.tensamin.net/privacy-policy/newest/)", pp, focus == Focus::Pp, pp));
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));
}
@ -128,70 +192,100 @@ pub async fn run_consent_ui(
}
text_lines.push(Line::from(""));
text_lines.push(Line::from("¹ Necessary, ² Optional, ³ Future"));
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 - Youll continue using this version, automatically updated when changes apply."));
}
text_lines.push(Line::from(""));
let (mut optional_lines, agree_lines): (Vec<i16>, Vec<&str>) =
let mut optional_lines: Vec<i16> =
if size.width > 143 {
(
vec![7, 3, 7, 4],
vec![
"By selecting Continue, you confirm that you have read, understood and 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.",
]
)
if tos_needed || pp_needed {
text_lines.push(Line::from("By selecting Downgrade, you confirm that you have 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 have 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 {
(
vec![8, 3, 8, 4],
vec![
"",
"By selecting Continue, you confirm that you have read, understood and 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.",
]
)
if tos_needed || pp_needed {
text_lines.push(Line::from("By selecting Continue, you confirm that you have 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 have 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 {
(
vec![8, 3, 8, 4],
vec![
"By selecting Continue, you confirm that you have read, understood and",
"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.",
]
)
if tos_needed || pp_needed {
text_lines.push(Line::from("By selecting Continue, you confirm that you have 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 have 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 {
(
vec![9, 3, 10, 4],
vec![
"By selecting Continue, you confirm that you have",
"read, understood and 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.",
]
)
if tos_needed || pp_needed {
text_lines.push(Line::from("By selecting Continue, you confirm that you have",));
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 have",));
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]
}
};
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;
@ -202,7 +296,11 @@ pub async fn run_consent_ui(
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)
@ -212,12 +310,12 @@ pub async fn run_consent_ui(
Style::default().fg(Color::Red)
};
let height_style = if size.height < 12 {
Style::default().fg(Color::Red)
} else if size.height > 19 {
let height_style = if size.height > 20 {
Style::default().fg(Color::Green)
} else {
} 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![
@ -229,16 +327,17 @@ pub async fn run_consent_ui(
Line::from(vec![
Span::raw("Height: "),
Span::styled(format!("{}", size.height), height_style),
Span::raw(format!(" / 12")),
Span::raw(format!(" / {}", header_lines + 10)),
]),
]);
let warning = Paragraph::new(warning_text)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("UI Too Small (Q to Quit)"),
.title(format!("UI Too Small [{}]", q_informer)),
);
too_small = true;
@ -247,7 +346,7 @@ pub async fn run_consent_ui(
}
let consent_block = Paragraph::new(Text::from(text_lines))
.block(Block::default().title(format!(" Update Tensamin User Consent [Q to Quit] {}, {}, {}", pp_needed, eula_needed, tos_needed)).borders(Borders::ALL));
.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;
@ -258,6 +357,7 @@ pub async fn run_consent_ui(
(eula, tos && pp),
update_needed,
downgrade_scenario,
pp_needed || tos_needed
);
})
.unwrap();
@ -271,10 +371,19 @@ pub async fn run_consent_ui(
continue;
}
}
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
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 {
if tos && pp && (pp_needed || tos_needed) {
possible_states.push(Focus::ContinueAll);
}
}
@ -311,9 +420,9 @@ pub async fn run_consent_ui(
_ => continue,
};
let _ = open::that(get_link(terms_type));
let _ = open::that(get_newest_link(terms_type));
}
KeyCode::Char(' ') => match focus {
KeyCode::Char(' ') | KeyCode::Enter => match focus {
Focus::Eula => {
eula = !eula;
tos = !tos_needed;
@ -329,9 +438,6 @@ pub async fn run_consent_ui(
pp = !pp
}
}
_ => {}
},
KeyCode::Enter => match focus {
Focus::Cancel => break UserChoice::Deny,
Focus::Continue if eula => break UserChoice::AcceptEULA,
Focus::ContinueAll if eula && tos && pp => {
@ -351,180 +457,24 @@ pub async fn run_consent_ui(
consent.accepted_eula = true;
consent.accepted_tos = true;
consent.accepted_pp = true;
consent.future_eula = eula_for_future;
consent.future_tos = tos_for_future;
consent.future_privacy = pp_for_future;
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;
consent.future_eula = eula_for_future;
if let Some(_) = eula_for_future {
consent.future_eula = eula_for_future;
}
}
UserChoice::Deny => {}
}
consent
}
#[allow(mismatched_lifetime_syntaxes)]
fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line {
let box_char = if checked { "[x]" } else { "[ ]" };
let (box_style, text_style) = if active {
if allowed {
(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
}
} else {
(Style::default(), Style::default())
};
Line::from(vec![
Span::styled(box_char, box_style),
Span::raw(" "),
Span::styled(label, text_style),
])
}
fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
let p = Paragraph::new(Span::styled(label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
fn draw_buttons(
f: &mut ratatui::Frame,
area: Rect,
current_focus: Focus,
state: (bool, bool),
update_needed: bool,
downgrade_scenario: bool,
) {
let cancel_text = if update_needed {
"[Q] Quit"
} else {
"[Q] Not now"
};
let continue_text = if downgrade_scenario {
"Downgrade"
} else {
"Continue"
};
let buttons = vec![
(cancel_text, Focus::Cancel),
(continue_text, Focus::Continue),
("Continue with Tensamin Services", Focus::ContinueAll),
];
let padding = 2;
let min_widths: Vec<u16> = buttons
.iter()
.map(|(label, _)| label.len() as u16 + padding)
.collect();
let widths = compute_widths(area.width, &min_widths);
let mut x = area.x;
for ((label, focus), width) in buttons.iter().zip(widths) {
let chunk = Rect {
x,
y: area.y,
width,
height: area.height,
};
x += width;
let is_focused = current_focus == *focus;
let style = match focus {
Focus::Cancel => {
if is_focused {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
}
}
Focus::Continue => {
if is_focused && state.0 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.0 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
Focus::ContinueAll => {
if is_focused && state.1 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.1 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
_ => Style::default().fg(Color::DarkGray),
};
draw_button(f, chunk, label, style);
}
}
fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec<u16> {
let mut widths = vec![0; min_widths.len()];
let mut remaining: Vec<usize> = (0..min_widths.len()).collect();
let mut remaining_width = area_width;
while !remaining.is_empty() {
let count = remaining.len() as u16;
let equal = remaining_width / count;
let mut clamped = Vec::new();
for &i in &remaining {
if min_widths[i] > equal {
widths[i] = min_widths[i];
remaining_width -= min_widths[i];
clamped.push(i);
}
}
if clamped.is_empty() {
let mut remainder = remaining_width % count;
for &i in &remaining {
widths[i] = equal
+ if remainder > 0 {
remainder -= 1;
1
} else {
0
};
}
break;
}
remaining.retain(|i| !clamped.contains(i));
}
widths
}