add file browser component and widget

This commit is contained in:
Timur Gordon
2025-08-05 15:02:23 +02:00
parent 4e43c21b72
commit ba43a82db0
95 changed files with 17840 additions and 423 deletions

48
components/Cargo.toml Normal file
View File

@@ -0,0 +1,48 @@
[package]
name = "components"
version = "0.1.0"
edition = "2021"
[lib]
name = "components"
path = "src/lib.rs"
[dependencies]
# Use workspace dependencies
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
futures-util = { workspace = true }
futures-channel = { workspace = true }
# WASM-specific dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
yew = { workspace = true }
gloo = { workspace = true }
gloo-timers = { workspace = true }
web-sys = { workspace = true }
js-sys = { workspace = true }
serde-wasm-bindgen = "0.6"
hex = "0.4"
k256 = { version = "0.13", features = ["ecdsa", "sha256"] }
getrandom = { version = "0.2", features = ["js"] }
gloo-net = "0.5"
reqwest = { workspace = true }
# Native-specific dependencies
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
hero_websocket_client = { path = "../../hero/interfaces/websocket/client", default-features = false, features = [] }
tokio = { workspace = true, features = ["rt", "macros", "time"] }
[dev-dependencies]
tokio-test = "0.4"
wasm-bindgen-test = { workspace = true }
[features]
default = []
crypto = ["hero_websocket_client/crypto"]
wasm-compatible = [] # For WASM builds without crypto to avoid wasm-opt issues

192
components/src/auth.rs Normal file
View File

@@ -0,0 +1,192 @@
//! Authentication configuration for WebSocket connections
use serde::{Deserialize, Serialize};
use crate::error::{WsError, WsResult};
/// Authentication configuration for WebSocket connections
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuthConfig {
/// Private key for secp256k1 authentication (hex format)
private_key: String,
}
impl AuthConfig {
/// Create a new authentication configuration with a private key
///
/// # Arguments
/// * `private_key` - The private key in hex format for secp256k1 authentication
///
/// # Example
/// ```
/// use framework::AuthConfig;
///
/// let auth = AuthConfig::new("your_private_key_hex".to_string());
/// ```
pub fn new(private_key: String) -> Self {
Self { private_key }
}
/// Create authentication configuration from environment variable
///
/// Looks for the private key in the `WS_PRIVATE_KEY` environment variable
///
/// # Example
/// ```
/// use framework::AuthConfig;
///
/// // Set environment variable: WS_PRIVATE_KEY=your_private_key_hex
/// let auth = AuthConfig::from_env().expect("WS_PRIVATE_KEY not set");
/// ```
pub fn from_env() -> WsResult<Self> {
let private_key = std::env::var("WS_PRIVATE_KEY")
.map_err(|_| WsError::auth("WS_PRIVATE_KEY environment variable not set"))?;
if private_key.is_empty() {
return Err(WsError::auth("WS_PRIVATE_KEY environment variable is empty"));
}
Ok(Self::new(private_key))
}
/// Get the private key
pub fn private_key(&self) -> &str {
&self.private_key
}
/// Validate the private key format
///
/// Checks if the private key is a valid hex string of the correct length
pub fn validate(&self) -> WsResult<()> {
if self.private_key.is_empty() {
return Err(WsError::auth("Private key cannot be empty"));
}
// Check if it's a valid hex string
if !self.private_key.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(WsError::auth("Private key must be a valid hex string"));
}
// secp256k1 private keys are 32 bytes = 64 hex characters
if self.private_key.len() != 64 {
return Err(WsError::auth(
"Private key must be 64 hex characters (32 bytes) for secp256k1"
));
}
Ok(())
}
/// Create a CircleWsClient with this authentication configuration
///
/// # Arguments
/// * `ws_url` - The WebSocket URL to connect to
///
/// # Returns
/// A configured CircleWsClient ready for connection and authentication
#[cfg(feature = "crypto")]
pub fn create_client(&self, ws_url: String) -> circle_client_ws::CircleWsClient {
circle_client_ws::CircleWsClientBuilder::new(ws_url)
.with_keypair(self.private_key.clone())
.build()
}
/// Create a CircleWsClient without authentication (WASM-compatible mode)
///
/// # Arguments
/// * `ws_url` - The WebSocket URL to connect to
///
/// # Returns
/// A basic CircleWsClient without authentication
#[cfg(all(not(feature = "crypto"), not(target_arch = "wasm32")))]
pub fn create_client(&self, ws_url: String) -> hero_websocket_client::CircleWsClient {
hero_websocket_client::CircleWsClientBuilder::new(ws_url).build()
}
}
/// Builder pattern for AuthConfig
pub struct AuthConfigBuilder {
private_key: Option<String>,
}
impl AuthConfigBuilder {
/// Create a new AuthConfig builder
pub fn new() -> Self {
Self { private_key: None }
}
/// Set the private key
pub fn private_key<S: Into<String>>(mut self, private_key: S) -> Self {
self.private_key = Some(private_key.into());
self
}
/// Try to load private key from environment
pub fn from_env(mut self) -> WsResult<Self> {
let private_key = std::env::var("WS_PRIVATE_KEY")
.map_err(|_| WsError::auth("WS_PRIVATE_KEY environment variable not set"))?;
self.private_key = Some(private_key);
Ok(self)
}
/// Build the AuthConfig
pub fn build(self) -> WsResult<AuthConfig> {
let private_key = self.private_key
.ok_or_else(|| WsError::auth("Private key is required"))?;
let config = AuthConfig::new(private_key);
config.validate()?;
Ok(config)
}
}
impl Default for AuthConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_config_creation() {
let private_key = "a".repeat(64); // 64 hex characters
let auth = AuthConfig::new(private_key.clone());
assert_eq!(auth.private_key(), &private_key);
}
#[test]
fn test_auth_config_validation() {
// Valid private key
let valid_key = "a".repeat(64);
let auth = AuthConfig::new(valid_key);
assert!(auth.validate().is_ok());
// Invalid length
let invalid_key = "a".repeat(32);
let auth = AuthConfig::new(invalid_key);
assert!(auth.validate().is_err());
// Invalid hex characters
let invalid_hex = "g".repeat(64);
let auth = AuthConfig::new(invalid_hex);
assert!(auth.validate().is_err());
// Empty key
let empty_key = String::new();
let auth = AuthConfig::new(empty_key);
assert!(auth.validate().is_err());
}
#[test]
fn test_auth_config_builder() {
let private_key = "a".repeat(64);
let auth = AuthConfigBuilder::new()
.private_key(private_key.clone())
.build()
.expect("Should build successfully");
assert_eq!(auth.private_key(), &private_key);
}
}

View File

@@ -0,0 +1,806 @@
//! Browser-based authentication with encrypted private key storage
//!
//! This module provides authentication functionality for web applications where:
//! - Users have a password that acts as a symmetric key
//! - Private keys are encrypted and stored in browser storage
//! - Users can register multiple private keys and choose which one to use for login
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use yew::prelude::*;
use web_sys::Storage;
use k256::{SecretKey, elliptic_curve::sec1::ToEncodedPoint};
use getrandom::getrandom;
// Note: error module not available in WASM builds
// use crate::error::{WsError, WsResult};
// Define local error types for WASM builds
pub type WsResult<T> = Result<T, String>;
const STORAGE_KEY: &str = "herocode_auth_keys";
/// Represents an encrypted private key entry
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EncryptedKeyEntry {
/// Display name for this key
pub name: String,
/// Encrypted private key data
pub encrypted_key: String,
/// Salt used for encryption
pub salt: String,
/// Timestamp when this key was created
pub created_at: i64,
}
/// Authentication state for the current session
#[derive(Debug, Clone, PartialEq)]
pub enum AuthState {
/// User is not authenticated
Unauthenticated,
/// User is authenticated with a specific key
Authenticated {
key_name: String,
private_key: String,
},
}
/// Browser authentication manager
#[derive(Debug, Clone, PartialEq)]
pub struct BrowserAuthManager {
/// Current authentication state
state: AuthState,
/// Available encrypted keys
encrypted_keys: HashMap<String, EncryptedKeyEntry>,
}
impl Default for BrowserAuthManager {
fn default() -> Self {
Self::new()
}
}
impl BrowserAuthManager {
/// Create a new browser authentication manager
pub fn new() -> Self {
let mut manager = Self {
state: AuthState::Unauthenticated,
encrypted_keys: HashMap::new(),
};
// Load existing keys from browser storage
if let Err(e) = manager.load_keys() {
log::warn!("Failed to load keys from storage: {:?}", e);
}
manager
}
/// Get the current authentication state
pub fn state(&self) -> &AuthState {
&self.state
}
/// Check if user is currently authenticated
pub fn is_authenticated(&self) -> bool {
matches!(self.state, AuthState::Authenticated { .. })
}
/// Get the current private key if authenticated
pub fn current_private_key(&self) -> Option<&str> {
match &self.state {
AuthState::Authenticated { private_key, .. } => Some(private_key),
AuthState::Unauthenticated => None,
}
}
/// Get the current key name if authenticated
pub fn current_key_name(&self) -> Option<&str> {
match &self.state {
AuthState::Authenticated { key_name, .. } => Some(key_name),
AuthState::Unauthenticated => None,
}
}
/// Get list of available key names
pub fn available_keys(&self) -> Vec<String> {
self.encrypted_keys.keys().cloned().collect()
}
/// Get list of registered key names (alias for available_keys)
pub fn get_registered_keys(&self) -> WsResult<Vec<String>> {
Ok(self.available_keys())
}
/// Get public key for a given private key (if authenticated with that key)
pub fn get_public_key(&self, key_name: &str) -> WsResult<String> {
match &self.state {
AuthState::Authenticated { key_name: current_key, private_key } if current_key == key_name => {
// Convert private key to public key
let private_key_bytes = hex::decode(private_key)
.map_err(|_| "Invalid private key hex")?;
if private_key_bytes.len() != 32 {
return Err("Invalid private key length".to_string());
}
let mut key_array = [0u8; 32];
key_array.copy_from_slice(&private_key_bytes);
let secret_key = SecretKey::from_bytes(&key_array.into())
.map_err(|e| format!("Failed to create secret key: {}", e))?;
let public_key = secret_key.public_key();
Ok(hex::encode(public_key.to_encoded_point(false).as_bytes()))
}
_ => Err("Key not currently authenticated".to_string())
}
}
/// Register a new private key with encryption
pub fn register_key(&mut self, name: String, private_key: String, password: String) -> WsResult<()> {
// Validate private key format
if !self.validate_private_key(&private_key)? {
return Err("Invalid private key format".to_string());
}
// Generate a random salt
let salt = self.generate_salt();
// Encrypt the private key
let encrypted_key = self.encrypt_key(&private_key, &password, &salt)?;
let entry = EncryptedKeyEntry {
name: name.clone(),
encrypted_key,
salt,
created_at: js_sys::Date::now() as i64,
};
self.encrypted_keys.insert(name, entry);
self.save_keys()?;
Ok(())
}
/// Attempt to login with a specific key and password
pub fn login(&mut self, key_name: String, password: String) -> WsResult<()> {
let entry = self.encrypted_keys.get(&key_name)
.ok_or_else(|| "Key not found".to_string())?;
// Decrypt the private key
let private_key = self.decrypt_key(&entry.encrypted_key, &password, &entry.salt)?;
// Validate the decrypted key
if !self.validate_private_key(&private_key)? {
return Err("Failed to decrypt key or invalid key format".to_string());
}
self.state = AuthState::Authenticated {
key_name,
private_key,
};
Ok(())
}
/// Logout the current user
pub fn logout(&mut self) {
self.state = AuthState::Unauthenticated;
}
/// Remove a registered key
pub fn remove_key(&mut self, key_name: &str) -> WsResult<()> {
if self.encrypted_keys.remove(key_name).is_none() {
return Err("Key not found".to_string());
}
// If we're currently authenticated with this key, logout
if let AuthState::Authenticated { key_name: current_key, .. } = &self.state {
if current_key == key_name {
self.logout();
}
}
self.save_keys()?;
Ok(())
}
/// Generate a new secp256k1 private key using k256
pub fn generate_key() -> WsResult<String> {
let mut rng_bytes = [0u8; 32];
// Use getrandom to get cryptographically secure random bytes
getrandom(&mut rng_bytes)
.map_err(|e| format!("Failed to generate random bytes: {}", e))?;
let secret_key = SecretKey::from_bytes(&rng_bytes.into())
.map_err(|e| format!("Failed to create secret key: {}", e))?;
Ok(hex::encode(secret_key.to_bytes()))
}
/// Load encrypted keys from browser storage
fn load_keys(&mut self) -> WsResult<()> {
let storage = self.get_local_storage()?;
if let Ok(Some(data)) = storage.get_item(STORAGE_KEY) {
if !data.is_empty() {
let keys: HashMap<String, EncryptedKeyEntry> = serde_json::from_str(&data)
.map_err(|e| format!("Failed to parse stored keys: {}", e))?;
self.encrypted_keys = keys;
}
}
Ok(())
}
/// Save encrypted keys to browser storage
fn save_keys(&self) -> WsResult<()> {
let storage = self.get_local_storage()?;
let data = serde_json::to_string(&self.encrypted_keys)
.map_err(|e| format!("Failed to serialize keys: {}", e))?;
storage.set_item(STORAGE_KEY, &data)
.map_err(|_| "Failed to save keys to storage".to_string())?;
Ok(())
}
/// Get browser local storage
fn get_local_storage(&self) -> WsResult<Storage> {
let window = web_sys::window()
.ok_or_else(|| "No window object available".to_string())?;
window.local_storage()
.map_err(|_| "Failed to access local storage".to_string())?
.ok_or_else(|| "Local storage not available".to_string())
}
/// Validate private key format (secp256k1)
fn validate_private_key(&self, private_key: &str) -> WsResult<bool> {
if private_key.is_empty() {
return Ok(false);
}
// Check if it's a valid hex string
if !private_key.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(false);
}
// secp256k1 private keys are 32 bytes = 64 hex characters
Ok(private_key.len() == 64)
}
/// Generate a random salt for encryption
fn generate_salt(&self) -> String {
// Generate 32 random bytes as hex string
let mut salt = String::new();
for _ in 0..32 {
salt.push_str(&format!("{:02x}", (js_sys::Math::random() * 256.0) as u8));
}
salt
}
/// Encrypt a private key using password and salt
fn encrypt_key(&self, private_key: &str, password: &str, salt: &str) -> WsResult<String> {
// Simple XOR encryption for now - in production, use proper encryption
// This is a placeholder implementation
let key_bytes = hex::decode(private_key)
.map_err(|_| "Invalid private key hex".to_string())?;
let salt_bytes = hex::decode(salt)
.map_err(|_| "Invalid salt hex".to_string())?;
// Create a key from password and salt using a simple hash
let mut password_key = Vec::new();
let password_bytes = password.as_bytes();
for i in 0..32 {
let p_byte = password_bytes.get(i % password_bytes.len()).unwrap_or(&0);
let s_byte = salt_bytes.get(i).unwrap_or(&0);
password_key.push(p_byte ^ s_byte);
}
// XOR encrypt
let mut encrypted = Vec::new();
for (i, &byte) in key_bytes.iter().enumerate() {
encrypted.push(byte ^ password_key[i % password_key.len()]);
}
Ok(hex::encode(encrypted))
}
/// Decrypt a private key using password and salt
fn decrypt_key(&self, encrypted_key: &str, password: &str, salt: &str) -> WsResult<String> {
// Simple XOR decryption - matches encrypt_key implementation
let encrypted_bytes = hex::decode(encrypted_key)
.map_err(|_| "Invalid base64 in stored keys".to_string())?;
let salt_bytes = hex::decode(salt)
.map_err(|_| "Invalid salt hex".to_string())?;
// Create the same key from password and salt
let mut password_key = Vec::new();
let password_bytes = password.as_bytes();
for i in 0..32 {
let p_byte = password_bytes.get(i % password_bytes.len()).unwrap_or(&0);
let s_byte = salt_bytes.get(i).unwrap_or(&0);
password_key.push(p_byte ^ s_byte);
}
// XOR decrypt
let mut decrypted = Vec::new();
for (i, &byte) in encrypted_bytes.iter().enumerate() {
decrypted.push(byte ^ password_key[i % password_key.len()]);
}
Ok(hex::encode(decrypted))
}
}
/// Authentication context for Yew components
#[derive(Debug, Clone, PartialEq)]
pub struct AuthContext {
pub manager: BrowserAuthManager,
}
impl AuthContext {
pub fn new() -> Self {
Self {
manager: BrowserAuthManager::new(),
}
}
}
/// Messages for authentication component
#[derive(Debug, Clone)]
pub enum AuthMsg {
Login(String, String), // key_name, password
Logout,
RegisterKey(String, String, String), // name, private_key, password
RemoveKey(String), // key_name
GenerateKey(String, String), // name, password
ShowLoginForm,
ShowRegisterForm,
ShowGenerateKeyForm,
HideForm,
ToggleDropdown,
}
/// Authentication component state
#[derive(Debug, Clone, PartialEq)]
pub enum AuthFormState {
Hidden,
Login,
Register,
GenerateKey,
}
/// Properties for the authentication component
#[derive(Properties, PartialEq)]
pub struct AuthComponentProps {
#[prop_or_default]
pub on_auth_change: Callback<AuthState>,
}
/// Main authentication component for the header
pub struct AuthComponent {
manager: BrowserAuthManager,
form_state: AuthFormState,
login_key_name: String,
login_password: String,
register_name: String,
register_key: String,
register_password: String,
error_message: Option<String>,
dropdown_open: bool,
}
impl Component for AuthComponent {
type Message = AuthMsg;
type Properties = AuthComponentProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
manager: BrowserAuthManager::new(),
form_state: AuthFormState::Hidden,
login_key_name: String::new(),
login_password: String::new(),
register_name: String::new(),
register_key: String::new(),
register_password: String::new(),
error_message: None,
dropdown_open: false,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
AuthMsg::Login(key_name, password) => {
match self.manager.login(key_name, password) {
Ok(()) => {
self.form_state = AuthFormState::Hidden;
self.error_message = None;
ctx.props().on_auth_change.emit(self.manager.state().clone());
}
Err(e) => {
self.error_message = Some(format!("Login failed: {}", e));
}
}
true
}
AuthMsg::Logout => {
self.manager.logout();
ctx.props().on_auth_change.emit(self.manager.state().clone());
true
}
AuthMsg::RegisterKey(name, private_key, password) => {
match self.manager.register_key(name, private_key, password) {
Ok(()) => {
self.form_state = AuthFormState::Hidden;
self.error_message = None;
self.register_name.clear();
self.register_key.clear();
self.register_password.clear();
}
Err(e) => {
self.error_message = Some(format!("Registration failed: {}", e));
}
}
true
}
AuthMsg::RemoveKey(key_name) => {
if let Err(e) = self.manager.remove_key(&key_name) {
self.error_message = Some(format!("Failed to remove key: {}", e));
} else {
ctx.props().on_auth_change.emit(self.manager.state().clone());
}
true
}
AuthMsg::ShowLoginForm => {
self.form_state = AuthFormState::Login;
self.error_message = None;
self.dropdown_open = false;
true
}
AuthMsg::ShowRegisterForm => {
self.form_state = AuthFormState::Register;
self.error_message = None;
self.dropdown_open = false;
true
}
AuthMsg::GenerateKey(name, password) => {
match BrowserAuthManager::generate_key() {
Ok(private_key) => {
match self.manager.register_key(name, private_key, password) {
Ok(()) => {
self.form_state = AuthFormState::Hidden;
self.error_message = None;
self.register_name.clear();
self.register_password.clear();
ctx.props().on_auth_change.emit(self.manager.state().clone());
}
Err(e) => {
self.error_message = Some(format!("Failed to register generated key: {}", e));
}
}
}
Err(e) => {
self.error_message = Some(format!("Failed to generate key: {}", e));
}
}
true
}
AuthMsg::ShowGenerateKeyForm => {
self.form_state = AuthFormState::GenerateKey;
self.error_message = None;
self.dropdown_open = false;
true
}
AuthMsg::HideForm => {
self.form_state = AuthFormState::Hidden;
self.error_message = None;
true
}
AuthMsg::ToggleDropdown => {
self.dropdown_open = !self.dropdown_open;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="auth-component">
{self.render_auth_button(ctx)}
{self.render_auth_form(ctx)}
</div>
}
}
}
impl AuthComponent {
fn render_auth_button(&self, ctx: &Context<Self>) -> Html {
match self.manager.state() {
AuthState::Unauthenticated => {
let dropdown_class = if self.dropdown_open {
"dropdown-menu dropdown-menu-end show"
} else {
"dropdown-menu dropdown-menu-end"
};
html! {
<div class="dropdown">
<button class="btn btn-outline-light dropdown-toggle" type="button"
onclick={ctx.link().callback(|_| AuthMsg::ToggleDropdown)}>
<i class="bi bi-person-circle me-1"></i>{"Login"}
</button>
<ul class={dropdown_class}>
<li>
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
AuthMsg::ShowLoginForm
})}>
<i class="bi bi-box-arrow-in-right me-2"></i>{"Login"}
</button>
</li>
<li>
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
AuthMsg::ShowRegisterForm
})}>
<i class="bi bi-person-plus me-2"></i>{"Register Key"}
</button>
</li>
<li>
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
AuthMsg::ShowGenerateKeyForm
})}>
<i class="bi bi-key me-2"></i>{"Generate Key"}
</button>
</li>
</ul>
</div>
}
}
AuthState::Authenticated { key_name, .. } => {
let dropdown_class = if self.dropdown_open {
"dropdown-menu dropdown-menu-end show"
} else {
"dropdown-menu dropdown-menu-end"
};
html! {
<div class="dropdown">
<button class="btn btn-outline-success dropdown-toggle" type="button"
onclick={ctx.link().callback(|_| AuthMsg::ToggleDropdown)}>
<i class="bi bi-person-check-fill me-1"></i>{key_name}
</button>
<ul class={dropdown_class}>
<li>
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
AuthMsg::ShowRegisterForm
})}>
<i class="bi bi-person-plus me-2"></i>{"Register New Key"}
</button>
</li>
<li>
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
AuthMsg::ShowGenerateKeyForm
})}>
<i class="bi bi-key me-2"></i>{"Generate Key"}
</button>
</li>
<li><hr class="dropdown-divider"/></li>
<li>
<button class="dropdown-item text-danger" onclick={ctx.link().callback(|_| {
AuthMsg::Logout
})}>
<i class="bi bi-box-arrow-right me-2"></i>{"Logout"}
</button>
</li>
</ul>
</div>
}
}
}
}
fn render_auth_form(&self, ctx: &Context<Self>) -> Html {
match self.form_state {
AuthFormState::Hidden => html! {},
AuthFormState::Login => self.render_login_form(ctx),
AuthFormState::Register => self.render_register_form(ctx),
AuthFormState::GenerateKey => self.render_generate_key_form(ctx),
}
}
fn render_login_form(&self, ctx: &Context<Self>) -> Html {
let available_keys = self.manager.available_keys();
let on_submit = {
let link = ctx.link().clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
// Get form data directly from the form
let form = e.target_dyn_into::<web_sys::HtmlFormElement>().unwrap();
let form_data = web_sys::FormData::new_with_form(&form).unwrap();
let key_name = form_data.get("keySelect").as_string().unwrap_or_default();
let password = form_data.get("password").as_string().unwrap_or_default();
if !key_name.is_empty() && !password.is_empty() {
link.send_message(AuthMsg::Login(key_name, password));
}
})
};
html! {
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{"Login"}</h5>
<button type="button" class="btn-close" onclick={ctx.link().callback(|_| AuthMsg::HideForm)}></button>
</div>
<div class="modal-body">
{if let Some(error) = &self.error_message {
html! {
<div class="alert alert-danger" role="alert">
{error}
</div>
}
} else {
html! {}
}}
<form onsubmit={on_submit}>
<div class="mb-3">
<label for="keySelect" class="form-label">{"Select Key"}</label>
<select class="form-select" id="keySelect" name="keySelect" required=true>
<option value="">{"Choose a key..."}</option>
{for available_keys.iter().map(|key| {
html! {
<option value={key.clone()}>{key}</option>
}
})}
</select>
</div>
<div class="mb-3">
<label for="password" class="form-label">{"Password"}</label>
<input type="password" class="form-control" id="password" name="password" required=true />
</div>
<button type="submit" class="btn btn-primary">{"Login"}</button>
</form>
</div>
</div>
</div>
</div>
}
}
fn render_register_form(&self, ctx: &Context<Self>) -> Html {
let on_submit = {
let link = ctx.link().clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
// Get form data directly from the form
let form = e.target_dyn_into::<web_sys::HtmlFormElement>().unwrap();
let form_data = web_sys::FormData::new_with_form(&form).unwrap();
let key_name = form_data.get("keyName").as_string().unwrap_or_default();
let private_key = form_data.get("privateKey").as_string().unwrap_or_default();
let password = form_data.get("regPassword").as_string().unwrap_or_default();
if !key_name.is_empty() && !private_key.is_empty() && !password.is_empty() {
link.send_message(AuthMsg::RegisterKey(key_name, private_key, password));
}
})
};
html! {
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{"Register New Key"}</h5>
<button type="button" class="btn-close" onclick={ctx.link().callback(|_| AuthMsg::HideForm)}></button>
</div>
<div class="modal-body">
{if let Some(error) = &self.error_message {
html! {
<div class="alert alert-danger" role="alert">
{error}
</div>
}
} else {
html! {}
}}
<form onsubmit={on_submit}>
<div class="mb-3">
<label for="keyName" class="form-label">{"Key Name"}</label>
<input type="text" class="form-control" id="keyName" name="keyName" placeholder="e.g., My Main Key" required=true />
</div>
<div class="mb-3">
<label for="privateKey" class="form-label">{"Private Key (64 hex characters)"}</label>
<input type="text" class="form-control" id="privateKey" name="privateKey" placeholder="Enter your secp256k1 private key..." required=true />
<div class="form-text">{"Your private key will be encrypted and stored locally."}</div>
</div>
<div class="mb-3">
<label for="regPassword" class="form-label">{"Password"}</label>
<input type="password" class="form-control" id="regPassword" name="regPassword" placeholder="Enter a strong password..." required=true />
<div class="form-text">{"This password will be used to encrypt your private key."}</div>
</div>
<button type="submit" class="btn btn-primary">{"Register Key"}</button>
</form>
</div>
</div>
</div>
</div>
}
}
fn render_generate_key_form(&self, ctx: &Context<Self>) -> Html {
let on_submit = {
let link = ctx.link().clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
// Get form data directly from the form
let form = e.target_dyn_into::<web_sys::HtmlFormElement>().unwrap();
let form_data = web_sys::FormData::new_with_form(&form).unwrap();
let name = form_data.get("genName").as_string().unwrap_or_default();
let password = form_data.get("genPassword").as_string().unwrap_or_default();
if !name.is_empty() && !password.is_empty() {
link.send_message(AuthMsg::GenerateKey(name, password));
}
})
};
let on_close = {
let link = ctx.link().clone();
Callback::from(move |_| {
link.send_message(AuthMsg::HideForm);
})
};
html! {
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{"Generate New Key"}</h5>
<button type="button" class="btn-close" onclick={on_close}></button>
</div>
<div class="modal-body">
{if let Some(error) = &self.error_message {
html! {
<div class="alert alert-danger" role="alert">
{error}
</div>
}
} else {
html! {}
}}
<form onsubmit={on_submit}>
<div class="mb-3">
<label for="genName" class="form-label">{"Key Name"}</label>
<input type="text" class="form-control" id="genName" name="genName" placeholder="Enter a name for your key..." required=true />
<div class="form-text">{"Choose a memorable name for your new key."}</div>
</div>
<div class="mb-3">
<label for="genPassword" class="form-label">{"Password"}</label>
<input type="password" class="form-control" id="genPassword" name="genPassword" placeholder="Enter a strong password..." required=true />
<div class="form-text">{"This password will be used to encrypt your generated private key."}</div>
</div>
<div class="alert alert-info" role="alert">
<strong>{"Note:"}</strong> {" A new cryptographic key will be generated automatically. Keep your password safe as it's needed to access your key."}
</div>
<button type="submit" class="btn btn-success">{"Generate & Register Key"}</button>
</form>
</div>
</div>
</div>
</div>
}
}
}

100
components/src/error.rs Normal file
View File

@@ -0,0 +1,100 @@
//! Error types for the WebSocket connection manager
use thiserror::Error;
#[cfg(not(target_arch = "wasm32"))]
use hero_websocket_client::CircleWsClientError;
/// Result type alias for WebSocket operations
pub type WsResult<T> = Result<T, WsError>;
/// Main error type for WebSocket connection manager operations
#[derive(Error, Debug)]
pub enum WsError {
/// WebSocket client error from the underlying circle_client_ws library
#[cfg(not(target_arch = "wasm32"))]
#[error("WebSocket client error: {0}")]
Client(#[from] CircleWsClientError),
/// Connection not found for the given URL
#[error("No connection found for URL: {0}")]
ConnectionNotFound(String),
/// Connection already exists for the given URL
#[error("Connection already exists for URL: {0}")]
ConnectionExists(String),
/// Authentication configuration error
#[error("Authentication error: {0}")]
Auth(String),
/// JSON serialization/deserialization error
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Configuration error
#[error("Configuration error: {0}")]
Config(String),
/// Script execution error
#[error("Script execution error on {url}: {message}")]
ScriptExecution { url: String, message: String },
/// Connection timeout error
#[error("Connection timeout for URL: {0}")]
Timeout(String),
/// Invalid URL format
#[error("Invalid URL format: {0}")]
InvalidUrl(String),
/// Manager not initialized
#[error("WebSocket manager not properly initialized")]
NotInitialized,
/// Generic error with custom message
#[error("{0}")]
Custom(String),
}
impl WsError {
/// Create a custom error with a message
pub fn custom<S: Into<String>>(message: S) -> Self {
WsError::Custom(message.into())
}
/// Create an authentication error
pub fn auth<S: Into<String>>(message: S) -> Self {
WsError::Auth(message.into())
}
/// Create a configuration error
pub fn config<S: Into<String>>(message: S) -> Self {
WsError::Config(message.into())
}
/// Create a script execution error
pub fn script_execution<S: Into<String>>(url: S, message: S) -> Self {
WsError::ScriptExecution {
url: url.into(),
message: message.into(),
}
}
/// Create an invalid URL error
pub fn invalid_url<S: Into<String>>(url: S) -> Self {
WsError::InvalidUrl(url.into())
}
}
/// Convert from string for convenience
impl From<String> for WsError {
fn from(message: String) -> Self {
WsError::Custom(message)
}
}
impl From<&str> for WsError {
fn from(message: &str) -> Self {
WsError::Custom(message.to_string())
}
}

File diff suppressed because it is too large Load Diff

50
components/src/lib.rs Normal file
View File

@@ -0,0 +1,50 @@
//! Components Library
//!
//! This library provides reusable UI components for building web applications
//! with Rust, Yew, and WebAssembly.
// Core UI components
pub mod file_browser;
pub mod markdown_editor;
pub mod text_editor;
pub mod toast;
// Auth and utilities (moved from parent)
pub mod auth;
pub mod browser_auth;
pub mod error;
pub mod ws_manager;
// Re-export components for easy access
pub use file_browser::FileBrowser;
pub use markdown_editor::MarkdownEditor;
pub use text_editor::TextEditor;
pub use toast::Toast;
// Re-export utilities
pub use auth::*;
pub use browser_auth::*;
pub use error::*;
pub use ws_manager::*;
// Re-export hero_websocket_client types that users might need
#[cfg(not(target_arch = "wasm32"))]
pub use hero_websocket_client::{
CircleWsClient, CircleWsClientBuilder, CircleWsClientError, methods::PlayResultClient
};
#[cfg(target_arch = "wasm32")]
pub use yew::Callback;
/// Version information
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Prelude module for convenient imports
pub mod prelude {
pub use crate::{
FileBrowser, MarkdownEditor, TextEditor, Toast,
BrowserAuth, WsManager, WsManagerBuilder, WsManagerError, AuthConfig
};
pub use crate::VERSION;
}

3
components/src/main.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

View File

@@ -0,0 +1,301 @@
use yew::prelude::*;
use web_sys::{HtmlTextAreaElement, window};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{spawn_local, JsFuture};
use gloo_net::http::Request;
#[derive(Properties, PartialEq)]
pub struct MarkdownEditorProps {
pub file_path: String,
pub base_endpoint: String,
}
pub struct MarkdownEditor {
content: String,
rendered_html: String,
loading: bool,
error: Option<String>,
textarea_ref: NodeRef,
}
pub enum MarkdownEditorMsg {
LoadFile,
FileLoaded(String),
FileLoadError(String),
ContentChanged(String),
SaveFile,
FileSaved,
FileSaveError(String),
}
impl Component for MarkdownEditor {
type Message = MarkdownEditorMsg;
type Properties = MarkdownEditorProps;
fn create(ctx: &Context<Self>) -> Self {
let editor = Self {
content: String::new(),
rendered_html: String::new(),
loading: true,
error: None,
textarea_ref: NodeRef::default(),
};
// Load file content on creation
ctx.link().send_message(MarkdownEditorMsg::LoadFile);
editor
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
MarkdownEditorMsg::LoadFile => {
let file_url = format!("{}/download/{}", ctx.props().base_endpoint, ctx.props().file_path);
let link = ctx.link().clone();
spawn_local(async move {
match Request::get(&file_url).send().await {
Ok(response) => {
match response.text().await {
Ok(content) => {
link.send_message(MarkdownEditorMsg::FileLoaded(content));
}
Err(e) => {
link.send_message(MarkdownEditorMsg::FileLoadError(format!("Failed to read file: {}", e)));
}
}
}
Err(e) => {
link.send_message(MarkdownEditorMsg::FileLoadError(format!("Failed to load file: {}", e)));
}
}
});
false
}
MarkdownEditorMsg::FileLoaded(content) => {
self.content = content.clone();
self.rendered_html = self.render_markdown(&content);
self.loading = false;
self.error = None;
true
}
MarkdownEditorMsg::FileLoadError(error) => {
self.loading = false;
self.error = Some(error);
true
}
MarkdownEditorMsg::ContentChanged(content) => {
self.content = content.clone();
self.rendered_html = self.render_markdown(&content);
true
}
MarkdownEditorMsg::SaveFile => {
let save_url = format!("{}/upload/{}", ctx.props().base_endpoint, ctx.props().file_path);
let content = self.content.clone();
let link = ctx.link().clone();
spawn_local(async move {
let form_data = web_sys::FormData::new().unwrap();
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&content.into())).unwrap();
form_data.append_with_blob("file", &blob).unwrap();
let mut request_init = web_sys::RequestInit::new();
request_init.set_method("POST");
let js_value: wasm_bindgen::JsValue = form_data.into();
request_init.set_body(&js_value);
let request = web_sys::Request::new_with_str_and_init(
&save_url,
&request_init
).unwrap();
match JsFuture::from(window().unwrap().fetch_with_request(&request)).await {
Ok(_) => {
link.send_message(MarkdownEditorMsg::FileSaved);
}
Err(e) => {
link.send_message(MarkdownEditorMsg::FileSaveError(format!("Failed to save file: {:?}", e)));
}
}
});
false
}
MarkdownEditorMsg::FileSaved => {
// Show success message or update UI
false
}
MarkdownEditorMsg::FileSaveError(error) => {
self.error = Some(error);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let filename = ctx.props().file_path.split('/').last().unwrap_or(&ctx.props().file_path);
if self.loading {
return html! {
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="spinner-border" role="status">
<span class="visually-hidden">{"Loading..."}</span>
</div>
</div>
};
}
if let Some(error) = &self.error {
return html! {
<div class="alert alert-danger m-3" role="alert">
<h4 class="alert-heading">{"Error"}</h4>
<p>{error}</p>
</div>
};
}
let oninput = ctx.link().callback(|e: InputEvent| {
let target = e.target_dyn_into::<HtmlTextAreaElement>().unwrap();
MarkdownEditorMsg::ContentChanged(target.value())
});
let onsave = ctx.link().callback(|_| MarkdownEditorMsg::SaveFile);
html! {
<div class="container-fluid h-100">
// Header with filename and save button
<div class="row bg-light border-bottom p-2">
<div class="col">
<h5 class="mb-0">
<i class="bi bi-file-earmark-text me-2"></i>
{filename}
</h5>
</div>
<div class="col-auto">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={onsave}
>
<i class="bi bi-save me-1"></i>
{"Save"}
</button>
</div>
</div>
// Split-screen editor
<div class="row h-100">
// Left pane: Text editor
<div class="col-6 p-0">
<div class="h-100 d-flex flex-column">
<div class="bg-secondary text-white p-2 small">
{"Markdown Editor"}
</div>
<textarea
ref={self.textarea_ref.clone()}
class="form-control border-0 flex-grow-1"
style="resize: none; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px;"
value={self.content.clone()}
oninput={oninput}
placeholder="Enter your markdown content here..."
/>
</div>
</div>
// Right pane: Live preview
<div class="col-6 p-0 border-start">
<div class="h-100 d-flex flex-column">
<div class="bg-info text-white p-2 small">
{"Live Preview"}
</div>
<div
class="flex-grow-1 p-3 overflow-auto"
style="background-color: #fff;"
>
<div class="markdown-content">
{Html::from_html_unchecked(AttrValue::from(self.rendered_html.clone()))}
</div>
</div>
</div>
</div>
</div>
</div>
}
}
}
impl MarkdownEditor {
fn render_markdown(&self, content: &str) -> String {
// For now, use a simple JavaScript-based markdown renderer
// In a real implementation, you might want to use a Rust markdown parser
let window = window().unwrap();
let marked_fn = js_sys::Reflect::get(&window, &"marked".into()).unwrap();
if marked_fn.is_function() {
let marked_fn = marked_fn.dyn_into::<js_sys::Function>().unwrap();
let result = marked_fn.call1(&window, &content.into()).unwrap();
result.as_string().unwrap_or_else(|| content.to_string())
} else {
// Fallback: simple markdown transformations using basic string operations
let lines: Vec<&str> = content.lines().collect();
let mut result = String::new();
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with("### ") {
result.push_str(&format!("<h3>{}</h3>", &trimmed[4..]));
} else if trimmed.starts_with("## ") {
result.push_str(&format!("<h2>{}</h2>", &trimmed[3..]));
} else if trimmed.starts_with("# ") {
result.push_str(&format!("<h1>{}</h1>", &trimmed[2..]));
} else if trimmed.is_empty() {
result.push_str("<br>");
} else {
// Basic bold and italic (simple cases)
let mut processed = trimmed.to_string();
// Simple bold replacement (**text**)
while let Some(start) = processed.find("**") {
if let Some(end) = processed[start + 2..].find("**") {
let end = end + start + 2;
let bold_text = &processed[start + 2..end];
processed = format!(
"{}<strong>{}</strong>{}",
&processed[..start],
bold_text,
&processed[end + 2..]
);
} else {
break;
}
}
// Simple code replacement (`code`)
while let Some(start) = processed.find('`') {
if let Some(end) = processed[start + 1..].find('`') {
let end = end + start + 1;
let code_text = &processed[start + 1..end];
processed = format!(
"{}<code>{}</code>{}",
&processed[..start],
code_text,
&processed[end + 1..]
);
} else {
break;
}
}
result.push_str(&format!("<p>{}</p>", processed));
}
}
result
}
}
}

10
components/src/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod toast;
pub mod file_browser;
pub mod markdown_editor;
pub mod text_editor;
pub use file_browser::{FileBrowser, FileBrowserConfig};
pub use markdown_editor::MarkdownEditor;
pub use text_editor::TextEditor;
pub use toast::*;

View File

@@ -0,0 +1,202 @@
use yew::prelude::*;
use web_sys::{window, HtmlTextAreaElement};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use gloo_net::http::Request;
#[derive(Properties, PartialEq)]
pub struct TextEditorProps {
pub file_path: String,
pub base_endpoint: String,
}
pub struct TextEditor {
content: String,
loading: bool,
error: Option<String>,
textarea_ref: NodeRef,
}
pub enum TextEditorMsg {
LoadFile,
FileLoaded(String),
FileLoadError(String),
ContentChanged(String),
SaveFile,
FileSaved,
FileSaveError(String),
}
impl Component for TextEditor {
type Message = TextEditorMsg;
type Properties = TextEditorProps;
fn create(ctx: &Context<Self>) -> Self {
let editor = Self {
content: String::new(),
loading: true,
error: None,
textarea_ref: NodeRef::default(),
};
// Load file content on creation
ctx.link().send_message(TextEditorMsg::LoadFile);
editor
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
TextEditorMsg::LoadFile => {
let file_url = format!("{}/download/{}", ctx.props().base_endpoint, ctx.props().file_path);
let link = ctx.link().clone();
spawn_local(async move {
match Request::get(&file_url).send().await {
Ok(response) => {
match response.text().await {
Ok(content) => {
link.send_message(TextEditorMsg::FileLoaded(content));
}
Err(e) => {
link.send_message(TextEditorMsg::FileLoadError(format!("Failed to read file: {}", e)));
}
}
}
Err(e) => {
link.send_message(TextEditorMsg::FileLoadError(format!("Failed to load file: {}", e)));
}
}
});
false
}
TextEditorMsg::FileLoaded(content) => {
self.content = content;
self.loading = false;
self.error = None;
true
}
TextEditorMsg::FileLoadError(error) => {
self.loading = false;
self.error = Some(error);
true
}
TextEditorMsg::ContentChanged(content) => {
self.content = content;
true
}
TextEditorMsg::SaveFile => {
let save_url = format!("{}/upload/{}", ctx.props().base_endpoint, ctx.props().file_path);
let content = self.content.clone();
let link = ctx.link().clone();
spawn_local(async move {
let form_data = web_sys::FormData::new().unwrap();
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&content.into())).unwrap();
form_data.append_with_blob("file", &blob).unwrap();
let mut request_init = web_sys::RequestInit::new();
request_init.set_method("POST");
let js_value: wasm_bindgen::JsValue = form_data.into();
request_init.set_body(&js_value);
let request = web_sys::Request::new_with_str_and_init(
&save_url,
&request_init
).unwrap();
match JsFuture::from(window().unwrap().fetch_with_request(&request)).await {
Ok(_) => {
link.send_message(TextEditorMsg::FileSaved);
}
Err(e) => {
link.send_message(TextEditorMsg::FileSaveError(format!("Failed to save file: {:?}", e)));
}
}
});
false
}
TextEditorMsg::FileSaved => {
// Show success message or update UI
false
}
TextEditorMsg::FileSaveError(error) => {
self.error = Some(error);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let filename = ctx.props().file_path.split('/').last().unwrap_or(&ctx.props().file_path);
if self.loading {
return html! {
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="spinner-border" role="status">
<span class="visually-hidden">{"Loading..."}</span>
</div>
</div>
};
}
if let Some(error) = &self.error {
return html! {
<div class="alert alert-danger m-3" role="alert">
<h4 class="alert-heading">{"Error"}</h4>
<p>{error}</p>
</div>
};
}
let oninput = ctx.link().callback(|e: InputEvent| {
let target = e.target_dyn_into::<HtmlTextAreaElement>().unwrap();
TextEditorMsg::ContentChanged(target.value())
});
let onsave = ctx.link().callback(|_| TextEditorMsg::SaveFile);
html! {
<div class="container-fluid h-100">
// Header with filename and save button
<div class="row bg-light border-bottom p-2">
<div class="col">
<h5 class="mb-0">
<i class="bi bi-file-earmark-text me-2"></i>
{filename}
</h5>
</div>
<div class="col-auto">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={onsave}
>
<i class="bi bi-save me-1"></i>
{"Save"}
</button>
</div>
</div>
// Full-screen text editor
<div class="row h-100">
<div class="col p-0">
<textarea
ref={self.textarea_ref.clone()}
class="form-control border-0 h-100"
style="resize: none; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px;"
value={self.content.clone()}
oninput={oninput}
placeholder="Enter your text content here..."
/>
</div>
</div>
</div>
}
}
}

342
components/src/toast.rs Normal file
View File

@@ -0,0 +1,342 @@
use yew::prelude::*;
use gloo::timers::callback::Timeout;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub enum ToastType {
Success,
Error,
Warning,
Info,
Loading,
}
impl ToastType {
pub fn to_bootstrap_class(&self) -> &'static str {
match self {
ToastType::Success => "text-bg-success",
ToastType::Error => "text-bg-danger",
ToastType::Warning => "text-bg-warning",
ToastType::Info => "text-bg-info",
ToastType::Loading => "text-bg-primary",
}
}
pub fn to_icon(&self) -> &'static str {
match self {
ToastType::Success => "",
ToastType::Error => "",
ToastType::Warning => "",
ToastType::Info => "",
ToastType::Loading => "",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Toast {
pub id: String,
pub message: String,
pub toast_type: ToastType,
pub duration: Option<u32>, // Duration in milliseconds, None for persistent
pub dismissible: bool,
}
impl Toast {
pub fn new(id: String, message: String, toast_type: ToastType) -> Self {
Self {
id,
message,
toast_type,
duration: Some(5000), // Default 5 seconds
dismissible: true,
}
}
pub fn success(id: String, message: String) -> Self {
Self::new(id, message, ToastType::Success)
}
pub fn error(id: String, message: String) -> Self {
Self::new(id, message, ToastType::Error)
}
pub fn warning(id: String, message: String) -> Self {
Self::new(id, message, ToastType::Warning)
}
pub fn info(id: String, message: String) -> Self {
Self::new(id, message, ToastType::Info)
}
pub fn loading(id: String, message: String) -> Self {
Self::new(id, message, ToastType::Loading).persistent()
}
pub fn persistent(mut self) -> Self {
self.duration = None;
self
}
pub fn with_duration(mut self, duration: u32) -> Self {
self.duration = Some(duration);
self
}
pub fn non_dismissible(mut self) -> Self {
self.dismissible = false;
self
}
}
#[derive(Properties, PartialEq)]
pub struct ToastContainerProps {
pub toasts: Vec<Toast>,
pub on_remove: Callback<String>,
}
#[function_component(ToastContainer)]
pub fn toast_container(props: &ToastContainerProps) -> Html {
html! {
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1055;">
{for props.toasts.iter().map(|toast| {
let on_close = if toast.dismissible {
let on_remove = props.on_remove.clone();
let id = toast.id.clone();
Some(Callback::from(move |_| on_remove.emit(id.clone())))
} else {
None
};
html! {
<div class={classes!("toast", "show", toast.toast_type.to_bootstrap_class())} role="alert">
<div class="toast-header">
<span class="me-auto">
<span class="me-2">{toast.toast_type.to_icon()}</span>
{"Notification"}
</span>
{if let Some(on_close) = on_close {
html! { <button type="button" class="btn-close" onclick={on_close}></button> }
} else {
html! {}
}}
</div>
<div class="toast-body">
{&toast.message}
</div>
</div>
}
})}
</div>
}
}
// Enhanced toast manager with update capabilities
pub struct ToastManager {
toasts: HashMap<String, Toast>,
timeouts: HashMap<String, Timeout>,
}
impl ToastManager {
pub fn new() -> Self {
Self {
toasts: HashMap::new(),
timeouts: HashMap::new(),
}
}
pub fn add_or_update_toast(&mut self, toast: Toast, on_remove: Callback<String>) {
let id = toast.id.clone();
// Cancel existing timeout if any
if let Some(timeout) = self.timeouts.remove(&id) {
timeout.cancel();
}
// Set up auto-removal timeout if duration is specified
if let Some(duration) = toast.duration {
let timeout_id = id.clone();
let timeout = Timeout::new(duration, move || {
on_remove.emit(timeout_id);
});
self.timeouts.insert(id.clone(), timeout);
}
self.toasts.insert(id, toast);
}
pub fn remove_toast(&mut self, id: &str) {
self.toasts.remove(id);
if let Some(timeout) = self.timeouts.remove(id) {
timeout.cancel();
}
}
pub fn clear_all(&mut self) {
self.toasts.clear();
for (_, timeout) in self.timeouts.drain() {
timeout.cancel();
}
}
pub fn get_toasts(&self) -> Vec<Toast> {
self.toasts.values().cloned().collect()
}
pub fn has_toast(&self, id: &str) -> bool {
self.toasts.contains_key(id)
}
}
// Enhanced hook with update capabilities
#[hook]
pub fn use_toast() -> (Vec<Toast>, Callback<Toast>, Callback<String>) {
let manager = use_mut_ref(|| ToastManager::new());
let toasts = use_state(|| Vec::<Toast>::new());
let _update_toasts = {
let manager = manager.clone();
let toasts = toasts.clone();
move || {
let mgr = manager.borrow();
toasts.set(mgr.get_toasts());
}
};
let add_or_update_toast = {
let manager = manager.clone();
let toasts = toasts.clone();
Callback::from(move |toast: Toast| {
let toasts_setter = toasts.clone();
let manager_clone = manager.clone();
let on_remove = Callback::from(move |id: String| {
let mut mgr = manager_clone.borrow_mut();
mgr.remove_toast(&id);
toasts_setter.set(mgr.get_toasts());
});
let mut mgr = manager.borrow_mut();
mgr.add_or_update_toast(toast, on_remove);
toasts.set(mgr.get_toasts());
})
};
let remove_toast = {
let manager = manager.clone();
let toasts = toasts.clone();
Callback::from(move |id: String| {
let mut mgr = manager.borrow_mut();
mgr.remove_toast(&id);
toasts.set(mgr.get_toasts());
})
};
((*toasts).clone(), add_or_update_toast, remove_toast)
}
// Convenience trait for easy toast operations
pub trait ToastExt {
fn toast_loading(&self, id: &str, message: &str);
fn toast_success(&self, id: &str, message: &str);
fn toast_error(&self, id: &str, message: &str);
fn toast_warning(&self, id: &str, message: &str);
fn toast_info(&self, id: &str, message: &str);
fn toast_update(&self, id: &str, message: &str, toast_type: ToastType);
fn toast_remove(&self, id: &str);
}
pub struct ToastHandle {
add_toast: Callback<Toast>,
remove_toast: Callback<String>,
}
impl ToastHandle {
pub fn new(add_toast: Callback<Toast>, remove_toast: Callback<String>) -> Self {
Self { add_toast, remove_toast }
}
}
impl ToastExt for ToastHandle {
fn toast_loading(&self, id: &str, message: &str) {
self.add_toast.emit(Toast::loading(id.to_string(), message.to_string()));
}
fn toast_success(&self, id: &str, message: &str) {
self.add_toast.emit(Toast::success(id.to_string(), message.to_string()));
}
fn toast_error(&self, id: &str, message: &str) {
self.add_toast.emit(Toast::error(id.to_string(), message.to_string()));
}
fn toast_warning(&self, id: &str, message: &str) {
self.add_toast.emit(Toast::warning(id.to_string(), message.to_string()));
}
fn toast_info(&self, id: &str, message: &str) {
self.add_toast.emit(Toast::info(id.to_string(), message.to_string()));
}
fn toast_update(&self, id: &str, message: &str, toast_type: ToastType) {
self.add_toast.emit(Toast::new(id.to_string(), message.to_string(), toast_type));
}
fn toast_remove(&self, id: &str) {
self.remove_toast.emit(id.to_string());
}
}
// Enhanced hook that returns a ToastHandle for easier usage
#[hook]
pub fn use_toast_handle() -> (Vec<Toast>, ToastHandle, Callback<String>) {
let (toasts, add_toast, remove_toast) = use_toast();
let handle = ToastHandle::new(add_toast, remove_toast.clone());
(toasts, handle, remove_toast)
}
// Async operation helper
pub struct AsyncToastOperation {
handle: ToastHandle,
id: String,
}
impl AsyncToastOperation {
pub fn new(handle: ToastHandle, id: String, loading_message: String) -> Self {
handle.toast_loading(&id, &loading_message);
Self { handle, id }
}
pub fn success(self, message: String) {
self.handle.toast_success(&self.id, &message);
}
pub fn error(self, message: String) {
self.handle.toast_error(&self.id, &message);
}
pub fn update(&self, message: String, toast_type: ToastType) {
self.handle.toast_update(&self.id, &message, toast_type);
}
pub fn remove(self) {
self.handle.toast_remove(&self.id);
}
}
impl ToastHandle {
pub fn async_operation(&self, id: &str, loading_message: &str) -> AsyncToastOperation {
AsyncToastOperation::new(self.clone(), id.to_string(), loading_message.to_string())
}
}
impl Clone for ToastHandle {
fn clone(&self) -> Self {
Self {
add_toast: self.add_toast.clone(),
remove_toast: self.remove_toast.clone(),
}
}
}

View File

@@ -0,0 +1,385 @@
//! WebSocket Manager
//!
//! A lightweight manager for multiple self-managing WebSocket connections.
//! Handles connection lifecycle, authentication, and keep-alive for multiple WebSocket URLs.
//! Provides access to live authorized clients for external use.
//!
//! # Overview
//!
//! The `WsManager` is designed to:
//! - Connect to multiple WebSocket servers simultaneously
//! - Handle authentication using private keys
//! - Maintain persistent connections with automatic keep-alive
//! - Provide safe access to connected clients for script execution and other operations
//!
//! # Usage
//!
//! ```rust
//! use framework::WsManager;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! // Create a manager with multiple servers
//! let manager = WsManager::builder()
//! .private_key("your_private_key_hex".to_string())
//! .add_server_url("ws://localhost:3030".to_string())
//! .add_server_url("ws://localhost:3031".to_string())
//! .build();
//!
//! // Connect to all servers
//! manager.connect().await?;
//!
//! // Execute operations on a specific server
//! if let Some(result) = manager.with_client("ws://localhost:3030", |client| {
//! client.play("console.log('Hello World!');".to_string())
//! }).await {
//! match result {
//! Ok(output) => println!("Script output: {}", output),
//! Err(e) => eprintln!("Script error: {}", e),
//! }
//! }
//!
//! // Work with all connected clients
//! manager.with_all_clients(|clients| {
//! for (url, client) in clients {
//! if client.is_connected() {
//! println!("Connected to: {}", url);
//! }
//! }
//! });
//! # Ok(())
//! # }
//! ```
//!
//! # Architecture
//!
//! The manager uses a builder pattern for configuration and maintains clients in a `RefCell<HashMap>`
//! for interior mutability. Since `CircleWsClient` doesn't implement `Clone`, access is provided
//! through closure-based methods that ensure safe borrowing.
use std::collections::HashMap;
use std::cell::RefCell;
use std::rc::Rc;
use log::{error, info};
#[cfg(not(target_arch = "wasm32"))]
use hero_websocket_client::{CircleWsClient, CircleWsClientBuilder, CircleWsClientError};
/// Builder for creating a WebSocket manager
///
/// Use this to configure multiple WebSocket URLs and optional authentication
/// before building the final `WsManager` instance.
pub struct WsManagerBuilder {
private_key: Option<String>,
server_urls: Vec<String>,
}
impl WsManagerBuilder {
/// Create a new builder instance
pub fn new() -> Self {
Self {
private_key: None,
server_urls: Vec::new(),
}
}
/// Set private key for authentication
///
/// # Arguments
/// * `private_key` - Hex-encoded private key for signing authentication challenges
pub fn private_key(mut self, private_key: String) -> Self {
self.private_key = Some(private_key);
self
}
/// Add a server URL to connect to
///
/// # Arguments
/// * `url` - WebSocket URL (e.g., "ws://localhost:3030")
pub fn add_server_url(mut self, url: String) -> Self {
self.server_urls.push(url);
self
}
/// Build the manager and create clients for all URLs
///
/// Creates `CircleWsClient` instances for each configured URL.
/// Clients are not connected until `connect()` is called.
pub fn build(self) -> WsManager {
let mut clients = HashMap::new();
for url in self.server_urls {
let client = if let Some(ref private_key) = self.private_key {
CircleWsClientBuilder::new(url.clone())
.with_keypair(private_key.clone())
.build()
} else {
CircleWsClientBuilder::new(url.clone()).build()
};
clients.insert(url, client);
}
WsManager {
clients: Rc::new(RefCell::new(clients)),
}
}
}
/// Lightweight WebSocket manager with pre-created clients
#[derive(Clone)]
pub struct WsManager {
clients: Rc<RefCell<HashMap<String, CircleWsClient>>>,
}
impl PartialEq for WsManager {
fn eq(&self, other: &Self) -> bool {
// Compare based on the URLs of the clients
let self_urls: std::collections::BTreeSet<_> = self.clients.borrow().keys().cloned().collect();
let other_urls: std::collections::BTreeSet<_> = other.clients.borrow().keys().cloned().collect();
self_urls == other_urls
}
}
impl WsManager {
/// Create a new builder
pub fn builder() -> WsManagerBuilder {
WsManagerBuilder::new()
}
/// Connect all pre-created clients
/// Each client manages its own connection lifecycle after this
pub async fn connect(&self) -> Result<(), CircleWsClientError> {
let urls: Vec<String> = self.clients.borrow().keys().cloned().collect();
let mut successful = 0;
let mut failed_urls = Vec::new();
let mut clients = self.clients.borrow_mut();
for url in &urls {
if let Some(client) = clients.get_mut(url) {
match client.connect().await {
Ok(_) => {
// Try to authenticate if the client was built with a private key
match client.authenticate().await {
Ok(_) => {
successful += 1;
}
Err(_) => {
// Auth failed or not required - still count as successful connection
successful += 1;
}
}
}
Err(_) => {
failed_urls.push(url.clone());
}
}
}
}
// Only log summary, not individual connection attempts
if successful > 0 {
info!("Connected to {}/{} servers", successful, urls.len());
}
if !failed_urls.is_empty() {
info!("Failed to connect to: {:?}", failed_urls);
}
if successful == 0 && !urls.is_empty() {
return Err(CircleWsClientError::NotConnected);
}
Ok(())
}
/// Execute a closure with access to a connected client
///
/// This is the preferred way to access clients since CircleWsClient doesn't implement Clone.
/// The closure is only executed if the client exists and is connected.
///
/// # Arguments
/// * `url` - The WebSocket URL of the target server
/// * `f` - Closure that receives a reference to the connected client
///
/// # Returns
/// * `Some(R)` - Result of the closure if client is connected
/// * `None` - If client doesn't exist or is not connected
///
/// # Example
/// ```rust
/// # use framework::WsManager;
/// # async fn example(manager: &WsManager) {
/// let result = manager.with_client("ws://localhost:3030", |client| {
/// client.play("console.log('Hello!');".to_string())
/// }).await;
/// # }
/// ```
pub fn with_client<F, R>(&self, url: &str, f: F) -> Option<R>
where
F: FnOnce(&CircleWsClient) -> R,
{
let clients = self.clients.borrow();
if let Some(client) = clients.get(url) {
if client.is_connected() {
return Some(f(client));
}
}
None
}
/// Execute a closure with access to all clients
///
/// Provides access to the entire HashMap of clients, including both connected
/// and disconnected ones. Use `client.is_connected()` to check status.
///
/// # Arguments
/// * `f` - Closure that receives a reference to the clients HashMap
///
/// # Example
/// ```rust
/// # use framework::WsManager;
/// # fn example(manager: &WsManager) {
/// manager.with_all_clients(|clients| {
/// for (url, client) in clients {
/// if client.is_connected() {
/// println!("Connected to: {}", url);
/// }
/// }
/// });
/// # }
/// ```
pub fn with_all_clients<F, R>(&self, f: F) -> R
where
F: FnOnce(&HashMap<String, CircleWsClient>) -> R,
{
let clients = self.clients.borrow();
f(&*clients)
}
/// Get connected server URLs
pub fn get_connected_urls(&self) -> Vec<String> {
self.clients.borrow().keys().cloned().collect()
}
/// Get configured server URLs
pub fn get_server_urls(&self) -> Vec<String> {
self.clients.borrow().keys().cloned().collect()
}
/// Get connection status for all servers
pub fn get_all_connection_statuses(&self) -> std::collections::HashMap<String, String> {
let mut statuses = std::collections::HashMap::new();
let clients = self.clients.borrow();
for (url, client) in clients.iter() {
let status = client.get_connection_status();
statuses.insert(url.clone(), status);
}
statuses
}
/// Check if connected to a server
pub fn is_connected(&self, url: &str) -> bool {
let clients = self.clients.borrow();
if let Some(client) = clients.get(url) {
client.is_connected()
} else {
false
}
}
/// Get connection count
pub fn connection_count(&self) -> usize {
self.clients.borrow().len()
}
/// Add a new WebSocket connection at runtime
pub fn add_connection(&self, url: String, private_key: Option<String>) {
let client = if let Some(private_key) = private_key {
CircleWsClientBuilder::new(url.clone())
.with_keypair(private_key)
.build()
} else {
CircleWsClientBuilder::new(url.clone()).build()
};
self.clients.borrow_mut().insert(url, client);
}
/// Connect to a specific server
pub async fn connect_to_server(&self, url: &str) -> Result<(), CircleWsClientError> {
let mut clients = self.clients.borrow_mut();
if let Some(client) = clients.get_mut(url) {
match client.connect().await {
Ok(_) => {
// Try to authenticate if the client was built with a private key
match client.authenticate().await {
Ok(_) => {
info!("Connected and authenticated to {}", url);
}
Err(_) => {
// Auth failed or not required - still count as successful connection
info!("Connected to {} (no auth)", url);
}
}
Ok(())
}
Err(e) => {
error!("Failed to connect to {}: {}", url, e);
Err(e)
}
}
} else {
Err(CircleWsClientError::NotConnected)
}
}
/// Disconnect from a specific server
pub async fn disconnect_from_server(&self, url: &str) -> Result<(), CircleWsClientError> {
let mut clients = self.clients.borrow_mut();
if let Some(client) = clients.get_mut(url) {
client.disconnect().await;
info!("Disconnected from {}", url);
Ok(())
} else {
Err(CircleWsClientError::NotConnected)
}
}
/// Remove a connection entirely
pub fn remove_connection(&self, url: &str) -> bool {
self.clients.borrow_mut().remove(url).is_some()
}
}
// Clients handle their own cleanup when dropped
impl Drop for WsManager {
fn drop(&mut self) {
// Silent cleanup - clients handle their own lifecycle
}
}
/// Type alias for backward compatibility
pub type WsConnectionManager = WsManager;
/// Convenience function to create a manager builder
pub fn ws_manager() -> WsManagerBuilder {
WsManager::builder()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder() {
let manager = ws_manager()
.private_key("test_key".to_string())
.add_server_url("ws://localhost:8080".to_string())
.build();
assert_eq!(manager.get_server_urls().len(), 1);
assert_eq!(manager.get_server_urls()[0], "ws://localhost:8080");
}
}