diff --git a/caesar-cli/src/cli/args.rs b/caesar-cli/src/cli/args.rs index fe58989..8c4d2f9 100644 --- a/caesar-cli/src/cli/args.rs +++ b/caesar-cli/src/cli/args.rs @@ -7,10 +7,22 @@ use tracing::debug; use crate::config::GLOBAL_CONFIG; +/// Struct representing the command line arguments parsed by clap. +/// +/// It uses the clap library to define the command line arguments and their +/// attributes. The version of the application is obtained from the cargo.toml +/// file. +/// +/// The `command` field is an optional subcommand. It is represented by the +/// `Commands` enum which defines the different subcommands that can be used. #[derive(Parser, Debug)] #[command(version = env!("CARGO_PKG_VERSION"), about = "Send and receive files securely")] #[command(long_about = None)] pub struct Args { + /// The subcommand to run. + /// + /// It is an optional field. If it is not provided, the program will run without + /// any specific subcommand. #[command(subcommand)] pub command: Option, } @@ -32,10 +44,6 @@ pub enum Commands { #[arg(short, long)] relay: Option, - /// Overwrite existing Files - #[arg(short, long)] - overwrite: bool, - /// Name of Transfer to download files #[arg(value_name = "Transfer_Name")] name: String, @@ -51,34 +59,69 @@ pub enum Commands { }, } + +/// Default implementation of the `Args` struct. +/// +/// This implementation uses the `new` method to create a new instance of `Args`. impl Default for Args { + /// Creates a new instance of `Args` by calling the `new` method. + /// + /// # Returns + /// + /// A new instance of `Args`. fn default() -> Self { Self::new() } } + +/// Struct representing the parsed command line arguments. +/// +/// This struct implements the `Default` trait to create a new instance of `Args` by calling the +/// `new` method. +/// +/// The `run` method is used to execute the corresponding command based on the parsed arguments. impl Args { + /// Creates a new instance of `Args` by calling the `parse` method. pub fn new() -> Self { Self::parse() } + /// Executes the corresponding command based on the parsed arguments. + /// + /// This method takes no parameters. + /// + /// # Returns + /// + /// A `Result` that either returns `Ok(())` indicating successful execution or an `Err` + /// indicating an error. pub async fn run(&self) -> Result<(), Box> { + // Retrieve the global configuration let cfg = &GLOBAL_CONFIG; debug!("args: {:#?}", self); + + // Match on the `command` field of `Args` to execute the corresponding command match &self.command { + // Command to send files to the receiver or relay server Some(Commands::Send { relay, files }) => { + // Create a string representation of the relay address let relay_string: String = relay.as_deref().unwrap_or(&cfg.app_origin).to_string(); + // Create Arc wrappers for the relay address and file paths let relay_arc = Arc::new(relay_string); let files_arc = Arc::new(files.to_vec()); + // Generate a random name let rand_name = generate_random_name(); + // Start the sender with the generated name, relay address, and file paths sender::start_sender(rand_name, relay_arc, files_arc).await; } + // Command to receive files from the sender with the matching password Some(Commands::Receive { relay, - overwrite: _, name, }) => { + // Print the received transfer name println!("Receive for {name:?}"); + // Start the receiver with the current directory, relay address, and transfer name let _ = receiver::start_receiver( ".".to_string(), relay.as_deref().unwrap_or(&cfg.app_origin), @@ -86,19 +129,23 @@ impl Args { ) .await; } + // Command to start a relay server Some(Commands::Serve { port, listen_address, }) => { - println!("Serve with address '{listen_address:?}' and '{port:?}'"); + // Create a string representation of the listen address let address: String = listen_address .as_deref() .unwrap_or(&cfg.app_host) .to_string(); + // Create an integer representation of the port let port_value = port.unwrap_or(cfg.app_port.parse::().unwrap_or(0)); let port: i32 = port_value; + // Start the relay server with the port and listen address relay::server::start_ws(&port, &address).await; } + // No command provided None => {} } Ok(()) diff --git a/caesar-cli/src/config.rs b/caesar-cli/src/config.rs index 41b86df..523f40b 100644 --- a/caesar-cli/src/config.rs +++ b/caesar-cli/src/config.rs @@ -1,25 +1,70 @@ use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; +/// Represents the configuration settings for the Caesar application. +/// +/// This struct is used to store the configuration settings for the application, +/// such as the environment, host, port, origin, and logging level. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct CaesarConfig { + /// The environment in which the application is running. + /// + /// Possible values are "production", "staging", or "development". pub app_environment: String, + + /// The host on which the application is running. + /// + /// This is typically an IP address or a hostname. pub app_host: String, + + /// The port on which the application is listening. + /// + /// This is typically a string representation of a port number. pub app_port: String, + + /// The origin of the application. + /// + /// This is typically a URL that specifies the protocol, hostname, and port. pub app_origin: String, + + /// The relay endpoint of the application. + /// + /// This is typically a combination of a hostname and port. pub app_relay: String, + + /// The logging level for the application. + /// + /// This is typically a string representation of a logging level, such as "info", + /// "debug", or "error". pub rust_log: String, } + +/// The default configuration values for the Caesar application. +/// +/// These values are used when loading the configuration file fails. +/// The default configuration is suitable for running the application in a production environment. impl Default for CaesarConfig { + /// Returns a new `CaesarConfig` instance with default values. + /// + /// # Returns + /// + /// A new `CaesarConfig` instance with the following default values: + /// + /// - `app_environment`: "production" + /// - `app_host`: "0.0.0.0" + /// - `app_port`: "8000" + /// - `app_origin`: "wss://caesar-transfer-iu.shuttleapp.rs" + /// - `app_relay`: "0.0.0.0:8000" + /// - `rust_log`: "info" fn default() -> Self { CaesarConfig { - app_environment: "production".to_string(), - app_host: "0.0.0.0".to_string(), - app_port: "8000".to_string(), - app_origin: "wss://caesar-transfer-iu.shuttleapp.rs".to_string(), - app_relay: "0.0.0.0:8000".to_string(), - rust_log: "info".to_string(), + app_environment: "production".to_string(), // The environment in which the application is running. + app_host: "0.0.0.0".to_string(), // The host on which the application is running. + app_port: "8000".to_string(), // The port on which the application is listening. + app_origin: "wss://caesar-transfer-iu.shuttleapp.rs".to_string(), // The origin of the application. + app_relay: "0.0.0.0:8000".to_string(), // The relay endpoint of the application. + rust_log: "info".to_string(), // The logging level for the application. } } } diff --git a/caesar-cli/src/main.rs b/caesar-cli/src/main.rs index cb57124..7788eb3 100644 --- a/caesar-cli/src/main.rs +++ b/caesar-cli/src/main.rs @@ -6,15 +6,29 @@ use tracing_subscriber::filter::EnvFilter; mod cli; mod config; +/// Entry point of the application. +/// +/// This function is called when the application is started. It initializes the environment, +/// parses the command line arguments, and runs the application. #[tokio::main] async fn main() -> Result<(), Box> { + // Load environment variables from the `.env` file. dotenv().ok(); + + // Initialize the logging subscriber. + // It configures the logging level based on the `RUST_LOG` environment variable. tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); + + // Parse the command line arguments. let args = Args::new(); + + // Run the application. + // If an error occurs, log the error message. if let Err(e) = args.run().await { error!("{e}"); } + Ok(()) } diff --git a/caesar-core/src/receiver/client.rs b/caesar-core/src/receiver/client.rs index 42f6fea..be28277 100644 --- a/caesar-core/src/receiver/client.rs +++ b/caesar-core/src/receiver/client.rs @@ -25,62 +25,185 @@ const NONCE_SIZE: usize = 12; #[cfg(target_os = "android")] const FILE_PATH_PREFIX: &str = "/storage/emulated/0/Download"; + +/// Represents a file to be transferred. +/// +/// # Fields +/// +/// - `name`: The name of the file. +/// - `size`: The total size of the file in bytes. +/// - `progress`: The number of bytes that have been transferred so far. +/// - `handle`: The file handle for reading and writing the file. +#[derive(Debug)] struct File { + /// The name of the file. name: String, + + /// The total size of the file in bytes. size: u64, + + /// The number of bytes that have been transferred so far. progress: u64, + + /// The file handle for reading and writing the file. handle: fs::File, } + +/// Represents the state of the receiver. +/// +/// # Fields +/// +/// - `hmac`: The HMAC key used for authentication. +/// - `sender`: The sender used for sending packets. +/// - `key`: The ephemeral secret key used for key agreement. +/// - `shared_key`: The shared key used for encryption. +/// - `files`: The list of files being transferred. +/// - `sequence`: The sequence number of the last received packet. +/// - `index`: The index of the current file being transferred. +/// - `progress`: The number of bytes transferred so far. +/// - `length`: The total length of the file being transferred. struct Context { + /// The HMAC key used for authentication. hmac: Vec, + + /// The sender used for sending packets. sender: Sender, + + /// The ephemeral secret key used for key agreement. key: EphemeralSecret, + + /// The shared key used for encryption. shared_key: Option, + + /// The list of files being transferred. files: Vec, + + /// The sequence number of the last received packet. sequence: u32, + + /// The index of the current file being transferred. index: usize, + + /// The number of bytes transferred so far. progress: u64, + + /// The total length of the file being transferred. length: u64, } + +/// Handle the join room packet. +/// +/// # Arguments +/// +/// * `size` - The size of the room. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error if the join room packet is invalid. fn on_join_room(size: Option) -> Status { + // Check if the size of the room is provided if size.is_none() { + // Return an error if the join room packet is invalid return Status::Err("Invalid join room packet.".into()); } + // Print a message indicating that the client has successfully connected to the room println!("Connected to room."); + // Return a continue status to indicate that the operation was successful Status::Continue() } + +/// Handle the error packet. +/// +/// # Arguments +/// +/// * `message` - The error message. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error with the provided error message. fn on_error(message: String) -> Status { + // Return an error with the provided error message Status::Err(message) } + +/// Handle the leave room packet. +/// +/// # Arguments +/// +/// * `context` - The receiver context. +/// * `_` - The index of the sender. Currently unused. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error if there are still files being transferred. fn on_leave_room(context: &mut Context, _: usize) -> Status { + // Check if there are any files being transferred with less than 100% progress if context.files.iter().any(|file| file.progress < 100) { + // Print a message indicating that the transfer was interrupted because the host left the room println!(); println!("Transfer was interrupted because the host left the room."); + // Return an error with the provided message Status::Err("Transfer was interrupted because the host left the room.".into()) } else { + // Return an exit status to indicate that the operation was successful Status::Exit() } } + +/// Handle the list packet. +/// +/// # Arguments +/// +/// * `filepath` - The path to the directory where the files will be saved. +/// * `context` - The receiver context. +/// * `list` - The list packet containing the files to be transferred. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error if the list packet is invalid or if a file with the same name already exists. fn on_list(filepath: String, context: &mut Context, list: ListPacket) -> Status { + // Check if the shared key is established if context.shared_key.is_none() { return Status::Err("Invalid list packet: no shared key established".into()); } + // Iterate over the entries in the list packet for entry in list.entries { + // Sanitize the filename to prevent directory traversal attacks let path = sanitize_filename::sanitize(entry.name.clone()); + // Construct the file path let file_path = format!("{}/{}", filepath, path); + // Check if the file already exists if Path::new(&file_path).exists() { return Status::Err(format!("The file '{}' already exists.", path)); } + + // Create a new file let handle = match fs::File::create(&file_path) { Ok(handle) => handle, Err(error) => { @@ -91,6 +214,7 @@ fn on_list(filepath: String, context: &mut Context, list: ListPacket) -> Status } }; + // Create a new file object and add it to the context let file = File { name: entry.name, size: entry.size, @@ -101,6 +225,7 @@ fn on_list(filepath: String, context: &mut Context, list: ListPacket) -> Status context.files.push(file); } + // Reset the context for the next file transfer context.index = 0; context.progress = 0; context.sequence = 0; @@ -109,11 +234,28 @@ fn on_list(filepath: String, context: &mut Context, list: ListPacket) -> Status Status::Continue() } +/// Handle a chunk packet. +/// +/// This function is responsible for processing chunk packets received from the sender. +/// It checks if the shared key has been established, verifies the sequence number, +/// writes the chunk to the corresponding file, updates the file's progress, sends progress +/// updates if necessary, and handles the end of a file transfer. +/// +/// # Arguments +/// +/// * `context` - The receiver context. +/// * `chunk` - The chunk packet received from the sender. +/// +/// # Returns +/// +/// A status indicating if the operation was successful. fn on_chunk(context: &mut Context, chunk: ChunkPacket) -> Status { + // Check if the shared key is established if context.shared_key.is_none() { return Status::Err("Invalid chunk packet: no shared key established".into()); } + // Verify the sequence number if chunk.sequence != context.sequence { return Status::Err(format!( "Expected sequence {}, but got {}.", @@ -121,18 +263,24 @@ fn on_chunk(context: &mut Context, chunk: ChunkPacket) -> Status { )); } + // Get the file corresponding to the current index let Some(file) = context.files.get_mut(context.index) else { return Status::Err("Invalid file index.".into()); }; + // Update the file's length context.length += chunk.chunk.len() as u64; + // Increment the sequence number context.sequence += 1; + // Write the chunk to the file file.handle.write(&chunk.chunk).unwrap(); + // Update the file's progress file.progress = (context.length * 100) / file.size; + // Send progress updates if necessary if file.progress == 100 || file.progress - context.progress >= 1 || chunk.sequence == 0 { context.progress = file.progress; @@ -151,6 +299,7 @@ fn on_chunk(context: &mut Context, chunk: ChunkPacket) -> Status { std::io::Write::flush(&mut stdout()).unwrap(); } + // Handle the end of a file transfer if file.size == context.length { context.index += 1; context.length = 0; @@ -163,59 +312,104 @@ fn on_chunk(context: &mut Context, chunk: ChunkPacket) -> Status { Status::Continue() } +/// Handle the handshake packet. +/// +/// This function is responsible for handling the handshake packet received from the sender. +/// It performs the necessary verification and establishes the shared key between the sender and receiver. +/// +/// # Arguments +/// +/// * `context` - The receiver context. +/// * `handshake` - The handshake packet received from the sender. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. fn on_handshake(context: &mut Context, handshake: HandshakePacket) -> Status { + // Check if the shared key is already established if context.shared_key.is_some() { return Status::Err("Already performed handshake.".into()); } + // Create a HMAC instance using the shared key let mut mac = Hmac::::new_from_slice(&context.hmac).unwrap(); + // Update the HMAC with the sender's public key mac.update(&handshake.public_key); + // Verify the signature using the HMAC let verification = mac.verify_slice(&handshake.signature); if verification.is_err() { return Status::Err("Invalid signature from the sender.".into()); } + // Generate the receiver's public key let public_key = context.key.public_key().to_sec1_bytes().into_vec(); + // Create a new HMAC instance using the shared key let mut mac = Hmac::::new_from_slice(&context.hmac).unwrap(); + // Update the HMAC with the receiver's public key mac.update(&public_key); + // Generate the signature using the HMAC let signature = mac.finalize().into_bytes().to_vec(); + + // Convert the sender's public key into a `PublicKey` object let shared_public_key = PublicKey::from_sec1_bytes(&handshake.public_key).unwrap(); + // Perform Diffie-Hellman key exchange let shared_secret = context.key.diffie_hellman(&shared_public_key); let shared_secret = shared_secret.raw_secret_bytes(); let shared_secret = &shared_secret[0..16]; + // Create a new 128-bit AES-GCM key from the shared secret let shared_key: &Key = shared_secret.into(); let shared_key = ::new(shared_key); + // Create the handshake response packet let handshake_response = HandshakeResponsePacket { public_key, signature, }; + // Send the handshake response packet to the sender context .sender .send_packet(DESTINATION, Value::HandshakeResponse(handshake_response)); + // Establish the shared key context.shared_key = Some(shared_key); Status::Continue() } +/// Handle a message received from the WebSocket connection. +/// +/// This function takes a `filepath` string, a mutable reference to a `Context` struct, +/// and a `WebSocketMessage` enum. It returns a `Status` enum. +/// +/// # Arguments +/// +/// * `filepath` - A string representing the file path. +/// * `context` - A mutable reference to a `Context` struct. +/// * `message` - A `WebSocketMessage` enum. +/// +/// # Returns +/// +/// A `Status` enum. fn on_message(filepath: String, context: &mut Context, message: WebSocketMessage) -> Status { + // Handle text messages match message.clone() { WebSocketMessage::Text(text) => { + // Parse the JSON packet let packet = match serde_json::from_str(&text) { Ok(packet) => packet, Err(_) => { return Status::Continue(); } }; + // Handle different types of JSON packets return match packet { JsonPacketResponse::Join { size } => on_join_room(size), JsonPacketResponse::Leave { index } => on_leave_room(context, index), @@ -223,7 +417,9 @@ fn on_message(filepath: String, context: &mut Context, message: WebSocketMessage _ => Status::Err(format!("Unexpected json packet: {:?}", packet)), }; } + // Handle binary messages WebSocketMessage::Binary(data) => { + // Extract the data from the binary message let data = &data[1..]; let data = if let Some(shared_key) = &context.shared_key { @@ -235,8 +431,10 @@ fn on_message(filepath: String, context: &mut Context, message: WebSocketMessage data.to_vec() }; + // Decode the packet let packet = Packet::decode(data.as_ref()).unwrap(); let value = packet.value.unwrap(); + // Handle different types of packets return match value { Value::List(list) => on_list(filepath, context, list), Value::Chunk(chunk) => on_chunk(context, chunk), @@ -247,9 +445,26 @@ fn on_message(filepath: String, context: &mut Context, message: WebSocketMessage _ => (), } + // Return an error status for invalid message types Status::Err("Invalid message type".into()) } + +/// Starts the receiver's client. +/// +/// This function takes in a file path, a socket, and a fragment string. It +/// then extracts the room ID and HMAC from the fragment string. The function +/// also generates an ephemeral secret key. +/// +/// The function initializes a `Context` struct with the extracted information +/// and sets up the necessary communication channels. It then sends a join +/// request to the server and starts handling incoming messages. +/// +/// # Arguments +/// +/// * `filepath` - The path to the file to be received. +/// * `socket` - The WebSocket connection to the server. +/// * `fragment` - The invite code containing the room ID and HMAC. pub async fn start(filepath: String, socket: Socket, fragment: &str) { let Some(index) = fragment.rfind('-') else { println!("Error: The invite code '{}' is not valid.", fragment); diff --git a/caesar-core/src/receiver/http_client.rs b/caesar-core/src/receiver/http_client.rs index 3f0f5b2..2ee5853 100644 --- a/caesar-core/src/receiver/http_client.rs +++ b/caesar-core/src/receiver/http_client.rs @@ -5,31 +5,65 @@ use sha2::{Digest, Sha256}; use crate::relay::transfer::TransferResponse; +/// Fetches download information from the relay server for the given file name. +/// +/// # Arguments +/// +/// * `relay` - The URL of the relay server. +/// * `name` - The name of the file. +/// +/// # Returns +/// +/// A future that resolves to a `Result` containing the download information +/// if the request is successful, or an error if the request fails. pub async fn download_info(relay: &str, name: &str) -> Result { + // Convert the relay server URL and file name to strings let url = String::from(relay); let hashed_name = Sha256::digest(name.as_bytes()); let hashed_string = hex::encode(hashed_name); + // Send a GET request to the relay server with the file name hash as a query parameter let resp = reqwest::get(format!("{}/download/{}", url, hashed_string)) .await + // If the request fails, return an error with the reason .map_err(|e| anyhow!("Failed to send GET request: {}", e))?; + // Parse the response body as JSON into a `TransferResponse` struct resp.json::() .await + // If the JSON parsing fails, return an error with the reason .map_err(|e| anyhow!("Failed to parse JSON response: {}", e)) } + +/// Notifies the relay server that the file download was successful for the given file name. +/// +/// # Arguments +/// +/// * `relay` - The URL of the relay server. +/// * `name` - The name of the file. +/// +/// # Returns +/// +/// A future that resolves to a `Result` containing `Ok(())` if the request is successful, +/// or an error if the request fails. pub async fn download_success(relay: &str, name: &str) -> Result<()> { + // Convert the relay server URL and file name to strings let url = String::from(relay); let hashed_name = Sha256::digest(name.as_bytes()); let hashed_string = hex::encode(hashed_name); + // Create a new HTTP client let client = Client::new(); + + // Send a POST request to the relay server with the file name hash as a query parameter let _ = client .post(format!("{}/download_success/{}", url, hashed_string)) .send() .await + // If the request fails, return an error with the reason .map_err(|e| anyhow!("Failed to send POST request: {}", e))?; + // Return Ok(()) if the request was successful Ok(()) } diff --git a/caesar-core/src/receiver/mod.rs b/caesar-core/src/receiver/mod.rs index 8d830d3..3b37e90 100644 --- a/caesar-core/src/receiver/mod.rs +++ b/caesar-core/src/receiver/mod.rs @@ -10,6 +10,23 @@ use tokio_tungstenite::{ }; use tracing::{debug, error}; +/// Start the receiver process. +/// +/// This function initiates the receiver process by performing the following steps: +/// 1. Replaces the protocol of the given `relay` URL. +/// 2. Downloads the room information from the server. +/// 3. Connects to the local or relay server based on the platform. +/// 4. Downloads the file from the server. +/// +/// # Arguments +/// +/// * `filepath` - The path to the file to be received. +/// * `relay` - The URL of the relay server. +/// * `name` - The name of the receiver. +/// +/// # Returns +/// +/// Returns a `Result` indicating the success or failure of the receiver process. pub async fn start_receiver(filepath: String, relay: &str, name: &str) -> Result<()> { let http_url = replace_protocol(relay); let res = http_client::download_info(http_url.as_str(), name) @@ -44,33 +61,55 @@ pub async fn start_receiver(filepath: String, relay: &str, name: &str) -> Result Ok(()) } +/// Asynchronously starts a WebSocket communication with a relay server. +/// +/// # Arguments +/// +/// * `filepath` - The path of the file to transfer. +/// * `relay` - The URL of the relay server. +/// * `name` - The name of the receiver. +/// +/// # Returns +/// +/// Returns a `Result` indicating the success or failure of the WebSocket communication. pub async fn start_ws_com(filepath: String, relay: &str, name: &str) -> Result<()> { + // Construct the WebSocket URL by appending "/ws" to the relay URL. let url = String::from(relay) + "/ws"; + + // Create a WebSocket request using the constructed URL. let mut request = url .into_client_request() .map_err(|e| anyhow!("Failed to create request: {}", e))?; + // Set the "Origin" header of the request to the relay URL. request .headers_mut() .insert("Origin", HeaderValue::from_str(relay).unwrap()); + // Print a message indicating the attempt to connect. println!("Attempting to connect..."); + // Attempt to establish a WebSocket connection with the relay server. + // If the connection fails or times out, return an error. let _ = match tokio::time::timeout(std::time::Duration::from_secs(5), connect_async(request)) .await { Ok(Ok((socket, _))) => { + // Start the receiver process with the established WebSocket connection. receiver::start(filepath, socket, name).await; Ok(()) } Ok(Err(e)) => { + // Log the failure to connect. error!("Error: Failed to connect: {e:?}"); Err(Box::new(e)) } Err(e) => { + // Log the timeout. error!("Error: Timeout reached for local connection attempt"); Err(Box::new(e)) }?, }; Ok(()) } + diff --git a/caesar-core/src/relay/appstate.rs b/caesar-core/src/relay/appstate.rs index 984caa0..3a4357f 100644 --- a/caesar-core/src/relay/appstate.rs +++ b/caesar-core/src/relay/appstate.rs @@ -4,18 +4,38 @@ use tokio::sync::RwLock; use crate::relay::room::Room; use crate::relay::transfer::TransferResponse; +/// State of the application. +/// +/// This structure holds the state of the application, which includes the rooms +/// and the transfers. #[derive(Debug, Clone)] pub struct AppState { + /// Map of rooms, where the key is the room's ID and the value is the room + /// itself. pub rooms: HashMap, + /// Vector of transfers. pub transfers: Vec, } impl AppState { + /// Creates a new instance of the `AppState` struct. + /// + /// This function initializes the state of the application with an empty map + /// of rooms and an empty vector of transfers. + /// + /// # Returns + /// + /// An `Arc>` that can be used to share the state across multiple + /// tasks. pub fn new() -> Arc> { - Arc::new(RwLock::new(AppState { + // Create a new instance of `AppState` with empty rooms and transfers. + let app_state = AppState { rooms: HashMap::new(), transfers: Vec::new(), - })) + }; + + // Wrap the `app_state` in a `RwLock` to make it thread-safe. + Arc::new(RwLock::new(app_state)) } } diff --git a/caesar-core/src/relay/client.rs b/caesar-core/src/relay/client.rs index ec45b59..a6eb4ff 100644 --- a/caesar-core/src/relay/client.rs +++ b/caesar-core/src/relay/client.rs @@ -10,43 +10,111 @@ use crate::relay::RequestPacket; use crate::relay::ResponsePacket; use uuid::Uuid; +/// Type alias for a synchronized WebSocket sender. +/// +/// This is used to send messages to a WebSocket connection. type Sender = Arc>>; + +/// Struct representing a WebSocket client. +/// +/// This struct contains a WebSocket sender and an optional room ID. +/// The sender is used to send messages to the WebSocket connection, +/// while the room ID is used to identify the client's room. #[derive(Debug)] pub struct Client { + /// The WebSocket sender for sending messages. sender: Sender, + /// The optional room ID of the client. + /// + /// This is used to identify the client's room. room_id: Option, } impl Client { + /// Creates a new WebSocket client. + /// + /// # Arguments + /// + /// * `sender` - A synchronized WebSocket sender. + /// + /// # Returns + /// + /// A new WebSocket client instance. pub fn new(sender: Sender) -> Client { Client { - sender, - room_id: None, + sender, // The WebSocket sender for sending messages. + room_id: None, // The optional room ID of the client. This is used to identify the client's room. } } + /// Sends a message to the WebSocket connection. + /// + /// # Arguments + /// + /// * `sender` - A synchronized WebSocket sender. + /// * `message` - The message to send. + /// + /// # Errors + /// + /// If the message fails to be sent. async fn send(&self, sender: Sender, message: Message) { - let mut sender = sender.lock().await; - if let Err(error) = sender.send(message).await { - error!("Failed to send message to the client: {}", error); + let mut sender = sender.lock().await; // Acquires a lock on the sender. + if let Err(error) = sender.send(message).await { // Sends the message. + error!("Failed to send message to the client: {}", error); // Logs the error if the message fails to be sent. } } + /// Sends a serialized packet to the WebSocket connection. + /// + /// # Arguments + /// + /// * `sender` - A synchronized WebSocket sender. + /// * `packet` - The packet to send. + /// + /// # Errors + /// + /// If the serialized packet fails to be sent. async fn send_packet(&self, sender: Sender, packet: ResponsePacket) { + // Serialize the packet to a string. let serialized_packet = serde_json::to_string(&packet).unwrap(); + // Send the serialized packet as a text message. self.send(sender, Message::Text(serialized_packet)).await; } + /// Sends an error message to the WebSocket connection. + /// + /// # Arguments + /// + /// * `sender` - A synchronized WebSocket sender. + /// * `message` - The error message to send. + /// + /// # Errors + /// + /// If the error message fails to be sent. async fn send_error_packet(&self, sender: Sender, message: String) { + // Create an error packet with the given message. let error_packet = ResponsePacket::Error { message }; - self.send_packet(sender, error_packet).await + // Send the error packet. + self.send_packet(sender, error_packet).await; } + /// Handles the "create_room" request from a client. + /// + /// # Arguments + /// + /// * `server` - A lock guard of the `AppState`. + /// * `id` - An optional string representing the room identifier. + /// + /// # Errors + /// + /// If the room already exists or if the room creation fails. async fn handle_create_room(&mut self, server: &RwLock, id: Option) { + // Acquire a write lock on the server state. let mut server = server.write().await; + // Check if the client is already in a room. if server.rooms.iter().any(|(_, room)| { room.senders .iter() @@ -55,14 +123,16 @@ impl Client { return; } + // Set the room size and generate a room identifier if none is provided. let size = Room::DEFAULT_ROOM_SIZE; let room_id = match id { Some(id) => id, None => Uuid::new_v4().to_string(), }; + // Check if the room identifier already exists. if server.rooms.contains_key(&room_id) { - drop(server); + drop(server); // Release the lock before returning. return self .send_error_packet( @@ -72,23 +142,37 @@ impl Client { .await; } + // Create a new room and add the client to it. let mut room = Room::new(size); room.senders.push(self.sender.clone()); + // Insert the room into the server state. server.rooms.insert(room_id.clone(), room); - self.room_id = Some(room_id.clone()); + self.room_id = Some(room_id.clone()); // Store the room identifier. - drop(server); + drop(server); // Release the lock before returning. debug!("Room created"); + // Send the response packet to the client. self.send_packet(self.sender.clone(), ResponsePacket::Create { id: room_id }) .await } + /// Handles the "join_room" request from a client. + /// + /// # Arguments + /// + /// * `server` - A lock guard of the `AppState`. + /// * `room_id` - A string representing the room identifier. + /// + /// # Errors + /// + /// If the room does not exist or if the room is full. async fn handle_join_room(&mut self, server: &RwLock, room_id: String) { let mut server = server.write().await; + // Check if the client is already in a room. if server.rooms.iter().any(|(_, room)| { room.senders .iter() @@ -100,24 +184,29 @@ impl Client { let Some(room) = server.rooms.get_mut(&room_id) else { drop(server); + // Send an error packet to the client. return self .send_error_packet(self.sender.clone(), "The room does not exist.".to_string()) .await; }; + // Check if the room is full. if room.senders.len() >= room.size { drop(server); + // Send an error packet to the client. return self .send_error_packet(self.sender.clone(), "The room is full.".to_string()) .await; } + // Add the client to the room. room.senders.push(self.sender.clone()); self.room_id = Some(room_id); let mut futures = vec![]; for sender in &room.senders { + // Send a join packet to the client with its position in the room. if Arc::ptr_eq(sender, &self.sender) { futures.push(self.send_packet( sender.clone(), @@ -126,6 +215,7 @@ impl Client { }, )); } else { + // Send a join packet to the client without its position in the room. futures.push(self.send_packet(sender.clone(), ResponsePacket::Join { size: None })); } } @@ -134,17 +224,34 @@ impl Client { join_all(futures).await; } + /// Handle the leave room request from the client. + /// + /// This function removes the client from the current room and notifies the other + /// clients in the room about the client's departure. + /// + /// # Arguments + /// + /// * `server` - A read-write lock guard for the server state. + /// + /// # Returns + /// + /// This function does not return anything. + #[allow(clippy::needless_pass_by_value)] async fn handle_leave_room(&mut self, server: &RwLock) { + // Acquire a write lock on the server state. let mut server = server.write().await; + // Get the room ID of the current room. let Some(room_id) = self.room_id.clone() else { return; }; + // Get the mutable reference to the room. let Some(room) = server.rooms.get_mut(&room_id) else { return; }; + // Get the index of the client in the room. let Some(index) = room .senders .iter() @@ -153,70 +260,94 @@ impl Client { return; }; + // Remove the client from the room. room.senders.remove(index); self.room_id = None; let mut futures = vec![]; for sender in &room.senders { + // Send a leave packet to the other clients in the room. futures.push(self.send_packet(sender.clone(), ResponsePacket::Leave { index })); } + // If the room is empty, remove it from the server state. if room.senders.is_empty() { server.rooms.remove(&room_id); } drop(server); + // Wait for all the futures to complete. join_all(futures).await; } + /// Handles incoming messages from the client. + /// + /// This function interprets the incoming message and performs the corresponding action. + /// + /// # Arguments + /// + /// * `server` - A RwLock guard containing the state of the server. + /// * `message` - The incoming message from the client. pub async fn handle_message(&mut self, server: &RwLock, message: Message) { + // Match on the type of the message. match message { + // If the message is text, parse it as a RequestPacket. Message::Text(text) => { let packet = match serde_json::from_str(&text) { Ok(packet) => packet, - Err(_) => return, + Err(_) => return, // Return if the parsing fails. }; + // Match on the RequestPacket type and perform the corresponding action. match packet { RequestPacket::Create { id } => self.handle_create_room(server, id).await, RequestPacket::Join { id } => self.handle_join_room(server, id).await, RequestPacket::Leave => self.handle_leave_room(server).await, } } + // If the message is binary, handle it accordingly. Message::Binary(_) => { + // Acquire a read lock on the server state. let server = server.read().await; + // Get the room ID of the current room. let Some(room_id) = &self.room_id else { drop(server); - return; + return; // Return if the client is not in a room. }; + // Get the room corresponding to the room ID. let Some(room) = server.rooms.get(room_id) else { drop(server); - return; + return; // Return if the room does not exist. }; + // Get the index of the client in the room. let Some(index) = room .senders .iter() .position(|sender| Arc::ptr_eq(sender, &self.sender)) else { drop(server); - return; + return; // Return if the client is not in the room. }; + // Get the binary data from the message. let mut data = message.into_data(); if data.is_empty() { drop(server); - return; + return; // Return if the data is empty. } + // Convert the index to a u8 and assign it as the source. let source = u8::try_from(index).unwrap(); + // Get the destination from the first byte of the data. let destination = usize::from(data[0]); - data[0] = source; + data[0] = source; // Assign the source to the first byte of the data. + // If the destination is within the range of the room senders, send the data to that sender. if destination < room.senders.len() { let sender = room.senders[destination].clone(); @@ -224,11 +355,12 @@ impl Client { return self.send(sender, Message::Binary(data)).await; } + // If the destination is u8::MAX, send the data to all the room senders except the current one. if destination == usize::from(u8::MAX) { let mut futures = vec![]; for sender in &room.senders { if Arc::ptr_eq(sender, &self.sender) { - continue; + continue; // Skip the current client. } futures.push(self.send(sender.clone(), Message::Binary(data.clone()))); @@ -238,12 +370,15 @@ impl Client { join_all(futures).await; } } + // If the message is Ping, print a message. Message::Ping(_) => { println!("Got Message Type Ping"); } + // If the message is Pong, print a message. Message::Pong(_) => { println!("Got Message Type Pong"); } + // If the message is Close, print a message and handle the close. Message::Close(_) => { println!("Got Message Type Close"); self.handle_close(server).await; diff --git a/caesar-core/src/relay/mod.rs b/caesar-core/src/relay/mod.rs index 71578a7..fa6bc8d 100644 --- a/caesar-core/src/relay/mod.rs +++ b/caesar-core/src/relay/mod.rs @@ -6,33 +6,75 @@ pub mod transfer; use serde::{Deserialize, Serialize}; + +/// Represents a packet sent by a client to the server. +/// +/// The `type` field is used to determine the type of the packet. It can be one of the following +/// values: +/// - `Join`: The client wants to join a room. +/// - `Create`: The client wants to create a new room. +/// - `Leave`: The client wants to leave the current room. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum RequestPacket { + /// The client wants to join a room. + /// + /// The `id` field is the ID of the room that the client wants to join. Join { - // The ID of the room that the client wants to join. + /// The ID of the room that the client wants to join. id: String, }, + /// The client wants to create a new room. + /// + /// The `id` field is an optional field that specifies the ID of the new room. If it is `None`, + /// a random ID will be generated. Create { + /// The ID of the new room. If it is `None`, a random ID will be generated. id: Option, }, + /// The client wants to leave the current room. Leave, } +/// Represents a packet sent by the server to the client. +/// +/// The `type` field is used to determine the type of the packet. It can be one of the following +/// values: +/// - `Join`: The client has joined a room. +/// - `Create`: The client has created a new room. +/// - `Leave`: The client has left the current room. +/// - `Error`: There was an error. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ResponsePacket { + /// The client has joined a room. + /// + /// The `size` field is an optional field that specifies the size of the room. If it is `None`, + /// the size is unknown. Join { + /// The size of the room. If it is `None`, the size is unknown. #[serde(skip_serializing_if = "Option::is_none")] size: Option, }, + /// The client has created a new room. + /// + /// The `id` field is the ID of the new room. Create { + /// The ID of the new room. id: String, }, + /// The client has left the current room. + /// + /// The `index` field is the index of the client in the room. Leave { + /// The index of the client in the room. index: usize, }, + /// There was an error. + /// + /// The `message` field is the error message. Error { + /// The error message. message: String, }, } diff --git a/caesar-core/src/relay/room.rs b/caesar-core/src/relay/room.rs index 9b5de38..3e2a702 100644 --- a/caesar-core/src/relay/room.rs +++ b/caesar-core/src/relay/room.rs @@ -3,17 +3,43 @@ use futures_util::stream::SplitSink; use std::sync::Arc; use tokio::sync::Mutex; +// `Sender` is a type alias for a synchronized WebSocket sender. +// +// This is used to send messages to a WebSocket connection. type Sender = Arc>>; +/// Struct representing a room of WebSocket clients. +/// +/// A `Room` contains a list of WebSocket senders and a room size. +/// The senders are used to send messages to the WebSocket connections, +/// while the room size represents the maximum number of clients allowed in the room. #[derive(Debug, Clone)] pub struct Room { + /// The list of WebSocket senders. + /// + /// Each sender is used to send messages to a WebSocket connection. pub senders: Vec, + /// The size of the room. + /// + /// This represents the maximum number of clients allowed in the room. pub size: usize, } impl Room { + /// The default room size. + /// + /// This is used as a fallback value when creating a new room. pub const DEFAULT_ROOM_SIZE: usize = 2; + /// Create a new room with the specified size. + /// + /// # Arguments + /// + /// * `size` - The size of the room. + /// + /// # Returns + /// + /// A new `Room` instance. pub fn new(size: usize) -> Room { Room { senders: Vec::new(), diff --git a/caesar-core/src/relay/server.rs b/caesar-core/src/relay/server.rs index d58ed3a..e5f3b36 100644 --- a/caesar-core/src/relay/server.rs +++ b/caesar-core/src/relay/server.rs @@ -21,29 +21,45 @@ use crate::relay::client::Client; use crate::relay::transfer::TransferResponse; use crate::relay::{appstate::AppState, transfer::TransferRequest}; +/// Start the WebSocket server. +/// +/// This function initializes the server and starts listening for incoming connections. +/// It configures the routes for the WebSocket handler and the upload and download routes. +/// Additionally, it sets up the tracing layer to log incoming requests. +/// +/// # Arguments +/// +/// * `port` - The port number to listen on. +/// * `listen_addr` - The IP address to listen on. +#[allow(clippy::unused_self)] pub async fn start_ws(port: &i32, listen_addr: &String) { - let app_host = listen_addr; - let app_port = port; - - debug!("Server configured to accept connections on host {app_host}...",); - debug!("Server configured to listen connections on port {app_port}...",); + // Log the server configuration. + debug!("Server configured to accept connections on host {listen_addr}..."); + debug!("Server configured to listen connections on port {port}..."); + // Create a new instance of the server state. let server = AppState::new(); + // Set up the routes for the server. let app = Router::new() .route("/ws", get(ws_handler)) .route("/upload", put(upload_info)) .route("/download/:name", get(download_info)) .route("/download_success/:name", post(download_success)) .with_state(server) + // Set up the tracing layer to log incoming requests. .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::default().include_headers(true)), ); - if let Ok(listener) = TcpListener::bind(&format!("{}:{}", app_host, app_port)).await { - info!("Listening on: {}", listener.local_addr().unwrap()); + // Start listening for incoming connections. + let addr = format!("{}:{}", listen_addr, port); + if let Ok(listener) = TcpListener::bind(&addr).await { + let local_addr = listener.local_addr().unwrap(); + info!("Listening on: {}", local_addr); + // Serve the app with the listener and handle shutdown gracefully. axum::serve( listener, app.into_make_service_with_connect_info::(), @@ -52,35 +68,68 @@ pub async fn start_ws(port: &i32, listen_addr: &String) { .await .unwrap(); } else { - error!("Failed to listen on: {}:{}", app_host, app_port); + // Log if the server failed to start. + error!("Failed to listen on: {listen_addr}:{port}"); } } +/// Handler for the WebSocket route. +/// +/// This function upgrades the connection to a WebSocket and handles the socket. +/// +/// # Arguments +/// +/// * `ws` - The WebSocketUpgrade struct containing the upgrade request. +/// * `shared_state` - The shared state of the server. pub async fn ws_handler( ws: WebSocketUpgrade, State(shared_state): State>>, ) -> impl IntoResponse { + // Log the request on the WebSocket route. debug!("Got Request on Websocket route"); + // Log the connection upgrade. debug!("Upgrading Connection"); + // Upgrade the connection to a WebSocket and handle the socket. + // Move the shared state to the handler to avoid holding the lock during the entire connection. ws.on_upgrade(move |socket| handle_socket(socket, shared_state)) } + +/// Handles the WebSocket connection. +/// +/// This function splits the WebSocket into a sender and receiver, +/// creates a client, and handles the messages received from the client. +/// It also handles the close event from the client. +/// +/// # Arguments +/// +/// * `socket` - The WebSocket connection. +/// * `rooms` - The shared state of the server. async fn handle_socket(socket: WebSocket, rooms: Arc>) { + // Split the WebSocket into a sender and receiver. let (sender, mut receiver) = socket.split(); + // Create a new Mutex to prevent concurrent access to the sender. let sender = Arc::new(Mutex::new(sender)); + + // Create a new client with the sender. let mut client = Client::new(sender.clone()); + + // Handle the messages received from the client. while let Some(message) = receiver.next().await { match message { Ok(message) => { + // Handle the message received from the client. client.handle_message(&rooms, message).await; } Err(error) => { + // Log the error if failed to read message from the client. warn!("Failed to read message from client: {}", error); break; } } } + // Handle the close event from the client. client.handle_close(&rooms).await } @@ -109,16 +158,34 @@ async fn shutdown_signal() { } } +/// Handles the upload_info route. +/// +/// It updates or creates a new transfer request in the shared state. +/// If the request is found in the shared state, it updates the relay_room_id or local_room_id +/// based on the payload. If the request is not found, it creates a new transfer request and +/// adds it to the shared state. +/// +/// # Arguments +/// +/// * `shared_state` - The shared state containing the transfer requests. +/// * `payload` - The JSON payload containing the transfer request information. +/// +/// # Returns +/// +/// A tuple of the HTTP status code and the JSON response. pub async fn upload_info( State(shared_state): State>>, Json(payload): Json, ) -> impl IntoResponse { let mut data = shared_state.write().await; + + // Find the transfer request in the shared state match data .transfers .iter_mut() .find(|request| request.name == payload.name) { + // Update the relay_room_id or local_room_id if the request is found Some(request) => { debug!("Found Transfer"); debug!("Request is: {:?}", request); @@ -134,7 +201,9 @@ pub async fn upload_info( (StatusCode::OK, Json(request.clone())) } } + // Create a new transfer request if the request is not found None => { + // Initialize relay and local room IDs based on the payload let mut local = String::from(""); let mut relay = String::from(""); if payload.relay_room_id.is_empty() { @@ -142,37 +211,58 @@ pub async fn upload_info( } else { relay = payload.relay_room_id; } + // Create a new transfer request let t_request = TransferResponse { name: payload.name, ip: payload.ip, local_room_id: local, relay_room_id: relay, }; + // Add the transfer request to the shared state data.transfers.push(t_request.clone()); debug!("New TransferRequest created"); debug!("Actual AppState is {:#?}", *data); + // Return the created transfer request as the response (StatusCode::CREATED, Json(t_request)) } } } + +/// Retrieve information about a transfer request based on the transfer name. +/// +/// # Arguments +/// +/// * `shared_state` - The shared state containing the transfer requests. +/// * `name` - The name of the transfer request. +/// +/// # Returns +/// +/// Returns a response containing the transfer request if found, or a not found +/// response if the transfer request is not found. pub async fn download_info( State(shared_state): State>>, Path(name): Path, ) -> impl IntoResponse { + // Acquire write lock on shared state let data = shared_state.write().await; + + // Find transfer request by name match data.transfers.iter().find(|request| request.name == name) { + // If transfer request is found, return Ok response with the transfer request Some(request) => { debug!("Found transfer name."); (StatusCode::OK, Json(request.clone())) } + // If transfer request is not found, return not found response None => { warn!("couldn't find transfer-name: {}", name); ( StatusCode::NOT_FOUND, Json(TransferResponse { + // Create a new empty transfer response name: String::from(""), ip: String::from(""), local_room_id: String::from(""), @@ -183,19 +273,34 @@ pub async fn download_info( } } +/// Delete a transfer request by its name. +/// +/// # Arguments +/// +/// * `shared_state` - The shared state containing the transfer requests. +/// * `name` - The name of the transfer request. +/// +/// # Returns +/// +/// Returns a response containing a JSON object with a message indicating the +/// success of the deletion. If the transfer request is not found, a not found +/// response is returned. pub async fn download_success( State(shared_state): State>>, Path(name): Path, ) -> impl IntoResponse { let mut data = shared_state.write().await; + // Find the index of the transfer request if let Some(index) = data .transfers .iter() .position(|request| request.name == name) { + // If the transfer request is found, remove it from the shared state debug!("Found Transfer by name '{name}'"); data.transfers.remove(index); debug!("Transfer deleted"); + // Return a success response ( StatusCode::OK, Json(json!({ @@ -203,6 +308,7 @@ pub async fn download_success( })), ) } else { + // If the transfer request is not found, return a not found response warn!("couldn't find transfer-name: {}", name); ( StatusCode::NOT_FOUND, diff --git a/caesar-core/src/relay/transfer.rs b/caesar-core/src/relay/transfer.rs index 30da55d..3fcf609 100644 --- a/caesar-core/src/relay/transfer.rs +++ b/caesar-core/src/relay/transfer.rs @@ -1,13 +1,31 @@ use serde::{Deserialize, Serialize}; +/// Request to transfer a connection from one relay to another #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TransferRequest { + /// The name of the client pub name: String, + /// The IP address of the client pub ip: String, + /// The local room ID of the client pub local_room_id: String, + /// The relay room ID of the client pub relay_room_id: String, } + impl TransferRequest { + /// Creates a new transfer request + /// + /// # Args + /// + /// * `name` - The name of the client + /// * `ip` - The IP address of the client + /// * `local_room_id` - The local room ID of the client + /// * `relay_room_id` - The relay room ID of the client + /// + /// # Returns + /// + /// A new `TransferRequest` instance pub fn new(name: String, ip: String, local_room_id: String, relay_room_id: String) -> Self { Self { name, @@ -18,15 +36,32 @@ impl TransferRequest { } } +/// Response containing the details of the transferred connection #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TransferResponse { + /// The name of the client pub name: String, + /// The IP address of the client pub ip: String, + /// The local room ID of the client pub local_room_id: String, + /// The relay room ID of the client pub relay_room_id: String, } impl TransferResponse { + /// Creates a new transfer response + /// + /// # Args + /// + /// * `name` - The name of the client + /// * `ip` - The IP address of the client + /// * `local_room_id` - The local room ID of the client + /// * `relay_room_id` - The relay room ID of the client + /// + /// # Returns + /// + /// A new `TransferResponse` instance pub fn new(name: String, ip: String, local_room_id: String, relay_room_id: String) -> Self { Self { name, diff --git a/caesar-core/src/sender/client.rs b/caesar-core/src/sender/client.rs index 80ea228..d778480 100644 --- a/caesar-core/src/sender/client.rs +++ b/caesar-core/src/sender/client.rs @@ -31,22 +31,57 @@ const NONCE_SIZE: usize = 12; const MAX_CHUNK_SIZE: isize = u16::MAX as isize; const DELAY: Duration = Duration::from_millis(750); + +/// Struct representing a file to be sent. +/// +/// This struct holds the path, name and size of a file. #[derive(Clone)] struct File { + /// The path of the file to be sent. path: String, + /// The name of the file to be sent. name: String, + /// The size of the file to be sent. size: u64, } +/// The context of a sender. +/// +/// This struct holds the necessary information for a sender to send files. +/// It includes the HMAC, the sender, the ephemeral secret, the list of files to +/// be sent, the shared key, and the task handling the sending of the files. struct Context { + /// The HMAC used for authentication. hmac: Vec, + /// The sender used to send packets. sender: Sender, + /// The ephemeral secret used for key exchange. key: EphemeralSecret, + /// The list of files to be sent. files: Vec, + /// The shared key used for encryption. shared_key: Option, + /// The task handling the sending of the files. task: Option>, } +/// Handles the create room packet. +/// +/// This function is called when a create room packet is received. +/// It creates a room on the specified relay and sends the necessary +/// information to the server. +/// +/// # Arguments +/// +/// * `context` - The context of the sender. +/// * `id` - The ID of the room. +/// * `relay` - The URL of the relay. +/// * `transfer_name` - The name of the transfer. +/// * `is_local` - A boolean indicating whether the room is local. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. fn on_create_room( context: &Context, id: String, @@ -54,16 +89,26 @@ fn on_create_room( transfer_name: String, is_local: bool, ) -> Status { + // Debug log the relay URL debug!("Creating room on: {relay}"); + + // Encode the HMAC key using base64 let base64 = general_purpose::STANDARD.encode(&context.hmac); + + // Generate the URL for the room let url = format!("{}-{}", id, base64); + // Hash the transfer name let hash_name = hash_random_name(transfer_name.clone()); + // Create copies of the necessary variables for the thread let send_url = url.to_string(); let h_name = hash_name.to_string(); let server_url = replace_protocol(relay.as_str()); + + // Spawn a new thread to send the information to the server let res = std::thread::spawn(move || { + // Create a new runtime and block on the current thread tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -72,14 +117,20 @@ fn on_create_room( }) .join() .unwrap(); + + // Debug log the result debug!("Got Result: {:?}", res); + + // Handle the result of sending the information to the server match res { Ok(transfer_response) => { + // Print the room URL and transfer name if !transfer_response.local_room_id.is_empty() && !transfer_response.relay_room_id.is_empty() { println!(); + // Print the QR code for the transfer name if let Err(error) = qr2term::print_qr(&transfer_name) { error!("Failed to generate QR code: {}", error); } @@ -90,30 +141,56 @@ fn on_create_room( } } Err(e) => { + // Log the error error!("Error sending info: {e}"); } } + // Continue with the operation Status::Continue() } + +/// Handle the join room packet. +/// +/// This function is responsible for handling the join room packet received from the receiver. +/// It checks if the size of the room is provided and returns an error if it is not. It then +/// generates the public key and signs it with the HMAC key. It sends the handshake packet to +/// the receiver. +/// +/// # Arguments +/// +/// * `context` - The sender context. +/// * `size` - The size of the room. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error if the join room packet is invalid. fn on_join_room(context: &Context, size: Option) -> Status { + // Check if the size of the room is provided if size.is_some() { return Status::Err("Invalid join room packet.".into()); } + // Generate the public key let public_key = context.key.public_key().to_sec1_bytes().into_vec(); + // Generate the signature by signing the public key with the HMAC key let mut mac = Hmac::::new_from_slice(&context.hmac).unwrap(); mac.update(&public_key); - let signature = mac.finalize().into_bytes().to_vec(); + // Create the handshake packet with the public key and signature let handshake = HandshakePacket { public_key, signature, }; + // Send the handshake packet to the receiver context .sender .send_packet(DESTINATION, Value::Handshake(handshake)); @@ -121,106 +198,215 @@ fn on_join_room(context: &Context, size: Option) -> Status { Status::Continue() } + + +/// Handles errors by returning a `Status` with the error message. +/// +/// # Arguments +/// +/// * `message` - The error message. +/// +/// # Returns +/// +/// A `Status` indicating the error with the error message. +#[allow(clippy::missing_panics_doc)] +#[inline] fn on_error(message: String) -> Status { + // Return a `Status` with the error message Status::Err(message) } + +/// Handle the leave room packet. +/// +/// This function handles the leave room packet by aborting any ongoing task, +/// generating a new random key, clearing the shared key, clearing the task, +/// and printing an error message indicating that the transfer was interrupted +/// because the receiver disconnected. +/// +/// # Arguments +/// +/// * `context` - The sender context. +/// * `_` - The index of the sender. Currently unused. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error if there are still files being transferred. +#[allow(clippy::missing_panics_doc)] +#[inline] fn on_leave_room(context: &mut Context, _: usize) -> Status { + // Abort any ongoing task if let Some(task) = &context.task { task.abort(); } + // Generate a new random key context.key = EphemeralSecret::random(&mut OsRng); + // Clear the shared key context.shared_key = None; + // Clear the task context.task = None; + // Print an error message println!(); error!("Transfer was interrupted because the receiver disconnected."); + // Return a `Status` to indicate that the operation was successful Status::Continue() } + +/// Handle the progress packet. +/// +/// # Arguments +/// +/// * `context` - The sender context. +/// * `progress` - The progress packet. +/// +/// # Returns +/// +/// A `Status` representing the result of the operation. +/// +/// # Errors +/// +/// Returns an error if the progress packet is invalid. +#[allow(clippy::missing_panics_doc)] +#[inline] fn on_progress(context: &Context, progress: ProgressPacket) -> Status { + // Check if the shared key is established if context.shared_key.is_none() { + // Return an error if the progress packet is invalid return Status::Err("Invalid progress packet: no shared key established".into()); } + // Get the file corresponding to the progress packet index let file = match context.files.get(progress.index as usize) { Some(file) => file, None => return Status::Err("Invalid index in progress packet.".into()), }; + // Print the progress of the file transfer print!("\rTransferring '{}': {}%", file.name, progress.progress); + // Flush the stdout stdout().flush().unwrap(); + // Check if the progress is 100% if progress.progress == 100 { + // Print a newline println!(); + // Check if this is the last file being transferred if progress.index as usize == context.files.len() - 1 { + // Return an exit status to indicate that the operation was successful return Status::Exit(); } } + // Return a continue status to indicate that the operation was successful Status::Continue() } -async fn on_chunk(sender: Sender, shared_key: Option, files: Vec) { + +/// Asynchronously transfers the chunks of files to the receiver. +/// +/// # Arguments +/// +/// * `sender` - The sender object used to send packets. +/// * `shared_key` - The shared key used for encryption. +/// * `files` - The list of files to be transferred. +#[allow(clippy::missing_panics_doc)] +#[inline] +async fn on_chunk( + sender: Sender, + shared_key: Option, + files: Vec, +) { + // For each file in the list of files for file in files { let mut sequence = 0; let mut chunk_size = MAX_CHUNK_SIZE; let mut size = file.size as isize; + // Open the file let mut handle = match tokio::fs::File::open(file.path).await { Ok(handle) => handle, Err(error) => { + // Print an error message if the file cannot be opened println!("Error: Unable to open file '{}': {}", file.name, error); return; } }; + // While there are still chunks to be transferred while size > 0 { + // If the remaining size is less than the maximum chunk size if size < chunk_size { + // Set the chunk size to the remaining size chunk_size = size; } + // Create a vector to hold the chunk let mut chunk = vec![0u8; chunk_size.try_into().unwrap()]; + + // Read the chunk from the file handle.read_exact(&mut chunk).await.unwrap(); + // Send the encrypted chunk packet to the receiver sender.send_encrypted_packet( &shared_key, DESTINATION, Value::Chunk(ChunkPacket { sequence, chunk }), ); + // Increment the sequence and decrement the size sequence += 1; size -= chunk_size; } + // Wait for a delay before starting the next file transfer sleep(DELAY).await; } } +/// Finalizes the handshake by sending the list of files to the receiver and +/// starting the file transfer task. +/// +/// # Arguments +/// +/// * `context` - The mutable context holding the sender, files, and shared key. +/// +/// # Returns +/// +/// A `Status` indicating the success or failure of the handshake finalization. fn on_handshake_finalize(context: &mut Context) -> Status { + // Create a vector of `Entry`s from the files in the context let mut entries = vec![]; - for (index, file) in context.files.iter().enumerate() { let entry = list_packet::Entry { + // The index of the file in the context index: index.try_into().unwrap(), + // The name of the file name: file.name.clone(), + // The size of the file size: file.size, }; - entries.push(entry); } + // Send the encrypted list packet to the receiver context.sender.send_encrypted_packet( &context.shared_key, DESTINATION, Value::List(ListPacket { entries }), ); + // Spawn the file transfer task and store it in the context context.task = Some(tokio::spawn(on_chunk( context.sender.clone(), context.shared_key.clone(), @@ -230,34 +416,73 @@ fn on_handshake_finalize(context: &mut Context) -> Status { Status::Continue() } +/// Handles the handshake response packet received from the receiver. +/// +/// # Arguments +/// +/// * `context` - The mutable context holding the sender, files, and shared key. +/// * `handshake_response` - The handshake response packet received from the receiver. +/// +/// # Returns +/// +/// A `Status` indicating the success or failure of the handshake. fn on_handshake(context: &mut Context, handshake_response: HandshakeResponsePacket) -> Status { + // Check if the handshake has already been performed if context.shared_key.is_some() { return Status::Err("Already performed handshake.".into()); } + // Create a HMAC instance with the HMAC key let mut mac = Hmac::::new_from_slice(&context.hmac).unwrap(); + // Update the HMAC with the public key from the handshake response mac.update(&handshake_response.public_key); + // Verify the signature in the handshake response let verification = mac.verify_slice(&handshake_response.signature); if verification.is_err() { + // Return an error if the signature is invalid return Status::Err("Invalid signature from the receiver.".into()); } + // Convert the public key from bytes to a public key instance let shared_public_key = PublicKey::from_sec1_bytes(&handshake_response.public_key).unwrap(); + // Derive the shared secret using Diffie-Hellman key exchange let shared_secret = context.key.diffie_hellman(&shared_public_key); + + // Extract the raw secret bytes from the shared secret let shared_secret = shared_secret.raw_secret_bytes(); + + // Take the first 16 bytes of the secret as the shared key let shared_secret = &shared_secret[0..16]; + // Convert the shared secret bytes to a `Key` instance let shared_key: &Key = shared_secret.into(); + + // Create a new instance of `Aes128Gcm` with the shared key let shared_key = ::new(shared_key); + // Set the shared key in the context context.shared_key = Some(shared_key); + // Finalize the handshake by sending the list of files and starting the file transfer task on_handshake_finalize(context) } +/// Handles the incoming message from the WebSocket. +/// +/// # Arguments +/// +/// * `context` - The mutable context holding the sender and shared key. +/// * `message` - The incoming WebSocket message. +/// * `relay` - The URL of the relay. +/// * `transfer_name` - The name of the transfer. +/// * `is_local` - Whether the transfer is local or not. +/// +/// # Returns +/// +/// A `Status` indicating the success or failure of handling the message. fn on_message( context: &mut Context, message: WebSocketMessage, @@ -266,25 +491,36 @@ fn on_message( is_local: bool, ) -> Status { match message.clone() { + // Handle the text WebSocket message WebSocketMessage::Text(text) => { + // Parse the JSON packet from the text message let packet = match serde_json::from_str(&text) { Ok(packet) => packet, Err(_) => { return Status::Continue(); } }; + + // Call the corresponding handler based on the packet type return match packet { + // Handle the `Create` packet JsonPacketResponse::Create { id } => { on_create_room(context, id, relay, transfer_name, is_local) } + // Handle the `Join` packet JsonPacketResponse::Join { size } => on_join_room(context, size), + // Handle the `Leave` packet JsonPacketResponse::Leave { index } => on_leave_room(context, index), + // Handle the `Error` packet JsonPacketResponse::Error { message } => on_error(message), }; } + // Handle the binary WebSocket message WebSocketMessage::Binary(data) => { + // Extract the encrypted data from the binary message let data = data[1..].to_vec(); + // Decrypt the data using the shared key if available let data = if let Some(shared_key) = &context.shared_key { let nonce = &data[..NONCE_SIZE]; let ciphertext = &data[NONCE_SIZE..]; @@ -294,23 +530,41 @@ fn on_message( data }; + // Decode the packet from the decrypted data let packet = Packet::decode(data.as_ref()).unwrap(); let value = packet.value.unwrap(); + // Call the corresponding handler based on the packet value return match value { + // Handle the `HandshakeResponse` packet Value::HandshakeResponse(handshake_response) => { on_handshake(context, handshake_response) } + // Handle the `Progress` packet Value::Progress(progress) => on_progress(context, progress), + // Handle unexpected packets _ => Status::Err(format!("Unexpected packet: {:?}", value)), }; } + // Handle other message types _ => (), } + // Return an error for unsupported message types Status::Err("Invalid message type".into()) } +/// Starts the sender process. +/// +/// # Arguments +/// +/// * `socket` - The WebSocket connection. +/// * `paths` - The paths to the files to be sent. +/// * `room_id` - The ID of the room to join. +/// * `relay` - The URL of the relay server. +/// * `transfer_name` - The name of the transfer. +/// * `is_local` - Whether the transfer is local or not. +#[allow(clippy::needless_doctest_main)] pub async fn start( socket: Socket, paths: Vec, @@ -319,9 +573,11 @@ pub async fn start( transfer_name: String, is_local: bool, ) { + // Prepare the files to be sent let mut files = vec![]; for path in paths { + // Open the file let handle = match fs::File::open(&path) { Ok(handle) => handle, Err(error) => { @@ -332,6 +588,7 @@ pub async fn start( let metadata = handle.metadata().unwrap(); + // Check if the path points to a file if metadata.is_dir() { error!("Error: The path '{}' does not point to a file.", path); return; @@ -341,6 +598,7 @@ pub async fn start( let size = metadata.len(); + // Check if the file is empty if size == 0 { error!("Error: The file '{}' is empty and cannot be sent.", name); return; @@ -353,15 +611,20 @@ pub async fn start( }); } + // Generate the HMAC key let mut hmac = [0u8; 32]; OsRng.fill_bytes(&mut hmac); + // Generate the encryption key let key = EphemeralSecret::random(&mut OsRng); + // Create the flume channels let (sender, receiver) = flume::bounded(1000); + // Split the WebSocket connection let (outgoing, incoming) = socket.split(); + // Create the context let mut context = Context { sender, key, @@ -375,10 +638,12 @@ pub async fn start( debug!("Attempting to create room..."); debug!("With Room-ID: {:?}", room_id); + // Send the create room packet context.sender.send_json_packet(JsonPacket::Create { id: room_id.clone(), }); + // Handle the incoming WebSocket messages let outgoing_handler = receiver.stream().map(Ok).forward(outgoing); let incoming_handler = incoming.try_for_each(|message| { @@ -390,15 +655,17 @@ pub async fn start( is_local, ) { Status::Exit() => { - // TODO: Signal Exit to the server + // Send the leave room packet context.sender.send_json_packet(JsonPacket::Leave); println!("Transfer has completed."); + // Return an error return future::err(Error::ConnectionClosed); } Status::Err(error) => { error!("Error: {}", error); + // Return an error return future::err(Error::ConnectionClosed); } _ => {} @@ -409,6 +676,7 @@ pub async fn start( pin_mut!(incoming_handler, outgoing_handler); + // Wait for the incoming or outgoing handlers to complete future::select(incoming_handler, outgoing_handler).await; } diff --git a/caesar-core/src/sender/http_client.rs b/caesar-core/src/sender/http_client.rs index 366e038..d6a1211 100644 --- a/caesar-core/src/sender/http_client.rs +++ b/caesar-core/src/sender/http_client.rs @@ -8,18 +8,34 @@ use crate::relay::transfer::{TransferRequest, TransferResponse}; type Result = std::result::Result>; +/// Asynchronously sends information about the sender to the specified relay. +/// +/// # Arguments +/// +/// * `relay` - The URL of the relay. +/// * `name` - The name of the sender. +/// * `room_id` - The ID of the room. +/// * `is_local` - Indicates whether the sender is local. +/// +/// # Returns +/// +/// A `Result` containing a `TransferResponse` if the request was successful, or an error if it failed. pub async fn send_info( relay: &str, name: &str, room_id: &str, is_local: bool, ) -> Result { + // Build the URL for the request let url = relay.to_string(); + + // Get the sender's IP address let sender_ip = match local_ipv6() { Ok(ip) => ip, Err(_) => match local_ip() { Ok(ip) => ip, Err(e) => { + // Log the error and return the error error!("Error getting local ip: {e:?}"); return Err(Box::new(e)); } @@ -27,14 +43,19 @@ pub async fn send_info( }; let ip_str = sender_ip.to_owned().to_string(); + // Create the transfer request let transfer_request = TransferRequest { + // Set the name of the sender name: String::from(name), + // Set the IP address of the sender ip: ip_str, + // Set the room ID for the local sender local_room_id: if is_local { String::from(room_id) } else { String::from("") }, + // Set the room ID for the relay sender relay_room_id: if !is_local { String::from(room_id) } else { @@ -42,7 +63,10 @@ pub async fn send_info( }, }; + // Log the start of the request debug!("Trying to send Request."); + + // Send the request and parse the response let result: Result = task::spawn_blocking(move || { let client = Client::new(); let response = client @@ -54,5 +78,6 @@ pub async fn send_info( }) .await?; + // Return the result result } diff --git a/caesar-core/src/sender/mod.rs b/caesar-core/src/sender/mod.rs index 712a152..01dac15 100644 --- a/caesar-core/src/sender/mod.rs +++ b/caesar-core/src/sender/mod.rs @@ -18,20 +18,33 @@ use tower_http::trace::{DefaultMakeSpan, TraceLayer}; use tracing::{debug, error, info}; use uuid::Uuid; +/// Start the sender process. +/// +/// This function starts the sender process which connects to a relay and +/// initiates file transfers. The sender also starts a local WebSocket server. +/// +/// # Arguments +/// +/// * `name` - The name of the sender. +/// * `relay` - The relay to connect to. +/// * `files` - The files to transfer. pub async fn start_sender(name: String, relay: Arc, files: Arc>) { + // Log the name of the sender debug!("Got name: {:?}", name); + // Create a channel for communication between threads let (tx, mut rx) = mpsc::channel(1); - debug!("Got relay: {relay}"); + // Generate a unique room ID let room_id = Uuid::new_v4().to_string(); - // let rand_name = generate_random_name(); let local_room_id = room_id.clone(); let local_files = files.clone(); let local_relay = relay.clone(); let local_rand_name = name.clone(); let local_tx = tx.clone(); + // Start a local WebSocket server let local_ws_thread = task::spawn(async move { start_local_ws().await; }); + // Connect to the relay let relay_thread = task::spawn(async move { connect_to_server( relay.clone(), @@ -44,6 +57,7 @@ pub async fn start_sender(name: String, relay: Arc, files: Arc, files: Arc(), @@ -90,10 +130,22 @@ pub async fn start_local_ws() { .await .unwrap(); } else { + // Log an error if the server fails to bind. error!("Failed to listen on: {}:{}", app_host, app_port); } } +/// Connects to the specified server and starts the file transfer. +/// +/// # Arguments +/// +/// * `relay` - The relay server URL. +/// * `files` - The files to be transferred. +/// * `room_id` - The room ID for the transfer. If `None`, a random UUID is generated. +/// * `message_server` - The message server URL. +/// * `transfer_name` - The name of the transfer. +/// * `tx` - The sender end of a channel to signal the completion of the transfer. +/// * `is_local` - Whether the transfer is local or not. async fn connect_to_server( relay: Arc, files: Arc>, @@ -103,21 +155,33 @@ async fn connect_to_server( tx: mpsc::Sender<()>, is_local: bool, ) { + // Construct the server URL. let url = format!("{}/ws", relay); + + // Construct the message server URL. let message_relay = format!("{}", message_server); + + // Construct the transfer name. let transfer_name = format!("{}", transfer_name); + + // Create a request to the server. match url.clone().into_client_request() { Ok(mut request) => { + // Set the "Origin" header. request .headers_mut() .insert("Origin", HeaderValue::from_str(relay.as_ref()).unwrap()); + // Log the connection attempt. debug!("Attempting to connect to {url}..."); + + // Generate a room ID if not provided. let room_id = match room_id { Some(id) => id, None => Uuid::new_v4().to_string(), }; + // Connect to the server and start the file transfer. match connect_async(request).await { Ok((socket, _)) => { let paths = files.to_vec(); @@ -130,14 +194,18 @@ async fn connect_to_server( is_local, ) .await; + + // Signal the completion of the transfer. tx.send(()).await.unwrap(); } Err(e) => { + // Log the connection error. error!("Error: Failed to connect with error: {e}"); } } } Err(e) => { + // Log the request creation error. error!("Error: failed to create request with reason: {e:?}"); } } diff --git a/caesar-core/src/sender/util.rs b/caesar-core/src/sender/util.rs index 9211561..d263a6d 100644 --- a/caesar-core/src/sender/util.rs +++ b/caesar-core/src/sender/util.rs @@ -2,40 +2,80 @@ use hex; use rand::{seq::SliceRandom, thread_rng}; use sha2::{Digest, Sha256}; + +/// Generates a random name composed of an adjective, a noun, and another noun. +/// +/// # Returns +/// +/// A string in the format "{adjective}-{noun1}-{noun2}". pub fn generate_random_name() -> String { let mut rng = thread_rng(); let adjective = adjectives().choose(&mut rng).unwrap(); - // let adjective = adjectives().sample(&mut rng).unwrap(); let noun1 = nouns1().choose(&mut rng).unwrap(); let noun2 = nouns2().choose(&mut rng).unwrap(); format!("{adjective}-{noun1}-{noun2}") } +/// Returns a random adjective. +/// +/// # Returns +/// +/// A `&'static str` representing an adjective. fn adjectives() -> &'static [&'static str] { + // Define a list of adjectives. static ADJECTIVES: &[&str] = &["funny", "smart", "creative", "friendly", "great"]; ADJECTIVES } +/// Returns a random noun. +/// +/// # Returns +/// +/// A `&'static str` representing a noun. fn nouns1() -> &'static [&'static str] { + // Define a list of nouns. static NOUNS1: &[&str] = &["dog", "cat", "flower", "tree", "house"]; NOUNS1 } +/// Returns a random noun. +/// +/// # Returns +/// +/// A `&'static str` representing a noun. fn nouns2() -> &'static [&'static str] { + // Define a list of nouns. static NOUNS2: &[&str] = &["cookie", "cake", "frosting"]; NOUNS2 } +/// Hashes a given name using SHA256 and returns the hex-encoded result. +/// +/// # Parameters +/// +/// * `name`: A `String` representing the name to be hashed. +/// +/// # Returns +/// +/// A `String` containing the hex-encoded hash of the name. pub fn hash_random_name(name: String) -> String { let hashed_name = Sha256::digest(name.as_bytes()); hex::encode(hashed_name) } +/// Replaces occurrences of "ws://" and "wss://" in a given address with "http://" and "https://" respectively. +/// +/// # Parameters +/// +/// * `address`: A `&str` representing the address to modify. +/// +/// # Returns +/// +/// A `String` representing the modified address. pub fn replace_protocol(address: &str) -> String { let mut result = address.to_string(); result = result.replace("ws://", "http://"); - result = result.replace("wss://", "https://"); result diff --git a/caesar-core/src/shared.rs b/caesar-core/src/shared.rs index 25de622..338b66d 100644 --- a/caesar-core/src/shared.rs +++ b/caesar-core/src/shared.rs @@ -14,46 +14,116 @@ use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::protocol::Message as WebSocketMessage; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +/// Represents a packet that is sent over a websocket connection. +/// +/// This enum is used to represent different types of packets that can be sent over a websocket connection. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum JsonPacket { - Join { id: String }, - Create { id: Option }, + /// A packet to join a room. + /// + /// This variant is used to request to join a room. The `id` field is used to specify the room id. + Join { + /// The id of the room to join. + id: String, + }, + /// A packet to create a new room. + /// + /// This variant is used to request to create a new room. The `id` field is used to specify the room id, which can be optional. + Create { + /// The id of the room to create. It can be `None` to generate a random room id. + id: Option, + }, + /// A packet to leave a room. + /// + /// This variant is used to request to leave a room. Leave, } +/// Represents a response to a `JsonPacket` packet. +/// +/// This enum is used to represent different types of responses to a `JsonPacket` packet. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum JsonPacketResponse { + /// A response to a `Join` packet. + /// + /// This variant is used to indicate the result of a `Join` packet. The `size` field is used to specify the number of existing users in the room. Join { + /// The number of existing users in the room. This field is `None` if the room is empty. #[serde(skip_serializing_if = "Option::is_none")] size: Option, }, + /// A response to a `Create` packet. + /// + /// This variant is used to indicate the result of a `Create` packet. The `id` field is used to specify the room id. Create { + /// The id of the created room. id: String, }, + /// A response to a `Leave` packet. + /// + /// This variant is used to indicate the result of a `Leave` packet. The `index` field is used to specify the index of the user who left the room. Leave { + /// The index of the user who left the room. index: usize, }, + /// An error response. + /// + /// This variant is used to indicate an error. The `message` field is used to specify the error message. Error { + /// The error message. message: String, }, } +/// Represents the result of an operation. +/// +/// This enum is used to indicate the status of an operation. It can be one of three +/// variants: +/// +/// - `Continue`: Operation was successful and the client should continue. +/// - `Exit`: Operation was successful and the client should exit. +/// - `Err`: Operation encountered an error. The error message is provided in the +/// variant. #[derive(Debug, PartialEq)] pub enum Status { + /// Operation was successful and the client should continue. Continue(), + /// Operation was successful and the client should exit. Exit(), + /// Operation encountered an error. The error message is provided in the variant. Err(String), } +/// Represents a sender of JSON packets. +/// +/// This trait is used to send JSON packets to a `JsonPacket` receiver. The +/// `send_json_packet` method is used to send a `JsonPacket` packet. pub trait JsonPacketSender { + /// Sends a `JsonPacket` packet to a receiver. + /// + /// This method sends a `JsonPacket` packet to a receiver. The `packet` argument + /// is the packet to send. fn send_json_packet(&self, packet: JsonPacket); } +/// Represents a sender of packets. +/// +/// This trait is used to send packets to a receiver. The `send_packet` method is used to send +/// a plain packet, and the `send_encrypted_packet` method is used to send an encrypted packet. pub trait PacketSender { + /// Sends a plain packet to a receiver. + /// + /// This method sends a plain packet to a receiver. The `destination` argument specifies the + /// destination of the packet, and the `packet` argument is the packet to send. fn send_packet(&self, destination: u8, packet: packets::packet::Value); + /// Sends an encrypted packet to a receiver. + /// + /// This method sends an encrypted packet to a receiver. The `key` argument is the encryption + /// key to use, the `destination` argument specifies the destination of the packet, and the + /// `value` argument is the packet to send. fn send_encrypted_packet( &self, key: &Option, @@ -62,47 +132,97 @@ pub trait PacketSender { ); } -impl JsonPacketSender for Sender { - fn send_json_packet(&self, packet: JsonPacket) { - let serialized_packet = - serde_json::to_string(&packet).expect("Failed to serialize JSON packet."); +/// Implementation of `JsonPacketSender` for `Sender` struct. +/// +/// This implementation of `JsonPacketSender` for `Sender` struct provides a method +/// `send_json_packet` to send a `JsonPacket` packet. +impl JsonPacketSender for Sender { + /// Sends a `JsonPacket` packet to a receiver. + /// + /// This method serializes the `JsonPacket` using `serde_json` and sends it as a + /// `WebSocketMessage::Text` to a receiver. + /// + /// # Arguments + /// + /// * `packet` - The `JsonPacket` to send. + fn send_json_packet(&self, packet: JsonPacket) { + // Serialize the JsonPacket using serde_json + let serialized_packet = serde_json::to_string(&packet) + .expect("Failed to serialize JSON packet."); + + // Send the serialized packet as a WebSocketMessage::Text self.send(WebSocketMessage::Text(serialized_packet)) .expect("Failed to send JSON packet."); } } +/// Implementation of `PacketSender` for `Sender` struct. +/// +/// This implementation of `PacketSender` for `Sender` struct provides methods +/// to send a packet to a receiver. impl PacketSender for Sender { + /// Sends a packet to a receiver. + /// + /// This method serializes the packet and sends it as a `WebSocketMessage::Binary` to a receiver. + /// + /// # Arguments + /// + /// * `destination` - The destination of the packet. + /// * `value` - The packet to send. fn send_packet(&self, destination: u8, value: packets::packet::Value) { + // Serialize the packet let packet = Packet { value: Some(value) }; - let mut serialized_packet = packet.encode_to_vec(); + + // Insert the destination at the beginning of the packet serialized_packet.insert(0, destination); + // Send the serialized packet as a WebSocketMessage::Binary self.send(WebSocketMessage::Binary(serialized_packet)) .expect("Failed to send Packet."); } + /// Sends an encrypted packet to a receiver. + /// + /// This method encrypts the packet using the provided key and sends it as a + /// `WebSocketMessage::Binary` to a receiver. + /// + /// # Arguments + /// + /// * `key` - The encryption key to use. + /// * `destination` - The destination of the packet. + /// * `value` - The packet to send. fn send_encrypted_packet( &self, key: &Option, destination: u8, value: packets::packet::Value, ) { + // Serialize the packet let packet = Packet { value: Some(value) }; + // Generate a nonce for encryption let nonce = Aes128Gcm::generate_nonce(&mut OsRng); + + // Serialize the packet let plaintext = packet.encode_to_vec(); + + // Encrypt the packet using the provided key let mut ciphertext = key .as_ref() .unwrap() .encrypt(&nonce, plaintext.as_ref()) .expect("Failed to encrypt Packet."); + // Create the serialized packet by concatenating the nonce and the ciphertext let mut serialized_packet = nonce.to_vec(); serialized_packet.append(&mut ciphertext); + + // Insert the destination at the beginning of the packet serialized_packet.insert(0, destination); + // Send the serialized packet as a WebSocketMessage::Binary self.send(WebSocketMessage::Binary(serialized_packet)) .expect("Failed to send encrypted Packet."); } diff --git a/caesar-shuttle/src/main.rs b/caesar-shuttle/src/main.rs index 63241e1..f329837 100644 --- a/caesar-shuttle/src/main.rs +++ b/caesar-shuttle/src/main.rs @@ -10,6 +10,18 @@ use caesar_core::relay::server::upload_info; use caesar_core::relay::server::ws_handler; use shuttle_axum::ShuttleAxum; + +/// The main function that sets up the Axum application. +/// +/// This function creates a new server data structure and sets up the application routes. +/// The routes include "/ws" for the websocket handler, "/upload" for the upload info handler, +/// "/download/:name" for the download info handler, and "/download_success/:name" for the download success handler. +/// The routes are associated with the corresponding handlers. +/// +/// The application state is wrapped around the routes using the `with_state` method. +/// The client IP source is added as an extension using the `layer` method. +/// +/// The function returns a `ShuttleAxum` result. #[shuttle_runtime::main] async fn axum() -> ShuttleAxum { // Create a new server data structure. @@ -17,12 +29,13 @@ async fn axum() -> ShuttleAxum { // Set up the application routes. let app = Router::new() - .route("/ws", get(ws_handler)) - .route("/upload", put(upload_info)) - .route("/download/:name", get(download_info)) - .route("/download_success/:name", post(download_success)) - .with_state(appstate) - .layer(SecureClientIpSource::ConnectInfo.into_extension()); + .route("/ws", get(ws_handler)) // Route for the websocket handler + .route("/upload", put(upload_info)) // Route for the upload info handler + .route("/download/:name", get(download_info)) // Route for the download info handler + .route("/download_success/:name", post(download_success)) // Route for the download success handler + .with_state(appstate) // Wrap the routes with the application state + .layer(SecureClientIpSource::ConnectInfo.into_extension()); // Add the client IP source as an extension + // Return the application router wrapped in a `ShuttleAxum` result. Ok(app.into()) } diff --git a/flutter_test_gui/lib/main.dart b/flutter_test_gui/lib/main.dart index 3f17f5a..ed58a50 100644 --- a/flutter_test_gui/lib/main.dart +++ b/flutter_test_gui/lib/main.dart @@ -8,92 +8,161 @@ import 'package:flutter_test_gui/pages/send_screen.dart'; import 'package:flutter_test_gui/pages/receive_screen.dart'; import 'package:flutter_test_gui/consts/consts.dart'; +/// Main entrypoint of the application. +/// +/// This function is called when the application starts. It initializes the +/// Rust library, sets up the application widget, and shows the window. +/// +/// The function first calls the [RustLib.init] function to initialize the +/// Rust library. Then, it runs the application using the [runApp] function +/// with the [MyApp] widget. If the application is running on Windows, Linux, +/// or macOS, it sets up the window properties such as the minimum size, +/// initial size, alignment, and title. Finally, it shows the window. Future main() async { + // Initialize the Rust library await RustLib.init(); + + // Set up the application widget runApp(const MyApp()); + + // Set up the window properties if running on Windows, Linux, or macOS if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { doWhenWindowReady(() { final win = appWindow; + + // Set the minimum size of the window const initialSize = Size(720, 512); win.minSize = initialSize; + + // Set the initial size of the window win.size = initialSize; + + // Set the alignment of the window win.alignment = Alignment.center; + + // Set the title of the window win.title = 'Caesar Test Demo'; + + // Show the window win.show(); }); } } -// const backColor = Color(0xFF32363E); -// const highlightColor = Color(0xFF98C379); -// const textColor = Color(0xFFABB2BF); - +/// The root widget of the application. +/// +/// It sets up the material design theme and provides the home page. class MyApp extends StatefulWidget { + /// Creates a new instance of [MyApp]. const MyApp({super.key}); @override State createState() => _MyAppState(); } +/// The state for the [MyApp] widget. class _MyAppState extends State { @override Widget build(BuildContext context) { + // Set up the material design theme. return MaterialApp( title: 'Caesar-Transfer', theme: ThemeData( useMaterial3: true, ), - home: const MyHomePage(title: 'Caesar-Transfer'), + // Set the home page. + home: const MyHomePage( + title: 'Caesar-Transfer', + ), ); } } +/// The root widget of the application that represents the home page. +/// +/// It sets up the material design theme and provides the home page. class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); + /// Creates a new instance of [MyHomePage]. + /// + /// The [title] argument is the title of the home page. + const MyHomePage({ + super.key, + required this.title, + }); + /// The title of the home page. final String title; @override State createState() => _MyHomePageState(); } +/// The state for the [MyHomePage] widget. class _MyHomePageState extends State { + /// The list of screens that can be displayed on the home page. final List _screens = [ SendScreen(), ReceiveScreen(), ]; + + /// The index of the currently selected screen. int _selectedIndex = 0; + + /// Handles the tap event on a tab. + /// + /// Updates the selected index and rebuilds the widget tree. + /// + /// The [index] argument is the index of the selected tab. void _onItemTapped(int index) { setState(() { _selectedIndex = index; }); } + /// Builds the user interface for the home page. + /// + /// It creates a [MaterialApp] widget with a [Scaffold] as the home page. + /// The [Scaffold] includes an [AppBar], [BottomNavigationBar], and a [Body]. + /// The [AppBar] displays the title of the app and a [PopupMenuButton] for + /// accessing the settings screen. The [BottomNavigationBar] displays icons + /// for the send and receive screens and allows the user to select one of them. + /// The [Body] displays the currently selected screen. + /// + /// Returns a [Widget] that represents the user interface for the home page. @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( + // Sets the background color of the scaffold. backgroundColor: Constants.backColor, appBar: AppBar( + // Sets the background color of the app bar. backgroundColor: const Color(0xFF292c3c), //0xFF282C34), centerTitle: true, + // Sets the title of the app bar. title: Text( widget.title, + // Sets the style of the title text. style: const TextStyle(color: Constants.textColor), ), + // Sets the action buttons for the app bar. actions: [ PopupMenuButton( + // Sets the action to perform when a menu item is selected. onSelected: (String result) { if (result == 'Settings') { + // Navigates to the settings screen when the 'Settings' menu item is selected. Navigator.push( context, MaterialPageRoute(builder: (context) => SettingsScreen()), ); } }, + // Sets the items to display in the popup menu. itemBuilder: (BuildContext context) => >[ const PopupMenuItem( + // Sets the value and label of a menu item. value: 'Settings', child: Text('Settings'), ) @@ -101,11 +170,16 @@ class _MyHomePageState extends State { ), ], ), + // Sets the body of the scaffold. body: _screens[_selectedIndex], + // Sets the bottom navigation bar. bottomNavigationBar: BottomNavigationBar( + // Sets the background color of the bottom navigation bar. backgroundColor: const Color(0xFF292c3c), //0xFF282C34), + // Sets the items to display in the bottom navigation bar. items: const [ BottomNavigationBarItem( + // Sets the icon and label of a bottom navigation bar item. icon: Icon(Icons.send), label: 'Send', ), @@ -114,9 +188,11 @@ class _MyHomePageState extends State { label: 'Receive', ), ], + // Sets the currently selected item in the bottom navigation bar. currentIndex: _selectedIndex, selectedItemColor: Constants.highlightColor, unselectedItemColor: Constants.textColor, + // Sets the action to perform when an item is tapped. onTap: _onItemTapped, ), ), diff --git a/flutter_test_gui/lib/pages/receive_screen.dart b/flutter_test_gui/lib/pages/receive_screen.dart index dc1263d..9f59765 100644 --- a/flutter_test_gui/lib/pages/receive_screen.dart +++ b/flutter_test_gui/lib/pages/receive_screen.dart @@ -11,28 +11,52 @@ import 'package:shared_preferences/shared_preferences.dart'; // import 'package:flutter_test_gui/src/rust/frb_generated.dart'; import 'package:flutter_test_gui/consts/consts.dart'; +/// Screen for receiving files. +/// +/// This screen is used to accept incoming file transfers. It displays a QR +/// code scanner on supported platforms and allows the user to enter a +/// connection link manually. class ReceiveScreen extends StatefulWidget { + /// Creates a new instance of the receive screen. const ReceiveScreen({super.key}); @override ReceiveScreenState createState() => ReceiveScreenState(); } +/// State for the receive screen. class ReceiveScreenState extends State { + /// The URL of the app that initiated the transfer. String appOrigin = ''; + + /// Text editing controller for the connection link input. final myController = TextEditingController(); + + /// The current input value of the connection link input. String inputValue = ''; + + /// Whether to show the QR code scanner. bool _showScanner = false; + /// Builds the QR code scanner widget. + /// + /// If the platform is iOS or Android, a QR code scanner is displayed. If the + /// platform is not supported, an empty box is returned. + /// + /// Returns a QR code scanner widget if the platform is supported, otherwise an + /// empty box. Widget _buildQRScanner() { + // Check if the platform is iOS or Android if (Platform.isIOS || Platform.isAndroid) { return MobileScanner( controller: MobileScannerController( detectionSpeed: DetectionSpeed.noDuplicates), onDetect: (barcode) { + // Check if the scanner failed to scan a QR code if (barcode.raw == null) { debugPrint('Failed to scan qr code'); } else { + // Set the input value to the scanned code final String code = barcode.barcodes.first.displayValue.toString(); print(code); setState(() { @@ -43,25 +67,49 @@ class ReceiveScreenState extends State { }, ); } else { + // If the platform is not supported, hide the scanner _showScanner = false; return const SizedBox.shrink(); } } + /// Loads the app origin from the shared preferences. + /// + /// If the app origin is not present in the shared preferences, it sets the + /// default value to 'wss://caesar-transfer-iu.shuttleapp.rs'. + /// + /// Returns a [Future] that completes with no value. Future loadSettings() async { SharedPreferences prefs = await SharedPreferences.getInstance(); appOrigin = prefs.getString('app_origin') ?? - 'wss://caesar-transfer-iu.shuttleapp.rs'; // Laden Sie die app_origin + 'wss://caesar-transfer-iu.shuttleapp.rs'; // Load the app origin } + /// Requests permission for a given [permission]. + /// + /// If the permission is already granted, it returns true. If the permission + /// is not granted, it requests the permission and returns true if the user + /// grants the permission, otherwise it returns false. + /// + /// Returns a [Future] that completes with a [bool] value indicating whether + /// the permission was granted or not. Future _requestPermission(Permission permission) async { + // Print the function name print("In _requestPermission"); + + // Check if the permission is already granted if (await permission.isGranted) { + // Print the message print("Granted"); return true; } else { + // Print the message print("Else Zweig"); + + // Request the permission final result = await permission.request(); + + // Check if the permission is granted if (result == PermissionStatus.granted) { return true; } else { @@ -70,25 +118,49 @@ class ReceiveScreenState extends State { } } + /// Starts a transfer by getting the directory path from the user and navigating + /// to the [TransferScreen] with the given [input] and [filePath]. + /// + /// The [input] is the value of the input field. If it is not empty, it gets the + /// directory path from the user using the [FilePicker.platform.getDirectoryPath] + /// method. If the user chooses a directory, it sets the [filePath] to the + /// selected directory path. If the user doesn't choose a directory, it prints + /// a message indicating that the user didn't choose a directory. + /// + /// If the platform is Android, it checks if the external storage permission is + /// granted. If it is not granted, it requests the permission and navigates to + /// the [TransferScreen] with the given [input] and [filePath]. If the permission + /// is granted, it navigates to the [MyHomePage] with the title 'Caesar Transfer'. + /// + /// If the platform is not Android, it navigates to the [TransferScreen] with the + /// given [input] and [filePath]. + /// + /// Returns a [Future] that completes with no value. Future _startTransfer(String appOrigin) async { final input = inputValue.trim(); String filePath = ''; if (input.isNotEmpty) { + // Get the directory path from the user String? selectDirectory = await FilePicker.platform.getDirectoryPath(); if (selectDirectory == null) { - print("User doesnt choose a directory"); + // Print a message indicating that the user didn't choose a directory + print("User doesn't choose a directory"); } else { - print("user choose: $selectDirectory"); + // Set the filePath to the selected directory path + print("User chose: $selectDirectory"); filePath = selectDirectory; } if (Platform.isAndroid) { + // Check if the external storage permission is granted if (await _requestPermission(Permission.manageExternalStorage)) { + // Navigate to the TransferScreen Navigator.push( context, MaterialPageRoute( builder: (context) => TransferScreen( transferName: input, directory: filePath))); } else { + // Navigate to the MyHomePage with the title 'Caesar Transfer' Navigator.push( context, MaterialPageRoute( @@ -96,6 +168,7 @@ class ReceiveScreenState extends State { const MyHomePage(title: 'Caesar Transfer'))); } } else { + // Navigate to the TransferScreen Navigator.push( context, MaterialPageRoute( @@ -105,6 +178,12 @@ class ReceiveScreenState extends State { } } + /// Builds the scaffold for the receive screen. + /// + /// The scaffold contains a center widget that contains a column of widgets. + /// The column contains a QR code scanner if [_showScanner] is true, otherwise + /// it contains a text field for entering the transfer name. Below the text + /// field is an elevated button for initiating the receive process. @override Widget build(BuildContext context) { return Scaffold( @@ -113,6 +192,8 @@ class ReceiveScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + // If _showScanner is false, display a QR code icon that can be tapped + // to start the QR code scanner. if (!_showScanner) GestureDetector( onTap: () { @@ -138,6 +219,7 @@ class ReceiveScreenState extends State { ), ), ), + // If _showScanner is true, display the QR code scanner. if (_showScanner) Container( width: MediaQuery.of(context).size.width * 0.8, @@ -148,7 +230,9 @@ class ReceiveScreenState extends State { ), child: _buildQRScanner(), ), + // Add some spacing between the scanner and the text field. const SizedBox(height: 32), + // Display a text field for entering the transfer name. Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SizedBox( @@ -179,7 +263,9 @@ class ReceiveScreenState extends State { ), ), ), + // Add some spacing between the text field and the receive button. const SizedBox(height: 16), + // Display an elevated button for initiating the receive process. ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Constants.textColor, diff --git a/flutter_test_gui/lib/pages/send_screen.dart b/flutter_test_gui/lib/pages/send_screen.dart index 0b97161..7a454a2 100644 --- a/flutter_test_gui/lib/pages/send_screen.dart +++ b/flutter_test_gui/lib/pages/send_screen.dart @@ -9,20 +9,45 @@ import 'package:flutter_test_gui/src/rust/api/simple.dart'; // import 'package:flutter_test_gui/src/rust/frb_generated.dart'; import 'package:flutter_test_gui/consts/consts.dart'; +/// Represents the screen for sending files. +/// +/// This is a [StatefulWidget] that displays a screen for sending files. +/// It allows the user to select files to send and provides a name for the transfer. +/// The selected files are stored in a list and can be accessed by the [SendScreenState]. +/// +/// See also: +/// - [SendScreenState] class SendScreen extends StatefulWidget { + /// Creates a [SendScreen]. + /// + /// The [key] parameter is used to identify the [SendScreen] widget. const SendScreen({super.key}); + /// Creates a [SendScreenState] to control the [SendScreen]. + /// + /// This method is called when a [SendScreen] widget is created. + /// It returns a new instance of [SendScreenState]. @override SendScreenState createState() => SendScreenState(); } class SendScreenState extends State { + /// List of selected files to send. final List _list = []; + + /// Name of the transfer. String transferName = ''; + + /// Indicates whether the user is currently dragging files. bool _dragging = false; + + /// Opens the file picker and adds the selected files to [_list]. + /// + /// See also: + /// - [FilePicker.platform.pickFiles] Future openFilePicker() async { FilePickerResult? result = await FilePicker.platform.pickFiles( - allowMultiple: true, // Erlaube die Auswahl mehrerer Dateien + allowMultiple: true, // Allow selecting multiple files ); if (result != null) { @@ -30,9 +55,15 @@ class SendScreenState extends State { } } + /// Generates a random name for the transfer and navigates to the waiting screen. + /// + /// See also: + /// - [generateRandomName] + /// - [WaitingScreen] Future _startTransfer() async { - final randomName = generateRandomName(); // Rust-Funktion aufrufen - print('Zusammengefügter Text: $randomName'); + final randomName = + generateRandomName(); // Call Rust function to generate random name + print('Generated transfer name: $randomName'); setState(() { transferName = randomName; }); @@ -43,42 +74,61 @@ class SendScreenState extends State { WaitingScreen(transferName: transferName, files: _list))); } + /// Builds the UI for the send screen. + /// + /// Returns a [Scaffold] widget that contains a [Column] with two children: + /// - A [Center] widget that contains a [Stack] with a [GestureDetector] that + /// handles file picking and dragging. + /// - An [ElevatedButton] that triggers the transfer when pressed. @override Widget build(BuildContext context) { return Scaffold( + // Set the background color of the scaffold. backgroundColor: Constants.backColor, + // Build the body of the scaffold. body: Column( + // Align the children vertically to the center. mainAxisAlignment: MainAxisAlignment.center, children: [ + // Build the file picking and dragging UI. Center( child: Stack( children: [ + // Build the gesture detector. GestureDetector( + // Handle file picking when the user taps. onTap: openFilePicker, + // Handle file dragging. child: DropTarget( + // Add the selected files to the list when the user drops files. onDragDone: (detail) { setState(() { _list.addAll(detail.files); }); }, + // Show the add icon when the user drags files over the drop area. onDragEntered: (detail) { setState(() { _dragging = true; }); }, + // Hide the add icon when the user stops dragging files. onDragExited: (detail) { setState(() { _dragging = false; }); }, + // Build the drop area UI. child: Column( children: [ + // Build the circular container for the drop area. Container( height: 200, width: 200, decoration: const BoxDecoration( shape: BoxShape.circle, color: Constants.textColor), + // Show the add icon when the user is dragging files. child: _dragging ? const Center( child: Icon( @@ -87,6 +137,7 @@ class SendScreenState extends State { size: 200, ), ) + // Show the upload icon when the user is not dragging files. : const Center( child: Icon( Icons.upload_rounded, @@ -95,6 +146,7 @@ class SendScreenState extends State { ), ), ), + // Add some spacing between the drop area and the send button. const SizedBox(height: 16), ], ), @@ -103,17 +155,23 @@ class SendScreenState extends State { ], ), ), + // Build the send button. ElevatedButton( style: ElevatedButton.styleFrom( + // Set the background color of the button. backgroundColor: Constants.textColor, + // Set the text color of the button. foregroundColor: Constants.backColor, + // Set the shape of the button. shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), ), + // Trigger the transfer when the user presses the button. onPressed: () { _startTransfer(); }, + // Set the text of the button. child: const Text("Send"), ), ], diff --git a/flutter_test_gui/lib/pages/settings_screen.dart b/flutter_test_gui/lib/pages/settings_screen.dart index f001954..dc118cd 100644 --- a/flutter_test_gui/lib/pages/settings_screen.dart +++ b/flutter_test_gui/lib/pages/settings_screen.dart @@ -2,12 +2,26 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_test_gui/consts/consts.dart'; +/// Screen for displaying and editing the app's settings. +/// +/// This screen allows the user to view and edit the app's settings. +/// The settings include the app environment, host, port, origin, and relay. +/// The settings are stored in SharedPreferences. class SettingsScreen extends StatefulWidget { + /// Constructs a [SettingsScreen]. + const SettingsScreen({Key? key}) : super(key: key); + @override _SettingsScreenState createState() => _SettingsScreenState(); } +/// State for the [SettingsScreen]. +/// +/// This state manages the text editing controllers for the app's settings. +/// It also loads the settings from SharedPreferences when the widget is +/// first created. class _SettingsScreenState extends State { + // Controllers for the text fields. final TextEditingController _appEnvironmentController = TextEditingController(); final TextEditingController _appHostController = TextEditingController(); @@ -15,21 +29,31 @@ class _SettingsScreenState extends State { final TextEditingController _appOriginController = TextEditingController(); final TextEditingController _appRelayController = TextEditingController(); + /// Loads the app settings from SharedPreferences when the widget is created. @override void initState() { super.initState(); loadSettings(); } + /// Loads the app settings from SharedPreferences. + /// + /// This function retrieves the app settings from SharedPreferences and + /// sets the text of the corresponding text editing controllers. + /// If any of the settings are not found in SharedPreferences, an empty + /// string is used instead. Future loadSettings() async { + // Retrieve the SharedPreferences instance SharedPreferences prefs = await SharedPreferences.getInstance(); + + // Retrieve the app settings from SharedPreferences String appEnvironment = prefs.getString('app_environment') ?? ''; String appHost = prefs.getString('app_host') ?? ''; String appPort = prefs.getString('app_port') ?? ''; String appOrigin = prefs.getString('app_origin') ?? ''; String appRelay = prefs.getString('app_relay') ?? ''; - // Setzen Sie die Controller-Texte nach dem Laden der Einstellungen + // Set the text of the corresponding text editing controllers setState(() { _appEnvironmentController.text = appEnvironment; _appHostController.text = appHost; @@ -39,28 +63,49 @@ class _SettingsScreenState extends State { }); } + /// Saves the app settings to SharedPreferences. + /// + /// This function retrieves the text from the corresponding text editing controllers + /// and saves them to SharedPreferences. + /// If any of the settings are empty, it saves an empty string. Future saveSettings() async { + // Retrieve the SharedPreferences instance SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString('app_environment', _appEnvironmentController.text); - await prefs.setString('app_host', _appHostController.text); - await prefs.setString('app_port', _appPortController.text); - await prefs.setString('app_origin', _appOriginController.text); - await prefs.setString('app_relay', _appRelayController.text); + + // Retrieve the text from the corresponding text editing controllers + String appEnvironment = _appEnvironmentController.text; + String appHost = _appHostController.text; + String appPort = _appPortController.text; + String appOrigin = _appOriginController.text; + String appRelay = _appRelayController.text; + + // Save the app settings to SharedPreferences + await prefs.setString('app_environment', appEnvironment); + await prefs.setString('app_host', appHost); + await prefs.setString('app_port', appPort); + await prefs.setString('app_origin', appOrigin); + await prefs.setString('app_relay', appRelay); } @override Widget build(BuildContext context) { return Scaffold( + // Set the background color of the Scaffold backgroundColor: Constants.backColor, appBar: AppBar( + // Set the title of the AppBar title: const Text('Settings'), + // Set the background color of the AppBar backgroundColor: const Color(0xFF292c3c), //0xFF282C34), + // Set the foreground color of the AppBar foregroundColor: Constants.textColor, ), body: Padding( + // Set the padding around the body of the Scaffold padding: const EdgeInsets.all(16.0), child: Column( children: [ + // Create a TextField for the 'App Environment' setting TextField( controller: _appEnvironmentController, decoration: const InputDecoration( @@ -68,6 +113,7 @@ class _SettingsScreenState extends State { labelStyle: TextStyle(color: Constants.highlightColor)), style: const TextStyle(color: Constants.textColor), ), + // Create a TextField for the 'App Host' setting TextField( controller: _appHostController, decoration: const InputDecoration( @@ -75,6 +121,7 @@ class _SettingsScreenState extends State { labelStyle: TextStyle(color: Constants.highlightColor)), style: const TextStyle(color: Constants.textColor), ), + // Create a TextField for the 'App Port' setting TextField( controller: _appPortController, decoration: const InputDecoration( @@ -82,6 +129,7 @@ class _SettingsScreenState extends State { labelStyle: TextStyle(color: Constants.highlightColor)), style: const TextStyle(color: Constants.textColor), ), + // Create a TextField for the 'App Origin' setting TextField( controller: _appOriginController, decoration: const InputDecoration( @@ -89,6 +137,7 @@ class _SettingsScreenState extends State { labelStyle: TextStyle(color: Constants.highlightColor)), style: const TextStyle(color: Constants.textColor), ), + // Create a TextField for the 'App Relay' setting TextField( controller: _appRelayController, decoration: const InputDecoration( @@ -97,6 +146,7 @@ class _SettingsScreenState extends State { style: const TextStyle(color: Constants.textColor), ), Spacer(), + // Create an ElevatedButton to save the settings and return to the previous screen ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Constants.textColor, diff --git a/flutter_test_gui/lib/pages/transfer_screen.dart b/flutter_test_gui/lib/pages/transfer_screen.dart index 6527638..125633d 100644 --- a/flutter_test_gui/lib/pages/transfer_screen.dart +++ b/flutter_test_gui/lib/pages/transfer_screen.dart @@ -2,18 +2,30 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test_gui/main.dart'; -import 'package:qr_flutter/qr_flutter.dart'; -import 'package:cross_file/cross_file.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_test_gui/src/rust/api/simple.dart'; // import 'package:flutter_test_gui/src/rust/frb_generated.dart'; import 'package:flutter_test_gui/consts/consts.dart'; import 'package:permission_handler/permission_handler.dart'; +/// Represents the screen for transferring files. +/// +/// This is a [StatefulWidget] that displays a screen for transferring files. +/// It takes in two parameters: +/// - [transferName]: The name of the transfer. +/// - [directory]: The directory containing the files to be transferred. class TransferScreen extends StatefulWidget { + // The name of the transfer. final String transferName; + + // The directory containing the files to be transferred. final String directory; + /// Creates a [TransferScreen] widget. + /// + /// The [transferName] and [directory] parameters are required. + /// + /// The [key] parameter is optional. const TransferScreen( {Key? key, required this.transferName, required this.directory}) : super(key: key); @@ -23,57 +35,92 @@ class TransferScreen extends StatefulWidget { } class TransferScreenState extends State { + // The origin of the application. String appOrigin = ''; + + // The input value of the transfer name. String inputValue = ''; + @override void initState() { + // Call the loadSettings function to load the settings. super.initState(); loadSettings().then((_) => callStartReceiver(appOrigin)); } + /// Loads the settings from the SharedPreferences. + /// + /// It retrieves the value of 'app_origin' from the SharedPreferences and + /// assigns it to the [appOrigin] variable. If the value is not present, it + /// assigns a default value of 'wss://caesar-transfer-iu.shuttleapp.rs'. Future loadSettings() async { SharedPreferences prefs = await SharedPreferences.getInstance(); appOrigin = prefs.getString('app_origin') ?? 'wss://caesar-transfer-iu.shuttleapp.rs'; } + /// Calls the start transfer function with the given [appOrigin]. + /// + /// It calls the _startTransfer function with the [appOrigin] parameter. Future callStartReceiver(String appOrigin) async { _startTransfer(appOrigin); } + /// Starts the transfer with the given [appOrigin]. + /// + /// If the transfer name is not empty, it checks if the platform is Android. + /// If it is, it requests the ManageExternalStorage permission. If the + /// permission is granted, it starts the receiver using the startRustReceiver + /// function. If the permission is not granted, it navigates to the + /// MyHomePage. If the platform is not Android, it starts the receiver + /// directly. If the transfer name is empty, it does not start the receiver. + /// + /// Parameters: + /// - appOrigin: The origin of the application. Future _startTransfer(String appOrigin) async { + // Get the input value from the widget. final input = widget.transferName; String filePath = widget.directory; + + // If the input value is not empty, start the transfer. if (input.isNotEmpty) { if (Platform.isAndroid) { - if (await _requestPermission(Permission.manageExternalStorage)) { - try { - final outcome = await startRustReceiver( - filepath: filePath, transfername: input, relay: appOrigin); - print('Ergebnis von Rust: $outcome'); - } catch (e) { - print('Fehler beim Starten des Receivers: $e'); - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const MyHomePage(title: 'Caesar Transfer'))); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const MyHomePage(title: 'Caesar Transfer'))); + // Check if the ManageExternalStorage permission is granted. + //if (await _requestPermission(Permission.manageExternalStorage)) { + try { + // Start the receiver with the given parameters. + final outcome = await startRustReceiver( + filepath: filePath, transfername: input, relay: appOrigin); + print('Ergebnis von Rust: $outcome'); + } catch (e) { + // If an error occurs, print the error message. + print('Fehler beim Starten des Receivers: $e'); } + // Navigate to the MyHomePage. + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const MyHomePage(title: 'Caesar Transfer'))); + //} else { + // // If the permission is not granted, navigate to the MyHomePage. + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => + // const MyHomePage(title: 'Caesar Transfer'))); + //} } else { + // If the platform is not Android, start the receiver directly. try { final outcome = await startRustReceiver( filepath: filePath, transfername: input, relay: appOrigin); print('Ergebnis von Rust: $outcome'); } catch (e) { + // If an error occurs, print the error message. print('Fehler beim Starten des Receivers: $e'); } + // Navigate to the MyHomePage. Navigator.push( context, MaterialPageRoute( @@ -81,36 +128,74 @@ class TransferScreenState extends State { const MyHomePage(title: 'Caesar Transfer'))); } } + // Print the app origin. print("Transfer startet with app_origin: $appOrigin"); } + /// Requests the given [permission] and returns a `Future` of `bool` indicating + /// whether the permission is granted. + /// + /// If the permission is already granted, it returns `true`. If the permission + /// is not granted, it requests the permission and returns `true` if the + /// permission is granted successfully, otherwise it returns `false`. + /// + /// Parameters: + /// - permission: The permission to be requested. + /// + /// Returns: + /// A `Future` of `bool` indicating whether the permission is granted. Future _requestPermission(Permission permission) async { + // If the permission is already granted, return true. if (await permission.isGranted) { return true; } else { + // Request the permission and get the result. var result = await permission.request(); - if (result == PermissionStatus.granted) { - return true; - } else { - return false; - } + // If the permission is granted, return true. Otherwise, return false. + return result == PermissionStatus.granted; } } @override + + /// Builds the widget tree for the TransferScreen. + /// + /// This method builds a widget tree for the TransferScreen. It returns a + /// Scaffold widget with a background color set to Constants.backColor. The + /// body of the scaffold is a Center widget that contains a Column widget. + /// The Column widget has its mainAxisAlignment set to MainAxisAlignment.center. + /// It contains three children: a Text widget displaying the transferName, a + /// Text widget with the text "Transfer in Progress", and a SizedBox widget + /// with a height of 32. The SizedBox widget is followed by a Center widget + /// that contains an Icon widget with the icon Icons.cloud_download_rounded, + /// its color set to Constants.highlightColor, and its size set to 200. + /// + /// Returns: + /// The widget tree for the TransferScreen. Widget build(BuildContext context) { return Scaffold( + // Set the background color of the Scaffold widget. backgroundColor: Constants.backColor, body: Center( + // The body of the Scaffold widget. child: Column( + // The Column widget has its mainAxisAlignment set to + // MainAxisAlignment.center. mainAxisAlignment: MainAxisAlignment.center, children: [ + // A Text widget displaying the transferName. Text( widget.transferName, - style: const TextStyle(color: Colors.white, fontSize: 24), + style: const TextStyle( + color: Colors.white, + fontSize: 24, + ), ), + // A Text widget with the text "Transfer in Progress". Text("Transfer in Progress"), + // A SizedBox widget with a height of 32. const SizedBox(height: 32), + // A Center widget containing an Icon widget. const Center( child: Icon( Icons.cloud_download_rounded, diff --git a/flutter_test_gui/lib/pages/waiting_screen.dart b/flutter_test_gui/lib/pages/waiting_screen.dart index 501e7bb..8972f4c 100644 --- a/flutter_test_gui/lib/pages/waiting_screen.dart +++ b/flutter_test_gui/lib/pages/waiting_screen.dart @@ -8,63 +8,135 @@ import 'package:flutter_test_gui/src/rust/api/simple.dart'; // import 'package:flutter_test_gui/src/rust/frb_generated.dart'; import 'package:flutter_test_gui/consts/consts.dart'; +/// Represents the screen for displaying the waiting state. +/// +/// This is a [StatefulWidget] that displays a screen for the waiting state. +/// It takes in two parameters: +/// - [transferName]: The name of the transfer. +/// - [files]: The list of files being transferred. class WaitingScreen extends StatefulWidget { + // The list of files being transferred. final List files; + + // The name of the transfer. final String transferName; + /// Creates a new instance of the [WaitingScreen] widget. + /// + /// The [transferName] parameter is the name of the transfer. + /// The [files] parameter is the list of files being transferred. const WaitingScreen( {Key? key, required this.transferName, required this.files}) : super(key: key); + /// Creates the mutable state for this widget at a given location in the tree. + /// + /// See also: + /// - [StatefulWidget.createState] @override WaitingScreenState createState() => WaitingScreenState(); } class WaitingScreenState extends State { + // The origin of the app. String appOrigin = ''; + + /// Initializes the state of the widget. + /// + /// This function is called when the widget is first created. @override void initState() { super.initState(); + + // Load the settings and then start the transfer. loadSettings().then((_) => callStartSender(appOrigin)); } + /// Loads the settings. + /// + /// This function loads the settings from the shared preferences. + /// It retrieves the app origin from the shared preferences and assigns it to + /// the [appOrigin] variable. + /// + /// Returns a [Future] that completes when the settings are loaded. Future loadSettings() async { + // Get the shared preferences instance. SharedPreferences prefs = await SharedPreferences.getInstance(); + + // Get the app origin from the shared preferences. + // If the app origin is not found, use the default value. appOrigin = prefs.getString('app_origin') ?? 'wss://caesar-transfer-iu.shuttleapp.rs'; } + /// Calls the start sender function. + /// + /// This function calls the [_startTransfer] function with the provided + /// [appOrigin]. + /// + /// Parameters: + /// - appOrigin: The origin of the app. Future callStartSender(String appOrigin) async { _startTransfer(appOrigin); } + /// Starts the transfer. + /// + /// This function converts the list of files to a list of file names and then + /// calls the [startRustSender] function with the provided parameters. + /// + /// Parameters: + /// - appOrigin: The origin of the app. Future _startTransfer(String appOrigin) async { + // Convert the list of files to a list of file names. List fileNames = widget.files.map((file) => file.path).toList(); + + // Start the transfer. final outcome = await startRustSender( name: widget.transferName, relay: appOrigin, files: fileNames); + + // Navigate to the home page. Navigator.push( context, MaterialPageRoute( builder: (context) => MyHomePage(title: 'Caesar Transfer'))); } + /// Builds the waiting screen widget. + /// + /// This widget displays the transfer name and a QR code representing the + /// transfer name. + /// + /// Returns: + /// A [Scaffold] widget containing the waiting screen UI. @override Widget build(BuildContext context) { return Scaffold( + // Set the background color of the scaffold. backgroundColor: Constants.backColor, + // Center the content of the scaffold. body: Center( child: Column( + // Align the children of the column in the center. mainAxisAlignment: MainAxisAlignment.center, children: [ + // Display the transfer name. Text( widget.transferName, + // Set the text style for the transfer name. style: const TextStyle(color: Colors.white, fontSize: 24), ), + // Add spacing between the transfer name and the QR code. const SizedBox(height: 32), + // Display a QR code representing the transfer name. QrImageView( + // Set the data to be encoded in the QR code. data: widget.transferName, + // Set the version of the QR code. version: QrVersions.auto, + // Set the size of the QR code. size: 200, + // Set the foreground color of the QR code. foregroundColor: Constants.highlightColor, ), ],