feat: begin refactor to tauri
14
.gitignore
vendored
|
@ -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
|
@ -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
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
61
.vscode/launch.json
vendored
|
@ -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
|
@ -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
|
@ -1,4 +0,0 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
default-members = ["echoed"]
|
||||
members = ["crates/*", "echoed"]
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 5c1156b1cd6ed765d4d06be82f8c027043aa0258
|
2349
echoed/Cargo.lock
generated
|
@ -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"
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
|||
pub mod echo;
|
||||
pub mod tui;
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>,
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>;
|
|
@ -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)
|
||||
};
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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
|
@ -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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
11
readme.md
|
@ -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
34
src-tauri/Cargo.toml
Normal 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
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
10
src-tauri/capabilities/default.json
Normal 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
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
14
src-tauri/readme.md
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
13
src/app.html
Normal 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>
|
5
src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import "../app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
5
src/routes/+layout.ts
Normal 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
|
@ -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
After Width: | Height: | Size: 1.5 KiB |
15
svelte.config.js
Normal 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
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
19
tsconfig.json
Normal 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
|
@ -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/**"],
|
||||
},
|
||||
},
|
||||
}));
|