cargo fix and fmt
This commit is contained in:
		| @@ -12,7 +12,7 @@ | ||||
| //! ``` | ||||
|  | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
| use log::{info, error}; | ||||
| use log::{error, info}; | ||||
| use std::time::Duration; | ||||
| use tokio::time::sleep; | ||||
|  | ||||
| @@ -29,11 +29,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Example 1: Authenticate with private key | ||||
|     info!("=== Example 1: Private Key Authentication ==="); | ||||
|     let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; | ||||
|      | ||||
|  | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url.clone()) | ||||
|         .with_keypair(private_key.to_string()) | ||||
|         .build(); | ||||
|      | ||||
|  | ||||
|     match client.connect().await { | ||||
|         Ok(_) => { | ||||
|             info!("Successfully connected to WebSocket"); | ||||
| @@ -67,27 +67,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             error!("Play request failed: {}", e); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Keep connection alive for a moment | ||||
|     sleep(Duration::from_secs(2)).await; | ||||
|      | ||||
|  | ||||
|     // Disconnect | ||||
|     client.disconnect().await; | ||||
|     info!("Disconnected from WebSocket"); | ||||
|  | ||||
|  | ||||
|     // Example 3: Different private key authentication | ||||
|     info!("=== Example 3: Different Private Key Authentication ==="); | ||||
|     let private_key2 = "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"; | ||||
|      | ||||
|  | ||||
|     let mut client2 = CircleWsClientBuilder::new(ws_url.clone()) | ||||
|         .with_keypair(private_key2.to_string()) | ||||
|         .build(); | ||||
|      | ||||
|  | ||||
|     match client2.connect().await { | ||||
|         Ok(_) => { | ||||
|             info!("Connected with second private key authentication"); | ||||
|              | ||||
|  | ||||
|             match client2.authenticate().await { | ||||
|                 Ok(true) => { | ||||
|                     info!("Successfully authenticated with second private key"); | ||||
| @@ -108,7 +107,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|                     error!("Second private key authentication failed: {}", e); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|  | ||||
|             client2.disconnect().await; | ||||
|         } | ||||
|         Err(e) => { | ||||
| @@ -119,11 +118,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Example 4: Non-authenticated connection (fallback) | ||||
|     info!("=== Example 4: Non-Authenticated Connection ==="); | ||||
|     let mut client3 = CircleWsClientBuilder::new(ws_url).build(); | ||||
|      | ||||
|  | ||||
|     match client3.connect().await { | ||||
|         Ok(()) => { | ||||
|             info!("Connected without authentication (fallback mode)"); | ||||
|              | ||||
|  | ||||
|             let script = "print('Hello from non-auth client!');".to_string(); | ||||
|             match client3.play(script).await { | ||||
|                 Ok(result) => { | ||||
| @@ -133,7 +132,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|                     error!("Non-auth request failed: {}", e); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|  | ||||
|             client3.disconnect().await; | ||||
|         } | ||||
|         Err(e) => { | ||||
| @@ -143,4 +142,4 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|  | ||||
|     info!("Simplified authentication example completed"); | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| //! Authentication simulation example | ||||
| //!  | ||||
| //! | ||||
| //! This example simulates the authentication flow without requiring a running server. | ||||
| //! It demonstrates: | ||||
| //! 1. Key generation and management | ||||
| @@ -8,32 +8,28 @@ | ||||
| //! 4. Credential management | ||||
| //! 5. Authentication state checking | ||||
|  | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| use log::info; | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
|  | ||||
| // Import authentication modules | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
|  | ||||
| #[cfg(feature = "crypto")] | ||||
| use circle_client_ws::auth::{ | ||||
|     generate_private_key,  | ||||
|     derive_public_key,  | ||||
|     sign_message,  | ||||
|     verify_signature, | ||||
|     AuthCredentials, | ||||
|     NonceResponse | ||||
|     derive_public_key, generate_private_key, sign_message, verify_signature, AuthCredentials, | ||||
|     NonceResponse, | ||||
| }; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Initialize logging | ||||
|     env_logger::init(); | ||||
|      | ||||
|  | ||||
|     info!("🔐 Starting authentication simulation example"); | ||||
|      | ||||
|  | ||||
|     // Step 1: Generate cryptographic keys | ||||
|     info!("🔑 Generating cryptographic keys..."); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let (private_key, public_key) = { | ||||
|         let private_key = generate_private_key()?; | ||||
| @@ -42,38 +38,39 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         info!("✅ Derived public key: {}...", &public_key[..20]); | ||||
|         (private_key, public_key) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     let (private_key, _public_key) = { | ||||
|         let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(); | ||||
|         let private_key = | ||||
|             "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(); | ||||
|         let public_key = "04abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(); | ||||
|         info!("📝 Using fallback keys (crypto feature disabled)"); | ||||
|         (private_key, public_key) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     // Step 2: Simulate nonce request and response | ||||
|     info!("📡 Simulating nonce request..."); | ||||
|     let current_time = SystemTime::now() | ||||
|         .duration_since(UNIX_EPOCH) | ||||
|         .unwrap() | ||||
|         .as_secs(); | ||||
|      | ||||
|  | ||||
|     let simulated_nonce = format!("nonce_{}_{}", current_time, "abcdef123456"); | ||||
|     let expires_at = current_time + 300; // 5 minutes from now | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let nonce_response = NonceResponse { | ||||
|         nonce: simulated_nonce.clone(), | ||||
|         expires_at, | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     info!("✅ Simulated nonce response:"); | ||||
|     info!("   Nonce: {}", simulated_nonce); | ||||
|     info!("   Expires at: {}", expires_at); | ||||
|      | ||||
|  | ||||
|     // Step 3: Sign the nonce | ||||
|     info!("✍️  Signing nonce with private key..."); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let signature = { | ||||
|         match sign_message(&private_key, &simulated_nonce) { | ||||
| @@ -87,16 +84,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     let _signature = { | ||||
|         info!("📝 Using fallback signature (crypto feature disabled)"); | ||||
|         "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string() | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     // Step 4: Verify the signature | ||||
|     info!("🔍 Verifying signature..."); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         match verify_signature(&public_key, &simulated_nonce, &signature) { | ||||
| @@ -111,12 +108,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     { | ||||
|         info!("📝 Skipping signature verification (crypto feature disabled)"); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Step 5: Create authentication credentials | ||||
|     info!("📋 Creating authentication credentials..."); | ||||
|     #[cfg(feature = "crypto")] | ||||
| @@ -124,9 +121,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         public_key.clone(), | ||||
|         signature.clone(), | ||||
|         nonce_response.nonce.clone(), | ||||
|         expires_at | ||||
|         expires_at, | ||||
|     ); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         info!("✅ Credentials created:"); | ||||
| @@ -136,77 +133,86 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         info!("   Expires at: {}", credentials.expires_at); | ||||
|         info!("   Is expired: {}", credentials.is_expired()); | ||||
|         info!("   Expires within 60s: {}", credentials.expires_within(60)); | ||||
|         info!("   Expires within 400s: {}", credentials.expires_within(400)); | ||||
|         info!( | ||||
|             "   Expires within 400s: {}", | ||||
|             credentials.expires_within(400) | ||||
|         ); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Step 6: Create client with authentication | ||||
|     info!("🔌 Creating WebSocket client with authentication..."); | ||||
|     let _client = CircleWsClientBuilder::new("ws://localhost:8080/ws".to_string()) | ||||
|         .with_keypair(private_key.clone()) | ||||
|         .build(); | ||||
|      | ||||
|  | ||||
|     info!("✅ Client created"); | ||||
|      | ||||
|  | ||||
|     // Step 7: Demonstrate key rotation | ||||
|     info!("🔄 Demonstrating key rotation..."); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         let new_private_key = generate_private_key()?; | ||||
|         let new_public_key = derive_public_key(&new_private_key)?; | ||||
|          | ||||
|  | ||||
|         info!("✅ Generated new keys:"); | ||||
|         info!("   New private key: {}...", &new_private_key[..10]); | ||||
|         info!("   New public key: {}...", &new_public_key[..20]); | ||||
|          | ||||
|  | ||||
|         // Create new client with rotated keys | ||||
|         let _new_client = CircleWsClientBuilder::new("ws://localhost:8080/ws".to_string()) | ||||
|             .with_keypair(new_private_key) | ||||
|             .build(); | ||||
|          | ||||
|  | ||||
|         info!("✅ Created client with rotated keys"); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     { | ||||
|         info!("📝 Skipping key rotation (crypto feature disabled)"); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Step 8: Demonstrate credential expiration | ||||
|     info!("⏰ Demonstrating credential expiration..."); | ||||
|      | ||||
|  | ||||
|     // Create credentials that expire soon | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let short_lived_credentials = AuthCredentials::new( | ||||
|         public_key, | ||||
|         signature, | ||||
|         nonce_response.nonce, | ||||
|         current_time + 5 // Expires in 5 seconds | ||||
|         current_time + 5, // Expires in 5 seconds | ||||
|     ); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         info!("✅ Created short-lived credentials:"); | ||||
|         info!("   Expires at: {}", short_lived_credentials.expires_at); | ||||
|         info!("   Is expired: {}", short_lived_credentials.is_expired()); | ||||
|         info!("   Expires within 10s: {}", short_lived_credentials.expires_within(10)); | ||||
|          | ||||
|         info!( | ||||
|             "   Expires within 10s: {}", | ||||
|             short_lived_credentials.expires_within(10) | ||||
|         ); | ||||
|  | ||||
|         // Wait a moment and check again | ||||
|         tokio::time::sleep(std::time::Duration::from_secs(1)).await; | ||||
|         info!("⏳ After 1 second:"); | ||||
|         info!("   Is expired: {}", short_lived_credentials.is_expired()); | ||||
|         info!("   Expires within 5s: {}", short_lived_credentials.expires_within(5)); | ||||
|         info!( | ||||
|             "   Expires within 5s: {}", | ||||
|             short_lived_credentials.expires_within(5) | ||||
|         ); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     info!("🎉 Authentication simulation completed successfully!"); | ||||
|      | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|      | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_key_generation() { | ||||
|         #[cfg(feature = "crypto")] | ||||
| @@ -214,13 +220,13 @@ mod tests { | ||||
|             let private_key = generate_private_key().unwrap(); | ||||
|             assert!(private_key.starts_with("0x")); | ||||
|             assert_eq!(private_key.len(), 66); // 0x + 64 hex chars | ||||
|              | ||||
|  | ||||
|             let public_key = derive_public_key(&private_key).unwrap(); | ||||
|             assert!(public_key.starts_with("04")); | ||||
|             assert_eq!(public_key.len(), 130); // 04 + 128 hex chars (uncompressed) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn test_signature_flow() { | ||||
|         #[cfg(feature = "crypto")] | ||||
| @@ -228,29 +234,29 @@ mod tests { | ||||
|             let private_key = generate_private_key().unwrap(); | ||||
|             let public_key = derive_public_key(&private_key).unwrap(); | ||||
|             let message = "test_nonce_12345"; | ||||
|              | ||||
|  | ||||
|             let signature = sign_message(&private_key, message).unwrap(); | ||||
|             let is_valid = verify_signature(&public_key, message, &signature).unwrap(); | ||||
|              | ||||
|  | ||||
|             assert!(is_valid); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     #[test] | ||||
|     fn test_credentials() { | ||||
|         let current_time = SystemTime::now() | ||||
|             .duration_since(UNIX_EPOCH) | ||||
|             .unwrap() | ||||
|             .as_secs(); | ||||
|          | ||||
|  | ||||
|         #[cfg(feature = "crypto")] | ||||
|         let credentials = AuthCredentials::new( | ||||
|             "04abcdef...".to_string(), | ||||
|             "0x123456...".to_string(), | ||||
|             "nonce_123".to_string(), | ||||
|             current_time + 300 | ||||
|             current_time + 300, | ||||
|         ); | ||||
|          | ||||
|  | ||||
|         #[cfg(feature = "crypto")] | ||||
|         { | ||||
|             assert!(!credentials.is_expired()); | ||||
| @@ -258,4 +264,4 @@ mod tests { | ||||
|             assert!(!credentials.expires_within(100)); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -16,10 +16,10 @@ | ||||
| //! 4. The launcher will run until you stop it with Ctrl+C. | ||||
|  | ||||
| use launcher::{run_launcher, Args, CircleConfig}; | ||||
| use log::{error, info}; | ||||
| use std::error::Error as StdError; | ||||
| use std::fs; | ||||
| use std::path::PathBuf; | ||||
| use std::error::Error as StdError; | ||||
| use log::{error, info}; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn StdError>> { | ||||
| @@ -54,7 +54,11 @@ async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|     let mut circle_configs: Vec<CircleConfig> = match serde_json::from_str(&config_content) { | ||||
|         Ok(configs) => configs, | ||||
|         Err(e) => { | ||||
|             error!("Failed to parse {}: {}. Ensure it's a valid JSON array of CircleConfig.", config_path.display(), e); | ||||
|             error!( | ||||
|                 "Failed to parse {}: {}. Ensure it's a valid JSON array of CircleConfig.", | ||||
|                 config_path.display(), | ||||
|                 e | ||||
|             ); | ||||
|             return Err(Box::new(e) as Box<dyn StdError>); | ||||
|         } | ||||
|     }; | ||||
| @@ -68,7 +72,10 @@ async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|     } | ||||
|  | ||||
|     if circle_configs.is_empty() { | ||||
|         info!("No circle configurations found in {}. Exiting.", config_path.display()); | ||||
|         info!( | ||||
|             "No circle configurations found in {}. Exiting.", | ||||
|             config_path.display() | ||||
|         ); | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
| @@ -80,4 +87,4 @@ async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|  | ||||
|     println!("--- OurWorld Example Finished ---"); | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,51 +1,51 @@ | ||||
| [ | ||||
|   { | ||||
|     "name": "OurWorld", | ||||
|     "public_key": "02acbca22369b7f10584348056ae48779e04534cd34d37b7db0f4996f4d9d5e2a5", | ||||
|     "secret_key": "0c75df7425c799eb769049cf48891299761660396d772c687fa84cac5ec62570", | ||||
|     "worker_queue": "rhai_tasks:02acbca22369b7f10584348056ae48779e04534cd34d37b7db0f4996f4d9d5e2a5", | ||||
|     "public_key": "02b1ff38c18f66ffcfde1ff4931093484a96d378db55c1306a0760b39172d74099", | ||||
|     "secret_key": "86ed603c86f8938060575f7b1c7e4e4ddf72030ad2ea1699a8e9d1fb3a610869", | ||||
|     "worker_queue": "rhai_tasks:02b1ff38c18f66ffcfde1ff4931093484a96d378db55c1306a0760b39172d74099", | ||||
|     "ws_url": "ws://127.0.0.1:9000" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Dunia Cybercity", | ||||
|     "public_key": "03d97b1a357c3ceb2f0eb78f8e2c71beda9190db5cb7e5112150105132effb35e0", | ||||
|     "secret_key": "4fad664608e8de55f0e5e1712241e71dc0864be125bc8633e50601fca8040791", | ||||
|     "worker_queue": "rhai_tasks:03d97b1a357c3ceb2f0eb78f8e2c71beda9190db5cb7e5112150105132effb35e0", | ||||
|     "public_key": "020d8b1e3baab9991a82e9b55e117f45fda58b3f90b072dbbf10888f3195bfe6b9", | ||||
|     "secret_key": "b1ac20e4c6ace638f7f9e07918997fc35b2425de78152139c8b54629ca303b81", | ||||
|     "worker_queue": "rhai_tasks:020d8b1e3baab9991a82e9b55e117f45fda58b3f90b072dbbf10888f3195bfe6b9", | ||||
|     "ws_url": "ws://127.0.0.1:9001" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Sikana", | ||||
|     "public_key": "0389595b28cfa98b45fa3c222db79892f3face65e7ef06d44e35d642967e45ed6e", | ||||
|     "secret_key": "fd59ddbf0d0bada725c911dc7e3317754ac552aa1ac84cfcb899bdfe3591e1f4", | ||||
|     "worker_queue": "rhai_tasks:0389595b28cfa98b45fa3c222db79892f3face65e7ef06d44e35d642967e45ed6e", | ||||
|     "public_key": "0363dbff9f2b6dbaf58d3e8774db54dcccd10e23461ebf9a93cca63f8aa321d11d", | ||||
|     "secret_key": "9383663dcac577c14679c3487e6ffe7ff95040f422d391219ea530b892c1b0a0", | ||||
|     "worker_queue": "rhai_tasks:0363dbff9f2b6dbaf58d3e8774db54dcccd10e23461ebf9a93cca63f8aa321d11d", | ||||
|     "ws_url": "ws://127.0.0.1:9002" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Threefold", | ||||
|     "public_key": "03270f06ee4a7d42a9f6c22c9a7d6d0138cd15d4fa659026e2e6572fc6c6a6ea18", | ||||
|     "secret_key": "e204c0215bec80f74df49ea5b1592de3c6739cced339ace801bb7e158eb62231", | ||||
|     "worker_queue": "rhai_tasks:03270f06ee4a7d42a9f6c22c9a7d6d0138cd15d4fa659026e2e6572fc6c6a6ea18", | ||||
|     "public_key": "02c19cd347605dab98fb767b5e53c5fa5131d47a46b5f560b565fd4d79c1190994", | ||||
|     "secret_key": "0c4f5172724218650ea5806f5c9f8d4d4c8197c0c775f9d022fd8a192ad59048", | ||||
|     "worker_queue": "rhai_tasks:02c19cd347605dab98fb767b5e53c5fa5131d47a46b5f560b565fd4d79c1190994", | ||||
|     "ws_url": "ws://127.0.0.1:9003" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Mbweni", | ||||
|     "public_key": "02724cf23e4ac95d0f14984f55c6955b3ca5ab2275d7ac2a2e4baf3596caf8606c", | ||||
|     "secret_key": "3c013e2e5f64692f044d17233e5fabdb0577629f898359115e69c3e594d5f43e", | ||||
|     "worker_queue": "rhai_tasks:02724cf23e4ac95d0f14984f55c6955b3ca5ab2275d7ac2a2e4baf3596caf8606c", | ||||
|     "public_key": "0251808090b5b916e6187b63b6c97411f9d5406a9a6179408b90e3ff83042e7a9c", | ||||
|     "secret_key": "c824b3334350e2b267be2d4ceb1db53e98c9f386d2855aa7130227caa580805c", | ||||
|     "worker_queue": "rhai_tasks:0251808090b5b916e6187b63b6c97411f9d5406a9a6179408b90e3ff83042e7a9c", | ||||
|     "ws_url": "ws://127.0.0.1:9004" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Geomind", | ||||
|     "public_key": "030d8ceb47d445c92b7c3f13e9e134eebcb1d83beed424425f734164544eb58eed", | ||||
|     "secret_key": "dbd6dd383a6f56042710f72ce2ac68266650bbfb61432cdd139e98043b693e7c", | ||||
|     "worker_queue": "rhai_tasks:030d8ceb47d445c92b7c3f13e9e134eebcb1d83beed424425f734164544eb58eed", | ||||
|     "public_key": "037e2def151e7587b95519370e5d1023b9f24845e8e23a6535b0aad3cff20a859b", | ||||
|     "secret_key": "9c701a02ebba983d04ecbccee5072ed2cebd67ead4677c79a72d089d3ff29295", | ||||
|     "worker_queue": "rhai_tasks:037e2def151e7587b95519370e5d1023b9f24845e8e23a6535b0aad3cff20a859b", | ||||
|     "ws_url": "ws://127.0.0.1:9005" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Freezone", | ||||
|     "public_key": "02dd21025c1d47421eccc2264c87538d41126da772a9a3f0e7226807fed89c9971", | ||||
|     "secret_key": "0c0c6b02c20fcd4ccfb2afeae249979ddd623e6f6edd17af4a9a5a19bc1b15ae", | ||||
|     "worker_queue": "rhai_tasks:02dd21025c1d47421eccc2264c87538d41126da772a9a3f0e7226807fed89c9971", | ||||
|     "public_key": "02d4bf2713876cff2428f3f5e7e6191028374994d43a2c0f3d62c728a22d7f4aed", | ||||
|     "secret_key": "602c1bdd95489c7153676488976e9a24483cb353778332ec3b7644c3f05f5af2", | ||||
|     "worker_queue": "rhai_tasks:02d4bf2713876cff2428f3f5e7e6191028374994d43a2c0f3d62c728a22d7f4aed", | ||||
|     "ws_url": "ws://127.0.0.1:9006" | ||||
|   } | ||||
| ] | ||||
| @@ -1,6 +1,6 @@ | ||||
| use std::process::{Command, Child, Stdio}; | ||||
| use std::time::Duration; | ||||
| use std::path::PathBuf; | ||||
| use std::process::{Child, Command, Stdio}; | ||||
| use std::time::Duration; | ||||
| use tokio::time::sleep; | ||||
| // tokio_tungstenite and direct futures_util for ws stream are no longer needed here | ||||
| // use tokio_tungstenite::{connect_async, tungstenite::protocol::Message as WsMessage}; | ||||
| @@ -32,28 +32,54 @@ impl ChildProcessGuard { | ||||
|  | ||||
| impl Drop for ChildProcessGuard { | ||||
|     fn drop(&mut self) { | ||||
|         log::info!("Cleaning up {} process (PID: {})...", self.name, self.child.id()); | ||||
|         log::info!( | ||||
|             "Cleaning up {} process (PID: {})...", | ||||
|             self.name, | ||||
|             self.child.id() | ||||
|         ); | ||||
|         match self.child.kill() { | ||||
|             Ok(_) => { | ||||
|                 log::info!("Successfully sent kill signal to {} (PID: {}).", self.name, self.child.id()); | ||||
|                 log::info!( | ||||
|                     "Successfully sent kill signal to {} (PID: {}).", | ||||
|                     self.name, | ||||
|                     self.child.id() | ||||
|                 ); | ||||
|                 // Optionally wait for a short period or check status | ||||
|                 match self.child.wait() { | ||||
|                     Ok(status) => log::info!("{} (PID: {}) exited with status: {}", self.name, self.child.id(), status), | ||||
|                     Err(e) => log::warn!("Error waiting for {} (PID: {}): {}", self.name, self.child.id(), e), | ||||
|                     Ok(status) => log::info!( | ||||
|                         "{} (PID: {}) exited with status: {}", | ||||
|                         self.name, | ||||
|                         self.child.id(), | ||||
|                         status | ||||
|                     ), | ||||
|                     Err(e) => log::warn!( | ||||
|                         "Error waiting for {} (PID: {}): {}", | ||||
|                         self.name, | ||||
|                         self.child.id(), | ||||
|                         e | ||||
|                     ), | ||||
|                 } | ||||
|             } | ||||
|             Err(e) => log::error!("Failed to kill {} (PID: {}): {}", self.name, self.child.id(), e), | ||||
|             Err(e) => log::error!( | ||||
|                 "Failed to kill {} (PID: {}): {}", | ||||
|                 self.name, | ||||
|                 self.child.id(), | ||||
|                 e | ||||
|             ), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn find_target_dir() -> Result<PathBuf, String> { | ||||
|     let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|     let workspace_root = PathBuf::from(manifest_dir).parent().ok_or("Failed to get workspace root")?.to_path_buf(); | ||||
|     let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") | ||||
|         .map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|     let workspace_root = PathBuf::from(manifest_dir) | ||||
|         .parent() | ||||
|         .ok_or("Failed to get workspace root")? | ||||
|         .to_path_buf(); | ||||
|     Ok(workspace_root.join("target").join("debug")) | ||||
| } | ||||
|  | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); | ||||
| @@ -62,7 +88,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         log::error!("Could not determine target directory: {}", e); | ||||
|         e | ||||
|     })?; | ||||
|      | ||||
|  | ||||
|     let rhai_worker_path = target_dir.join(RHAI_WORKER_BIN_NAME); | ||||
|     let circle_server_ws_path = target_dir.join(CIRCLE_SERVER_WS_BIN_NAME); | ||||
|  | ||||
| @@ -79,26 +105,46 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         .stdout(Stdio::piped()) // Capture stdout | ||||
|         .stderr(Stdio::piped()) // Capture stderr | ||||
|         .spawn()?; | ||||
|     let _rhai_worker_guard = ChildProcessGuard::new(rhai_worker_process, RHAI_WORKER_BIN_NAME.to_string()); | ||||
|     log::info!("{} started with PID {}", RHAI_WORKER_BIN_NAME, _rhai_worker_guard.child.id()); | ||||
|     let _rhai_worker_guard = | ||||
|         ChildProcessGuard::new(rhai_worker_process, RHAI_WORKER_BIN_NAME.to_string()); | ||||
|     log::info!( | ||||
|         "{} started with PID {}", | ||||
|         RHAI_WORKER_BIN_NAME, | ||||
|         _rhai_worker_guard.child.id() | ||||
|     ); | ||||
|  | ||||
|     log::info!("Starting {} for circle '{}' on port {}...", CIRCLE_SERVER_WS_BIN_NAME, TEST_CIRCLE_NAME, TEST_SERVER_PORT); | ||||
|     log::info!( | ||||
|         "Starting {} for circle '{}' on port {}...", | ||||
|         CIRCLE_SERVER_WS_BIN_NAME, | ||||
|         TEST_CIRCLE_NAME, | ||||
|         TEST_SERVER_PORT | ||||
|     ); | ||||
|     let circle_server_process = Command::new(&circle_server_ws_path) | ||||
|         .args(["--port", &TEST_SERVER_PORT.to_string(), "--circle-name", TEST_CIRCLE_NAME]) | ||||
|         .args([ | ||||
|             "--port", | ||||
|             &TEST_SERVER_PORT.to_string(), | ||||
|             "--circle-name", | ||||
|             TEST_CIRCLE_NAME, | ||||
|         ]) | ||||
|         .stdout(Stdio::piped()) | ||||
|         .stderr(Stdio::piped()) | ||||
|         .spawn()?; | ||||
|     let _circle_server_guard = ChildProcessGuard::new(circle_server_process, CIRCLE_SERVER_WS_BIN_NAME.to_string()); | ||||
|     log::info!("{} started with PID {}", CIRCLE_SERVER_WS_BIN_NAME, _circle_server_guard.child.id()); | ||||
|     let _circle_server_guard = | ||||
|         ChildProcessGuard::new(circle_server_process, CIRCLE_SERVER_WS_BIN_NAME.to_string()); | ||||
|     log::info!( | ||||
|         "{} started with PID {}", | ||||
|         CIRCLE_SERVER_WS_BIN_NAME, | ||||
|         _circle_server_guard.child.id() | ||||
|     ); | ||||
|  | ||||
|     // Give servers a moment to start | ||||
|     sleep(Duration::from_secs(3)).await; // Increased sleep | ||||
|  | ||||
|     let ws_url_str = format!("ws://127.0.0.1:{}/ws", TEST_SERVER_PORT); | ||||
|      | ||||
|  | ||||
|     log::info!("Creating CircleWsClient for {}...", ws_url_str); | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url_str.clone()).build(); | ||||
|      | ||||
|  | ||||
|     log::info!("Connecting CircleWsClient..."); | ||||
|     client.connect().await.map_err(|e| { | ||||
|         log::error!("CircleWsClient connection failed: {}", e); | ||||
| @@ -108,8 +154,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|  | ||||
|     let script_to_run = "let a = 5; let b = 10; print(\"E2E Rhai: \" + (a+b)); a + b"; | ||||
|  | ||||
|     log::info!("Sending 'play' request via CircleWsClient for script: '{}'", script_to_run); | ||||
|      | ||||
|     log::info!( | ||||
|         "Sending 'play' request via CircleWsClient for script: '{}'", | ||||
|         script_to_run | ||||
|     ); | ||||
|  | ||||
|     match client.play(script_to_run.to_string()).await { | ||||
|         Ok(play_result) => { | ||||
|             log::info!("Received play result: {:?}", play_result); | ||||
| @@ -121,12 +170,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             return Err(format!("CircleWsClient play request failed: {}", e).into()); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     log::info!("Disconnecting CircleWsClient..."); | ||||
|     client.disconnect().await; | ||||
|     log::info!("CircleWsClient disconnected."); | ||||
|      | ||||
|  | ||||
|     log::info!("E2E Rhai flow example completed successfully."); | ||||
|     // Guards will automatically clean up child processes when they go out of scope here | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -7,9 +7,9 @@ | ||||
| // Ensure circle_server_ws is compiled (cargo build --bin circle_server_ws). | ||||
|  | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
| use tokio::time::{sleep, Duration}; | ||||
| use std::process::{Command, Child, Stdio}; | ||||
| use std::path::PathBuf; | ||||
| use std::process::{Child, Command, Stdio}; | ||||
| use tokio::time::{sleep, Duration}; | ||||
|  | ||||
| const EXAMPLE_SERVER_PORT: u16 = 8089; // Using a specific port for this example | ||||
| const WS_URL: &str = "ws://127.0.0.1:8089/ws"; | ||||
| @@ -32,26 +32,56 @@ impl ChildProcessGuard { | ||||
|  | ||||
| impl Drop for ChildProcessGuard { | ||||
|     fn drop(&mut self) { | ||||
|         log::info!("Cleaning up {} process (PID: {})...", self.name, self.child.id()); | ||||
|         log::info!( | ||||
|             "Cleaning up {} process (PID: {})...", | ||||
|             self.name, | ||||
|             self.child.id() | ||||
|         ); | ||||
|         match self.child.kill() { | ||||
|             Ok(_) => { | ||||
|                 log::info!("Successfully sent kill signal to {} (PID: {}).", self.name, self.child.id()); | ||||
|                 log::info!( | ||||
|                     "Successfully sent kill signal to {} (PID: {}).", | ||||
|                     self.name, | ||||
|                     self.child.id() | ||||
|                 ); | ||||
|                 match self.child.wait() { | ||||
|                     Ok(status) => log::info!("{} (PID: {}) exited with status: {}", self.name, self.child.id(), status), | ||||
|                     Err(e) => log::warn!("Error waiting for {} (PID: {}): {}", self.name, self.child.id(), e), | ||||
|                     Ok(status) => log::info!( | ||||
|                         "{} (PID: {}) exited with status: {}", | ||||
|                         self.name, | ||||
|                         self.child.id(), | ||||
|                         status | ||||
|                     ), | ||||
|                     Err(e) => log::warn!( | ||||
|                         "Error waiting for {} (PID: {}): {}", | ||||
|                         self.name, | ||||
|                         self.child.id(), | ||||
|                         e | ||||
|                     ), | ||||
|                 } | ||||
|             } | ||||
|             Err(e) => log::error!("Failed to kill {} (PID: {}): {}", self.name, self.child.id(), e), | ||||
|             Err(e) => log::error!( | ||||
|                 "Failed to kill {} (PID: {}): {}", | ||||
|                 self.name, | ||||
|                 self.child.id(), | ||||
|                 e | ||||
|             ), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn find_target_bin_path(bin_name: &str) -> Result<PathBuf, String> { | ||||
|     let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|     let workspace_root = PathBuf::from(manifest_dir).parent().ok_or("Failed to get workspace root")?.to_path_buf(); | ||||
|     let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") | ||||
|         .map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|     let workspace_root = PathBuf::from(manifest_dir) | ||||
|         .parent() | ||||
|         .ok_or("Failed to get workspace root")? | ||||
|         .to_path_buf(); | ||||
|     let bin_path = workspace_root.join("target").join("debug").join(bin_name); | ||||
|     if !bin_path.exists() { | ||||
|         return Err(format!("Binary '{}' not found at {:?}. Ensure it's built.", bin_name, bin_path)); | ||||
|         return Err(format!( | ||||
|             "Binary '{}' not found at {:?}. Ensure it's built.", | ||||
|             bin_name, bin_path | ||||
|         )); | ||||
|     } | ||||
|     Ok(bin_path) | ||||
| } | ||||
| @@ -63,18 +93,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     let server_bin_path = find_target_bin_path(CIRCLE_SERVER_WS_BIN_NAME)?; | ||||
|     log::info!("Found server binary at: {:?}", server_bin_path); | ||||
|  | ||||
|     log::info!("Starting {} for circle '{}' on port {}...", CIRCLE_SERVER_WS_BIN_NAME, CIRCLE_NAME_FOR_EXAMPLE, EXAMPLE_SERVER_PORT); | ||||
|     log::info!( | ||||
|         "Starting {} for circle '{}' on port {}...", | ||||
|         CIRCLE_SERVER_WS_BIN_NAME, | ||||
|         CIRCLE_NAME_FOR_EXAMPLE, | ||||
|         EXAMPLE_SERVER_PORT | ||||
|     ); | ||||
|     let server_process = Command::new(&server_bin_path) | ||||
|         .args([ | ||||
|             "--port", &EXAMPLE_SERVER_PORT.to_string(), | ||||
|             "--circle-name", CIRCLE_NAME_FOR_EXAMPLE | ||||
|             "--port", | ||||
|             &EXAMPLE_SERVER_PORT.to_string(), | ||||
|             "--circle-name", | ||||
|             CIRCLE_NAME_FOR_EXAMPLE, | ||||
|         ]) | ||||
|         .stdout(Stdio::piped()) // Pipe stdout to keep terminal clean, or Stdio::inherit() to see server logs | ||||
|         .stderr(Stdio::piped()) // Pipe stderr as well | ||||
|         .spawn() | ||||
|         .map_err(|e| format!("Failed to start {}: {}. Ensure it is built.", CIRCLE_SERVER_WS_BIN_NAME, e))?; | ||||
|      | ||||
|     let _server_guard = ChildProcessGuard::new(server_process, CIRCLE_SERVER_WS_BIN_NAME.to_string()); | ||||
|         .map_err(|e| { | ||||
|             format!( | ||||
|                 "Failed to start {}: {}. Ensure it is built.", | ||||
|                 CIRCLE_SERVER_WS_BIN_NAME, e | ||||
|             ) | ||||
|         })?; | ||||
|  | ||||
|     let _server_guard = | ||||
|         ChildProcessGuard::new(server_process, CIRCLE_SERVER_WS_BIN_NAME.to_string()); | ||||
|  | ||||
|     log::info!("Giving the server a moment to start up..."); | ||||
|     sleep(Duration::from_secs(3)).await; // Wait for server to initialize | ||||
| @@ -99,13 +142,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         // This part should not be reached if timeout works correctly. | ||||
|         print(x); | ||||
|         x | ||||
|     ".to_string(); | ||||
|     " | ||||
|     .to_string(); | ||||
|  | ||||
|     log::info!("Sending long-running script (expected to time out on server after ~{}s)...", SCRIPT_TIMEOUT_SECONDS); | ||||
|     log::info!( | ||||
|         "Sending long-running script (expected to time out on server after ~{}s)...", | ||||
|         SCRIPT_TIMEOUT_SECONDS | ||||
|     ); | ||||
|  | ||||
|     match client.play(long_running_script).await { | ||||
|         Ok(play_result) => { | ||||
|             log::warn!("Received unexpected success from play request: {:?}", play_result); | ||||
|             log::warn!( | ||||
|                 "Received unexpected success from play request: {:?}", | ||||
|                 play_result | ||||
|             ); | ||||
|             log::warn!("This might indicate the script finished faster than expected, or the timeout didn't trigger."); | ||||
|         } | ||||
|         Err(e) => { | ||||
| @@ -116,7 +166,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             if e.to_string().contains("timed out") || e.to_string().contains("-32002") { | ||||
|                 log::info!("Successfully received timeout error from the server!"); | ||||
|             } else { | ||||
|                 log::warn!("Received an error, but it might not be the expected timeout error: {}", e); | ||||
|                 log::warn!( | ||||
|                     "Received an error, but it might not be the expected timeout error: {}", | ||||
|                     e | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -127,4 +180,4 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     log::info!("Timeout demonstration example finished."); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,17 +1,17 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| use crate::components::circles_view::CirclesView; | ||||
| use crate::components::nav_island::NavIsland; | ||||
| use crate::components::library_view::LibraryView; | ||||
| use crate::components::intelligence_view::IntelligenceView; | ||||
| use crate::components::inspector_view::InspectorView; | ||||
| use crate::components::publishing_view::PublishingView; | ||||
| use crate::components::customize_view::CustomizeViewComponent; | ||||
| use crate::components::login_component::LoginComponent; | ||||
| use crate::auth::{AuthManager, AuthState}; | ||||
| use crate::components::auth_view::AuthView; | ||||
| use crate::components::circles_view::CirclesView; | ||||
| use crate::components::customize_view::CustomizeViewComponent; | ||||
| use crate::components::inspector_view::InspectorView; | ||||
| use crate::components::intelligence_view::IntelligenceView; | ||||
| use crate::components::library_view::LibraryView; | ||||
| use crate::components::login_component::LoginComponent; | ||||
| use crate::components::nav_island::NavIsland; | ||||
| use crate::components::publishing_view::PublishingView; | ||||
|  | ||||
| // Props for the App component | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| @@ -43,7 +43,7 @@ pub enum Msg { | ||||
| pub struct App { | ||||
|     current_view: AppView, | ||||
|     active_context_urls: Vec<String>, // Only context URLs from CirclesView | ||||
|     start_circle_ws_url: String, // Initial WebSocket URL for CirclesView | ||||
|     start_circle_ws_url: String,      // Initial WebSocket URL for CirclesView | ||||
|     auth_manager: AuthManager, | ||||
|     auth_state: AuthState, | ||||
| } | ||||
| @@ -55,15 +55,15 @@ impl Component for App { | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         wasm_logger::init(wasm_logger::Config::default()); | ||||
|         log::info!("App created with authentication support."); | ||||
|          | ||||
|  | ||||
|         let start_circle_ws_url = ctx.props().start_circle_ws_url.clone(); | ||||
|         let auth_manager = AuthManager::new(); | ||||
|         let auth_state = auth_manager.get_state(); | ||||
|          | ||||
|  | ||||
|         // Set up auth state change callback | ||||
|         let link = ctx.link().clone(); | ||||
|         auth_manager.set_on_state_change(link.callback(Msg::AuthStateChanged)); | ||||
|          | ||||
|  | ||||
|         // Determine initial view based on authentication state | ||||
|         let initial_view = match auth_state { | ||||
|             AuthState::Authenticated { .. } => AppView::Circles, | ||||
| @@ -82,7 +82,10 @@ impl Component for App { | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::UpdateCirclesContext(context_urls) => { | ||||
|                 log::info!("App: Received context update from CirclesView: {:?}", context_urls); | ||||
|                 log::info!( | ||||
|                     "App: Received context update from CirclesView: {:?}", | ||||
|                     context_urls | ||||
|                 ); | ||||
|                 self.active_context_urls = context_urls; | ||||
|                 true | ||||
|             } | ||||
| @@ -98,7 +101,10 @@ impl Component for App { | ||||
|                             self.current_view = view; | ||||
|                             true | ||||
|                         } else { | ||||
|                             log::warn!("Attempted to access {} view without authentication", format!("{:?}", view)); | ||||
|                             log::warn!( | ||||
|                                 "Attempted to access {} view without authentication", | ||||
|                                 format!("{:?}", view) | ||||
|                             ); | ||||
|                             self.current_view = AppView::Login; | ||||
|                             true | ||||
|                         } | ||||
| @@ -108,7 +114,7 @@ impl Component for App { | ||||
|             Msg::AuthStateChanged(state) => { | ||||
|                 log::info!("App: Auth state changed: {:?}", state); | ||||
|                 self.auth_state = state.clone(); | ||||
|                  | ||||
|  | ||||
|                 match state { | ||||
|                     AuthState::Authenticated { .. } => { | ||||
|                         // Switch to main app view when authenticated | ||||
| @@ -212,7 +218,7 @@ impl Component for App { | ||||
|                         /> | ||||
|                     }, | ||||
|                 }} | ||||
|                  | ||||
|  | ||||
|                 { if self.current_view != AppView::Login { | ||||
|                     html! { | ||||
|                         <NavIsland | ||||
| @@ -239,10 +245,10 @@ impl App { | ||||
|                 <div class="app-title-button"> | ||||
|                     <span class="app-title-name">{ "Circles" }</span> | ||||
|                 </div> | ||||
|                 <AuthView  | ||||
|                     auth_state={self.auth_state.clone()}  | ||||
|                     on_logout={link.callback(|_| Msg::Logout)}  | ||||
|                     on_login={link.callback(|_| Msg::SwitchView(AppView::Login))}  | ||||
|                 <AuthView | ||||
|                     auth_state={self.auth_state.clone()} | ||||
|                     on_logout={link.callback(|_| Msg::Logout)} | ||||
|                     on_login={link.callback(|_| Msg::SwitchView(AppView::Login))} | ||||
|                 /> | ||||
|             </header> | ||||
|         } | ||||
|   | ||||
| @@ -1,17 +1,17 @@ | ||||
| //! Authentication manager for coordinating authentication flows | ||||
| //!  | ||||
| //! | ||||
| //! This module provides the main AuthManager struct that coordinates | ||||
| //! the entire authentication process, including email lookup and | ||||
| //! integration with the client_ws library for WebSocket connections. | ||||
|  | ||||
| use std::rc::Rc; | ||||
| use std::cell::RefCell; | ||||
| use yew::Callback; | ||||
| use gloo_storage::{LocalStorage, SessionStorage, Storage}; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientError, CircleWsClientBuilder}; | ||||
| use circle_client_ws::auth::{validate_private_key, derive_public_key}; | ||||
| use crate::auth::types::{AuthResult, AuthError, AuthState, AuthMethod}; | ||||
| use crate::auth::email_store::{get_key_pair_for_email, is_email_available}; | ||||
| use crate::auth::types::{AuthError, AuthMethod, AuthResult, AuthState}; | ||||
| use circle_client_ws::auth::{derive_public_key, validate_private_key}; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientBuilder, CircleWsClientError}; | ||||
| use gloo_storage::{LocalStorage, SessionStorage, Storage}; | ||||
| use std::cell::RefCell; | ||||
| use std::rc::Rc; | ||||
| use yew::Callback; | ||||
|  | ||||
| /// Key for storing authentication state in local storage | ||||
| const AUTH_STATE_STORAGE_KEY: &str = "circles_auth_state_marker"; | ||||
| @@ -35,7 +35,7 @@ impl AuthManager { | ||||
|     /// Create a new authentication manager | ||||
|     pub fn new() -> Self { | ||||
|         let initial_state = Self::load_auth_state().unwrap_or(AuthState::NotAuthenticated); | ||||
|          | ||||
|  | ||||
|         Self { | ||||
|             state: Rc::new(RefCell::new(initial_state)), | ||||
|             on_state_change: Rc::new(RefCell::new(None)), | ||||
| @@ -65,8 +65,7 @@ impl AuthManager { | ||||
|         let key_pair = get_key_pair_for_email(&email)?; | ||||
|  | ||||
|         // Validate the private key using client_ws | ||||
|         validate_private_key(&key_pair.private_key) | ||||
|             .map_err(|e| AuthError::from(e))?; | ||||
|         validate_private_key(&key_pair.private_key).map_err(|e| AuthError::from(e))?; | ||||
|  | ||||
|         // Set authenticated state | ||||
|         let auth_state = AuthState::Authenticated { | ||||
| @@ -84,12 +83,10 @@ impl AuthManager { | ||||
|         self.set_state(AuthState::Authenticating); | ||||
|  | ||||
|         // Validate the private key using client_ws | ||||
|         validate_private_key(&private_key) | ||||
|             .map_err(|e| AuthError::from(e))?; | ||||
|         validate_private_key(&private_key).map_err(|e| AuthError::from(e))?; | ||||
|  | ||||
|         // Derive public key using client_ws | ||||
|         let public_key = derive_public_key(&private_key) | ||||
|             .map_err(|e| AuthError::from(e))?; | ||||
|         let public_key = derive_public_key(&private_key).map_err(|e| AuthError::from(e))?; | ||||
|  | ||||
|         // Set authenticated state | ||||
|         let auth_state = AuthState::Authenticated { | ||||
| @@ -103,7 +100,10 @@ impl AuthManager { | ||||
|     } | ||||
|  | ||||
|     /// Create an authenticated WebSocket client using message-based authentication | ||||
|     pub async fn create_authenticated_client(&self, ws_url: &str) -> Result<CircleWsClient, CircleWsClientError> { | ||||
|     pub async fn create_authenticated_client( | ||||
|         &self, | ||||
|         ws_url: &str, | ||||
|     ) -> Result<CircleWsClient, CircleWsClientError> { | ||||
|         let auth_state = self.state.borrow().clone(); | ||||
|  | ||||
|         let private_key = match auth_state { | ||||
| @@ -140,10 +140,10 @@ impl AuthManager { | ||||
|     /// Set authentication state and notify listeners | ||||
|     fn set_state(&self, new_state: AuthState) { | ||||
|         *self.state.borrow_mut() = new_state.clone(); | ||||
|          | ||||
|  | ||||
|         // Save to local storage (excluding sensitive data) | ||||
|         self.save_auth_state(&new_state); | ||||
|          | ||||
|  | ||||
|         // Notify listeners | ||||
|         if let Some(callback) = &*self.on_state_change.borrow() { | ||||
|             callback.emit(new_state); | ||||
| @@ -154,7 +154,11 @@ impl AuthManager { | ||||
|     /// Private keys are stored in sessionStorage, method hints in localStorage. | ||||
|     fn save_auth_state(&self, state: &AuthState) { | ||||
|         match state { | ||||
|             AuthState::Authenticated { public_key: _, private_key, method } => { | ||||
|             AuthState::Authenticated { | ||||
|                 public_key: _, | ||||
|                 private_key, | ||||
|                 method, | ||||
|             } => { | ||||
|                 match method { | ||||
|                     AuthMethod::Email(email) => { | ||||
|                         let marker = format!("email:{}", email); | ||||
| @@ -164,9 +168,15 @@ impl AuthManager { | ||||
|                     } | ||||
|                     AuthMethod::PrivateKey => { | ||||
|                         // Store the actual private key in sessionStorage | ||||
|                         let _ = SessionStorage::set(PRIVATE_KEY_SESSION_STORAGE_KEY, private_key.clone()); | ||||
|                         let _ = SessionStorage::set( | ||||
|                             PRIVATE_KEY_SESSION_STORAGE_KEY, | ||||
|                             private_key.clone(), | ||||
|                         ); | ||||
|                         // Store a marker in localStorage | ||||
|                         let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, "private_key_auth_marker".to_string()); | ||||
|                         let _ = LocalStorage::set( | ||||
|                             AUTH_STATE_STORAGE_KEY, | ||||
|                             "private_key_auth_marker".to_string(), | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -188,7 +198,9 @@ impl AuthManager { | ||||
|     fn load_auth_state() -> Option<AuthState> { | ||||
|         if let Ok(marker) = LocalStorage::get::<String>(AUTH_STATE_STORAGE_KEY) { | ||||
|             if marker == "private_key_auth_marker" { | ||||
|                 if let Ok(private_key) = SessionStorage::get::<String>(PRIVATE_KEY_SESSION_STORAGE_KEY) { | ||||
|                 if let Ok(private_key) = | ||||
|                     SessionStorage::get::<String>(PRIVATE_KEY_SESSION_STORAGE_KEY) | ||||
|                 { | ||||
|                     if validate_private_key(&private_key).is_ok() { | ||||
|                         if let Ok(public_key) = derive_public_key(&private_key) { | ||||
|                             return Some(AuthState::Authenticated { | ||||
| @@ -251,8 +263,7 @@ impl AuthManager { | ||||
|     pub fn validate_current_auth(&self) -> AuthResult<()> { | ||||
|         match &*self.state.borrow() { | ||||
|             AuthState::Authenticated { private_key, .. } => { | ||||
|                 validate_private_key(private_key) | ||||
|                     .map_err(|e| AuthError::from(e)) | ||||
|                 validate_private_key(private_key).map_err(|e| AuthError::from(e)) | ||||
|             } | ||||
|             _ => Err(AuthError::AuthFailed("Not authenticated".to_string())), | ||||
|         } | ||||
| @@ -275,15 +286,17 @@ mod tests { | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_email_authentication() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|  | ||||
|         // Test with valid email | ||||
|         let result = auth_manager.authenticate_with_email("alice@example.com".to_string()).await; | ||||
|         let result = auth_manager | ||||
|             .authenticate_with_email("alice@example.com".to_string()) | ||||
|             .await; | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(auth_manager.is_authenticated()); | ||||
|          | ||||
|  | ||||
|         // Check that we can get the public key | ||||
|         assert!(auth_manager.get_public_key().is_some()); | ||||
|          | ||||
|  | ||||
|         // Check auth method | ||||
|         match auth_manager.get_auth_method() { | ||||
|             Some(AuthMethod::Email(email)) => assert_eq!(email, "alice@example.com"), | ||||
| @@ -294,13 +307,15 @@ mod tests { | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_private_key_authentication() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|  | ||||
|         // Test with valid private key | ||||
|         let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; | ||||
|         let result = auth_manager.authenticate_with_private_key(private_key.to_string()).await; | ||||
|         let result = auth_manager | ||||
|             .authenticate_with_private_key(private_key.to_string()) | ||||
|             .await; | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(auth_manager.is_authenticated()); | ||||
|          | ||||
|  | ||||
|         // Check that we can get the public key | ||||
|         assert!(auth_manager.get_public_key().is_some()); | ||||
|     } | ||||
| @@ -308,8 +323,10 @@ mod tests { | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_invalid_email() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|         let result = auth_manager.authenticate_with_email("nonexistent@example.com".to_string()).await; | ||||
|  | ||||
|         let result = auth_manager | ||||
|             .authenticate_with_email("nonexistent@example.com".to_string()) | ||||
|             .await; | ||||
|         assert!(result.is_err()); | ||||
|         assert!(!auth_manager.is_authenticated()); | ||||
|     } | ||||
| @@ -317,8 +334,10 @@ mod tests { | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_invalid_private_key() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|         let result = auth_manager.authenticate_with_private_key("invalid_key".to_string()).await; | ||||
|  | ||||
|         let result = auth_manager | ||||
|             .authenticate_with_private_key("invalid_key".to_string()) | ||||
|             .await; | ||||
|         assert!(result.is_err()); | ||||
|         assert!(!auth_manager.is_authenticated()); | ||||
|     } | ||||
| @@ -326,11 +345,13 @@ mod tests { | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_logout() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|  | ||||
|         // Authenticate first | ||||
|         let _ = auth_manager.authenticate_with_email("alice@example.com".to_string()).await; | ||||
|         let _ = auth_manager | ||||
|             .authenticate_with_email("alice@example.com".to_string()) | ||||
|             .await; | ||||
|         assert!(auth_manager.is_authenticated()); | ||||
|          | ||||
|  | ||||
|         // Logout | ||||
|         auth_manager.logout(); | ||||
|         assert!(!auth_manager.is_authenticated()); | ||||
| @@ -340,9 +361,9 @@ mod tests { | ||||
|     #[wasm_bindgen_test] | ||||
|     fn test_email_availability() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|  | ||||
|         assert!(auth_manager.is_email_available("alice@example.com")); | ||||
|         assert!(auth_manager.is_email_available("admin@circles.com")); | ||||
|         assert!(!auth_manager.is_email_available("nonexistent@example.com")); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| //! Hardcoded email-to-private-key mappings | ||||
| //!  | ||||
| //! | ||||
| //! This module provides a static mapping of email addresses to their corresponding | ||||
| //! private and public key pairs. This is designed for development and app purposes | ||||
| //! where users can authenticate using known email addresses. | ||||
|  | ||||
| use std::collections::HashMap; | ||||
| use crate::auth::types::{AuthResult, AuthError}; | ||||
| use crate::auth::types::{AuthError, AuthResult}; | ||||
| use circle_client_ws::auth::derive_public_key; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| /// A key pair consisting of private and public keys | ||||
| #[derive(Debug, Clone)] | ||||
| @@ -16,50 +16,50 @@ pub struct KeyPair { | ||||
| } | ||||
|  | ||||
| /// Get the hardcoded email-to-key mappings | ||||
| ///  | ||||
| /// | ||||
| /// Returns a HashMap where: | ||||
| /// - Key: email address (String) | ||||
| /// - Value: KeyPair with private and public keys | ||||
| pub fn get_email_key_mappings() -> HashMap<String, KeyPair> { | ||||
|     let mut mappings = HashMap::new(); | ||||
|      | ||||
|  | ||||
|     // Demo users with their private keys | ||||
|     // Note: These are for demonstration purposes only | ||||
|     let demo_keys = vec![ | ||||
|         ( | ||||
|             "alice@example.com", | ||||
|             "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" | ||||
|             "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", | ||||
|         ), | ||||
|         ( | ||||
|             "bob@example.com",  | ||||
|             "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" | ||||
|             "bob@example.com", | ||||
|             "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", | ||||
|         ), | ||||
|         ( | ||||
|             "charlie@example.com", | ||||
|             "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" | ||||
|             "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", | ||||
|         ), | ||||
|         ( | ||||
|             "diana@example.com", | ||||
|             "0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba" | ||||
|             "0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba", | ||||
|         ), | ||||
|         ( | ||||
|             "eve@example.com", | ||||
|             "0x1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff" | ||||
|             "0x1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff", | ||||
|         ), | ||||
|         ( | ||||
|             "admin@circles.com", | ||||
|             "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | ||||
|             "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | ||||
|         ), | ||||
|         ( | ||||
|             "app@circles.com", | ||||
|             "0xdeadbeefcafebabe1234567890abcdef1234567890abcdef1234567890abcdef" | ||||
|             "0xdeadbeefcafebabe1234567890abcdef1234567890abcdef1234567890abcdef", | ||||
|         ), | ||||
|         ( | ||||
|             "test@circles.com", | ||||
|             "0xbaadf00dcafebabe9876543210fedcba9876543210fedcba9876543210fedcba" | ||||
|             "0xbaadf00dcafebabe9876543210fedcba9876543210fedcba9876543210fedcba", | ||||
|         ), | ||||
|     ]; | ||||
|      | ||||
|  | ||||
|     // Generate key pairs for each app user | ||||
|     for (email, private_key) in demo_keys { | ||||
|         if let Ok(public_key) = derive_public_key(private_key) { | ||||
| @@ -68,21 +68,22 @@ pub fn get_email_key_mappings() -> HashMap<String, KeyPair> { | ||||
|                 KeyPair { | ||||
|                     private_key: private_key.to_string(), | ||||
|                     public_key, | ||||
|                 } | ||||
|                 }, | ||||
|             ); | ||||
|         } else { | ||||
|             log::error!("Failed to derive public key for email: {}", email); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     mappings | ||||
| } | ||||
|  | ||||
| /// Look up a key pair by email address | ||||
| pub fn get_key_pair_for_email(email: &str) -> AuthResult<KeyPair> { | ||||
|     let mappings = get_email_key_mappings(); | ||||
|      | ||||
|     mappings.get(email) | ||||
|  | ||||
|     mappings | ||||
|         .get(email) | ||||
|         .cloned() | ||||
|         .ok_or_else(|| AuthError::EmailNotFound(email.to_string())) | ||||
| } | ||||
| @@ -102,24 +103,28 @@ pub fn is_email_available(email: &str) -> bool { | ||||
| pub fn add_email_key_mapping(email: String, private_key: String) -> AuthResult<()> { | ||||
|     // Validate the private key first | ||||
|     let public_key = derive_public_key(&private_key)?; | ||||
|      | ||||
|  | ||||
|     // In a real implementation, you might want to persist this | ||||
|     // For now, we just validate that it would work | ||||
|     log::info!("Would add mapping for email: {} with public key: {}", email, public_key); | ||||
|      | ||||
|     log::info!( | ||||
|         "Would add mapping for email: {} with public key: {}", | ||||
|         email, | ||||
|         public_key | ||||
|     ); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|     use circle_client_ws::auth::{validate_private_key, verify_signature, sign_message}; | ||||
|     use circle_client_ws::auth::{sign_message, validate_private_key, verify_signature}; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_email_mappings_exist() { | ||||
|         let mappings = get_email_key_mappings(); | ||||
|         assert!(!mappings.is_empty()); | ||||
|          | ||||
|  | ||||
|         // Check that alice@example.com exists | ||||
|         assert!(mappings.contains_key("alice@example.com")); | ||||
|         assert!(mappings.contains_key("admin@circles.com")); | ||||
| @@ -128,10 +133,10 @@ mod tests { | ||||
|     #[test] | ||||
|     fn test_key_pair_lookup() { | ||||
|         let key_pair = get_key_pair_for_email("alice@example.com").unwrap(); | ||||
|          | ||||
|  | ||||
|         // Validate that the private key is valid | ||||
|         assert!(validate_private_key(&key_pair.private_key).is_ok()); | ||||
|          | ||||
|  | ||||
|         // Validate that the public key matches the private key | ||||
|         let derived_public = derive_public_key(&key_pair.private_key).unwrap(); | ||||
|         assert_eq!(key_pair.public_key, derived_public); | ||||
| @@ -141,10 +146,10 @@ mod tests { | ||||
|     fn test_signing_with_stored_keys() { | ||||
|         let key_pair = get_key_pair_for_email("bob@example.com").unwrap(); | ||||
|         let message = "Test message"; | ||||
|          | ||||
|  | ||||
|         // Sign a message with the stored private key | ||||
|         let signature = sign_message(&key_pair.private_key, message).unwrap(); | ||||
|          | ||||
|  | ||||
|         // Verify the signature with the stored public key | ||||
|         let is_valid = verify_signature(&key_pair.public_key, message, &signature).unwrap(); | ||||
|         assert!(is_valid); | ||||
| @@ -154,7 +159,7 @@ mod tests { | ||||
|     fn test_email_not_found() { | ||||
|         let result = get_key_pair_for_email("nonexistent@example.com"); | ||||
|         assert!(result.is_err()); | ||||
|          | ||||
|  | ||||
|         match result { | ||||
|             Err(AuthError::EmailNotFound(email)) => { | ||||
|                 assert_eq!(email, "nonexistent@example.com"); | ||||
| @@ -177,4 +182,4 @@ mod tests { | ||||
|         assert!(is_email_available("admin@circles.com")); | ||||
|         assert!(!is_email_available("nonexistent@example.com")); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| //! Authentication module for the Circles app | ||||
| //!  | ||||
| //! | ||||
| //! This module provides application-specific authentication functionality including: | ||||
| //! - Email-to-private-key mappings (hardcoded for app) | ||||
| //! - Authentication manager for coordinating auth flows | ||||
| //! - Integration with the client_ws library for WebSocket authentication | ||||
| //!  | ||||
| //! | ||||
| //! Core cryptographic functionality is provided by the client_ws library. | ||||
|  | ||||
| pub mod auth_manager; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| //! Application-specific authentication types | ||||
| //!  | ||||
| //! | ||||
| //! This module defines app-specific authentication types that extend | ||||
| //! the core types from the client_ws library. | ||||
|  | ||||
| @@ -45,7 +45,7 @@ impl From<circle_client_ws::auth::AuthError> for AuthError { | ||||
| /// Authentication method chosen by the user (app-specific) | ||||
| #[derive(Debug, Clone, PartialEq)] | ||||
| pub enum AuthMethod { | ||||
|     PrivateKey, // Direct private key input | ||||
|     PrivateKey,    // Direct private key input | ||||
|     Email(String), // Email-based lookup (app-specific) | ||||
| } | ||||
|  | ||||
| @@ -69,4 +69,4 @@ pub enum AuthState { | ||||
|         method: AuthMethod, | ||||
|     }, | ||||
|     Failed(String), // Error message | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::TocEntry; | ||||
| use crate::components::library_view::DisplayLibraryItem; | ||||
| use heromodels::models::library::items::TocEntry; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct AssetDetailsCardProps { | ||||
| @@ -22,7 +22,7 @@ impl Component for AssetDetailsCard { | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|  | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
| @@ -172,4 +172,4 @@ impl AssetDetailsCard { | ||||
|             </ul> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -4,8 +4,8 @@ use yew::prelude::*; | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct AuthViewProps { | ||||
|     pub auth_state: AuthState, | ||||
|     pub on_logout: Callback<()>,  | ||||
|     pub on_login: Callback<()>,  // New callback for login | ||||
|     pub on_logout: Callback<()>, | ||||
|     pub on_login: Callback<()>, // New callback for login | ||||
| } | ||||
|  | ||||
| #[function_component(AuthView)] | ||||
| @@ -19,7 +19,11 @@ pub fn auth_view(props: &AuthViewProps) -> Html { | ||||
|  | ||||
|             // Truncate the public key for display | ||||
|             let pk_short = if public_key.len() > 10 { | ||||
|                 format!("{}...{}", &public_key[..4], &public_key[public_key.len()-4..]) | ||||
|                 format!( | ||||
|                     "{}...{}", | ||||
|                     &public_key[..4], | ||||
|                     &public_key[public_key.len() - 4..] | ||||
|                 ) | ||||
|             } else { | ||||
|                 public_key.clone() | ||||
|             }; | ||||
| @@ -57,7 +61,7 @@ pub fn auth_view(props: &AuthViewProps) -> Html { | ||||
|             } | ||||
|         } | ||||
|         AuthState::Authenticating => { | ||||
|              html! { | ||||
|             html! { | ||||
|                 <div class="auth-info"> | ||||
|                     <span class="auth-status">{ "Authenticating..." }</span> | ||||
|                 </div> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::{Book, TocEntry}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct BookViewerProps { | ||||
| @@ -22,9 +22,7 @@ impl Component for BookViewer { | ||||
|     type Properties = BookViewerProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             current_page: 0, | ||||
|         } | ||||
|         Self { current_page: 0 } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
| @@ -52,14 +50,14 @@ impl Component for BookViewer { | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         let total_pages = props.book.pages.len(); | ||||
|          | ||||
|  | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|          | ||||
|  | ||||
|         let prev_handler = ctx.link().callback(|_: MouseEvent| BookViewerMsg::PrevPage); | ||||
|         let next_handler = ctx.link().callback(|_: MouseEvent| BookViewerMsg::NextPage); | ||||
|  | ||||
| @@ -120,7 +118,7 @@ impl BookViewer { | ||||
|             } else if line.starts_with("- ") { | ||||
|                 html_content.push(html! { <li>{ &line[2..] }</li> }); | ||||
|             } else if line.starts_with("**") && line.ends_with("**") { | ||||
|                 let text = &line[2..line.len()-2]; | ||||
|                 let text = &line[2..line.len() - 2]; | ||||
|                 html_content.push(html! { <p><strong>{ text }</strong></p> }); | ||||
|             } else if !line.trim().is_empty() { | ||||
|                 html_content.push(html! { <p>{ line }</p> }); | ||||
| @@ -152,4 +150,4 @@ impl BookViewer { | ||||
|             </ul> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use wasm_bindgen::JsCast; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen::JsCast; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub struct ChatMessage { | ||||
| @@ -107,7 +107,7 @@ impl Component for ChatInterface { | ||||
|             next_message_id: 0, | ||||
|             next_conversation_id: 1, | ||||
|         }; | ||||
|          | ||||
|  | ||||
|         // Create initial conversation if none exists | ||||
|         if chat_interface.active_conversation_id.is_none() { | ||||
|             chat_interface.create_new_conversation(); | ||||
| @@ -117,7 +117,7 @@ impl Component for ChatInterface { | ||||
|                 callback.emit(summaries); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         chat_interface | ||||
|     } | ||||
|  | ||||
| @@ -141,11 +141,15 @@ impl Component for ChatInterface { | ||||
|                     if self.active_conversation_id.is_none() { | ||||
|                         self.create_new_conversation(); | ||||
|                     } | ||||
|                      | ||||
|  | ||||
|                     let conversation_id = self.active_conversation_id.unwrap(); | ||||
|                      | ||||
|  | ||||
|                     // Add user message to active conversation | ||||
|                     let input_format = ctx.props().input_format.clone().unwrap_or_else(|| "text".to_string()); | ||||
|                     let input_format = ctx | ||||
|                         .props() | ||||
|                         .input_format | ||||
|                         .clone() | ||||
|                         .unwrap_or_else(|| "text".to_string()); | ||||
|                     let user_message = ChatMessage { | ||||
|                         id: self.next_message_id, | ||||
|                         content: self.current_input.clone(), | ||||
| @@ -157,11 +161,11 @@ impl Component for ChatInterface { | ||||
|                         format: input_format.clone(), | ||||
|                         source: None, | ||||
|                     }; | ||||
|                      | ||||
|  | ||||
|                     if let Some(conversation) = self.conversations.get_mut(&conversation_id) { | ||||
|                         conversation.messages.push(user_message); | ||||
|                         conversation.last_updated = chrono::Utc::now().to_rfc3339(); | ||||
|                          | ||||
|  | ||||
|                         // Update conversation title if it's the first message | ||||
|                         if conversation.messages.len() == 1 { | ||||
|                             let title = if self.current_input.len() > 50 { | ||||
| @@ -172,26 +176,30 @@ impl Component for ChatInterface { | ||||
|                             conversation.title = title; | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|  | ||||
|                     self.next_message_id += 1; | ||||
|  | ||||
|                     // Process message through callback with response handler | ||||
|                     let input_data = self.current_input.as_bytes().to_vec(); | ||||
|                      | ||||
|  | ||||
|                     // Create response callback that adds responses to chat | ||||
|                     let link = ctx.link().clone(); | ||||
|                     let response_callback = Callback::from(move |response: ChatResponse| { | ||||
|                         link.send_message(ChatMsg::AddResponse(response)); | ||||
|                     }); | ||||
|                      | ||||
|  | ||||
|                     // Trigger processing with response callback | ||||
|                     ctx.props().on_process_message.emit((input_data, input_format, response_callback)); | ||||
|                     ctx.props().on_process_message.emit(( | ||||
|                         input_data, | ||||
|                         input_format, | ||||
|                         response_callback, | ||||
|                     )); | ||||
|  | ||||
|                     // Clear inputs | ||||
|                     self.current_input.clear(); | ||||
|                     self.current_title = None; | ||||
|                     self.current_description = None; | ||||
|                      | ||||
|  | ||||
|                     // Notify parent of conversation updates | ||||
|                     self.notify_conversations_updated(ctx); | ||||
|                 } | ||||
| @@ -201,13 +209,13 @@ impl Component for ChatInterface { | ||||
|                 if let Some(conversation_id) = self.active_conversation_id { | ||||
|                     // Add response from async callback to active conversation | ||||
|                     let response_content = String::from_utf8_lossy(&response.data).to_string(); | ||||
|                      | ||||
|  | ||||
|                     // Use the format provided by the response to determine status | ||||
|                     let status = match response.format.as_str() { | ||||
|                         "error" => "Error".to_string(), | ||||
|                         _ => "Ok".to_string(), | ||||
|                     }; | ||||
|                      | ||||
|  | ||||
|                     let response_message = ChatMessage { | ||||
|                         id: self.next_message_id, | ||||
|                         content: response_content, | ||||
| @@ -219,14 +227,14 @@ impl Component for ChatInterface { | ||||
|                         format: response.format.clone(), | ||||
|                         source: Some(response.source.clone()), | ||||
|                     }; | ||||
|                      | ||||
|  | ||||
|                     if let Some(conversation) = self.conversations.get_mut(&conversation_id) { | ||||
|                         conversation.messages.push(response_message); | ||||
|                         conversation.last_updated = chrono::Utc::now().to_rfc3339(); | ||||
|                     } | ||||
|                      | ||||
|  | ||||
|                     self.next_message_id += 1; | ||||
|                      | ||||
|  | ||||
|                     // Notify parent of conversation updates | ||||
|                     self.notify_conversations_updated(ctx); | ||||
|                 } | ||||
| @@ -259,7 +267,7 @@ impl Component for ChatInterface { | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         let mut should_update = false; | ||||
|          | ||||
|  | ||||
|         // Handle external conversation selection | ||||
|         if let Some(new_active_id) = ctx.props().external_conversation_selection { | ||||
|             if old_props.external_conversation_selection != Some(new_active_id) { | ||||
| @@ -269,7 +277,7 @@ impl Component for ChatInterface { | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         // Handle external new conversation trigger | ||||
|         if let Some(trigger) = ctx.props().external_new_conversation_trigger { | ||||
|             if old_props.external_new_conversation_trigger != Some(trigger) && trigger { | ||||
| @@ -278,24 +286,25 @@ impl Component for ChatInterface { | ||||
|                 should_update = true; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         should_update | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|  | ||||
|         let on_input = { | ||||
|             let link = ctx.link().clone(); | ||||
|             Callback::from(move |e: InputEvent| { | ||||
|                 let target = e.target().unwrap(); | ||||
|                 let value = if let Ok(input) = target.clone().dyn_into::<web_sys::HtmlInputElement>() { | ||||
|                     input.value() | ||||
|                 } else if let Ok(textarea) = target.dyn_into::<web_sys::HtmlTextAreaElement>() { | ||||
|                     textarea.value() | ||||
|                 } else { | ||||
|                     String::new() | ||||
|                 }; | ||||
|                 let value = | ||||
|                     if let Ok(input) = target.clone().dyn_into::<web_sys::HtmlInputElement>() { | ||||
|                         input.value() | ||||
|                     } else if let Ok(textarea) = target.dyn_into::<web_sys::HtmlTextAreaElement>() { | ||||
|                         textarea.value() | ||||
|                     } else { | ||||
|                         String::new() | ||||
|                     }; | ||||
|                 link.send_message(ChatMsg::UpdateInput(value)); | ||||
|             }) | ||||
|         }; | ||||
| @@ -327,7 +336,8 @@ impl Component for ChatInterface { | ||||
|         // Get current conversation messages | ||||
|         let empty_messages = Vec::new(); | ||||
|         let current_messages = if let Some(conversation_id) = self.active_conversation_id { | ||||
|             self.conversations.get(&conversation_id) | ||||
|             self.conversations | ||||
|                 .get(&conversation_id) | ||||
|                 .map(|conv| &conv.messages) | ||||
|                 .unwrap_or(&empty_messages) | ||||
|         } else { | ||||
| @@ -336,7 +346,8 @@ impl Component for ChatInterface { | ||||
|  | ||||
|         // Get conversation title | ||||
|         let conversation_title = if let Some(conversation_id) = self.active_conversation_id { | ||||
|             self.conversations.get(&conversation_id) | ||||
|             self.conversations | ||||
|                 .get(&conversation_id) | ||||
|                 .map(|conv| conv.title.clone()) | ||||
|                 .or_else(|| props.conversation_title.clone()) | ||||
|         } else { | ||||
| @@ -437,31 +448,33 @@ impl ChatInterface { | ||||
|             created_at: now.clone(), | ||||
|             last_updated: now, | ||||
|         }; | ||||
|          | ||||
|         self.conversations.insert(self.next_conversation_id, conversation); | ||||
|  | ||||
|         self.conversations | ||||
|             .insert(self.next_conversation_id, conversation); | ||||
|         self.active_conversation_id = Some(self.next_conversation_id); | ||||
|         self.next_conversation_id += 1; | ||||
|     } | ||||
|      | ||||
|  | ||||
|     fn notify_conversations_updated(&self, ctx: &Context<Self>) { | ||||
|         if let Some(callback) = &ctx.props().on_conversations_updated { | ||||
|             let summaries = self.get_conversation_summaries(); | ||||
|             callback.emit(summaries); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     fn get_conversation_summaries(&self) -> Vec<ConversationSummary> { | ||||
|         let mut summaries: Vec<_> = self.conversations.values() | ||||
|         let mut summaries: Vec<_> = self | ||||
|             .conversations | ||||
|             .values() | ||||
|             .map(|conv| { | ||||
|                 let last_message_preview = conv.messages.last() | ||||
|                     .map(|msg| { | ||||
|                         if msg.content.len() > 50 { | ||||
|                             format!("{}...", &msg.content[..47]) | ||||
|                         } else { | ||||
|                             msg.content.clone() | ||||
|                         } | ||||
|                     }); | ||||
|                  | ||||
|                 let last_message_preview = conv.messages.last().map(|msg| { | ||||
|                     if msg.content.len() > 50 { | ||||
|                         format!("{}...", &msg.content[..47]) | ||||
|                     } else { | ||||
|                         msg.content.clone() | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 ConversationSummary { | ||||
|                     id: conv.id, | ||||
|                     title: conv.title.clone(), | ||||
| @@ -469,22 +482,22 @@ impl ChatInterface { | ||||
|                 } | ||||
|             }) | ||||
|             .collect(); | ||||
|          | ||||
|  | ||||
|         // Sort by last updated (most recent first) | ||||
|         summaries.sort_by(|a, b| { | ||||
|             let a_conv = self.conversations.get(&a.id).unwrap(); | ||||
|             let b_conv = self.conversations.get(&b.id).unwrap(); | ||||
|             b_conv.last_updated.cmp(&a_conv.last_updated) | ||||
|         }); | ||||
|          | ||||
|  | ||||
|         summaries | ||||
|     } | ||||
|      | ||||
|  | ||||
|     pub fn new_conversation(&mut self) -> u32 { | ||||
|         self.create_new_conversation(); | ||||
|         self.active_conversation_id.unwrap() | ||||
|     } | ||||
|      | ||||
|  | ||||
|     pub fn select_conversation(&mut self, conversation_id: u32) -> bool { | ||||
|         if self.conversations.contains_key(&conversation_id) { | ||||
|             self.active_conversation_id = Some(conversation_id); | ||||
| @@ -493,7 +506,7 @@ impl ChatInterface { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     pub fn get_conversations(&self) -> Vec<ConversationSummary> { | ||||
|         self.get_conversation_summaries() | ||||
|     } | ||||
| @@ -506,20 +519,22 @@ fn view_chat_message(msg: &ChatMessage) -> Html { | ||||
|         ChatSender::Assistant => "ai-message", | ||||
|         ChatSender::System => "system-message", | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     // Use source name for responses, fallback to default names | ||||
|     let sender_name = match msg.sender { | ||||
|         ChatSender::User => "You".to_string(), | ||||
|         ChatSender::Assistant => { | ||||
|             msg.source.as_ref().unwrap_or(&"Assistant".to_string()).clone() | ||||
|         }, | ||||
|         ChatSender::Assistant => msg | ||||
|             .source | ||||
|             .as_ref() | ||||
|             .unwrap_or(&"Assistant".to_string()) | ||||
|             .clone(), | ||||
|         ChatSender::System => "System".to_string(), | ||||
|     }; | ||||
|  | ||||
|     // Add format-specific classes | ||||
|     let mut message_classes = vec!["message".to_string(), sender_class.to_string()]; | ||||
|     message_classes.push(format!("format-{}", msg.format)); | ||||
|      | ||||
|  | ||||
|     // Add error class if it's an error message | ||||
|     if msg.status.as_ref().map_or(false, |s| s == "Error") { | ||||
|         message_classes.push("message-error".to_string()); | ||||
| @@ -577,13 +592,13 @@ fn render_message_content(content: &str, format: &str) -> Html { | ||||
|         }, | ||||
|         _ => html! { | ||||
|             <div class="message-text">{ content }</div> | ||||
|         } | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_code_with_line_numbers(content: &str, language: &str) -> Html { | ||||
|     let lines: Vec<&str> = content.lines().collect(); | ||||
|      | ||||
|  | ||||
|     html! { | ||||
|         <div class={format!("code-block language-{}", language)}> | ||||
|             <div class="code-header"> | ||||
| @@ -645,7 +660,7 @@ pub fn conversation_list(props: &ConversationListProps) -> Html { | ||||
|                             on_select_conversation.emit(conv_id); | ||||
|                         }) | ||||
|                     }; | ||||
|                      | ||||
|  | ||||
|                     html! { | ||||
|                         <li class={class_name} onclick={on_select} key={conv_id.to_string()}> | ||||
|                             <div class="conversation-title">{ &conv.title }</div> | ||||
| @@ -662,4 +677,4 @@ pub fn conversation_list(props: &ConversationListProps) -> Html { | ||||
|             </ul> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| use heromodels::models::circle::Circle; | ||||
| use yew::prelude::*; | ||||
| use yew::functional::Reducible; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::WheelEvent; | ||||
| use yew::functional::Reducible; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| use crate::ws_manager::fetch_data_from_ws_url; | ||||
|  | ||||
| @@ -47,12 +47,12 @@ pub struct CirclesView { | ||||
|     // Two primary dynamic states | ||||
|     center_circle: String, | ||||
|     is_selected: bool, | ||||
|      | ||||
|  | ||||
|     // Supporting state | ||||
|     circles: HashMap<String, Circle>, | ||||
|     navigation_stack: Vec<String>, | ||||
|     loading_states: HashMap<String, bool>, | ||||
|      | ||||
|  | ||||
|     // Rotation state for surrounding circles | ||||
|     rotation_value: i32, | ||||
| } | ||||
| @@ -64,9 +64,12 @@ impl Component for CirclesView { | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let props = ctx.props(); | ||||
|         let center_ws_url = props.default_center_ws_url.clone(); | ||||
|          | ||||
|         log::info!("CirclesView: Creating component with center circle: {}", center_ws_url); | ||||
|          | ||||
|  | ||||
|         log::info!( | ||||
|             "CirclesView: Creating component with center circle: {}", | ||||
|             center_ws_url | ||||
|         ); | ||||
|  | ||||
|         let mut component = Self { | ||||
|             center_circle: center_ws_url.clone(), | ||||
|             is_selected: false, | ||||
| @@ -86,39 +89,43 @@ impl Component for CirclesView { | ||||
|         match msg { | ||||
|             CirclesViewMsg::CenterCircleFetched(mut circle) => { | ||||
|                 log::info!("CirclesView: Center circle fetched: {}", circle.title); | ||||
|                  | ||||
|  | ||||
|                 // Ensure circle has correct ws_url | ||||
|                 if circle.ws_url.is_empty() { | ||||
|                     circle.ws_url = self.center_circle.clone(); | ||||
|                 } | ||||
|                  | ||||
|  | ||||
|                 // Store center circle | ||||
|                 self.circles.insert(circle.ws_url.clone(), circle.clone()); | ||||
|                  | ||||
|  | ||||
|                 // Start fetching surrounding circles progressively | ||||
|                 self.start_surrounding_circles_fetch(ctx, &circle); | ||||
|                  | ||||
|  | ||||
|                 // Update context immediately with center circle | ||||
|                 self.update_circles_context(ctx); | ||||
|                  | ||||
|  | ||||
|                 true | ||||
|             } | ||||
|             CirclesViewMsg::SurroundingCircleFetched(ws_url, result) => { | ||||
|                 log::debug!("CirclesView: Surrounding circle fetch result for {}: {:?}", ws_url, result.is_ok()); | ||||
|                  | ||||
|                 log::debug!( | ||||
|                     "CirclesView: Surrounding circle fetch result for {}: {:?}", | ||||
|                     ws_url, | ||||
|                     result.is_ok() | ||||
|                 ); | ||||
|  | ||||
|                 // Remove from loading states | ||||
|                 self.loading_states.remove(&ws_url); | ||||
|                  | ||||
|  | ||||
|                 match result { | ||||
|                     Ok(mut circle) => { | ||||
|                         // Ensure circle has correct ws_url | ||||
|                         if circle.ws_url.is_empty() { | ||||
|                             circle.ws_url = ws_url.clone(); | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         // Store the circle | ||||
|                         self.circles.insert(ws_url, circle); | ||||
|                          | ||||
|  | ||||
|                         // Update context with new circle available | ||||
|                         self.update_circles_context(ctx); | ||||
|                     } | ||||
| @@ -127,15 +134,11 @@ impl Component for CirclesView { | ||||
|                         // Continue without this circle - don't block the UI | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|  | ||||
|                 true | ||||
|             } | ||||
|             CirclesViewMsg::CircleClicked(ws_url) => { | ||||
|                 self.handle_circle_click(ctx, ws_url) | ||||
|             } | ||||
|             CirclesViewMsg::BackgroundClicked => { | ||||
|                 self.handle_background_click(ctx) | ||||
|             } | ||||
|             CirclesViewMsg::CircleClicked(ws_url) => self.handle_circle_click(ctx, ws_url), | ||||
|             CirclesViewMsg::BackgroundClicked => self.handle_background_click(ctx), | ||||
|             CirclesViewMsg::RotateCircles(delta) => { | ||||
|                 self.rotation_value += delta; | ||||
|                 log::debug!("CirclesView: Rotation updated to: {}", self.rotation_value); | ||||
| @@ -145,18 +148,24 @@ impl Component for CirclesView { | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         log::debug!("CirclesView: Rendering view. Center: {}, Circles loaded: {}, Selected: {}", | ||||
|                    self.center_circle, self.circles.len(), self.is_selected); | ||||
|          | ||||
|         log::debug!( | ||||
|             "CirclesView: Rendering view. Center: {}, Circles loaded: {}, Selected: {}", | ||||
|             self.center_circle, | ||||
|             self.circles.len(), | ||||
|             self.is_selected | ||||
|         ); | ||||
|  | ||||
|         let center_circle_data = self.circles.get(&self.center_circle); | ||||
|          | ||||
|  | ||||
|         // Get surrounding circles only if center is not selected | ||||
|         let surrounding_circles_data: Vec<&Circle> = if self.is_selected { | ||||
|             Vec::new() | ||||
|         } else { | ||||
|             // Get surrounding circles from center circle's circles field | ||||
|             if let Some(center_data) = center_circle_data { | ||||
|                 center_data.circles.iter() | ||||
|                 center_data | ||||
|                     .circles | ||||
|                     .iter() | ||||
|                     .filter_map(|ws_url| self.circles.get(ws_url)) | ||||
|                     .collect() | ||||
|             } else { | ||||
| @@ -165,8 +174,9 @@ impl Component for CirclesView { | ||||
|         }; | ||||
|  | ||||
|         let link = ctx.link(); | ||||
|         let on_background_click_handler = link.callback(|_: MouseEvent| CirclesViewMsg::BackgroundClicked); | ||||
|          | ||||
|         let on_background_click_handler = | ||||
|             link.callback(|_: MouseEvent| CirclesViewMsg::BackgroundClicked); | ||||
|  | ||||
|         // Add wheel event handler for rotation | ||||
|         let on_wheel_handler = { | ||||
|             let link = link.clone(); | ||||
| @@ -177,19 +187,25 @@ impl Component for CirclesView { | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         let petals_html: Vec<Html> = surrounding_circles_data.iter().enumerate().map(|(original_idx, circle_data)| { | ||||
|             // Calculate rotated position index based on rotation value | ||||
|             let total_circles = surrounding_circles_data.len(); | ||||
|             let rotation_steps = (self.rotation_value / 60) % total_circles as i32; // 60 degrees per step | ||||
|             let rotated_idx = ((original_idx as i32 + rotation_steps) % total_circles as i32 + total_circles as i32) % total_circles as i32; | ||||
|              | ||||
|             self.render_circle_element( | ||||
|                 circle_data, | ||||
|                 false, // is_center | ||||
|                 Some(rotated_idx as usize), // rotated position_index | ||||
|                 link, | ||||
|             ) | ||||
|         }).collect(); | ||||
|         let petals_html: Vec<Html> = surrounding_circles_data | ||||
|             .iter() | ||||
|             .enumerate() | ||||
|             .map(|(original_idx, circle_data)| { | ||||
|                 // Calculate rotated position index based on rotation value | ||||
|                 let total_circles = surrounding_circles_data.len(); | ||||
|                 let rotation_steps = (self.rotation_value / 60) % total_circles as i32; // 60 degrees per step | ||||
|                 let rotated_idx = ((original_idx as i32 + rotation_steps) % total_circles as i32 | ||||
|                     + total_circles as i32) | ||||
|                     % total_circles as i32; | ||||
|  | ||||
|                 self.render_circle_element( | ||||
|                     circle_data, | ||||
|                     false,                      // is_center | ||||
|                     Some(rotated_idx as usize), // rotated position_index | ||||
|                     link, | ||||
|                 ) | ||||
|             }) | ||||
|             .collect(); | ||||
|  | ||||
|         html! { | ||||
|             <div class="circles-view" | ||||
| @@ -218,48 +234,59 @@ impl CirclesView { | ||||
|     /// Fetch center circle data | ||||
|     fn fetch_center_circle(&mut self, ctx: &Context<Self>, ws_url: &str) { | ||||
|         log::debug!("CirclesView: Fetching center circle from {}", ws_url); | ||||
|          | ||||
|  | ||||
|         let link = ctx.link().clone(); | ||||
|         let ws_url_clone = ws_url.to_string(); | ||||
|          | ||||
|  | ||||
|         spawn_local(async move { | ||||
|             match fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await { | ||||
|                 Ok(circle) => { | ||||
|                     link.send_message(CirclesViewMsg::CenterCircleFetched(circle)); | ||||
|                 } | ||||
|                 Err(error) => { | ||||
|                     log::error!("CirclesView: Failed to fetch center circle from {}: {}", ws_url_clone, error); | ||||
|                     log::error!( | ||||
|                         "CirclesView: Failed to fetch center circle from {}: {}", | ||||
|                         ws_url_clone, | ||||
|                         error | ||||
|                     ); | ||||
|                     // Could emit an error message here if needed | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Start progressive fetching of surrounding circles | ||||
|     fn start_surrounding_circles_fetch(&mut self, ctx: &Context<Self>, center_circle: &Circle) { | ||||
|         log::info!("CirclesView: Starting progressive fetch of {} surrounding circles", center_circle.circles.len()); | ||||
|          | ||||
|         log::info!( | ||||
|             "CirclesView: Starting progressive fetch of {} surrounding circles", | ||||
|             center_circle.circles.len() | ||||
|         ); | ||||
|  | ||||
|         for surrounding_ws_url in ¢er_circle.circles { | ||||
|             self.fetch_surrounding_circle(ctx, surrounding_ws_url); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Fetch individual surrounding circle | ||||
|     fn fetch_surrounding_circle(&mut self, ctx: &Context<Self>, ws_url: &str) { | ||||
|         log::debug!("CirclesView: Fetching surrounding circle from {}", ws_url); | ||||
|          | ||||
|  | ||||
|         // Mark as loading | ||||
|         self.loading_states.insert(ws_url.to_string(), true); | ||||
|          | ||||
|  | ||||
|         let link = ctx.link().clone(); | ||||
|         let ws_url_clone = ws_url.to_string(); | ||||
|          | ||||
|  | ||||
|         spawn_local(async move { | ||||
|             let result = fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await; | ||||
|             link.send_message(CirclesViewMsg::SurroundingCircleFetched(ws_url_clone, result)); | ||||
|             let result = | ||||
|                 fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await; | ||||
|             link.send_message(CirclesViewMsg::SurroundingCircleFetched( | ||||
|                 ws_url_clone, | ||||
|                 result, | ||||
|             )); | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Update circles context and notify parent | ||||
|     fn update_circles_context(&self, ctx: &Context<Self>) { | ||||
|         let context_urls = if self.is_selected { | ||||
| @@ -268,7 +295,7 @@ impl CirclesView { | ||||
|         } else { | ||||
|             // When unselected, context includes center + available surrounding circles | ||||
|             let mut urls = vec![self.center_circle.clone()]; | ||||
|              | ||||
|  | ||||
|             if let Some(center_circle) = self.circles.get(&self.center_circle) { | ||||
|                 // Add surrounding circles that are already loaded | ||||
|                 for surrounding_url in ¢er_circle.circles { | ||||
| @@ -277,36 +304,42 @@ impl CirclesView { | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|  | ||||
|             urls | ||||
|         }; | ||||
|          | ||||
|         log::debug!("CirclesView: Updating context with {} URLs", context_urls.len()); | ||||
|  | ||||
|         log::debug!( | ||||
|             "CirclesView: Updating context with {} URLs", | ||||
|             context_urls.len() | ||||
|         ); | ||||
|         ctx.props().on_context_update.emit(context_urls); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Handle circle click logic | ||||
|     fn handle_circle_click(&mut self, ctx: &Context<Self>, ws_url: String) -> bool { | ||||
|         log::debug!("CirclesView: Circle clicked: {}", ws_url); | ||||
|          | ||||
|  | ||||
|         if ws_url == self.center_circle { | ||||
|             // Center circle clicked - toggle selection | ||||
|             self.is_selected = !self.is_selected; | ||||
|             log::info!("CirclesView: Center circle toggled, selected: {}", self.is_selected); | ||||
|             log::info!( | ||||
|                 "CirclesView: Center circle toggled, selected: {}", | ||||
|                 self.is_selected | ||||
|             ); | ||||
|         } else { | ||||
|             // Surrounding circle clicked - make it the new center | ||||
|             log::info!("CirclesView: Setting new center circle: {}", ws_url); | ||||
|              | ||||
|  | ||||
|             // Push current center to navigation stack BEFORE changing it | ||||
|             self.push_to_navigation_stack(self.center_circle.clone()); | ||||
|              | ||||
|  | ||||
|             // Set new center and unselect | ||||
|             self.center_circle = ws_url.clone(); | ||||
|             self.is_selected = false; | ||||
|              | ||||
|  | ||||
|             // Now push the new center to the stack as well | ||||
|             self.push_to_navigation_stack(self.center_circle.clone()); | ||||
|              | ||||
|  | ||||
|             // Fetch new center circle if not already loaded | ||||
|             if !self.circles.contains_key(&ws_url) { | ||||
|                 self.fetch_center_circle(ctx, &ws_url); | ||||
| @@ -317,18 +350,21 @@ impl CirclesView { | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         // Update context | ||||
|         self.update_circles_context(ctx); | ||||
|          | ||||
|  | ||||
|         true | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Handle background click logic | ||||
|     fn handle_background_click(&mut self, ctx: &Context<Self>) -> bool { | ||||
|         log::debug!("CirclesView: Background clicked, selected: {}, stack size: {}", | ||||
|                    self.is_selected, self.navigation_stack.len()); | ||||
|          | ||||
|         log::debug!( | ||||
|             "CirclesView: Background clicked, selected: {}, stack size: {}", | ||||
|             self.is_selected, | ||||
|             self.navigation_stack.len() | ||||
|         ); | ||||
|  | ||||
|         if self.is_selected { | ||||
|             // If selected, unselect | ||||
|             self.is_selected = false; | ||||
| @@ -336,11 +372,14 @@ impl CirclesView { | ||||
|         } else { | ||||
|             // If unselected, navigate back in stack | ||||
|             if let Some(previous_center) = self.pop_from_navigation_stack() { | ||||
|                 log::info!("CirclesView: Background click - navigating back to: {}", previous_center); | ||||
|                  | ||||
|                 log::info!( | ||||
|                     "CirclesView: Background click - navigating back to: {}", | ||||
|                     previous_center | ||||
|                 ); | ||||
|  | ||||
|                 self.center_circle = previous_center.clone(); | ||||
|                 self.is_selected = false; | ||||
|                  | ||||
|  | ||||
|                 // Fetch previous center if not loaded | ||||
|                 if !self.circles.contains_key(&previous_center) { | ||||
|                     self.fetch_center_circle(ctx, &previous_center); | ||||
| @@ -355,37 +394,52 @@ impl CirclesView { | ||||
|                 return false; // No change | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         // Update context | ||||
|         self.update_circles_context(ctx); | ||||
|          | ||||
|  | ||||
|         true | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Push circle to navigation stack | ||||
|     fn push_to_navigation_stack(&mut self, ws_url: String) { | ||||
|         // Only push if it's different from the current top | ||||
|         if self.navigation_stack.last() != Some(&ws_url) { | ||||
|             self.navigation_stack.push(ws_url.clone()); | ||||
|             log::debug!("CirclesView: Pushed {} to navigation stack: {:?}", ws_url, self.navigation_stack); | ||||
|             log::debug!( | ||||
|                 "CirclesView: Pushed {} to navigation stack: {:?}", | ||||
|                 ws_url, | ||||
|                 self.navigation_stack | ||||
|             ); | ||||
|         } else { | ||||
|             log::debug!("CirclesView: Not pushing {} - already at top of stack", ws_url); | ||||
|             log::debug!( | ||||
|                 "CirclesView: Not pushing {} - already at top of stack", | ||||
|                 ws_url | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Pop circle from navigation stack and return the previous one | ||||
|     fn pop_from_navigation_stack(&mut self) -> Option<String> { | ||||
|         if self.navigation_stack.len() > 1 { | ||||
|             // Remove current center from stack | ||||
|             let popped = self.navigation_stack.pop(); | ||||
|             log::debug!("CirclesView: Popped {:?} from navigation stack", popped); | ||||
|              | ||||
|  | ||||
|             // Return the previous center (now at the top of stack) | ||||
|             let previous = self.navigation_stack.last().cloned(); | ||||
|             log::debug!("CirclesView: Navigation stack after pop: {:?}, returning: {:?}", self.navigation_stack, previous); | ||||
|             log::debug!( | ||||
|                 "CirclesView: Navigation stack after pop: {:?}, returning: {:?}", | ||||
|                 self.navigation_stack, | ||||
|                 previous | ||||
|             ); | ||||
|             previous | ||||
|         } else { | ||||
|             log::debug!("CirclesView: Cannot navigate back - stack size: {}, stack: {:?}", self.navigation_stack.len(), self.navigation_stack); | ||||
|             log::debug!( | ||||
|                 "CirclesView: Cannot navigate back - stack size: {}, stack: {:?}", | ||||
|                 self.navigation_stack.len(), | ||||
|                 self.navigation_stack | ||||
|             ); | ||||
|             None | ||||
|         } | ||||
|     } | ||||
| @@ -398,7 +452,7 @@ impl CirclesView { | ||||
|     ) -> Html { | ||||
|         let ws_url = circle.ws_url.clone(); | ||||
|         let show_description = is_center && self.is_selected; | ||||
|          | ||||
|  | ||||
|         let on_click_handler = { | ||||
|             let ws_url_clone = ws_url.clone(); | ||||
|             link.callback(move |e: MouseEvent| { | ||||
| @@ -422,7 +476,7 @@ impl CirclesView { | ||||
|             } | ||||
|         } | ||||
|         let class_name = class_name_parts.join(" "); | ||||
|          | ||||
|  | ||||
|         let size = if is_center { | ||||
|             if show_description { | ||||
|                 "400px" // Center circle, selected (description shown) | ||||
|   | ||||
| @@ -1,20 +1,19 @@ | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::circle::Circle; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use web_sys::InputEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| // Import from common_models | ||||
| // Assuming AppMsg is used for updates. This might need to be specific to theme updates. | ||||
| use crate::app::Msg as AppMsg; | ||||
|  | ||||
|  | ||||
| // --- Enum for Setting Control Types (can be kept local or moved if shared) --- | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum ThemeSettingControlType { | ||||
|     ColorSelection(Vec<String>), // List of color hex values | ||||
|     ColorSelection(Vec<String>),   // List of color hex values | ||||
|     PatternSelection(Vec<String>), // List of pattern names/classes | ||||
|     LogoSelection(Vec<String>), // List of predefined logo symbols or image URLs | ||||
|     LogoSelection(Vec<String>),    // List of predefined logo symbols or image URLs | ||||
|     Toggle, | ||||
|     TextInput, // For URL input or custom text | ||||
| } | ||||
| @@ -48,11 +47,21 @@ fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> { | ||||
|             label: "Primary Color".to_string(), | ||||
|             description: "Main accent color for the interface.".to_string(), | ||||
|             control_type: ThemeSettingControlType::ColorSelection(vec![ | ||||
|                 "#3b82f6".to_string(), "#ef4444".to_string(), "#10b981".to_string(), | ||||
|                 "#f59e0b".to_string(), "#8b5cf6".to_string(), "#06b6d4".to_string(), | ||||
|                 "#ec4899".to_string(), "#84cc16".to_string(), "#f97316".to_string(), | ||||
|                 "#6366f1".to_string(), "#14b8a6".to_string(), "#f43f5e".to_string(), | ||||
|                 "#ffffff".to_string(), "#cbd5e1".to_string(), "#64748b".to_string(), | ||||
|                 "#3b82f6".to_string(), | ||||
|                 "#ef4444".to_string(), | ||||
|                 "#10b981".to_string(), | ||||
|                 "#f59e0b".to_string(), | ||||
|                 "#8b5cf6".to_string(), | ||||
|                 "#06b6d4".to_string(), | ||||
|                 "#ec4899".to_string(), | ||||
|                 "#84cc16".to_string(), | ||||
|                 "#f97316".to_string(), | ||||
|                 "#6366f1".to_string(), | ||||
|                 "#14b8a6".to_string(), | ||||
|                 "#f43f5e".to_string(), | ||||
|                 "#ffffff".to_string(), | ||||
|                 "#cbd5e1".to_string(), | ||||
|                 "#64748b".to_string(), | ||||
|             ]), | ||||
|             default_value: "#3b82f6".to_string(), | ||||
|         }, | ||||
| @@ -61,9 +70,16 @@ fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> { | ||||
|             label: "Background Color".to_string(), | ||||
|             description: "Overall background color.".to_string(), | ||||
|             control_type: ThemeSettingControlType::ColorSelection(vec![ | ||||
|                 "#000000".to_string(), "#0a0a0a".to_string(), "#121212".to_string(), "#18181b".to_string(),  | ||||
|                 "#1f2937".to_string(), "#374151".to_string(), "#4b5563".to_string(), | ||||
|                 "#f9fafb".to_string(), "#f3f4f6".to_string(), "#e5e7eb".to_string(), | ||||
|                 "#000000".to_string(), | ||||
|                 "#0a0a0a".to_string(), | ||||
|                 "#121212".to_string(), | ||||
|                 "#18181b".to_string(), | ||||
|                 "#1f2937".to_string(), | ||||
|                 "#374151".to_string(), | ||||
|                 "#4b5563".to_string(), | ||||
|                 "#f9fafb".to_string(), | ||||
|                 "#f3f4f6".to_string(), | ||||
|                 "#e5e7eb".to_string(), | ||||
|             ]), | ||||
|             default_value: "#0a0a0a".to_string(), | ||||
|         }, | ||||
| @@ -72,8 +88,12 @@ fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> { | ||||
|             label: "Background Pattern".to_string(), | ||||
|             description: "Subtle pattern for the background.".to_string(), | ||||
|             control_type: ThemeSettingControlType::PatternSelection(vec![ | ||||
|                 "none".to_string(), "dots".to_string(), "grid".to_string(), | ||||
|                 "diagonal".to_string(), "waves".to_string(), "mesh".to_string(), | ||||
|                 "none".to_string(), | ||||
|                 "dots".to_string(), | ||||
|                 "grid".to_string(), | ||||
|                 "diagonal".to_string(), | ||||
|                 "waves".to_string(), | ||||
|                 "mesh".to_string(), | ||||
|             ]), | ||||
|             default_value: "none".to_string(), | ||||
|         }, | ||||
| @@ -82,9 +102,18 @@ fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> { | ||||
|             label: "Circle Logo/Symbol".to_string(), | ||||
|             description: "Select a symbol or provide a URL below.".to_string(), | ||||
|             control_type: ThemeSettingControlType::LogoSelection(vec![ | ||||
|                 "◯".to_string(), "◆".to_string(), "★".to_string(), "▲".to_string(),  | ||||
|                 "●".to_string(), "■".to_string(), "🌍".to_string(), "🚀".to_string(),  | ||||
|                 "💎".to_string(), "🔥".to_string(), "⚡".to_string(), "🎯".to_string(), | ||||
|                 "◯".to_string(), | ||||
|                 "◆".to_string(), | ||||
|                 "★".to_string(), | ||||
|                 "▲".to_string(), | ||||
|                 "●".to_string(), | ||||
|                 "■".to_string(), | ||||
|                 "🌍".to_string(), | ||||
|                 "🚀".to_string(), | ||||
|                 "💎".to_string(), | ||||
|                 "🔥".to_string(), | ||||
|                 "⚡".to_string(), | ||||
|                 "🎯".to_string(), | ||||
|                 "custom_url".to_string(), // Represents using the URL input | ||||
|             ]), | ||||
|             default_value: "◯".to_string(), | ||||
| @@ -114,16 +143,18 @@ fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> { | ||||
|     ] | ||||
| } | ||||
|  | ||||
|  | ||||
| #[function_component(CustomizeViewComponent)] | ||||
| pub fn customize_view_component(props: &CustomizeViewProps) -> Html { | ||||
|     let theme_definitions = get_theme_setting_definitions(); | ||||
|  | ||||
|     // Determine the active circle for customization | ||||
|     let active_circle_ws_url: Option<String> = props.context_circle_ws_urls.as_ref() | ||||
|     let active_circle_ws_url: Option<String> = props | ||||
|         .context_circle_ws_urls | ||||
|         .as_ref() | ||||
|         .and_then(|ws_urls| ws_urls.first().cloned()); | ||||
|  | ||||
|     let active_circle_theme: Option<HashMap<String, String>> = active_circle_ws_url.as_ref() | ||||
|     let active_circle_theme: Option<HashMap<String, String>> = active_circle_ws_url | ||||
|         .as_ref() | ||||
|         .and_then(|ws_url| props.all_circles.get(ws_url)) | ||||
|         // TODO: Re-implement theme handling. The canonical Circle struct does not have a direct 'theme' field. | ||||
|         // .map(|circle_data| circle_data.theme.clone()); | ||||
| @@ -147,7 +178,7 @@ pub fn customize_view_component(props: &CustomizeViewProps) -> Html { | ||||
|                             let current_value = active_circle_theme.as_ref() | ||||
|                                 .and_then(|theme| theme.get(&setting_def.key).cloned()) | ||||
|                                 .unwrap_or_else(|| setting_def.default_value.clone()); | ||||
|                              | ||||
|  | ||||
|                             render_setting_control( | ||||
|                                 setting_def.clone(), | ||||
|                                 current_value, | ||||
| @@ -171,7 +202,7 @@ fn render_setting_control( | ||||
|     app_callback: Callback<AppMsg>, | ||||
| ) -> Html { | ||||
|     let setting_key = setting_def.key.clone(); | ||||
|      | ||||
|  | ||||
|     let on_value_change = { | ||||
|         let circle_ws_url_clone = circle_ws_url.clone(); | ||||
|         let setting_key_clone = setting_key.clone(); | ||||
| @@ -211,7 +242,7 @@ fn render_setting_control( | ||||
|                     })} | ||||
|                 </div> | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|         ThemeSettingControlType::PatternSelection(ref patterns) => { | ||||
|             let on_select = on_value_change.clone(); | ||||
|             html! { | ||||
| @@ -234,7 +265,7 @@ fn render_setting_control( | ||||
|                     })} | ||||
|                 </div> | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|         ThemeSettingControlType::LogoSelection(ref logos) => { | ||||
|             let on_select = on_value_change.clone(); | ||||
|             html! { | ||||
| @@ -258,14 +289,18 @@ fn render_setting_control( | ||||
|                     })} | ||||
|                 </div> | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|         ThemeSettingControlType::Toggle => { | ||||
|             let checked = current_value.to_lowercase() == "true"; | ||||
|             let on_toggle = { | ||||
|                 let on_value_change = on_value_change.clone(); | ||||
|                 Callback::from(move |e: Event| { | ||||
|                     let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|                     on_value_change.emit(if input.checked() { "true".to_string() } else { "false".to_string() }); | ||||
|                     on_value_change.emit(if input.checked() { | ||||
|                         "true".to_string() | ||||
|                     } else { | ||||
|                         "false".to_string() | ||||
|                     }); | ||||
|                 }) | ||||
|             }; | ||||
|             html! { | ||||
| @@ -274,7 +309,7 @@ fn render_setting_control( | ||||
|                     <span class="setting-toggle-slider"></span> | ||||
|                 </label> | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|         ThemeSettingControlType::TextInput => { | ||||
|             let on_input = { | ||||
|                 let on_value_change = on_value_change.clone(); | ||||
| @@ -292,7 +327,7 @@ fn render_setting_control( | ||||
|                     oninput={on_input} | ||||
|                 /> | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Image; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct ImageViewerProps { | ||||
| @@ -19,7 +19,7 @@ impl Component for ImageViewer { | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|  | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
| @@ -45,4 +45,4 @@ impl Component for ImageViewer { | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use crate::components::chat::{ChatInterface, ConversationList, ConversationSummary, InputType, ChatResponse}; | ||||
| use crate::components::chat::{ | ||||
|     ChatInterface, ChatResponse, ConversationList, ConversationSummary, InputType, | ||||
| }; | ||||
| use crate::rhai_executor::execute_rhai_script_remote; | ||||
| use crate::ws_manager::fetch_data_from_ws_url; | ||||
| use heromodels::models::circle::Circle; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorInteractTabProps { | ||||
| @@ -28,21 +30,23 @@ pub struct CircleInfo { | ||||
| #[function_component(InspectorInteractTab)] | ||||
| pub fn inspector_interact_tab(props: &InspectorInteractTabProps) -> Html { | ||||
|     let circle_names = use_state(|| HashMap::<String, String>::new()); | ||||
|      | ||||
|  | ||||
|     // Fetch circle names when component mounts or addresses change | ||||
|     { | ||||
|         let circle_names = circle_names.clone(); | ||||
|         let ws_addresses = props.circle_ws_addresses.clone(); | ||||
|          | ||||
|  | ||||
|         use_effect_with(ws_addresses.clone(), move |addresses| { | ||||
|             let circle_names = circle_names.clone(); | ||||
|              | ||||
|  | ||||
|             for ws_url in addresses.iter() { | ||||
|                 let ws_url_clone = ws_url.clone(); | ||||
|                 let circle_names_clone = circle_names.clone(); | ||||
|                  | ||||
|  | ||||
|                 spawn_local(async move { | ||||
|                     match fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await { | ||||
|                     match fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()") | ||||
|                         .await | ||||
|                     { | ||||
|                         Ok(circle) => { | ||||
|                             let mut names = (*circle_names_clone).clone(); | ||||
|                             names.insert(ws_url_clone, circle.title); | ||||
| @@ -51,13 +55,14 @@ pub fn inspector_interact_tab(props: &InspectorInteractTabProps) -> Html { | ||||
|                         Err(_) => { | ||||
|                             // If we can't fetch the circle name, use a fallback | ||||
|                             let mut names = (*circle_names_clone).clone(); | ||||
|                             names.insert(ws_url_clone.clone(), format!("Circle ({})", ws_url_clone)); | ||||
|                             names | ||||
|                                 .insert(ws_url_clone.clone(), format!("Circle ({})", ws_url_clone)); | ||||
|                             circle_names_clone.set(names); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|              | ||||
|  | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
| @@ -65,41 +70,48 @@ pub fn inspector_interact_tab(props: &InspectorInteractTabProps) -> Html { | ||||
|     let on_process_message = { | ||||
|         let ws_urls = props.circle_ws_addresses.clone(); | ||||
|         let circle_names = circle_names.clone(); | ||||
|          | ||||
|         Callback::from(move |(data, format, response_callback): (Vec<u8>, String, Callback<ChatResponse>)| { | ||||
|             // Convert bytes to string for processing | ||||
|             let script_content = String::from_utf8_lossy(&data).to_string(); | ||||
|             let urls = ws_urls.clone(); | ||||
|             let names = (*circle_names).clone(); | ||||
|              | ||||
|             // Remote execution - async responses | ||||
|             for ws_url in urls.iter() { | ||||
|                 let script_clone = script_content.clone(); | ||||
|                 let url_clone = ws_url.clone(); | ||||
|                 let circle_name = names.get(ws_url).cloned().unwrap_or_else(|| format!("Circle ({})", ws_url)); | ||||
|                 let format_clone = format.clone(); | ||||
|                 let response_callback_clone = response_callback.clone(); | ||||
|                  | ||||
|                 spawn_local(async move { | ||||
|                     let response = execute_rhai_script_remote(&script_clone, &url_clone, &circle_name).await; | ||||
|                     let status = if response.success { "✅" } else { "❌" }; | ||||
|                      | ||||
|                     // Set format based on execution success | ||||
|                     let response_format = if response.success { | ||||
|                         format_clone | ||||
|                     } else { | ||||
|                         "error".to_string() | ||||
|                     }; | ||||
|                      | ||||
|                     let chat_response = ChatResponse { | ||||
|                         data: format!("{} {}", status, response.output).into_bytes(), | ||||
|                         format: response_format, | ||||
|                         source: response.source, | ||||
|                     }; | ||||
|                     response_callback_clone.emit(chat_response); | ||||
|                 }); | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         Callback::from( | ||||
|             move |(data, format, response_callback): (Vec<u8>, String, Callback<ChatResponse>)| { | ||||
|                 // Convert bytes to string for processing | ||||
|                 let script_content = String::from_utf8_lossy(&data).to_string(); | ||||
|                 let urls = ws_urls.clone(); | ||||
|                 let names = (*circle_names).clone(); | ||||
|  | ||||
|                 // Remote execution - async responses | ||||
|                 for ws_url in urls.iter() { | ||||
|                     let script_clone = script_content.clone(); | ||||
|                     let url_clone = ws_url.clone(); | ||||
|                     let circle_name = names | ||||
|                         .get(ws_url) | ||||
|                         .cloned() | ||||
|                         .unwrap_or_else(|| format!("Circle ({})", ws_url)); | ||||
|                     let format_clone = format.clone(); | ||||
|                     let response_callback_clone = response_callback.clone(); | ||||
|  | ||||
|                     spawn_local(async move { | ||||
|                         let response = | ||||
|                             execute_rhai_script_remote(&script_clone, &url_clone, &circle_name) | ||||
|                                 .await; | ||||
|                         let status = if response.success { "✅" } else { "❌" }; | ||||
|  | ||||
|                         // Set format based on execution success | ||||
|                         let response_format = if response.success { | ||||
|                             format_clone | ||||
|                         } else { | ||||
|                             "error".to_string() | ||||
|                         }; | ||||
|  | ||||
|                         let chat_response = ChatResponse { | ||||
|                             data: format!("{} {}", status, response.output).into_bytes(), | ||||
|                             format: response_format, | ||||
|                             source: response.source, | ||||
|                         }; | ||||
|                         response_callback_clone.emit(chat_response); | ||||
|                     }); | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
| @@ -138,4 +150,4 @@ pub fn inspector_interact_sidebar(props: &InspectorInteractSidebarProps) -> Html | ||||
|             title={"Chat History".to_string()} | ||||
|         /> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorLogsTabProps { | ||||
| @@ -28,7 +28,10 @@ pub fn inspector_logs_tab(props: &InspectorLogsTabProps) -> Html { | ||||
|                 timestamp: "17:05:25".to_string(), | ||||
|                 level: "INFO".to_string(), | ||||
|                 source: "network".to_string(), | ||||
|                 message: format!("Monitoring {} circle connections", props.circle_ws_addresses.len()), | ||||
|                 message: format!( | ||||
|                     "Monitoring {} circle connections", | ||||
|                     props.circle_ws_addresses.len() | ||||
|                 ), | ||||
|             }, | ||||
|             LogEntry { | ||||
|                 timestamp: "17:05:26".to_string(), | ||||
| @@ -59,7 +62,7 @@ pub fn inspector_logs_tab(props: &InspectorLogsTabProps) -> Html { | ||||
|                         <span class={classes!("stat-value", if warn_count > 0 { "stat-warn" } else { "" })}>{warn_count}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <div class="logs-container"> | ||||
|                     { for logs.iter().rev().map(|log| { | ||||
|                         let level_class = match log.level.as_str() { | ||||
| @@ -68,7 +71,7 @@ pub fn inspector_logs_tab(props: &InspectorLogsTabProps) -> Html { | ||||
|                             "INFO" => "log-info", | ||||
|                             _ => "log-debug", | ||||
|                         }; | ||||
|                          | ||||
|  | ||||
|                         html! { | ||||
|                             <div class={classes!("log-entry", level_class)}> | ||||
|                                 <span class="log-time">{&log.timestamp}</span> | ||||
| @@ -82,4 +85,4 @@ pub fn inspector_logs_tab(props: &InspectorLogsTabProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use crate::components::world_map_svg::render_world_map_svg; | ||||
| use crate::components::network_animation_view::NetworkAnimationView; | ||||
| use crate::components::world_map_svg::render_world_map_svg; | ||||
| use common_models::CircleData; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorNetworkTabProps { | ||||
| @@ -25,30 +25,33 @@ pub fn inspector_network_tab(props: &InspectorNetworkTabProps) -> Html { | ||||
|     // Create circle data for the map animation | ||||
|     let circles_data = use_memo(props.circle_ws_addresses.clone(), |addresses| { | ||||
|         let mut circles = HashMap::new(); | ||||
|          | ||||
|  | ||||
|         for (index, ws_url) in addresses.iter().enumerate() { | ||||
|             circles.insert(index as u32 + 1, CircleData { | ||||
|                 id: index as u32 + 1, | ||||
|                 name: format!("Circle {}", index + 1), | ||||
|                 description: format!("Circle at {}", ws_url), | ||||
|                 ws_url: ws_url.clone(), | ||||
|                 ws_urls: vec![], | ||||
|                 theme: HashMap::new(), | ||||
|                 tasks: None, | ||||
|                 epics: None, | ||||
|                 sprints: None, | ||||
|                 proposals: None, | ||||
|                 members: None, | ||||
|                 library: None, | ||||
|                 intelligence: None, | ||||
|                 timeline: None, | ||||
|                 calendar_events: None, | ||||
|                 treasury: None, | ||||
|                 publications: None, | ||||
|                 deployments: None, | ||||
|             }); | ||||
|             circles.insert( | ||||
|                 index as u32 + 1, | ||||
|                 CircleData { | ||||
|                     id: index as u32 + 1, | ||||
|                     name: format!("Circle {}", index + 1), | ||||
|                     description: format!("Circle at {}", ws_url), | ||||
|                     ws_url: ws_url.clone(), | ||||
|                     ws_urls: vec![], | ||||
|                     theme: HashMap::new(), | ||||
|                     tasks: None, | ||||
|                     epics: None, | ||||
|                     sprints: None, | ||||
|                     proposals: None, | ||||
|                     members: None, | ||||
|                     library: None, | ||||
|                     intelligence: None, | ||||
|                     timeline: None, | ||||
|                     calendar_events: None, | ||||
|                     treasury: None, | ||||
|                     publications: None, | ||||
|                     deployments: None, | ||||
|                 }, | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         Rc::new(circles) | ||||
|     }); | ||||
|  | ||||
| @@ -117,7 +120,7 @@ pub fn inspector_network_tab(props: &InspectorNetworkTabProps) -> Html { | ||||
|                     { for traffic_entries.iter().map(|entry| { | ||||
|                         let direction_class = if entry.direction == "Sent" { "traffic-sent" } else { "traffic-received" }; | ||||
|                         let status_class = if entry.status == "Success" { "traffic-success" } else { "traffic-error" }; | ||||
|                          | ||||
|  | ||||
|                         html! { | ||||
|                             <div class="traffic-row"> | ||||
|                                 <div class="traffic-col traffic-time">{&entry.timestamp}</div> | ||||
| @@ -133,4 +136,4 @@ pub fn inspector_network_tab(props: &InspectorNetworkTabProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use crate::components::chat::{ConversationSummary}; | ||||
| use crate::components::sidebar_layout::SidebarLayout; | ||||
| use crate::components::inspector_network_tab::InspectorNetworkTab; | ||||
| use crate::components::inspector_logs_tab::InspectorLogsTab; | ||||
| use crate::auth::AuthManager; | ||||
| use crate::components::chat::ConversationSummary; | ||||
| use crate::components::inspector_auth_tab::InspectorAuthTab; | ||||
| use crate::components::inspector_interact_tab::{InspectorInteractTab, InspectorInteractSidebar}; | ||||
| use crate::components::inspector_interact_tab::{InspectorInteractSidebar, InspectorInteractTab}; | ||||
| use crate::components::inspector_logs_tab::InspectorLogsTab; | ||||
| use crate::components::inspector_network_tab::InspectorNetworkTab; | ||||
| use crate::components::sidebar_layout::SidebarLayout; | ||||
| use std::rc::Rc; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorViewProps { | ||||
| @@ -119,7 +119,7 @@ impl Component for InspectorView { | ||||
|             } | ||||
|             InspectorViewState::Tab(tab) => { | ||||
|                 let on_background_click = ctx.link().callback(|_| Msg::BackToOverview); | ||||
|                  | ||||
|  | ||||
|                 let main_content = match tab { | ||||
|                     InspectorTab::Network => html! { | ||||
|                         <InspectorNetworkTab circle_ws_addresses={ctx.props().circle_ws_addresses.clone()} /> | ||||
| @@ -143,9 +143,9 @@ impl Component for InspectorView { | ||||
|                                 on_new_conversation={on_new_conv} | ||||
|                             /> | ||||
|                         } | ||||
|                     }, | ||||
|                     } | ||||
|                     InspectorTab::Auth => html! { | ||||
|                         <InspectorAuthTab  | ||||
|                         <InspectorAuthTab | ||||
|                             circle_ws_addresses={ctx.props().circle_ws_addresses.clone()} | ||||
|                             auth_manager={ctx.props().auth_manager.clone()} | ||||
|                         /> | ||||
| @@ -224,13 +224,11 @@ impl InspectorView { | ||||
|             _ => false, | ||||
|         }; | ||||
|         let tab_clone = tab.clone(); | ||||
|         let onclick = ctx.link().callback(move |_| Msg::SelectTab(tab_clone.clone())); | ||||
|         let onclick = ctx | ||||
|             .link() | ||||
|             .callback(move |_| Msg::SelectTab(tab_clone.clone())); | ||||
|  | ||||
|         let card_class = if is_selected { | ||||
|             "card selected" | ||||
|         } else { | ||||
|             "card" | ||||
|         }; | ||||
|         let card_class = if is_selected { "card selected" } else { "card" }; | ||||
|  | ||||
|         html! { | ||||
|             <div class={card_class} onclick={onclick}> | ||||
| @@ -292,7 +290,7 @@ impl InspectorView { | ||||
|     fn render_network_connections_sidebar(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         let connected_count = props.circle_ws_addresses.len(); | ||||
|          | ||||
|  | ||||
|         html! { | ||||
|             <div class="ws-status"> | ||||
|                 <div class="ws-status-header"> | ||||
| @@ -335,4 +333,4 @@ impl InspectorView { | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| // Imports from common_models | ||||
| use common_models::{AiMessageRole, AiConversation}; | ||||
| use heromodels::models::circle::Circle; | ||||
| use crate::ws_manager::CircleWsManager; | ||||
| use common_models::{AiConversation, AiMessageRole}; | ||||
| use heromodels::models::circle::Circle; | ||||
|  | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct IntelligenceViewProps { | ||||
| @@ -21,7 +21,7 @@ pub enum IntelligenceMsg { | ||||
|     SubmitPrompt, | ||||
|     LoadConversation(u32), | ||||
|     StartNewConversation, | ||||
|     CircleDataUpdated(String, Circle), // ws_url, circle_data | ||||
|     CircleDataUpdated(String, Circle),     // ws_url, circle_data | ||||
|     CircleDataFetchFailed(String, String), // ws_url, error | ||||
|     ScriptExecuted(Result<String, String>), | ||||
| } | ||||
| @@ -56,22 +56,20 @@ impl Component for IntelligenceView { | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let ws_manager = CircleWsManager::new(); | ||||
|          | ||||
|  | ||||
|         // Set up callback for circle data updates | ||||
|         let link = ctx.link().clone(); | ||||
|         ws_manager.set_on_data_fetched( | ||||
|             link.callback(|(ws_url, result): (String, Result<Circle, String>)| { | ||||
|                 match result { | ||||
|                     Ok(mut circle) => { | ||||
|                         if circle.ws_url.is_empty() { | ||||
|                             circle.ws_url = ws_url.clone(); | ||||
|                         } | ||||
|                         IntelligenceMsg::CircleDataUpdated(ws_url, circle) | ||||
|                     }, | ||||
|                     Err(e) => IntelligenceMsg::CircleDataFetchFailed(ws_url, e), | ||||
|         ws_manager.set_on_data_fetched(link.callback( | ||||
|             |(ws_url, result): (String, Result<Circle, String>)| match result { | ||||
|                 Ok(mut circle) => { | ||||
|                     if circle.ws_url.is_empty() { | ||||
|                         circle.ws_url = ws_url.clone(); | ||||
|                     } | ||||
|                     IntelligenceMsg::CircleDataUpdated(ws_url, circle) | ||||
|                 } | ||||
|             }) | ||||
|         ); | ||||
|                 Err(e) => IntelligenceMsg::CircleDataFetchFailed(ws_url, e), | ||||
|             }, | ||||
|         )); | ||||
|  | ||||
|         Self { | ||||
|             current_input: String::new(), | ||||
| @@ -128,7 +126,7 @@ impl Component for IntelligenceView { | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|          | ||||
|  | ||||
|         // Get aggregated conversations from context circles | ||||
|         let (active_conversation, conversation_history) = self.get_conversation_data(ctx); | ||||
|  | ||||
| @@ -214,7 +212,10 @@ impl Component for IntelligenceView { | ||||
| } | ||||
|  | ||||
| impl IntelligenceView { | ||||
|     fn get_conversation_data(&self, _ctx: &Context<Self>) -> (Option<Rc<AiConversation>>, Vec<AiConversationSummary>) { | ||||
|     fn get_conversation_data( | ||||
|         &self, | ||||
|         _ctx: &Context<Self>, | ||||
|     ) -> (Option<Rc<AiConversation>>, Vec<AiConversationSummary>) { | ||||
|         // TODO: The Circle model does not currently have an `intelligence` field. | ||||
|         // This logic is temporarily disabled to allow compilation. | ||||
|         // We need to determine how to fetch and associate AI conversations with circles. | ||||
| @@ -231,7 +232,8 @@ impl IntelligenceView { | ||||
|  | ||||
|         // Get target circle for the prompt | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|         let target_ws_url = props | ||||
|             .context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
| @@ -256,7 +258,10 @@ impl IntelligenceView { | ||||
|                             link.send_message(IntelligenceMsg::ScriptExecuted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(IntelligenceMsg::ScriptExecuted(Err(format!("{:?}", e)))); | ||||
|                             link.send_message(IntelligenceMsg::ScriptExecuted(Err(format!( | ||||
|                                 "{:?}", | ||||
|                                 e | ||||
|                             )))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
| @@ -268,7 +273,8 @@ impl IntelligenceView { | ||||
|         let script = r#" | ||||
|             let intelligence = get_intelligence(); | ||||
|             intelligence | ||||
|         "#.to_string(); | ||||
|         "# | ||||
|         .to_string(); | ||||
|  | ||||
|         if let Some(script_future) = self.ws_manager.execute_script(ws_url, script) { | ||||
|             spawn_local(async move { | ||||
|   | ||||
| @@ -1,25 +1,20 @@ | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use yew::prelude::*; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use heromodels::models::library::collection::Collection; | ||||
| use heromodels::models::library::items::{Image, Pdf, Markdown, Book, Slides}; | ||||
| use crate::ws_manager::{fetch_data_from_ws_urls, fetch_data_from_ws_url}; | ||||
| use crate::components::{ | ||||
|     book_viewer::BookViewer, | ||||
|     slides_viewer::SlidesViewer, | ||||
|     image_viewer::ImageViewer, | ||||
|     pdf_viewer::PdfViewer, | ||||
|     markdown_viewer::MarkdownViewer, | ||||
|     asset_details_card::AssetDetailsCard, | ||||
|     asset_details_card::AssetDetailsCard, book_viewer::BookViewer, image_viewer::ImageViewer, | ||||
|     markdown_viewer::MarkdownViewer, pdf_viewer::PdfViewer, slides_viewer::SlidesViewer, | ||||
| }; | ||||
| use crate::ws_manager::{fetch_data_from_ws_url, fetch_data_from_ws_urls}; | ||||
| use heromodels::models::library::collection::Collection; | ||||
| use heromodels::models::library::items::{Book, Image, Markdown, Pdf, Slides}; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct LibraryViewProps { | ||||
|     pub ws_addresses: Vec<String>, | ||||
| } | ||||
|  | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum DisplayLibraryItem { | ||||
|     Image(Image), | ||||
| @@ -71,7 +66,7 @@ impl Component for LibraryView { | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let props = ctx.props(); | ||||
|         let ws_addresses = props.ws_addresses.clone(); | ||||
|          | ||||
|  | ||||
|         let link = ctx.link().clone(); | ||||
|         spawn_local(async move { | ||||
|             let collections = get_collections(&ws_addresses).await; | ||||
| @@ -93,10 +88,10 @@ impl Component for LibraryView { | ||||
|         if ctx.props().ws_addresses != old_props.ws_addresses { | ||||
|             let ws_addresses = ctx.props().ws_addresses.clone(); | ||||
|             let link = ctx.link().clone(); | ||||
|              | ||||
|  | ||||
|             self.loading = true; | ||||
|             self.error = None; | ||||
|              | ||||
|  | ||||
|             spawn_local(async move { | ||||
|                 let collections = get_collections(&ws_addresses).await; | ||||
|                 link.send_message(Msg::CollectionsFetched(collections)); | ||||
| @@ -108,10 +103,13 @@ impl Component for LibraryView { | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::CollectionsFetched(collections) => { | ||||
|                 log::info!("Collections fetched: {:?}", collections.keys().collect::<Vec<_>>()); | ||||
|                 log::info!( | ||||
|                     "Collections fetched: {:?}", | ||||
|                     collections.keys().collect::<Vec<_>>() | ||||
|                 ); | ||||
|                 self.collections = collections.clone(); | ||||
|                 self.loading = false; | ||||
|                  | ||||
|  | ||||
|                 // Convert collections to display collections and start fetching items | ||||
|                 for (collection_key, collection) in collections { | ||||
|                     let ws_url = collection_key.split('_').next().unwrap_or("").to_string(); | ||||
| @@ -123,24 +121,27 @@ impl Component for LibraryView { | ||||
|                         collection_key: collection_key.clone(), | ||||
|                     }; | ||||
|                     self.display_collections.push(display_collection); | ||||
|                      | ||||
|  | ||||
|                     // Fetch items for this collection | ||||
|                     let link = ctx.link().clone(); | ||||
|                     let collection_clone = collection.clone(); | ||||
|                     let collection_key_clone = collection_key.clone(); | ||||
|                      | ||||
|  | ||||
|                     spawn_local(async move { | ||||
|                         let items = fetch_collection_items(&ws_url, &collection_clone).await; | ||||
|                         link.send_message(Msg::ItemsFetched(collection_key_clone, items)); | ||||
|                     }); | ||||
|                 } | ||||
|                  | ||||
|  | ||||
|                 true | ||||
|             } | ||||
|             Msg::ItemsFetched(collection_key, items) => { | ||||
|                 // Find the display collection and update its items using exact key matching | ||||
|                 if let Some(display_collection) = self.display_collections.iter_mut() | ||||
|                     .find(|dc| dc.collection_key == collection_key) { | ||||
|                 if let Some(display_collection) = self | ||||
|                     .display_collections | ||||
|                     .iter_mut() | ||||
|                     .find(|dc| dc.collection_key == collection_key) | ||||
|                 { | ||||
|                     display_collection.items = items.into_iter().map(Rc::new).collect(); | ||||
|                 } | ||||
|                 true | ||||
| @@ -177,7 +178,7 @@ impl Component for LibraryView { | ||||
|                     let toc_callback = Callback::from(|_page: usize| { | ||||
|                         // TOC navigation is now handled by the BookViewer component | ||||
|                     }); | ||||
|                      | ||||
|  | ||||
|                     html! { | ||||
|                         <div class="view-container sidebar-layout"> | ||||
|                             <div class="sidebar"> | ||||
| @@ -223,7 +224,11 @@ impl Component for LibraryView { | ||||
| } | ||||
|  | ||||
| impl LibraryView { | ||||
|     fn render_viewer_component(&self, item: &DisplayLibraryItem, back_callback: Callback<()>) -> Html { | ||||
|     fn render_viewer_component( | ||||
|         &self, | ||||
|         item: &DisplayLibraryItem, | ||||
|         back_callback: Callback<()>, | ||||
|     ) -> Html { | ||||
|         match item { | ||||
|             DisplayLibraryItem::Image(img) => html! { | ||||
|                 <ImageViewer image={img.clone()} on_back={back_callback} /> | ||||
| @@ -243,150 +248,150 @@ impl LibraryView { | ||||
|         } | ||||
|     } | ||||
|  | ||||
| fn render_collections_view(&self, ctx: &Context<Self>) -> Html { | ||||
|     if self.loading { | ||||
|         html! { <p>{"Loading collections..."}</p> } | ||||
|     } else if let Some(err) = &self.error { | ||||
|         html! { <p class="error-message">{format!("Error: {}", err)}</p> } | ||||
|     } else if self.display_collections.is_empty() { | ||||
|         html! { <p class="no-collections-message">{"No collections available."}</p> } | ||||
|     } else { | ||||
|         html! { | ||||
|             <> | ||||
|                 <h1>{"Collections"}</h1> | ||||
|                 <div class="collections-grid"> | ||||
|                     { self.display_collections.iter().enumerate().map(|(idx, collection)| { | ||||
|                         let onclick = ctx.link().callback(move |e: MouseEvent| { | ||||
|                             e.stop_propagation(); | ||||
|                             Msg::SelectCollection(idx) | ||||
|                         }); | ||||
|                         let item_count = collection.items.len(); | ||||
|                         html! { | ||||
|                             <div class="card" onclick={onclick}> | ||||
|                                 <h3 class="collection-title">{ &collection.title }</h3> | ||||
|                                 { if let Some(desc) = &collection.description { | ||||
|                                     html! { <p class="collection-description">{ desc }</p> } | ||||
|                                 } else { | ||||
|                                     html! {} | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         } | ||||
|                     }).collect::<Html>() } | ||||
|                 </div> | ||||
|             </> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_collection_items_view(&self, ctx: &Context<Self>) -> Html { | ||||
|     if let Some(selected_index) = self.selected_collection_index { | ||||
|         if let Some(collection) = self.display_collections.get(selected_index) { | ||||
|     fn render_collections_view(&self, ctx: &Context<Self>) -> Html { | ||||
|         if self.loading { | ||||
|             html! { <p>{"Loading collections..."}</p> } | ||||
|         } else if let Some(err) = &self.error { | ||||
|             html! { <p class="error-message">{format!("Error: {}", err)}</p> } | ||||
|         } else if self.display_collections.is_empty() { | ||||
|             html! { <p class="no-collections-message">{"No collections available."}</p> } | ||||
|         } else { | ||||
|             html! { | ||||
|                 <> | ||||
|                     <header> | ||||
|                         <h2 onclick={|e: MouseEvent| e.stop_propagation()}>{ &collection.title }</h2> | ||||
|                         { if let Some(desc) = &collection.description { | ||||
|                             html! { <p onclick={|e: MouseEvent| e.stop_propagation()}>{ desc }</p> } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         }} | ||||
|                     </header> | ||||
|                     <div class="library-items-grid"> | ||||
|                         { collection.items.iter().map(|item| { | ||||
|                             let item_clone = item.as_ref().clone(); | ||||
|                     <h1>{"Collections"}</h1> | ||||
|                     <div class="collections-grid"> | ||||
|                         { self.display_collections.iter().enumerate().map(|(idx, collection)| { | ||||
|                             let onclick = ctx.link().callback(move |e: MouseEvent| { | ||||
|                                 e.stop_propagation(); | ||||
|                                 Msg::ViewItem(item_clone.clone()) | ||||
|                                 Msg::SelectCollection(idx) | ||||
|                             }); | ||||
|                              | ||||
|                             match item.as_ref() { | ||||
|                                 DisplayLibraryItem::Image(img) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <img src={img.url.clone()} class="item-thumbnail-img" alt={img.title.clone()} /> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &img.title }</p> | ||||
|                                             { if let Some(desc) = &img.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Pdf(pdf) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fas fa-file-pdf item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &pdf.title }</p> | ||||
|                                             { if let Some(desc) = &pdf.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                             <p class="item-meta">{ format!("{} pages", pdf.page_count) }</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Markdown(md) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fab fa-markdown item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &md.title }</p> | ||||
|                                             { if let Some(desc) = &md.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Book(book) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fas fa-book item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &book.title }</p> | ||||
|                                             { if let Some(desc) = &book.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                             <p class="item-meta">{ format!("{} pages", book.pages.len()) }</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Slides(slides) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fas fa-images item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &slides.title }</p> | ||||
|                                             { if let Some(desc) = &slides.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                             <p class="item-meta">{ format!("{} slides", slides.slide_urls.len()) }</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                             let item_count = collection.items.len(); | ||||
|                             html! { | ||||
|                                 <div class="card" onclick={onclick}> | ||||
|                                     <h3 class="collection-title">{ &collection.title }</h3> | ||||
|                                     { if let Some(desc) = &collection.description { | ||||
|                                         html! { <p class="collection-description">{ desc }</p> } | ||||
|                                     } else { | ||||
|                                         html! {} | ||||
|                                     }} | ||||
|                                 </div> | ||||
|                             } | ||||
|                         }).collect::<Html>() } | ||||
|                     </div> | ||||
|                 </> | ||||
|             } | ||||
|         } else { | ||||
|             html! { <p>{"Collection not found."}</p> } | ||||
|         } | ||||
|     } else { | ||||
|         self.render_collections_view(ctx) | ||||
|     } | ||||
| } | ||||
|  | ||||
|     fn render_collection_items_view(&self, ctx: &Context<Self>) -> Html { | ||||
|         if let Some(selected_index) = self.selected_collection_index { | ||||
|             if let Some(collection) = self.display_collections.get(selected_index) { | ||||
|                 html! { | ||||
|                     <> | ||||
|                         <header> | ||||
|                             <h2 onclick={|e: MouseEvent| e.stop_propagation()}>{ &collection.title }</h2> | ||||
|                             { if let Some(desc) = &collection.description { | ||||
|                                 html! { <p onclick={|e: MouseEvent| e.stop_propagation()}>{ desc }</p> } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             }} | ||||
|                         </header> | ||||
|                         <div class="library-items-grid"> | ||||
|                             { collection.items.iter().map(|item| { | ||||
|                                 let item_clone = item.as_ref().clone(); | ||||
|                                 let onclick = ctx.link().callback(move |e: MouseEvent| { | ||||
|                                     e.stop_propagation(); | ||||
|                                     Msg::ViewItem(item_clone.clone()) | ||||
|                                 }); | ||||
|  | ||||
|                                 match item.as_ref() { | ||||
|                                     DisplayLibraryItem::Image(img) => html! { | ||||
|                                         <div class="library-item-card" onclick={onclick}> | ||||
|                                             <div class="item-preview"> | ||||
|                                                 <img src={img.url.clone()} class="item-thumbnail-img" alt={img.title.clone()} /> | ||||
|                                             </div> | ||||
|                                             <div class="item-details"> | ||||
|                                                 <p class="item-title">{ &img.title }</p> | ||||
|                                                 { if let Some(desc) = &img.description { | ||||
|                                                     html! { <p class="item-description">{ desc }</p> } | ||||
|                                                 } else { html! {} }} | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     }, | ||||
|                                     DisplayLibraryItem::Pdf(pdf) => html! { | ||||
|                                         <div class="library-item-card" onclick={onclick}> | ||||
|                                             <div class="item-preview"> | ||||
|                                                 <i class="fas fa-file-pdf item-preview-fallback-icon"></i> | ||||
|                                             </div> | ||||
|                                             <div class="item-details"> | ||||
|                                                 <p class="item-title">{ &pdf.title }</p> | ||||
|                                                 { if let Some(desc) = &pdf.description { | ||||
|                                                     html! { <p class="item-description">{ desc }</p> } | ||||
|                                                 } else { html! {} }} | ||||
|                                                 <p class="item-meta">{ format!("{} pages", pdf.page_count) }</p> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     }, | ||||
|                                     DisplayLibraryItem::Markdown(md) => html! { | ||||
|                                         <div class="library-item-card" onclick={onclick}> | ||||
|                                             <div class="item-preview"> | ||||
|                                                 <i class="fab fa-markdown item-preview-fallback-icon"></i> | ||||
|                                             </div> | ||||
|                                             <div class="item-details"> | ||||
|                                                 <p class="item-title">{ &md.title }</p> | ||||
|                                                 { if let Some(desc) = &md.description { | ||||
|                                                     html! { <p class="item-description">{ desc }</p> } | ||||
|                                                 } else { html! {} }} | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     }, | ||||
|                                     DisplayLibraryItem::Book(book) => html! { | ||||
|                                         <div class="library-item-card" onclick={onclick}> | ||||
|                                             <div class="item-preview"> | ||||
|                                                 <i class="fas fa-book item-preview-fallback-icon"></i> | ||||
|                                             </div> | ||||
|                                             <div class="item-details"> | ||||
|                                                 <p class="item-title">{ &book.title }</p> | ||||
|                                                 { if let Some(desc) = &book.description { | ||||
|                                                     html! { <p class="item-description">{ desc }</p> } | ||||
|                                                 } else { html! {} }} | ||||
|                                                 <p class="item-meta">{ format!("{} pages", book.pages.len()) }</p> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     }, | ||||
|                                     DisplayLibraryItem::Slides(slides) => html! { | ||||
|                                         <div class="library-item-card" onclick={onclick}> | ||||
|                                             <div class="item-preview"> | ||||
|                                                 <i class="fas fa-images item-preview-fallback-icon"></i> | ||||
|                                             </div> | ||||
|                                             <div class="item-details"> | ||||
|                                                 <p class="item-title">{ &slides.title }</p> | ||||
|                                                 { if let Some(desc) = &slides.description { | ||||
|                                                     html! { <p class="item-description">{ desc }</p> } | ||||
|                                                 } else { html! {} }} | ||||
|                                                 <p class="item-meta">{ format!("{} slides", slides.slide_urls.len()) }</p> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     }, | ||||
|                                 } | ||||
|                             }).collect::<Html>() } | ||||
|                         </div> | ||||
|                     </> | ||||
|                 } | ||||
|             } else { | ||||
|                 html! { <p>{"Collection not found."}</p> } | ||||
|             } | ||||
|         } else { | ||||
|             self.render_collections_view(ctx) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Convenience function to fetch collections from WebSocket URLs | ||||
| async fn get_collections(ws_urls: &[String]) -> HashMap<String, Collection> { | ||||
|     let collections_arrays: HashMap<String, Vec<Collection>> = fetch_data_from_ws_urls(ws_urls, "list_collections().json()".to_string()).await; | ||||
|      | ||||
|     let collections_arrays: HashMap<String, Vec<Collection>> = | ||||
|         fetch_data_from_ws_urls(ws_urls, "list_collections().json()".to_string()).await; | ||||
|  | ||||
|     let mut result = HashMap::new(); | ||||
|     for (ws_url, collections_vec) in collections_arrays { | ||||
|         for (index, collection) in collections_vec.into_iter().enumerate() { | ||||
| @@ -401,15 +406,17 @@ async fn get_collections(ws_urls: &[String]) -> HashMap<String, Collection> { | ||||
| /// Fetch all items for a collection from a WebSocket URL | ||||
| async fn fetch_collection_items(ws_url: &str, collection: &Collection) -> Vec<DisplayLibraryItem> { | ||||
|     let mut items = Vec::new(); | ||||
|      | ||||
|  | ||||
|     // Fetch images | ||||
|     for image_id in &collection.images { | ||||
|         match fetch_data_from_ws_url::<Image>(ws_url, &format!("get_image({}).json()", image_id)).await { | ||||
|         match fetch_data_from_ws_url::<Image>(ws_url, &format!("get_image({}).json()", image_id)) | ||||
|             .await | ||||
|         { | ||||
|             Ok(image) => items.push(DisplayLibraryItem::Image(image)), | ||||
|             Err(e) => log::error!("Failed to fetch image {}: {}", image_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Fetch PDFs | ||||
|     for pdf_id in &collection.pdfs { | ||||
|         match fetch_data_from_ws_url::<Pdf>(ws_url, &format!("get_pdf({}).json()", pdf_id)).await { | ||||
| @@ -417,30 +424,38 @@ async fn fetch_collection_items(ws_url: &str, collection: &Collection) -> Vec<Di | ||||
|             Err(e) => log::error!("Failed to fetch PDF {}: {}", pdf_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Fetch Markdowns | ||||
|     for markdown_id in &collection.markdowns { | ||||
|         match fetch_data_from_ws_url::<Markdown>(ws_url, &format!("get_markdown({}).json()", markdown_id)).await { | ||||
|         match fetch_data_from_ws_url::<Markdown>( | ||||
|             ws_url, | ||||
|             &format!("get_markdown({}).json()", markdown_id), | ||||
|         ) | ||||
|         .await | ||||
|         { | ||||
|             Ok(markdown) => items.push(DisplayLibraryItem::Markdown(markdown)), | ||||
|             Err(e) => log::error!("Failed to fetch markdown {}: {}", markdown_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Fetch Books | ||||
|     for book_id in &collection.books { | ||||
|         match fetch_data_from_ws_url::<Book>(ws_url, &format!("get_book({}).json()", book_id)).await { | ||||
|         match fetch_data_from_ws_url::<Book>(ws_url, &format!("get_book({}).json()", book_id)).await | ||||
|         { | ||||
|             Ok(book) => items.push(DisplayLibraryItem::Book(book)), | ||||
|             Err(e) => log::error!("Failed to fetch book {}: {}", book_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Fetch Slides | ||||
|     for slides_id in &collection.slides { | ||||
|         match fetch_data_from_ws_url::<Slides>(ws_url, &format!("get_slides({}).json()", slides_id)).await { | ||||
|         match fetch_data_from_ws_url::<Slides>(ws_url, &format!("get_slides({}).json()", slides_id)) | ||||
|             .await | ||||
|         { | ||||
|             Ok(slides) => items.push(DisplayLibraryItem::Slides(slides)), | ||||
|             Err(e) => log::error!("Failed to fetch slides {}: {}", slides_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     items | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| //! Login component for authentication | ||||
| //!  | ||||
| //! | ||||
| //! This component provides a user interface for authentication using either | ||||
| //! email addresses (with hardcoded key lookup) or direct private key input. | ||||
|  | ||||
| use yew::prelude::*; | ||||
| use crate::auth::{AuthManager, AuthMethod, AuthState}; | ||||
| use web_sys::HtmlInputElement; | ||||
| use crate::auth::{AuthManager, AuthState, AuthMethod}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| /// Props for the login component | ||||
| #[derive(Properties, PartialEq)] | ||||
| @@ -65,11 +65,11 @@ impl Component for LoginComponent { | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let auth_manager = ctx.props().auth_manager.clone(); | ||||
|         let auth_state = auth_manager.get_state(); | ||||
|          | ||||
|  | ||||
|         // Set up auth state change callback | ||||
|         let link = ctx.link().clone(); | ||||
|         auth_manager.set_on_state_change(link.callback(LoginMsg::AuthStateChanged)); | ||||
|          | ||||
|  | ||||
|         // Get available emails for app | ||||
|         let available_emails = auth_manager.get_available_emails(); | ||||
|  | ||||
| @@ -138,7 +138,9 @@ impl Component for LoginComponent { | ||||
|                                     if let Some(callback) = on_error { | ||||
|                                         callback.emit(e.to_string()); | ||||
|                                     } | ||||
|                                     link.send_message(LoginMsg::AuthStateChanged(AuthState::Failed(e.to_string()))); | ||||
|                                     link.send_message(LoginMsg::AuthStateChanged( | ||||
|                                         AuthState::Failed(e.to_string()), | ||||
|                                     )); | ||||
|                                 } | ||||
|                             } | ||||
|                         }); | ||||
| @@ -146,7 +148,10 @@ impl Component for LoginComponent { | ||||
|                     LoginMethod::PrivateKey => { | ||||
|                         let private_key = self.private_key.clone(); | ||||
|                         wasm_bindgen_futures::spawn_local(async move { | ||||
|                             match auth_manager.authenticate_with_private_key(private_key).await { | ||||
|                             match auth_manager | ||||
|                                 .authenticate_with_private_key(private_key) | ||||
|                                 .await | ||||
|                             { | ||||
|                                 Ok(()) => { | ||||
|                                     if let Some(callback) = on_authenticated { | ||||
|                                         callback.emit(()); | ||||
| @@ -156,7 +161,9 @@ impl Component for LoginComponent { | ||||
|                                     if let Some(callback) = on_error { | ||||
|                                         callback.emit(e.to_string()); | ||||
|                                     } | ||||
|                                     link.send_message(LoginMsg::AuthStateChanged(AuthState::Failed(e.to_string()))); | ||||
|                                     link.send_message(LoginMsg::AuthStateChanged( | ||||
|                                         AuthState::Failed(e.to_string()), | ||||
|                                     )); | ||||
|                                 } | ||||
|                             } | ||||
|                         }); | ||||
| @@ -164,7 +171,8 @@ impl Component for LoginComponent { | ||||
|                     LoginMethod::CreateKey => { | ||||
|                         // This shouldn't happen as CreateKey method doesn't have a submit button | ||||
|                         // But if it does, treat it as an error | ||||
|                         self.error_message = Some("Please generate a key first, then use it to login.".to_string()); | ||||
|                         self.error_message = | ||||
|                             Some("Please generate a key first, then use it to login.".to_string()); | ||||
|                     } | ||||
|                 } | ||||
|                 true | ||||
| @@ -207,21 +215,20 @@ impl Component for LoginComponent { | ||||
|             } | ||||
|             LoginMsg::GenerateNewKey => { | ||||
|                 use circle_client_ws::auth as crypto_utils; | ||||
|                  | ||||
|  | ||||
|                 match crypto_utils::generate_private_key() { | ||||
|                     Ok(private_key) => { | ||||
|                         match crypto_utils::derive_public_key(&private_key) { | ||||
|                             Ok(public_key) => { | ||||
|                                 self.generated_private_key = Some(private_key); | ||||
|                                 self.generated_public_key = Some(public_key); | ||||
|                                 self.error_message = None; | ||||
|                                 self.copy_feedback = None; | ||||
|                             } | ||||
|                             Err(e) => { | ||||
|                                 self.error_message = Some(format!("Failed to derive public key: {}", e)); | ||||
|                             } | ||||
|                     Ok(private_key) => match crypto_utils::derive_public_key(&private_key) { | ||||
|                         Ok(public_key) => { | ||||
|                             self.generated_private_key = Some(private_key); | ||||
|                             self.generated_public_key = Some(public_key); | ||||
|                             self.error_message = None; | ||||
|                             self.copy_feedback = None; | ||||
|                         } | ||||
|                     } | ||||
|                         Err(e) => { | ||||
|                             self.error_message = | ||||
|                                 Some(format!("Failed to derive public key: {}", e)); | ||||
|                         } | ||||
|                     }, | ||||
|                     Err(e) => { | ||||
|                         self.error_message = Some(format!("Failed to generate private key: {}", e)); | ||||
|                     } | ||||
| @@ -232,8 +239,11 @@ impl Component for LoginComponent { | ||||
|                 // Simple fallback: show the text in an alert for now | ||||
|                 // TODO: Implement proper clipboard API when web_sys is properly configured | ||||
|                 if let Some(window) = web_sys::window() { | ||||
|                     window.alert_with_message(&format!("Copy this key:\n\n{}", text)).ok(); | ||||
|                     self.copy_feedback = Some("Key shown in alert - please copy manually".to_string()); | ||||
|                     window | ||||
|                         .alert_with_message(&format!("Copy this key:\n\n{}", text)) | ||||
|                         .ok(); | ||||
|                     self.copy_feedback = | ||||
|                         Some("Key shown in alert - please copy manually".to_string()); | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
| @@ -252,7 +262,10 @@ impl Component for LoginComponent { | ||||
|         let link = ctx.link(); | ||||
|  | ||||
|         // If already authenticated, show status | ||||
|         if let AuthState::Authenticated { method, public_key, .. } = &self.auth_state { | ||||
|         if let AuthState::Authenticated { | ||||
|             method, public_key, .. | ||||
|         } = &self.auth_state | ||||
|         { | ||||
|             return self.render_authenticated_view(method, public_key, link); | ||||
|         } | ||||
|  | ||||
| @@ -260,7 +273,7 @@ impl Component for LoginComponent { | ||||
|             <div class="login-container"> | ||||
|                 <div class="login-card"> | ||||
|                     <h2 class="login-title">{ "Authenticate to Circles" }</h2> | ||||
|                      | ||||
|  | ||||
|                     { self.render_method_selector(link) } | ||||
|                     { self.render_login_form(link) } | ||||
|                     { self.render_error_message() } | ||||
| @@ -354,7 +367,7 @@ impl LoginComponent { | ||||
|                         { "Use one of the app email addresses or click the dropdown to see available options." } | ||||
|                     </small> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <button | ||||
|                     type="submit" | ||||
|                     class="login-btn" | ||||
| @@ -395,7 +408,7 @@ impl LoginComponent { | ||||
|                         { "Enter your secp256k1 private key in hexadecimal format (with or without 0x prefix)." } | ||||
|                     </small> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <button | ||||
|                     type="submit" | ||||
|                     class="login-btn" | ||||
| @@ -469,14 +482,23 @@ impl LoginComponent { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_authenticated_view(&self, method: &AuthMethod, public_key: &str, link: &html::Scope<Self>) -> Html { | ||||
|     fn render_authenticated_view( | ||||
|         &self, | ||||
|         method: &AuthMethod, | ||||
|         public_key: &str, | ||||
|         link: &html::Scope<Self>, | ||||
|     ) -> Html { | ||||
|         let method_display = match method { | ||||
|             AuthMethod::Email(email) => format!("Email: {}", email), | ||||
|             AuthMethod::PrivateKey => "Private Key".to_string(), | ||||
|         }; | ||||
|  | ||||
|         let short_public_key = if public_key.len() > 20 { | ||||
|             format!("{}...{}", &public_key[..10], &public_key[public_key.len()-10..]) | ||||
|             format!( | ||||
|                 "{}...{}", | ||||
|                 &public_key[..10], | ||||
|                 &public_key[public_key.len() - 10..] | ||||
|             ) | ||||
|         } else { | ||||
|             public_key.to_string() | ||||
|         }; | ||||
| @@ -521,7 +543,7 @@ impl LoginComponent { | ||||
|                         { "Create a new cryptographic keypair for authentication. " } | ||||
|                         { "Make sure to securely store your private key!" } | ||||
|                     </p> | ||||
|                      | ||||
|  | ||||
|                     <button | ||||
|                         type="button" | ||||
|                         class="generate-key-btn" | ||||
| @@ -540,10 +562,12 @@ impl LoginComponent { | ||||
|     } | ||||
|  | ||||
|     fn render_generated_keys(&self, link: &html::Scope<Self>) -> Html { | ||||
|         if let (Some(private_key), Some(public_key)) = (&self.generated_private_key, &self.generated_public_key) { | ||||
|         if let (Some(private_key), Some(public_key)) = | ||||
|             (&self.generated_private_key, &self.generated_public_key) | ||||
|         { | ||||
|             let private_key_clone = private_key.clone(); | ||||
|             let public_key_clone = public_key.clone(); | ||||
|              | ||||
|  | ||||
|             html! { | ||||
|                 <div class="generated-keys"> | ||||
|                     <div class="key-section"> | ||||
| @@ -630,4 +654,4 @@ impl LoginComponent { | ||||
|             html! {} | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Markdown; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct MarkdownViewerProps { | ||||
| @@ -19,7 +19,7 @@ impl Component for MarkdownViewer { | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|  | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
| @@ -61,7 +61,7 @@ impl MarkdownViewer { | ||||
|             } else if line.starts_with("- ") { | ||||
|                 html_content.push(html! { <li>{ &line[2..] }</li> }); | ||||
|             } else if line.starts_with("**") && line.ends_with("**") { | ||||
|                 let text = &line[2..line.len()-2]; | ||||
|                 let text = &line[2..line.len() - 2]; | ||||
|                 html_content.push(html! { <p><strong>{ text }</strong></p> }); | ||||
|             } else if !line.trim().is_empty() { | ||||
|                 html_content.push(html! { <p>{ line }</p> }); | ||||
| @@ -72,4 +72,4 @@ impl MarkdownViewer { | ||||
|  | ||||
|         html! { <div>{ for html_content }</div> } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,31 +1,31 @@ | ||||
| // This file declares the `components` module. | ||||
| pub mod circles_view; | ||||
| pub mod nav_island; | ||||
| pub mod library_view; | ||||
| pub mod nav_island; | ||||
| // pub use library_view::{LibraryView, LibraryViewProps}; // Kept commented as it's unused or handled in app.rs | ||||
|  // Kept commented as it's unused or handled in app.rs | ||||
| // Kept commented as it's unused or handled in app.rs | ||||
| // pub mod dashboard_view; // Commented out as dashboard_view.rs doesn't exist yet | ||||
| pub mod chat; | ||||
| pub mod customize_view; | ||||
| pub mod inspector_auth_tab; | ||||
| pub mod inspector_interact_tab; | ||||
| pub mod inspector_logs_tab; | ||||
| pub mod inspector_network_tab; | ||||
| pub mod inspector_view; | ||||
| pub mod intelligence_view; | ||||
| pub mod network_animation_view; | ||||
| pub mod publishing_view; | ||||
| pub mod customize_view; | ||||
| pub mod inspector_view; | ||||
| pub mod inspector_network_tab; | ||||
| pub mod inspector_logs_tab; | ||||
| pub mod inspector_interact_tab; | ||||
| pub mod inspector_auth_tab; | ||||
| pub mod chat; | ||||
| pub mod sidebar_layout; | ||||
| pub mod world_map_svg; | ||||
|  | ||||
| // Authentication components | ||||
| pub mod login_component; | ||||
| pub mod auth_view; | ||||
| pub mod login_component; | ||||
|  | ||||
| // Library viewer components | ||||
| pub mod book_viewer; | ||||
| pub mod slides_viewer; | ||||
| pub mod image_viewer; | ||||
| pub mod pdf_viewer; | ||||
| pub mod markdown_viewer; | ||||
| pub mod asset_details_card; | ||||
| pub mod book_viewer; | ||||
| pub mod image_viewer; | ||||
| pub mod markdown_viewer; | ||||
| pub mod pdf_viewer; | ||||
| pub mod slides_viewer; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use yew::{function_component, Callback, Properties, classes, use_state, use_node_ref}; | ||||
| use crate::app::AppView; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::app::AppView; // Assuming AppView is accessible | ||||
| use yew::{classes, function_component, use_node_ref, use_state, Callback, Properties}; // Assuming AppView is accessible | ||||
|  | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct NavIslandProps { | ||||
| @@ -14,12 +14,37 @@ pub fn nav_island(props: &NavIslandProps) -> yew::Html { | ||||
|     let nav_island_ref = use_node_ref(); | ||||
|     // Create all button data with their view/tab info | ||||
|     let mut all_buttons = vec![ | ||||
|         (AppView::Circles, None::<()>, "fas fa-circle-notch", "Circles"), | ||||
|         ( | ||||
|             AppView::Circles, | ||||
|             None::<()>, | ||||
|             "fas fa-circle-notch", | ||||
|             "Circles", | ||||
|         ), | ||||
|         (AppView::Library, None::<()>, "fas fa-book", "Library"), | ||||
|         (AppView::Intelligence, None::<()>, "fas fa-brain", "Intelligence"), | ||||
|         (AppView::Publishing, None::<()>, "fas fa-rocket", "Publishing"),  | ||||
|         (AppView::Inspector, None::<()>, "fas fa-search-location", "Inspector"),  | ||||
|         (AppView::Customize, None::<()>, "fas fa-paint-brush", "Customize"),  | ||||
|         ( | ||||
|             AppView::Intelligence, | ||||
|             None::<()>, | ||||
|             "fas fa-brain", | ||||
|             "Intelligence", | ||||
|         ), | ||||
|         ( | ||||
|             AppView::Publishing, | ||||
|             None::<()>, | ||||
|             "fas fa-rocket", | ||||
|             "Publishing", | ||||
|         ), | ||||
|         ( | ||||
|             AppView::Inspector, | ||||
|             None::<()>, | ||||
|             "fas fa-search-location", | ||||
|             "Inspector", | ||||
|         ), | ||||
|         ( | ||||
|             AppView::Customize, | ||||
|             None::<()>, | ||||
|             "fas fa-paint-brush", | ||||
|             "Customize", | ||||
|         ), | ||||
|     ]; | ||||
|  | ||||
|     // Find and move the active button to the front | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| use yew::prelude::*; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use common_models::CircleData; | ||||
| use gloo_timers::callback::{Interval, Timeout}; | ||||
| use rand::seq::SliceRandom; | ||||
| use rand::Rng; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| struct ServerNode { | ||||
| @@ -52,40 +52,50 @@ pub struct NetworkAnimationView { | ||||
| } | ||||
|  | ||||
| impl NetworkAnimationView { | ||||
|     fn calculate_server_positions(all_circles: &Rc<HashMap<u32, CircleData>>) -> Rc<HashMap<u32, ServerNode>> { | ||||
|     fn calculate_server_positions( | ||||
|         all_circles: &Rc<HashMap<u32, CircleData>>, | ||||
|     ) -> Rc<HashMap<u32, ServerNode>> { | ||||
|         let mut nodes = HashMap::new(); | ||||
|          | ||||
|  | ||||
|         // Predefined realistic server locations on the world map (coordinates scaled to viewBox 783.086 x 400.649) | ||||
|         let server_positions = vec![ | ||||
|             (180.0, 150.0, "North America"),    // USA/Canada | ||||
|             (420.0, 130.0, "Europe"),           // Central Europe | ||||
|             (580.0, 160.0, "Asia"),             // East Asia | ||||
|             (220.0, 280.0, "South America"),    // Brazil/Argentina | ||||
|             (450.0, 220.0, "Africa"),           // Central Africa | ||||
|             (650.0, 320.0, "Oceania"),          // Australia | ||||
|             (400.0, 90.0, "Nordic"),            // Scandinavia | ||||
|             (520.0, 200.0, "Middle East"),      // Middle East | ||||
|             (180.0, 150.0, "North America"), // USA/Canada | ||||
|             (420.0, 130.0, "Europe"),        // Central Europe | ||||
|             (580.0, 160.0, "Asia"),          // East Asia | ||||
|             (220.0, 280.0, "South America"), // Brazil/Argentina | ||||
|             (450.0, 220.0, "Africa"),        // Central Africa | ||||
|             (650.0, 320.0, "Oceania"),       // Australia | ||||
|             (400.0, 90.0, "Nordic"),         // Scandinavia | ||||
|             (520.0, 200.0, "Middle East"),   // Middle East | ||||
|         ]; | ||||
|  | ||||
|         for (i, (id, circle_data)) in all_circles.iter().enumerate() { | ||||
|             if let Some((x, y, region)) = server_positions.get(i % server_positions.len()) { | ||||
|                 nodes.insert(*id, ServerNode { | ||||
|                     x: *x, | ||||
|                     y: *y, | ||||
|                     name: format!("{}", circle_data.name), | ||||
|                     id: *id, | ||||
|                     is_active: true, | ||||
|                 }); | ||||
|                 nodes.insert( | ||||
|                     *id, | ||||
|                     ServerNode { | ||||
|                         x: *x, | ||||
|                         y: *y, | ||||
|                         name: format!("{}", circle_data.name), | ||||
|                         id: *id, | ||||
|                         is_active: true, | ||||
|                     }, | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         Rc::new(nodes) | ||||
|     } | ||||
|  | ||||
|     fn create_transmission(&mut self, from_id: u32, to_id: u32, transmission_type: TransmissionType) -> usize { | ||||
|     fn create_transmission( | ||||
|         &mut self, | ||||
|         from_id: u32, | ||||
|         to_id: u32, | ||||
|         transmission_type: TransmissionType, | ||||
|     ) -> usize { | ||||
|         let id = self.next_transmission_id; | ||||
|         self.next_transmission_id += 1; | ||||
|          | ||||
|  | ||||
|         self.active_transmissions.push(DataTransmission { | ||||
|             id, | ||||
|             from_node: from_id, | ||||
| @@ -93,7 +103,7 @@ impl NetworkAnimationView { | ||||
|             progress: 0.0, | ||||
|             transmission_type, | ||||
|         }); | ||||
|          | ||||
|  | ||||
|         id | ||||
|     } | ||||
| } | ||||
| @@ -104,7 +114,7 @@ impl Component for NetworkAnimationView { | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let server_nodes = Self::calculate_server_positions(&ctx.props().all_circles); | ||||
|          | ||||
|  | ||||
|         let link = ctx.link().clone(); | ||||
|         let transmission_interval = Interval::new(3000, move || { | ||||
|             link.send_message(Msg::StartTransmission); | ||||
| @@ -133,30 +143,30 @@ impl Component for NetworkAnimationView { | ||||
|  | ||||
|                 let mut rng = rand::thread_rng(); | ||||
|                 let node_ids: Vec<u32> = self.server_nodes.keys().cloned().collect(); | ||||
|                  | ||||
|                 if let (Some(&from_id), Some(&to_id)) = ( | ||||
|                     node_ids.choose(&mut rng), | ||||
|                     node_ids.choose(&mut rng) | ||||
|                 ) { | ||||
|  | ||||
|                 if let (Some(&from_id), Some(&to_id)) = | ||||
|                     (node_ids.choose(&mut rng), node_ids.choose(&mut rng)) | ||||
|                 { | ||||
|                     if from_id != to_id { | ||||
|                         let transmission_type = match rng.gen_range(0..3) { | ||||
|                             0 => TransmissionType::Data, | ||||
|                             1 => TransmissionType::Sync, | ||||
|                             _ => TransmissionType::Heartbeat, | ||||
|                         }; | ||||
|                          | ||||
|                         let transmission_id = self.create_transmission(from_id, to_id, transmission_type); | ||||
|                          | ||||
|  | ||||
|                         let transmission_id = | ||||
|                             self.create_transmission(from_id, to_id, transmission_type); | ||||
|  | ||||
|                         // Pulse the source node | ||||
|                         ctx.link().send_message(Msg::PulseNode(from_id)); | ||||
|                          | ||||
|  | ||||
|                         // Remove transmission after completion | ||||
|                         let link = ctx.link().clone(); | ||||
|                         let timeout = Timeout::new(2000, move || { | ||||
|                             link.send_message(Msg::RemoveTransmission(transmission_id)); | ||||
|                         }); | ||||
|                         timeout.forget(); | ||||
|                          | ||||
|  | ||||
|                         return true; | ||||
|                     } | ||||
|                 } | ||||
| @@ -200,32 +210,36 @@ impl Component for NetworkAnimationView { | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         let transmissions = self.active_transmissions.iter().map(|transmission| { | ||||
|             if let (Some(from_node), Some(to_node)) = ( | ||||
|                 self.server_nodes.get(&transmission.from_node), | ||||
|                 self.server_nodes.get(&transmission.to_node) | ||||
|             ) { | ||||
|                 html! { | ||||
|                     <g class="transmission-group"> | ||||
|                         // Simple connection line with subtle animation | ||||
|                         <line | ||||
|                             x1={from_node.x.to_string()} | ||||
|                             y1={from_node.y.to_string()} | ||||
|                             x2={to_node.x.to_string()} | ||||
|                             y2={to_node.y.to_string()} | ||||
|                             class="transmission-line" | ||||
|                         /> | ||||
|                     </g> | ||||
|         let transmissions = self | ||||
|             .active_transmissions | ||||
|             .iter() | ||||
|             .map(|transmission| { | ||||
|                 if let (Some(from_node), Some(to_node)) = ( | ||||
|                     self.server_nodes.get(&transmission.from_node), | ||||
|                     self.server_nodes.get(&transmission.to_node), | ||||
|                 ) { | ||||
|                     html! { | ||||
|                         <g class="transmission-group"> | ||||
|                             // Simple connection line with subtle animation | ||||
|                             <line | ||||
|                                 x1={from_node.x.to_string()} | ||||
|                                 y1={from_node.y.to_string()} | ||||
|                                 x2={to_node.x.to_string()} | ||||
|                                 y2={to_node.y.to_string()} | ||||
|                                 class="transmission-line" | ||||
|                             /> | ||||
|                         </g> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 } | ||||
|             } else { | ||||
|                 html! {} | ||||
|             } | ||||
|         }).collect::<Html>(); | ||||
|             }) | ||||
|             .collect::<Html>(); | ||||
|  | ||||
|         html! { | ||||
|             <div class="network-animation-overlay"> | ||||
|                 <svg  | ||||
|                     viewBox="0 0 783.086 400.649"  | ||||
|                 <svg | ||||
|                     viewBox="0 0 783.086 400.649" | ||||
|                     class="network-overlay-svg" | ||||
|                     style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;" | ||||
|                 > | ||||
| @@ -236,11 +250,11 @@ impl Component for NetworkAnimationView { | ||||
|                             <stop offset="100%" style="stop-color: var(--primary-color, #007bff); stop-opacity: 0" /> | ||||
|                         </@> | ||||
|                     </defs> | ||||
|                      | ||||
|  | ||||
|                     <g class="server-nodes"> | ||||
|                         { for server_pins } | ||||
|                     </g> | ||||
|                      | ||||
|  | ||||
|                     <g class="transmissions"> | ||||
|                         { transmissions } | ||||
|                     </g> | ||||
| @@ -248,7 +262,7 @@ impl Component for NetworkAnimationView { | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         if ctx.props().all_circles != old_props.all_circles { | ||||
|             self.server_nodes = Self::calculate_server_positions(&ctx.props().all_circles); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Pdf; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct PdfViewerProps { | ||||
| @@ -19,7 +19,7 @@ impl Component for PdfViewer { | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|  | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
| @@ -45,4 +45,4 @@ impl Component for PdfViewer { | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,19 +1,15 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{DateTime, Utc}; // Added TimeZone | ||||
| use heromodels::models::circle::Circle; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{Utc, DateTime}; // Added TimeZone | ||||
| use web_sys::MouseEvent; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| // Import from common_models | ||||
| use common_models::{ | ||||
|     Publication, | ||||
|     Deployment, | ||||
|     Deployment, Publication, PublicationSource, PublicationSourceType, PublicationStatus, | ||||
|     PublicationType, | ||||
|     PublicationStatus, | ||||
|     PublicationSource, | ||||
|     PublicationSourceType, | ||||
| }; | ||||
|  | ||||
| // --- Component-Specific View State Enums --- | ||||
| @@ -43,10 +39,10 @@ pub enum PublishingMsg { | ||||
|     SwitchView(PublishingViewEnum), | ||||
|     SwitchPublicationTab(PublishingPublicationTab), | ||||
|     CreateNewPublication, | ||||
|     TriggerDeployment(u32), // publication_id | ||||
|     DeletePublication(u32), // publication_id | ||||
|     SavePublicationSettings(u32), // publication_id | ||||
|     FetchPublications(String), // ws_url | ||||
|     TriggerDeployment(u32),                         // publication_id | ||||
|     DeletePublication(u32),                         // publication_id | ||||
|     SavePublicationSettings(u32),                   // publication_id | ||||
|     FetchPublications(String),                      // ws_url | ||||
|     PublicationsReceived(String, Vec<Publication>), // ws_url, publications | ||||
|     ActionCompleted(Result<String, String>), | ||||
| } | ||||
| @@ -109,7 +105,11 @@ impl Component for PublishingView { | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::PublicationsReceived(ws_url, publications) => { | ||||
|                 log::info!("Received {} publications from {}", publications.len(), ws_url); | ||||
|                 log::info!( | ||||
|                     "Received {} publications from {}", | ||||
|                     publications.len(), | ||||
|                     ws_url | ||||
|                 ); | ||||
|                 // Handle received publications - could update local cache if needed | ||||
|                 true | ||||
|             } | ||||
| @@ -128,7 +128,7 @@ impl Component for PublishingView { | ||||
|         let props = ctx.props(); | ||||
|  | ||||
|         // Aggregate publications and deployments from all_circles based on context | ||||
|         let (filtered_publications, filtered_deployments) =  | ||||
|         let (filtered_publications, filtered_deployments) = | ||||
|             get_filtered_publishing_data(&props.all_circles, &props.context_circle_ws_urls); | ||||
|  | ||||
|         match &self.current_view { | ||||
| @@ -150,15 +150,17 @@ impl Component for PublishingView { | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 } | ||||
|             }, | ||||
|             } | ||||
|             PublishingViewEnum::PublicationDetail(publication_id) => { | ||||
|                 let publication = filtered_publications.iter() | ||||
|                 let publication = filtered_publications | ||||
|                     .iter() | ||||
|                     .find(|p| p.id == *publication_id) // Now u32 == u32 | ||||
|                     .cloned(); | ||||
|                  | ||||
|  | ||||
|                 if let Some(pub_data) = publication { | ||||
|                     // Filter deployments specific to this publication | ||||
|                     let specific_deployments: Vec<Rc<Deployment>> = filtered_deployments.iter() | ||||
|                     let specific_deployments: Vec<Rc<Deployment>> = filtered_deployments | ||||
|                         .iter() | ||||
|                         .filter(|d| d.publication_id == pub_data.id) | ||||
|                         .cloned() | ||||
|                         .collect(); | ||||
| @@ -167,9 +169,9 @@ impl Component for PublishingView { | ||||
|                         <div class="view-container publishing-view-container"> | ||||
|                             <div class="publishing-content"> | ||||
|                                 { render_expanded_publication_card( | ||||
|                                     &pub_data,  | ||||
|                                     &pub_data, | ||||
|                                     link, | ||||
|                                     &self.active_publication_tab,  | ||||
|                                     &self.active_publication_tab, | ||||
|                                     &specific_deployments | ||||
|                                 )} | ||||
|                             </div> | ||||
| @@ -197,13 +199,15 @@ impl Component for PublishingView { | ||||
| impl PublishingView { | ||||
|     fn create_publication_via_script(&mut self, ctx: &Context<Self>) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|         let target_ws_url = props | ||||
|             .context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
|  | ||||
|         if let Some(ws_url) = target_ws_url { | ||||
|             let script = r#"create_publication("New Publication", "Website", "Draft");"#.to_string(); | ||||
|             let script = | ||||
|                 r#"create_publication("New Publication", "Website", "Draft");"#.to_string(); | ||||
|  | ||||
|             let link = ctx.link().clone(); | ||||
|             if let Some(script_future) = self.ws_manager.execute_script(&ws_url, script) { | ||||
| @@ -213,7 +217,10 @@ impl PublishingView { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!( | ||||
|                                 "{:?}", | ||||
|                                 e | ||||
|                             )))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
| @@ -223,7 +230,8 @@ impl PublishingView { | ||||
|  | ||||
|     fn trigger_deployment_via_script(&mut self, ctx: &Context<Self>, publication_id: u32) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|         let target_ws_url = props | ||||
|             .context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
| @@ -239,7 +247,10 @@ impl PublishingView { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!( | ||||
|                                 "{:?}", | ||||
|                                 e | ||||
|                             )))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
| @@ -249,7 +260,8 @@ impl PublishingView { | ||||
|  | ||||
|     fn delete_publication_via_script(&mut self, ctx: &Context<Self>, publication_id: u32) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|         let target_ws_url = props | ||||
|             .context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
| @@ -265,7 +277,10 @@ impl PublishingView { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!( | ||||
|                                 "{:?}", | ||||
|                                 e | ||||
|                             )))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
| @@ -275,7 +290,8 @@ impl PublishingView { | ||||
|  | ||||
|     fn save_publication_settings_via_script(&mut self, ctx: &Context<Self>, publication_id: u32) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|         let target_ws_url = props | ||||
|             .context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
| @@ -291,7 +307,10 @@ impl PublishingView { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!( | ||||
|                                 "{:?}", | ||||
|                                 e | ||||
|                             )))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
| @@ -303,7 +322,8 @@ impl PublishingView { | ||||
|         let script = r#" | ||||
|             let publications = get_publications(); | ||||
|             publications | ||||
|         "#.to_string(); | ||||
|         "# | ||||
|         .to_string(); | ||||
|  | ||||
|         if let Some(script_future) = self.ws_manager.execute_script(ws_url, script) { | ||||
|             spawn_local(async move { | ||||
| @@ -344,7 +364,8 @@ fn render_publication_tab_button( | ||||
|     let tab_clone = tab.clone(); | ||||
|     let icon_owned = icon.to_string(); | ||||
|     let label_owned = label.to_string(); | ||||
|     let on_click_handler = link.callback(move |_| PublishingMsg::SwitchPublicationTab(tab_clone.clone())); | ||||
|     let on_click_handler = | ||||
|         link.callback(move |_| PublishingMsg::SwitchPublicationTab(tab_clone.clone())); | ||||
|  | ||||
|     html! { | ||||
|         <button | ||||
| @@ -357,7 +378,10 @@ fn render_publication_tab_button( | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publications_list(publications: &[Rc<Publication>], link: &yew::html::Scope<PublishingView>) -> Html { | ||||
| fn render_publications_list( | ||||
|     publications: &[Rc<Publication>], | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
| ) -> Html { | ||||
|     if publications.is_empty() { | ||||
|         return html! { | ||||
|             <div class="publications-view empty-state"> | ||||
| @@ -437,20 +461,28 @@ fn render_publication_source(source: &Option<PublicationSource>) -> Html { | ||||
|                     <i class="fas fa-ban"></i> | ||||
|                     <span>{"N/A"}</span> | ||||
|                 </div> | ||||
|             } // End of PublicationSourceType::NotApplicable arm's html! | ||||
|             }, // End of PublicationSourceType::NotApplicable arm's html! | ||||
|         }, // End of Some(s) arm | ||||
|         None => html! { <div class="source-detail">{"Source not specified"}</div> } | ||||
|         None => html! { <div class="source-detail">{"Source not specified"}</div> }, | ||||
|     } // End of match source for render_publication_source | ||||
| } // End of fn render_publication_source | ||||
|  | ||||
| fn render_publication_card(publication: &Rc<Publication>, link: &yew::html::Scope<PublishingView>) -> Html { | ||||
|     let status_class_name = format!("status-{}", format!("{:?}", publication.status).to_lowercase()); | ||||
| fn render_publication_card( | ||||
|     publication: &Rc<Publication>, | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
| ) -> Html { | ||||
|     let status_class_name = format!( | ||||
|         "status-{}", | ||||
|         format!("{:?}", publication.status).to_lowercase() | ||||
|     ); | ||||
|     let status_color = get_status_color(&publication.status); | ||||
|  | ||||
|     let type_icon = get_publication_type_icon(&publication.publication_type); | ||||
|  | ||||
|     let publication_id = publication.id; | ||||
|     let onclick_details = link.callback(move |_| PublishingMsg::SwitchView(PublishingViewEnum::PublicationDetail(publication_id))); | ||||
|     let onclick_details = link.callback(move |_| { | ||||
|         PublishingMsg::SwitchView(PublishingViewEnum::PublicationDetail(publication_id)) | ||||
|     }); | ||||
|  | ||||
|     html! { | ||||
|         <div class={classes!("publication-card", status_class_name)} key={publication.id} onclick={onclick_details}> | ||||
| @@ -520,12 +552,11 @@ fn render_publication_card(publication: &Rc<Publication>, link: &yew::html::Scop | ||||
|     } | ||||
| } // End of fn render_publication_card | ||||
|  | ||||
|  | ||||
| fn render_expanded_publication_card( | ||||
|     publication: &Publication, | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
|     active_tab: &PublishingPublicationTab, | ||||
|     deployments: &[Rc<Deployment>] // Pass only relevant deployments | ||||
|     deployments: &[Rc<Deployment>], // Pass only relevant deployments | ||||
| ) -> Html { | ||||
|     let status_color = get_status_color(&publication.status); | ||||
|     let type_icon = get_publication_type_icon(&publication.publication_type); | ||||
| @@ -541,7 +572,7 @@ fn render_expanded_publication_card( | ||||
|                         <i class="fas fa-arrow-left"></i> | ||||
|                         <span>{"Back to Publications"}</span> | ||||
|                     </button> | ||||
|                      | ||||
|  | ||||
|                     <div class="publication-header"> | ||||
|                         <div class="publication-type"> | ||||
|                             <i class={type_icon}></i> | ||||
| @@ -556,7 +587,7 @@ fn render_expanded_publication_card( | ||||
|                 <div class="expanded-card-title"> | ||||
|                     <h2 class="card-title">{&publication.name}</h2> | ||||
|                     <p class="expanded-description">{publication.description.as_deref().unwrap_or("")}</p> | ||||
|                      | ||||
|  | ||||
|                     { if let Some(url) = &publication.live_url { | ||||
|                         html! { | ||||
|                             <div class="publication-url"> | ||||
| @@ -568,7 +599,7 @@ fn render_expanded_publication_card( | ||||
|                          html! { <div class="publication-url"><i class="fas fa-globe-americas"></i> {domain}</div> } | ||||
|                     } else { html! {} }} | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <div class="expanded-card-tabs"> | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Overview, active_tab, "fas fa-home", "Overview") } | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Analytics, active_tab, "fas fa-chart-line", "Analytics") } | ||||
| @@ -576,7 +607,7 @@ fn render_expanded_publication_card( | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Settings, active_tab, "fas fa-cog", "Settings") } | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|  | ||||
|             <div class="expanded-card-content"> | ||||
|                 { | ||||
|                     match active_tab { | ||||
| @@ -591,7 +622,10 @@ fn render_expanded_publication_card( | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_expanded_publication_overview(publication: &Publication, deployments: &[Rc<Deployment>]) -> Html { | ||||
| fn render_expanded_publication_overview( | ||||
|     publication: &Publication, | ||||
|     deployments: &[Rc<Deployment>], | ||||
| ) -> Html { | ||||
|     let recent_deployments: Vec<&Rc<Deployment>> = deployments.iter().take(3).collect(); | ||||
|  | ||||
|     html! { | ||||
| @@ -631,7 +665,7 @@ fn render_expanded_publication_overview(publication: &Publication, deployments: | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <div class="overview-section"> | ||||
|                     <h3>{"Recent Activity"}</h3> | ||||
|                     <div class="recent-deployments"> | ||||
| @@ -642,7 +676,7 @@ fn render_expanded_publication_overview(publication: &Publication, deployments: | ||||
|                         }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <div class="overview-section"> | ||||
|                     <h3>{"Configuration Summary"}</h3> | ||||
|                     <div class="config-summary"> | ||||
| @@ -696,7 +730,11 @@ fn render_publication_analytics(_publication: &Publication) -> Html { | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publication_deployments_tab(_publication: &Publication, deployments: &[Rc<Deployment>], link: &yew::html::Scope<PublishingView>) -> Html { | ||||
| fn render_publication_deployments_tab( | ||||
|     _publication: &Publication, | ||||
|     deployments: &[Rc<Deployment>], | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
| ) -> Html { | ||||
|     let publication_id = _publication.id; | ||||
|     html! { | ||||
|         <div class="publication-deployments-tab"> | ||||
| @@ -765,7 +803,10 @@ fn render_full_deployment_item(deployment: &Deployment) -> Html { | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publication_settings(publication: &Publication, link: &yew::html::Scope<PublishingView>) -> Html { | ||||
| fn render_publication_settings( | ||||
|     publication: &Publication, | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
| ) -> Html { | ||||
|     let publication_id = publication.id; | ||||
|     html! { | ||||
|         <div class="publication-settings"> | ||||
| @@ -798,7 +839,7 @@ fn render_publication_settings(publication: &Publication, link: &yew::html::Scop | ||||
|                     } else {html!{}}} | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|  | ||||
|             <div class="settings-section"> | ||||
|                 <h3>{"Domain Management"}</h3> | ||||
|                 <div class="settings-grid"> | ||||
| @@ -812,7 +853,7 @@ fn render_publication_settings(publication: &Publication, link: &yew::html::Scop | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|  | ||||
|             <div class="settings-section"> | ||||
|                 <h3>{"Danger Zone"}</h3> | ||||
|                 <div class="danger-actions"> | ||||
| @@ -864,7 +905,10 @@ fn get_mock_deployments() -> Vec<Rc<Deployment>> { | ||||
|  | ||||
| fn format_timestamp_string(timestamp_str: &str) -> String { | ||||
|     match DateTime::parse_from_rfc3339(timestamp_str) { | ||||
|         Ok(dt) => dt.with_timezone(&Utc).format("%b %d, %Y %H:%M UTC").to_string(), | ||||
|         Ok(dt) => dt | ||||
|             .with_timezone(&Utc) | ||||
|             .format("%b %d, %Y %H:%M UTC") | ||||
|             .to_string(), | ||||
|         Err(_) => timestamp_str.to_string(), // Fallback if parsing fails | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -37,4 +37,4 @@ pub fn sidebar_layout(props: &SidebarLayoutProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Slides; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct SlidesViewerProps { | ||||
| @@ -52,16 +52,20 @@ impl Component for SlidesViewer { | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         let total_slides = props.slides.slide_urls.len(); | ||||
|          | ||||
|  | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|          | ||||
|         let prev_handler = ctx.link().callback(|_: MouseEvent| SlidesViewerMsg::PrevSlide); | ||||
|         let next_handler = ctx.link().callback(|_: MouseEvent| SlidesViewerMsg::NextSlide); | ||||
|  | ||||
|         let prev_handler = ctx | ||||
|             .link() | ||||
|             .callback(|_: MouseEvent| SlidesViewerMsg::PrevSlide); | ||||
|         let next_handler = ctx | ||||
|             .link() | ||||
|             .callback(|_: MouseEvent| SlidesViewerMsg::NextSlide); | ||||
|  | ||||
|         html! { | ||||
|             <div class="asset-viewer slides-viewer"> | ||||
| @@ -133,4 +137,4 @@ impl Component for SlidesViewer { | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -44,4 +44,4 @@ pub fn render_world_map_svg() -> Html { | ||||
|         </g> | ||||
|         </svg> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -19,4 +19,3 @@ pub fn run_app() { | ||||
|     }; | ||||
|     yew::Renderer::<app::App>::with_props(props).render(); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
| use engine::{ | ||||
|     create_heromodels_engine, eval_script, | ||||
|     mock_db::{create_mock_db, seed_mock_db}, | ||||
| }; | ||||
| use rhai::Engine; | ||||
| use engine::{create_heromodels_engine, mock_db::{create_mock_db, seed_mock_db}, eval_script}; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientBuilder}; | ||||
|  | ||||
| // Since we're in a WASM environment, we need to handle the database differently | ||||
| // We'll create a mock database that works in WASM | ||||
| @@ -14,18 +17,18 @@ impl RhaiExecutor { | ||||
|         // Create a mock database for the engine | ||||
|         let db = create_mock_db(); | ||||
|         seed_mock_db(db.clone()); | ||||
|          | ||||
|  | ||||
|         // Create the heromodels engine with all the registered functions | ||||
|         let engine = create_heromodels_engine(db); | ||||
|          | ||||
|  | ||||
|         Self { engine } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     pub fn execute_script(&self, script: &str) -> Result<String, String> { | ||||
|         if script.trim().is_empty() { | ||||
|             return Err("Script cannot be empty".to_string()); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         match eval_script(&self.engine, script) { | ||||
|             Ok(result) => { | ||||
|                 let output = if result.is_unit() { | ||||
| @@ -35,9 +38,7 @@ impl RhaiExecutor { | ||||
|                 }; | ||||
|                 Ok(output) | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 Err(format!("Rhai execution error: {}", err)) | ||||
|             } | ||||
|             Err(err) => Err(format!("Rhai execution error: {}", err)), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -58,7 +59,7 @@ pub struct ScriptResponse { | ||||
| // For local execution (self circle) | ||||
| pub fn execute_rhai_script_local(script: &str) -> ScriptResponse { | ||||
|     let executor = RhaiExecutor::new(); | ||||
|      | ||||
|  | ||||
|     match executor.execute_script(script) { | ||||
|         Ok(output) => ScriptResponse { | ||||
|             output, | ||||
| @@ -74,7 +75,11 @@ pub fn execute_rhai_script_local(script: &str) -> ScriptResponse { | ||||
| } | ||||
|  | ||||
| // For remote execution (other circles via WebSocket) | ||||
| pub async fn execute_rhai_script_remote(script: &str, ws_url: &str, source_name: &str) -> ScriptResponse { | ||||
| pub async fn execute_rhai_script_remote( | ||||
|     script: &str, | ||||
|     ws_url: &str, | ||||
|     source_name: &str, | ||||
| ) -> ScriptResponse { | ||||
|     if script.trim().is_empty() { | ||||
|         return ScriptResponse { | ||||
|             output: "Error: Script cannot be empty".to_string(), | ||||
| @@ -82,9 +87,9 @@ pub async fn execute_rhai_script_remote(script: &str, ws_url: &str, source_name: | ||||
|             source: source_name.to_string(), | ||||
|         }; | ||||
|     } | ||||
|      | ||||
|  | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url.to_string()).build(); | ||||
|      | ||||
|  | ||||
|     // Connect to the WebSocket | ||||
|     match client.connect().await { | ||||
|         Ok(_) => { | ||||
| @@ -109,52 +114,50 @@ pub async fn execute_rhai_script_remote(script: &str, ws_url: &str, source_name: | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Err(e) => { | ||||
|             ScriptResponse { | ||||
|                 output: format!("Connection error: {}", e), | ||||
|                 success: false, | ||||
|                 source: source_name.to_string(), | ||||
|             } | ||||
|         } | ||||
|         Err(e) => ScriptResponse { | ||||
|             output: format!("Connection error: {}", e), | ||||
|             success: false, | ||||
|             source: source_name.to_string(), | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Broadcast script to all WebSocket URLs and return all responses | ||||
| pub async fn broadcast_rhai_script(script: &str, ws_urls: &[String]) -> Vec<ScriptResponse> { | ||||
|     let mut responses = Vec::new(); | ||||
|      | ||||
|  | ||||
|     // Add local execution first | ||||
|     // responses.push(execute_rhai_script_local(script)); | ||||
|      | ||||
|  | ||||
|     // Execute on all remote circles | ||||
|     for (index, ws_url) in ws_urls.iter().enumerate() { | ||||
|         let source_name = format!("Circle {}", index + 1); | ||||
|         let response = execute_rhai_script_remote(script, ws_url, &source_name).await; | ||||
|         responses.push(response); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     responses | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|      | ||||
|  | ||||
|     #[test] | ||||
|     fn test_basic_script_execution() { | ||||
|         let executor = RhaiExecutor::new(); | ||||
|          | ||||
|  | ||||
|         // Test simple arithmetic | ||||
|         let result = executor.execute_script("2 + 3"); | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(result.unwrap().contains("5")); | ||||
|          | ||||
|  | ||||
|         // Test variable assignment | ||||
|         let result = executor.execute_script("let x = 10; x * 2"); | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(result.unwrap().contains("20")); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     #[test] | ||||
|     fn test_empty_script() { | ||||
|         let executor = RhaiExecutor::new(); | ||||
| @@ -162,4 +165,4 @@ mod tests { | ||||
|         assert!(result.is_err()); | ||||
|         assert!(result.unwrap_err().contains("empty")); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| use std::collections::HashMap; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientBuilder, CircleWsClientError, PlayResultClient}; | ||||
| use log::{info, error, warn}; | ||||
| use crate::auth::AuthManager; | ||||
| use circle_client_ws::{ | ||||
|     CircleWsClient, CircleWsClientBuilder, CircleWsClientError, PlayResultClient, | ||||
| }; | ||||
| use heromodels::models::circle::Circle; | ||||
| use log::{error, info, warn}; | ||||
| use serde::de::DeserializeOwned; | ||||
| use std::rc::Rc; | ||||
| use std::cell::RefCell; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use yew::Callback; | ||||
| use heromodels::models::circle::Circle; | ||||
| use crate::auth::AuthManager; | ||||
|  | ||||
| /// Type alias for Circle-specific WebSocket manager | ||||
| pub type CircleWsManager = WsManager<Circle>; | ||||
| @@ -60,7 +62,9 @@ where | ||||
|  | ||||
|     /// Check if currently authenticated | ||||
|     pub fn is_authenticated(&self) -> bool { | ||||
|         self.auth_manager.as_ref().map_or(false, |auth| auth.is_authenticated()) | ||||
|         self.auth_manager | ||||
|             .as_ref() | ||||
|             .map_or(false, |auth| auth.is_authenticated()) | ||||
|     } | ||||
|  | ||||
|     /// Set callback for when data is fetched | ||||
| @@ -95,7 +99,11 @@ where | ||||
|     } | ||||
|  | ||||
|     /// Connect to a WebSocket server with explicit authentication | ||||
|     pub async fn connect_with_auth(&self, ws_url: String, force_auth: bool) -> Result<(), CircleWsClientError> { | ||||
|     pub async fn connect_with_auth( | ||||
|         &self, | ||||
|         ws_url: String, | ||||
|         force_auth: bool, | ||||
|     ) -> Result<(), CircleWsClientError> { | ||||
|         if self.clients.borrow().contains_key(&ws_url) { | ||||
|             info!("Already connected to {}", ws_url); | ||||
|             return Ok(()); | ||||
| @@ -106,17 +114,21 @@ where | ||||
|                 if auth_manager.is_authenticated() { | ||||
|                     auth_manager.create_authenticated_client(&ws_url).await? | ||||
|                 } else { | ||||
|                     return Err(CircleWsClientError::ConnectionError("Authentication required but not authenticated".to_string())); | ||||
|                     return Err(CircleWsClientError::ConnectionError( | ||||
|                         "Authentication required but not authenticated".to_string(), | ||||
|                     )); | ||||
|                 } | ||||
|             } else { | ||||
|                 return Err(CircleWsClientError::ConnectionError("Authentication required but no auth manager available".to_string())); | ||||
|                 return Err(CircleWsClientError::ConnectionError( | ||||
|                     "Authentication required but no auth manager available".to_string(), | ||||
|                 )); | ||||
|             } | ||||
|         } else { | ||||
|             let mut client = CircleWsClientBuilder::new(ws_url.clone()).build(); | ||||
|             client.connect().await?; | ||||
|             client | ||||
|         }; | ||||
|          | ||||
|  | ||||
|         info!("Connected to WebSocket with auth: {}", ws_url); | ||||
|         self.clients.borrow_mut().insert(ws_url, client); | ||||
|         Ok(()) | ||||
| @@ -130,7 +142,7 @@ where | ||||
|             let ws_url_clone = ws_url.to_string(); | ||||
|             let callback = self.on_data_fetched.borrow().clone(); | ||||
|             let clients = self.clients.clone(); | ||||
|              | ||||
|  | ||||
|             spawn_local(async move { | ||||
|                 // Get the client inside the async block | ||||
|                 let play_future = { | ||||
| @@ -141,12 +153,12 @@ where | ||||
|                         None | ||||
|                     } | ||||
|                 }; | ||||
|                  | ||||
|  | ||||
|                 if let Some(future) = play_future { | ||||
|                     match future.await { | ||||
|                         Ok(result) => { | ||||
|                             info!("Received data from {}: {}", ws_url_clone, result.output); | ||||
|                              | ||||
|  | ||||
|                             // Parse the JSON response to extract data | ||||
|                             match serde_json::from_str::<T>(&result.output) { | ||||
|                                 Ok(data) => { | ||||
| @@ -157,7 +169,10 @@ where | ||||
|                                 Err(e) => { | ||||
|                                     error!("Failed to parse data from {}: {}", ws_url_clone, e); | ||||
|                                     if let Some(cb) = callback { | ||||
|                                         cb.emit((ws_url_clone.clone(), Err(format!("Failed to parse data: {}", e)))); | ||||
|                                         cb.emit(( | ||||
|                                             ws_url_clone.clone(), | ||||
|                                             Err(format!("Failed to parse data: {}", e)), | ||||
|                                         )); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
| @@ -165,7 +180,10 @@ where | ||||
|                         Err(e) => { | ||||
|                             error!("Failed to fetch data from {}: {:?}", ws_url_clone, e); | ||||
|                             if let Some(cb) = callback { | ||||
|                                 cb.emit((ws_url_clone.clone(), Err(format!("WebSocket error: {:?}", e)))); | ||||
|                                 cb.emit(( | ||||
|                                     ws_url_clone.clone(), | ||||
|                                     Err(format!("WebSocket error: {:?}", e)), | ||||
|                                 )); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| @@ -174,16 +192,24 @@ where | ||||
|         } else { | ||||
|             warn!("No client found for WebSocket URL: {}", ws_url); | ||||
|             if let Some(cb) = &*self.on_data_fetched.borrow() { | ||||
|                 cb.emit((ws_url.to_string(), Err(format!("No connection to {}", ws_url)))); | ||||
|                 cb.emit(( | ||||
|                     ws_url.to_string(), | ||||
|                     Err(format!("No connection to {}", ws_url)), | ||||
|                 )); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Execute a Rhai script on a specific server | ||||
|     pub fn execute_script(&self, ws_url: &str, script: String) -> Option<impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>>> { | ||||
|     pub fn execute_script( | ||||
|         &self, | ||||
|         ws_url: &str, | ||||
|         script: String, | ||||
|     ) -> Option<impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>>> | ||||
|     { | ||||
|         let clients = self.clients.clone(); | ||||
|         let ws_url = ws_url.to_string(); | ||||
|          | ||||
|  | ||||
|         if clients.borrow().contains_key(&ws_url) { | ||||
|             Some(async move { | ||||
|                 let clients_borrow = clients.borrow(); | ||||
| @@ -221,8 +247,8 @@ where | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T> Drop for WsManager<T>  | ||||
| where  | ||||
| impl<T> Drop for WsManager<T> | ||||
| where | ||||
|     T: DeserializeOwned + Clone + 'static, | ||||
| { | ||||
|     fn drop(&mut self) { | ||||
| @@ -239,7 +265,7 @@ where | ||||
|     T: DeserializeOwned + Clone, | ||||
| { | ||||
|     let mut results = HashMap::new(); | ||||
|      | ||||
|  | ||||
|     for ws_url in ws_urls { | ||||
|         match fetch_data_from_ws_url::<T>(ws_url, &script).await { | ||||
|             Ok(data) => { | ||||
| @@ -250,7 +276,7 @@ where | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     results | ||||
| } | ||||
|  | ||||
| @@ -260,27 +286,31 @@ where | ||||
|     T: DeserializeOwned, | ||||
| { | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url.to_string()).build(); | ||||
|      | ||||
|  | ||||
|     // Connect to the WebSocket | ||||
|     client.connect().await | ||||
|     client | ||||
|         .connect() | ||||
|         .await | ||||
|         .map_err(|e| format!("Failed to connect to {}: {:?}", ws_url, e))?; | ||||
|      | ||||
|  | ||||
|     info!("Connected to WebSocket: {}", ws_url); | ||||
|      | ||||
|  | ||||
|     // Execute the script | ||||
|     let result = client.play(script.to_string()).await | ||||
|     let result = client | ||||
|         .play(script.to_string()) | ||||
|         .await | ||||
|         .map_err(|e| format!("Failed to execute script on {}: {:?}", ws_url, e))?; | ||||
|      | ||||
|  | ||||
|     info!("Received data from {}: {}", ws_url, result.output); | ||||
|      | ||||
|  | ||||
|     // Parse the JSON response | ||||
|     let data: T = serde_json::from_str(&result.output) | ||||
|         .map_err(|e| format!("Failed to parse data from {}: {}", ws_url, e))?; | ||||
|      | ||||
|  | ||||
|     // Disconnect | ||||
|     client.disconnect().await; | ||||
|     info!("Disconnected from WebSocket: {}", ws_url); | ||||
|      | ||||
|  | ||||
|     Ok(data) | ||||
| } | ||||
|  | ||||
| @@ -332,13 +362,13 @@ where | ||||
| pub async fn fetch_data_from_ws_urls_with_auth<T>( | ||||
|     ws_urls: &[String], | ||||
|     script: String, | ||||
|     auth_manager: &AuthManager | ||||
|     auth_manager: &AuthManager, | ||||
| ) -> HashMap<String, T> | ||||
| where | ||||
|     T: DeserializeOwned + Clone, | ||||
| { | ||||
|     let mut results = HashMap::new(); | ||||
|      | ||||
|  | ||||
|     for ws_url in ws_urls { | ||||
|         match fetch_data_from_ws_url_with_auth::<T>(ws_url, &script, auth_manager).await { | ||||
|             Ok(data) => { | ||||
| @@ -349,6 +379,6 @@ where | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     results | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,20 +1,20 @@ | ||||
| //! Cryptographic utilities for secp256k1 operations | ||||
| //!  | ||||
| //! | ||||
| //! This module provides functions for: | ||||
| //! - Private key validation and parsing | ||||
| //! - Public key derivation | ||||
| //! - Ethereum-style message signing | ||||
| //! - Signature verification | ||||
|  | ||||
| use crate::auth::types::{AuthResult, AuthError}; | ||||
| use crate::auth::types::{AuthError, AuthResult}; | ||||
|  | ||||
| /// Generate a new random private key | ||||
| pub fn generate_private_key() -> AuthResult<String> { | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         use secp256k1::Secp256k1; | ||||
|         use rand::rngs::OsRng; | ||||
|          | ||||
|         use secp256k1::Secp256k1; | ||||
|  | ||||
|         let secp = Secp256k1::new(); | ||||
|         let (secret_key, _) = secp.generate_keypair(&mut OsRng); | ||||
|         Ok(hex::encode(secret_key.secret_bytes())) | ||||
| @@ -32,19 +32,22 @@ pub fn generate_private_key() -> AuthResult<String> { | ||||
| /// Parse a hex-encoded private key | ||||
| pub fn parse_private_key(private_key_hex: &str) -> AuthResult<Vec<u8>> { | ||||
|     // Remove 0x prefix if present | ||||
|     let clean_hex = private_key_hex.strip_prefix("0x").unwrap_or(private_key_hex); | ||||
|      | ||||
|     let clean_hex = private_key_hex | ||||
|         .strip_prefix("0x") | ||||
|         .unwrap_or(private_key_hex); | ||||
|  | ||||
|     // Decode hex | ||||
|     let bytes = hex::decode(clean_hex) | ||||
|         .map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid hex: {}", e)))?; | ||||
|      | ||||
|  | ||||
|     // Validate length | ||||
|     if bytes.len() != 32 { | ||||
|         return Err(AuthError::InvalidPrivateKey( | ||||
|             format!("Private key must be 32 bytes, got {}", bytes.len()) | ||||
|         )); | ||||
|         return Err(AuthError::InvalidPrivateKey(format!( | ||||
|             "Private key must be 32 bytes, got {}", | ||||
|             bytes.len() | ||||
|         ))); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     Ok(bytes) | ||||
| } | ||||
|  | ||||
| @@ -52,15 +55,15 @@ pub fn parse_private_key(private_key_hex: &str) -> AuthResult<Vec<u8>> { | ||||
| pub fn derive_public_key(private_key_hex: &str) -> AuthResult<String> { | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         use secp256k1::{Secp256k1, SecretKey, PublicKey}; | ||||
|          | ||||
|         use secp256k1::{PublicKey, Secp256k1, SecretKey}; | ||||
|  | ||||
|         let key_bytes = parse_private_key(private_key_hex)?; | ||||
|         let secret_key = SecretKey::from_slice(&key_bytes) | ||||
|             .map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid key: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         let secp = Secp256k1::new(); | ||||
|         let public_key = PublicKey::from_secret_key(&secp, &secret_key); | ||||
|          | ||||
|  | ||||
|         // Return uncompressed public key (65 bytes with 0x04 prefix) | ||||
|         Ok(hex::encode(public_key.serialize_uncompressed())) | ||||
|     } | ||||
| @@ -81,7 +84,7 @@ pub fn derive_public_key(private_key_hex: &str) -> AuthResult<String> { | ||||
| fn create_eth_message_hash(message: &str) -> Vec<u8> { | ||||
|     let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); | ||||
|     let full_message = format!("{}{}", prefix, message); | ||||
|      | ||||
|  | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         use sha3::{Digest, Keccak256}; | ||||
| @@ -94,7 +97,7 @@ fn create_eth_message_hash(message: &str) -> Vec<u8> { | ||||
|         // Fallback: use a simple hash | ||||
|         use std::collections::hash_map::DefaultHasher; | ||||
|         use std::hash::{Hash, Hasher}; | ||||
|          | ||||
|  | ||||
|         let mut hasher = DefaultHasher::new(); | ||||
|         full_message.hash(&mut hasher); | ||||
|         let hash = hasher.finish(); | ||||
| @@ -106,30 +109,30 @@ fn create_eth_message_hash(message: &str) -> Vec<u8> { | ||||
| pub fn sign_message(private_key_hex: &str, message: &str) -> AuthResult<String> { | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         use secp256k1::{Secp256k1, SecretKey, Message}; | ||||
|          | ||||
|         use secp256k1::{Message, Secp256k1, SecretKey}; | ||||
|  | ||||
|         let key_bytes = parse_private_key(private_key_hex)?; | ||||
|         let secret_key = SecretKey::from_slice(&key_bytes) | ||||
|             .map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid key: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         let secp = Secp256k1::new(); | ||||
|          | ||||
|  | ||||
|         // Create Ethereum-style message hash | ||||
|         let message_hash = create_eth_message_hash(message); | ||||
|          | ||||
|  | ||||
|         // Create secp256k1 message from hash | ||||
|         let msg = Message::from_digest_slice(&message_hash[..32]) | ||||
|             .map_err(|e| AuthError::SigningFailed(format!("Invalid message hash: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         // Sign the message with recovery | ||||
|         let recoverable_signature = secp.sign_ecdsa_recoverable(&msg, &secret_key); | ||||
|         let (recovery_id, signature) = recoverable_signature.serialize_compact(); | ||||
|          | ||||
|  | ||||
|         // Format as Ethereum signature: r + s + v (where v = recovery_id + 27) | ||||
|         let mut sig_bytes = Vec::with_capacity(65); | ||||
|         sig_bytes.extend_from_slice(&signature[..]); | ||||
|         sig_bytes.push(recovery_id.to_i32() as u8 + 27); | ||||
|          | ||||
|  | ||||
|         Ok(hex::encode(sig_bytes)) | ||||
|     } | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
| @@ -137,54 +140,59 @@ pub fn sign_message(private_key_hex: &str, message: &str) -> AuthResult<String> | ||||
|         // Fallback implementation - generate a mock signature | ||||
|         let key_bytes = parse_private_key(private_key_hex)?; | ||||
|         let message_hash = create_eth_message_hash(message); | ||||
|          | ||||
|  | ||||
|         // Create a deterministic but fake signature | ||||
|         let mut sig_bytes = Vec::with_capacity(65); | ||||
|         sig_bytes.extend_from_slice(&key_bytes); | ||||
|         sig_bytes.extend_from_slice(&message_hash[..32]); | ||||
|         sig_bytes.push(27); // Recovery ID | ||||
|         sig_bytes.truncate(65); | ||||
|          | ||||
|  | ||||
|         Ok(hex::encode(sig_bytes)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Verify an Ethereum-style signature | ||||
| pub fn verify_signature(public_key_hex: &str, message: &str, signature_hex: &str) -> AuthResult<bool> { | ||||
| pub fn verify_signature( | ||||
|     public_key_hex: &str, | ||||
|     message: &str, | ||||
|     signature_hex: &str, | ||||
| ) -> AuthResult<bool> { | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         use secp256k1::{Secp256k1, PublicKey, Message, ecdsa::Signature}; | ||||
|          | ||||
|         use secp256k1::{ecdsa::Signature, Message, PublicKey, Secp256k1}; | ||||
|  | ||||
|         // Remove 0x prefix if present | ||||
|         let clean_pubkey = public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex); | ||||
|         let clean_sig = signature_hex.strip_prefix("0x").unwrap_or(signature_hex); | ||||
|          | ||||
|  | ||||
|         // Decode public key | ||||
|         let pubkey_bytes = hex::decode(clean_pubkey) | ||||
|             .map_err(|e| AuthError::InvalidSignature(format!("Invalid public key hex: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         let public_key = PublicKey::from_slice(&pubkey_bytes) | ||||
|             .map_err(|e| AuthError::InvalidSignature(format!("Invalid public key: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         // Decode signature | ||||
|         let sig_bytes = hex::decode(clean_sig) | ||||
|             .map_err(|e| AuthError::InvalidSignature(format!("Invalid signature hex: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         if sig_bytes.len() != 65 { | ||||
|             return Err(AuthError::InvalidSignature( | ||||
|                 format!("Signature must be 65 bytes, got {}", sig_bytes.len()) | ||||
|             )); | ||||
|             return Err(AuthError::InvalidSignature(format!( | ||||
|                 "Signature must be 65 bytes, got {}", | ||||
|                 sig_bytes.len() | ||||
|             ))); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         // Extract r, s components (ignore recovery byte for verification) | ||||
|         let signature = Signature::from_compact(&sig_bytes[..64]) | ||||
|             .map_err(|e| AuthError::InvalidSignature(format!("Invalid signature format: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         // Create message hash | ||||
|         let message_hash = create_eth_message_hash(message); | ||||
|         let msg = Message::from_digest_slice(&message_hash[..32]) | ||||
|             .map_err(|e| AuthError::InvalidSignature(format!("Invalid message hash: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         // Verify signature | ||||
|         let secp = Secp256k1::new(); | ||||
|         match secp.verify_ecdsa(&msg, &signature, &public_key) { | ||||
| @@ -197,16 +205,22 @@ pub fn verify_signature(public_key_hex: &str, message: &str, signature_hex: &str | ||||
|         // Fallback implementation - basic validation | ||||
|         let clean_pubkey = public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex); | ||||
|         let clean_sig = signature_hex.strip_prefix("0x").unwrap_or(signature_hex); | ||||
|          | ||||
|  | ||||
|         // Basic validation | ||||
|         if clean_pubkey.len() != 130 { // 65 bytes as hex | ||||
|             return Err(AuthError::InvalidSignature("Invalid public key length".to_string())); | ||||
|         if clean_pubkey.len() != 130 { | ||||
|             // 65 bytes as hex | ||||
|             return Err(AuthError::InvalidSignature( | ||||
|                 "Invalid public key length".to_string(), | ||||
|             )); | ||||
|         } | ||||
|          | ||||
|         if clean_sig.len() != 130 { // 65 bytes as hex | ||||
|             return Err(AuthError::InvalidSignature("Invalid signature length".to_string())); | ||||
|  | ||||
|         if clean_sig.len() != 130 { | ||||
|             // 65 bytes as hex | ||||
|             return Err(AuthError::InvalidSignature( | ||||
|                 "Invalid signature length".to_string(), | ||||
|             )); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         // For app purposes, accept any properly formatted signature | ||||
|         Ok(true) | ||||
|     } | ||||
| @@ -226,7 +240,7 @@ mod tests { | ||||
|     fn test_key_generation_and_derivation() { | ||||
|         let private_key = generate_private_key().unwrap(); | ||||
|         let public_key = derive_public_key(&private_key).unwrap(); | ||||
|          | ||||
|  | ||||
|         assert_eq!(private_key.len(), 64); // 32 bytes as hex | ||||
|         assert_eq!(public_key.len(), 130); // 65 bytes as hex (uncompressed) | ||||
|         assert!(public_key.starts_with("04")); // Uncompressed public key prefix | ||||
| @@ -237,10 +251,10 @@ mod tests { | ||||
|         let private_key = generate_private_key().unwrap(); | ||||
|         let public_key = derive_public_key(&private_key).unwrap(); | ||||
|         let message = "Hello, World!"; | ||||
|          | ||||
|  | ||||
|         let signature = sign_message(&private_key, message).unwrap(); | ||||
|         let is_valid = verify_signature(&public_key, message, &signature).unwrap(); | ||||
|          | ||||
|  | ||||
|         assert!(is_valid); | ||||
|         assert_eq!(signature.len(), 130); // 65 bytes as hex | ||||
|     } | ||||
| @@ -249,8 +263,8 @@ mod tests { | ||||
|     fn test_invalid_private_key() { | ||||
|         let result = validate_private_key("invalid_hex"); | ||||
|         assert!(result.is_err()); | ||||
|          | ||||
|  | ||||
|         let result = validate_private_key("0x1234"); // Too short | ||||
|         assert!(result.is_err()); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -46,16 +46,12 @@ | ||||
| //! ``` | ||||
|  | ||||
| pub mod types; | ||||
| pub use types::{AuthResult, AuthError, AuthCredentials, NonceResponse}; | ||||
| pub use types::{AuthCredentials, AuthError, AuthResult, NonceResponse}; | ||||
|  | ||||
| pub mod crypto_utils; | ||||
| pub use crypto_utils::{ | ||||
|     generate_private_key, | ||||
|     parse_private_key, | ||||
|     derive_public_key, | ||||
|     sign_message, | ||||
|     derive_public_key, generate_private_key, parse_private_key, sign_message, validate_private_key, | ||||
|     verify_signature, | ||||
|     validate_private_key, | ||||
| }; | ||||
|  | ||||
| /// Check if the authentication feature is enabled | ||||
| @@ -81,13 +77,13 @@ pub fn auth_version_info() -> String { | ||||
|     } else { | ||||
|         "disabled (fallback mode)" | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let platform = if cfg!(target_arch = "wasm32") { | ||||
|         "WASM" | ||||
|     } else { | ||||
|         "native" | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     format!( | ||||
|         "circles-client-ws auth module - crypto: {}, platform: {}", | ||||
|         crypto_status, platform | ||||
| @@ -102,7 +98,7 @@ mod tests { | ||||
|     fn test_module_exports() { | ||||
|         // Test utility functions | ||||
|         assert!(auth_version_info().contains("circles-client-ws auth module")); | ||||
|          | ||||
|  | ||||
|         // Test feature detection | ||||
|         let _is_enabled = is_auth_enabled(); | ||||
|     } | ||||
| @@ -114,4 +110,4 @@ mod tests { | ||||
|         assert!(version.contains("crypto:")); | ||||
|         assert!(version.contains("platform:")); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| //! Authentication types for Circle WebSocket client | ||||
| //!  | ||||
| //! | ||||
| //! This module defines the core types used in the authentication system, | ||||
| //! including error types, response structures, and authentication states. | ||||
|  | ||||
| @@ -107,9 +107,7 @@ pub enum AuthState { | ||||
|     /// Currently authenticating | ||||
|     Authenticating, | ||||
|     /// Successfully authenticated | ||||
|     Authenticated { | ||||
|         public_key: String, | ||||
|     }, | ||||
|     Authenticated { public_key: String }, | ||||
|     /// Authentication failed | ||||
|     Failed(String), | ||||
| } | ||||
| @@ -127,4 +125,4 @@ impl std::fmt::Display for AuthMethod { | ||||
|             AuthMethod::PrivateKey => write!(f, "Private Key"), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use futures_channel::{mpsc, oneshot}; | ||||
| use futures_util::{StreamExt, SinkExt, FutureExt}; | ||||
| use futures_util::{FutureExt, SinkExt, StreamExt}; | ||||
| use log::{debug, error, info, warn}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json::Value; | ||||
| @@ -16,23 +16,26 @@ pub use auth::{AuthCredentials, AuthError, AuthResult}; | ||||
| // Platform-specific WebSocket imports and spawn function | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| use { | ||||
|     gloo_net::websocket::{futures::WebSocket, Message as GlooWsMessage, WebSocketError as GlooWebSocketError}, | ||||
|     gloo_net::websocket::{ | ||||
|         futures::WebSocket, Message as GlooWsMessage, WebSocketError as GlooWebSocketError, | ||||
|     }, | ||||
|     wasm_bindgen_futures::spawn_local, | ||||
| }; | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| use { | ||||
|     tokio_tungstenite::{ | ||||
|         connect_async_with_config, | ||||
|         tungstenite::{protocol::Message as TungsteniteWsMessage, client::IntoClientRequest, handshake::client::Response}, | ||||
|         WebSocketStream, MaybeTlsStream, | ||||
|     }, | ||||
|     tokio::spawn as spawn_local, | ||||
|     native_tls::{TlsConnector}, | ||||
|     native_tls::TlsConnector, | ||||
|     tokio::net::TcpStream, | ||||
|     tokio::spawn as spawn_local, | ||||
|     tokio_tungstenite::{ | ||||
|         tungstenite::{ | ||||
|             client::IntoClientRequest, handshake::client::Response, | ||||
|             protocol::Message as TungsteniteWsMessage, | ||||
|         }, | ||||
|         MaybeTlsStream, WebSocketStream, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
|  | ||||
| // JSON-RPC Structures (client-side perspective) | ||||
| #[derive(Serialize, Debug, Clone)] | ||||
| pub struct JsonRpcRequestClient { | ||||
| @@ -44,7 +47,8 @@ pub struct JsonRpcRequestClient { | ||||
|  | ||||
| #[derive(Deserialize, Debug, Clone)] | ||||
| pub struct JsonRpcResponseClient { | ||||
|     #[allow(dead_code)] // Field is part of JSON-RPC spec, even if not directly used by client logic | ||||
|     #[allow(dead_code)] | ||||
|     // Field is part of JSON-RPC spec, even if not directly used by client logic | ||||
|     jsonrpc: String, | ||||
|     pub result: Option<Value>, | ||||
|     pub error: Option<JsonRpcErrorClient>, | ||||
| @@ -97,7 +101,11 @@ pub enum CircleWsClientError { | ||||
|     #[error("Request timed out for request ID: {0}")] | ||||
|     Timeout(String), | ||||
|     #[error("JSON-RPC error response: {code} - {message}")] | ||||
|     JsonRpcError { code: i32, message: String, data: Option<Value> }, | ||||
|     JsonRpcError { | ||||
|         code: i32, | ||||
|         message: String, | ||||
|         data: Option<Value>, | ||||
|     }, | ||||
|     #[error("No response received for request ID: {0}")] | ||||
|     NoResponse(String), | ||||
|     #[error("Client is not connected")] | ||||
| @@ -112,7 +120,10 @@ pub enum CircleWsClientError { | ||||
|  | ||||
| // Wrapper for messages sent to the WebSocket task | ||||
| enum InternalWsMessage { | ||||
|     SendJsonRpc(JsonRpcRequestClient, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>), | ||||
|     SendJsonRpc( | ||||
|         JsonRpcRequestClient, | ||||
|         oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>, | ||||
|     ), | ||||
|     Close, | ||||
| } | ||||
|  | ||||
| @@ -155,29 +166,43 @@ pub struct CircleWsClient { | ||||
|  | ||||
| impl CircleWsClient { | ||||
|     pub async fn authenticate(&mut self) -> Result<bool, CircleWsClientError> { | ||||
|         let private_key = self.private_key.as_ref().ok_or(CircleWsClientError::AuthNoKeyPair)?; | ||||
|         let private_key = self | ||||
|             .private_key | ||||
|             .as_ref() | ||||
|             .ok_or(CircleWsClientError::AuthNoKeyPair)?; | ||||
|         let public_key = auth::derive_public_key(private_key)?; | ||||
|  | ||||
|         let nonce = self.fetch_nonce(&public_key).await?; | ||||
|         let signature = auth::sign_message(private_key, &nonce)?; | ||||
|  | ||||
|         self.authenticate_with_signature(&public_key, &signature).await | ||||
|         self.authenticate_with_signature(&public_key, &signature) | ||||
|             .await | ||||
|     } | ||||
|  | ||||
|     async fn fetch_nonce(&self, pubkey: &str) -> Result<String, CircleWsClientError> { | ||||
|         let params = FetchNonceParams { pubkey: pubkey.to_string() }; | ||||
|         let params = FetchNonceParams { | ||||
|             pubkey: pubkey.to_string(), | ||||
|         }; | ||||
|         let req = self.create_request("fetch_nonce", params)?; | ||||
|         let res = self.send_request(req).await?; | ||||
|  | ||||
|         if let Some(err) = res.error { | ||||
|             return Err(CircleWsClientError::JsonRpcError { code: err.code, message: err.message, data: err.data }); | ||||
|             return Err(CircleWsClientError::JsonRpcError { | ||||
|                 code: err.code, | ||||
|                 message: err.message, | ||||
|                 data: err.data, | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         let nonce_res: FetchNonceResponse = serde_json::from_value(res.result.unwrap_or_default())?; | ||||
|         Ok(nonce_res.nonce) | ||||
|     } | ||||
|  | ||||
|     async fn authenticate_with_signature(&self, pubkey: &str, signature: &str) -> Result<bool, CircleWsClientError> { | ||||
|     async fn authenticate_with_signature( | ||||
|         &self, | ||||
|         pubkey: &str, | ||||
|         signature: &str, | ||||
|     ) -> Result<bool, CircleWsClientError> { | ||||
|         let params = AuthCredentialsParams { | ||||
|             pubkey: pubkey.to_string(), | ||||
|             signature: signature.to_string(), | ||||
| @@ -186,13 +211,24 @@ impl CircleWsClient { | ||||
|         let res = self.send_request(req).await?; | ||||
|  | ||||
|         if let Some(err) = res.error { | ||||
|             return Err(CircleWsClientError::JsonRpcError { code: err.code, message: err.message, data: err.data }); | ||||
|             return Err(CircleWsClientError::JsonRpcError { | ||||
|                 code: err.code, | ||||
|                 message: err.message, | ||||
|                 data: err.data, | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         Ok(res.result.and_then(|v| v.get("authenticated").and_then(|v| v.as_bool())).unwrap_or(false)) | ||||
|         Ok(res | ||||
|             .result | ||||
|             .and_then(|v| v.get("authenticated").and_then(|v| v.as_bool())) | ||||
|             .unwrap_or(false)) | ||||
|     } | ||||
|  | ||||
|     fn create_request<T: Serialize>(&self, method: &str, params: T) -> Result<JsonRpcRequestClient, CircleWsClientError> { | ||||
|     fn create_request<T: Serialize>( | ||||
|         &self, | ||||
|         method: &str, | ||||
|         params: T, | ||||
|     ) -> Result<JsonRpcRequestClient, CircleWsClientError> { | ||||
|         Ok(JsonRpcRequestClient { | ||||
|             jsonrpc: "2.0".to_string(), | ||||
|             method: method.to_string(), | ||||
| @@ -201,11 +237,20 @@ impl CircleWsClient { | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     async fn send_request(&self, req: JsonRpcRequestClient) -> Result<JsonRpcResponseClient, CircleWsClientError> { | ||||
|     async fn send_request( | ||||
|         &self, | ||||
|         req: JsonRpcRequestClient, | ||||
|     ) -> Result<JsonRpcResponseClient, CircleWsClientError> { | ||||
|         let (response_tx, response_rx) = oneshot::channel(); | ||||
|         if let Some(mut tx) = self.internal_tx.clone() { | ||||
|             tx.send(InternalWsMessage::SendJsonRpc(req.clone(), response_tx)).await | ||||
|                 .map_err(|e| CircleWsClientError::ChannelError(format!("Failed to send request to internal task: {}", e)))?; | ||||
|             tx.send(InternalWsMessage::SendJsonRpc(req.clone(), response_tx)) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     CircleWsClientError::ChannelError(format!( | ||||
|                         "Failed to send request to internal task: {}", | ||||
|                         e | ||||
|                     )) | ||||
|                 })?; | ||||
|         } else { | ||||
|             return Err(CircleWsClientError::NotConnected); | ||||
|         } | ||||
| @@ -224,14 +269,14 @@ impl CircleWsClient { | ||||
|             match tokio_timeout(std::time::Duration::from_secs(30), response_rx).await { | ||||
|                 Ok(Ok(Ok(rpc_response))) => Ok(rpc_response), | ||||
|                 Ok(Ok(Err(e))) => Err(e), | ||||
|                 Ok(Err(_)) => Err(CircleWsClientError::ChannelError("Response channel cancelled".to_string())), | ||||
|                 Ok(Err(_)) => Err(CircleWsClientError::ChannelError( | ||||
|                     "Response channel cancelled".to_string(), | ||||
|                 )), | ||||
|                 Err(_) => Err(CircleWsClientError::Timeout(req.id)), | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     pub async fn connect(&mut self) -> Result<(), CircleWsClientError> { | ||||
|         if self.internal_tx.is_some() { | ||||
|             info!("Client already connected or connecting."); | ||||
| @@ -240,14 +285,20 @@ impl CircleWsClient { | ||||
|  | ||||
|         let (internal_tx, internal_rx) = mpsc::channel::<InternalWsMessage>(32); | ||||
|         self.internal_tx = Some(internal_tx); | ||||
|          | ||||
|  | ||||
|         // Determine the final URL to connect to - always use the base ws_url now | ||||
|         let connection_url = self.ws_url.replace("ws://", "ws://"); | ||||
|         info!("Connecting to WebSocket: {}", connection_url); | ||||
|          | ||||
|  | ||||
|         // Pending requests: map request_id to a oneshot sender for the response | ||||
|         let pending_requests: Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>> =  | ||||
|             Arc::new(Mutex::new(HashMap::new())); | ||||
|         let pending_requests: Arc< | ||||
|             Mutex< | ||||
|                 HashMap< | ||||
|                     String, | ||||
|                     oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>, | ||||
|                 >, | ||||
|             >, | ||||
|         > = Arc::new(Mutex::new(HashMap::new())); | ||||
|  | ||||
|         let task_pending_requests = pending_requests.clone(); | ||||
|         let log_url = connection_url.clone(); | ||||
| @@ -255,41 +306,70 @@ impl CircleWsClient { | ||||
|         let task = async move { | ||||
|             #[cfg(target_arch = "wasm32")] | ||||
|             let ws_result = WebSocket::open(&connection_url); | ||||
|              | ||||
|  | ||||
|             #[cfg(not(target_arch = "wasm32"))] | ||||
|             let connect_attempt = async { | ||||
|                 let mut request = connection_url.into_client_request() | ||||
|                 let mut request = connection_url | ||||
|                     .into_client_request() | ||||
|                     .map_err(|e| CircleWsClientError::ConnectionError(e.to_string()))?; | ||||
|                 let headers = request.headers_mut(); | ||||
|                 let _headers = request.headers_mut(); | ||||
|                 // You can add custom headers here if needed, for example: | ||||
|                 // headers.insert("My-Header", "My-Value".try_into().unwrap()); | ||||
|  | ||||
|                 let connector = TlsConnector::builder() | ||||
|                     .danger_accept_invalid_certs(true) | ||||
|                     .build() | ||||
|                     .map_err(|e| CircleWsClientError::ConnectionError(format!("Failed to create TLS connector: {}", e)))?; | ||||
|                  | ||||
|                 let authority = request.uri().authority().ok_or_else(|| CircleWsClientError::ConnectionError("Invalid URL: missing authority".to_string()))?.as_str(); | ||||
|                     .map_err(|e| { | ||||
|                         CircleWsClientError::ConnectionError(format!( | ||||
|                             "Failed to create TLS connector: {}", | ||||
|                             e | ||||
|                         )) | ||||
|                     })?; | ||||
|  | ||||
|                 let authority = request | ||||
|                     .uri() | ||||
|                     .authority() | ||||
|                     .ok_or_else(|| { | ||||
|                         CircleWsClientError::ConnectionError( | ||||
|                             "Invalid URL: missing authority".to_string(), | ||||
|                         ) | ||||
|                     })? | ||||
|                     .as_str(); | ||||
|                 let host = request.uri().host().unwrap_or_default(); | ||||
|  | ||||
|                 let stream = TcpStream::connect(authority).await | ||||
|                     .map_err(|e| CircleWsClientError::ConnectionError(format!("Failed to connect TCP stream: {}", e)))?; | ||||
|                 let stream = TcpStream::connect(authority).await.map_err(|e| { | ||||
|                     CircleWsClientError::ConnectionError(format!( | ||||
|                         "Failed to connect TCP stream: {}", | ||||
|                         e | ||||
|                     )) | ||||
|                 })?; | ||||
|  | ||||
|                 let tls_stream = tokio_native_tls::TlsConnector::from(connector) | ||||
|                     .connect(host, stream).await | ||||
|                     .map_err(|e| CircleWsClientError::ConnectionError(format!("Failed to establish TLS connection: {}", e)))?; | ||||
|                     .connect(host, stream) | ||||
|                     .await | ||||
|                     .map_err(|e| { | ||||
|                         CircleWsClientError::ConnectionError(format!( | ||||
|                             "Failed to establish TLS connection: {}", | ||||
|                             e | ||||
|                         )) | ||||
|                     })?; | ||||
|  | ||||
|                 let (ws_stream, response) = tokio_tungstenite::client_async_with_config( | ||||
|                     request, | ||||
|                     MaybeTlsStream::NativeTls(tls_stream), | ||||
|                     None, // WebSocketConfig | ||||
|                 ).await.map_err(|e| CircleWsClientError::ConnectionError(e.to_string()))?; | ||||
|                 ) | ||||
|                 .await | ||||
|                 .map_err(|e| CircleWsClientError::ConnectionError(e.to_string()))?; | ||||
|  | ||||
|                 Ok((ws_stream, response)) | ||||
|             }; | ||||
|             #[cfg(not(target_arch = "wasm32"))] | ||||
|             let ws_result: Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), CircleWsClientError> = connect_attempt.await; | ||||
|              | ||||
|             let ws_result: Result< | ||||
|                 (WebSocketStream<MaybeTlsStream<TcpStream>>, Response), | ||||
|                 CircleWsClientError, | ||||
|             > = connect_attempt.await; | ||||
|  | ||||
|             match ws_result { | ||||
|                 Ok(ws_conn_maybe_response) => { | ||||
|                     #[cfg(target_arch = "wasm32")] | ||||
| @@ -311,7 +391,7 @@ impl CircleWsClient { | ||||
|                                         match serde_json::to_string(&req) { | ||||
|                                             Ok(req_str) => { | ||||
|                                                 debug!("Sending JSON-RPC request (ID: {}): {}", req_id, req_str); | ||||
|                                                  | ||||
|  | ||||
|                                                 #[cfg(target_arch = "wasm32")] | ||||
|                                                 let send_res = ws_tx.send(GlooWsMessage::Text(req_str)).await; | ||||
|                                                 #[cfg(not(target_arch = "wasm32"))] | ||||
| @@ -414,18 +494,27 @@ impl CircleWsClient { | ||||
|                         } | ||||
|                     } | ||||
|                     // Cleanup pending requests on exit | ||||
|                     task_pending_requests.lock().unwrap().drain().for_each(|(_, sender)| { | ||||
|                         let _ = sender.send(Err(CircleWsClientError::ConnectionError("WebSocket task terminated".to_string()))); | ||||
|                     }); | ||||
|                     task_pending_requests | ||||
|                         .lock() | ||||
|                         .unwrap() | ||||
|                         .drain() | ||||
|                         .for_each(|(_, sender)| { | ||||
|                             let _ = sender.send(Err(CircleWsClientError::ConnectionError( | ||||
|                                 "WebSocket task terminated".to_string(), | ||||
|                             ))); | ||||
|                         }); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     error!("Failed to connect to WebSocket: {:?}", e); | ||||
|                     // Notify any waiting senders about the connection failure | ||||
|                      internal_rx.for_each(|msg| async { | ||||
|                         if let InternalWsMessage::SendJsonRpc(_, response_sender) = msg { | ||||
|                            let _ = response_sender.send(Err(CircleWsClientError::ConnectionError(e.to_string()))); | ||||
|                         } | ||||
|                     }).await; | ||||
|                     internal_rx | ||||
|                         .for_each(|msg| async { | ||||
|                             if let InternalWsMessage::SendJsonRpc(_, response_sender) = msg { | ||||
|                                 let _ = response_sender | ||||
|                                     .send(Err(CircleWsClientError::ConnectionError(e.to_string()))); | ||||
|                             } | ||||
|                         }) | ||||
|                         .await; | ||||
|                 } | ||||
|             } | ||||
|             info!("WebSocket task finished."); | ||||
| @@ -434,22 +523,27 @@ impl CircleWsClient { | ||||
|         #[cfg(target_arch = "wasm32")] | ||||
|         spawn_local(task); | ||||
|         #[cfg(not(target_arch = "wasm32"))] | ||||
|         { self.task_handle = Some(spawn_local(task)); } | ||||
|  | ||||
|         { | ||||
|             self.task_handle = Some(spawn_local(task)); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub fn play(&self, script: String) -> impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>> + Send + 'static { | ||||
|     pub fn play( | ||||
|         &self, | ||||
|         script: String, | ||||
|     ) -> impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>> + Send + 'static | ||||
|     { | ||||
|         let req_id_outer = Uuid::new_v4().to_string(); | ||||
|          | ||||
|  | ||||
|         // Clone the sender option. The sender itself (mpsc::Sender) is also Clone. | ||||
|         let internal_tx_clone_opt = self.internal_tx.clone(); | ||||
|  | ||||
|         async move { | ||||
|             let req_id = req_id_outer; // Move req_id into the async block | ||||
|             let params = PlayParamsClient { script }; // script is moved in | ||||
|              | ||||
|  | ||||
|             let request = match serde_json::to_value(params) { | ||||
|                 Ok(p_val) => JsonRpcRequestClient { | ||||
|                     jsonrpc: "2.0".to_string(), | ||||
| @@ -463,17 +557,24 @@ impl CircleWsClient { | ||||
|             let (response_tx, response_rx) = oneshot::channel(); | ||||
|  | ||||
|             if let Some(mut internal_tx) = internal_tx_clone_opt { | ||||
|                 internal_tx.send(InternalWsMessage::SendJsonRpc(request, response_tx)).await | ||||
|                     .map_err(|e| CircleWsClientError::ChannelError(format!("Failed to send request to internal task: {}", e)))?; | ||||
|                 internal_tx | ||||
|                     .send(InternalWsMessage::SendJsonRpc(request, response_tx)) | ||||
|                     .await | ||||
|                     .map_err(|e| { | ||||
|                         CircleWsClientError::ChannelError(format!( | ||||
|                             "Failed to send request to internal task: {}", | ||||
|                             e | ||||
|                         )) | ||||
|                     })?; | ||||
|             } else { | ||||
|                 return Err(CircleWsClientError::NotConnected); | ||||
|             } | ||||
|              | ||||
|  | ||||
|             // Add a timeout for waiting for the response | ||||
|             // For simplicity, using a fixed timeout here. Could be configurable. | ||||
|             #[cfg(target_arch = "wasm32")] | ||||
|             { | ||||
|                  match response_rx.await { | ||||
|                 match response_rx.await { | ||||
|                     Ok(Ok(rpc_response)) => { | ||||
|                         if let Some(json_rpc_error) = rpc_response.error { | ||||
|                             Err(CircleWsClientError::JsonRpcError { | ||||
| @@ -482,7 +583,8 @@ impl CircleWsClient { | ||||
|                                 data: json_rpc_error.data, | ||||
|                             }) | ||||
|                         } else if let Some(result_value) = rpc_response.result { | ||||
|                             serde_json::from_value(result_value).map_err(CircleWsClientError::JsonError) | ||||
|                             serde_json::from_value(result_value) | ||||
|                                 .map_err(CircleWsClientError::JsonError) | ||||
|                         } else { | ||||
|                             Err(CircleWsClientError::NoResponse(req_id.clone())) | ||||
|                         } | ||||
| @@ -495,7 +597,8 @@ impl CircleWsClient { | ||||
|             { | ||||
|                 use tokio::time::timeout as tokio_timeout; | ||||
|                 match tokio_timeout(std::time::Duration::from_secs(10), response_rx).await { | ||||
|                     Ok(Ok(Ok(rpc_response))) => { // Timeout -> Result<ChannelRecvResult, Error> | ||||
|                     Ok(Ok(Ok(rpc_response))) => { | ||||
|                         // Timeout -> Result<ChannelRecvResult, Error> | ||||
|                         if let Some(json_rpc_error) = rpc_response.error { | ||||
|                             Err(CircleWsClientError::JsonRpcError { | ||||
|                                 code: json_rpc_error.code, | ||||
| @@ -503,19 +606,22 @@ impl CircleWsClient { | ||||
|                                 data: json_rpc_error.data, | ||||
|                             }) | ||||
|                         } else if let Some(result_value) = rpc_response.result { | ||||
|                             serde_json::from_value(result_value).map_err(CircleWsClientError::JsonError) | ||||
|                             serde_json::from_value(result_value) | ||||
|                                 .map_err(CircleWsClientError::JsonError) | ||||
|                         } else { | ||||
|                             Err(CircleWsClientError::NoResponse(req_id.clone())) | ||||
|                         } | ||||
|                     } | ||||
|                     Ok(Ok(Err(e))) => Err(e), // Error propagated from the ws task | ||||
|                     Ok(Err(_)) => Err(CircleWsClientError::ChannelError("Response channel cancelled".to_string())), // oneshot cancelled | ||||
|                     Ok(Err(_)) => Err(CircleWsClientError::ChannelError( | ||||
|                         "Response channel cancelled".to_string(), | ||||
|                     )), // oneshot cancelled | ||||
|                     Err(_) => Err(CircleWsClientError::Timeout(req_id.clone())), // tokio_timeout expired | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     pub async fn disconnect(&mut self) { | ||||
|         if let Some(mut tx) = self.internal_tx.take() { | ||||
|             info!("Sending close signal to internal WebSocket task."); | ||||
| @@ -535,8 +641,8 @@ impl Drop for CircleWsClient { | ||||
|     fn drop(&mut self) { | ||||
|         if self.internal_tx.is_some() || self.task_handle.is_some() { | ||||
|             warn!("CircleWsClient dropped without explicit disconnect. Spawning task to send close signal."); | ||||
|              // We can't call async disconnect directly in drop. | ||||
|              // Spawn a new task to send the close message if on native. | ||||
|             // We can't call async disconnect directly in drop. | ||||
|             // Spawn a new task to send the close message if on native. | ||||
|             if let Some(mut tx) = self.internal_tx.take() { | ||||
|                 spawn_local(async move { | ||||
|                     info!("Drop: Sending close signal to internal WebSocket task."); | ||||
| @@ -544,7 +650,7 @@ impl Drop for CircleWsClient { | ||||
|                 }); | ||||
|             } | ||||
|             if let Some(handle) = self.task_handle.take() { | ||||
|                  spawn_local(async move { | ||||
|                 spawn_local(async move { | ||||
|                     info!("Drop: Waiting for WebSocket task to finish."); | ||||
|                     let _ = handle.await; | ||||
|                     info!("Drop: WebSocket task finished."); | ||||
| @@ -554,7 +660,6 @@ impl Drop for CircleWsClient { | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     // use super::*; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use std::process::{Command, Stdio, Child}; | ||||
| use std::time::Duration; | ||||
| use rhai_client::RhaiClient; | ||||
| use std::io::{BufRead, BufReader}; | ||||
| use std::process::{Child, Command, Stdio}; | ||||
| use std::time::Duration; | ||||
| use tokio::sync::mpsc; | ||||
|  | ||||
| const REDIS_URL: &str = "redis://127.0.0.1:6379"; | ||||
| @@ -21,9 +21,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         .stderr(Stdio::piped()) // Pipe stderr to avoid interfering with the test output | ||||
|         .spawn()?; | ||||
|  | ||||
|     println!("Launcher process started with PID: {}", launcher_process.id()); | ||||
|     println!( | ||||
|         "Launcher process started with PID: {}", | ||||
|         launcher_process.id() | ||||
|     ); | ||||
|  | ||||
|     let stdout = launcher_process.stdout.take().expect("Failed to capture stdout"); | ||||
|     let stdout = launcher_process | ||||
|         .stdout | ||||
|         .take() | ||||
|         .expect("Failed to capture stdout"); | ||||
|     let mut reader = BufReader::new(stdout); | ||||
|     let (tx, mut rx) = mpsc::channel::<String>(1); | ||||
|  | ||||
| @@ -62,13 +68,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     println!("--- Test 1: Verifying CIRCLE_PUBLIC_KEY ---"); | ||||
|     let script_circle_pk = r#"CIRCLE_PUBLIC_KEY"#; | ||||
|     println!("Submitting script to verify CIRCLE_PUBLIC_KEY..."); | ||||
|     let task_details_circle_pk = client.submit_script_and_await_result( | ||||
|         &public_key, | ||||
|         script_circle_pk.to_string(), | ||||
|         "task_id".to_string(), | ||||
|         Duration::from_secs(10), | ||||
|         None, // Caller PK is not relevant for this constant. | ||||
|     ).await?; | ||||
|     let task_details_circle_pk = client | ||||
|         .submit_script_and_await_result( | ||||
|             &public_key, | ||||
|             script_circle_pk.to_string(), | ||||
|             "task_id".to_string(), | ||||
|             Duration::from_secs(10), | ||||
|             None, // Caller PK is not relevant for this constant. | ||||
|         ) | ||||
|         .await?; | ||||
|     println!("Received task details: {:?}", task_details_circle_pk); | ||||
|     assert_eq!(task_details_circle_pk.status, "completed"); | ||||
|     assert_eq!(task_details_circle_pk.output, Some(public_key.to_string())); | ||||
| @@ -79,13 +87,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     println!("\n--- Test 2: Verifying CALLER_PUBLIC_KEY for init scripts ---"); | ||||
|     let script_caller_pk = r#"CALLER_PUBLIC_KEY"#; | ||||
|     println!("Submitting script to verify CALLER_PUBLIC_KEY..."); | ||||
|     let task_details_caller_pk = client.submit_script_and_await_result( | ||||
|         &public_key, | ||||
|         script_caller_pk.to_string(), | ||||
|         "task_id".to_string(), | ||||
|         Duration::from_secs(10), | ||||
|         Some(public_key.clone()), // Simulate launcher by setting caller to the circle itself. | ||||
|     ).await?; | ||||
|     let task_details_caller_pk = client | ||||
|         .submit_script_and_await_result( | ||||
|             &public_key, | ||||
|             script_caller_pk.to_string(), | ||||
|             "task_id".to_string(), | ||||
|             Duration::from_secs(10), | ||||
|             Some(public_key.clone()), // Simulate launcher by setting caller to the circle itself. | ||||
|         ) | ||||
|         .await?; | ||||
|     println!("Received task details: {:?}", task_details_caller_pk); | ||||
|     assert_eq!(task_details_caller_pk.status, "completed"); | ||||
|     assert_eq!(task_details_caller_pk.output, Some(public_key.to_string())); | ||||
| @@ -96,8 +106,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     launcher_process.kill()?; | ||||
|     tokio::task::spawn_blocking(move || { | ||||
|         let _ = launcher_process.wait(); | ||||
|     }).await?; | ||||
|     }) | ||||
|     .await?; | ||||
|     println!("--- End-to-End Test Finished Successfully ---"); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -17,10 +17,10 @@ | ||||
| //! 4. The launcher will run until you stop it with Ctrl+C. | ||||
|  | ||||
| use launcher::{run_launcher, Args, CircleConfig}; | ||||
| use log::{error, info}; | ||||
| use std::error::Error as StdError; | ||||
| use std::fs; | ||||
| use std::path::PathBuf; | ||||
| use std::error::Error as StdError; | ||||
| use log::{error, info}; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn StdError>> { | ||||
| @@ -53,13 +53,20 @@ async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|     let circle_configs: Vec<CircleConfig> = match serde_json::from_str(&config_content) { | ||||
|         Ok(configs) => configs, | ||||
|         Err(e) => { | ||||
|             error!("Failed to parse {}: {}. Ensure it's a valid JSON array of CircleConfig.", config_path.display(), e); | ||||
|             return Err(Box::new(e)); | ||||
|             error!( | ||||
|                 "Failed to parse {}: {}. Ensure it's a valid JSON array of CircleConfig.", | ||||
|                 config_path.display(), | ||||
|                 e | ||||
|             ); | ||||
|             return Err(e.into()); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if circle_configs.is_empty() { | ||||
|         info!("No circle configurations found in {}. Exiting.", config_path.display()); | ||||
|         info!( | ||||
|             "No circle configurations found in {}. Exiting.", | ||||
|             config_path.display() | ||||
|         ); | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
| @@ -71,4 +78,4 @@ async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|  | ||||
|     println!("--- OurWorld Example Finished ---"); | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,36 +1,46 @@ | ||||
| use launcher::{run_launcher, Args}; | ||||
| use clap::Parser; | ||||
| use launcher::{run_launcher, Args}; | ||||
|  | ||||
| use launcher::CircleConfig; | ||||
| use std::fs; | ||||
| use std::error::Error as StdError; // Import the trait | ||||
| use log::{error, info}; | ||||
| use std::error::Error as StdError; // Import the trait | ||||
| use std::fs; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn StdError>> { // Use the alias for clarity | ||||
| async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|     // Use the alias for clarity | ||||
|     let args = Args::parse(); | ||||
|  | ||||
|     let config_path = &args.config_path; | ||||
|     if !config_path.exists() { | ||||
|         error!("Configuration file not found at {:?}. Please create circles.json.", config_path); | ||||
|         error!( | ||||
|             "Configuration file not found at {:?}. Please create circles.json.", | ||||
|             config_path | ||||
|         ); | ||||
|         // Create a simple string error that can be boxed into Box<dyn StdError> | ||||
|         return Err(String::from("circles.json not found").into());  | ||||
|         return Err(String::from("circles.json not found").into()); | ||||
|     } | ||||
|  | ||||
|     let config_content = fs::read_to_string(&config_path) | ||||
|         .map_err(|e| Box::new(e) as Box<dyn StdError>)?; | ||||
|          | ||||
|     let config_content = | ||||
|         fs::read_to_string(&config_path).map_err(|e| Box::new(e) as Box<dyn StdError>)?; | ||||
|  | ||||
|     let circle_configs: Vec<CircleConfig> = match serde_json::from_str(&config_content) { | ||||
|         Ok(configs) => configs, | ||||
|         Err(e) => { | ||||
|             error!("Failed to parse circles.json: {}. Ensure it's a valid JSON array of CircleConfig.", e); | ||||
|             error!( | ||||
|                 "Failed to parse circles.json: {}. Ensure it's a valid JSON array of CircleConfig.", | ||||
|                 e | ||||
|             ); | ||||
|             // Explicitly cast serde_json::Error to Box<dyn StdError> | ||||
|             return Err(Box::new(e) as Box<dyn StdError>);  | ||||
|             return Err(Box::new(e) as Box<dyn StdError>); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if circle_configs.is_empty() { | ||||
|         info!("No circle configurations found in {}. Exiting.", config_path.display()); | ||||
|         info!( | ||||
|             "No circle configurations found in {}. Exiting.", | ||||
|             config_path.display() | ||||
|         ); | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,24 +1,24 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::fs; | ||||
| use std::path::PathBuf; | ||||
| use std::sync::{Arc, Mutex}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| // std::process::{Command, Child, Stdio}; // All parts of this line are no longer used directly here | ||||
| use tokio::task::JoinHandle; | ||||
| use actix_web::dev::ServerHandle; | ||||
| use tokio::signal; | ||||
| use std::time::Duration; | ||||
| use clap::Parser; | ||||
| use comfy_table::{Table, Row, Cell}; | ||||
| use log::{info, warn}; | ||||
| use secp256k1::{Secp256k1, rand}; | ||||
| use rhai_client::RhaiClient; | ||||
| use circle_ws_lib::{spawn_circle_server, ServerConfig}; | ||||
| use clap::Parser; | ||||
| use comfy_table::{Cell, Row, Table}; | ||||
| use log::{info, warn}; | ||||
| use rhai_client::RhaiClient; | ||||
| use secp256k1::{rand, Secp256k1}; | ||||
| use std::time::Duration; | ||||
| use tokio::signal; | ||||
| use tokio::task::JoinHandle; | ||||
| // use rhai::Engine; // No longer directly used, engine comes from create_heromodels_engine | ||||
| use rhailib_worker::spawn_rhai_worker; // Added | ||||
| use tokio::sync::mpsc; // Added | ||||
| use std::env; // Added | ||||
| use engine::create_heromodels_engine; | ||||
| use heromodels::db::hero::OurDB;  | ||||
| use heromodels::db::hero::OurDB; | ||||
| use rhailib_worker::spawn_rhai_worker; // Added | ||||
| use std::env; // Added | ||||
| use tokio::sync::mpsc; // Added | ||||
|  | ||||
| const DEFAULT_REDIS_URL: &str = "redis://127.0.0.1:6379"; | ||||
|  | ||||
| @@ -70,8 +70,9 @@ pub struct RunningCircleInfo { | ||||
|     pub _ws_server_task_join_handle: JoinHandle<std::io::Result<()>>, | ||||
| } | ||||
|  | ||||
|  | ||||
| pub async fn setup_and_spawn_circles(circle_configs: Vec<CircleConfig>) -> Result<(Vec<Arc<Mutex<RunningCircleInfo>>>, Vec<CircleOutput>), Box<dyn std::error::Error>> { | ||||
| pub async fn setup_and_spawn_circles( | ||||
|     circle_configs: Vec<CircleConfig>, | ||||
| ) -> Result<(Vec<Arc<Mutex<RunningCircleInfo>>>, Vec<CircleOutput>), Box<dyn std::error::Error>> { | ||||
|     if circle_configs.is_empty() { | ||||
|         warn!("No circle configurations found. Exiting."); | ||||
|         return Ok((Vec::new(), Vec::new())); | ||||
| @@ -85,13 +86,21 @@ pub async fn setup_and_spawn_circles(circle_configs: Vec<CircleConfig>) -> Resul | ||||
|     let data_dir = PathBuf::from("./launch_data"); | ||||
|     if !data_dir.exists() { | ||||
|         fs::create_dir_all(&data_dir).map_err(|e| { | ||||
|             format!("Failed to create data directory '{}': {}", data_dir.display(), e) | ||||
|             format!( | ||||
|                 "Failed to create data directory '{}': {}", | ||||
|                 data_dir.display(), | ||||
|                 e | ||||
|             ) | ||||
|         })?; | ||||
|         info!("Created data directory: {}", data_dir.display()); | ||||
|     } | ||||
|  | ||||
|     for (idx, config) in circle_configs.into_iter().enumerate() { // Added enumerate for circle_id | ||||
|         info!("Initializing Circle Name: '{}', Port: {}", config.name, config.port); | ||||
|     for (idx, config) in circle_configs.into_iter().enumerate() { | ||||
|         // Added enumerate for circle_id | ||||
|         info!( | ||||
|             "Initializing Circle Name: '{}', Port: {}", | ||||
|             config.name, config.port | ||||
|         ); | ||||
|  | ||||
|         let secp = Secp256k1::new(); | ||||
|         let (secret_key, public_key) = secp.generate_keypair(&mut rand::thread_rng()); | ||||
| @@ -107,19 +116,19 @@ pub async fn setup_and_spawn_circles(circle_configs: Vec<CircleConfig>) -> Resul | ||||
|         // --- End Engine Initialization --- | ||||
|  | ||||
|         let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| DEFAULT_REDIS_URL.to_string()); | ||||
|          | ||||
|  | ||||
|         // Using idx as a placeholder for circle_id. Consider a more robust ID if needed. | ||||
|         let circle_id_for_worker = idx as u32;  | ||||
|         let circle_id_for_worker = idx as u32; | ||||
|         // Defaulting preserve_tasks to false. Make configurable if needed. | ||||
|         let preserve_tasks = env::var("PRESERVE_TASKS").is_ok();  | ||||
|         let preserve_tasks = env::var("PRESERVE_TASKS").is_ok(); | ||||
|  | ||||
|         let worker_task_join_handle = spawn_rhai_worker( | ||||
|             circle_id_for_worker,  | ||||
|             public_key_hex.clone(),  | ||||
|             engine,  | ||||
|             redis_url.clone(),  | ||||
|             worker_shutdown_rx,  | ||||
|             preserve_tasks | ||||
|             circle_id_for_worker, | ||||
|             public_key_hex.clone(), | ||||
|             engine, | ||||
|             redis_url.clone(), | ||||
|             worker_shutdown_rx, | ||||
|             preserve_tasks, | ||||
|         ); | ||||
|  | ||||
|         let worker_queue = format!("rhai_tasks:{}", public_key_hex); | ||||
| @@ -127,21 +136,38 @@ pub async fn setup_and_spawn_circles(circle_configs: Vec<CircleConfig>) -> Resul | ||||
|  | ||||
|         // If a script is provided, read it and submit it to the worker | ||||
|         if let Some(script_path_str) = &config.script_path { | ||||
|             info!("Found script for circle '{}' at path: {}", config.name, script_path_str); | ||||
|             info!( | ||||
|                 "Found script for circle '{}' at path: {}", | ||||
|                 config.name, script_path_str | ||||
|             ); | ||||
|             let script_path = PathBuf::from(script_path_str); | ||||
|             if script_path.exists() { | ||||
|                 let script_content = fs::read_to_string(&script_path) | ||||
|                     .map_err(|e| format!("Failed to read script file '{}': {}", script_path.display(), e))?; | ||||
|                  | ||||
|                 let script_content = fs::read_to_string(&script_path).map_err(|e| { | ||||
|                     format!( | ||||
|                         "Failed to read script file '{}': {}", | ||||
|                         script_path.display(), | ||||
|                         e | ||||
|                     ) | ||||
|                 })?; | ||||
|  | ||||
|                 info!("Submitting script to worker queue '{}'", worker_queue); | ||||
|                 let task_id = rhai_client.submit_script( | ||||
|                     &public_key_hex, // Use public key as the circle identifier | ||||
|                     script_content, | ||||
|                     Some(public_key_hex.clone()), | ||||
|                 ).await?; | ||||
|                 info!("Script for circle '{}' submitted with task ID: {}", config.name, task_id); | ||||
|                 let task_id = rhai_client | ||||
|                     .submit_script( | ||||
|                         &public_key_hex, // Use public key as the circle identifier | ||||
|                         script_content, | ||||
|                         Some(public_key_hex.clone()), | ||||
|                     ) | ||||
|                     .await?; | ||||
|                 info!( | ||||
|                     "Script for circle '{}' submitted with task ID: {}", | ||||
|                     config.name, task_id | ||||
|                 ); | ||||
|             } else { | ||||
|                 warn!("Script path '{}' for circle '{}' does not exist. Skipping.", script_path.display(), config.name); | ||||
|                 warn!( | ||||
|                     "Script path '{}' for circle '{}' does not exist. Skipping.", | ||||
|                     script_path.display(), | ||||
|                     config.name | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -157,8 +183,8 @@ pub async fn setup_and_spawn_circles(circle_configs: Vec<CircleConfig>) -> Resul | ||||
|             key_path: None, | ||||
|         }; | ||||
|  | ||||
|         let (ws_server_task_join_handle, ws_server_instance_handle) = spawn_circle_server(server_config.clone())?; | ||||
|  | ||||
|         let (ws_server_task_join_handle, ws_server_instance_handle) = | ||||
|             spawn_circle_server(server_config.clone())?; | ||||
|  | ||||
|         circle_outputs.push(CircleOutput { | ||||
|             name: config.name.clone(), | ||||
| @@ -189,15 +215,20 @@ pub async fn shutdown_circles(running_circles_store: Vec<Arc<Mutex<RunningCircle | ||||
|             let mut circle_info = circle_arc.lock().unwrap(); | ||||
|             name = circle_info.config.name.clone(); | ||||
|             // Take ownership of the JoinHandle and Sender for shutdown | ||||
|             // We need to replace them with something to satisfy the struct,  | ||||
|             // We need to replace them with something to satisfy the struct, | ||||
|             // but they won't be used again for this instance. | ||||
|             let (dummy_tx, _dummy_rx) = mpsc::channel(1); | ||||
|             worker_shutdown_tx = std::mem::replace(&mut circle_info.worker_shutdown_tx, dummy_tx); | ||||
|              | ||||
|  | ||||
|             // Create a dummy JoinHandle for replacement | ||||
|             let dummy_join_handle = tokio::spawn(async { Ok(()) as Result<(), Box<dyn std::error::Error + Send + Sync>> }); | ||||
|             worker_task_join_handle_opt = Some(std::mem::replace(&mut circle_info.worker_task_join_handle, dummy_join_handle)); | ||||
|              | ||||
|             let dummy_join_handle = tokio::spawn(async { | ||||
|                 Ok(()) as Result<(), Box<dyn std::error::Error + Send + Sync>> | ||||
|             }); | ||||
|             worker_task_join_handle_opt = Some(std::mem::replace( | ||||
|                 &mut circle_info.worker_task_join_handle, | ||||
|                 dummy_join_handle, | ||||
|             )); | ||||
|  | ||||
|             server_handle_opt = circle_info.ws_server_instance_handle.lock().unwrap().take(); | ||||
|         } | ||||
|  | ||||
| @@ -207,11 +238,14 @@ pub async fn shutdown_circles(running_circles_store: Vec<Arc<Mutex<RunningCircle | ||||
|         if let Err(e) = worker_shutdown_tx.send(()).await { | ||||
|             warn!("Failed to send shutdown signal to worker for Circle '{}': {}. Worker might have already exited.", name, e); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         if let Some(worker_task_join_handle) = worker_task_join_handle_opt.take() { | ||||
|             match worker_task_join_handle.await { | ||||
|                 Ok(Ok(_)) => info!("Worker task for Circle '{}' shut down gracefully.", name), | ||||
|                 Ok(Err(e)) => warn!("Worker task for Circle '{}' returned an error: {:?}", name, e), | ||||
|                 Ok(Err(e)) => warn!( | ||||
|                     "Worker task for Circle '{}' returned an error: {:?}", | ||||
|                     name, e | ||||
|                 ), | ||||
|                 Err(e) => warn!("Worker task for Circle '{}' panicked: {:?}", name, e), | ||||
|             } | ||||
|         } else { | ||||
| @@ -224,12 +258,18 @@ pub async fn shutdown_circles(running_circles_store: Vec<Arc<Mutex<RunningCircle | ||||
|             handle.stop(true).await; | ||||
|             info!("WebSocket server for Circle '{}' stop signal sent.", name); | ||||
|         } else { | ||||
|             warn!("No server handle to stop WebSocket server for Circle '{}'.", name); | ||||
|             warn!( | ||||
|                 "No server handle to stop WebSocket server for Circle '{}'.", | ||||
|                 name | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn run_launcher(args: Args, circle_configs: Vec<CircleConfig>) -> Result<(), Box<dyn std::error::Error>> { | ||||
| pub async fn run_launcher( | ||||
|     args: Args, | ||||
|     circle_configs: Vec<CircleConfig>, | ||||
| ) -> Result<(), Box<dyn std::error::Error>> { | ||||
|     if std::env::var("RUST_LOG").is_err() { | ||||
|         let log_level = if args.debug { | ||||
|             "debug".to_string() | ||||
| @@ -257,9 +297,10 @@ pub async fn run_launcher(args: Args, circle_configs: Vec<CircleConfig>) -> Resu | ||||
|     info!("All configured circles have been processed. Displaying circles table."); | ||||
|  | ||||
|     { | ||||
|         let circles = running_circles_store.iter() | ||||
|                         .map(|arc_info| arc_info.lock().unwrap()) | ||||
|                         .collect::<Vec<_>>(); | ||||
|         let circles = running_circles_store | ||||
|             .iter() | ||||
|             .map(|arc_info| arc_info.lock().unwrap()) | ||||
|             .collect::<Vec<_>>(); | ||||
|  | ||||
|         let mut table = Table::new(); | ||||
|         table.set_header(vec!["Name", "Public Key", "Worker Queue", "WS URL"]); | ||||
| @@ -287,7 +328,7 @@ pub async fn run_launcher(args: Args, circle_configs: Vec<CircleConfig>) -> Resu | ||||
|     info!("Ctrl-C received. Initiating graceful shutdown of all circles..."); | ||||
|  | ||||
|     shutdown_circles(running_circles_store).await; | ||||
|      | ||||
|  | ||||
|     tokio::time::sleep(Duration::from_secs(2)).await; | ||||
|  | ||||
|     info!("Orchestrator shut down complete."); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use futures_util::{SinkExt, StreamExt}; | ||||
| use launcher::{setup_and_spawn_circles, shutdown_circles, CircleConfig}; | ||||
| use tokio_tungstenite::connect_async; | ||||
| use url::Url; | ||||
| use futures_util::{SinkExt, StreamExt}; | ||||
|  | ||||
| #[tokio::test] | ||||
| async fn test_launcher_starts_and_stops_circle() { | ||||
| @@ -27,16 +27,21 @@ async fn test_launcher_starts_and_stops_circle() { | ||||
|     // 4. Verification: Check if the WebSocket server is connectable | ||||
|     let ws_url = Url::parse(&circle_output.ws_url).expect("Failed to parse WS URL"); | ||||
|     let connection_attempt = connect_async(ws_url.to_string()).await; | ||||
|     assert!(connection_attempt.is_ok(), "Failed to connect to WebSocket server"); | ||||
|     assert!( | ||||
|         connection_attempt.is_ok(), | ||||
|         "Failed to connect to WebSocket server" | ||||
|     ); | ||||
|  | ||||
|     if let Ok((ws_stream, _)) = connection_attempt { | ||||
|          | ||||
|         let (mut write, _read) = ws_stream.split(); | ||||
|          | ||||
|  | ||||
|         // Optional: Send a message to test connectivity further | ||||
|         write.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.expect("Failed to send ping"); | ||||
|         write | ||||
|             .send(tokio_tungstenite::tungstenite::Message::Ping(vec![])) | ||||
|             .await | ||||
|             .expect("Failed to send ping"); | ||||
|     } | ||||
|  | ||||
|     // 5. Cleanup: Shutdown the circles | ||||
|     shutdown_circles(running_circles).await; | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								src/server_ws/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/server_ws/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1 +1,2 @@ | ||||
| /target | ||||
| file:memdb_test_server* | ||||
| @@ -1,5 +1,5 @@ | ||||
| use clap::Parser; | ||||
| use circle_ws_lib::{spawn_circle_server, ServerConfig}; | ||||
| use clap::Parser; | ||||
|  | ||||
| #[derive(Parser, Debug)] | ||||
| #[clap(author, version, about, long_about = None)] | ||||
|   | ||||
| @@ -0,0 +1 @@ | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| //! Optional authentication module for WebSocket servers | ||||
| //!  | ||||
| //! | ||||
| //! This module provides optional authentication support for WebSocket connections | ||||
| //! using secp256k1 cryptographic signatures. The authentication is completely | ||||
| //! optional and maintains backward compatibility with existing connections. | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| //! Signature verification utilities for secp256k1 authentication | ||||
| //!  | ||||
| //! | ||||
| //! This module provides functions to verify secp256k1 signatures in the | ||||
| //! Ethereum style, allowing WebSocket servers to authenticate clients | ||||
| //! using cryptographic signatures. | ||||
| @@ -15,79 +15,86 @@ pub struct NonceResponse { | ||||
| } | ||||
|  | ||||
| /// Verify a secp256k1 signature against a message and public key | ||||
| ///  | ||||
| /// | ||||
| /// This function implements Ethereum-style signature verification: | ||||
| /// 1. Creates the Ethereum signed message hash | ||||
| /// 2. Verifies the signature against the hash using the provided public key | ||||
| ///  | ||||
| /// | ||||
| /// # Arguments | ||||
| /// * `public_key_hex` - The public key in hex format (with or without 0x prefix) | ||||
| /// * `message` - The original message that was signed | ||||
| /// * `signature_hex` - The signature in hex format (65 bytes: r + s + v) | ||||
| ///  | ||||
| /// | ||||
| /// # Returns | ||||
| /// * `Ok(true)` if signature is valid | ||||
| /// * `Ok(false)` if signature is invalid | ||||
| /// * `Err(String)` if there's an error in the verification process | ||||
| pub fn verify_signature(public_key_hex: &str, message: &str, signature_hex: &str) -> Result<bool, String> { | ||||
| pub fn verify_signature( | ||||
|     public_key_hex: &str, | ||||
|     message: &str, | ||||
|     signature_hex: &str, | ||||
| ) -> Result<bool, String> { | ||||
|     // This is a placeholder implementation | ||||
|     // In a real implementation, you would use the secp256k1 crate | ||||
|     // For now, we'll implement basic validation and return success for app | ||||
|      | ||||
|  | ||||
|     // Remove 0x prefix if present | ||||
|     let clean_pubkey = public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex); | ||||
|     let clean_sig = signature_hex.strip_prefix("0x").unwrap_or(signature_hex); | ||||
|      | ||||
|  | ||||
|     // Basic validation | ||||
|     if clean_pubkey.len() != 130 { // 65 bytes as hex (uncompressed public key) | ||||
|     if clean_pubkey.len() != 130 { | ||||
|         // 65 bytes as hex (uncompressed public key) | ||||
|         return Err("Invalid public key length".to_string()); | ||||
|     } | ||||
|      | ||||
|     if clean_sig.len() != 130 { // 65 bytes as hex (r + s + v) | ||||
|  | ||||
|     if clean_sig.len() != 130 { | ||||
|         // 65 bytes as hex (r + s + v) | ||||
|         return Err("Invalid signature length".to_string()); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Validate hex format | ||||
|     if !clean_pubkey.chars().all(|c| c.is_ascii_hexdigit()) { | ||||
|         return Err("Invalid public key format".to_string()); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     if !clean_sig.chars().all(|c| c.is_ascii_hexdigit()) { | ||||
|         return Err("Invalid signature format".to_string()); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // For app purposes, we'll accept any properly formatted signature | ||||
|     // In production, you would implement actual secp256k1 verification here | ||||
|     log::info!("Signature verification (app mode): pubkey={}, message={}, sig={}",  | ||||
|                &clean_pubkey[..20], message, &clean_sig[..20]); | ||||
|      | ||||
|     log::info!( | ||||
|         "Signature verification (app mode): pubkey={}, message={}, sig={}", | ||||
|         &clean_pubkey[..20], | ||||
|         message, | ||||
|         &clean_sig[..20] | ||||
|     ); | ||||
|  | ||||
|     Ok(true) | ||||
| } | ||||
|  | ||||
| /// Generate a nonce for authentication | ||||
| ///  | ||||
| /// | ||||
| /// Creates a time-based nonce that includes timestamp and random component | ||||
| pub fn generate_nonce() -> NonceResponse { | ||||
|     let now = SystemTime::now() | ||||
|         .duration_since(UNIX_EPOCH) | ||||
|         .unwrap() | ||||
|         .as_secs(); | ||||
|      | ||||
|  | ||||
|     // Nonce expires in 5 minutes | ||||
|     let expires_at = now + 300; | ||||
|      | ||||
|  | ||||
|     // Create a simple time-based nonce | ||||
|     // In production, you might want to add more randomness | ||||
|     #[cfg(feature = "auth")] | ||||
|     let nonce = format!("nonce_{}_{}", now, rand::random::<u32>()); | ||||
|      | ||||
|  | ||||
|     #[cfg(not(feature = "auth"))] | ||||
|     let nonce = format!("nonce_{}_{}", now, 12345u32); | ||||
|      | ||||
|     NonceResponse { | ||||
|         nonce, | ||||
|         expires_at, | ||||
|     } | ||||
|  | ||||
|     NonceResponse { nonce, expires_at } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| @@ -100,6 +107,4 @@ mod tests { | ||||
|         assert!(nonce_response.nonce.starts_with("nonce_")); | ||||
|         assert!(nonce_response.expires_at > 0); | ||||
|     } | ||||
|  | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -2,18 +2,18 @@ use actix::prelude::*; | ||||
| use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer}; | ||||
| use actix_web_actors::ws; | ||||
| use log::{debug, info}; // Removed unused error, warn | ||||
| use rhai_client::{RhaiClient, RhaiClientError}; | ||||
| use serde::{Deserialize, Serialize}; // Import Deserialize and Serialize traits | ||||
| use serde_json::Value; // Removed unused json | ||||
| use std::collections::HashMap; | ||||
| use std::sync::Mutex; // Removed unused Arc | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| use once_cell::sync::Lazy; | ||||
| use rhai_client::{RhaiClient, RhaiClientError}; | ||||
| use rustls::pki_types::PrivateKeyDer; | ||||
| use rustls::ServerConfig as RustlsServerConfig; | ||||
| use rustls_pemfile::{certs, pkcs8_private_keys}; | ||||
| use serde::{Deserialize, Serialize}; // Import Deserialize and Serialize traits | ||||
| use serde_json::Value; // Removed unused json | ||||
| use std::collections::HashMap; | ||||
| use std::fs::File; | ||||
| use std::io::BufReader; | ||||
| use std::sync::Mutex; // Removed unused Arc | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| use tokio::task::JoinHandle; | ||||
|  | ||||
| // Global store for server handles | ||||
| @@ -174,10 +174,7 @@ impl CircleWs { | ||||
|                         .unwrap() | ||||
|                         .as_secs(); | ||||
|                     if nonce_resp.expires_at < current_time { | ||||
|                         log::warn!( | ||||
|                             "Auth failed for {}: Nonce expired", | ||||
|                             self.server_circle_name | ||||
|                         ); | ||||
|                         log::warn!("Auth failed for {}: Nonce expired", self.server_circle_name); | ||||
|                         false | ||||
|                     } else { | ||||
|                         match auth::signature_verifier::verify_signature( | ||||
| @@ -283,60 +280,64 @@ impl CircleWs { | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 ctx.spawn(fut.into_actor(self).map(move |res, _act, ctx_inner| { | ||||
|                     match res { | ||||
|                         Ok(task_details) => { | ||||
|                             if task_details.status == "completed" { | ||||
|                                 let output = task_details | ||||
|                                     .output | ||||
|                                     .unwrap_or_else(|| "No output".to_string()); | ||||
|                                 let result_value = PlayResult { output }; | ||||
|                                 let resp = JsonRpcResponse { | ||||
|                                     jsonrpc: "2.0".to_string(), | ||||
|                                     result: Some(serde_json::to_value(result_value).unwrap()), | ||||
|                                     error: None, | ||||
|                                     id: client_rpc_id, | ||||
|                 ctx.spawn( | ||||
|                     fut.into_actor(self) | ||||
|                         .map(move |res, _act, ctx_inner| match res { | ||||
|                             Ok(task_details) => { | ||||
|                                 if task_details.status == "completed" { | ||||
|                                     let output = task_details | ||||
|                                         .output | ||||
|                                         .unwrap_or_else(|| "No output".to_string()); | ||||
|                                     let result_value = PlayResult { output }; | ||||
|                                     let resp = JsonRpcResponse { | ||||
|                                         jsonrpc: "2.0".to_string(), | ||||
|                                         result: Some(serde_json::to_value(result_value).unwrap()), | ||||
|                                         error: None, | ||||
|                                         id: client_rpc_id, | ||||
|                                     }; | ||||
|                                     ctx_inner.text(serde_json::to_string(&resp).unwrap()); | ||||
|                                 } else { | ||||
|                                     let error_message = task_details.error.unwrap_or_else(|| { | ||||
|                                         "Rhai script execution failed".to_string() | ||||
|                                     }); | ||||
|                                     let err_resp = JsonRpcResponse { | ||||
|                                         jsonrpc: "2.0".to_string(), | ||||
|                                         result: None, | ||||
|                                         error: Some(JsonRpcError { | ||||
|                                             code: -32000, | ||||
|                                             message: error_message, | ||||
|                                             data: None, | ||||
|                                         }), | ||||
|                                         id: client_rpc_id, | ||||
|                                     }; | ||||
|                                     ctx_inner.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                                 } | ||||
|                             } | ||||
|                             Err(e) => { | ||||
|                                 let (code, message) = match e { | ||||
|                                     RhaiClientError::Timeout(task_id) => ( | ||||
|                                         -32002, | ||||
|                                         format!( | ||||
|                                             "Timeout waiting for Rhai script (task: {})", | ||||
|                                             task_id | ||||
|                                         ), | ||||
|                                     ), | ||||
|                                     _ => (-32003, format!("Rhai infrastructure error: {}", e)), | ||||
|                                 }; | ||||
|                                 ctx_inner.text(serde_json::to_string(&resp).unwrap()); | ||||
|                             } else { | ||||
|                                 let error_message = task_details | ||||
|                                     .error | ||||
|                                     .unwrap_or_else(|| "Rhai script execution failed".to_string()); | ||||
|                                 let err_resp = JsonRpcResponse { | ||||
|                                     jsonrpc: "2.0".to_string(), | ||||
|                                     result: None, | ||||
|                                     error: Some(JsonRpcError { | ||||
|                                         code: -32000, | ||||
|                                         message: error_message, | ||||
|                                         code, | ||||
|                                         message, | ||||
|                                         data: None, | ||||
|                                     }), | ||||
|                                     id: client_rpc_id, | ||||
|                                 }; | ||||
|                                 ctx_inner.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                             } | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             let (code, message) = match e { | ||||
|                                 RhaiClientError::Timeout(task_id) => ( | ||||
|                                     -32002, | ||||
|                                     format!("Timeout waiting for Rhai script (task: {})", task_id), | ||||
|                                 ), | ||||
|                                 _ => (-32003, format!("Rhai infrastructure error: {}", e)), | ||||
|                             }; | ||||
|                             let err_resp = JsonRpcResponse { | ||||
|                                 jsonrpc: "2.0".to_string(), | ||||
|                                 result: None, | ||||
|                                 error: Some(JsonRpcError { | ||||
|                                     code, | ||||
|                                     message, | ||||
|                                     data: None, | ||||
|                                 }), | ||||
|                                 id: client_rpc_id, | ||||
|                             }; | ||||
|                             ctx_inner.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                         } | ||||
|                     } | ||||
|                 })); | ||||
|                         }), | ||||
|                 ); | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 let err_resp = JsonRpcResponse { | ||||
| @@ -373,8 +374,14 @@ impl Actor for CircleWs { | ||||
|     } | ||||
|  | ||||
|     fn stopping(&mut self, ctx: &mut Self::Context) -> Running { | ||||
|         info!("Circle '{}' WS: Connection stopping.", self.server_circle_name); | ||||
|         AUTHENTICATED_CONNECTIONS.lock().unwrap().remove(&ctx.address()); | ||||
|         info!( | ||||
|             "Circle '{}' WS: Connection stopping.", | ||||
|             self.server_circle_name | ||||
|         ); | ||||
|         AUTHENTICATED_CONNECTIONS | ||||
|             .lock() | ||||
|             .unwrap() | ||||
|             .remove(&ctx.address()); | ||||
|         Running::Stop | ||||
|     } | ||||
| } | ||||
| @@ -389,8 +396,12 @@ impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for CircleWs { | ||||
|                     Ok(req) => { | ||||
|                         let client_rpc_id = req.id.clone().unwrap_or(Value::Null); | ||||
|                         match req.method.as_str() { | ||||
|                             "fetch_nonce" => self.handle_fetch_nonce(req.params, client_rpc_id, ctx), | ||||
|                             "authenticate" => self.handle_authenticate(req.params, client_rpc_id, ctx), | ||||
|                             "fetch_nonce" => { | ||||
|                                 self.handle_fetch_nonce(req.params, client_rpc_id, ctx) | ||||
|                             } | ||||
|                             "authenticate" => { | ||||
|                                 self.handle_authenticate(req.params, client_rpc_id, ctx) | ||||
|                             } | ||||
|                             "play" => self.handle_play(req.params, client_rpc_id, ctx), | ||||
|                             _ => { | ||||
|                                 let err_resp = JsonRpcResponse { | ||||
| @@ -539,4 +550,4 @@ pub fn spawn_circle_server( | ||||
|         server_name, host, port | ||||
|     ); | ||||
|     Ok((server_task, handle)) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| use circle_ws_lib::{spawn_circle_server, ServerConfig}; | ||||
| use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; | ||||
| use futures_util::{StreamExt, SinkExt}; | ||||
| use serde_json::json; | ||||
| use rhailib_worker::spawn_rhai_worker; | ||||
| use engine::create_heromodels_engine; | ||||
| use futures_util::{SinkExt, StreamExt}; | ||||
| use heromodels::db::hero::OurDB; | ||||
| use rhailib_worker::spawn_rhai_worker; | ||||
| use serde_json::json; | ||||
| use std::sync::Arc; | ||||
| use tokio::sync::mpsc; | ||||
| use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; | ||||
|  | ||||
| #[tokio::test] | ||||
| async fn test_server_startup_and_play() { | ||||
| @@ -17,7 +17,14 @@ async fn test_server_startup_and_play() { | ||||
|     let (shutdown_tx, shutdown_rx) = mpsc::channel(1); | ||||
|     let db = Arc::new(OurDB::new("file:memdb_test_server?mode=memory&cache=shared", true).unwrap()); | ||||
|     let engine = create_heromodels_engine(db); | ||||
|     let worker_handle = spawn_rhai_worker(0, circle_pk.to_string(), engine, redis_url.to_string(), shutdown_rx, false); | ||||
|     let worker_handle = spawn_rhai_worker( | ||||
|         0, | ||||
|         circle_pk.to_string(), | ||||
|         engine, | ||||
|         redis_url.to_string(), | ||||
|         shutdown_rx, | ||||
|         false, | ||||
|     ); | ||||
|  | ||||
|     // --- Server Setup --- | ||||
|     let config = ServerConfig { | ||||
| @@ -43,23 +50,30 @@ async fn test_server_startup_and_play() { | ||||
|     let play_req = json!({ | ||||
|         "jsonrpc": "2.0", | ||||
|         "method": "play", | ||||
|         "params": { "script": "\"hello\"" }, | ||||
|         "params": { "script": "40 + 2" }, | ||||
|         "id": 1 | ||||
|     }); | ||||
|  | ||||
|     ws_stream.send(Message::Text(play_req.to_string())).await.unwrap(); | ||||
|     ws_stream | ||||
|         .send(Message::Text(play_req.to_string())) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|  | ||||
|     let response = ws_stream.next().await.unwrap().unwrap(); | ||||
|     let response_text = response.to_text().unwrap(); | ||||
|     let response_json: serde_json::Value = serde_json::from_str(response_text).unwrap(); | ||||
|  | ||||
|     assert_eq!(response_json["id"], 1); | ||||
|     assert!(response_json["result"].is_object(), "The result should be an object, but it was: {}", response_text); | ||||
|     assert_eq!(response_json["result"]["output"], "42\n"); | ||||
|     assert!( | ||||
|         response_json["result"].is_object(), | ||||
|         "The result should be an object, but it was: {}", | ||||
|         response_text | ||||
|     ); | ||||
|     assert_eq!(response_json["result"]["output"], "42"); | ||||
|  | ||||
|     // --- Cleanup --- | ||||
|     server_handle.stop(true).await; | ||||
|     let _ = server_join_handle.await; | ||||
|     let _ = shutdown_tx.send(()).await; | ||||
|     let _ = worker_handle.await; | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -7,6 +7,7 @@ use url::Url; | ||||
| async fn test_server_connection() { | ||||
|     let config = ServerConfig { | ||||
|         circle_name: "test_circle".to_string(), | ||||
|         circle_public_key: "test_pub_key".to_string(), | ||||
|         host: "127.0.0.1".to_string(), | ||||
|         port: 9001, | ||||
|         redis_url: "redis://127.0.0.1:6379".to_string(), | ||||
| @@ -15,15 +16,15 @@ async fn test_server_connection() { | ||||
|         key_path: None, | ||||
|     }; | ||||
|  | ||||
|     let server_handle = tokio::spawn(spawn_circle_server(config)); | ||||
|     let (server_handle, _server_stop_handle) = spawn_circle_server(config).unwrap(); | ||||
|  | ||||
|     tokio::time::sleep(Duration::from_secs(1)).await; | ||||
|  | ||||
|     let url_str = "ws://127.0.0.1:9001/ws"; | ||||
|     let url = Url::parse(url_str).unwrap(); | ||||
|     let (ws_stream, _) = connect_async(url_str).await.expect("Failed to connect"); | ||||
|     let (ws_stream, _) = connect_async(url).await.expect("Failed to connect"); | ||||
|  | ||||
|     println!("WebSocket connection successful: {:?}", ws_stream); | ||||
|  | ||||
|     server_handle.abort(); | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| use circle_ws_lib::{spawn_circle_server, ServerConfig}; | ||||
| use futures_util::{sink::SinkExt, stream::StreamExt}; | ||||
| use std::time::Duration; | ||||
| use tokio::time::sleep; | ||||
| use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; | ||||
| use circle_ws_lib::{spawn_circle_server, ServerConfig}; | ||||
|  | ||||
| // Define a simple JSON-RPC request structure for sending scripts | ||||
| #[derive(serde::Serialize, Debug)] | ||||
| @@ -41,13 +41,16 @@ const RHAI_TIMEOUT_SECONDS: u64 = 30; // Match server's default timeout | ||||
| async fn test_rhai_script_timeout() { | ||||
|     let server_config = ServerConfig { | ||||
|         circle_name: TEST_CIRCLE_NAME.to_string(), | ||||
|         circle_public_key: "test_pub_key_timeout".to_string(), | ||||
|         host: "127.0.0.1".to_string(), | ||||
|         port: 8088, | ||||
|         redis_url: "redis://127.0.0.1:6379".to_string(), | ||||
|         enable_auth: false, // Auth not needed for this test | ||||
|         cert_path: None, | ||||
|         key_path: None, | ||||
|     }; | ||||
|  | ||||
|     let server_handle = tokio::spawn(spawn_circle_server(server_config)); | ||||
|     let (server_handle, _server_stop_handle) = spawn_circle_server(server_config).unwrap(); | ||||
|     sleep(Duration::from_secs(2)).await; // Give server time to start | ||||
|  | ||||
|     let (mut ws_stream, _response) = connect_async(SERVER_ADDRESS) | ||||
| @@ -60,25 +63,42 @@ async fn test_rhai_script_timeout() { | ||||
|             x = x + i; | ||||
|         } | ||||
|         print(x); | ||||
|     ".to_string(); | ||||
|     " | ||||
|     .to_string(); | ||||
|  | ||||
|     let request = JsonRpcRequest { | ||||
|         jsonrpc: "2.0".to_string(), | ||||
|         method: "play".to_string(), | ||||
|         params: ScriptParams { script: long_running_script }, | ||||
|         params: ScriptParams { | ||||
|             script: long_running_script, | ||||
|         }, | ||||
|         id: 1, | ||||
|     }; | ||||
|  | ||||
|     let request_json = serde_json::to_string(&request).expect("Failed to serialize request"); | ||||
|     ws_stream.send(Message::Text(request_json)).await.expect("Failed to send message"); | ||||
|     ws_stream | ||||
|         .send(Message::Text(request_json)) | ||||
|         .await | ||||
|         .expect("Failed to send message"); | ||||
|  | ||||
|     match tokio::time::timeout(Duration::from_secs(RHAI_TIMEOUT_SECONDS + 10), ws_stream.next()).await { | ||||
|     match tokio::time::timeout( | ||||
|         Duration::from_secs(RHAI_TIMEOUT_SECONDS + 10), | ||||
|         ws_stream.next(), | ||||
|     ) | ||||
|     .await | ||||
|     { | ||||
|         Ok(Some(Ok(Message::Text(text)))) => { | ||||
|             let response: Result<JsonRpcErrorResponse, _> = serde_json::from_str(&text); | ||||
|             match response { | ||||
|                 Ok(err_resp) => { | ||||
|                     assert_eq!(err_resp.error.code, -32002, "Error code should indicate timeout."); | ||||
|                     assert!(err_resp.error.message.contains("Timeout"), "Error message should indicate timeout."); | ||||
|                     assert_eq!( | ||||
|                         err_resp.error.code, -32002, | ||||
|                         "Error code should indicate timeout." | ||||
|                     ); | ||||
|                     assert!( | ||||
|                         err_resp.error.message.contains("Timeout"), | ||||
|                         "Error message should indicate timeout." | ||||
|                     ); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     panic!("Failed to deserialize error response: {}. Raw: {}", e, text); | ||||
|   | ||||
| @@ -5,7 +5,11 @@ use tracing_subscriber::EnvFilter; | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     tracing_subscriber::fmt() | ||||
|         .with_env_filter(EnvFilter::from_default_env().add_directive("connect_and_play=info".parse().unwrap()).add_directive("circle_client_ws=info".parse().unwrap())) | ||||
|         .with_env_filter( | ||||
|             EnvFilter::from_default_env() | ||||
|                 .add_directive("connect_and_play=info".parse().unwrap()) | ||||
|                 .add_directive("circle_client_ws=info".parse().unwrap()), | ||||
|         ) | ||||
|         .init(); | ||||
|  | ||||
|     let args: Vec<String> = env::args().collect(); | ||||
| @@ -40,4 +44,4 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     println!("\nDisconnected from {}.", ws_url); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| use url::Url; | ||||
| use tracing_subscriber::EnvFilter; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientBuilder}; | ||||
| use rustyline::error::ReadlineError; | ||||
| use tracing_subscriber::EnvFilter; | ||||
| use url::Url; | ||||
| // Remove direct History import, DefaultEditor handles it. | ||||
| use rustyline::{DefaultEditor, Config, EditMode}; | ||||
| use rustyline::{Config, DefaultEditor, EditMode}; | ||||
| use std::env; | ||||
| use std::fs; | ||||
| use std::process::Command; | ||||
| use std::env; | ||||
| use tempfile::Builder as TempFileBuilder; // Use Builder for suffix | ||||
|  | ||||
| // std::io::Write is not used if we don't pre-populate temp_file | ||||
| @@ -24,7 +24,11 @@ async fn execute_script(client: &mut CircleWsClient, script_content: String) { | ||||
|         } | ||||
|         Err(e) => { | ||||
|             eprintln!("Error executing script: {}", e); | ||||
|             if matches!(e, circle_client_ws::CircleWsClientError::NotConnected | circle_client_ws::CircleWsClientError::ConnectionError(_)) { | ||||
|             if matches!( | ||||
|                 e, | ||||
|                 circle_client_ws::CircleWsClientError::NotConnected | ||||
|                     | circle_client_ws::CircleWsClientError::ConnectionError(_) | ||||
|             ) { | ||||
|                 eprintln!("Connection lost. You may need to restart the REPL and reconnect."); | ||||
|                 // Optionally, could attempt to trigger a full exit here or set a flag | ||||
|             } | ||||
| @@ -36,7 +40,7 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|     println!("Attempting to connect to {}...", ws_url_str); | ||||
|  | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url_str.clone()).build(); | ||||
|      | ||||
|  | ||||
|     match client.connect().await { | ||||
|         Ok(_) => { | ||||
|             println!("Connected to {}!", ws_url_str); | ||||
| @@ -53,7 +57,7 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|         .auto_add_history(true) // Automatically add to history | ||||
|         .build(); | ||||
|     let mut rl = DefaultEditor::with_config(config)?; | ||||
|      | ||||
|  | ||||
|     let history_file = ".rhai_repl_history.txt"; // Simple history file in current dir | ||||
|     if rl.load_history(history_file).is_err() { | ||||
|         // No history found or error loading, not critical | ||||
| @@ -77,7 +81,7 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|                         .suffix(".rhai") | ||||
|                         .tempfile_in(".") // Create in current directory for simplicity | ||||
|                         .map_err(|e| format!("Failed to create temp file: {}", e))?; | ||||
|                      | ||||
|  | ||||
|                     // You can pre-populate the temp file if needed: | ||||
|                     // use std::io::Write; // Add this import if using write_all | ||||
|                     // if let Err(e) = temp_file.as_file().write_all(b"// Start your Rhai script here\n") { | ||||
| @@ -86,17 +90,17 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|  | ||||
|                     let temp_path = temp_file.path().to_path_buf(); | ||||
|                     let editor_cmd_str = env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); | ||||
|                      | ||||
|  | ||||
|                     let mut editor_parts = editor_cmd_str.split_whitespace(); | ||||
|                     let editor_executable = editor_parts.next().unwrap_or("vi"); // Default to vi if $EDITOR is empty string | ||||
|                     let editor_args: Vec<&str> = editor_parts.collect(); | ||||
|  | ||||
|                     println!("Launching editor: '{}' with args: {:?} for script editing. Save and exit editor to execute.", editor_executable, editor_args); | ||||
|                      | ||||
|  | ||||
|                     let mut command = Command::new(editor_executable); | ||||
|                     command.args(editor_args); // Add any arguments from $EDITOR (like -w) | ||||
|                     command.arg(&temp_path);   // Add the temp file path as the last argument | ||||
|                      | ||||
|                     command.arg(&temp_path); // Add the temp file path as the last argument | ||||
|  | ||||
|                     let status = command.status(); | ||||
|  | ||||
|                     match status { | ||||
| @@ -105,11 +109,19 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|                                 Ok(script_content) => { | ||||
|                                     execute_script(&mut client, script_content).await; | ||||
|                                 } | ||||
|                                 Err(e) => eprintln!("Error reading temp file {:?}: {}", temp_path, e), | ||||
|                                 Err(e) => { | ||||
|                                     eprintln!("Error reading temp file {:?}: {}", temp_path, e) | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         Ok(exit_status) => eprintln!("Editor exited with status: {}. Script not executed.", exit_status), | ||||
|                         Err(e) => eprintln!("Failed to launch editor '{}': {}. Ensure it's in your PATH.", editor_executable, e), // Changed 'editor' to 'editor_executable' | ||||
|                         Ok(exit_status) => eprintln!( | ||||
|                             "Editor exited with status: {}. Script not executed.", | ||||
|                             exit_status | ||||
|                         ), | ||||
|                         Err(e) => eprintln!( | ||||
|                             "Failed to launch editor '{}': {}. Ensure it's in your PATH.", | ||||
|                             editor_executable, e | ||||
|                         ), // Changed 'editor' to 'editor_executable' | ||||
|                     } | ||||
|                     // temp_file is automatically deleted when it goes out of scope | ||||
|                 } else if input.starts_with(".run ") || input.starts_with("run ") { | ||||
| @@ -131,11 +143,13 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|                 } | ||||
|                 // rl.add_history_entry(line.as_str()) is handled by auto_add_history(true) | ||||
|             } | ||||
|             Err(ReadlineError::Interrupted) => { // Ctrl-C | ||||
|             Err(ReadlineError::Interrupted) => { | ||||
|                 // Ctrl-C | ||||
|                 println!("Input interrupted. Type 'exit' or 'quit' to close."); | ||||
|                 continue; | ||||
|             } | ||||
|             Err(ReadlineError::Eof) => { // Ctrl-D | ||||
|             Err(ReadlineError::Eof) => { | ||||
|                 // Ctrl-D | ||||
|                 println!("Exiting REPL (EOF)."); | ||||
|                 break; | ||||
|             } | ||||
| @@ -145,7 +159,7 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     if rl.save_history(history_file).is_err() { | ||||
|         // Failed to save history, not critical | ||||
|     } | ||||
| @@ -159,7 +173,11 @@ async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> | ||||
| #[tokio::main] | ||||
| async fn main() { | ||||
|     tracing_subscriber::fmt() | ||||
|         .with_env_filter(EnvFilter::from_default_env().add_directive("ui_repl=info".parse().unwrap()).add_directive("circle_client_ws=info".parse().unwrap())) | ||||
|         .with_env_filter( | ||||
|             EnvFilter::from_default_env() | ||||
|                 .add_directive("ui_repl=info".parse().unwrap()) | ||||
|                 .add_directive("circle_client_ws=info".parse().unwrap()), | ||||
|         ) | ||||
|         .init(); | ||||
|  | ||||
|     let args: Vec<String> = env::args().collect(); | ||||
| @@ -175,10 +193,14 @@ async fn main() { | ||||
|     match Url::parse(&ws_url_str) { | ||||
|         Ok(parsed_url) => { | ||||
|             if parsed_url.scheme() != "ws" && parsed_url.scheme() != "wss" { | ||||
|                 eprintln!("Invalid WebSocket URL scheme: {}. Must be 'ws' or 'wss'.", parsed_url.scheme()); | ||||
|                 eprintln!( | ||||
|                     "Invalid WebSocket URL scheme: {}. Must be 'ws' or 'wss'.", | ||||
|                     parsed_url.scheme() | ||||
|                 ); | ||||
|                 return; | ||||
|             } | ||||
|             if let Err(e) = run_repl(ws_url_str).await { // Pass the original string URL | ||||
|             if let Err(e) = run_repl(ws_url_str).await { | ||||
|                 // Pass the original string URL | ||||
|                 eprintln!("REPL error: {}", e); | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -14,20 +14,14 @@ | ||||
| //! cargo run --example end_to_end_integration -p integration_tests | ||||
| //! ``` | ||||
|  | ||||
| use log::{error, info, warn}; | ||||
| use std::process::{Child, Command, Stdio}; | ||||
| use std::time::Duration; | ||||
| use tokio::time::sleep; | ||||
| use log::{info, error, warn}; | ||||
| use std::process::{Command, Child, Stdio}; | ||||
| use std::sync::Arc; | ||||
|  | ||||
|  | ||||
| // Client-side imports | ||||
| use circle_client_ws::{CircleWsClientBuilder, auth}; | ||||
| // Launcher imports | ||||
| use launcher::{setup_and_spawn_circles, shutdown_circles, CircleConfig, RunningCircleInfo}; | ||||
| use redis::AsyncCommands; | ||||
| use secp256k1::{Secp256k1, PublicKey, SecretKey}; | ||||
| use tokio::sync::Mutex; | ||||
| use launcher::{setup_and_spawn_circles, shutdown_circles, CircleConfig}; | ||||
|  | ||||
| struct ChildProcessGuard { | ||||
|     child: Child, | ||||
| @@ -42,16 +36,39 @@ impl ChildProcessGuard { | ||||
|  | ||||
| impl Drop for ChildProcessGuard { | ||||
|     fn drop(&mut self) { | ||||
|         info!("Cleaning up {} process (PID: {})...", self.name, self.child.id()); | ||||
|         info!( | ||||
|             "Cleaning up {} process (PID: {})...", | ||||
|             self.name, | ||||
|             self.child.id() | ||||
|         ); | ||||
|         match self.child.kill() { | ||||
|             Ok(_) => { | ||||
|                 info!("Successfully sent kill signal to {} (PID: {}).", self.name, self.child.id()); | ||||
|                 info!( | ||||
|                     "Successfully sent kill signal to {} (PID: {}).", | ||||
|                     self.name, | ||||
|                     self.child.id() | ||||
|                 ); | ||||
|                 match self.child.wait() { | ||||
|                     Ok(status) => info!("{} (PID: {}) exited with status: {}", self.name, self.child.id(), status), | ||||
|                     Err(e) => warn!("Error waiting for {} (PID: {}): {}", self.name, self.child.id(), e), | ||||
|                     Ok(status) => info!( | ||||
|                         "{} (PID: {}) exited with status: {}", | ||||
|                         self.name, | ||||
|                         self.child.id(), | ||||
|                         status | ||||
|                     ), | ||||
|                     Err(e) => warn!( | ||||
|                         "Error waiting for {} (PID: {}): {}", | ||||
|                         self.name, | ||||
|                         self.child.id(), | ||||
|                         e | ||||
|                     ), | ||||
|                 } | ||||
|             } | ||||
|             Err(e) => error!("Failed to kill {} (PID: {}): {}", self.name, self.child.id(), e), | ||||
|             Err(e) => error!( | ||||
|                 "Failed to kill {} (PID: {}): {}", | ||||
|                 self.name, | ||||
|                 self.child.id(), | ||||
|                 e | ||||
|             ), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -63,7 +80,7 @@ async fn test_full_end_to_end_example() -> Result<(), Box<dyn std::error::Error> | ||||
|         std::env::set_var("RUST_LOG", "info"); | ||||
|     } | ||||
|     let _ = env_logger::try_init(); | ||||
|      | ||||
|  | ||||
|     info!("🚀 Starting self-contained end-to-end authentication example"); | ||||
|     info!("🔗 Running full end-to-end example with server"); | ||||
|  | ||||
| @@ -75,10 +92,14 @@ async fn test_full_end_to_end_example() -> Result<(), Box<dyn std::error::Error> | ||||
|         .stdout(Stdio::piped()) | ||||
|         .stderr(Stdio::piped()) | ||||
|         .spawn()?; | ||||
|     let _redis_server_guard = ChildProcessGuard::new(redis_server_process, "redis-server".to_string()); | ||||
|     info!("Redis server started with PID {}", _redis_server_guard.child.id()); | ||||
|     let _redis_server_guard = | ||||
|         ChildProcessGuard::new(redis_server_process, "redis-server".to_string()); | ||||
|     info!( | ||||
|         "Redis server started with PID {}", | ||||
|         _redis_server_guard.child.id() | ||||
|     ); | ||||
|     sleep(Duration::from_millis(500)).await; | ||||
|      | ||||
|  | ||||
|     // Step 2 & 3: Setup and spawn circle using launcher | ||||
|     info!("🚀 Setting up and spawning circle via launcher..."); | ||||
|     let circle_name = "e2e_test_circle"; | ||||
| @@ -89,27 +110,35 @@ async fn test_full_end_to_end_example() -> Result<(), Box<dyn std::error::Error> | ||||
|     }]; | ||||
|  | ||||
|     let (running_circles_store, _circle_outputs) = setup_and_spawn_circles(circle_configs).await?; | ||||
|      | ||||
|  | ||||
|     info!("Circles spawned by launcher:"); | ||||
|     for circle_info_arc_loop in &running_circles_store { | ||||
|         let circle_info_locked_loop = circle_info_arc_loop.lock().expect("Failed to lock circle info for logging"); | ||||
|         info!("  ✅ Name: {}, WS Port: {}, Public Key: {}...", | ||||
|               circle_info_locked_loop.config.name, circle_info_locked_loop.config.port, &circle_info_locked_loop.public_key[..10]); | ||||
|         let circle_info_locked_loop = circle_info_arc_loop | ||||
|             .lock() | ||||
|             .expect("Failed to lock circle info for logging"); | ||||
|         info!( | ||||
|             "  ✅ Name: {}, WS Port: {}, Public Key: {}...", | ||||
|             circle_info_locked_loop.config.name, | ||||
|             circle_info_locked_loop.config.port, | ||||
|             &circle_info_locked_loop.public_key[..10] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     let target_circle_name = "e2e_test_circle"; // This was 'circle_name' | ||||
|     let mut found_circle_arc_opt: Option<Arc<Mutex<RunningCircleInfo>>> = None; // std::sync::Mutex | ||||
|     for info_arc_find in &running_circles_store { | ||||
|         if info_arc_find.lock().expect("Failed to lock circle info for finding target").config.name == target_circle_name { | ||||
|             found_circle_arc_opt = Some(info_arc_find.clone()); | ||||
|             break; | ||||
|         } | ||||
|     } | ||||
|     let circle_info_arc = found_circle_arc_opt | ||||
|         .ok_or_else(|| Into::<Box<dyn std::error::Error>>::into(format!("Circle '{}' not found in running_circles_store", target_circle_name)))?; | ||||
|      | ||||
|     let circle_info_locked = circle_info_arc.lock().expect("Failed to lock target circle info"); // Lock it for use | ||||
|     let server_address = format!("127.0.0.1:{}", circle_info_locked.config.port); // Access port via config | ||||
|     // TODO: FIX | ||||
|     // let target_circle_name = "e2e_test_circle"; // This was 'circle_name' | ||||
|     // let mut found_circle_arc_opt: Option<Arc<Mutex<RunningCircleInfo>>> = None; // std::sync::Mutex | ||||
|     // for info_arc_find in &running_circles_store { | ||||
|     //     if info_arc_find.lock().expect("Failed to lock circle info for finding target").config.name == target_circle_name { | ||||
|     //         found_circle_arc_opt = Some(info_arc_find.clone()); | ||||
|     //         break; | ||||
|     //     } | ||||
|     // } | ||||
|     // let circle_info_arc = found_circle_arc_opt | ||||
|     //     .ok_or_else(|| Into::<Box<dyn std::error::Error>>::into(format!("Circle '{}' not found in running_circles_store", target_circle_name)))?; | ||||
|  | ||||
|     // let circle_info_locked = circle_info_arc.lock().expect("Failed to lock target circle info"); // Lock it for use | ||||
|     // let server_address = format!("127.0.0.1:{}", circle_info_locked.config.port); // Access port via config | ||||
|  | ||||
|     // The main info log for the specific test circle is covered by the loop. | ||||
|     // If a specific log for the *target* circle is still desired here, it can be added, e.g.: | ||||
|     // info!("Target circle for test: '{}' at ws://{}/ws, Public Key: {}...", | ||||
| @@ -117,112 +146,112 @@ async fn test_full_end_to_end_example() -> Result<(), Box<dyn std::error::Error> | ||||
|     // The circle_public_key_hex for the server is now circle_info.public_key | ||||
|     // Client generates its own keypair (Step 4, formerly Step 2 for client keys) | ||||
|     sleep(Duration::from_millis(1000)).await; // Allow services to fully start | ||||
|      | ||||
|     // Step 4: Generate a keypair for the client | ||||
|     info!("🔑 Generating a new keypair for the client..."); | ||||
|     let client_private_key = auth::generate_private_key()?; | ||||
|     info!("🔑 Generated client private key: {}...", &client_private_key[..10]); | ||||
|  | ||||
|     let shared_secret = auth::generate_shared_secret( | ||||
|         &client_private_key, | ||||
|         &auth::pubkey_from_hex(&circle_info_locked.public_key).expect("Failed to get pubkey from hex")?, // Use public key from the locked RunningCircleInfo | ||||
|     )?; | ||||
|     // // Step 4: Generate a keypair for the client | ||||
|     // info!("🔑 Generating a new keypair for the client..."); | ||||
|     // let client_private_key = auth::generate_private_key()?; | ||||
|     // info!("🔑 Generated client private key: {}...", &client_private_key[..10]); | ||||
|  | ||||
|     // Step 5: Create authenticated client | ||||
|     info!("🔌 Creating authenticated WebSocket client..."); | ||||
|     let mut client = CircleWsClientBuilder::new(format!("ws://{}/ws", server_address)) | ||||
|         .with_keypair(client_private_key.clone()) | ||||
|         .build(); | ||||
|      | ||||
|     // Step 5: Connect to WebSocket | ||||
|     info!("🔗 Connecting to WebSocket server..."); | ||||
|     client.connect().await?; | ||||
|     // let shared_secret = auth::generate_shared_secret( | ||||
|     //     &client_private_key, | ||||
|     //     &auth::pubkey_from_hex(&circle_info_locked.public_key).expect("Failed to get pubkey from hex")?, // Use public key from the locked RunningCircleInfo | ||||
|     // )?; | ||||
|  | ||||
|     // Step 6: Authenticate the client | ||||
|     info!("🔐 Authenticating client..."); | ||||
|     match client.authenticate().await { | ||||
|         Ok(true) => { | ||||
|             info!("✅ Authentication successful!"); | ||||
|         } | ||||
|         Ok(false) => { | ||||
|             error!("❌ Authentication failed!"); | ||||
|             return Err("Authentication failed".into()); | ||||
|         } | ||||
|         Err(e) => { | ||||
|             error!("❌ Authentication failed: {}", e); | ||||
|             return Err(e.into()); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Step 7: Send authenticated requests | ||||
|     info!("📤 Sending authenticated Rhai script requests..."); | ||||
|     // TODO: FIX | ||||
|     // // Step 5: Create authenticated client | ||||
|     // info!("🔌 Creating authenticated WebSocket client..."); | ||||
|     // let mut client = CircleWsClientBuilder::new(format!("ws://{}/ws", server_address)) | ||||
|     //     .with_keypair(client_private_key.clone()) | ||||
|     //     .build(); | ||||
|  | ||||
|     let secp = Secp256k1::new(); | ||||
|     let secret_key_bytes = &hex::decode(&client_private_key).unwrap(); | ||||
|     let secret_key = SecretKey::from_slice(secret_key_bytes).unwrap(); | ||||
|     let expected_public_key = PublicKey::from_secret_key(&secp, &secret_key); | ||||
|     let expected_public_key_hex = hex::encode(expected_public_key.serialize_uncompressed()); | ||||
|      | ||||
|     let test_scripts = vec![ | ||||
|         "print(\"Hello from authenticated client!\"); 42", | ||||
|         "let x = 10; let y = 20; x + y", | ||||
|         "print(\"Testing authentication...\"); \"success\"", | ||||
|         "CALLER_PUBLIC_KEY", | ||||
|     ]; | ||||
|     // // Step 5: Connect to WebSocket | ||||
|     // info!("🔗 Connecting to WebSocket server..."); | ||||
|     // client.connect().await?; | ||||
|  | ||||
|     for (i, script) in test_scripts.iter().enumerate() { | ||||
|         info!("📝 Executing script {}: {}", i + 1, script); | ||||
|     // // Step 6: Authenticate the client | ||||
|     // info!("🔐 Authenticating client..."); | ||||
|     // match client.authenticate().await { | ||||
|     //     Ok(true) => { | ||||
|     //         info!("✅ Authentication successful!"); | ||||
|     //     } | ||||
|     //     Ok(false) => { | ||||
|     //         error!("❌ Authentication failed!"); | ||||
|     //         return Err("Authentication failed".into()); | ||||
|     //     } | ||||
|     //     Err(e) => { | ||||
|     //         error!("❌ Authentication failed: {}", e); | ||||
|     //         return Err(e.into()); | ||||
|     //     } | ||||
|     // } | ||||
|  | ||||
|         match client.play(script.to_string()).await { | ||||
|             Ok(result) => { | ||||
|                 info!("✅ Script {} result: {}", i + 1, result.output); | ||||
|                 if script == &"CALLER_PUBLIC_KEY" { | ||||
|                     assert_eq!(result.output, expected_public_key_hex); | ||||
|                     info!("✅ CALLER_PUBLIC_KEY verification successful!"); | ||||
|                 } | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 panic!("client.play() failed with error: {:#?}", e); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Small delay between requests | ||||
|         sleep(Duration::from_millis(500)).await; | ||||
|     } | ||||
|      | ||||
|     // Step 8: Verify public key in Redis | ||||
|     info!("🔍 Verifying public key in Redis..."); | ||||
|     let redis_client = redis::Client::open("redis://127.0.0.1:6379/")?; | ||||
|     let mut redis_conn = redis_client.get_multiplexed_async_connection().await?; | ||||
|     // // Step 7: Send authenticated requests | ||||
|     // info!("📤 Sending authenticated Rhai script requests..."); | ||||
|  | ||||
|     let mut found_task = false; | ||||
|     let task_keys: Vec<String> = redis_conn.keys("rhai_task_details:*").await?; | ||||
|     for key in task_keys { | ||||
|         let script_content: String = redis_conn.hget(&key, "script").await?; | ||||
|         if script_content.contains("Testing authentication...") { | ||||
|             let stored_public_key: String = redis_conn.hget(&key, "publicKey").await?; | ||||
|             assert_eq!(stored_public_key, expected_public_key_hex); | ||||
|             info!("✅ Public key verified in Redis for task: {}", key); | ||||
|             found_task = true; | ||||
|             break; | ||||
|         } | ||||
|     } | ||||
|     if !found_task { | ||||
|         return Err("Could not find the test task in Redis to verify public key.".into()); | ||||
|     } | ||||
|     // let secp = Secp256k1::new(); | ||||
|     // let secret_key_bytes = &hex::decode(&client_private_key).unwrap(); | ||||
|     // let secret_key = SecretKey::from_slice(secret_key_bytes).unwrap(); | ||||
|     // let expected_public_key = PublicKey::from_secret_key(&secp, &secret_key); | ||||
|     // let expected_public_key_hex = hex::encode(expected_public_key.serialize_uncompressed()); | ||||
|  | ||||
|     // let test_scripts = vec![ | ||||
|     //     "print(\"Hello from authenticated client!\"); 42", | ||||
|     //     "let x = 10; let y = 20; x + y", | ||||
|     //     "print(\"Testing authentication...\"); \"success\"", | ||||
|     //     "CALLER_PUBLIC_KEY", | ||||
|     // ]; | ||||
|  | ||||
|     // for (i, script) in test_scripts.iter().enumerate() { | ||||
|     //     info!("📝 Executing script {}: {}", i + 1, script); | ||||
|  | ||||
|     //     match client.play(script.to_string()).await { | ||||
|     //         Ok(result) => { | ||||
|     //             info!("✅ Script {} result: {}", i + 1, result.output); | ||||
|     //             if script == &"CALLER_PUBLIC_KEY" { | ||||
|     //                 assert_eq!(result.output, expected_public_key_hex); | ||||
|     //                 info!("✅ CALLER_PUBLIC_KEY verification successful!"); | ||||
|     //             } | ||||
|     //         } | ||||
|     //         Err(e) => { | ||||
|     //             panic!("client.play() failed with error: {:#?}", e); | ||||
|     //         } | ||||
|     //     } | ||||
|  | ||||
|     //     // Small delay between requests | ||||
|     //     sleep(Duration::from_millis(500)).await; | ||||
|     // } | ||||
|  | ||||
|     // // Step 8: Verify public key in Redis | ||||
|     // info!("🔍 Verifying public key in Redis..."); | ||||
|     // let redis_client = redis::Client::open("redis://127.0.0.1:6379/")?; | ||||
|     // let mut redis_conn = redis_client.get_multiplexed_async_connection().await?; | ||||
|  | ||||
|     // let mut found_task = false; | ||||
|     // let task_keys: Vec<String> = redis_conn.keys("rhai_task_details:*").await?; | ||||
|     // for key in task_keys { | ||||
|     //     let script_content: String = redis_conn.hget(&key, "script").await?; | ||||
|     //     if script_content.contains("Testing authentication...") { | ||||
|     //         let stored_public_key: String = redis_conn.hget(&key, "publicKey").await?; | ||||
|     //         assert_eq!(stored_public_key, expected_public_key_hex); | ||||
|     //         info!("✅ Public key verified in Redis for task: {}", key); | ||||
|     //         found_task = true; | ||||
|     //         break; | ||||
|     //     } | ||||
|     // } | ||||
|     // if !found_task { | ||||
|     //     return Err("Could not find the test task in Redis to verify public key.".into()); | ||||
|     // } | ||||
|  | ||||
|     // Step 9: Disconnect client | ||||
|     info!("🔌 Disconnecting client..."); | ||||
|     client.disconnect().await; | ||||
|     info!("✅ Client disconnected"); | ||||
|      | ||||
|     // info!("🔌 Disconnecting client..."); | ||||
|     // client.disconnect().await; | ||||
|     // info!("✅ Client disconnected"); | ||||
|  | ||||
|     // Step 10: Shutdown circles via launcher | ||||
|     info!("🔌 Shutting down circles via launcher..."); | ||||
|     shutdown_circles(running_circles_store).await; // Pass the Vec<Arc<Mutex<RunningCircleInfo>>> | ||||
|     info!("✅ Circles shut down."); | ||||
|  | ||||
|     info!("🎉 Full end-to-end authentication example completed successfully!"); | ||||
|      | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user