feat: begin refactor to tauri

This commit is contained in:
newt 2024-11-23 21:07:46 +00:00
parent 68fb777e5f
commit 76c294b709
63 changed files with 6979 additions and 5656 deletions

14
.gitignore vendored
View file

@ -1,3 +1,13 @@
/target
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
cookies.cbor
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
target
gen
old

7
.gitmodules vendored
View file

@ -1,6 +1,3 @@
[submodule "crates/tui-confirm-dialog"]
path = crates/tui-confirm-dialog
url = https://git.newty.dev/newt/tui-confirm-dialog-tokio
[submodule "crates/reqwest_cookie_store_tokio"]
path = crates/reqwest_cookie_store_tokio
[submodule "reqwest_cookie_store_tokio"]
path = reqwest_cookie_store_tokio
url = https://git.newty.dev/newt/reqwest_cookie_store_tokio

7
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

61
.vscode/launch.json vendored
View file

@ -1,61 +0,0 @@
{
"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}"
}
]
}

13
.vscode/settings.json vendored
View file

@ -1,5 +1,12 @@
{
"files.exclude": {
"target": true
}
"svelte.enable-ts-plugin": true,
"files.exclude": {
"node_modules": true,
".svelte-kit": true,
"**/target": true,
"build": true
},
"rust-analyzer.linkedProjects": [
"src-tauri/Cargo.toml"
]
}

2415
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
[workspace]
resolver = "2"
default-members = ["echoed"]
members = ["crates/*", "echoed"]

@ -1 +0,0 @@
Subproject commit 5c1156b1cd6ed765d4d06be82f8c027043aa0258

2349
echoed/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
[package]
name = "echoed"
publish = false
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1.83"
chrono = { version = "0.4.38", default-features = false, features = ["now"] }
color-eyre = "0.6.3"
cookie_store = "0.21.1"
crossterm = "0.28.1"
email_address = "0.2.9"
ijson = "0.1.3"
lazy_static = "1.5.0"
paste = "1.0.15"
ratatui = "0.29.0"
regex = "1.11.1"
reqwest = { version = "0.12.9", features = ["cookies", "json", "multipart"] }
reqwest_cookie_store = { path = "../crates/reqwest_cookie_store_tokio" }
serde_cbor = "0.11.2"
thiserror = "1.0.66"
tokio = { version = "1.41.1", default-features = false, features = ["macros", "rt-multi-thread"] }
tui-textarea = "0.7.0"
tui_confirm_dialog = { path = "../crates/tui-confirm-dialog" }
url = "2.5.3"

View file

@ -1,5 +0,0 @@
https://content.echo360.org.uk/0000.<instid>/<media_id>/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

View file

@ -1,2 +0,0 @@
pub mod echo;
pub mod tui;

View file

@ -1,137 +0,0 @@
use color_eyre::Result;
use echoed::{UserData, DEFAULT_ECHO360};
use regex::Regex;
use reqwest::multipart::Form;
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,
}
async fn save_to_state(state: &mut State) -> Result<(), AuthError> {
let base_url = DEFAULT_ECHO360.clone();
let domain = base_url.domain().unwrap();
// 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(())
}
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?;
save_to_state(state).await?;
Ok(())
}
pub async fn check_auth(state: &mut State) -> Result<bool> {
let html = state
.client
.get(format!("{}courses", DEFAULT_ECHO360.clone()))
.send()
.await?
.text()
.await?;
// if first name is found, then the user is authenticated
let authenticated = Regex::new(r#"\\"firstName\\":\\"(\w+)\\""#)?
.captures(&html)
.is_some();
if authenticated {
save_to_state(state).await?;
}
Ok(authenticated)
}

View file

@ -1,20 +0,0 @@
use ratatui::{
prelude::*,
widgets::{Block, BorderType, Borders, Padding},
};
// Create a block with
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)
.padding(Padding::proportional(1));
if let Some(title) = title {
block = block.title(title);
}
frame.render_widget(block.clone(), outer);
block.inner(outer)
}

View file

@ -1,15 +0,0 @@
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>,
}

View file

@ -1,214 +0,0 @@
use color_eyre::Result;
use cookie_store::{Cookie, CookieDomain, CookieStore};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use echoed::{UserData, DEFAULT_ECHO360};
use helper::echo;
use ratatui::{
prelude::*,
widgets::{BorderType, Borders},
};
use reqwest::Client;
use reqwest_cookie_store::CookieStoreMutex;
use std::{
env::var,
io::{Stdout, Write},
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::{mpsc, Mutex};
use tui_confirm_dialog::{ConfirmDialog, ConfirmDialogState, Listener};
use url::Url;
use views::ViewContainer;
#[macro_use]
extern crate async_trait;
mod helper;
pub mod prelude;
mod views;
const EXIT_TIMEOUT: Duration = Duration::from_millis(150);
const POLL_TIMEOUT: Duration = Duration::from_millis(16);
pub enum Command {
ChangeView(ViewContainer),
}
pub struct State {
exit: ConfirmDialogState,
exit_rx: mpsc::Receiver<Listener>,
client: Client,
cookies: Arc<CookieStoreMutex>,
echo_user: Option<UserData>,
}
impl State {
fn new(jar: CookieStoreMutex) -> Result<Self> {
let (exit_tx, exit_rx) = mpsc::channel::<Listener>(1);
let jar = Arc::new(jar);
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,
client: Client::builder().cookie_provider(jar.clone()).build()?,
cookies: jar,
echo_user: None,
})
}
}
async fn run(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
// load cookies
let mut file = std::fs::File::open("cookies.cbor").ok();
let jar = if let Some(file) = file.as_mut() {
let cookies = serde_cbor::from_reader::<Vec<Cookie>, _>(file)?;
let mut store = CookieStore::default();
for cookie in cookies {
store.insert(
cookie.clone(),
&if let CookieDomain::HostOnly(domain) = cookie.domain {
Url::parse(&format!("https://{}", domain))?
} else {
DEFAULT_ECHO360.clone()
},
)?;
}
CookieStoreMutex::new(store)
} else {
CookieStoreMutex::default()
};
// check if the cookies provide authentication
let mut state = State::new(jar)?;
let mut authenticated = echo::check_auth(&mut state).await?;
// authenticate if env variables are provided
if !authenticated {
if let Ok((email, password)) = var("ECHO360_EMAIL")
.and_then(|email| var("ECHO360_PASSWORD").map(|password| (email, password)))
{
authenticated = echo::authenticate(&email, &password, &mut state)
.await
.is_ok();
}
}
let mut view: ViewContainer = if authenticated {
Arc::new(Mutex::new(views::Courses::default())) as ViewContainer
} else {
Arc::new(Mutex::new(views::Auth::default())) as ViewContainer
};
view.lock().await.setup(&state).await?;
let (view_tx, mut view_rx) = mpsc::channel::<Command>(1);
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() {
println!("{:?}", exit);
if exit.unwrap_or_default() {
// save cookies
// todo: decide on a place to store this
let mut file = std::fs::File::create("cookies.cbor")?;
let lock = state.cookies.lock().await;
let cookies = lock.iter_unexpired().collect::<Vec<_>>();
let cbor = serde_cbor::to_vec(&cookies)?;
file.write_all(&cbor)?;
break;
}
}
// check for new commands
match view_rx.try_recv() {
Ok(Command::ChangeView(new_view)) => {
view = new_view;
view.lock().await.setup(&state).await?;
}
_ => {}
}
// draw the view
let mut view = view.lock().await;
terminal.draw(|frame| {
view.draw(frame, &state);
if state.exit.is_opened() {
frame.render_stateful_widget(
ConfirmDialog::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.selected_button_style(Style::default().yellow().underlined().bold()),
frame.area(),
&mut state.exit,
);
}
})?;
// handle events
if event::poll(POLL_TIMEOUT)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// handle exit popup keypresses above all else
if state.exit.is_opened() {
if key.code == KeyCode::Esc && exit_triggered.elapsed() < EXIT_TIMEOUT {
continue;
} else {
state.exit.handle(key).await;
exit_triggered = Instant::now();
}
continue;
}
// then handle view keypresses
view.keypress(key, &mut state, &view_tx).await?;
// then handle global keypresses
// esc + ctrl+c quit
if (key.code == KeyCode::Esc
|| (key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)))
&& !state.exit.is_opened()
&& exit_triggered.elapsed() >= EXIT_TIMEOUT
{
let exit = std::mem::take(&mut state.exit);
state.exit = exit.open();
exit_triggered = Instant::now();
}
drop(view);
}
_ => {}
}
}
}
#[allow(unreachable_code)]
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
// setup ratatui
let mut terminal = ratatui::init();
terminal.clear()?;
// run ratatui application
let result = run(terminal).await;
// restore terminal to previous state
ratatui::restore();
result
}

View file

@ -1,10 +0,0 @@
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 use std::borrow::Cow;
pub type ViewSender = tokio::sync::mpsc::Sender<Command>;

View file

@ -1,50 +0,0 @@
use crate::{prelude::*, State};
use std::sync::Arc;
use tokio::sync::Mutex;
macro_rules! import_view {
($($view:ident),+) => {
paste::paste! {
$(
mod [<$view:lower>];
pub use [<$view:lower>]::$view;
)+
}
};
}
import_view!(Auth, Courses, Lectures);
#[async_trait]
pub trait View: Send + Sync {
async fn setup(&mut self, _state: &State) -> Result<()> {
Ok(())
}
fn draw(&mut 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<Mutex<dyn View>>;
#[macro_export]
macro_rules! change_view {
(@ $tx:expr, $value:expr) => {
$tx.send(Command::ChangeView(std::sync::Arc::new(
tokio::sync::Mutex::new($value),
)))
.await?
};
($tx:expr, $view:ident) => {
change_view!(@ $tx, super::$view::default())
};
($tx:expr, $view:expr) => {
change_view!(@ $tx, $view)
};
}

View file

@ -1,135 +0,0 @@
use crate::{helper::echo, prelude::*};
use echoed::DEFAULT_ECHO360;
use email_address::EmailAddress;
use ratatui::widgets::Paragraph;
use tui_textarea::TextArea;
const SHOW_STYLE: Style = Style::new().add_modifier(Modifier::REVERSED);
const HIDE_STYLE: Style = Style::new().bg(Color::Reset);
#[derive(Default, Clone)]
pub struct Auth<'t> {
email: TextArea<'t>,
password: TextArea<'t>,
pass_select: bool,
error: Option<Cow<'static, str>>,
}
#[async_trait]
impl<'t> View for Auth<'t> {
async fn setup(&mut self, _: &State) -> Result<()> {
self.password.set_mask_char('\u{2022}');
self.password.set_cursor_style(HIDE_STYLE);
Ok(())
}
fn draw(&mut 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);
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],
);
// 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, state: &mut State, tx: &ViewSender) -> Result<()> {
match key.code {
KeyCode::Up => {
self.pass_select = false;
self.email.set_cursor_style(SHOW_STYLE);
self.password.set_cursor_style(HIDE_STYLE);
}
KeyCode::Down => {
self.pass_select = true;
self.email.set_cursor_style(HIDE_STYLE);
self.password.set_cursor_style(SHOW_STYLE);
}
KeyCode::Char(_) | KeyCode::Backspace => {
let field = if self.pass_select {
&mut self.password
} else {
&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, Courses);
}
Err(e) => {
self.error = Some(e.to_string().into());
return Ok(());
}
}
}
_ => {}
}
Ok(())
}
}

View file

@ -1,89 +0,0 @@
use crate::{prelude::*, views::Lectures};
use color_eyre::eyre::eyre;
use echoed::DEFAULT_ECHO360;
use ratatui::widgets::{List, ListState};
#[derive(Debug)]
pub struct Course {
name: Cow<'static, str>,
code: Cow<'static, str>,
section_id: Cow<'static, str>,
}
#[derive(Default)]
pub struct Courses {
courses: Vec<Course>,
selected: ListState,
}
#[async_trait]
impl View for Courses {
async fn setup(&mut self, state: &State) -> Result<()> {
let res = state
.client
.get(format!("{}user/enrollments", DEFAULT_ECHO360.clone()))
.send()
.await?
.json::<ijson::IValue>()
.await?;
let enrollments = res["data"]
.as_array()
.map(|data| data[0]["userSections"].as_array())
.flatten()
.ok_or(eyre!("No courses found"))?
.iter()
.map(|course| {
course["sectionId"].as_string().and_then(|section_id| {
course["sectionName"].as_string().and_then(|code| {
course["courseName"].as_string().map(|name| Course {
name: name.to_string().into(),
code: code.to_string().into(),
section_id: section_id.to_string().into(),
})
})
})
})
.collect::<Option<Vec<_>>>()
.ok_or(eyre!("No courses found"))?;
self.courses = enrollments;
self.selected.select_first();
Ok(())
}
fn draw(&mut self, frame: &mut Frame, _state: &State) {
let items = self
.courses
.iter()
.map(|course| format!("{} ({})", course.name, course.code));
let list = List::new(items).highlight_style(Style::new().reversed());
frame.render_stateful_widget(list, frame.area(), &mut self.selected);
}
async fn keypress(
&mut self,
key: KeyEvent,
_state: &mut State,
command_tx: &ViewSender,
) -> Result<()> {
match key.code {
KeyCode::Up => self.selected.select_previous(),
KeyCode::Down => self.selected.select_next(),
KeyCode::Enter => {
let i = self.selected.selected_mut().unwrap_or(0);
let course = self.courses.get(i).expect("should exist");
change_view!(
command_tx,
Lectures::new(
format!("{} ({})", course.name, course.code).into(),
course.section_id.clone()
)
)
}
_ => {}
}
Ok(())
}
}

View file

@ -1,103 +0,0 @@
use crate::prelude::*;
use chrono::{DateTime, Utc};
use color_eyre::eyre::eyre;
use echoed::DEFAULT_ECHO360;
use ratatui::widgets::{List, ListState};
pub struct Lecture {
name: Cow<'static, str>,
date: DateTime<Utc>,
}
pub struct Lectures {
name: Cow<'static, str>,
section_id: Cow<'static, str>,
lectures: Vec<Lecture>,
count: u16,
selected: ListState,
}
impl Lectures {
pub fn new(name: Cow<'static, str>, section_id: Cow<'static, str>) -> Self {
Self {
name,
section_id,
lectures: vec![],
count: 0,
selected: ListState::default(),
}
}
}
#[async_trait]
impl View for Lectures {
async fn setup(&mut self, state: &State) -> Result<()> {
let res = state
.client
.get(format!(
"{}section/{}/syllabus",
DEFAULT_ECHO360.clone(),
self.section_id
))
.send()
.await?
.json::<ijson::IValue>()
.await?;
let lectures = res["data"]
.as_array()
.map(|data| {
data.iter()
.map(|data| data["lesson"].clone())
.map(|lesson| {
lesson["lesson"]["displayName"]
.as_string()
.and_then(|name| {
lesson["endTimeUTC"].as_string().and_then(|date| {
DateTime::parse_from_rfc3339(&date.to_string()).ok().map(
|date| Lecture {
name: name.to_string().into(),
date: date.with_timezone(&Utc),
},
)
})
})
})
.collect::<Option<Vec<_>>>()
})
.flatten()
.ok_or(eyre!("No lectures found"))?;
self.count = lectures.len() as u16;
let now = Utc::now();
self.lectures = lectures
.into_iter()
.filter(|lecture| lecture.date <= now)
.collect();
self.selected.select_first();
Ok(())
}
fn draw(&mut self, frame: &mut Frame, _state: &State) {
let items = self.lectures.iter().map(|lecture| lecture.name.to_string());
let list = List::new(items).highlight_style(Style::new().reversed());
frame.render_stateful_widget(list, frame.area(), &mut self.selected);
}
async fn keypress(
&mut self,
key: KeyEvent,
_state: &mut State,
_command_tx: &ViewSender,
) -> Result<()> {
match key.code {
KeyCode::Up => self.selected.select_previous(),
KeyCode::Down => self.selected.select_next(),
_ => {}
}
Ok(())
}
}

17
justfile Normal file
View file

@ -0,0 +1,17 @@
set shell := ["cmd.exe", "/c"]
[doc('Start tauri application')]
run:
pnpm run tauri dev
[doc('Build tauri application')]
build:
pnpm run tauri build
[doc('Run cargo command')]
cargo +cmd:
cd src-tauri && cargo {{cmd}}
[doc('Run tauri command')]
tauri +cmd:
pnpm run tauri {cmd}

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "echoed",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2"
},
"devDependencies": {
"@iconify/svelte": "^4.0.2",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.7.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tauri-apps/cli": "^2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.15",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"vite": "^5.4.10"
}
}

1898
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,12 +1,3 @@
# echoed
## todo
- loading screen for checking authentication (cookie persistence)
- browsing
- downloading lectures
- support for other echo360 instances
- make it look good
- cache courses
- check for network assets
- download queue
desktop app for echo360 that supports downloading lectures

4672
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

34
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "echoed"
version = "0.1.0"
publish = false
description = "echo360 desktop application"
authors = ["newt <hi@newty.dev>"]
edition = "2021"
[lib]
name = "echoed_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12.9", default-features = false, features = ["json", "multipart"] }
reqwest_cookie_store = { path = "../reqwest_cookie_store_tokio" }
thiserror = "2.0.3"
regex = { version = "1.11.1", default-features = false, features = ["perf", "std"] }
[profile.dev]
incremental = true # Compile your binary in smaller steps.
[profile.release]
codegen-units = 1 # Allows LLVM to perform better optimization.
lto = true # Enables link-time-optimizations.
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
panic = "abort" # Higher performance by disabling panic handlers.
strip = true # Ensures debug symbols are removed.

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

14
src-tauri/readme.md Normal file
View file

@ -0,0 +1,14 @@
## notes
- profiles file
- all profiles in one file
- protobuf encoding
- schema
- default (index of default profile)
- profiles
- friendly name
- server url
- cookies
## todo
- automatically load cookies from [`tauri::api::path::app_config_dir`](https://docs.rs/tauri/1.8.1/tauri/api/path/fn.app_config_dir.html)

11
src-tauri/src/data.rs Normal file
View file

@ -0,0 +1,11 @@
use tauri::async_runtime::Mutex;
// todo: reqwest client for each profile, or a way to swap the cookie jar used by a reqwest client
/// Tauri application state
pub struct AppState {
/// Reqwest client connected to profile cookie jar
pub client: reqwest::Client,
}
/// Wrapper around `tauri::State` to use custom app state.
pub type State<'t> = tauri::State<'t, Mutex<AppState>>;

20
src-tauri/src/error.rs Normal file
View file

@ -0,0 +1,20 @@
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Tauri(#[from] tauri::Error),
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
#[error(transparent)]
Regex(#[from] regex::Error),
}
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}

62
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,62 @@
use data::{AppState, State};
use error::Result;
use reqwest_cookie_store::CookieStoreMutex;
use std::sync::Arc;
use tauri::{async_runtime::Mutex, Manager};
mod data;
mod error;
#[macro_use]
extern crate tauri;
const DEFAULT_ECHO360: &str = "https://echo360.org.uk";
/// Check if the user is authenticated with echo360
#[command(rename_all = "snake_case")]
async fn is_authenticated(state: State<'_>) -> Result<bool> {
// get client
let state = state.lock().await;
// fetch courses page
let html = state
.client
.get(format!("{}/courses", DEFAULT_ECHO360))
.send()
.await?
.text()
.await?;
// the courses page redirects to a sign in page if the user is not authenticated
// if we can find the user's first name somewhere in the html, then the user must be authenticated
let authenticated = regex::Regex::new(r#"\\"firstName\\":\\"(\w+)\\""#)?
.captures(&html)
.is_some();
Ok(authenticated)
}
#[cfg_attr(mobile, mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![is_authenticated])
.setup(|app| {
// todo: load profile file, or create if it does not exist
// create reqwest client with cookie jar
// todo: load cookies into cookie jar
//? https://docs.rs/tauri/1.8.1/tauri/api/path/fn.app_config_dir.html
let jar = Arc::new(CookieStoreMutex::default());
let client = reqwest::Client::builder()
.cookie_provider(jar.clone())
.build()?;
// store app state
app.manage(Mutex::new(AppState { client }));
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
echoed_lib::run()
}

35
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "echoed",
"version": "0.1.0",
"identifier": "dev.newty.echoed",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "echoed",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

3
src/app.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tauri + SvelteKit + Typescript App</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,5 @@
<script lang="ts">
import "../app.css";
</script>
<slot />

5
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we will use adapter-static to prerender the app (SSG)
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const prerender = true;
export const ssr = false;

24
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
let is_authenticated: Promise<boolean> = invoke("is_authenticated");
</script>
{#await is_authenticated}
<h1>Loading...</h1>
{:then authenticated}
{#if authenticated}
<h1 class="text-green-500">Authenticated</h1>
{:else}
<h1 class="text-red-500">Unauthenticated</h1>
{/if}
{/await}
<style lang="postcss">
@tailwind components;
@layer components {
h1 {
@apply text-2xl font-bold;
}
}
</style>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

15
svelte.config.js Normal file
View file

@ -0,0 +1,15 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we will use adapter-static to prerender the app (SSG)
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

9
tailwind.config.js Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [],
}

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

32
vite.config.js Normal file
View file

@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));