diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..92c386f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,61 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'echoed'", + "cargo": { + "args": [ + "test", + "--no-run", + "--lib", + "--package=echoed" + ], + "filter": { + "name": "echoed", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'echoed'", + "cargo": { + "args": [ + "build", + "--bin=echoed", + "--package=echoed" + ], + "filter": { + "name": "echoed", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'echoed'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=echoed", + "--package=echoed" + ], + "filter": { + "name": "echoed", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/Cargo.lock b/Cargo.lock index de7bdeb..d289c37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,10 +250,15 @@ dependencies = [ ] [[package]] -name = "dyn-clone" -version = "1.0.17" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "echoed" @@ -261,14 +266,15 @@ version = "0.1.0" dependencies = [ "async-trait", "color-eyre", - "cookie_store", "crossterm", - "dyn-clone", + "email_address", + "lazy_static", "paste", "ratatui", "regex", "reqwest", "reqwest_cookie_store", + "thiserror", "tokio", "tui-textarea", "tui_confirm_dialog", @@ -281,6 +287,15 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -358,6 +373,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -365,6 +395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -373,6 +404,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-sink" version = "0.3.31" @@ -391,10 +439,15 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -568,6 +621,124 @@ dependencies = [ "tracing", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "idna" version = "0.3.0" @@ -588,6 +759,27 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indenter" version = "0.3.3" @@ -668,6 +860,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + [[package]] name = "lock_api" version = "0.4.12" @@ -1075,12 +1273,13 @@ dependencies = [ [[package]] name = "reqwest_cookie_store" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9" +source = "git+https://git.newty.dev/newt/reqwest_cookie_store_tokio.git#aad0c697e209c6e79b15ff49b3bf0d05663492c3" dependencies = [ "bytes", "cookie_store", + "futures", "reqwest", + "tokio", "url", ] @@ -1327,6 +1526,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1381,6 +1586,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -1415,6 +1631,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d171f59dbaa811dbbb1aee1e73db92ec2b122911a48e1390dfe327a821ddede" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08be0f17bd307950653ce45db00cd31200d82b624b36e181337d9c7d92765b5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -1458,6 +1694,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -1475,9 +1721,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.0" +version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" dependencies = [ "backtrace", "bytes", @@ -1674,15 +1920,27 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna 1.0.3", "percent-encoding", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.0" @@ -1927,6 +2185,42 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -1948,8 +2242,51 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 4cadb76..7e8aa79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,15 +7,16 @@ edition = "2021" [dependencies] async-trait = "0.1.83" color-eyre = "0.6.3" -cookie_store = "0.21.0" crossterm = "0.28.1" -dyn-clone = "1.0.17" +email_address = "0.2.9" +lazy_static = "1.5.0" paste = "1.0.15" ratatui = "0.29.0" regex = "1.11.1" reqwest = { version = "0.12.9", features = ["cookies", "multipart"] } -reqwest_cookie_store = "0.8.0" -tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] } +reqwest_cookie_store = { git = "https://git.newty.dev/newt/reqwest_cookie_store_tokio.git" } +thiserror = "1.0.66" +tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] } tui-textarea = "0.7.0" tui_confirm_dialog = "0.2.4" -url = "2.5.2" +url = "2.5.3" diff --git a/Untitled-1.txt b/Untitled-1.txt new file mode 100644 index 0000000..86e4898 --- /dev/null +++ b/Untitled-1.txt @@ -0,0 +1,5 @@ +https://content.echo360.org.uk/0000.//1/s1_av.m3u8 + +https://content.echo360.org.uk/0000.89dd0669-23e4-4b1c-b183-3e572d3428e5/f9dab8de-54bb-4081-8ddb-85dfa4819d87/1/s1_v.m3u8 + +https://content.echo360.org.uk/0000.89dd0669-23e4-4b1c-b183-3e572d3428e5/e232784c-6d82-4d19-af69-28feea79b0a8/1/s1_av.m3u8?x-ui diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..12e15d3 --- /dev/null +++ b/readme.md @@ -0,0 +1,8 @@ +# echoed + +## todo + +- cookie persistence +- browsing +- downloading lectures +- support for other echo360 instances diff --git a/src/helper.rs b/src/helper.rs index 10925a2..37c65aa 100644 --- a/src/helper.rs +++ b/src/helper.rs @@ -1 +1,2 @@ +pub mod echo; pub mod tui; diff --git a/src/helper/echo.rs b/src/helper/echo.rs new file mode 100644 index 0000000..275f910 --- /dev/null +++ b/src/helper/echo.rs @@ -0,0 +1,109 @@ +use color_eyre::{eyre::eyre, Result}; +use echoed::{UserData, DEFAULT_ECHO360}; +use regex::Regex; +use reqwest::multipart::Form; +use reqwest_cookie_store::CookieStore; +use std::sync::{MutexGuard, PoisonError}; +use thiserror::Error; + +use crate::State; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("Failed to connect to echo360. Do you have an internet connection?")] + Request(#[from] reqwest::Error), + #[error("Failed to find {0}.")] + Find(&'static str), + #[error("Failed to parse regex.")] + Regex(#[from] regex::Error), + #[error("Invalid details.")] + InvalidDetails, +} + +pub async fn authenticate(email: &str, password: &str, state: &mut State) -> Result<(), AuthError> { + let base_url = DEFAULT_ECHO360.clone(); + let domain = base_url.domain().unwrap(); + + // get the csrf token + state.client.get(base_url.clone()).send().await?; + + let csrf_token = state + .cookies + .lock() + .await + .get(domain, "/", "PLAY_SESSION") + .map(|cookie| { + let value = cookie.value().to_string(); + value + .split("&") + .nth(1) + .map(|part| part.split("=").last()) + .flatten() + .map(|x| x.to_string()) + }) + .flatten() + .ok_or(AuthError::Find("CSRF token"))?; + + // authenticate + state + .client + .post(format!("{base_url}login")) + .query(&[("csrfToken", csrf_token)]) + .multipart( + Form::new() + .text("email", email.to_string()) + .text("password", password.to_string()), + ) + .send() + .await?; + + // get institution id + let insitution_id = state + .cookies + .lock() + .await + .get(domain, "/", "PLAY_SESSION") + .map(|cookie| { + let value = cookie.value().to_string(); + value + .split("&") + .nth(1) + .map(|part| part.split("=").last()) + .flatten() + .map(|x| x.to_string()) + }) + .flatten() + .ok_or(AuthError::Find("Institution id"))?; + + // get authenticated user's name + let name = { + let html = state + .client + .get(format!("{base_url}courses")) + .send() + .await? + .text() + .await?; + let first_name = Regex::new(r#"\\"firstName\\":\\"(\w+)\\""#)? + .captures(&html) + .ok_or(AuthError::InvalidDetails)? + .get(1) + .unwrap() + .as_str(); + let last_name = Regex::new(r#"\\"lastName\\":\\"(\w+)\\""#)? + .captures(&html) + .ok_or(AuthError::InvalidDetails)? + .get(1) + .unwrap() + .as_str(); + format!("{} {}", first_name, last_name) + }; + + // save to state + state.echo_user = Some(UserData { + name: name.into(), + institution_id: insitution_id.into(), + }); + + Ok(()) +} diff --git a/src/helper/tui.rs b/src/helper/tui.rs index 83f35ad..8c91ff3 100644 --- a/src/helper/tui.rs +++ b/src/helper/tui.rs @@ -1,6 +1,6 @@ use ratatui::{ prelude::*, - widgets::{Block, BorderType, Borders}, + widgets::{Block, BorderType, Borders, Padding}, }; // Create a block with @@ -8,13 +8,13 @@ pub fn border(frame: &mut Frame, title: Option<&str>) -> Rect { let outer = frame.area(); let mut block = Block::default() .borders(Borders::ALL) - .border_type(BorderType::Rounded); + .border_type(BorderType::Rounded) + .padding(Padding::proportional(1)); if let Some(title) = title { block = block.title(title); } frame.render_widget(block.clone(), outer); - block.inner(outer) } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0b73a10 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +use std::borrow::Cow; + +use url::Url; + +#[macro_use] +extern crate lazy_static; + +lazy_static! { + pub static ref DEFAULT_ECHO360: Url = Url::parse("https://echo360.org.uk").unwrap(); +} + +pub struct UserData { + pub name: Cow<'static, str>, + pub institution_id: Cow<'static, str>, +} diff --git a/src/main.rs b/src/main.rs index a78abe1..b5903ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,20 @@ use color_eyre::Result; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use echoed::UserData; use ratatui::{ prelude::*, widgets::{BorderType, Borders}, }; +use reqwest::Client; +use reqwest_cookie_store::CookieStoreMutex; use std::{ io::Stdout, - sync::{mpsc, Arc}, + sync::{mpsc as std_mpsc, Arc}, time::{Duration, Instant}, }; use tokio::sync::Mutex; -use tui_confirm_dialog::{ConfirmDialog, ConfirmDialogState, Listener, PopupMessage}; -use views::{ViewCommand, ViewContainer}; +use tui_confirm_dialog::{ConfirmDialog, ConfirmDialogState, Listener}; +use views::ViewContainer; #[macro_use] extern crate async_trait; @@ -23,34 +26,45 @@ mod views; const EXIT_TIMEOUT: Duration = Duration::from_millis(150); const POLL_TIMEOUT: Duration = Duration::from_millis(16); -pub struct State { - exit: ConfirmDialogState, - exit_rx: mpsc::Receiver, +pub enum Command { + ChangeView(ViewContainer), } -impl Default for State { - fn default() -> Self { - let (exit_tx, exit_rx) = mpsc::channel::(); +pub struct State { + exit: ConfirmDialogState, + exit_rx: std_mpsc::Receiver, + client: Client, + cookies: Arc, + echo_user: Option, +} - Self { +impl State { + fn new() -> Result { + let (exit_tx, exit_rx) = std_mpsc::channel::(); + let jar = Arc::new(CookieStoreMutex::default()); + + Ok(Self { exit: ConfirmDialogState::default() .with_title("Exit") .with_text(Text::raw("Are you sure you would like to exit?")) .with_listener(Some(exit_tx)), exit_rx, - // ..Default::default() - } + client: Client::builder().cookie_provider(jar.clone()).build()?, + cookies: jar, + echo_user: None, + }) } } async fn run(mut terminal: Terminal>) -> Result<()> { let mut view: ViewContainer = Arc::new(Mutex::new(views::Auth::default())); view.lock().await.setup(); - let mut state = State::default(); - let (view_tx, view_rx) = mpsc::channel::(); + let mut state = State::new()?; + let (view_tx, view_rx) = std_mpsc::channel::(); let mut exit_triggered = Instant::now(); exit_triggered -= EXIT_TIMEOUT; + // tui loop { // check if the app should exit if let Ok((_, exit)) = state.exit_rx.try_recv() { @@ -59,21 +73,21 @@ async fn run(mut terminal: Terminal>) -> Result<()> { } } - // check for new view commands + // check for new commands match view_rx.try_recv() { - Ok(ViewCommand::Change(new_view)) => { + Ok(Command::ChangeView(new_view)) => { view = new_view; view.lock().await.setup(); } - Err(_) => {} + _ => {} } // draw the view let mut view = view.lock().await; terminal.draw(|frame| { - view.draw(frame); + view.draw(frame, &state); if state.exit.is_opened() { frame.render_stateful_widget( @@ -104,7 +118,7 @@ async fn run(mut terminal: Terminal>) -> Result<()> { } // then handle view keypresses - view.keypress(key, &view_tx).await?; + view.keypress(key, &mut state, &view_tx).await?; // then handle global keypresses @@ -115,7 +129,8 @@ async fn run(mut terminal: Terminal>) -> Result<()> { && !state.exit.is_opened() && exit_triggered.elapsed() >= EXIT_TIMEOUT { - state.exit = state.exit.open(); + let exit = std::mem::take(&mut state.exit); + state.exit = exit.open(); exit_triggered = Instant::now(); } diff --git a/src/prelude.rs b/src/prelude.rs index a2e6a08..d0554e4 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -1,7 +1,9 @@ -pub use crate::{helper::tui as helper, views::ViewCommand}; +pub use crate::helper::tui as helper; +pub use crate::views::View; +pub use crate::{change_view, Command, State}; pub use color_eyre::Result; pub use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; pub use ratatui::prelude::*; pub use std::any::Any; -pub type ViewSender = std::sync::mpsc::Sender; +pub type ViewSender = std::sync::mpsc::Sender; diff --git a/src/views.rs b/src/views.rs index 0a71205..fc544ae 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,5 +1,4 @@ -use crate::prelude::*; -use dyn_clone::DynClone; +use crate::{prelude::*, State}; use std::sync::Arc; use tokio::sync::Mutex; @@ -14,17 +13,29 @@ macro_rules! import_view { }; } -import_view!(Auth); +import_view!(Auth, Home); #[async_trait] -pub trait View: DynClone { - fn setup(&mut self); - fn draw(&self, frame: &mut Frame); - async fn keypress(&mut self, key: KeyEvent, command_tx: &ViewSender) -> Result<()>; +pub trait View { + fn setup(&mut self) {} + fn draw(&self, frame: &mut Frame, state: &State); + async fn keypress( + &mut self, + key: KeyEvent, + state: &mut State, + command_tx: &ViewSender, + ) -> Result<()> { + Ok(()) + } } pub type ViewContainer = Arc>; -pub enum ViewCommand { - Change(ViewContainer), +#[macro_export] +macro_rules! change_view { + ($tx:expr, $view:ident) => { + $tx.send(Command::ChangeView(std::sync::Arc::new( + tokio::sync::Mutex::new(super::$view), + )))? + }; } diff --git a/src/views/auth.rs b/src/views/auth.rs index 5b0b568..a632f11 100644 --- a/src/views/auth.rs +++ b/src/views/auth.rs @@ -1,5 +1,8 @@ -use super::View; -use crate::prelude::*; +use crate::{helper::echo, prelude::*}; +use echoed::DEFAULT_ECHO360; +use email_address::EmailAddress; +use ratatui::widgets::Paragraph; +use std::borrow::Cow; use tui_textarea::TextArea; const SHOW_STYLE: Style = Style::new().add_modifier(Modifier::REVERSED); @@ -10,6 +13,7 @@ pub struct Auth<'t> { email: TextArea<'t>, password: TextArea<'t>, pass_select: bool, + error: Option>, } #[async_trait] @@ -19,26 +23,50 @@ impl<'t> View for Auth<'t> { self.password.set_cursor_style(HIDE_STYLE); } - fn draw(&self, frame: &mut Frame) { - let area = helper::border(frame, Some("Auth")); - let field_areas = - Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); + fn draw(&self, frame: &mut Frame, _: &State) { + let area = helper::border(frame, Some("Login")); + let areas = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(3), + ]) + .split(area); - #[allow(unused_mut)] - for ((mut field, label), area) in [(&self.email, "Email"), (&self.password, "Password")] - .iter() - .zip(field_areas.iter()) - { - let [label_area, field_area] = - Layout::horizontal([Constraint::Percentage(20), Constraint::Percentage(80)]) - .areas(*area); + frame.render_widget( + Paragraph::new(vec![ + Line::raw(format!( + "Please enter your {} details", + DEFAULT_ECHO360.domain().unwrap() + )) + .bold(), + Line::raw("Hit enter to submit."), + ]), + areas[0], + ); - frame.render_widget(Text::raw(*label), label_area); - frame.render_widget(field, field_area); + // render email + let [label_area, field_area] = + Layout::horizontal([Constraint::Length(10), Constraint::Fill(1)]).areas(areas[1]); + frame.render_widget(Text::raw("Email:"), label_area); + frame.render_widget(&self.email, field_area); + + // render password + let [label_area, field_area] = + Layout::horizontal([Constraint::Length(10), Constraint::Fill(1)]).areas(areas[2]); + frame.render_widget(Text::raw("Password:"), label_area); + frame.render_widget(&self.password, field_area); + + // render error + if let Some(error) = &self.error { + frame.render_widget( + Paragraph::new(vec![Line::raw(""), Line::raw(error.clone()).fg(Color::Red)]), + areas[3], + ); } } - async fn keypress(&mut self, key: KeyEvent, _command_tx: &ViewSender) -> Result<()> { + async fn keypress(&mut self, key: KeyEvent, state: &mut State, tx: &ViewSender) -> Result<()> { match key.code { KeyCode::Up => { self.pass_select = false; @@ -57,8 +85,49 @@ impl<'t> View for Auth<'t> { &mut self.email }; + self.error = None; field.input(key); } + KeyCode::Enter => { + // validate email + let email = self.email.lines().concat(); + + if email.is_empty() { + self.error = Some("Email is required".into()); + return Ok(()); + } else if !EmailAddress::is_valid(&email) { + self.error = Some("Invalid email address".into()); + return Ok(()); + } + + // validate password + let password = self.password.lines().concat(); + + if password.is_empty() { + self.error = Some("Password is required".into()); + return Ok(()); + } + + // attempt to authenticate + let email = self.email.lines().concat(); + let password = self.password.lines().concat(); + + match echo::authenticate( + Box::leak(email.into_boxed_str()), + Box::leak(password.into_boxed_str()), + state, + ) + .await + { + Ok(_) => { + change_view!(tx, Home); + } + Err(e) => { + self.error = Some(e.to_string().into()); + return Ok(()); + } + } + } _ => {} } Ok(()) diff --git a/src/views/home.rs b/src/views/home.rs new file mode 100644 index 0000000..5fdca57 --- /dev/null +++ b/src/views/home.rs @@ -0,0 +1,22 @@ +use crate::prelude::*; + +#[derive(Default)] +pub struct Home; + +#[async_trait] +impl View for Home { + fn draw(&self, frame: &mut Frame, state: &State) { + let user = state.echo_user.as_ref().unwrap(); + + frame.render_widget(Line::raw(user.name.to_string()), frame.area()); + } + + async fn keypress( + &mut self, + key: KeyEvent, + state: &mut State, + _command_tx: &ViewSender, + ) -> Result<()> { + Ok(()) + } +} diff --git a/src/views/quit.rs b/src/views/quit.rs new file mode 100644 index 0000000..85d2bea --- /dev/null +++ b/src/views/quit.rs @@ -0,0 +1,61 @@ +use super::{View, ViewContainer}; +use crate::prelude::*; +use ratatui::widgets::{Block, Borders}; + +#[derive(Clone)] +pub struct Quit { + previous: ViewContainer, + yes: bool, +} + +impl Quit { + pub fn new(previous: ViewContainer) -> Self { + Self { + previous, + yes: true, + } + } +} + +#[async_trait] +impl View for Quit { + fn draw(&self, frame: &mut Frame) { + let area = helper::border(frame, None); + + // split vertically + let [title_area, buttons_area] = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area); + + // render title + frame.render_widget( + Text::raw("Are you sure you would like to quit?").centered(), + title_area, + ); + + // render buttons + let [yes_btn_area, no_btn_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let yes_btn = Text::raw("Yes"); + let no_btn = Text::raw("No"); + + frame.render_widget(yes_btn, yes_btn_area); + frame.render_widget(no_btn, no_btn_area); + } + + async fn keypress(&mut self, key: KeyEvent, tx: &ViewSender) -> Result<()> { + match key.code { + KeyCode::Esc => { + tx.send(ViewCommand::Change(self.previous.clone())).await?; + } + _ => {} + } + + Ok(()) + } + + fn as_any(&self) -> &dyn Any { + self + } +}