From c390975a8d80c2920419e1e67cbd9598fe00f027 Mon Sep 17 00:00:00 2001 From: itsscb <dev@itsscb.de> Date: Tue, 29 Oct 2024 22:34:52 +0100 Subject: [PATCH] feat: add manager (web portal) --- Cargo.toml | 5 +++ assets/index.html | 93 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 ++- src/library.rs | 39 ++++++++++++++++- src/manager.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 assets/index.html create mode 100644 src/manager.rs diff --git a/Cargo.toml b/Cargo.toml index ab1f8b6..630deb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,15 @@ version = "0.1.0" edition = "2021" [dependencies] +axum = { version = "0.7.7", features = ["multipart"] } crossbeam-channel = "0.5.13" rand = "0.8.5" rodio = "0.19.0" rusb = "0.9.4" serde = { version = "1.0.210", features = ["derive", "rc"] } serde_json = "1.0.132" +tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] } +tower-http = { version = "0.6.1", features = ["fs"] } +tracing = { version = "0.1.40", features = ["async-await"] } +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } wifi-rs = "0.2.4" diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..bf07b7d --- /dev/null +++ b/assets/index.html @@ -0,0 +1,93 @@ +<!-- index.html --> +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Marlinbox</title> + <link rel="stylesheet" href="assets/styles.css"> + + <script> + async function sendPairRequest() { + try { + const response = await fetch('/pair'); + const data = await response.text(); + showToast(data); + } catch (error) { + showToast('An error occurred'); + } + } + function showToast(message) { + const toast = document.createElement('div'); + toast.textContent = message; + toast.style.position = 'fixed'; + toast.style.bottom = '15%'; + toast.classList = 'text-3xl font-bold uppercase bg-green-300 text-white rounded-xl p-x-2 p-y-4 md:w-[25%] w-[60%] text-center transform transition-transform duration-500 ease-in-out translate-y-full opacity-0'; + document.body.appendChild(toast); + setTimeout(() => { + toast.classList.remove('translate-y-full', 'opacity-0'); + toast.classList.add('translate-y-0', 'opacity-100'); + }, 100); // Slight delay to trigger the transition + setTimeout(() => { + toast.classList.remove('translate-y-0', 'opacity-100'); + toast.classList.add('translate-y-full', 'opacity-0'); + setTimeout(() => { + document.body.removeChild(toast); + }, 500); // Wait for the transition to complete before removing + }, 5000); + } + + + // document.getElementById('card-add').addEventListener('click', sendPairRequest); + </script> +</head> + + +<body> + <div class="flex flex-col items-center gap-y-12 md:gap-y-24"> + <div></div> + <section class="flex flex-col gap-y-2 text-center"> + <h1 class="text-4xl md:text-7xl font-bold uppercase tracking-widest">Marlinbox</h1> + <p class="text-xl md:text-3xl">The Open Source Music Box</p> + </section> + <section class="flex flex-row justify-between md:justify-evenly md:gap-x-20 w-[65%] md:w-[35%]"> + <div class="flex flex-col justify-center gap-y-6 md:gap-y-20"> + <button id="vol-up" + class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff"> + <path + d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320ZM400-606l-86 86H200v80h114l86 86v-252ZM300-480Z" /> + </svg> + </button> + <button id="vol-down" + class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff"> + <path + d="M200-360v-240h160l200-200v640L360-360H200Zm440 40v-322q45 21 72.5 65t27.5 97q0 53-27.5 96T640-320ZM480-606l-86 86H280v80h114l86 86v-252ZM380-480Z" /> + </svg> + </button> + </div> + <div class="flex flex-col justify-center gap-y-6 md:gap-y-20"> + <button id="card-add" + class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center" + onclick="sendPairRequest()"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff"> + <path + d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v240H160v240h400v80H160Zm0-480h640v-80H160v80ZM760-80v-120H640v-80h120v-120h80v120h120v80H840v120h-80ZM160-240v-480 480Z" /> + </svg> + </button> + <button id="card-manage" + class="text-xl md:text-3xl font-bold uppercase bg-cyan-300 text-white rounded-xl p-2 w-16 md:w-28 flex flex-col items-center"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff"> + <path + d="M160-240v-320 13-173 480Zm0-400h640v-80H160v80Zm303 480H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v213q-35-25-76.5-39T716-560q-57 0-107.5 21.5T520-480H160v240h279q3 21 9 41t15 39Zm213 80-12-60q-12-5-22.5-10.5T620-164l-58 18-40-68 46-40q-2-13-2-26t2-26l-46-40 40-68 58 18q11-8 21.5-13.5T664-420l12-60h80l12 60q12 5 22.5 10.5T812-396l58-18 40 68-46 40q2 13 2 26t-2 26l46 40-40 68-58-18q-11 8-21.5 13.5T768-140l-12 60h-80Zm40-120q33 0 56.5-23.5T796-280q0-33-23.5-56.5T716-360q-33 0-56.5 23.5T636-280q0 33 23.5 56.5T716-200Z" /> + </svg> + </button> + </div> + </section> + </div> + +</body> + +</html> \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index c79616e..cba2d42 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod card; pub mod card_reader; pub mod error; -pub mod library; -pub mod portal; +mod library; +pub mod manager; pub mod service; +pub use library::Library; diff --git a/src/library.rs b/src/library.rs index aab6148..a1b23fa 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,9 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, io::Write, sync::Arc}; use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; -use crate::card::Card; +use crate::{card::Card, error::Error}; +use std::fs::File; #[derive(Debug, Serialize, Deserialize)] pub struct Library { @@ -18,10 +19,44 @@ impl Library { } } + /// Saves the library to a file. + /// + /// # Arguments + /// + /// * `file_path` - The path to the file where the library will be saved. + /// + /// # Errors + /// + /// Returns an `Error` if there was an error writing the library to the file. + pub fn save_to_file<P: AsRef<str>>(&self, file_path: P) -> Result<(), Error> { + let serialized = serde_json::to_string(self)?; + let mut file = File::create(file_path.as_ref())?; + file.write_all(serialized.as_bytes())?; + Ok(()) + } + + /// Loads the library from a file. + /// + /// # Arguments + /// + /// * `file_path` - The path to the file from which the library will be loaded. + /// + /// # Errors + /// + /// Returns an `Error` if there was an error reading the library from the file. + pub fn from_file<P: AsRef<str>>(file_path: P) -> Result<Self, Error> { + let file = File::open(file_path.as_ref())?; + Ok(serde_json::from_reader(file)?) + } + pub fn add(&mut self, card_id: &str) { self.music.insert(card_id.into(), None); } + pub fn remove(&mut self, card_id: &str) -> Option<Card> { + self.music.remove_entry(card_id).and_then(|(_, v)| v) + } + pub fn update(&mut self, card_id: &str, music_file: Option<Card>) { self.music .insert(card_id.into(), music_file.map(Into::into)); diff --git a/src/manager.rs b/src/manager.rs new file mode 100644 index 0000000..15fefa9 --- /dev/null +++ b/src/manager.rs @@ -0,0 +1,108 @@ +use std::{fs, path::PathBuf, sync::Arc}; + +use axum::{ + extract::{Multipart, State}, + response::IntoResponse, + routing::{get, post}, +}; +use crossbeam_channel::{Receiver, Sender}; +use tokio::runtime::Runtime; +use tower_http::services::{ServeDir, ServeFile}; +use tracing::{debug, error, info}; + +/// Starts the Manager and listens for incoming connections. +/// +/// # Arguments +/// +/// * `connection_string` - A string representing the connection address and port. +/// * `tx_pairing` - An Arc-wrapped Sender used for message passing. +/// * `rx_shutdown` - A Receiver used to receive shutdown signals. +/// +/// # Returns +/// +/// Returns `Ok(())` if the server started successfully, otherwise returns an error. +pub fn serve( + connection_string: &str, + tx_pairing: Arc<Sender<()>>, + rx_shutdown: Arc<Receiver<()>>, +) -> Result<(), Box<dyn std::error::Error>> { + let rt = Runtime::new()?; + let app = axum::Router::new() + .nest_service("/assets", ServeDir::new(PathBuf::from("assets"))) + .route_service("/", ServeFile::new(PathBuf::from("assets/index.html"))) + .route("/pair", get(handler)) + .route("/failed", get(|| async { "Failed to send message" })) + .route("/upload", post(upload_file)) + .with_state(tx_pairing); + + rt.block_on(async { + let listener = match tokio::net::TcpListener::bind(connection_string).await { + Ok(it) => { + info!("Manager listening on: {connection_string}"); + it + } + Err(err) => { + error!("Manager failed to bind to {connection_string}: {err}"); + return; + } + }; + match axum::serve(listener, app) + .with_graceful_shutdown(async move { + match rx_shutdown.recv() { + Ok(()) => info!("Manager shutting down"), + Err(err) => error!("Manager failed to shut down: {err}"), + } + }) + .await + { + Ok(()) => info!("Manager listening on {connection_string}"), + Err(err) => error!("Manager failed to start: {err}"), + } + }); + Ok(()) +} + +async fn handler(State(state): State<Arc<Sender<()>>>) -> impl IntoResponse { + match state.send(()) { + Ok(()) => { + info!("Sent pairing request"); + "Pairing" + } + Err(err) => { + error!("Failed to send pairing request: {err}"); + "Failed to start pairing" + } + } +} + +async fn upload_file(mut multipart: Multipart) -> impl IntoResponse { + while let Ok(resp) = multipart.next_field().await { + if let Some(field) = resp { + let Some(filename) = field.file_name() else { + return "No file name provided"; + }; + let filepath = PathBuf::from(format!("./uploads/{filename}")); + + if let Err(err) = fs::create_dir_all("uploads") { + error!("Failed to create directory: {err}"); + return "Failed to create directory"; + } + + let data = match field.bytes().await { + Ok(bytes) => bytes, + Err(err) => { + error!("Failed to read file: {err}"); + return "Failed to read file"; + } + }; + if let Err(err) = fs::write(&filepath, data) { + error!("Failed to write file: {err}"); + return "Failed to write file"; + } + debug!("File uploaded: {}", filepath.to_string_lossy()); + } else { + break; + } + } + "File uploaded" +}