feat: Add Rust formatter and linter configuration, fix warnings

- Add rustfmt.toml with comprehensive formatting rules
- Add clippy.toml with linting configuration
- Add .cargo/config.toml with clippy warning flags
- Fix unused imports and variables warnings
- Connect unused functions in terminal.rs, server.rs, tray_menu.rs, and session_monitor.rs
- Add proper error handling and cleanup on app quit
- Apply formatting to all Rust code files
- Ensure feature parity with Mac app implementation

This improves code quality, consistency, and maintainability of the Tauri app.
This commit is contained in:
Peter Steinberger 2025-06-20 13:36:36 +02:00
parent 0d9232574c
commit 5d3576a205
31 changed files with 2870 additions and 2246 deletions

View file

@ -1,10 +1,10 @@
use serde::{Serialize, Deserialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::RwLock;
/// API test method /// API test method
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@ -36,11 +36,22 @@ impl HttpMethod {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AssertionType { pub enum AssertionType {
StatusCode(u16), StatusCode(u16),
StatusRange { min: u16, max: u16 }, StatusRange {
ResponseTime { max_ms: u64 }, min: u16,
max: u16,
},
ResponseTime {
max_ms: u64,
},
HeaderExists(String), HeaderExists(String),
HeaderEquals { key: String, value: String }, HeaderEquals {
JsonPath { path: String, expected: serde_json::Value }, key: String,
value: String,
},
JsonPath {
path: String,
expected: serde_json::Value,
},
BodyContains(String), BodyContains(String),
BodyMatches(String), // Regex BodyMatches(String), // Regex
ContentType(String), ContentType(String),
@ -78,9 +89,16 @@ pub enum APITestBody {
/// API test authentication /// API test authentication
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum APITestAuth { pub enum APITestAuth {
Basic { username: String, password: String }, Basic {
username: String,
password: String,
},
Bearer(String), Bearer(String),
ApiKey { key: String, value: String, in_header: bool }, ApiKey {
key: String,
value: String,
in_header: bool,
},
Custom(HashMap<String, String>), Custom(HashMap<String, String>),
} }
@ -205,7 +223,10 @@ impl APITestingManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -221,7 +242,10 @@ impl APITestingManager {
/// Add test suite /// Add test suite
pub async fn add_test_suite(&self, suite: APITestSuite) { pub async fn add_test_suite(&self, suite: APITestSuite) {
self.test_suites.write().await.insert(suite.id.clone(), suite); self.test_suites
.write()
.await
.insert(suite.id.clone(), suite);
} }
/// Get test suite /// Get test suite
@ -235,7 +259,11 @@ impl APITestingManager {
} }
/// Run single test /// Run single test
pub async fn run_test(&self, test: &APITest, variables: &HashMap<String, String>) -> APITestResult { pub async fn run_test(
&self,
test: &APITest,
variables: &HashMap<String, String>,
) -> APITestResult {
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
let mut result = APITestResult { let mut result = APITestResult {
test_id: test.id.clone(), test_id: test.id.clone(),
@ -274,7 +302,9 @@ impl APITestingManager {
result.retries_used = retry; result.retries_used = retry;
// Run assertions // Run assertions
result.assertion_results = self.run_assertions(&test.assertions, status, &result.response_headers, &body).await; result.assertion_results = self
.run_assertions(&test.assertions, status, &result.response_headers, &body)
.await;
result.success = result.assertion_results.iter().all(|a| a.passed); result.success = result.assertion_results.iter().all(|a| a.passed);
break; break;
@ -324,9 +354,9 @@ impl APITestingManager {
let vars = variables.clone(); let vars = variables.clone();
let manager = self.clone_for_parallel(); let manager = self.clone_for_parallel();
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(
manager.run_test(&test, &vars).await async move { manager.run_test(&test, &vars).await },
})); ));
} }
for task in tasks { for task in tasks {
@ -371,11 +401,10 @@ impl APITestingManager {
// Send notification // Send notification
if let Some(notification_manager) = &self.notification_manager { if let Some(notification_manager) = &self.notification_manager {
let message = format!( let message = format!("Test suite completed: {} passed, {} failed", passed, failed);
"Test suite completed: {} passed, {} failed", let _ = notification_manager
passed, failed .notify_success("API Tests", &message)
); .await;
let _ = notification_manager.notify_success("API Tests", &message).await;
} }
Some(history_entry) Some(history_entry)
@ -403,7 +432,9 @@ impl APITestingManager {
/// Export test suite /// Export test suite
pub async fn export_test_suite(&self, suite_id: &str) -> Result<String, String> { pub async fn export_test_suite(&self, suite_id: &str) -> Result<String, String> {
let suite = self.get_test_suite(suite_id).await let suite = self
.get_test_suite(suite_id)
.await
.ok_or_else(|| "Test suite not found".to_string())?; .ok_or_else(|| "Test suite not found".to_string())?;
serde_json::to_string_pretty(&suite) serde_json::to_string_pretty(&suite)
@ -493,42 +524,39 @@ impl APITestingManager {
for assertion in assertions { for assertion in assertions {
let result = match assertion { let result = match assertion {
AssertionType::StatusCode(expected) => { AssertionType::StatusCode(expected) => AssertionResult {
AssertionResult { assertion: assertion.clone(),
assertion: assertion.clone(), passed: status == *expected,
passed: status == *expected, actual_value: Some(status.to_string()),
actual_value: Some(status.to_string()), error_message: if status != *expected {
error_message: if status != *expected { Some(format!("Expected status {}, got {}", expected, status))
Some(format!("Expected status {}, got {}", expected, status)) } else {
} else { None
None },
}, },
} AssertionType::StatusRange { min, max } => AssertionResult {
} assertion: assertion.clone(),
AssertionType::StatusRange { min, max } => { passed: status >= *min && status <= *max,
AssertionResult { actual_value: Some(status.to_string()),
assertion: assertion.clone(), error_message: if status < *min || status > *max {
passed: status >= *min && status <= *max, Some(format!(
actual_value: Some(status.to_string()), "Expected status between {} and {}, got {}",
error_message: if status < *min || status > *max { min, max, status
Some(format!("Expected status between {} and {}, got {}", min, max, status)) ))
} else { } else {
None None
}, },
} },
} AssertionType::HeaderExists(key) => AssertionResult {
AssertionType::HeaderExists(key) => { assertion: assertion.clone(),
AssertionResult { passed: headers.contains_key(key),
assertion: assertion.clone(), actual_value: None,
passed: headers.contains_key(key), error_message: if !headers.contains_key(key) {
actual_value: None, Some(format!("Header '{}' not found", key))
error_message: if !headers.contains_key(key) { } else {
Some(format!("Header '{}' not found", key)) None
} else { },
None },
},
}
}
AssertionType::HeaderEquals { key, value } => { AssertionType::HeaderEquals { key, value } => {
let actual = headers.get(key); let actual = headers.get(key);
AssertionResult { AssertionResult {
@ -536,25 +564,29 @@ impl APITestingManager {
passed: actual == Some(value), passed: actual == Some(value),
actual_value: actual.cloned(), actual_value: actual.cloned(),
error_message: if actual != Some(value) { error_message: if actual != Some(value) {
Some(format!("Header '{}' expected '{}', got '{:?}'", key, value, actual)) Some(format!(
"Header '{}' expected '{}', got '{:?}'",
key, value, actual
))
} else { } else {
None None
}, },
} }
} }
AssertionType::BodyContains(text) => { AssertionType::BodyContains(text) => AssertionResult {
AssertionResult { assertion: assertion.clone(),
assertion: assertion.clone(), passed: body.contains(text),
passed: body.contains(text), actual_value: None,
actual_value: None, error_message: if !body.contains(text) {
error_message: if !body.contains(text) { Some(format!("Body does not contain '{}'", text))
Some(format!("Body does not contain '{}'", text)) } else {
} else { None
None },
}, },
} AssertionType::JsonPath {
} path: _,
AssertionType::JsonPath { path: _, expected: _ } => { expected: _,
} => {
// TODO: Implement JSON path assertion // TODO: Implement JSON path assertion
AssertionResult { AssertionResult {
assertion: assertion.clone(), assertion: assertion.clone(),
@ -563,14 +595,12 @@ impl APITestingManager {
error_message: Some("JSON path assertions not yet implemented".to_string()), error_message: Some("JSON path assertions not yet implemented".to_string()),
} }
} }
_ => { _ => AssertionResult {
AssertionResult { assertion: assertion.clone(),
assertion: assertion.clone(), passed: false,
passed: false, actual_value: None,
actual_value: None, error_message: Some("Assertion type not implemented".to_string()),
error_message: Some("Assertion type not implemented".to_string()), },
}
}
}; };
results.push(result); results.push(result);
} }
@ -602,7 +632,11 @@ impl APITestingManager {
let token = self.replace_variables(token, variables); let token = self.replace_variables(token, variables);
request.bearer_auth(token) request.bearer_auth(token)
} }
APITestAuth::ApiKey { key, value, in_header } => { APITestAuth::ApiKey {
key,
value,
in_header,
} => {
let key = self.replace_variables(key, variables); let key = self.replace_variables(key, variables);
let value = self.replace_variables(value, variables); let value = self.replace_variables(value, variables);
if *in_header { if *in_header {

View file

@ -1,11 +1,10 @@
use tauri::AppHandle;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::AppHandle;
/// Check if the app should be moved to Applications folder /// Check if the app should be moved to Applications folder
/// This is a macOS-specific feature /// This is a macOS-specific feature
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub async fn check_and_prompt_move(app_handle: AppHandle) -> Result<(), String> { pub async fn check_and_prompt_move(_app_handle: AppHandle) -> Result<(), String> {
// Get current app bundle path // Get current app bundle path
let bundle_path = get_app_bundle_path()?; let bundle_path = get_app_bundle_path()?;
@ -27,7 +26,8 @@ pub async fn check_and_prompt_move(app_handle: AppHandle) -> Result<(), String>
// TODO: Implement dialog using tauri-plugin-dialog // TODO: Implement dialog using tauri-plugin-dialog
tracing::info!("App should be moved to Applications folder"); tracing::info!("App should be moved to Applications folder");
if false { // Temporarily disabled until dialog is implemented if false {
// Temporarily disabled until dialog is implemented
move_to_applications_folder(bundle_path)?; move_to_applications_folder(bundle_path)?;
// Restart the app from the new location // Restart the app from the new location
@ -53,8 +53,8 @@ fn get_app_bundle_path() -> Result<PathBuf, String> {
use std::env; use std::env;
// Get the executable path // Get the executable path
let exe_path = env::current_exe() let exe_path =
.map_err(|e| format!("Failed to get executable path: {}", e))?; env::current_exe().map_err(|e| format!("Failed to get executable path: {}", e))?;
// Navigate up to the .app bundle // Navigate up to the .app bundle
// Typical structure: /path/to/VibeTunnel.app/Contents/MacOS/VibeTunnel // Typical structure: /path/to/VibeTunnel.app/Contents/MacOS/VibeTunnel
@ -62,7 +62,8 @@ fn get_app_bundle_path() -> Result<PathBuf, String> {
// Go up three levels to reach the .app bundle // Go up three levels to reach the .app bundle
for _ in 0..3 { for _ in 0..3 {
bundle_path = bundle_path.parent() bundle_path = bundle_path
.parent()
.ok_or("Failed to find app bundle")? .ok_or("Failed to find app bundle")?
.to_path_buf(); .to_path_buf();
} }
@ -83,10 +84,11 @@ fn is_in_applications_folder(bundle_path: &PathBuf) -> bool {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn move_to_applications_folder(bundle_path: PathBuf) -> Result<(), String> { fn move_to_applications_folder(bundle_path: PathBuf) -> Result<(), String> {
use std::process::Command;
use std::fs; use std::fs;
use std::process::Command;
let app_name = bundle_path.file_name() let app_name = bundle_path
.file_name()
.ok_or("Failed to get app name")? .ok_or("Failed to get app name")?
.to_string_lossy(); .to_string_lossy();

View file

@ -5,7 +5,7 @@ use axum::{
response::Response, response::Response,
Json, Json,
}; };
use base64::{Engine as _, engine::general_purpose}; use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;

View file

@ -1,9 +1,9 @@
use serde::{Serialize, Deserialize}; use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use std::collections::HashMap;
use chrono::{DateTime, Utc, Duration};
use sha2::{Sha256, Digest};
/// Authentication token type /// Authentication token type
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
@ -104,7 +104,7 @@ impl Default for AuthCacheConfig {
Self { Self {
enabled: true, enabled: true,
max_entries: 1000, max_entries: 1000,
default_ttl_seconds: 3600, // 1 hour default_ttl_seconds: 3600, // 1 hour
refresh_threshold_seconds: 300, // 5 minutes refresh_threshold_seconds: 300, // 5 minutes
persist_to_disk: false, persist_to_disk: false,
encryption_enabled: true, encryption_enabled: true,
@ -126,7 +126,11 @@ pub struct AuthCacheStats {
} }
/// Token refresh callback /// Token refresh callback
pub type TokenRefreshCallback = Arc<dyn Fn(CachedToken) -> futures::future::BoxFuture<'static, Result<CachedToken, String>> + Send + Sync>; pub type TokenRefreshCallback = Arc<
dyn Fn(CachedToken) -> futures::future::BoxFuture<'static, Result<CachedToken, String>>
+ Send
+ Sync,
>;
/// Authentication cache manager /// Authentication cache manager
pub struct AuthCacheManager { pub struct AuthCacheManager {
@ -168,7 +172,10 @@ impl AuthCacheManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -244,7 +251,8 @@ impl AuthCacheManager {
// Check if needs refresh // Check if needs refresh
if token.needs_refresh(config.refresh_threshold_seconds) { if token.needs_refresh(config.refresh_threshold_seconds) {
// Trigger refresh in background // Trigger refresh in background
if let Some(refresh_callback) = self.refresh_callbacks.read().await.get(key) { if let Some(refresh_callback) = self.refresh_callbacks.read().await.get(key)
{
let token_clone = token.clone(); let token_clone = token.clone();
let callback = refresh_callback.clone(); let callback = refresh_callback.clone();
let key_clone = key.to_string(); let key_clone = key.to_string();
@ -269,7 +277,11 @@ impl AuthCacheManager {
} }
/// Store credential in cache /// Store credential in cache
pub async fn store_credential(&self, key: &str, credential: AuthCredential) -> Result<(), String> { pub async fn store_credential(
&self,
key: &str,
credential: AuthCredential,
) -> Result<(), String> {
let config = self.config.read().await; let config = self.config.read().await;
if !config.enabled { if !config.enabled {
return Ok(()); return Ok(());
@ -315,7 +327,10 @@ impl AuthCacheManager {
/// Register token refresh callback /// Register token refresh callback
pub async fn register_refresh_callback(&self, key: &str, callback: TokenRefreshCallback) { pub async fn register_refresh_callback(&self, key: &str, callback: TokenRefreshCallback) {
self.refresh_callbacks.write().await.insert(key.to_string(), callback); self.refresh_callbacks
.write()
.await
.insert(key.to_string(), callback);
} }
/// Clear specific cache entry /// Clear specific cache entry
@ -344,7 +359,9 @@ impl AuthCacheManager {
/// List all cache entries /// List all cache entries
pub async fn list_entries(&self) -> Vec<(String, DateTime<Utc>, u64)> { pub async fn list_entries(&self) -> Vec<(String, DateTime<Utc>, u64)> {
self.cache.read().await self.cache
.read()
.await
.values() .values()
.map(|entry| (entry.key.clone(), entry.last_accessed, entry.access_count)) .map(|entry| (entry.key.clone(), entry.last_accessed, entry.access_count))
.collect() .collect()
@ -372,9 +389,7 @@ impl AuthCacheManager {
} }
stats.total_entries = cache.len(); stats.total_entries = cache.len();
stats.total_tokens = cache.values() stats.total_tokens = cache.values().map(|e| e.tokens.len()).sum();
.map(|e| e.tokens.len())
.sum();
Ok(()) Ok(())
} }
@ -388,14 +403,20 @@ impl AuthCacheManager {
// Helper methods // Helper methods
fn token_matches_scope(&self, token: &CachedToken, scope: &AuthScope) -> bool { fn token_matches_scope(&self, token: &CachedToken, scope: &AuthScope) -> bool {
token.scope.service == scope.service && token.scope.service == scope.service
token.scope.resource == scope.resource && && token.scope.resource == scope.resource
scope.permissions.iter().all(|p| token.scope.permissions.contains(p)) && scope
.permissions
.iter()
.all(|p| token.scope.permissions.contains(p))
} }
fn evict_oldest_entry(&self, cache: &mut HashMap<String, AuthCacheEntry>, stats: &mut AuthCacheStats) { fn evict_oldest_entry(
if let Some((key, _)) = cache.iter() &self,
.min_by_key(|(_, entry)| entry.last_accessed) { cache: &mut HashMap<String, AuthCacheEntry>,
stats: &mut AuthCacheStats,
) {
if let Some((key, _)) = cache.iter().min_by_key(|(_, entry)| entry.last_accessed) {
let key = key.clone(); let key = key.clone();
cache.remove(&key); cache.remove(&key);
stats.eviction_count += 1; stats.eviction_count += 1;
@ -429,9 +450,7 @@ impl AuthCacheManager {
} }
stats.expired_tokens += total_expired; stats.expired_tokens += total_expired;
stats.total_tokens = cache.values() stats.total_tokens = cache.values().map(|e| e.tokens.len()).sum();
.map(|e| e.tokens.len())
.sum();
// Remove empty entries // Remove empty entries
cache.retain(|_, entry| !entry.tokens.is_empty() || entry.credential.is_some()); cache.retain(|_, entry| !entry.tokens.is_empty() || entry.credential.is_some());

View file

@ -1,6 +1,6 @@
use crate::state::AppState;
use auto_launch::AutoLaunchBuilder; use auto_launch::AutoLaunchBuilder;
use tauri::State; use tauri::State;
use crate::state::AppState;
fn get_app_path() -> String { fn get_app_path() -> String {
let exe_path = std::env::current_exe().unwrap(); let exe_path = std::env::current_exe().unwrap();
@ -64,10 +64,7 @@ pub fn is_auto_launch_enabled() -> Result<bool, String> {
} }
#[tauri::command] #[tauri::command]
pub async fn set_auto_launch( pub async fn set_auto_launch(enabled: bool, _state: State<'_, AppState>) -> Result<(), String> {
enabled: bool,
_state: State<'_, AppState>,
) -> Result<(), String> {
if enabled { if enabled {
enable_auto_launch() enable_auto_launch()
} else { } else {
@ -76,8 +73,6 @@ pub async fn set_auto_launch(
} }
#[tauri::command] #[tauri::command]
pub async fn get_auto_launch( pub async fn get_auto_launch(_state: State<'_, AppState>) -> Result<bool, String> {
_state: State<'_, AppState>,
) -> Result<bool, String> {
is_auto_launch_enabled() is_auto_launch_enabled()
} }

View file

@ -1,10 +1,10 @@
use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc};
use std::sync::Arc; use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{DateTime, Utc}; use std::sync::Arc;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::RwLock;
/// Backend type enumeration /// Backend type enumeration
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
@ -155,7 +155,10 @@ impl BackendManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -164,103 +167,112 @@ impl BackendManager {
let mut configs = HashMap::new(); let mut configs = HashMap::new();
// Rust backend (built-in) // Rust backend (built-in)
configs.insert(BackendType::Rust, BackendConfig { configs.insert(
backend_type: BackendType::Rust, BackendType::Rust,
name: "Rust (Built-in)".to_string(), BackendConfig {
version: env!("CARGO_PKG_VERSION").to_string(), backend_type: BackendType::Rust,
executable_path: None, name: "Rust (Built-in)".to_string(),
working_directory: None, version: env!("CARGO_PKG_VERSION").to_string(),
environment_variables: HashMap::new(), executable_path: None,
arguments: vec![], working_directory: None,
port: Some(4020), environment_variables: HashMap::new(),
features: BackendFeatures { arguments: vec![],
terminal_sessions: true, port: Some(4020),
file_browser: true, features: BackendFeatures {
port_forwarding: true, terminal_sessions: true,
authentication: true, file_browser: true,
websocket_support: true, port_forwarding: true,
rest_api: true, authentication: true,
graphql_api: false, websocket_support: true,
metrics: true, rest_api: true,
graphql_api: false,
metrics: true,
},
requirements: BackendRequirements {
runtime: None,
runtime_version: None,
dependencies: vec![],
system_packages: vec![],
min_memory_mb: Some(64),
min_disk_space_mb: Some(10),
},
}, },
requirements: BackendRequirements { );
runtime: None,
runtime_version: None,
dependencies: vec![],
system_packages: vec![],
min_memory_mb: Some(64),
min_disk_space_mb: Some(10),
},
});
// Node.js backend // Node.js backend
configs.insert(BackendType::NodeJS, BackendConfig { configs.insert(
backend_type: BackendType::NodeJS, BackendType::NodeJS,
name: "Node.js Server".to_string(), BackendConfig {
version: "1.0.0".to_string(), backend_type: BackendType::NodeJS,
executable_path: Some(PathBuf::from("node")), name: "Node.js Server".to_string(),
working_directory: None, version: "1.0.0".to_string(),
environment_variables: HashMap::new(), executable_path: Some(PathBuf::from("node")),
arguments: vec!["server.js".to_string()], working_directory: None,
port: Some(4021), environment_variables: HashMap::new(),
features: BackendFeatures { arguments: vec!["server.js".to_string()],
terminal_sessions: true, port: Some(4021),
file_browser: true, features: BackendFeatures {
port_forwarding: false, terminal_sessions: true,
authentication: true, file_browser: true,
websocket_support: true, port_forwarding: false,
rest_api: true, authentication: true,
graphql_api: true, websocket_support: true,
metrics: false, rest_api: true,
graphql_api: true,
metrics: false,
},
requirements: BackendRequirements {
runtime: Some("node".to_string()),
runtime_version: Some(">=16.0.0".to_string()),
dependencies: vec![
"express".to_string(),
"socket.io".to_string(),
"node-pty".to_string(),
],
system_packages: vec![],
min_memory_mb: Some(128),
min_disk_space_mb: Some(50),
},
}, },
requirements: BackendRequirements { );
runtime: Some("node".to_string()),
runtime_version: Some(">=16.0.0".to_string()),
dependencies: vec![
"express".to_string(),
"socket.io".to_string(),
"node-pty".to_string(),
],
system_packages: vec![],
min_memory_mb: Some(128),
min_disk_space_mb: Some(50),
},
});
// Python backend // Python backend
configs.insert(BackendType::Python, BackendConfig { configs.insert(
backend_type: BackendType::Python, BackendType::Python,
name: "Python Server".to_string(), BackendConfig {
version: "1.0.0".to_string(), backend_type: BackendType::Python,
executable_path: Some(PathBuf::from("python3")), name: "Python Server".to_string(),
working_directory: None, version: "1.0.0".to_string(),
environment_variables: HashMap::new(), executable_path: Some(PathBuf::from("python3")),
arguments: vec!["-m".to_string(), "vibetunnel_server".to_string()], working_directory: None,
port: Some(4022), environment_variables: HashMap::new(),
features: BackendFeatures { arguments: vec!["-m".to_string(), "vibetunnel_server".to_string()],
terminal_sessions: true, port: Some(4022),
file_browser: true, features: BackendFeatures {
port_forwarding: false, terminal_sessions: true,
authentication: true, file_browser: true,
websocket_support: true, port_forwarding: false,
rest_api: true, authentication: true,
graphql_api: false, websocket_support: true,
metrics: true, rest_api: true,
graphql_api: false,
metrics: true,
},
requirements: BackendRequirements {
runtime: Some("python3".to_string()),
runtime_version: Some(">=3.8".to_string()),
dependencies: vec![
"fastapi".to_string(),
"uvicorn".to_string(),
"websockets".to_string(),
"ptyprocess".to_string(),
],
system_packages: vec![],
min_memory_mb: Some(96),
min_disk_space_mb: Some(30),
},
}, },
requirements: BackendRequirements { );
runtime: Some("python3".to_string()),
runtime_version: Some(">=3.8".to_string()),
dependencies: vec![
"fastapi".to_string(),
"uvicorn".to_string(),
"websockets".to_string(),
"ptyprocess".to_string(),
],
system_packages: vec![],
min_memory_mb: Some(96),
min_disk_space_mb: Some(30),
},
});
configs configs
} }
@ -305,7 +317,9 @@ impl BackendManager {
} }
// Get backend configuration // Get backend configuration
let config = self.get_backend_config(backend_type).await let config = self
.get_backend_config(backend_type)
.await
.ok_or_else(|| "Backend configuration not found".to_string())?; .ok_or_else(|| "Backend configuration not found".to_string())?;
// Generate instance ID // Generate instance ID
@ -332,13 +346,17 @@ impl BackendManager {
}; };
// Store instance // Store instance
self.instances.write().await.insert(instance_id.clone(), instance); self.instances
.write()
.await
.insert(instance_id.clone(), instance);
// Start backend process // Start backend process
match backend_type { match backend_type {
BackendType::Rust => { BackendType::Rust => {
// Rust backend is handled internally // Rust backend is handled internally
self.update_instance_status(&instance_id, BackendStatus::Running).await; self.update_instance_status(&instance_id, BackendStatus::Running)
.await;
*self.active_backend.write().await = Some(BackendType::Rust); *self.active_backend.write().await = Some(BackendType::Rust);
Ok(instance_id) Ok(instance_id)
} }
@ -351,7 +369,10 @@ impl BackendManager {
/// Stop backend /// Stop backend
pub async fn stop_backend(&self, instance_id: &str) -> Result<(), String> { pub async fn stop_backend(&self, instance_id: &str) -> Result<(), String> {
let instance = self.instances.read().await let instance = self
.instances
.read()
.await
.get(instance_id) .get(instance_id)
.cloned() .cloned()
.ok_or_else(|| "Backend instance not found".to_string())?; .ok_or_else(|| "Backend instance not found".to_string())?;
@ -359,7 +380,8 @@ impl BackendManager {
match instance.backend_type { match instance.backend_type {
BackendType::Rust => { BackendType::Rust => {
// Rust backend is handled internally // Rust backend is handled internally
self.update_instance_status(instance_id, BackendStatus::Stopped).await; self.update_instance_status(instance_id, BackendStatus::Stopped)
.await;
Ok(()) Ok(())
} }
_ => { _ => {
@ -378,8 +400,12 @@ impl BackendManager {
// Find and stop current backend instances // Find and stop current backend instances
let instance_id = { let instance_id = {
let instances = self.instances.read().await; let instances = self.instances.read().await;
instances.iter() instances
.find(|(_, instance)| instance.backend_type == current && instance.status == BackendStatus::Running) .iter()
.find(|(_, instance)| {
instance.backend_type == current
&& instance.status == BackendStatus::Running
})
.map(|(id, _)| id.clone()) .map(|(id, _)| id.clone())
}; };
if let Some(id) = instance_id { if let Some(id) = instance_id {
@ -396,10 +422,12 @@ impl BackendManager {
// Notify about backend switch // Notify about backend switch
if let Some(notification_manager) = &self.notification_manager { if let Some(notification_manager) = &self.notification_manager {
let _ = notification_manager.notify_success( let _ = notification_manager
"Backend Switched", .notify_success(
&format!("Switched to {:?} backend", backend_type) "Backend Switched",
).await; &format!("Switched to {:?} backend", backend_type),
)
.await;
} }
Ok(()) Ok(())
@ -417,7 +445,10 @@ impl BackendManager {
/// Get backend health /// Get backend health
pub async fn check_backend_health(&self, instance_id: &str) -> Result<HealthStatus, String> { pub async fn check_backend_health(&self, instance_id: &str) -> Result<HealthStatus, String> {
let instance = self.instances.read().await let instance = self
.instances
.read()
.await
.get(instance_id) .get(instance_id)
.cloned() .cloned()
.ok_or_else(|| "Backend instance not found".to_string())?; .ok_or_else(|| "Backend instance not found".to_string())?;
@ -487,7 +518,11 @@ impl BackendManager {
Err("Python backend installation not yet implemented".to_string()) Err("Python backend installation not yet implemented".to_string())
} }
async fn start_external_backend(&self, _instance_id: &str, _config: BackendConfig) -> Result<String, String> { async fn start_external_backend(
&self,
_instance_id: &str,
_config: BackendConfig,
) -> Result<String, String> {
// TODO: Implement external backend startup // TODO: Implement external backend startup
Err("External backend startup not yet implemented".to_string()) Err("External backend startup not yet implemented".to_string())
} }
@ -497,7 +532,10 @@ impl BackendManager {
Err("External backend shutdown not yet implemented".to_string()) Err("External backend shutdown not yet implemented".to_string())
} }
async fn check_external_backend_health(&self, _instance: &BackendInstance) -> Result<HealthStatus, String> { async fn check_external_backend_health(
&self,
_instance: &BackendInstance,
) -> Result<HealthStatus, String> {
// TODO: Implement health check for external backends // TODO: Implement health check for external backends
Ok(HealthStatus::Unknown) Ok(HealthStatus::Unknown)
} }

View file

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;
@ -5,7 +6,6 @@ use std::io::{BufWriter, Write};
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use chrono::{DateTime, Utc};
/// Asciinema cast v2 format header /// Asciinema cast v2 format header
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -62,12 +62,7 @@ pub struct CastRecorder {
impl CastRecorder { impl CastRecorder {
/// Create a new cast recorder /// Create a new cast recorder
pub fn new( pub fn new(width: u16, height: u16, title: Option<String>, command: Option<String>) -> Self {
width: u16,
height: u16,
title: Option<String>,
command: Option<String>,
) -> Self {
let now = Utc::now(); let now = Utc::now();
let header = CastHeader { let header = CastHeader {
version: 2, version: 2,
@ -114,7 +109,8 @@ impl CastRecorder {
self.write_event_to_file(&mut writer, event)?; self.write_event_to_file(&mut writer, event)?;
} }
writer.flush() writer
.flush()
.map_err(|e| format!("Failed to flush writer: {}", e))?; .map_err(|e| format!("Failed to flush writer: {}", e))?;
self.file_writer = Some(Arc::new(Mutex::new(writer))); self.file_writer = Some(Arc::new(Mutex::new(writer)));
@ -131,7 +127,8 @@ impl CastRecorder {
if let Some(writer_arc) = self.file_writer.take() { if let Some(writer_arc) = self.file_writer.take() {
let mut writer = writer_arc.lock().await; let mut writer = writer_arc.lock().await;
writer.flush() writer
.flush()
.map_err(|e| format!("Failed to flush final data: {}", e))?; .map_err(|e| format!("Failed to flush final data: {}", e))?;
} }
@ -153,7 +150,8 @@ impl CastRecorder {
async fn add_event(&self, event_type: EventType, data: &[u8]) -> Result<(), String> { async fn add_event(&self, event_type: EventType, data: &[u8]) -> Result<(), String> {
let timestamp = Utc::now() let timestamp = Utc::now()
.signed_duration_since(self.start_time) .signed_duration_since(self.start_time)
.num_milliseconds() as f64 / 1000.0; .num_milliseconds() as f64
/ 1000.0;
// Convert data to string (handling potential UTF-8 errors) // Convert data to string (handling potential UTF-8 errors)
let data_string = String::from_utf8_lossy(data).to_string(); let data_string = String::from_utf8_lossy(data).to_string();
@ -168,7 +166,8 @@ impl CastRecorder {
if let Some(writer_arc) = &self.file_writer { if let Some(writer_arc) = &self.file_writer {
let mut writer = writer_arc.lock().await; let mut writer = writer_arc.lock().await;
self.write_event_to_file(&mut writer, &event)?; self.write_event_to_file(&mut writer, &event)?;
writer.flush() writer
.flush()
.map_err(|e| format!("Failed to flush event: {}", e))?; .map_err(|e| format!("Failed to flush event: {}", e))?;
} }
@ -186,14 +185,10 @@ impl CastRecorder {
event: &CastEvent, event: &CastEvent,
) -> Result<(), String> { ) -> Result<(), String> {
// Format: [timestamp, event_type, data] // Format: [timestamp, event_type, data]
let event_array = serde_json::json!([ let event_array =
event.timestamp, serde_json::json!([event.timestamp, event.event_type.as_str(), event.data]);
event.event_type.as_str(),
event.data
]);
writeln!(writer, "{}", event_array) writeln!(writer, "{}", event_array).map_err(|e| format!("Failed to write event: {}", e))?;
.map_err(|e| format!("Failed to write event: {}", e))?;
Ok(()) Ok(())
} }
@ -223,7 +218,8 @@ impl CastRecorder {
self.write_event_to_file(&mut writer, event)?; self.write_event_to_file(&mut writer, event)?;
} }
writer.flush() writer
.flush()
.map_err(|e| format!("Failed to flush file: {}", e))?; .map_err(|e| format!("Failed to flush file: {}", e))?;
Ok(()) Ok(())

View file

@ -1,8 +1,8 @@
use serde::Serialize;
use std::fs; use std::fs;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use serde::Serialize;
const CLI_SCRIPT: &str = r#"#!/bin/bash const CLI_SCRIPT: &str = r#"#!/bin/bash
# VibeTunnel CLI wrapper # VibeTunnel CLI wrapper
@ -21,6 +21,7 @@ fi
"$APP_PATH/Contents/MacOS/VibeTunnel" --cli "$@" "$APP_PATH/Contents/MacOS/VibeTunnel" --cli "$@"
"#; "#;
#[cfg(target_os = "windows")]
const WINDOWS_CLI_SCRIPT: &str = r#"@echo off const WINDOWS_CLI_SCRIPT: &str = r#"@echo off
:: VibeTunnel CLI wrapper for Windows :: VibeTunnel CLI wrapper for Windows
@ -36,6 +37,7 @@ if not exist "%APP_PATH%" (
"%APP_PATH%" --cli %* "%APP_PATH%" --cli %*
"#; "#;
#[cfg(target_os = "linux")]
const LINUX_CLI_SCRIPT: &str = r#"#!/bin/bash const LINUX_CLI_SCRIPT: &str = r#"#!/bin/bash
# VibeTunnel CLI wrapper for Linux # VibeTunnel CLI wrapper for Linux
@ -86,8 +88,12 @@ fn install_cli_macos() -> Result<CliInstallResult, String> {
// Check if /usr/local/bin exists, create if not // Check if /usr/local/bin exists, create if not
let bin_dir = cli_path.parent().unwrap(); let bin_dir = cli_path.parent().unwrap();
if !bin_dir.exists() { if !bin_dir.exists() {
fs::create_dir_all(bin_dir) fs::create_dir_all(bin_dir).map_err(|e| {
.map_err(|e| format!("Failed to create /usr/local/bin: {}. Try running with sudo.", e))?; format!(
"Failed to create /usr/local/bin: {}. Try running with sudo.",
e
)
})?;
} }
// Write the CLI script // Write the CLI script
@ -114,8 +120,7 @@ fn install_cli_macos() -> Result<CliInstallResult, String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn install_cli_windows() -> Result<CliInstallResult, String> { fn install_cli_windows() -> Result<CliInstallResult, String> {
let user_path = std::env::var("USERPROFILE") let user_path = std::env::var("USERPROFILE").map_err(|_| "Failed to get user profile path")?;
.map_err(|_| "Failed to get user profile path")?;
let cli_dir = PathBuf::from(&user_path).join(".vibetunnel"); let cli_dir = PathBuf::from(&user_path).join(".vibetunnel");
let cli_path = cli_dir.join("vt.cmd"); let cli_path = cli_dir.join("vt.cmd");
@ -136,14 +141,16 @@ fn install_cli_windows() -> Result<CliInstallResult, String> {
Ok(CliInstallResult { Ok(CliInstallResult {
installed: true, installed: true,
path: cli_path.to_string_lossy().to_string(), path: cli_path.to_string_lossy().to_string(),
message: format!("CLI tool installed successfully at {}. Restart your terminal to use 'vt' command.", cli_path.display()), message: format!(
"CLI tool installed successfully at {}. Restart your terminal to use 'vt' command.",
cli_path.display()
),
}) })
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn install_cli_linux() -> Result<CliInstallResult, String> { fn install_cli_linux() -> Result<CliInstallResult, String> {
let home_dir = std::env::var("HOME") let home_dir = std::env::var("HOME").map_err(|_| "Failed to get home directory")?;
.map_err(|_| "Failed to get home directory")?;
let local_bin = PathBuf::from(&home_dir).join(".local").join("bin"); let local_bin = PathBuf::from(&home_dir).join(".local").join("bin");
let cli_path = local_bin.join("vt"); let cli_path = local_bin.join("vt");
@ -172,7 +179,10 @@ fn install_cli_linux() -> Result<CliInstallResult, String> {
Ok(CliInstallResult { Ok(CliInstallResult {
installed: true, installed: true,
path: cli_path.to_string_lossy().to_string(), path: cli_path.to_string_lossy().to_string(),
message: format!("CLI tool installed successfully at {}. Make sure ~/.local/bin is in your PATH.", cli_path.display()), message: format!(
"CLI tool installed successfully at {}. Make sure ~/.local/bin is in your PATH.",
cli_path.display()
),
}) })
} }
@ -183,7 +193,8 @@ fn add_to_windows_path(dir: &Path) -> Result<(), String> {
use winreg::enums::*; use winreg::enums::*;
use winreg::RegKey; use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER); let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) let env = hkcu
.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
.map_err(|e| format!("Failed to open registry key: {}", e))?; .map_err(|e| format!("Failed to open registry key: {}", e))?;
let path: String = env.get_value("Path").unwrap_or_default(); let path: String = env.get_value("Path").unwrap_or_default();
@ -227,13 +238,12 @@ pub fn uninstall_cli_tool() -> Result<CliInstallResult, String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let user_path = std::env::var("USERPROFILE") let user_path =
.map_err(|_| "Failed to get user profile path")?; std::env::var("USERPROFILE").map_err(|_| "Failed to get user profile path")?;
let cli_path = PathBuf::from(&user_path).join(".vibetunnel").join("vt.cmd"); let cli_path = PathBuf::from(&user_path).join(".vibetunnel").join("vt.cmd");
if cli_path.exists() { if cli_path.exists() {
fs::remove_file(&cli_path) fs::remove_file(&cli_path).map_err(|e| format!("Failed to remove CLI tool: {}", e))?;
.map_err(|e| format!("Failed to remove CLI tool: {}", e))?;
} }
Ok(CliInstallResult { Ok(CliInstallResult {
@ -245,13 +255,14 @@ pub fn uninstall_cli_tool() -> Result<CliInstallResult, String> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let home_dir = std::env::var("HOME") let home_dir = std::env::var("HOME").map_err(|_| "Failed to get home directory")?;
.map_err(|_| "Failed to get home directory")?; let cli_path = PathBuf::from(&home_dir)
let cli_path = PathBuf::from(&home_dir).join(".local").join("bin").join("vt"); .join(".local")
.join("bin")
.join("vt");
if cli_path.exists() { if cli_path.exists() {
fs::remove_file(&cli_path) fs::remove_file(&cli_path).map_err(|e| format!("Failed to remove CLI tool: {}", e))?;
.map_err(|e| format!("Failed to remove CLI tool: {}", e))?;
} }
Ok(CliInstallResult { Ok(CliInstallResult {
@ -271,7 +282,10 @@ pub fn is_cli_installed() -> bool {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
if let Ok(user_path) = std::env::var("USERPROFILE") { if let Ok(user_path) = std::env::var("USERPROFILE") {
PathBuf::from(&user_path).join(".vibetunnel").join("vt.cmd").exists() PathBuf::from(&user_path)
.join(".vibetunnel")
.join("vt.cmd")
.exists()
} else { } else {
false false
} }
@ -280,7 +294,11 @@ pub fn is_cli_installed() -> bool {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if let Ok(home_dir) = std::env::var("HOME") { if let Ok(home_dir) = std::env::var("HOME") {
PathBuf::from(&home_dir).join(".local").join("bin").join("vt").exists() PathBuf::from(&home_dir)
.join(".local")
.join("bin")
.join("vt")
.exists()
} else { } else {
false false
} }

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use std::collections::{HashMap, VecDeque};
use chrono::{DateTime, Utc};
use std::path::PathBuf;
/// Debug feature types /// Debug feature types
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
@ -245,7 +245,10 @@ impl DebugFeaturesManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -260,7 +263,13 @@ impl DebugFeaturesManager {
} }
/// Log a message /// Log a message
pub async fn log(&self, level: LogLevel, component: &str, message: &str, metadata: HashMap<String, serde_json::Value>) { pub async fn log(
&self,
level: LogLevel,
component: &str,
message: &str,
metadata: HashMap<String, serde_json::Value>,
) {
let settings = self.settings.read().await; let settings = self.settings.read().await;
// Check if logging is enabled and level is appropriate // Check if logging is enabled and level is appropriate
@ -294,7 +303,13 @@ impl DebugFeaturesManager {
} }
/// Record a performance metric /// Record a performance metric
pub async fn record_metric(&self, name: &str, value: f64, unit: &str, tags: HashMap<String, String>) { pub async fn record_metric(
&self,
name: &str,
value: f64,
unit: &str,
tags: HashMap<String, String>,
) {
let settings = self.settings.read().await; let settings = self.settings.read().await;
if !settings.enabled || !settings.enable_performance_monitoring { if !settings.enabled || !settings.enable_performance_monitoring {
@ -379,7 +394,8 @@ impl DebugFeaturesManager {
// Store result // Store result
let mut test_results = self.api_test_results.write().await; let mut test_results = self.api_test_results.write().await;
test_results.entry(test.id.clone()) test_results
.entry(test.id.clone())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push(result); .push(result);
} }
@ -443,7 +459,12 @@ impl DebugFeaturesManager {
let app_info = self.get_app_info().await; let app_info = self.get_app_info().await;
let performance_summary = self.get_performance_summary().await; let performance_summary = self.get_performance_summary().await;
let error_summary = self.get_error_summary().await; let error_summary = self.get_error_summary().await;
let recommendations = self.generate_recommendations(&system_info, &app_info, &performance_summary, &error_summary); let recommendations = self.generate_recommendations(
&system_info,
&app_info,
&performance_summary,
&error_summary,
);
DiagnosticReport { DiagnosticReport {
timestamp: Utc::now(), timestamp: Utc::now(),
@ -522,7 +543,9 @@ impl DebugFeaturesManager {
} else { } else {
"Debug mode disabled" "Debug mode disabled"
}; };
let _ = notification_manager.notify_success("Debug Mode", message).await; let _ = notification_manager
.notify_success("Debug Mode", message)
.await;
} }
} }
@ -545,7 +568,8 @@ impl DebugFeaturesManager {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
file.write_all(log_line.as_bytes()).await file.write_all(log_line.as_bytes())
.await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
@ -568,7 +592,7 @@ impl DebugFeaturesManager {
AppInfo { AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
build_date: chrono::Utc::now().to_rfc3339(), // TODO: Get actual build date build_date: chrono::Utc::now().to_rfc3339(), // TODO: Get actual build date
uptime_seconds: 0, // TODO: Track uptime uptime_seconds: 0, // TODO: Track uptime
active_sessions: 0, active_sessions: 0,
total_requests: 0, total_requests: 0,
error_count: 0, error_count: 0,
@ -588,14 +612,17 @@ impl DebugFeaturesManager {
async fn get_error_summary(&self) -> ErrorSummary { async fn get_error_summary(&self) -> ErrorSummary {
let logs = self.logs.read().await; let logs = self.logs.read().await;
let errors: Vec<_> = logs.iter() let errors: Vec<_> = logs
.iter()
.filter(|log| log.level == LogLevel::Error) .filter(|log| log.level == LogLevel::Error)
.cloned() .cloned()
.collect(); .collect();
let mut errors_by_type = HashMap::new(); let mut errors_by_type = HashMap::new();
for error in &errors { for error in &errors {
let error_type = error.metadata.get("type") let error_type = error
.metadata
.get("type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
@ -609,11 +636,20 @@ impl DebugFeaturesManager {
} }
} }
fn generate_recommendations(&self, system: &SystemInfo, _app: &AppInfo, perf: &PerformanceSummary, errors: &ErrorSummary) -> Vec<String> { fn generate_recommendations(
&self,
system: &SystemInfo,
_app: &AppInfo,
perf: &PerformanceSummary,
errors: &ErrorSummary,
) -> Vec<String> {
let mut recommendations = Vec::new(); let mut recommendations = Vec::new();
if perf.cpu_usage_percent > 80.0 { if perf.cpu_usage_percent > 80.0 {
recommendations.push("High CPU usage detected. Consider optimizing performance-critical code.".to_string()); recommendations.push(
"High CPU usage detected. Consider optimizing performance-critical code."
.to_string(),
);
} }
if perf.memory_usage_mb > (system.total_memory_mb as f64 * 0.8) { if perf.memory_usage_mb > (system.total_memory_mb as f64 * 0.8) {
@ -621,11 +657,14 @@ impl DebugFeaturesManager {
} }
if errors.total_errors > 100 { if errors.total_errors > 100 {
recommendations.push("High error rate detected. Review error logs for patterns.".to_string()); recommendations
.push("High error rate detected. Review error logs for patterns.".to_string());
} }
if perf.avg_response_time_ms > 1000.0 { if perf.avg_response_time_ms > 1000.0 {
recommendations.push("Slow response times detected. Consider caching or query optimization.".to_string()); recommendations.push(
"Slow response times detected. Consider caching or query optimization.".to_string(),
);
} }
recommendations recommendations

View file

@ -1,6 +1,6 @@
use axum::{ use axum::{
extract::Query, extract::Query,
http::{StatusCode, header}, http::{header, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
@ -62,8 +62,7 @@ pub struct OperationResult {
/// Expand tilde to home directory /// Expand tilde to home directory
fn expand_path(path: &str) -> Result<PathBuf, StatusCode> { fn expand_path(path: &str) -> Result<PathBuf, StatusCode> {
if path.starts_with('~') { if path.starts_with('~') {
let home = dirs::home_dir() let home = dirs::home_dir().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(home.join(path.strip_prefix("~/").unwrap_or(""))) Ok(home.join(path.strip_prefix("~/").unwrap_or("")))
} else { } else {
Ok(PathBuf::from(path)) Ok(PathBuf::from(path))
@ -76,34 +75,40 @@ pub async fn get_file_info(
) -> Result<Json<FileMetadata>, StatusCode> { ) -> Result<Json<FileMetadata>, StatusCode> {
let path = expand_path(&params.path)?; let path = expand_path(&params.path)?;
let metadata = fs::metadata(&path).await let metadata = fs::metadata(&path)
.await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
let name = path.file_name() let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string()); .unwrap_or_else(|| path.to_string_lossy().to_string());
let is_symlink = fs::symlink_metadata(&path).await let is_symlink = fs::symlink_metadata(&path)
.await
.map(|m| m.file_type().is_symlink()) .map(|m| m.file_type().is_symlink())
.unwrap_or(false); .unwrap_or(false);
let hidden = name.starts_with('.'); let hidden = name.starts_with('.');
let created = metadata.created() let created = metadata
.created()
.map(|t| { .map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into(); let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime.to_rfc3339() datetime.to_rfc3339()
}) })
.ok(); .ok();
let modified = metadata.modified() let modified = metadata
.modified()
.map(|t| { .map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into(); let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime.to_rfc3339() datetime.to_rfc3339()
}) })
.ok(); .ok();
let accessed = metadata.accessed() let accessed = metadata
.accessed()
.map(|t| { .map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into(); let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime.to_rfc3339() datetime.to_rfc3339()
@ -118,23 +123,24 @@ pub async fn get_file_info(
let mime_type = if metadata.is_file() { let mime_type = if metadata.is_file() {
// Simple MIME type detection based on extension // Simple MIME type detection based on extension
let ext = path.extension() let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
.and_then(|e| e.to_str())
.unwrap_or("");
Some(match ext { Some(
"txt" => "text/plain", match ext {
"html" | "htm" => "text/html", "txt" => "text/plain",
"css" => "text/css", "html" | "htm" => "text/html",
"js" => "application/javascript", "css" => "text/css",
"json" => "application/json", "js" => "application/javascript",
"png" => "image/png", "json" => "application/json",
"jpg" | "jpeg" => "image/jpeg", "png" => "image/png",
"gif" => "image/gif", "jpg" | "jpeg" => "image/jpeg",
"pdf" => "application/pdf", "gif" => "image/gif",
"zip" => "application/zip", "pdf" => "application/pdf",
_ => "application/octet-stream", "zip" => "application/zip",
}.to_string()) _ => "application/octet-stream",
}
.to_string(),
)
} else { } else {
None None
}; };
@ -160,13 +166,12 @@ pub async fn get_file_info(
} }
/// Read file contents /// Read file contents
pub async fn read_file( pub async fn read_file(Query(params): Query<FileQuery>) -> Result<Response, StatusCode> {
Query(params): Query<FileQuery>,
) -> Result<Response, StatusCode> {
let path = expand_path(&params.path)?; let path = expand_path(&params.path)?;
// Check if file exists and is a file // Check if file exists and is a file
let metadata = fs::metadata(&path).await let metadata = fs::metadata(&path)
.await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
if !metadata.is_file() { if !metadata.is_file() {
@ -174,15 +179,18 @@ pub async fn read_file(
} }
// Read file contents // Read file contents
let mut file = fs::File::open(&path).await let mut file = fs::File::open(&path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut contents = Vec::new(); let mut contents = Vec::new();
file.read_to_end(&mut contents).await file.read_to_end(&mut contents)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Determine content type // Determine content type
let content_type = path.extension() let content_type = path
.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.and_then(|ext| match ext { .and_then(|ext| match ext {
"txt" => Some("text/plain"), "txt" => Some("text/plain"),
@ -198,10 +206,7 @@ pub async fn read_file(
}) })
.unwrap_or("application/octet-stream"); .unwrap_or("application/octet-stream");
Ok(( Ok(([(header::CONTENT_TYPE, content_type)], contents).into_response())
[(header::CONTENT_TYPE, content_type)],
contents,
).into_response())
} }
/// Write file contents /// Write file contents
@ -212,19 +217,22 @@ pub async fn write_file(
// Ensure parent directory exists // Ensure parent directory exists
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await fs::create_dir_all(parent)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} }
// Write file // Write file
let content = if req.encoding.as_deref() == Some("base64") { let content = if req.encoding.as_deref() == Some("base64") {
base64::engine::general_purpose::STANDARD.decode(&req.content) base64::engine::general_purpose::STANDARD
.decode(&req.content)
.map_err(|_| StatusCode::BAD_REQUEST)? .map_err(|_| StatusCode::BAD_REQUEST)?
} else { } else {
req.content.into_bytes() req.content.into_bytes()
}; };
fs::write(&path, content).await fs::write(&path, content)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(OperationResult { Ok(Json(OperationResult {
@ -240,15 +248,18 @@ pub async fn delete_file(
let path = expand_path(&params.path)?; let path = expand_path(&params.path)?;
// Check if path exists // Check if path exists
let metadata = fs::metadata(&path).await let metadata = fs::metadata(&path)
.await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
// Delete based on type // Delete based on type
if metadata.is_dir() { if metadata.is_dir() {
fs::remove_dir_all(&path).await fs::remove_dir_all(&path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} else { } else {
fs::remove_file(&path).await fs::remove_file(&path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} }
@ -259,9 +270,7 @@ pub async fn delete_file(
} }
/// Move/rename file or directory /// Move/rename file or directory
pub async fn move_file( pub async fn move_file(Json(req): Json<MoveRequest>) -> Result<Json<OperationResult>, StatusCode> {
Json(req): Json<MoveRequest>,
) -> Result<Json<OperationResult>, StatusCode> {
let from_path = expand_path(&req.from)?; let from_path = expand_path(&req.from)?;
let to_path = expand_path(&req.to)?; let to_path = expand_path(&req.to)?;
@ -277,29 +286,34 @@ pub async fn move_file(
// Ensure destination parent directory exists // Ensure destination parent directory exists
if let Some(parent) = to_path.parent() { if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await fs::create_dir_all(parent)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} }
// Move the file/directory // Move the file/directory
fs::rename(&from_path, &to_path).await fs::rename(&from_path, &to_path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(OperationResult { Ok(Json(OperationResult {
success: true, success: true,
message: format!("Moved from {} to {}", from_path.display(), to_path.display()), message: format!(
"Moved from {} to {}",
from_path.display(),
to_path.display()
),
})) }))
} }
/// Copy file or directory /// Copy file or directory
pub async fn copy_file( pub async fn copy_file(Json(req): Json<CopyRequest>) -> Result<Json<OperationResult>, StatusCode> {
Json(req): Json<CopyRequest>,
) -> Result<Json<OperationResult>, StatusCode> {
let from_path = expand_path(&req.from)?; let from_path = expand_path(&req.from)?;
let to_path = expand_path(&req.to)?; let to_path = expand_path(&req.to)?;
// Check if source exists // Check if source exists
let metadata = fs::metadata(&from_path).await let metadata = fs::metadata(&from_path)
.await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
// Check if destination already exists // Check if destination already exists
@ -309,13 +323,15 @@ pub async fn copy_file(
// Ensure destination parent directory exists // Ensure destination parent directory exists
if let Some(parent) = to_path.parent() { if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await fs::create_dir_all(parent)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} }
// Copy based on type // Copy based on type
if metadata.is_file() { if metadata.is_file() {
fs::copy(&from_path, &to_path).await fs::copy(&from_path, &to_path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} else if metadata.is_dir() { } else if metadata.is_dir() {
// Recursive directory copy // Recursive directory copy
@ -324,29 +340,40 @@ pub async fn copy_file(
Ok(Json(OperationResult { Ok(Json(OperationResult {
success: true, success: true,
message: format!("Copied from {} to {}", from_path.display(), to_path.display()), message: format!(
"Copied from {} to {}",
from_path.display(),
to_path.display()
),
})) }))
} }
/// Recursively copy a directory /// Recursively copy a directory
async fn copy_dir_recursive(from: &PathBuf, to: &PathBuf) -> Result<(), StatusCode> { async fn copy_dir_recursive(from: &PathBuf, to: &PathBuf) -> Result<(), StatusCode> {
fs::create_dir_all(to).await fs::create_dir_all(to)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut entries = fs::read_dir(from).await let mut entries = fs::read_dir(from)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
while let Some(entry) = entries.next_entry().await while let Some(entry) = entries
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? { .next_entry()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let from_path = entry.path(); let from_path = entry.path();
let to_path = to.join(entry.file_name()); let to_path = to.join(entry.file_name());
let metadata = entry.metadata().await let metadata = entry
.metadata()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if metadata.is_file() { if metadata.is_file() {
fs::copy(&from_path, &to_path).await fs::copy(&from_path, &to_path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} else if metadata.is_dir() { } else if metadata.is_dir() {
Box::pin(copy_dir_recursive(&from_path, &to_path)).await?; Box::pin(copy_dir_recursive(&from_path, &to_path)).await?;
@ -396,17 +423,22 @@ async fn search_recursive(
return Ok(()); return Ok(());
} }
let mut entries = fs::read_dir(path).await let mut entries = fs::read_dir(path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
while let Some(entry) = entries.next_entry().await while let Some(entry) = entries
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? { .next_entry()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let entry_path = entry.path(); let entry_path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string(); let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.to_lowercase().contains(pattern) { if file_name.to_lowercase().contains(pattern) {
let metadata = entry.metadata().await let metadata = entry
.metadata()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
results.push(SearchResult { results.push(SearchResult {
@ -418,10 +450,15 @@ async fn search_recursive(
} }
// Recurse into directories // Recurse into directories
if entry.file_type().await if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
.map(|t| t.is_dir()) Box::pin(search_recursive(
.unwrap_or(false) { &entry_path,
Box::pin(search_recursive(&entry_path, pattern, depth + 1, max_depth, results)).await?; pattern,
depth + 1,
max_depth,
results,
))
.await?;
} }
} }

View file

@ -1,31 +1,31 @@
pub mod commands;
pub mod terminal;
pub mod server;
pub mod state;
pub mod settings;
pub mod auto_launch;
pub mod ngrok;
pub mod auth;
pub mod terminal_detector;
pub mod cli_installer;
pub mod tray_menu;
pub mod cast;
pub mod tty_forward;
pub mod session_monitor;
pub mod port_conflict;
pub mod network_utils;
pub mod notification_manager;
pub mod welcome;
pub mod permissions;
pub mod updater;
pub mod backend_manager;
pub mod debug_features;
pub mod api_testing; pub mod api_testing;
pub mod auth_cache;
pub mod terminal_integrations;
pub mod app_mover; pub mod app_mover;
pub mod terminal_spawn_service; pub mod auth;
pub mod auth_cache;
pub mod auto_launch;
pub mod backend_manager;
pub mod cast;
pub mod cli_installer;
pub mod commands;
pub mod debug_features;
pub mod fs_api; pub mod fs_api;
pub mod network_utils;
pub mod ngrok;
pub mod notification_manager;
pub mod permissions;
pub mod port_conflict;
pub mod server;
pub mod session_monitor;
pub mod settings;
pub mod state;
pub mod terminal;
pub mod terminal_detector;
pub mod terminal_integrations;
pub mod terminal_spawn_service;
pub mod tray_menu;
pub mod tty_forward;
pub mod updater;
pub mod welcome;
#[cfg(mobile)] #[cfg(mobile)]
pub fn init() { pub fn init() {

View file

@ -3,44 +3,44 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use tauri::{AppHandle, Manager, Emitter, WindowEvent};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::menu::Menu; use tauri::menu::Menu;
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod commands;
mod terminal;
mod server;
mod state;
mod settings;
mod auto_launch;
mod ngrok;
mod terminal_detector;
mod cli_installer;
mod auth;
mod tray_menu;
mod cast;
mod tty_forward;
mod session_monitor;
mod port_conflict;
mod network_utils;
mod notification_manager;
mod welcome;
mod permissions;
mod updater;
mod backend_manager;
mod debug_features;
mod api_testing; mod api_testing;
mod auth_cache;
mod terminal_integrations;
mod app_mover; mod app_mover;
mod terminal_spawn_service; mod auth;
mod auth_cache;
mod auto_launch;
mod backend_manager;
mod cast;
mod cli_installer;
mod commands;
mod debug_features;
mod fs_api; mod fs_api;
mod network_utils;
mod ngrok;
mod notification_manager;
mod permissions;
mod port_conflict;
mod server;
mod session_monitor;
mod settings;
mod state;
mod terminal;
mod terminal_detector;
mod terminal_integrations;
mod terminal_spawn_service;
mod tray_menu;
mod tty_forward;
mod updater;
mod welcome;
use commands::*;
use state::AppState;
use server::HttpServer;
use commands::ServerStatus; use commands::ServerStatus;
use commands::*;
use server::HttpServer;
use state::AppState;
#[tauri::command] #[tauri::command]
fn open_settings_window(app: AppHandle) -> Result<(), String> { fn open_settings_window(app: AppHandle) -> Result<(), String> {
@ -53,7 +53,7 @@ fn open_settings_window(app: AppHandle) -> Result<(), String> {
tauri::WebviewWindowBuilder::new( tauri::WebviewWindowBuilder::new(
&app, &app,
"settings", "settings",
tauri::WebviewUrl::App("settings.html".into()) tauri::WebviewUrl::App("settings.html".into()),
) )
.title("VibeTunnel Settings") .title("VibeTunnel Settings")
.inner_size(800.0, 600.0) .inner_size(800.0, 600.0)
@ -66,10 +66,13 @@ fn open_settings_window(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
fn update_tray_menu_status(_app: &AppHandle, port: u16, _session_count: usize) { fn update_tray_menu_status(app: &AppHandle, port: u16, session_count: usize) {
// For now, just log the status update // Update tray menu status using the tray menu manager
// TODO: In Tauri v2, dynamic menu updates require rebuilding the menu let app_handle = app.clone();
tracing::info!("Server status updated: port {}", port); tauri::async_runtime::spawn(async move {
tray_menu::TrayMenuManager::update_server_status(&app_handle, port, true).await;
tray_menu::TrayMenuManager::update_session_count(&app_handle, session_count).await;
});
} }
fn main() { fn main() {
@ -317,7 +320,11 @@ fn main() {
}); });
// Create system tray icon using menu-bar-icon.png with template mode // Create system tray icon using menu-bar-icon.png with template mode
let icon_path = app.path().resource_dir().unwrap().join("icons/menu-bar-icon.png"); let icon_path = app
.path()
.resource_dir()
.unwrap()
.join("icons/menu-bar-icon.png");
let tray_icon = if let Ok(icon_data) = std::fs::read(&icon_path) { let tray_icon = if let Ok(icon_data) = std::fs::read(&icon_path) {
tauri::image::Image::from_bytes(&icon_data).ok() tauri::image::Image::from_bytes(&icon_data).ok()
} else { } else {
@ -362,7 +369,8 @@ fn main() {
let settings = settings::Settings::load().unwrap_or_default(); let settings = settings::Settings::load().unwrap_or_default();
// Check if launched at startup (auto-launch) // Check if launched at startup (auto-launch)
let is_auto_launched = std::env::args().any(|arg| arg == "--auto-launch" || arg == "--minimized"); let is_auto_launched =
std::env::args().any(|arg| arg == "--auto-launch" || arg == "--minimized");
let window = app.get_webview_window("main").unwrap(); let window = app.get_webview_window("main").unwrap();
@ -399,7 +407,9 @@ fn main() {
{ {
if let Ok(settings) = settings::Settings::load() { if let Ok(settings) = settings::Settings::load() {
if !settings.general.show_dock_icon { if !settings.general.show_dock_icon {
let _ = window_clone.app_handle().set_activation_policy(tauri::ActivationPolicy::Accessory); let _ = window_clone
.app_handle()
.set_activation_policy(tauri::ActivationPolicy::Accessory);
} }
} }
} }
@ -420,6 +430,7 @@ fn main() {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[allow(dead_code)]
fn create_app_menu(app: &tauri::App) -> Result<Menu<tauri::Wry>, tauri::Error> { fn create_app_menu(app: &tauri::App) -> Result<Menu<tauri::Wry>, tauri::Error> {
// Create the menu using the builder pattern // Create the menu using the builder pattern
let menu = Menu::new(app)?; let menu = Menu::new(app)?;
@ -560,7 +571,16 @@ fn show_main_window(app: AppHandle) -> Result<(), String> {
fn quit_app(app: AppHandle) { fn quit_app(app: AppHandle) {
// Stop monitoring before exit // Stop monitoring before exit
let state = app.state::<AppState>(); let state = app.state::<AppState>();
state.server_monitoring.store(false, std::sync::atomic::Ordering::Relaxed); state
.server_monitoring
.store(false, std::sync::atomic::Ordering::Relaxed);
// Close all terminal sessions
let terminal_manager = state.terminal_manager.clone();
tauri::async_runtime::block_on(async move {
let _ = terminal_manager.close_all_sessions().await;
});
app.exit(0); app.exit(0);
} }
@ -578,14 +598,20 @@ async fn start_server_with_monitoring(app_handle: AppHandle) {
update_tray_menu_status(&app_handle, status.port, 0); update_tray_menu_status(&app_handle, status.port, 0);
// Show notification // Show notification
let _ = state.notification_manager.notify_server_status(true, status.port).await; let _ = state
.notification_manager
.notify_server_status(true, status.port)
.await;
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to start server: {}", e); tracing::error!("Failed to start server: {}", e);
let _ = state.notification_manager.notify_error( let _ = state
"Server Start Failed", .notification_manager
&format!("Failed to start server: {}", e) .notify_error(
).await; "Server Start Failed",
&format!("Failed to start server: {}", e),
)
.await;
} }
} }
@ -596,7 +622,10 @@ async fn start_server_with_monitoring(app_handle: AppHandle) {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut check_interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); let mut check_interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
while monitoring_state.server_monitoring.load(std::sync::atomic::Ordering::Relaxed) { while monitoring_state
.server_monitoring
.load(std::sync::atomic::Ordering::Relaxed)
{
check_interval.tick().await; check_interval.tick().await;
// Check if server is still running // Check if server is still running
@ -633,14 +662,20 @@ async fn start_server_with_monitoring(app_handle: AppHandle) {
} }
// Show notification // Show notification
let _ = monitoring_state.notification_manager.notify_server_status(true, status.port).await; let _ = monitoring_state
.notification_manager
.notify_server_status(true, status.port)
.await;
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to restart server: {}", e); tracing::error!("Failed to restart server: {}", e);
let _ = monitoring_state.notification_manager.notify_error( let _ = monitoring_state
"Server Restart Failed", .notification_manager
&format!("Failed to restart server: {}", e) .notify_error(
).await; "Server Restart Failed",
&format!("Failed to restart server: {}", e),
)
.await;
} }
} }
} }
@ -660,14 +695,20 @@ async fn start_server_with_monitoring(app_handle: AppHandle) {
} }
// Show notification // Show notification
let _ = monitoring_state.notification_manager.notify_server_status(true, status.port).await; let _ = monitoring_state
.notification_manager
.notify_server_status(true, status.port)
.await;
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to start server: {}", e); tracing::error!("Failed to start server: {}", e);
let _ = monitoring_state.notification_manager.notify_error( let _ = monitoring_state
"Server Start Failed", .notification_manager
&format!("Failed to start server: {}", e) .notify_error(
).await; "Server Start Failed",
&format!("Failed to start server: {}", e),
)
.await;
} }
} }
} }
@ -726,19 +767,27 @@ async fn start_server_internal(state: &AppState) -> Result<ServerStatus, String>
let settings = crate::settings::Settings::load().unwrap_or_default(); let settings = crate::settings::Settings::load().unwrap_or_default();
// Start HTTP server with auth if configured // Start HTTP server with auth if configured
let mut http_server = if settings.dashboard.enable_password && !settings.dashboard.password.is_empty() { let mut http_server =
let auth_config = crate::auth::AuthConfig::new(true, Some(settings.dashboard.password)); if settings.dashboard.enable_password && !settings.dashboard.password.is_empty() {
HttpServer::with_auth(state.terminal_manager.clone(), state.session_monitor.clone(), auth_config) let auth_config = crate::auth::AuthConfig::new(true, Some(settings.dashboard.password));
} else { HttpServer::with_auth(
HttpServer::new(state.terminal_manager.clone(), state.session_monitor.clone()) state.terminal_manager.clone(),
}; state.session_monitor.clone(),
auth_config,
)
} else {
HttpServer::new(
state.terminal_manager.clone(),
state.session_monitor.clone(),
)
};
// Start server with appropriate access mode // Start server with appropriate access mode
let (port, url) = match settings.dashboard.access_mode.as_str() { let (port, url) = match settings.dashboard.access_mode.as_str() {
"network" => { "network" => {
let port = http_server.start_with_mode("network").await?; let port = http_server.start_with_mode("network").await?;
(port, format!("http://0.0.0.0:{}", port)) (port, format!("http://0.0.0.0:{}", port))
}, }
"ngrok" => { "ngrok" => {
// For ngrok mode, start in localhost and let ngrok handle the tunneling // For ngrok mode, start in localhost and let ngrok handle the tunneling
let port = http_server.start_with_mode("localhost").await?; let port = http_server.start_with_mode("localhost").await?;
@ -746,7 +795,11 @@ async fn start_server_internal(state: &AppState) -> Result<ServerStatus, String>
// Try to start ngrok tunnel if auth token is configured // Try to start ngrok tunnel if auth token is configured
let url = if let Some(auth_token) = settings.advanced.ngrok_auth_token { let url = if let Some(auth_token) = settings.advanced.ngrok_auth_token {
if !auth_token.is_empty() { if !auth_token.is_empty() {
match state.ngrok_manager.start_tunnel(port, Some(auth_token)).await { match state
.ngrok_manager
.start_tunnel(port, Some(auth_token))
.await
{
Ok(tunnel) => tunnel.url, Ok(tunnel) => tunnel.url,
Err(e) => { Err(e) => {
tracing::error!("Failed to start ngrok tunnel: {}", e); tracing::error!("Failed to start ngrok tunnel: {}", e);
@ -761,7 +814,7 @@ async fn start_server_internal(state: &AppState) -> Result<ServerStatus, String>
}; };
(port, url) (port, url)
}, }
_ => { _ => {
let port = http_server.start_with_mode("localhost").await?; let port = http_server.start_with_mode("localhost").await?;
(port, format!("http://localhost:{}", port)) (port, format!("http://localhost:{}", port))

View file

@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use tracing::error; use tracing::error;
@ -106,12 +106,16 @@ impl NetworkUtils {
let name = ifaddr.interface_name.clone(); let name = ifaddr.interface_name.clone();
let flags = ifaddr.flags; let flags = ifaddr.flags;
let interface = interfaces.entry(name.clone()).or_insert_with(|| NetworkInterface { let interface =
name, interfaces
addresses: Vec::new(), .entry(name.clone())
is_up: flags.contains(nix::net::if_::InterfaceFlags::IFF_UP), .or_insert_with(|| NetworkInterface {
is_loopback: flags.contains(nix::net::if_::InterfaceFlags::IFF_LOOPBACK), name,
}); addresses: Vec::new(),
is_up: flags.contains(nix::net::if_::InterfaceFlags::IFF_UP),
is_loopback: flags
.contains(nix::net::if_::InterfaceFlags::IFF_LOOPBACK),
});
if let Some(address) = ifaddr.address { if let Some(address) = ifaddr.address {
if let Some(sockaddr) = address.as_sockaddr_in() { if let Some(sockaddr) = address.as_sockaddr_in() {
@ -244,9 +248,9 @@ impl NetworkUtils {
/// Test network connectivity to a host /// Test network connectivity to a host
pub async fn test_connectivity(host: &str, port: u16) -> bool { pub async fn test_connectivity(host: &str, port: u16) -> bool {
use std::time::Duration;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::time::timeout; use tokio::time::timeout;
use std::time::Duration;
let addr = format!("{}:{}", host, port); let addr = format!("{}:{}", host, port);
match timeout(Duration::from_secs(3), TcpStream::connect(&addr)).await { match timeout(Duration::from_secs(3), TcpStream::connect(&addr)).await {
@ -282,8 +286,12 @@ mod tests {
#[test] #[test]
fn test_private_ipv4() { fn test_private_ipv4() {
assert!(NetworkUtils::is_private_ipv4(&"10.0.0.1".parse().unwrap())); assert!(NetworkUtils::is_private_ipv4(&"10.0.0.1".parse().unwrap()));
assert!(NetworkUtils::is_private_ipv4(&"172.16.0.1".parse().unwrap())); assert!(NetworkUtils::is_private_ipv4(
assert!(NetworkUtils::is_private_ipv4(&"192.168.1.1".parse().unwrap())); &"172.16.0.1".parse().unwrap()
));
assert!(NetworkUtils::is_private_ipv4(
&"192.168.1.1".parse().unwrap()
));
assert!(!NetworkUtils::is_private_ipv4(&"8.8.8.8".parse().unwrap())); assert!(!NetworkUtils::is_private_ipv4(&"8.8.8.8".parse().unwrap()));
} }
} }

View file

@ -1,8 +1,8 @@
use crate::state::AppState;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::{Command, Child}; use std::process::{Child, Command};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::State; use tauri::State;
use crate::state::AppState;
use tracing::info; use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -25,7 +25,11 @@ impl NgrokManager {
} }
} }
pub async fn start_tunnel(&self, port: u16, auth_token: Option<String>) -> Result<NgrokTunnel, String> { pub async fn start_tunnel(
&self,
port: u16,
auth_token: Option<String>,
) -> Result<NgrokTunnel, String> {
// Check if ngrok is installed // Check if ngrok is installed
let ngrok_path = which::which("ngrok") let ngrok_path = which::which("ngrok")
.map_err(|_| "ngrok not found. Please install ngrok first.".to_string())?; .map_err(|_| "ngrok not found. Please install ngrok first.".to_string())?;
@ -61,7 +65,8 @@ impl NgrokManager {
pub async fn stop_tunnel(&self) -> Result<(), String> { pub async fn stop_tunnel(&self) -> Result<(), String> {
if let Some(mut child) = self.process.lock().unwrap().take() { if let Some(mut child) = self.process.lock().unwrap().take() {
child.kill() child
.kill()
.map_err(|e| format!("Failed to stop ngrok: {}", e))?; .map_err(|e| format!("Failed to stop ngrok: {}", e))?;
info!("ngrok tunnel stopped"); info!("ngrok tunnel stopped");
@ -82,23 +87,28 @@ impl NgrokManager {
.await .await
.map_err(|e| format!("Failed to query ngrok API: {}", e))?; .map_err(|e| format!("Failed to query ngrok API: {}", e))?;
let data: serde_json::Value = response.json() let data: serde_json::Value = response
.json()
.await .await
.map_err(|e| format!("Failed to parse ngrok API response: {}", e))?; .map_err(|e| format!("Failed to parse ngrok API response: {}", e))?;
// Extract tunnel URL // Extract tunnel URL
let tunnels = data["tunnels"].as_array() let tunnels = data["tunnels"]
.as_array()
.ok_or_else(|| "No tunnels found".to_string())?; .ok_or_else(|| "No tunnels found".to_string())?;
let tunnel = tunnels.iter() let tunnel = tunnels
.iter()
.find(|t| t["proto"].as_str() == Some("https")) .find(|t| t["proto"].as_str() == Some("https"))
.or_else(|| tunnels.first()) .or_else(|| tunnels.first())
.ok_or_else(|| "No tunnel found".to_string())?; .ok_or_else(|| "No tunnel found".to_string())?;
let url = tunnel["public_url"].as_str() let url = tunnel["public_url"]
.as_str()
.ok_or_else(|| "No public URL found".to_string())?; .ok_or_else(|| "No public URL found".to_string())?;
let port = tunnel["config"]["addr"].as_str() let port = tunnel["config"]["addr"]
.as_str()
.and_then(|addr| addr.split(':').last()) .and_then(|addr| addr.split(':').last())
.and_then(|p| p.parse::<u16>().ok()) .and_then(|p| p.parse::<u16>().ok())
.unwrap_or(3000); .unwrap_or(3000);
@ -121,15 +131,11 @@ pub async fn start_ngrok_tunnel(
} }
#[tauri::command] #[tauri::command]
pub async fn stop_ngrok_tunnel( pub async fn stop_ngrok_tunnel(state: State<'_, AppState>) -> Result<(), String> {
state: State<'_, AppState>,
) -> Result<(), String> {
state.ngrok_manager.stop_tunnel().await state.ngrok_manager.stop_tunnel().await
} }
#[tauri::command] #[tauri::command]
pub async fn get_ngrok_status( pub async fn get_ngrok_status(state: State<'_, AppState>) -> Result<Option<NgrokTunnel>, String> {
state: State<'_, AppState>,
) -> Result<Option<NgrokTunnel>, String> {
Ok(state.ngrok_manager.get_tunnel_status()) Ok(state.ngrok_manager.get_tunnel_status())
} }

View file

@ -1,10 +1,10 @@
use serde::{Serialize, Deserialize}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use std::collections::HashMap;
use chrono::{DateTime, Utc};
/// Notification type enumeration /// Notification type enumeration
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
@ -154,7 +154,10 @@ impl NotificationManager {
}; };
// Store notification // Store notification
self.notifications.write().await.insert(notification_id.clone(), notification.clone()); self.notifications
.write()
.await
.insert(notification_id.clone(), notification.clone());
// Add to history // Add to history
let mut history = self.notification_history.write().await; let mut history = self.notification_history.write().await;
@ -168,8 +171,11 @@ impl NotificationManager {
// Show system notification if enabled // Show system notification if enabled
if settings.show_in_system { if settings.show_in_system {
match self.show_system_notification(&title, &body, notification_type).await { match self
Ok(_) => {}, .show_system_notification(&title, &body, notification_type)
.await
{
Ok(_) => {}
Err(e) => { Err(e) => {
tracing::error!("Failed to show system notification: {}", e); tracing::error!("Failed to show system notification: {}", e);
} }
@ -178,7 +184,8 @@ impl NotificationManager {
// Emit notification event to frontend // Emit notification event to frontend
if let Some(app_handle) = self.app_handle.read().await.as_ref() { if let Some(app_handle) = self.app_handle.read().await.as_ref() {
app_handle.emit("notification:new", &notification) app_handle
.emit("notification:new", &notification)
.map_err(|e| format!("Failed to emit notification event: {}", e))?; .map_err(|e| format!("Failed to emit notification event: {}", e))?;
} }
@ -193,13 +200,11 @@ impl NotificationManager {
notification_type: NotificationType, notification_type: NotificationType,
) -> Result<(), String> { ) -> Result<(), String> {
let app_handle_guard = self.app_handle.read().await; let app_handle_guard = self.app_handle.read().await;
let app_handle = app_handle_guard.as_ref() let app_handle = app_handle_guard
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
let mut builder = app_handle.notification() let mut builder = app_handle.notification().builder().title(title).body(body);
.builder()
.title(title)
.body(body);
// Set icon based on notification type // Set icon based on notification type
let icon = match notification_type { let icon = match notification_type {
@ -217,7 +222,8 @@ impl NotificationManager {
builder = builder.icon(icon_str); builder = builder.icon(icon_str);
} }
builder.show() builder
.show()
.map_err(|e| format!("Failed to show notification: {}", e))?; .map_err(|e| format!("Failed to show notification: {}", e))?;
Ok(()) Ok(())
@ -263,7 +269,9 @@ impl NotificationManager {
/// Get unread notification count /// Get unread notification count
pub async fn get_unread_count(&self) -> usize { pub async fn get_unread_count(&self) -> usize {
self.notifications.read().await self.notifications
.read()
.await
.values() .values()
.filter(|n| !n.read) .filter(|n| !n.read)
.count() .count()
@ -311,50 +319,69 @@ impl NotificationManager {
body, body,
vec![], vec![],
HashMap::new(), HashMap::new(),
).await )
.await
} }
/// Show update available notification /// Show update available notification
pub async fn notify_update_available(&self, version: &str, download_url: &str) -> Result<String, String> { pub async fn notify_update_available(
&self,
version: &str,
download_url: &str,
) -> Result<String, String> {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("version".to_string(), serde_json::Value::String(version.to_string())); metadata.insert(
metadata.insert("download_url".to_string(), serde_json::Value::String(download_url.to_string())); "version".to_string(),
serde_json::Value::String(version.to_string()),
);
metadata.insert(
"download_url".to_string(),
serde_json::Value::String(download_url.to_string()),
);
self.show_notification( self.show_notification(
NotificationType::UpdateAvailable, NotificationType::UpdateAvailable,
NotificationPriority::High, NotificationPriority::High,
"Update Available".to_string(), "Update Available".to_string(),
format!("VibeTunnel {} is now available. Click to download.", version), format!(
vec![ "VibeTunnel {} is now available. Click to download.",
NotificationAction { version
id: "download".to_string(), ),
label: "Download".to_string(), vec![NotificationAction {
action_type: "open_url".to_string(), id: "download".to_string(),
} label: "Download".to_string(),
], action_type: "open_url".to_string(),
}],
metadata, metadata,
).await )
.await
} }
/// Show permission required notification /// Show permission required notification
pub async fn notify_permission_required(&self, permission: &str, reason: &str) -> Result<String, String> { pub async fn notify_permission_required(
&self,
permission: &str,
reason: &str,
) -> Result<String, String> {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("permission".to_string(), serde_json::Value::String(permission.to_string())); metadata.insert(
"permission".to_string(),
serde_json::Value::String(permission.to_string()),
);
self.show_notification( self.show_notification(
NotificationType::PermissionRequired, NotificationType::PermissionRequired,
NotificationPriority::High, NotificationPriority::High,
"Permission Required".to_string(), "Permission Required".to_string(),
format!("{} permission is required: {}", permission, reason), format!("{} permission is required: {}", permission, reason),
vec![ vec![NotificationAction {
NotificationAction { id: "grant".to_string(),
id: "grant".to_string(), label: "Grant Permission".to_string(),
label: "Grant Permission".to_string(), action_type: "request_permission".to_string(),
action_type: "request_permission".to_string(), }],
}
],
metadata, metadata,
).await )
.await
} }
/// Show error notification /// Show error notification
@ -366,7 +393,8 @@ impl NotificationManager {
error_message.to_string(), error_message.to_string(),
vec![], vec![],
HashMap::new(), HashMap::new(),
).await )
.await
} }
/// Show success notification /// Show success notification
@ -378,6 +406,7 @@ impl NotificationManager {
message.to_string(), message.to_string(),
vec![], vec![],
HashMap::new(), HashMap::new(),
).await )
.await
} }
} }

View file

@ -1,9 +1,9 @@
use serde::{Serialize, Deserialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::AppHandle; use tauri::AppHandle;
use tokio::sync::RwLock;
/// Permission type enumeration /// Permission type enumeration
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
@ -94,7 +94,10 @@ impl PermissionsManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -107,91 +110,118 @@ impl PermissionsManager {
match platform { match platform {
"macos" => { "macos" => {
permissions.insert(PermissionType::ScreenRecording, PermissionInfo { permissions.insert(
permission_type: PermissionType::ScreenRecording, PermissionType::ScreenRecording,
status: PermissionStatus::NotDetermined, PermissionInfo {
required: false, permission_type: PermissionType::ScreenRecording,
platform_specific: true, status: PermissionStatus::NotDetermined,
description: "Required for recording terminal sessions with system UI".to_string(), required: false,
last_checked: None, platform_specific: true,
request_count: 0, description: "Required for recording terminal sessions with system UI"
}); .to_string(),
last_checked: None,
request_count: 0,
},
);
permissions.insert(PermissionType::Accessibility, PermissionInfo { permissions.insert(
permission_type: PermissionType::Accessibility, PermissionType::Accessibility,
status: PermissionStatus::NotDetermined, PermissionInfo {
required: false, permission_type: PermissionType::Accessibility,
platform_specific: true, status: PermissionStatus::NotDetermined,
description: "Required for advanced terminal integration features".to_string(), required: false,
last_checked: None, platform_specific: true,
request_count: 0, description: "Required for advanced terminal integration features"
}); .to_string(),
last_checked: None,
request_count: 0,
},
);
permissions.insert(PermissionType::NotificationAccess, PermissionInfo { permissions.insert(
permission_type: PermissionType::NotificationAccess, PermissionType::NotificationAccess,
status: PermissionStatus::NotDetermined, PermissionInfo {
required: false, permission_type: PermissionType::NotificationAccess,
platform_specific: true, status: PermissionStatus::NotDetermined,
description: "Required to show system notifications".to_string(), required: false,
last_checked: None, platform_specific: true,
request_count: 0, description: "Required to show system notifications".to_string(),
}); last_checked: None,
request_count: 0,
},
);
} }
"windows" => { "windows" => {
permissions.insert(PermissionType::TerminalAccess, PermissionInfo { permissions.insert(
permission_type: PermissionType::TerminalAccess, PermissionType::TerminalAccess,
status: PermissionStatus::NotDetermined, PermissionInfo {
required: true, permission_type: PermissionType::TerminalAccess,
platform_specific: true, status: PermissionStatus::NotDetermined,
description: "Required to create and manage terminal sessions".to_string(), required: true,
last_checked: None, platform_specific: true,
request_count: 0, description: "Required to create and manage terminal sessions".to_string(),
}); last_checked: None,
request_count: 0,
},
);
permissions.insert(PermissionType::AutoStart, PermissionInfo { permissions.insert(
permission_type: PermissionType::AutoStart, PermissionType::AutoStart,
status: PermissionStatus::NotDetermined, PermissionInfo {
required: false, permission_type: PermissionType::AutoStart,
platform_specific: true, status: PermissionStatus::NotDetermined,
description: "Required to start VibeTunnel with Windows".to_string(), required: false,
last_checked: None, platform_specific: true,
request_count: 0, description: "Required to start VibeTunnel with Windows".to_string(),
}); last_checked: None,
request_count: 0,
},
);
} }
"linux" => { "linux" => {
permissions.insert(PermissionType::FileSystemFull, PermissionInfo { permissions.insert(
permission_type: PermissionType::FileSystemFull, PermissionType::FileSystemFull,
status: PermissionStatus::Granted, PermissionInfo {
required: true, permission_type: PermissionType::FileSystemFull,
platform_specific: false, status: PermissionStatus::Granted,
description: "Required for saving recordings and configurations".to_string(), required: true,
last_checked: None, platform_specific: false,
request_count: 0, description: "Required for saving recordings and configurations"
}); .to_string(),
last_checked: None,
request_count: 0,
},
);
} }
_ => {} _ => {}
} }
// Add common permissions // Add common permissions
permissions.insert(PermissionType::NetworkAccess, PermissionInfo { permissions.insert(
permission_type: PermissionType::NetworkAccess, PermissionType::NetworkAccess,
status: PermissionStatus::Granted, PermissionInfo {
required: true, permission_type: PermissionType::NetworkAccess,
platform_specific: false, status: PermissionStatus::Granted,
description: "Required for web server and remote access features".to_string(), required: true,
last_checked: None, platform_specific: false,
request_count: 0, description: "Required for web server and remote access features".to_string(),
}); last_checked: None,
request_count: 0,
},
);
permissions.insert(PermissionType::FileSystemRestricted, PermissionInfo { permissions.insert(
permission_type: PermissionType::FileSystemRestricted, PermissionType::FileSystemRestricted,
status: PermissionStatus::Granted, PermissionInfo {
required: true, permission_type: PermissionType::FileSystemRestricted,
platform_specific: false, status: PermissionStatus::Granted,
description: "Required for basic application functionality".to_string(), required: true,
last_checked: None, platform_specific: false,
request_count: 0, description: "Required for basic application functionality".to_string(),
}); last_checked: None,
request_count: 0,
},
);
permissions permissions
} }
@ -251,7 +281,10 @@ impl PermissionsManager {
} }
/// Request permission /// Request permission
pub async fn request_permission(&self, permission_type: PermissionType) -> Result<PermissionRequestResult, String> { pub async fn request_permission(
&self,
permission_type: PermissionType,
) -> Result<PermissionRequestResult, String> {
// Update request count // Update request count
if let Some(info) = self.permissions.write().await.get_mut(&permission_type) { if let Some(info) = self.permissions.write().await.get_mut(&permission_type) {
info.request_count += 1; info.request_count += 1;
@ -283,7 +316,10 @@ impl PermissionsManager {
} }
/// Get permission info /// Get permission info
pub async fn get_permission_info(&self, permission_type: PermissionType) -> Option<PermissionInfo> { pub async fn get_permission_info(
&self,
permission_type: PermissionType,
) -> Option<PermissionInfo> {
self.permissions.read().await.get(&permission_type).cloned() self.permissions.read().await.get(&permission_type).cloned()
} }
@ -294,7 +330,9 @@ impl PermissionsManager {
/// Get required permissions /// Get required permissions
pub async fn get_required_permissions(&self) -> Vec<PermissionInfo> { pub async fn get_required_permissions(&self) -> Vec<PermissionInfo> {
self.permissions.read().await self.permissions
.read()
.await
.values() .values()
.filter(|info| info.required) .filter(|info| info.required)
.cloned() .cloned()
@ -303,7 +341,9 @@ impl PermissionsManager {
/// Get missing required permissions /// Get missing required permissions
pub async fn get_missing_required_permissions(&self) -> Vec<PermissionInfo> { pub async fn get_missing_required_permissions(&self) -> Vec<PermissionInfo> {
self.permissions.read().await self.permissions
.read()
.await
.values() .values()
.filter(|info| info.required && info.status != PermissionStatus::Granted) .filter(|info| info.required && info.status != PermissionStatus::Granted)
.cloned() .cloned()
@ -312,13 +352,19 @@ impl PermissionsManager {
/// Check if all required permissions are granted /// Check if all required permissions are granted
pub async fn all_required_permissions_granted(&self) -> bool { pub async fn all_required_permissions_granted(&self) -> bool {
!self.permissions.read().await !self
.permissions
.read()
.await
.values() .values()
.any(|info| info.required && info.status != PermissionStatus::Granted) .any(|info| info.required && info.status != PermissionStatus::Granted)
} }
/// Open system settings for permission /// Open system settings for permission
pub async fn open_system_settings(&self, permission_type: PermissionType) -> Result<(), String> { pub async fn open_system_settings(
&self,
permission_type: PermissionType,
) -> Result<(), String> {
let platform = std::env::consts::OS; let platform = std::env::consts::OS;
match (platform, permission_type) { match (platform, permission_type) {
@ -335,9 +381,7 @@ impl PermissionsManager {
self.open_notification_settings_macos().await self.open_notification_settings_macos().await
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
("windows", PermissionType::AutoStart) => { ("windows", PermissionType::AutoStart) => self.open_startup_settings_windows().await,
self.open_startup_settings_windows().await
}
_ => Err("No system settings available for this permission".to_string()), _ => Err("No system settings available for this permission".to_string()),
} }
} }
@ -360,13 +404,17 @@ impl PermissionsManager {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
async fn request_screen_recording_permission_macos(&self) -> Result<PermissionRequestResult, String> { async fn request_screen_recording_permission_macos(
&self,
) -> Result<PermissionRequestResult, String> {
// Show notification about needing to grant permission // Show notification about needing to grant permission
if let Some(notification_manager) = &self.notification_manager { if let Some(notification_manager) = &self.notification_manager {
let _ = notification_manager.notify_permission_required( let _ = notification_manager
"Screen Recording", .notify_permission_required(
"VibeTunnel needs screen recording permission to capture terminal sessions" "Screen Recording",
).await; "VibeTunnel needs screen recording permission to capture terminal sessions",
)
.await;
} }
// Open system preferences // Open system preferences
@ -375,7 +423,9 @@ impl PermissionsManager {
Ok(PermissionRequestResult { Ok(PermissionRequestResult {
permission_type: PermissionType::ScreenRecording, permission_type: PermissionType::ScreenRecording,
status: PermissionStatus::NotDetermined, status: PermissionStatus::NotDetermined,
message: Some("Please grant screen recording permission in System Settings".to_string()), message: Some(
"Please grant screen recording permission in System Settings".to_string(),
),
requires_restart: true, requires_restart: true,
requires_system_settings: true, requires_system_settings: true,
}) })
@ -416,7 +466,9 @@ impl PermissionsManager {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
async fn request_accessibility_permission_macos(&self) -> Result<PermissionRequestResult, String> { async fn request_accessibility_permission_macos(
&self,
) -> Result<PermissionRequestResult, String> {
let _ = self.open_accessibility_settings_macos().await; let _ = self.open_accessibility_settings_macos().await;
Ok(PermissionRequestResult { Ok(PermissionRequestResult {
@ -447,7 +499,9 @@ impl PermissionsManager {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
async fn request_notification_permission_macos(&self) -> Result<PermissionRequestResult, String> { async fn request_notification_permission_macos(
&self,
) -> Result<PermissionRequestResult, String> {
Ok(PermissionRequestResult { Ok(PermissionRequestResult {
permission_type: PermissionType::NotificationAccess, permission_type: PermissionType::NotificationAccess,
status: PermissionStatus::Granted, status: PermissionStatus::Granted,
@ -505,12 +559,17 @@ impl PermissionsManager {
} }
/// Show permission required notification /// Show permission required notification
pub async fn notify_permission_required(&self, permission_info: &PermissionInfo) -> Result<(), String> { pub async fn notify_permission_required(
&self,
permission_info: &PermissionInfo,
) -> Result<(), String> {
if let Some(notification_manager) = &self.notification_manager { if let Some(notification_manager) = &self.notification_manager {
notification_manager.notify_permission_required( notification_manager
&format!("{:?}", permission_info.permission_type), .notify_permission_required(
&permission_info.description &format!("{:?}", permission_info.permission_type),
).await?; &permission_info.description,
)
.await?;
} }
Ok(()) Ok(())

View file

@ -1,7 +1,7 @@
use std::process::Command; use serde::{Deserialize, Serialize};
use std::net::TcpListener; use std::net::TcpListener;
use serde::{Serialize, Deserialize}; use std::process::Command;
use tracing::{info, error}; use tracing::{error, info};
/// Information about a process using a port /// Information about a process using a port
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -23,8 +23,13 @@ impl ProcessDetails {
/// Check if this is one of our managed servers /// Check if this is one of our managed servers
pub fn is_managed_server(&self) -> bool { pub fn is_managed_server(&self) -> bool {
self.name == "vibetunnel" || self.name == "vibetunnel"
self.name.contains("node") && self.path.as_ref().map(|p| p.contains("VibeTunnel")).unwrap_or(false) || self.name.contains("node")
&& self
.path
.as_ref()
.map(|p| p.contains("VibeTunnel"))
.unwrap_or(false)
} }
} }
@ -133,10 +138,7 @@ impl PortConflictResolver {
} }
// Fallback to netstat // Fallback to netstat
if let Ok(output) = Command::new("netstat") if let Ok(output) = Command::new("netstat").args(&["-tulpn"]).output() {
.args(&["-tulpn"])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
// Parse netstat output (simplified) // Parse netstat output (simplified)
for line in stdout.lines() { for line in stdout.lines() {
@ -145,7 +147,8 @@ impl PortConflictResolver {
if let Some(pid_part) = line.split_whitespace().last() { if let Some(pid_part) = line.split_whitespace().last() {
if let Some(pid_str) = pid_part.split('/').next() { if let Some(pid_str) = pid_part.split('/').next() {
if let Ok(pid) = pid_str.parse::<u32>() { if let Ok(pid) = pid_str.parse::<u32>() {
let name = pid_part.split('/').nth(1).unwrap_or("unknown").to_string(); let name =
pid_part.split('/').nth(1).unwrap_or("unknown").to_string();
let process_info = ProcessDetails { let process_info = ProcessDetails {
pid, pid,
name, name,
@ -267,9 +270,7 @@ impl PortConflictResolver {
.args(&["-p", &pid.to_string(), "-o", "comm="]) .args(&["-p", &pid.to_string(), "-o", "comm="])
.output() .output()
{ {
let path = String::from_utf8_lossy(&output.stdout) let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
.trim()
.to_string();
if !path.is_empty() { if !path.is_empty() {
return Some(path); return Some(path);
} }
@ -360,7 +361,10 @@ impl PortConflictResolver {
} }
/// Determine action for conflict resolution /// Determine action for conflict resolution
fn determine_action(process: &ProcessDetails, root_process: &Option<ProcessDetails>) -> ConflictAction { fn determine_action(
process: &ProcessDetails,
root_process: &Option<ProcessDetails>,
) -> ConflictAction {
// If it's our managed server, kill it // If it's our managed server, kill it
if process.is_managed_server() { if process.is_managed_server() {
return ConflictAction::KillOurInstance { return ConflictAction::KillOurInstance {
@ -397,7 +401,10 @@ impl PortConflictResolver {
pub async fn resolve_conflict(conflict: &PortConflict) -> Result<(), String> { pub async fn resolve_conflict(conflict: &PortConflict) -> Result<(), String> {
match &conflict.suggested_action { match &conflict.suggested_action {
ConflictAction::KillOurInstance { pid, process_name } => { ConflictAction::KillOurInstance { pid, process_name } => {
info!("Killing conflicting process: {} (PID: {})", process_name, pid); info!(
"Killing conflicting process: {} (PID: {})",
process_name, pid
);
#[cfg(unix)] #[cfg(unix)]
{ {
@ -436,7 +443,10 @@ impl PortConflictResolver {
/// Force kill a process /// Force kill a process
pub async fn force_kill_process(conflict: &PortConflict) -> Result<(), String> { pub async fn force_kill_process(conflict: &PortConflict) -> Result<(), String> {
info!("Force killing process: {} (PID: {})", conflict.process.name, conflict.process.pid); info!(
"Force killing process: {} (PID: {})",
conflict.process.name, conflict.process.pid
);
#[cfg(unix)] #[cfg(unix)]
{ {

View file

@ -1,29 +1,28 @@
use axum::{ use axum::extract::ws::{Message, WebSocket};
Router,
routing::{get, post, delete},
response::IntoResponse,
extract::{ws::WebSocketUpgrade, Path, State as AxumState, Query},
http::StatusCode,
Json,
middleware,
};
use axum::extract::ws::{WebSocket, Message};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::{
extract::{ws::WebSocketUpgrade, Path, Query, State as AxumState},
http::StatusCode,
middleware,
response::IntoResponse,
routing::{delete, get, post},
Json, Router,
};
use futures::sink::SinkExt;
use futures::stream::{Stream, StreamExt as FuturesStreamExt}; use futures::stream::{Stream, StreamExt as FuturesStreamExt};
use serde::{Deserialize, Serialize};
use std::convert::Infallible; use std::convert::Infallible;
use tokio::time::{interval, Duration}; use std::fs;
use tower_http::cors::{CorsLayer, Any}; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use futures::sink::SinkExt; use tokio::time::{interval, Duration};
use serde::{Deserialize, Serialize}; use tower_http::cors::{Any, CorsLayer};
use tracing::{info, error, debug}; use tracing::{debug, error, info};
use std::path::PathBuf;
use std::fs;
use crate::terminal::TerminalManager; use crate::auth::{auth_middleware, check_auth, login, AuthConfig};
use crate::auth::{AuthConfig, auth_middleware, check_auth, login};
use crate::session_monitor::SessionMonitor; use crate::session_monitor::SessionMonitor;
use crate::terminal::TerminalManager;
// Combined app state for Axum // Combined app state for Axum
#[derive(Clone)] #[derive(Clone)]
@ -100,7 +99,10 @@ impl HttpServer {
self.port self.port
} }
pub fn new(terminal_manager: Arc<TerminalManager>, session_monitor: Arc<SessionMonitor>) -> Self { pub fn new(
terminal_manager: Arc<TerminalManager>,
session_monitor: Arc<SessionMonitor>,
) -> Self {
Self { Self {
terminal_manager, terminal_manager,
auth_config: Arc::new(AuthConfig::new(false, None)), auth_config: Arc::new(AuthConfig::new(false, None)),
@ -111,7 +113,11 @@ impl HttpServer {
} }
} }
pub fn with_auth(terminal_manager: Arc<TerminalManager>, session_monitor: Arc<SessionMonitor>, auth_config: AuthConfig) -> Self { pub fn with_auth(
terminal_manager: Arc<TerminalManager>,
session_monitor: Arc<SessionMonitor>,
auth_config: AuthConfig,
) -> Self {
Self { Self {
terminal_manager, terminal_manager,
auth_config: Arc::new(auth_config), auth_config: Arc::new(auth_config),
@ -122,6 +128,7 @@ impl HttpServer {
} }
} }
#[allow(dead_code)]
pub async fn start(&mut self) -> Result<u16, String> { pub async fn start(&mut self) -> Result<u16, String> {
self.start_with_mode("localhost").await self.start_with_mode("localhost").await
} }
@ -130,7 +137,7 @@ impl HttpServer {
// Determine bind address based on mode // Determine bind address based on mode
let bind_addr = match mode { let bind_addr = match mode {
"localhost" => "127.0.0.1:0", "localhost" => "127.0.0.1:0",
"network" => "0.0.0.0:0", // Bind to all interfaces "network" => "0.0.0.0:0", // Bind to all interfaces
_ => "127.0.0.1:0", _ => "127.0.0.1:0",
}; };
@ -139,7 +146,8 @@ impl HttpServer {
.await .await
.map_err(|e| format!("Failed to bind to {}: {}", bind_addr, e))?; .map_err(|e| format!("Failed to bind to {}: {}", bind_addr, e))?;
let addr = listener.local_addr() let addr = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {}", e))?; .map_err(|e| format!("Failed to get local address: {}", e))?;
self.port = addr.port(); self.port = addr.port();
@ -155,11 +163,10 @@ impl HttpServer {
// Start server // Start server
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let server = axum::serve(listener, app) let server = axum::serve(listener, app).with_graceful_shutdown(async {
.with_graceful_shutdown(async { let _ = shutdown_rx.await;
let _ = shutdown_rx.await; info!("Graceful shutdown initiated");
info!("Graceful shutdown initiated"); });
});
if let Err(e) = server.await { if let Err(e) = server.await {
error!("Server error: {}", e); error!("Server error: {}", e);
@ -183,10 +190,7 @@ impl HttpServer {
// Wait for server task to complete // Wait for server task to complete
if let Some(handle) = self.handle.take() { if let Some(handle) = self.handle.take() {
match tokio::time::timeout( match tokio::time::timeout(tokio::time::Duration::from_secs(10), handle).await {
tokio::time::Duration::from_secs(10),
handle
).await {
Ok(Ok(())) => { Ok(Ok(())) => {
info!("HTTP server stopped gracefully"); info!("HTTP server stopped gracefully");
} }
@ -243,17 +247,19 @@ impl HttpServer {
.route("/api/cleanup-exited", post(cleanup_exited)) .route("/api/cleanup-exited", post(cleanup_exited))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
app_state.auth_config.clone(), app_state.auth_config.clone(),
auth_middleware auth_middleware,
)) ))
.with_state(app_state); .with_state(app_state);
Router::new() Router::new()
.merge(auth_routes) .merge(auth_routes)
.merge(protected_routes) .merge(protected_routes)
.layer(CorsLayer::new() .layer(
.allow_origin(Any) CorsLayer::new()
.allow_methods(Any) .allow_origin(Any)
.allow_headers(Any)) .allow_methods(Any)
.allow_headers(Any),
)
} }
} }
@ -263,12 +269,15 @@ async fn list_sessions(
) -> Result<Json<Vec<SessionInfo>>, StatusCode> { ) -> Result<Json<Vec<SessionInfo>>, StatusCode> {
let sessions = state.terminal_manager.list_sessions().await; let sessions = state.terminal_manager.list_sessions().await;
let session_infos: Vec<SessionInfo> = sessions.into_iter().map(|s| SessionInfo { let session_infos: Vec<SessionInfo> = sessions
id: s.id, .into_iter()
name: s.name, .map(|s| SessionInfo {
status: "running".to_string(), id: s.id,
created_at: s.created_at, name: s.name,
}).collect(); status: "running".to_string(),
created_at: s.created_at,
})
.collect();
Ok(Json(session_infos)) Ok(Json(session_infos))
} }
@ -277,17 +286,21 @@ async fn create_session(
AxumState(state): AxumState<AppState>, AxumState(state): AxumState<AppState>,
Json(req): Json<CreateSessionRequest>, Json(req): Json<CreateSessionRequest>,
) -> Result<Json<SessionInfo>, StatusCode> { ) -> Result<Json<SessionInfo>, StatusCode> {
let session = state.terminal_manager.create_session( let session = state
req.name.unwrap_or_else(|| "Terminal".to_string()), .terminal_manager
req.rows.unwrap_or(24), .create_session(
req.cols.unwrap_or(80), req.name.unwrap_or_else(|| "Terminal".to_string()),
req.cwd, req.rows.unwrap_or(24),
None, req.cols.unwrap_or(80),
None, req.cwd,
).await.map_err(|e| { None,
error!("Failed to create session: {}", e); None,
StatusCode::INTERNAL_SERVER_ERROR )
})?; .await
.map_err(|e| {
error!("Failed to create session: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(SessionInfo { Ok(Json(SessionInfo {
id: session.id, id: session.id,
@ -303,14 +316,17 @@ async fn get_session(
) -> Result<Json<SessionInfo>, StatusCode> { ) -> Result<Json<SessionInfo>, StatusCode> {
let sessions = state.terminal_manager.list_sessions().await; let sessions = state.terminal_manager.list_sessions().await;
sessions.into_iter() sessions
.into_iter()
.find(|s| s.id == id) .find(|s| s.id == id)
.map(|s| Json(SessionInfo { .map(|s| {
id: s.id, Json(SessionInfo {
name: s.name, id: s.id,
status: "running".to_string(), name: s.name,
created_at: s.created_at, status: "running".to_string(),
})) created_at: s.created_at,
})
})
.ok_or(StatusCode::NOT_FOUND) .ok_or(StatusCode::NOT_FOUND)
} }
@ -318,7 +334,10 @@ async fn delete_session(
Path(id): Path<String>, Path(id): Path<String>,
AxumState(state): AxumState<AppState>, AxumState(state): AxumState<AppState>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
state.terminal_manager.close_session(&id).await state
.terminal_manager
.close_session(&id)
.await
.map(|_| StatusCode::NO_CONTENT) .map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::NOT_FOUND) .map_err(|_| StatusCode::NOT_FOUND)
} }
@ -334,7 +353,10 @@ async fn resize_session(
AxumState(state): AxumState<AppState>, AxumState(state): AxumState<AppState>,
Json(req): Json<ResizeRequest>, Json(req): Json<ResizeRequest>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
state.terminal_manager.resize_session(&id, req.rows, req.cols).await state
.terminal_manager
.resize_session(&id, req.rows, req.cols)
.await
.map(|_| StatusCode::NO_CONTENT) .map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::NOT_FOUND) .map_err(|_| StatusCode::NOT_FOUND)
} }
@ -358,7 +380,9 @@ async fn handle_terminal_websocket(
let _session = match terminal_manager.get_session(&session_id).await { let _session = match terminal_manager.get_session(&session_id).await {
Some(s) => s, Some(s) => s,
None => { None => {
let _ = sender.send(Message::Text("Session not found".to_string())).await; let _ = sender
.send(Message::Text("Session not found".to_string()))
.await;
return; return;
} }
}; };
@ -368,7 +392,10 @@ async fn handle_terminal_websocket(
let terminal_manager_clone = terminal_manager.clone(); let terminal_manager_clone = terminal_manager.clone();
let read_task = tokio::spawn(async move { let read_task = tokio::spawn(async move {
loop { loop {
match terminal_manager_clone.read_from_session(&session_id_clone).await { match terminal_manager_clone
.read_from_session(&session_id_clone)
.await
{
Ok(data) if !data.is_empty() => { Ok(data) if !data.is_empty() => {
if sender.send(Message::Binary(data)).await.is_err() { if sender.send(Message::Binary(data)).await.is_err() {
break; break;
@ -399,15 +426,12 @@ async fn handle_terminal_websocket(
// Handle text messages (e.g., resize commands) // Handle text messages (e.g., resize commands)
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
if json["type"] == "resize" { if json["type"] == "resize" {
if let (Some(rows), Some(cols)) = ( if let (Some(rows), Some(cols)) =
json["rows"].as_u64(), (json["rows"].as_u64(), json["cols"].as_u64())
json["cols"].as_u64() {
) { let _ = terminal_manager
let _ = terminal_manager.resize_session( .resize_session(&session_id, rows as u16, cols as u16)
&session_id, .await;
rows as u16,
cols as u16
).await;
} }
} }
} }
@ -437,7 +461,10 @@ async fn send_input(
AxumState(state): AxumState<AppState>, AxumState(state): AxumState<AppState>,
Json(req): Json<InputRequest>, Json(req): Json<InputRequest>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
state.terminal_manager.write_to_session(&id, req.input.as_bytes()).await state
.terminal_manager
.write_to_session(&id, req.input.as_bytes())
.await
.map(|_| StatusCode::NO_CONTENT) .map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::NOT_FOUND) .map_err(|_| StatusCode::NOT_FOUND)
} }
@ -448,7 +475,8 @@ async fn terminal_stream(
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, StatusCode> { ) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, StatusCode> {
// Check if session exists // Check if session exists
let sessions = state.terminal_manager.list_sessions().await; let sessions = state.terminal_manager.list_sessions().await;
let session = sessions.into_iter() let session = sessions
.into_iter()
.find(|s| s.id == id) .find(|s| s.id == id)
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
@ -530,11 +558,10 @@ async fn session_events_stream(
session_monitor.start_monitoring().await; session_monitor.start_monitoring().await;
// Create SSE stream from session monitor // Create SSE stream from session monitor
let stream = session_monitor.create_sse_stream() let stream = session_monitor.create_sse_stream().map(|data| {
.map(|data| { data.map(|json| Event::default().data(json))
data.map(|json| Event::default().data(json)) .map_err(|_| unreachable!())
.map_err(|_| unreachable!()) });
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default())) Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
} }
@ -547,8 +574,7 @@ async fn browse_directory(
// Expand tilde to home directory // Expand tilde to home directory
let path = if path_str.starts_with('~') { let path = if path_str.starts_with('~') {
let home = dirs::home_dir() let home = dirs::home_dir().ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)?;
.ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)?;
home.join(path_str.strip_prefix("~/").unwrap_or("")) home.join(path_str.strip_prefix("~/").unwrap_or(""))
} else { } else {
PathBuf::from(&path_str) PathBuf::from(&path_str)
@ -565,22 +591,24 @@ async fn browse_directory(
// Read directory entries // Read directory entries
let mut files = Vec::new(); let mut files = Vec::new();
let entries = fs::read_dir(&path) let entries = fs::read_dir(&path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let entry = entry.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let metadata = entry.metadata() let metadata = entry
.metadata()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let created = metadata.created() let created = metadata
.created()
.map(|t| { .map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into(); let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime.to_rfc3339() datetime.to_rfc3339()
}) })
.unwrap_or_else(|_| String::new()); .unwrap_or_else(|_| String::new());
let modified = metadata.modified() let modified = metadata
.modified()
.map(|t| { .map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into(); let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime.to_rfc3339() datetime.to_rfc3339()
@ -597,12 +625,10 @@ async fn browse_directory(
} }
// Sort directories first, then files, alphabetically // Sort directories first, then files, alphabetically
files.sort_by(|a, b| { files.sort_by(|a, b| match (a.is_dir, b.is_dir) {
match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less,
(true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name),
_ => a.name.cmp(&b.name),
}
}); });
Ok(Json(DirectoryListing { Ok(Json(DirectoryListing {
@ -611,21 +637,19 @@ async fn browse_directory(
})) }))
} }
async fn create_directory( async fn create_directory(Json(req): Json<CreateDirRequest>) -> Result<StatusCode, StatusCode> {
Json(req): Json<CreateDirRequest>,
) -> Result<StatusCode, StatusCode> {
// Validate directory name // Validate directory name
if req.name.is_empty() || if req.name.is_empty()
req.name.contains('/') || || req.name.contains('/')
req.name.contains('\\') || || req.name.contains('\\')
req.name.starts_with('.') { || req.name.starts_with('.')
{
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
} }
// Expand path // Expand path
let base_path = if req.path.starts_with('~') { let base_path = if req.path.starts_with('~') {
let home = dirs::home_dir() let home = dirs::home_dir().ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)?;
.ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)?;
home.join(req.path.strip_prefix("~/").unwrap_or("")) home.join(req.path.strip_prefix("~/").unwrap_or(""))
} else { } else {
PathBuf::from(&req.path) PathBuf::from(&req.path)
@ -640,8 +664,7 @@ async fn create_directory(
} }
// Create directory // Create directory
fs::create_dir(&full_path) fs::create_dir(&full_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@ -656,9 +679,19 @@ async fn cleanup_exited(
// Check each session and close if the process has exited // Check each session and close if the process has exited
for session in sessions { for session in sessions {
// Try to write empty data to check if session is alive // Try to write empty data to check if session is alive
if state.terminal_manager.write_to_session(&session.id, &[]).await.is_err() { if state
.terminal_manager
.write_to_session(&session.id, &[])
.await
.is_err()
{
// Session is dead, clean it up // Session is dead, clean it up
if state.terminal_manager.close_session(&session.id).await.is_ok() { if state
.terminal_manager
.close_session(&session.id)
.await
.is_ok()
{
cleaned_sessions.push(session.id); cleaned_sessions.push(session.id);
} }
} }
@ -679,7 +712,8 @@ async fn get_snapshot(
) -> Result<String, StatusCode> { ) -> Result<String, StatusCode> {
// Check if session exists // Check if session exists
let sessions = state.terminal_manager.list_sessions().await; let sessions = state.terminal_manager.list_sessions().await;
let session = sessions.into_iter() let session = sessions
.into_iter()
.find(|s| s.id == id) .find(|s| s.id == id)
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;

View file

@ -1,10 +1,10 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{RwLock, mpsc}; use tokio::sync::{mpsc, RwLock};
use tokio::time::{interval, Duration}; use tokio::time::{interval, Duration};
use chrono::Utc;
use serde::{Serialize, Deserialize};
use serde_json;
use uuid::Uuid; use uuid::Uuid;
/// Information about a terminal session /// Information about a terminal session
@ -25,19 +25,10 @@ pub struct SessionInfo {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionEvent { pub enum SessionEvent {
SessionCreated { SessionCreated { session: SessionInfo },
session: SessionInfo, SessionUpdated { session: SessionInfo },
}, SessionClosed { id: String },
SessionUpdated { SessionActivity { id: String, timestamp: String },
session: SessionInfo,
},
SessionClosed {
id: String,
},
SessionActivity {
id: String,
timestamp: String,
},
} }
/// Session monitoring service /// Session monitoring service
@ -93,21 +84,24 @@ impl SessionMonitor {
Self::broadcast_event( Self::broadcast_event(
&subscribers, &subscribers,
SessionEvent::SessionCreated { SessionEvent::SessionCreated {
session: session_info.clone() session: session_info.clone(),
} },
).await; )
.await;
} else { } else {
// Check if session was updated // Check if session was updated
if let Some(existing) = sessions_map.get(&session.id) { if let Some(existing) = sessions_map.get(&session.id) {
if existing.rows != session_info.rows || if existing.rows != session_info.rows
existing.cols != session_info.cols { || existing.cols != session_info.cols
{
// Broadcast session updated event // Broadcast session updated event
Self::broadcast_event( Self::broadcast_event(
&subscribers, &subscribers,
SessionEvent::SessionUpdated { SessionEvent::SessionUpdated {
session: session_info.clone() session: session_info.clone(),
} },
).await; )
.await;
} }
} }
} }
@ -116,7 +110,8 @@ impl SessionMonitor {
} }
// Check for closed sessions // Check for closed sessions
let closed_sessions: Vec<String> = sessions_map.keys() let closed_sessions: Vec<String> = sessions_map
.keys()
.filter(|id| !updated_sessions.contains_key(*id)) .filter(|id| !updated_sessions.contains_key(*id))
.cloned() .cloned()
.collect(); .collect();
@ -126,9 +121,10 @@ impl SessionMonitor {
Self::broadcast_event( Self::broadcast_event(
&subscribers, &subscribers,
SessionEvent::SessionClosed { SessionEvent::SessionClosed {
id: session_id.clone() id: session_id.clone(),
} },
).await; )
.await;
} }
// Update the sessions map // Update the sessions map
@ -138,21 +134,27 @@ impl SessionMonitor {
} }
/// Subscribe to session events /// Subscribe to session events
#[allow(dead_code)]
pub async fn subscribe(&self) -> mpsc::UnboundedReceiver<SessionEvent> { pub async fn subscribe(&self) -> mpsc::UnboundedReceiver<SessionEvent> {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
let subscriber_id = Uuid::new_v4().to_string(); let subscriber_id = Uuid::new_v4().to_string();
self.event_subscribers.write().await.insert(subscriber_id, tx); self.event_subscribers
.write()
.await
.insert(subscriber_id, tx);
rx rx
} }
/// Unsubscribe from session events /// Unsubscribe from session events
#[allow(dead_code)]
pub async fn unsubscribe(&self, subscriber_id: &str) { pub async fn unsubscribe(&self, subscriber_id: &str) {
self.event_subscribers.write().await.remove(subscriber_id); self.event_subscribers.write().await.remove(subscriber_id);
} }
/// Get current session count /// Get current session count
#[allow(dead_code)]
pub async fn get_session_count(&self) -> usize { pub async fn get_session_count(&self) -> usize {
self.sessions.read().await.len() self.sessions.read().await.len()
} }
@ -163,11 +165,13 @@ impl SessionMonitor {
} }
/// Get a specific session /// Get a specific session
#[allow(dead_code)]
pub async fn get_session(&self, id: &str) -> Option<SessionInfo> { pub async fn get_session(&self, id: &str) -> Option<SessionInfo> {
self.sessions.read().await.get(id).cloned() self.sessions.read().await.get(id).cloned()
} }
/// Notify activity for a session /// Notify activity for a session
#[allow(dead_code)]
pub async fn notify_activity(&self, session_id: &str) { pub async fn notify_activity(&self, session_id: &str) {
if let Some(session) = self.sessions.write().await.get_mut(session_id) { if let Some(session) = self.sessions.write().await.get_mut(session_id) {
session.last_activity = Utc::now().to_rfc3339(); session.last_activity = Utc::now().to_rfc3339();
@ -178,8 +182,9 @@ impl SessionMonitor {
SessionEvent::SessionActivity { SessionEvent::SessionActivity {
id: session_id.to_string(), id: session_id.to_string(),
timestamp: session.last_activity.clone(), timestamp: session.last_activity.clone(),
} },
).await; )
.await;
} }
} }
@ -208,7 +213,10 @@ impl SessionMonitor {
} }
/// Create an SSE stream for session events /// Create an SSE stream for session events
pub fn create_sse_stream(self: Arc<Self>) -> impl futures::Stream<Item = Result<String, std::convert::Infallible>> + Send + 'static { pub fn create_sse_stream(
self: Arc<Self>,
) -> impl futures::Stream<Item = Result<String, std::convert::Infallible>> + Send + 'static
{
async_stream::stream! { async_stream::stream! {
// Subscribe to events // Subscribe to events
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
@ -260,7 +268,7 @@ impl SessionMonitor {
total_sessions: sessions.len(), total_sessions: sessions.len(),
active_sessions, active_sessions,
total_clients, total_clients,
uptime_seconds: 0, // TODO: Track uptime uptime_seconds: 0, // TODO: Track uptime
sessions_created_today: 0, // TODO: Track daily stats sessions_created_today: 0, // TODO: Track daily stats
} }
} }

View file

@ -1,9 +1,9 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use directories::ProjectDirs;
use tauri::{Manager, State};
use crate::state::AppState; use crate::state::AppState;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{Manager, State};
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GeneralSettings { pub struct GeneralSettings {
@ -336,8 +336,7 @@ impl Settings {
let contents = std::fs::read_to_string(&config_path) let contents = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read settings: {}", e))?; .map_err(|e| format!("Failed to read settings: {}", e))?;
toml::from_str(&contents) toml::from_str(&contents).map_err(|e| format!("Failed to parse settings: {}", e))
.map_err(|e| format!("Failed to parse settings: {}", e))
} }
pub fn save(&self) -> Result<(), String> { pub fn save(&self) -> Result<(), String> {
@ -367,9 +366,7 @@ impl Settings {
} }
#[tauri::command] #[tauri::command]
pub async fn get_settings( pub async fn get_settings(_state: State<'_, AppState>) -> Result<Settings, String> {
_state: State<'_, AppState>,
) -> Result<Settings, String> {
Settings::load() Settings::load()
} }
@ -392,7 +389,10 @@ pub async fn save_settings(
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Check if any windows are visible // Check if any windows are visible
let has_visible_windows = app.windows().values().any(|w| w.is_visible().unwrap_or(false)); let has_visible_windows = app
.windows()
.values()
.any(|w| w.is_visible().unwrap_or(false));
if !has_visible_windows && !settings.general.show_dock_icon { if !has_visible_windows && !settings.general.show_dock_icon {
// Hide dock icon if no windows are visible and setting is disabled // Hide dock icon if no windows are visible and setting is disabled

View file

@ -1,22 +1,22 @@
use tokio::sync::RwLock;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::terminal::TerminalManager;
use crate::server::HttpServer;
use crate::ngrok::NgrokManager;
use crate::cast::CastManager;
use crate::tty_forward::TTYForwardManager;
use crate::session_monitor::SessionMonitor;
use crate::notification_manager::NotificationManager;
use crate::welcome::WelcomeManager;
use crate::permissions::PermissionsManager;
use crate::updater::UpdateManager;
use crate::backend_manager::BackendManager;
use crate::debug_features::DebugFeaturesManager;
use crate::api_testing::APITestingManager; use crate::api_testing::APITestingManager;
use crate::auth_cache::AuthCacheManager; use crate::auth_cache::AuthCacheManager;
use crate::backend_manager::BackendManager;
use crate::cast::CastManager;
use crate::debug_features::DebugFeaturesManager;
use crate::ngrok::NgrokManager;
use crate::notification_manager::NotificationManager;
use crate::permissions::PermissionsManager;
use crate::server::HttpServer;
use crate::session_monitor::SessionMonitor;
use crate::terminal::TerminalManager;
use crate::terminal_integrations::TerminalIntegrationsManager; use crate::terminal_integrations::TerminalIntegrationsManager;
use crate::terminal_spawn_service::TerminalSpawnService; use crate::terminal_spawn_service::TerminalSpawnService;
use crate::tty_forward::TTYForwardManager;
use crate::updater::UpdateManager;
use crate::welcome::WelcomeManager;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -75,7 +75,7 @@ impl AppState {
let terminal_integrations_manager = Arc::new(terminal_integrations_manager); let terminal_integrations_manager = Arc::new(terminal_integrations_manager);
let terminal_spawn_service = Arc::new(TerminalSpawnService::new( let terminal_spawn_service = Arc::new(TerminalSpawnService::new(
terminal_integrations_manager.clone() terminal_integrations_manager.clone(),
)); ));
Self { Self {

View file

@ -1,13 +1,13 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::io::{Read, Write};
use portable_pty::{CommandBuilder, PtySize, native_pty_system, PtyPair, Child};
use tokio::sync::{mpsc, RwLock};
use bytes::Bytes;
use uuid::Uuid;
use chrono::Utc;
use tracing::{info, error, debug};
use crate::cast::CastManager; use crate::cast::CastManager;
use bytes::Bytes;
use chrono::Utc;
use portable_pty::{native_pty_system, Child, CommandBuilder, PtyPair, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, RwLock};
use tracing::{debug, error, info};
use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
pub struct TerminalManager { pub struct TerminalManager {
@ -24,9 +24,12 @@ pub struct TerminalSession {
pub created_at: String, pub created_at: String,
pub cwd: String, pub cwd: String,
pty_pair: PtyPair, pty_pair: PtyPair,
#[allow(dead_code)]
child: Box<dyn Child + Send + Sync>, child: Box<dyn Child + Send + Sync>,
writer: Box<dyn Write + Send>, writer: Box<dyn Write + Send>,
#[allow(dead_code)]
reader_thread: Option<std::thread::JoinHandle<()>>, reader_thread: Option<std::thread::JoinHandle<()>>,
#[allow(dead_code)]
output_tx: mpsc::UnboundedSender<Bytes>, output_tx: mpsc::UnboundedSender<Bytes>,
pub output_rx: Arc<Mutex<mpsc::UnboundedReceiver<Bytes>>>, pub output_rx: Arc<Mutex<mpsc::UnboundedReceiver<Bytes>>>,
} }
@ -159,7 +162,12 @@ impl TerminalManager {
rows, rows,
cols, cols,
created_at: Utc::now().to_rfc3339(), created_at: Utc::now().to_rfc3339(),
cwd: cwd.unwrap_or_else(|| std::env::current_dir().unwrap().to_string_lossy().to_string()), cwd: cwd.unwrap_or_else(|| {
std::env::current_dir()
.unwrap()
.to_string_lossy()
.to_string()
}),
pty_pair, pty_pair,
child, child,
writer, writer,
@ -169,7 +177,10 @@ impl TerminalManager {
}; };
// Store session // Store session
self.sessions.write().await.insert(id.clone(), Arc::new(RwLock::new(session))); self.sessions
.write()
.await
.insert(id.clone(), Arc::new(RwLock::new(session)));
info!("Created terminal session: {} ({})", name, id); info!("Created terminal session: {} ({})", name, id);
@ -240,7 +251,8 @@ impl TerminalManager {
if let Some(session_arc) = self.get_session(id).await { if let Some(session_arc) = self.get_session(id).await {
let mut session = session_arc.write().await; let mut session = session_arc.write().await;
session.pty_pair session
.pty_pair
.master .master
.resize(PtySize { .resize(PtySize {
rows, rows,
@ -277,11 +289,13 @@ impl TerminalManager {
let _ = cast_manager.add_input(id, data).await; let _ = cast_manager.add_input(id, data).await;
} }
session.writer session
.writer
.write_all(data) .write_all(data)
.map_err(|e| format!("Failed to write to PTY: {}", e))?; .map_err(|e| format!("Failed to write to PTY: {}", e))?;
session.writer session
.writer
.flush() .flush()
.map_err(|e| format!("Failed to flush PTY: {}", e))?; .map_err(|e| format!("Failed to flush PTY: {}", e))?;

View file

@ -21,10 +21,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Check for Terminal.app // Check for Terminal.app
if let Ok(_) = Command::new("open") if let Ok(_) = Command::new("open").args(&["-Ra", "Terminal.app"]).output() {
.args(&["-Ra", "Terminal.app"])
.output()
{
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "Terminal".to_string(), name: "Terminal".to_string(),
path: "/System/Applications/Utilities/Terminal.app".to_string(), path: "/System/Applications/Utilities/Terminal.app".to_string(),
@ -33,10 +30,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
} }
// Check for iTerm2 // Check for iTerm2
if let Ok(_) = Command::new("open") if let Ok(_) = Command::new("open").args(&["-Ra", "iTerm.app"]).output() {
.args(&["-Ra", "iTerm.app"])
.output()
{
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "iTerm2".to_string(), name: "iTerm2".to_string(),
path: "/Applications/iTerm.app".to_string(), path: "/Applications/iTerm.app".to_string(),
@ -45,10 +39,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
} }
// Check for Warp // Check for Warp
if let Ok(output) = Command::new("which") if let Ok(output) = Command::new("which").arg("warp").output() {
.arg("warp")
.output()
{
if output.status.success() { if output.status.success() {
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "Warp".to_string(), name: "Warp".to_string(),
@ -59,10 +50,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
} }
// Check for Hyper // Check for Hyper
if let Ok(_) = Command::new("open") if let Ok(_) = Command::new("open").args(&["-Ra", "Hyper.app"]).output() {
.args(&["-Ra", "Hyper.app"])
.output()
{
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "Hyper".to_string(), name: "Hyper".to_string(),
path: "/Applications/Hyper.app".to_string(), path: "/Applications/Hyper.app".to_string(),
@ -71,10 +59,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
} }
// Check for Alacritty // Check for Alacritty
if let Ok(output) = Command::new("which") if let Ok(output) = Command::new("which").arg("alacritty").output() {
.arg("alacritty")
.output()
{
if output.status.success() { if output.status.success() {
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "Alacritty".to_string(), name: "Alacritty".to_string(),
@ -114,10 +99,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// Check for Windows Terminal // Check for Windows Terminal
if let Ok(output) = Command::new("where") if let Ok(output) = Command::new("where").arg("wt.exe").output() {
.arg("wt.exe")
.output()
{
if output.status.success() { if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
@ -134,10 +116,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
} }
// Check for PowerShell // Check for PowerShell
if let Ok(output) = Command::new("where") if let Ok(output) = Command::new("where").arg("powershell.exe").output() {
.arg("powershell.exe")
.output()
{
if output.status.success() { if output.status.success() {
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "PowerShell".to_string(), name: "PowerShell".to_string(),
@ -148,10 +127,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
} }
// Check for Command Prompt // Check for Command Prompt
if let Ok(output) = Command::new("where") if let Ok(output) = Command::new("where").arg("cmd.exe").output() {
.arg("cmd.exe")
.output()
{
if output.status.success() { if output.status.success() {
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: "Command Prompt".to_string(), name: "Command Prompt".to_string(),
@ -187,10 +163,7 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
]; ];
for (cmd, name) in terminals { for (cmd, name) in terminals {
if let Ok(output) = Command::new("which") if let Ok(output) = Command::new("which").arg(cmd).output() {
.arg(cmd)
.output()
{
if output.status.success() { if output.status.success() {
available_terminals.push(TerminalInfo { available_terminals.push(TerminalInfo {
name: name.to_string(), name: name.to_string(),
@ -205,17 +178,20 @@ pub fn detect_terminals() -> Result<DetectedTerminals, String> {
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
match desktop.to_lowercase().as_str() { match desktop.to_lowercase().as_str() {
"gnome" | "ubuntu" => { "gnome" | "ubuntu" => {
default_terminal = available_terminals.iter() default_terminal = available_terminals
.iter()
.find(|t| t.name == "GNOME Terminal") .find(|t| t.name == "GNOME Terminal")
.cloned(); .cloned();
} }
"kde" => { "kde" => {
default_terminal = available_terminals.iter() default_terminal = available_terminals
.iter()
.find(|t| t.name == "Konsole") .find(|t| t.name == "Konsole")
.cloned(); .cloned();
} }
"xfce" => { "xfce" => {
default_terminal = available_terminals.iter() default_terminal = available_terminals
.iter()
.find(|t| t.name == "XFCE Terminal") .find(|t| t.name == "XFCE Terminal")
.cloned(); .cloned();
} }
@ -260,10 +236,7 @@ pub async fn get_default_shell() -> Result<String, String> {
#[cfg(windows)] #[cfg(windows)]
{ {
// On Windows, default to PowerShell // On Windows, default to PowerShell
if let Ok(output) = Command::new("where") if let Ok(output) = Command::new("where").arg("powershell.exe").output() {
.arg("powershell.exe")
.output()
{
if output.status.success() { if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
} }

View file

@ -1,29 +1,29 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Terminal emulator type /// Terminal emulator type
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum TerminalEmulator { pub enum TerminalEmulator {
SystemDefault, SystemDefault,
Terminal, // macOS Terminal.app Terminal, // macOS Terminal.app
ITerm2, // iTerm2 ITerm2, // iTerm2
Hyper, // Hyper Hyper, // Hyper
Alacritty, // Alacritty Alacritty, // Alacritty
Kitty, // Kitty Kitty, // Kitty
WezTerm, // WezTerm WezTerm, // WezTerm
Ghostty, // Ghostty Ghostty, // Ghostty
Warp, // Warp Warp, // Warp
WindowsTerminal, // Windows Terminal WindowsTerminal, // Windows Terminal
ConEmu, // ConEmu ConEmu, // ConEmu
Cmder, // Cmder Cmder, // Cmder
Gnome, // GNOME Terminal Gnome, // GNOME Terminal
Konsole, // KDE Konsole Konsole, // KDE Konsole
Xterm, // XTerm Xterm, // XTerm
Custom, // Custom terminal Custom, // Custom terminal
} }
impl TerminalEmulator { impl TerminalEmulator {
@ -151,7 +151,10 @@ impl TerminalIntegrationsManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -160,123 +163,148 @@ impl TerminalIntegrationsManager {
let mut configs = HashMap::new(); let mut configs = HashMap::new();
// WezTerm configuration // WezTerm configuration
configs.insert(TerminalEmulator::WezTerm, TerminalConfig { configs.insert(
emulator: TerminalEmulator::WezTerm, TerminalEmulator::WezTerm,
name: "WezTerm".to_string(), TerminalConfig {
executable_path: PathBuf::from("/Applications/WezTerm.app/Contents/MacOS/wezterm"), emulator: TerminalEmulator::WezTerm,
args_template: vec![ name: "WezTerm".to_string(),
"start".to_string(), executable_path: PathBuf::from("/Applications/WezTerm.app/Contents/MacOS/wezterm"),
"--cwd".to_string(), args_template: vec![
"{working_directory}".to_string(), "start".to_string(),
"--".to_string(), "--cwd".to_string(),
"{command}".to_string(), "{working_directory}".to_string(),
"{args}".to_string(), "--".to_string(),
], "{command}".to_string(),
env_vars: HashMap::new(), "{args}".to_string(),
features: TerminalFeatures { ],
supports_tabs: true, env_vars: HashMap::new(),
supports_splits: true, features: TerminalFeatures {
supports_profiles: true, supports_tabs: true,
supports_themes: true, supports_splits: true,
supports_scripting: true, supports_profiles: true,
supports_url_scheme: false, supports_themes: true,
supports_remote_control: true, supports_scripting: true,
supports_url_scheme: false,
supports_remote_control: true,
},
platform: vec![
"macos".to_string(),
"windows".to_string(),
"linux".to_string(),
],
}, },
platform: vec!["macos".to_string(), "windows".to_string(), "linux".to_string()], );
});
// Ghostty configuration // Ghostty configuration
configs.insert(TerminalEmulator::Ghostty, TerminalConfig { configs.insert(
emulator: TerminalEmulator::Ghostty, TerminalEmulator::Ghostty,
name: "Ghostty".to_string(), TerminalConfig {
executable_path: PathBuf::from("/Applications/Ghostty.app/Contents/MacOS/ghostty"), emulator: TerminalEmulator::Ghostty,
args_template: vec![ name: "Ghostty".to_string(),
"--working-directory".to_string(), executable_path: PathBuf::from("/Applications/Ghostty.app/Contents/MacOS/ghostty"),
"{working_directory}".to_string(), args_template: vec![
"--command".to_string(), "--working-directory".to_string(),
"{command}".to_string(), "{working_directory}".to_string(),
"{args}".to_string(), "--command".to_string(),
], "{command}".to_string(),
env_vars: HashMap::new(), "{args}".to_string(),
features: TerminalFeatures { ],
supports_tabs: true, env_vars: HashMap::new(),
supports_splits: true, features: TerminalFeatures {
supports_profiles: true, supports_tabs: true,
supports_themes: true, supports_splits: true,
supports_scripting: false, supports_profiles: true,
supports_url_scheme: false, supports_themes: true,
supports_remote_control: false, supports_scripting: false,
supports_url_scheme: false,
supports_remote_control: false,
},
platform: vec!["macos".to_string()],
}, },
platform: vec!["macos".to_string()], );
});
// iTerm2 configuration // iTerm2 configuration
configs.insert(TerminalEmulator::ITerm2, TerminalConfig { configs.insert(
emulator: TerminalEmulator::ITerm2, TerminalEmulator::ITerm2,
name: "iTerm2".to_string(), TerminalConfig {
executable_path: PathBuf::from("/Applications/iTerm.app/Contents/MacOS/iTerm2"), emulator: TerminalEmulator::ITerm2,
args_template: vec![], name: "iTerm2".to_string(),
env_vars: HashMap::new(), executable_path: PathBuf::from("/Applications/iTerm.app/Contents/MacOS/iTerm2"),
features: TerminalFeatures { args_template: vec![],
supports_tabs: true, env_vars: HashMap::new(),
supports_splits: true, features: TerminalFeatures {
supports_profiles: true, supports_tabs: true,
supports_themes: true, supports_splits: true,
supports_scripting: true, supports_profiles: true,
supports_url_scheme: true, supports_themes: true,
supports_remote_control: true, supports_scripting: true,
supports_url_scheme: true,
supports_remote_control: true,
},
platform: vec!["macos".to_string()],
}, },
platform: vec!["macos".to_string()], );
});
// Alacritty configuration // Alacritty configuration
configs.insert(TerminalEmulator::Alacritty, TerminalConfig { configs.insert(
emulator: TerminalEmulator::Alacritty, TerminalEmulator::Alacritty,
name: "Alacritty".to_string(), TerminalConfig {
executable_path: PathBuf::from("/Applications/Alacritty.app/Contents/MacOS/alacritty"), emulator: TerminalEmulator::Alacritty,
args_template: vec![ name: "Alacritty".to_string(),
"--working-directory".to_string(), executable_path: PathBuf::from(
"{working_directory}".to_string(), "/Applications/Alacritty.app/Contents/MacOS/alacritty",
"-e".to_string(), ),
"{command}".to_string(), args_template: vec![
"{args}".to_string(), "--working-directory".to_string(),
], "{working_directory}".to_string(),
env_vars: HashMap::new(), "-e".to_string(),
features: TerminalFeatures { "{command}".to_string(),
supports_tabs: false, "{args}".to_string(),
supports_splits: false, ],
supports_profiles: true, env_vars: HashMap::new(),
supports_themes: true, features: TerminalFeatures {
supports_scripting: false, supports_tabs: false,
supports_url_scheme: false, supports_splits: false,
supports_remote_control: false, supports_profiles: true,
supports_themes: true,
supports_scripting: false,
supports_url_scheme: false,
supports_remote_control: false,
},
platform: vec![
"macos".to_string(),
"windows".to_string(),
"linux".to_string(),
],
}, },
platform: vec!["macos".to_string(), "windows".to_string(), "linux".to_string()], );
});
// Kitty configuration // Kitty configuration
configs.insert(TerminalEmulator::Kitty, TerminalConfig { configs.insert(
emulator: TerminalEmulator::Kitty, TerminalEmulator::Kitty,
name: "Kitty".to_string(), TerminalConfig {
executable_path: PathBuf::from("/Applications/kitty.app/Contents/MacOS/kitty"), emulator: TerminalEmulator::Kitty,
args_template: vec![ name: "Kitty".to_string(),
"--directory".to_string(), executable_path: PathBuf::from("/Applications/kitty.app/Contents/MacOS/kitty"),
"{working_directory}".to_string(), args_template: vec![
"{command}".to_string(), "--directory".to_string(),
"{args}".to_string(), "{working_directory}".to_string(),
], "{command}".to_string(),
env_vars: HashMap::new(), "{args}".to_string(),
features: TerminalFeatures { ],
supports_tabs: true, env_vars: HashMap::new(),
supports_splits: true, features: TerminalFeatures {
supports_profiles: true, supports_tabs: true,
supports_themes: true, supports_splits: true,
supports_scripting: true, supports_profiles: true,
supports_url_scheme: false, supports_themes: true,
supports_remote_control: true, supports_scripting: true,
supports_url_scheme: false,
supports_remote_control: true,
},
platform: vec!["macos".to_string(), "linux".to_string()],
}, },
platform: vec!["macos".to_string(), "linux".to_string()], );
});
configs configs
} }
@ -285,12 +313,15 @@ impl TerminalIntegrationsManager {
fn initialize_url_schemes() -> HashMap<TerminalEmulator, TerminalURLScheme> { fn initialize_url_schemes() -> HashMap<TerminalEmulator, TerminalURLScheme> {
let mut schemes = HashMap::new(); let mut schemes = HashMap::new();
schemes.insert(TerminalEmulator::ITerm2, TerminalURLScheme { schemes.insert(
scheme: "iterm2".to_string(), TerminalEmulator::ITerm2,
supports_ssh: true, TerminalURLScheme {
supports_local: true, scheme: "iterm2".to_string(),
template: "iterm2://ssh/{user}@{host}:{port}".to_string(), supports_ssh: true,
}); supports_local: true,
template: "iterm2://ssh/{user}@{host}:{port}".to_string(),
},
);
schemes schemes
} }
@ -304,7 +335,10 @@ impl TerminalIntegrationsManager {
let info = self.check_terminal_installation(emulator, config).await; let info = self.check_terminal_installation(emulator, config).await;
if info.installed { if info.installed {
detected.push(info.clone()); detected.push(info.clone());
self.detected_terminals.write().await.insert(*emulator, info); self.detected_terminals
.write()
.await
.insert(*emulator, info);
} }
} }
@ -316,10 +350,15 @@ impl TerminalIntegrationsManager {
} }
/// Check if a specific terminal is installed /// Check if a specific terminal is installed
async fn check_terminal_installation(&self, emulator: &TerminalEmulator, config: &TerminalConfig) -> TerminalIntegrationInfo { async fn check_terminal_installation(
&self,
emulator: &TerminalEmulator,
config: &TerminalConfig,
) -> TerminalIntegrationInfo {
let installed = config.executable_path.exists(); let installed = config.executable_path.exists();
let version = if installed { let version = if installed {
self.get_terminal_version(emulator, &config.executable_path).await self.get_terminal_version(emulator, &config.executable_path)
.await
} else { } else {
None None
}; };
@ -328,31 +367,39 @@ impl TerminalIntegrationsManager {
emulator: *emulator, emulator: *emulator,
installed, installed,
version, version,
path: if installed { Some(config.executable_path.clone()) } else { None }, path: if installed {
Some(config.executable_path.clone())
} else {
None
},
is_default: false, is_default: false,
config: if installed { Some(config.clone()) } else { None }, config: if installed {
Some(config.clone())
} else {
None
},
} }
} }
/// Get terminal version /// Get terminal version
async fn get_terminal_version(&self, emulator: &TerminalEmulator, path: &PathBuf) -> Option<String> { async fn get_terminal_version(
&self,
emulator: &TerminalEmulator,
path: &PathBuf,
) -> Option<String> {
match emulator { match emulator {
TerminalEmulator::WezTerm => { TerminalEmulator::WezTerm => Command::new(path)
Command::new(path) .arg("--version")
.arg("--version") .output()
.output() .ok()
.ok() .and_then(|output| String::from_utf8(output.stdout).ok())
.and_then(|output| String::from_utf8(output.stdout).ok()) .map(|v| v.trim().to_string()),
.map(|v| v.trim().to_string()) TerminalEmulator::Alacritty => Command::new(path)
} .arg("--version")
TerminalEmulator::Alacritty => { .output()
Command::new(path) .ok()
.arg("--version") .and_then(|output| String::from_utf8(output.stdout).ok())
.output() .map(|v| v.trim().to_string()),
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|v| v.trim().to_string())
}
_ => None, _ => None,
} }
} }
@ -413,10 +460,12 @@ impl TerminalIntegrationsManager {
// Notify user // Notify user
if let Some(notification_manager) = &self.notification_manager { if let Some(notification_manager) = &self.notification_manager {
let _ = notification_manager.notify_success( let _ = notification_manager
"Default Terminal Changed", .notify_success(
&format!("Default terminal set to {}", emulator.display_name()) "Default Terminal Changed",
).await; &format!("Default terminal set to {}", emulator.display_name()),
)
.await;
} }
Ok(()) Ok(())
@ -461,7 +510,8 @@ impl TerminalIntegrationsManager {
options: TerminalLaunchOptions, options: TerminalLaunchOptions,
) -> Result<(), String> { ) -> Result<(), String> {
let configs = self.configs.read().await; let configs = self.configs.read().await;
let config = configs.get(&emulator) let config = configs
.get(&emulator)
.ok_or_else(|| "Terminal configuration not found".to_string())?; .ok_or_else(|| "Terminal configuration not found".to_string())?;
let mut command = Command::new(&config.executable_path); let mut command = Command::new(&config.executable_path);
@ -488,7 +538,8 @@ impl TerminalIntegrationsManager {
} }
// Launch terminal // Launch terminal
command.spawn() command
.spawn()
.map_err(|e| format!("Failed to launch terminal: {}", e))?; .map_err(|e| format!("Failed to launch terminal: {}", e))?;
Ok(()) Ok(())
@ -503,11 +554,16 @@ impl TerminalIntegrationsManager {
script.push_str(" activate\n"); script.push_str(" activate\n");
if options.tab { if options.tab {
script.push_str(" tell application \"System Events\" to keystroke \"t\" using command down\n"); script.push_str(
" tell application \"System Events\" to keystroke \"t\" using command down\n",
);
} }
if let Some(cwd) = options.working_directory { if let Some(cwd) = options.working_directory {
script.push_str(&format!(" do script \"cd '{}'\" in front window\n", cwd.display())); script.push_str(&format!(
" do script \"cd '{}'\" in front window\n",
cwd.display()
));
} }
if let Some(command) = options.command { if let Some(command) = options.command {
@ -516,7 +572,10 @@ impl TerminalIntegrationsManager {
} else { } else {
format!("{} {}", command, options.args.join(" ")) format!("{} {}", command, options.args.join(" "))
}; };
script.push_str(&format!(" do script \"{}\" in front window\n", full_command)); script.push_str(&format!(
" do script \"{}\" in front window\n",
full_command
));
} }
script.push_str("end tell\n"); script.push_str("end tell\n");
@ -552,7 +611,8 @@ impl TerminalIntegrationsManager {
} }
} }
command.spawn() command
.spawn()
.map_err(|e| format!("Failed to launch Windows Terminal: {}", e))?; .map_err(|e| format!("Failed to launch Windows Terminal: {}", e))?;
Ok(()) Ok(())
@ -600,7 +660,8 @@ impl TerminalIntegrationsManager {
} }
} }
return command.spawn() return command
.spawn()
.map_err(|e| format!("Failed to launch terminal: {}", e)) .map_err(|e| format!("Failed to launch terminal: {}", e))
.map(|_| ()); .map(|_| ());
} }
@ -620,7 +681,8 @@ impl TerminalIntegrationsManager {
) -> Option<String> { ) -> Option<String> {
let schemes = self.url_schemes.read().await; let schemes = self.url_schemes.read().await;
schemes.get(&emulator).map(|scheme| { schemes.get(&emulator).map(|scheme| {
scheme.template scheme
.template
.replace("{user}", user) .replace("{user}", user)
.replace("{host}", host) .replace("{host}", host)
.replace("{port}", &port.to_string()) .replace("{port}", &port.to_string())
@ -639,11 +701,20 @@ impl TerminalIntegrationsManager {
/// List detected terminals /// List detected terminals
pub async fn list_detected_terminals(&self) -> Vec<TerminalIntegrationInfo> { pub async fn list_detected_terminals(&self) -> Vec<TerminalIntegrationInfo> {
self.detected_terminals.read().await.values().cloned().collect() self.detected_terminals
.read()
.await
.values()
.cloned()
.collect()
} }
// Helper methods // Helper methods
fn replace_template_variables(&self, template: &str, options: &TerminalLaunchOptions) -> String { fn replace_template_variables(
&self,
template: &str,
options: &TerminalLaunchOptions,
) -> String {
let mut result = template.to_string(); let mut result = template.to_string();
if let Some(cwd) = &options.working_directory { if let Some(cwd) = &options.working_directory {

View file

@ -1,6 +1,6 @@
use tokio::sync::mpsc;
use std::sync::Arc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::mpsc;
/// Request to spawn a terminal /// Request to spawn a terminal
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -23,12 +23,15 @@ pub struct TerminalSpawnResponse {
/// Terminal Spawn Service - manages background terminal spawning /// Terminal Spawn Service - manages background terminal spawning
pub struct TerminalSpawnService { pub struct TerminalSpawnService {
request_tx: mpsc::Sender<TerminalSpawnRequest>, request_tx: mpsc::Sender<TerminalSpawnRequest>,
#[allow(dead_code)]
terminal_integrations_manager: Arc<crate::terminal_integrations::TerminalIntegrationsManager>, terminal_integrations_manager: Arc<crate::terminal_integrations::TerminalIntegrationsManager>,
} }
impl TerminalSpawnService { impl TerminalSpawnService {
pub fn new( pub fn new(
terminal_integrations_manager: Arc<crate::terminal_integrations::TerminalIntegrationsManager>, terminal_integrations_manager: Arc<
crate::terminal_integrations::TerminalIntegrationsManager,
>,
) -> Self { ) -> Self {
let (tx, mut rx) = mpsc::channel::<TerminalSpawnRequest>(100); let (tx, mut rx) = mpsc::channel::<TerminalSpawnRequest>(100);
@ -52,14 +55,18 @@ impl TerminalSpawnService {
/// Queue a terminal spawn request /// Queue a terminal spawn request
pub async fn spawn_terminal(&self, request: TerminalSpawnRequest) -> Result<(), String> { pub async fn spawn_terminal(&self, request: TerminalSpawnRequest) -> Result<(), String> {
self.request_tx.send(request).await self.request_tx
.send(request)
.await
.map_err(|e| format!("Failed to queue terminal spawn: {}", e)) .map_err(|e| format!("Failed to queue terminal spawn: {}", e))
} }
/// Handle a spawn request /// Handle a spawn request
async fn handle_spawn_request( async fn handle_spawn_request(
request: TerminalSpawnRequest, request: TerminalSpawnRequest,
terminal_integrations_manager: Arc<crate::terminal_integrations::TerminalIntegrationsManager>, terminal_integrations_manager: Arc<
crate::terminal_integrations::TerminalIntegrationsManager,
>,
) -> Result<TerminalSpawnResponse, String> { ) -> Result<TerminalSpawnResponse, String> {
// Determine which terminal to use // Determine which terminal to use
let terminal_type = if let Some(terminal) = &request.terminal_type { let terminal_type = if let Some(terminal) = &request.terminal_type {
@ -82,7 +89,9 @@ impl TerminalSpawnService {
// Build launch options // Build launch options
let mut launch_options = crate::terminal_integrations::TerminalLaunchOptions { let mut launch_options = crate::terminal_integrations::TerminalLaunchOptions {
command: request.command, command: request.command,
working_directory: request.working_directory.map(|s| std::path::PathBuf::from(s)), working_directory: request
.working_directory
.map(|s| std::path::PathBuf::from(s)),
args: vec![], args: vec![],
env_vars: request.environment.unwrap_or_default(), env_vars: request.environment.unwrap_or_default(),
title: Some(format!("VibeTunnel Session {}", request.session_id)), title: Some(format!("VibeTunnel Session {}", request.session_id)),
@ -96,11 +105,17 @@ impl TerminalSpawnService {
if launch_options.command.is_none() { if launch_options.command.is_none() {
// Get server status to build the correct URL // Get server status to build the correct URL
let port = 4020; // Default port, should get from settings let port = 4020; // Default port, should get from settings
launch_options.command = Some(format!("vt connect localhost:{}/{}", port, request.session_id)); launch_options.command = Some(format!(
"vt connect localhost:{}/{}",
port, request.session_id
));
} }
// Launch the terminal // Launch the terminal
match terminal_integrations_manager.launch_terminal(Some(terminal_type), launch_options).await { match terminal_integrations_manager
.launch_terminal(Some(terminal_type), launch_options)
.await
{
Ok(_) => Ok(TerminalSpawnResponse { Ok(_) => Ok(TerminalSpawnResponse {
success: true, success: true,
error: None, error: None,
@ -158,7 +173,9 @@ pub async fn spawn_terminal_for_session(
state: tauri::State<'_, crate::state::AppState>, state: tauri::State<'_, crate::state::AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let spawn_service = &state.terminal_spawn_service; let spawn_service = &state.terminal_spawn_service;
spawn_service.spawn_terminal_for_session(session_id, terminal_type).await spawn_service
.spawn_terminal_for_session(session_id, terminal_type)
.await
} }
#[tauri::command] #[tauri::command]
@ -169,7 +186,9 @@ pub async fn spawn_terminal_with_command(
state: tauri::State<'_, crate::state::AppState>, state: tauri::State<'_, crate::state::AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let spawn_service = &state.terminal_spawn_service; let spawn_service = &state.terminal_spawn_service;
spawn_service.spawn_terminal_with_command(command, working_directory, terminal_type).await spawn_service
.spawn_terminal_with_command(command, working_directory, terminal_type)
.await
} }
#[tauri::command] #[tauri::command]

View file

@ -1,5 +1,5 @@
use tauri::menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::AppHandle; use tauri::AppHandle;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder, Menu};
pub struct TrayMenuManager; pub struct TrayMenuManager;
@ -27,9 +27,7 @@ impl TrayMenuManager {
.id("show_tutorial") .id("show_tutorial")
.build(app)?; .build(app)?;
let website = MenuItemBuilder::new("Website") let website = MenuItemBuilder::new("Website").id("website").build(app)?;
.id("website")
.build(app)?;
let report_issue = MenuItemBuilder::new("Report Issue") let report_issue = MenuItemBuilder::new("Report Issue")
.id("report_issue") .id("report_issue")
@ -70,9 +68,7 @@ impl TrayMenuManager {
.build(app)?; .build(app)?;
// Quit // Quit
let quit = MenuItemBuilder::new("Quit") let quit = MenuItemBuilder::new("Quit").id("quit").build(app)?;
.id("quit")
.build(app)?;
// Build the complete menu - matching Mac app exactly // Build the complete menu - matching Mac app exactly
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)

View file

@ -1,13 +1,13 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock, oneshot};
use std::io::{Read, Write};
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use uuid::Uuid;
use tracing::{info, error};
use bytes::Bytes; use bytes::Bytes;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot, RwLock};
use tracing::{error, info};
use uuid::Uuid;
/// Represents a forwarded TTY session /// Represents a forwarded TTY session
pub struct ForwardedSession { pub struct ForwardedSession {
@ -48,7 +48,8 @@ impl TTYForwardManager {
.await .await
.map_err(|e| format!("Failed to bind to port {}: {}", local_port, e))?; .map_err(|e| format!("Failed to bind to port {}: {}", local_port, e))?;
let actual_port = listener.local_addr() let actual_port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {}", e))? .map_err(|e| format!("Failed to get local address: {}", e))?
.port(); .port();
@ -91,7 +92,8 @@ impl TTYForwardManager {
remote_port, remote_port,
shell, shell,
shutdown_rx, shutdown_rx,
).await; )
.await;
}); });
info!("Started TTY forward on port {} (ID: {})", actual_port, id); info!("Started TTY forward on port {} (ID: {})", actual_port, id);
@ -305,7 +307,9 @@ impl TTYForwardManager {
/// List all active forwarding sessions /// List all active forwarding sessions
pub async fn list_forwards(&self) -> Vec<ForwardedSession> { pub async fn list_forwards(&self) -> Vec<ForwardedSession> {
self.sessions.read().await self.sessions
.read()
.await
.values() .values()
.map(|s| ForwardedSession { .map(|s| ForwardedSession {
id: s.id.clone(), id: s.id.clone(),
@ -320,22 +324,23 @@ impl TTYForwardManager {
/// Get a specific forwarding session /// Get a specific forwarding session
pub async fn get_forward(&self, id: &str) -> Option<ForwardedSession> { pub async fn get_forward(&self, id: &str) -> Option<ForwardedSession> {
self.sessions.read().await.get(id).map(|s| ForwardedSession { self.sessions
id: s.id.clone(), .read()
local_port: s.local_port, .await
remote_host: s.remote_host.clone(), .get(id)
remote_port: s.remote_port, .map(|s| ForwardedSession {
connected: s.connected, id: s.id.clone(),
client_count: s.client_count, local_port: s.local_port,
}) remote_host: s.remote_host.clone(),
remote_port: s.remote_port,
connected: s.connected,
client_count: s.client_count,
})
} }
} }
/// HTTP endpoint handler for terminal spawn requests /// HTTP endpoint handler for terminal spawn requests
pub async fn handle_terminal_spawn( pub async fn handle_terminal_spawn(port: u16, _shell: Option<String>) -> Result<(), String> {
port: u16,
_shell: Option<String>,
) -> Result<(), String> {
// Listen for HTTP requests on the specified port // Listen for HTTP requests on the specified port
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)) let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
.await .await
@ -344,7 +349,8 @@ pub async fn handle_terminal_spawn(
info!("Terminal spawn service listening on port {}", port); info!("Terminal spawn service listening on port {}", port);
loop { loop {
let (stream, addr) = listener.accept() let (stream, addr) = listener
.accept()
.await .await
.map_err(|e| format!("Failed to accept spawn connection: {}", e))?; .map_err(|e| format!("Failed to accept spawn connection: {}", e))?;
@ -360,13 +366,11 @@ pub async fn handle_terminal_spawn(
} }
/// Handle a single terminal spawn request /// Handle a single terminal spawn request
async fn handle_spawn_request( async fn handle_spawn_request(mut stream: TcpStream, _shell: Option<String>) -> Result<(), String> {
mut stream: TcpStream,
_shell: Option<String>,
) -> Result<(), String> {
// Simple HTTP response // Simple HTTP response
let response = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nTerminal spawned\r\n"; let response = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nTerminal spawned\r\n";
stream.write_all(response) stream
.write_all(response)
.await .await
.map_err(|e| format!("Failed to write response: {}", e))?; .map_err(|e| format!("Failed to write response: {}", e))?;

View file

@ -1,9 +1,9 @@
use serde::{Serialize, Deserialize}; use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
use chrono::{DateTime, Utc, TimeZone};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
use tokio::sync::RwLock;
/// Update channel type /// Update channel type
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
@ -155,7 +155,10 @@ impl UpdateManager {
} }
/// Set the notification manager /// Set the notification manager
pub fn set_notification_manager(&mut self, notification_manager: Arc<crate::notification_manager::NotificationManager>) { pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager); self.notification_manager = Some(notification_manager);
} }
@ -166,12 +169,13 @@ impl UpdateManager {
let mut updater_settings = self.settings.write().await; let mut updater_settings = self.settings.write().await;
updater_settings.channel = UpdateChannel::from_str(&update_settings.channel); updater_settings.channel = UpdateChannel::from_str(&update_settings.channel);
updater_settings.check_on_startup = true; updater_settings.check_on_startup = true;
updater_settings.check_interval_hours = match update_settings.check_frequency.as_str() { updater_settings.check_interval_hours =
"daily" => 24, match update_settings.check_frequency.as_str() {
"weekly" => 168, "daily" => 24,
"monthly" => 720, "weekly" => 168,
_ => 24, "monthly" => 720,
}; _ => 24,
};
updater_settings.auto_download = update_settings.auto_download; updater_settings.auto_download = update_settings.auto_download;
updater_settings.auto_install = update_settings.auto_install; updater_settings.auto_install = update_settings.auto_install;
updater_settings.show_release_notes = update_settings.show_release_notes; updater_settings.show_release_notes = update_settings.show_release_notes;
@ -229,7 +233,8 @@ impl UpdateManager {
self.emit_update_event("checking", None).await; self.emit_update_event("checking", None).await;
let app_handle_guard = self.app_handle.read().await; let app_handle_guard = self.app_handle.read().await;
let app_handle = app_handle_guard.as_ref() let app_handle = app_handle_guard
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
// Get the updater instance // Get the updater instance
@ -241,13 +246,19 @@ impl UpdateManager {
// Build updater with channel-specific endpoint // Build updater with channel-specific endpoint
let updater_result = match settings.channel { let updater_result = match settings.channel {
UpdateChannel::Stable => updater.endpoints(vec![ UpdateChannel::Stable => updater.endpoints(vec![
"https://releases.vibetunnel.com/stable/{{target}}/{{arch}}/{{current_version}}".parse().unwrap() "https://releases.vibetunnel.com/stable/{{target}}/{{arch}}/{{current_version}}"
.parse()
.unwrap(),
]), ]),
UpdateChannel::Beta => updater.endpoints(vec![ UpdateChannel::Beta => updater.endpoints(vec![
"https://releases.vibetunnel.com/beta/{{target}}/{{arch}}/{{current_version}}".parse().unwrap() "https://releases.vibetunnel.com/beta/{{target}}/{{arch}}/{{current_version}}"
.parse()
.unwrap(),
]), ]),
UpdateChannel::Nightly => updater.endpoints(vec![ UpdateChannel::Nightly => updater.endpoints(vec![
"https://releases.vibetunnel.com/nightly/{{target}}/{{arch}}/{{current_version}}".parse().unwrap() "https://releases.vibetunnel.com/nightly/{{target}}/{{arch}}/{{current_version}}"
.parse()
.unwrap(),
]), ]),
UpdateChannel::Custom => { UpdateChannel::Custom => {
if let Some(endpoint) = &settings.custom_endpoint { if let Some(endpoint) = &settings.custom_endpoint {
@ -265,77 +276,84 @@ impl UpdateManager {
match updater_result { match updater_result {
Ok(updater_builder) => match updater_builder.build() { Ok(updater_builder) => match updater_builder.build() {
Ok(updater) => { Ok(updater) => {
match updater.check().await { match updater.check().await {
Ok(Some(update)) => { Ok(Some(update)) => {
let update_info = UpdateInfo { let update_info = UpdateInfo {
version: update.version.clone(), version: update.version.clone(),
notes: update.body.clone().unwrap_or_default(), notes: update.body.clone().unwrap_or_default(),
pub_date: update.date.map(|d| Utc.timestamp_opt(d.unix_timestamp(), 0).single().unwrap_or(Utc::now())), pub_date: update.date.map(|d| {
download_size: None, // TODO: Get from update Utc.timestamp_opt(d.unix_timestamp(), 0)
signature: None, .single()
download_url: String::new(), // Will be set by updater .unwrap_or(Utc::now())
channel: settings.channel, }),
}; download_size: None, // TODO: Get from update
signature: None,
download_url: String::new(), // Will be set by updater
channel: settings.channel,
};
// Update state // Update state
{ {
let mut state = self.state.write().await;
state.status = UpdateStatus::Available;
state.available_update = Some(update_info.clone());
state.last_check = Some(Utc::now());
}
// Emit available event
self.emit_update_event("available", Some(&update_info))
.await;
// Show notification
if let Some(notification_manager) = &self.notification_manager {
let _ = notification_manager
.notify_update_available(
&update_info.version,
&update_info.download_url,
)
.await;
}
// Auto-download if enabled
if settings.auto_download {
let _ = self.download_update().await;
}
Ok(Some(update_info))
}
Ok(None) => {
// No update available
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.status = UpdateStatus::Available; state.status = UpdateStatus::NoUpdate;
state.available_update = Some(update_info.clone());
state.last_check = Some(Utc::now()); state.last_check = Some(Utc::now());
self.emit_update_event("no-update", None).await;
Ok(None)
} }
Err(e) => {
let error_msg = format!("Failed to check for updates: {}", e);
// Emit available event let mut state = self.state.write().await;
self.emit_update_event("available", Some(&update_info)).await; state.status = UpdateStatus::Error;
state.last_error = Some(error_msg.clone());
state.last_check = Some(Utc::now());
// Show notification self.emit_update_event("error", None).await;
if let Some(notification_manager) = &self.notification_manager {
let _ = notification_manager.notify_update_available( Err(error_msg)
&update_info.version,
&update_info.download_url
).await;
} }
// Auto-download if enabled
if settings.auto_download {
let _ = self.download_update().await;
}
Ok(Some(update_info))
}
Ok(None) => {
// No update available
let mut state = self.state.write().await;
state.status = UpdateStatus::NoUpdate;
state.last_check = Some(Utc::now());
self.emit_update_event("no-update", None).await;
Ok(None)
}
Err(e) => {
let error_msg = format!("Failed to check for updates: {}", e);
let mut state = self.state.write().await;
state.status = UpdateStatus::Error;
state.last_error = Some(error_msg.clone());
state.last_check = Some(Utc::now());
self.emit_update_event("error", None).await;
Err(error_msg)
} }
} }
} Err(e) => {
Err(e) => { let error_msg = format!("Failed to build updater: {}", e);
let error_msg = format!("Failed to build updater: {}", e);
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.status = UpdateStatus::Error; state.status = UpdateStatus::Error;
state.last_error = Some(error_msg.clone()); state.last_error = Some(error_msg.clone());
Err(error_msg) Err(error_msg)
} }
}, },
Err(e) => { Err(e) => {
let error_msg = format!("Failed to configure updater endpoints: {}", e); let error_msg = format!("Failed to configure updater endpoints: {}", e);
@ -487,7 +505,8 @@ impl UpdateManager {
return; return;
} }
let check_interval = std::time::Duration::from_secs(settings.check_interval_hours as u64 * 3600); let check_interval =
std::time::Duration::from_secs(settings.check_interval_hours as u64 * 3600);
drop(settings); drop(settings);
tokio::spawn(async move { tokio::spawn(async move {

View file

@ -1,9 +1,9 @@
use serde::{Serialize, Deserialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use tauri::{AppHandle, Manager, Emitter}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::RwLock;
/// Tutorial step structure /// Tutorial step structure
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -113,7 +113,8 @@ impl WelcomeManager {
// Update settings to reflect welcome state // Update settings to reflect welcome state
if let Ok(mut settings) = crate::settings::Settings::load() { if let Ok(mut settings) = crate::settings::Settings::load() {
settings.general.show_welcome_on_startup = Some(!state.tutorial_completed && !state.tutorial_skipped); settings.general.show_welcome_on_startup =
Some(!state.tutorial_completed && !state.tutorial_skipped);
settings.save().map_err(|e| e.to_string())?; settings.save().map_err(|e| e.to_string())?;
} }
@ -138,7 +139,9 @@ impl WelcomeManager {
/// Get specific tutorial category /// Get specific tutorial category
pub async fn get_tutorial_category(&self, category_id: &str) -> Option<TutorialCategory> { pub async fn get_tutorial_category(&self, category_id: &str) -> Option<TutorialCategory> {
self.tutorials.read().await self.tutorials
.read()
.await
.iter() .iter()
.find(|c| c.id == category_id) .find(|c| c.id == category_id)
.cloned() .cloned()
@ -153,9 +156,7 @@ impl WelcomeManager {
// Check if all steps are completed // Check if all steps are completed
let tutorials = self.tutorials.read().await; let tutorials = self.tutorials.read().await;
let total_steps: usize = tutorials.iter() let total_steps: usize = tutorials.iter().map(|c| c.steps.len()).sum();
.map(|c| c.steps.len())
.sum();
if state.completed_steps.len() >= total_steps { if state.completed_steps.len() >= total_steps {
state.tutorial_completed = true; state.tutorial_completed = true;
@ -212,7 +213,7 @@ impl WelcomeManager {
tauri::WebviewWindowBuilder::new( tauri::WebviewWindowBuilder::new(
app_handle, app_handle,
"welcome", "welcome",
tauri::WebviewUrl::App("welcome.html".into()) tauri::WebviewUrl::App("welcome.html".into()),
) )
.title("Welcome to VibeTunnel") .title("Welcome to VibeTunnel")
.inner_size(800.0, 600.0) .inner_size(800.0, 600.0)
@ -409,9 +410,7 @@ VibeTunnel will be ready whenever you need it."#.to_string(),
let state = self.state.read().await; let state = self.state.read().await;
let tutorials = self.tutorials.read().await; let tutorials = self.tutorials.read().await;
let total_steps: usize = tutorials.iter() let total_steps: usize = tutorials.iter().map(|c| c.steps.len()).sum();
.map(|c| c.steps.len())
.sum();
let completed_steps = state.completed_steps.len(); let completed_steps = state.completed_steps.len();
let percentage = if total_steps > 0 { let percentage = if total_steps > 0 {
@ -424,18 +423,23 @@ VibeTunnel will be ready whenever you need it."#.to_string(),
total_steps, total_steps,
completed_steps, completed_steps,
percentage, percentage,
categories: tutorials.iter().map(|category| { categories: tutorials
let category_completed = category.steps.iter() .iter()
.filter(|s| state.completed_steps.contains(&s.id)) .map(|category| {
.count(); let category_completed = category
.steps
.iter()
.filter(|s| state.completed_steps.contains(&s.id))
.count();
CategoryProgress { CategoryProgress {
category_id: category.id.clone(), category_id: category.id.clone(),
category_name: category.name.clone(), category_name: category.name.clone(),
total_steps: category.steps.len(), total_steps: category.steps.len(),
completed_steps: category_completed, completed_steps: category_completed,
} }
}).collect(), })
.collect(),
} }
} }
} }