diff --git a/tauri/package-lock.json b/tauri/package-lock.json new file mode 100644 index 00000000..30888778 --- /dev/null +++ b/tauri/package-lock.json @@ -0,0 +1,233 @@ +{ + "name": "vibetunnel-tauri", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vibetunnel-tauri", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@tauri-apps/cli": "^2.0.0-rc.18" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.5.0.tgz", + "integrity": "sha512-rAtHqG0Gh/IWLjN2zTf3nZqYqbo81oMbqop56rGTjrlWk9pTTAjkqOjSL9XQLIMZ3RbeVjveCqqCA0s8RnLdMg==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.5.0", + "@tauri-apps/cli-darwin-x64": "2.5.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.5.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.5.0", + "@tauri-apps/cli-linux-arm64-musl": "2.5.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.5.0", + "@tauri-apps/cli-linux-x64-gnu": "2.5.0", + "@tauri-apps/cli-linux-x64-musl": "2.5.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.5.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.5.0", + "@tauri-apps/cli-win32-x64-msvc": "2.5.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz", + "integrity": "sha512-hUF01sC06cZVa8+I0/VtsHOk9BbO75rd+YdtHJ48xTdcYaQ5QIwL4yZz9OR1AKBTaUYhBam8UX9Pvd5V2/4Dpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.5.0.tgz", + "integrity": "sha512-LQKqttsK252LlqYyX8R02MinUsfFcy3+NZiJwHFgi5Y3+ZUIAED9cSxJkyNtuY5KMnR4RlpgWyLv4P6akN1xhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.5.0.tgz", + "integrity": "sha512-mTQufsPcpdHg5RW0zypazMo4L55EfeE5snTzrPqbLX4yCK2qalN7+rnP8O8GT06xhp6ElSP/Ku1M2MR297SByQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-rQO1HhRUQqyEaal5dUVOQruTRda/TD36s9kv1hTxZiFuSq3558lsTjAcUEnMAtBcBkps20sbyTJNMT0AwYIk8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.5.0.tgz", + "integrity": "sha512-7oS18FN46yDxyw1zX/AxhLAd7T3GrLj3Ai6s8hZKd9qFVzrAn36ESL7d3G05s8wEtsJf26qjXnVF4qleS3dYsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.5.0.tgz", + "integrity": "sha512-SG5sFNL7VMmDBdIg3nO3EzNRT306HsiEQ0N90ILe3ZABYAVoPDO/ttpCO37ApLInTzrq/DLN+gOlC/mgZvLw1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-QXDM8zp/6v05PNWju5ELsVwF0VH1n6b5pk2E6W/jFbbiwz80Vs1lACl9pv5kEHkrxBj+aWU/03JzGuIj2g3SkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.5.0.tgz", + "integrity": "sha512-pFSHFK6b+o9y4Un8w0gGLwVyFTZaC3P0kQ7umRt/BLDkzD5RnQ4vBM7CF8BCU5nkwmEBUCZd7Wt3TWZxe41o6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.5.0.tgz", + "integrity": "sha512-EArv1IaRlogdLAQyGlKmEqZqm5RfHCUMhJoedWu7GtdbOMUfSAz6FMX2boE1PtEmNO4An+g188flLeVErrxEKg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.5.0.tgz", + "integrity": "sha512-lj43EFYbnAta8pd9JnUq87o+xRUR0odz+4rixBtTUwUgdRdwQ2V9CzFtsMu6FQKpFQ6mujRK6P1IEwhL6ADRsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/tauri/public/server-console.html b/tauri/public/server-console.html new file mode 100644 index 00000000..6ee0dd06 --- /dev/null +++ b/tauri/public/server-console.html @@ -0,0 +1,634 @@ + + + + + + Server Console - VibeTunnel + + + +
+
+
+ Server Console + Port 4020 +
+
+ + + + +
+
+ +
+ +
+ + + + + +
+
+ +
+
+
+
+ Connecting to server... +
+ +
+
+ + + + + + \ No newline at end of file diff --git a/tauri/public/settings.html b/tauri/public/settings.html new file mode 100644 index 00000000..806c85dc --- /dev/null +++ b/tauri/public/settings.html @@ -0,0 +1,1286 @@ + + + + + + VibeTunnel Settings + + + +
+ +
+
+ + + + + General +
+
+ + + + Dashboard +
+
+ + + + Advanced +
+ +
+ + + + About +
+
+ + +
+ +
+
+

Startup

+
+ +

Automatically start VibeTunnel when you log in to your computer

+
+
+ +
+

Updates

+
+ + +

Choose which release channel to receive updates from

+
+
+ + +
+
+ +
+

System Permissions

+
+ VibeTunnel requires certain permissions to function properly. Grant these permissions to enable all features. +
+
+ +
+
+
+ + +
+
+

Dashboard Security

+
+ +

Require a password to access the terminal dashboard

+
+ +
+ +
+

Server Configuration

+
+ + +

The port VibeTunnel's HTTP server will listen on

+
+
+ + +

Control who can access your terminal dashboard

+
+ +
+ +
+

Remote Access

+
+ +

Create a secure tunnel to access your terminals from anywhere

+
+ +
+
+ + +
+
+

Terminal Settings

+
+ + +

Choose your preferred terminal emulator

+
+
+ + +

Shell to use for new terminal sessions

+
+
+ +
+

CLI Tool

+
+ The vt command lets you quickly create terminal sessions from your existing terminal. +
+
+
+ + +
+ +
+
+ +
+

Session Management

+
+ +

Remove all terminal sessions when VibeTunnel starts

+
+
+ + +

Automatically close idle sessions (0 = disabled)

+
+
+ +
+

Display

+
+ +

Display the VibeTunnel icon in the macOS Dock

+
+
+ +

Show debug options and additional logging

+
+
+
+ + +
+
+

Server Status

+
+
Server: Running
+
Port: 4020
+
Mode: Rust
+
Sessions: 0
+
+
+ + +
+
+ +
+

API Testing

+
+ + +
+
+ +
+ +
+ +
+

Server Console

+
+ + +
+
+
Server started on port 4020
+
Health check: OK
+ +
+
+ +
+

Developer Tools

+
+ + +
+
+ +

Help improve VibeTunnel by sending anonymous usage data

+
+
+
+ + +
+
+ VibeTunnel +

VibeTunnel

+

Version 1.0.0

+ +
+ + + +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index 5955fee2..6308cd85 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -82,13 +82,20 @@ reqwest = { version = "0.12", features = ["json"] } base64 = "0.22" sha2 = "0.10" +# Debug features +num_cpus = "1" + +# Network utilities +[target.'cfg(unix)'.dependencies] +nix = { version = "0.27", features = ["net"] } + +[target.'cfg(windows)'.dependencies] +ipconfig = "0.3" +windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2.0.1" -# Platform-specific dependencies -[target.'cfg(windows)'.dependencies] -windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } - [profile.release] panic = "abort" codegen-units = 1 diff --git a/tauri/src-tauri/public/icon.png b/tauri/src-tauri/public/icon.png new file mode 100644 index 00000000..1a5ee251 Binary files /dev/null and b/tauri/src-tauri/public/icon.png differ diff --git a/tauri/src-tauri/public/server-console.html b/tauri/src-tauri/public/server-console.html new file mode 100644 index 00000000..6ee0dd06 --- /dev/null +++ b/tauri/src-tauri/public/server-console.html @@ -0,0 +1,634 @@ + + + + + + Server Console - VibeTunnel + + + +
+
+
+ Server Console + Port 4020 +
+
+ + + + +
+
+ +
+ +
+ + + + + +
+
+ +
+
+
+
+ Connecting to server... +
+ +
+
+ + + + + + \ No newline at end of file diff --git a/tauri/src-tauri/public/settings.html b/tauri/src-tauri/public/settings.html new file mode 100644 index 00000000..806c85dc --- /dev/null +++ b/tauri/src-tauri/public/settings.html @@ -0,0 +1,1286 @@ + + + + + + VibeTunnel Settings + + + +
+ +
+
+ + + + + General +
+
+ + + + Dashboard +
+
+ + + + Advanced +
+ +
+ + + + About +
+
+ + +
+ +
+
+

Startup

+
+ +

Automatically start VibeTunnel when you log in to your computer

+
+
+ +
+

Updates

+
+ + +

Choose which release channel to receive updates from

+
+
+ + +
+
+ +
+

System Permissions

+
+ VibeTunnel requires certain permissions to function properly. Grant these permissions to enable all features. +
+
+ +
+
+
+ + +
+
+

Dashboard Security

+
+ +

Require a password to access the terminal dashboard

+
+ +
+ +
+

Server Configuration

+
+ + +

The port VibeTunnel's HTTP server will listen on

+
+
+ + +

Control who can access your terminal dashboard

+
+ +
+ +
+

Remote Access

+
+ +

Create a secure tunnel to access your terminals from anywhere

+
+ +
+
+ + +
+
+

Terminal Settings

+
+ + +

Choose your preferred terminal emulator

+
+
+ + +

Shell to use for new terminal sessions

+
+
+ +
+

CLI Tool

+
+ The vt command lets you quickly create terminal sessions from your existing terminal. +
+
+
+ + +
+ +
+
+ +
+

Session Management

+
+ +

Remove all terminal sessions when VibeTunnel starts

+
+
+ + +

Automatically close idle sessions (0 = disabled)

+
+
+ +
+

Display

+
+ +

Display the VibeTunnel icon in the macOS Dock

+
+
+ +

Show debug options and additional logging

+
+
+
+ + +
+
+

Server Status

+
+
Server: Running
+
Port: 4020
+
Mode: Rust
+
Sessions: 0
+
+
+ + +
+
+ +
+

API Testing

+
+ + +
+
+ +
+ +
+ +
+

Server Console

+
+ + +
+
+
Server started on port 4020
+
Health check: OK
+ +
+
+ +
+

Developer Tools

+
+ + +
+
+ +

Help improve VibeTunnel by sending anonymous usage data

+
+
+
+ + +
+
+ VibeTunnel +

VibeTunnel

+

Version 1.0.0

+ +
+ + + +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/tauri/src-tauri/public/welcome.html b/tauri/src-tauri/public/welcome.html new file mode 100644 index 00000000..9a3ec713 --- /dev/null +++ b/tauri/src-tauri/public/welcome.html @@ -0,0 +1,417 @@ + + + + + + Welcome to VibeTunnel + + + +
+
+ +
+ VibeTunnel +
+

Welcome to VibeTunnel

+

Turn any browser into your terminal. Command your agents on the go.

+

+ You'll be quickly guided through the basics of VibeTunnel.
+ This screen can always be opened from the settings. +

+
+
+ + +
+ VibeTunnel +
+

What VibeTunnel Does

+

A secure terminal server that runs on your machine

+
+
+ + + + Access your terminal from any web browser +
+
+ + + + Create multiple isolated terminal sessions +
+
+ + + + Secure with password protection +
+
+ + + + Works with ngrok or Tailscale for remote access +
+
+
+
+ + +
+ VibeTunnel +
+

Accessing Your Dashboard

+

+ To access your terminals from any device, create a tunnel from your device.

+ This can be done via ngrok in settings or Tailscale (recommended). +

+
+ + +
+
+
+ + +
+ VibeTunnel +
+

You're All Set!

+

VibeTunnel is now running in your system tray

+

+ Click the VibeTunnel icon in your system tray to access settings,
+ open the dashboard, or manage your terminal sessions. +

+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/tauri/src-tauri/src/api_testing.rs b/tauri/src-tauri/src/api_testing.rs new file mode 100644 index 00000000..d221e997 --- /dev/null +++ b/tauri/src-tauri/src/api_testing.rs @@ -0,0 +1,648 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use std::time::Duration; + +/// API test method +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum HttpMethod { + GET, + POST, + PUT, + PATCH, + DELETE, + HEAD, + OPTIONS, +} + +impl HttpMethod { + pub fn as_str(&self) -> &str { + match self { + HttpMethod::GET => "GET", + HttpMethod::POST => "POST", + HttpMethod::PUT => "PUT", + HttpMethod::PATCH => "PATCH", + HttpMethod::DELETE => "DELETE", + HttpMethod::HEAD => "HEAD", + HttpMethod::OPTIONS => "OPTIONS", + } + } +} + +/// API test assertion type +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AssertionType { + StatusCode(u16), + StatusRange { min: u16, max: u16 }, + ResponseTime { max_ms: u64 }, + HeaderExists(String), + HeaderEquals { key: String, value: String }, + JsonPath { path: String, expected: serde_json::Value }, + BodyContains(String), + BodyMatches(String), // Regex + ContentType(String), +} + +/// API test case +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITest { + pub id: String, + pub name: String, + pub description: Option, + pub group: Option, + pub endpoint_url: String, + pub method: HttpMethod, + pub headers: HashMap, + pub query_params: HashMap, + pub body: Option, + pub auth: Option, + pub assertions: Vec, + pub timeout_ms: u64, + pub retry_count: u32, + pub delay_ms: Option, + pub save_response: bool, +} + +/// API test body +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum APITestBody { + Json(serde_json::Value), + Form(HashMap), + Text(String), + Binary(Vec), +} + +/// API test authentication +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum APITestAuth { + Basic { username: String, password: String }, + Bearer(String), + ApiKey { key: String, value: String, in_header: bool }, + Custom(HashMap), +} + +/// API test result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestResult { + pub test_id: String, + pub test_name: String, + pub success: bool, + pub timestamp: DateTime, + pub duration_ms: u64, + pub status_code: Option, + pub response_headers: HashMap, + pub response_body: Option, + pub assertion_results: Vec, + pub error: Option, + pub retries_used: u32, +} + +/// Assertion result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssertionResult { + pub assertion: AssertionType, + pub passed: bool, + pub actual_value: Option, + pub error_message: Option, +} + +/// API test suite +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestSuite { + pub id: String, + pub name: String, + pub description: Option, + pub base_url: Option, + pub default_headers: HashMap, + pub default_auth: Option, + pub tests: Vec, + pub setup_tests: Vec, + pub teardown_tests: Vec, + pub variables: HashMap, +} + +/// API test collection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestCollection { + pub id: String, + pub name: String, + pub suites: Vec, + pub global_variables: HashMap, +} + +/// API test runner configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestRunnerConfig { + pub parallel_execution: bool, + pub max_parallel_tests: usize, + pub stop_on_failure: bool, + pub capture_responses: bool, + pub follow_redirects: bool, + pub verify_ssl: bool, + pub proxy: Option, + pub environment_variables: HashMap, +} + +impl Default for APITestRunnerConfig { + fn default() -> Self { + Self { + parallel_execution: false, + max_parallel_tests: 5, + stop_on_failure: false, + capture_responses: true, + follow_redirects: true, + verify_ssl: true, + proxy: None, + environment_variables: HashMap::new(), + } + } +} + +/// API test history entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestHistoryEntry { + pub run_id: String, + pub timestamp: DateTime, + pub suite_name: String, + pub total_tests: usize, + pub passed_tests: usize, + pub failed_tests: usize, + pub total_duration_ms: u64, + pub results: Vec, +} + +/// API testing manager +pub struct APITestingManager { + client: Arc, + config: Arc>, + test_suites: Arc>>, + test_history: Arc>>, + running_tests: Arc>>, + shared_variables: Arc>>, + notification_manager: Option>, +} + +impl APITestingManager { + /// Create a new API testing manager + pub fn new() -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + + Self { + client: Arc::new(client), + config: Arc::new(RwLock::new(APITestRunnerConfig::default())), + test_suites: Arc::new(RwLock::new(HashMap::new())), + test_history: Arc::new(RwLock::new(Vec::new())), + running_tests: Arc::new(RwLock::new(HashMap::new())), + shared_variables: Arc::new(RwLock::new(HashMap::new())), + notification_manager: None, + } + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Get configuration + pub async fn get_config(&self) -> APITestRunnerConfig { + self.config.read().await.clone() + } + + /// Update configuration + pub async fn update_config(&self, config: APITestRunnerConfig) { + *self.config.write().await = config; + } + + /// Add test suite + pub async fn add_test_suite(&self, suite: APITestSuite) { + self.test_suites.write().await.insert(suite.id.clone(), suite); + } + + /// Get test suite + pub async fn get_test_suite(&self, suite_id: &str) -> Option { + self.test_suites.read().await.get(suite_id).cloned() + } + + /// List test suites + pub async fn list_test_suites(&self) -> Vec { + self.test_suites.read().await.values().cloned().collect() + } + + /// Run single test + pub async fn run_test(&self, test: &APITest, variables: &HashMap) -> APITestResult { + let start_time = std::time::Instant::now(); + let mut result = APITestResult { + test_id: test.id.clone(), + test_name: test.name.clone(), + success: false, + timestamp: Utc::now(), + duration_ms: 0, + status_code: None, + response_headers: HashMap::new(), + response_body: None, + assertion_results: Vec::new(), + error: None, + retries_used: 0, + }; + + // Replace variables in URL + let url = self.replace_variables(&test.endpoint_url, variables); + + // Run test with retries + let mut last_error = None; + for retry in 0..=test.retry_count { + if retry > 0 { + // Delay between retries + if let Some(delay) = test.delay_ms { + tokio::time::sleep(Duration::from_millis(delay)).await; + } + } + + match self.execute_request(&test, &url, variables).await { + Ok((status, headers, body)) => { + result.status_code = Some(status); + result.response_headers = headers; + if test.save_response { + result.response_body = Some(body.clone()); + } + result.retries_used = retry; + + // Run assertions + result.assertion_results = self.run_assertions(&test.assertions, status, &result.response_headers, &body).await; + result.success = result.assertion_results.iter().all(|a| a.passed); + + break; + } + Err(e) => { + last_error = Some(e); + } + } + } + + if let Some(error) = last_error { + result.error = Some(error); + } + + result.duration_ms = start_time.elapsed().as_millis() as u64; + result + } + + /// Run test suite + pub async fn run_test_suite(&self, suite_id: &str) -> Option { + let suite = self.get_test_suite(suite_id).await?; + let run_id = uuid::Uuid::new_v4().to_string(); + let start_time = std::time::Instant::now(); + + // Merge variables + let mut variables = self.shared_variables.read().await.clone(); + variables.extend(suite.variables.clone()); + + let mut results = Vec::new(); + + // Run setup tests + for test in &suite.setup_tests { + let result = self.run_test(test, &variables).await; + if !result.success && self.config.read().await.stop_on_failure { + break; + } + results.push(result); + } + + // Run main tests + let config = self.config.read().await; + if config.parallel_execution { + // Run tests in parallel + let mut tasks = Vec::new(); + for test in &suite.tests { + let test = test.clone(); + let vars = variables.clone(); + let manager = self.clone_for_parallel(); + + tasks.push(tokio::spawn(async move { + manager.run_test(&test, &vars).await + })); + } + + for task in tasks { + if let Ok(result) = task.await { + results.push(result); + } + } + } else { + // Run tests sequentially + for test in &suite.tests { + let result = self.run_test(test, &variables).await; + if !result.success && config.stop_on_failure { + break; + } + results.push(result); + } + } + + // Run teardown tests + for test in &suite.teardown_tests { + let result = self.run_test(test, &variables).await; + results.push(result); + } + + let total_duration = start_time.elapsed().as_millis() as u64; + let passed = results.iter().filter(|r| r.success).count(); + let failed = results.len() - passed; + + let history_entry = APITestHistoryEntry { + run_id, + timestamp: Utc::now(), + suite_name: suite.name, + total_tests: results.len(), + passed_tests: passed, + failed_tests: failed, + total_duration_ms: total_duration, + results, + }; + + // Store in history + self.test_history.write().await.push(history_entry.clone()); + + // Send notification + if let Some(notification_manager) = &self.notification_manager { + let message = format!( + "Test suite completed: {} passed, {} failed", + passed, failed + ); + let _ = notification_manager.notify_success("API Tests", &message).await; + } + + Some(history_entry) + } + + /// Get test history + pub async fn get_test_history(&self, limit: Option) -> Vec { + let history = self.test_history.read().await; + match limit { + Some(n) => history.iter().rev().take(n).cloned().collect(), + None => history.clone(), + } + } + + /// Clear test history + pub async fn clear_test_history(&self) { + self.test_history.write().await.clear(); + } + + /// Import Postman collection + pub async fn import_postman_collection(&self, _json_data: &str) -> Result { + // TODO: Implement Postman collection import + Err("Postman import not yet implemented".to_string()) + } + + /// Export test suite + pub async fn export_test_suite(&self, suite_id: &str) -> Result { + let suite = self.get_test_suite(suite_id).await + .ok_or_else(|| "Test suite not found".to_string())?; + + serde_json::to_string_pretty(&suite) + .map_err(|e| format!("Failed to serialize test suite: {}", e)) + } + + // Helper methods + async fn execute_request( + &self, + test: &APITest, + url: &str, + variables: &HashMap, + ) -> Result<(u16, HashMap, String), String> { + let config = self.config.read().await; + let client = Client::builder() + .timeout(Duration::from_millis(test.timeout_ms)) + .redirect(if config.follow_redirects { + reqwest::redirect::Policy::default() + } else { + reqwest::redirect::Policy::none() + }) + .danger_accept_invalid_certs(!config.verify_ssl) + .build() + .map_err(|e| e.to_string())?; + + let mut request = match test.method { + HttpMethod::GET => client.get(url), + HttpMethod::POST => client.post(url), + HttpMethod::PUT => client.put(url), + HttpMethod::PATCH => client.patch(url), + HttpMethod::DELETE => client.delete(url), + HttpMethod::HEAD => client.head(url), + HttpMethod::OPTIONS => client.request(reqwest::Method::OPTIONS, url), + }; + + // Add headers + for (key, value) in &test.headers { + let value = self.replace_variables(value, variables); + request = request.header(key, value); + } + + // Add query params + for (key, value) in &test.query_params { + let value = self.replace_variables(value, variables); + request = request.query(&[(key, value)]); + } + + // Add auth + if let Some(auth) = &test.auth { + request = self.apply_auth(request, auth, variables); + } + + // Add body + if let Some(body) = &test.body { + request = match body { + APITestBody::Json(json) => request.json(json), + APITestBody::Form(form) => request.form(form), + APITestBody::Text(text) => request.body(text.clone()), + APITestBody::Binary(bytes) => request.body(bytes.clone()), + }; + } + + // Execute request + let response = request.send().await.map_err(|e| e.to_string())?; + let status = response.status().as_u16(); + + let mut headers = HashMap::new(); + for (key, value) in response.headers() { + if let Ok(value_str) = value.to_str() { + headers.insert(key.to_string(), value_str.to_string()); + } + } + + let body = response.text().await.unwrap_or_default(); + + Ok((status, headers, body)) + } + + async fn run_assertions( + &self, + assertions: &[AssertionType], + status: u16, + headers: &HashMap, + body: &str, + ) -> Vec { + let mut results = Vec::new(); + + for assertion in assertions { + let result = match assertion { + AssertionType::StatusCode(expected) => { + AssertionResult { + assertion: assertion.clone(), + passed: status == *expected, + actual_value: Some(status.to_string()), + error_message: if status != *expected { + Some(format!("Expected status {}, got {}", expected, status)) + } else { + None + }, + } + } + AssertionType::StatusRange { min, max } => { + AssertionResult { + assertion: assertion.clone(), + passed: status >= *min && status <= *max, + actual_value: Some(status.to_string()), + error_message: if status < *min || status > *max { + Some(format!("Expected status between {} and {}, got {}", min, max, status)) + } else { + None + }, + } + } + AssertionType::HeaderExists(key) => { + AssertionResult { + assertion: assertion.clone(), + passed: headers.contains_key(key), + actual_value: None, + error_message: if !headers.contains_key(key) { + Some(format!("Header '{}' not found", key)) + } else { + None + }, + } + } + AssertionType::HeaderEquals { key, value } => { + let actual = headers.get(key); + AssertionResult { + assertion: assertion.clone(), + passed: actual == Some(value), + actual_value: actual.cloned(), + error_message: if actual != Some(value) { + Some(format!("Header '{}' expected '{}', got '{:?}'", key, value, actual)) + } else { + None + }, + } + } + AssertionType::BodyContains(text) => { + AssertionResult { + assertion: assertion.clone(), + passed: body.contains(text), + actual_value: None, + error_message: if !body.contains(text) { + Some(format!("Body does not contain '{}'", text)) + } else { + None + }, + } + } + AssertionType::JsonPath { path: _, expected: _ } => { + // TODO: Implement JSON path assertion + AssertionResult { + assertion: assertion.clone(), + passed: false, + actual_value: None, + error_message: Some("JSON path assertions not yet implemented".to_string()), + } + } + _ => { + AssertionResult { + assertion: assertion.clone(), + passed: false, + actual_value: None, + error_message: Some("Assertion type not implemented".to_string()), + } + } + }; + results.push(result); + } + + results + } + + fn replace_variables(&self, text: &str, variables: &HashMap) -> String { + let mut result = text.to_string(); + for (key, value) in variables { + result = result.replace(&format!("{{{{{}}}}}", key), value); + } + result + } + + fn apply_auth( + &self, + request: reqwest::RequestBuilder, + auth: &APITestAuth, + variables: &HashMap, + ) -> reqwest::RequestBuilder { + match auth { + APITestAuth::Basic { username, password } => { + let username = self.replace_variables(username, variables); + let password = self.replace_variables(password, variables); + request.basic_auth(username, Some(password)) + } + APITestAuth::Bearer(token) => { + let token = self.replace_variables(token, variables); + request.bearer_auth(token) + } + APITestAuth::ApiKey { key, value, in_header } => { + let key = self.replace_variables(key, variables); + let value = self.replace_variables(value, variables); + if *in_header { + request.header(key, value) + } else { + request.query(&[(key, value)]) + } + } + APITestAuth::Custom(headers) => { + let mut req = request; + for (key, value) in headers { + let value = self.replace_variables(value, variables); + req = req.header(key, value); + } + req + } + } + } + + fn clone_for_parallel(&self) -> Self { + Self { + client: self.client.clone(), + config: self.config.clone(), + test_suites: self.test_suites.clone(), + test_history: self.test_history.clone(), + running_tests: self.running_tests.clone(), + shared_variables: self.shared_variables.clone(), + notification_manager: self.notification_manager.clone(), + } + } +} + +/// API test statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestStatistics { + pub total_suites: usize, + pub total_tests: usize, + pub total_runs: usize, + pub success_rate: f64, + pub average_duration_ms: f64, + pub most_failed_tests: Vec<(String, usize)>, + pub slowest_tests: Vec<(String, u64)>, +} \ No newline at end of file diff --git a/tauri/src-tauri/src/app_mover.rs b/tauri/src-tauri/src/app_mover.rs new file mode 100644 index 00000000..3444ef1f --- /dev/null +++ b/tauri/src-tauri/src/app_mover.rs @@ -0,0 +1,172 @@ +use tauri::{AppHandle, Manager}; +use std::path::PathBuf; + +/// Check if the app should be moved to Applications folder +/// This is a macOS-specific feature +#[cfg(target_os = "macos")] +pub async fn check_and_prompt_move(app_handle: AppHandle) -> Result<(), String> { + use std::process::Command; + + // Get current app bundle path + let bundle_path = get_app_bundle_path()?; + + // Check if already in Applications folder + if is_in_applications_folder(&bundle_path) { + return Ok(()); + } + + // Check if we've already asked this question + let settings = crate::settings::Settings::load().unwrap_or_default(); + if let Some(asked) = settings.general.show_welcome_on_startup { + if !asked { + // User has already been asked, don't ask again + return Ok(()); + } + } + + // Show dialog asking if user wants to move to Applications + let response = tauri::api::dialog::blocking::ask( + Some(&app_handle.get_webview_window("main").unwrap()), + "Move to Applications Folder?", + "VibeTunnel works best when run from the Applications folder. Would you like to move it there?" + ); + + if response { + move_to_applications_folder(bundle_path)?; + + // Restart the app from the new location + restart_from_applications()?; + } + + // Update settings to not ask again + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.general.show_welcome_on_startup = Some(false); + settings.save().ok(); + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +pub async fn check_and_prompt_move(_app_handle: AppHandle) -> Result<(), String> { + // Not applicable on other platforms + Ok(()) +} + +#[cfg(target_os = "macos")] +fn get_app_bundle_path() -> Result { + use std::env; + + // Get the executable path + let exe_path = env::current_exe() + .map_err(|e| format!("Failed to get executable path: {}", e))?; + + // Navigate up to the .app bundle + // Typical structure: /path/to/VibeTunnel.app/Contents/MacOS/VibeTunnel + let mut bundle_path = exe_path; + + // Go up three levels to reach the .app bundle + for _ in 0..3 { + bundle_path = bundle_path.parent() + .ok_or("Failed to find app bundle")? + .to_path_buf(); + } + + // Verify this is an .app bundle + if !bundle_path.to_string_lossy().ends_with(".app") { + return Err("Not running from an app bundle".to_string()); + } + + Ok(bundle_path) +} + +#[cfg(target_os = "macos")] +fn is_in_applications_folder(bundle_path: &PathBuf) -> bool { + let path_str = bundle_path.to_string_lossy(); + path_str.contains("/Applications/") || path_str.contains("/System/Applications/") +} + +#[cfg(target_os = "macos")] +fn move_to_applications_folder(bundle_path: PathBuf) -> Result<(), String> { + use std::process::Command; + use std::fs; + + let app_name = bundle_path.file_name() + .ok_or("Failed to get app name")? + .to_string_lossy(); + + let dest_path = PathBuf::from("/Applications").join(&app_name); + + // Check if destination already exists + if dest_path.exists() { + // Ask user if they want to replace + let response = tauri::api::dialog::blocking::ask( + None, + "Replace Existing App?", + "VibeTunnel already exists in the Applications folder. Do you want to replace it?" + ); + + if !response { + return Err("User cancelled move operation".to_string()); + } + + // Remove existing app + fs::remove_dir_all(&dest_path) + .map_err(|e| format!("Failed to remove existing app: {}", e))?; + } + + // Use AppleScript to move the app with proper permissions + let script = format!( + r#"tell application "Finder" + move (POSIX file "{}") to (POSIX file "/Applications/") with replacing + end tell"#, + bundle_path.to_string_lossy() + ); + + let output = Command::new("osascript") + .arg("-e") + .arg(script) + .output() + .map_err(|e| format!("Failed to execute move command: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to move app: {}", error)); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn restart_from_applications() -> Result<(), String> { + use std::process::Command; + + // Launch the app from the Applications folder + let output = Command::new("open") + .arg("-n") + .arg("/Applications/VibeTunnel.app") + .spawn() + .map_err(|e| format!("Failed to restart app: {}", e))?; + + // Exit the current instance + std::process::exit(0); +} + +#[tauri::command] +pub async fn prompt_move_to_applications(app_handle: AppHandle) -> Result<(), String> { + check_and_prompt_move(app_handle).await +} + +#[tauri::command] +pub async fn is_in_applications_folder() -> Result { + #[cfg(target_os = "macos")] + { + let bundle_path = get_app_bundle_path()?; + Ok(is_in_applications_folder(&bundle_path)) + } + + #[cfg(not(target_os = "macos"))] + { + // Always return true on non-macOS platforms + Ok(true) + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/auth_cache.rs b/tauri/src-tauri/src/auth_cache.rs new file mode 100644 index 00000000..8f25be25 --- /dev/null +++ b/tauri/src-tauri/src/auth_cache.rs @@ -0,0 +1,483 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use chrono::{DateTime, Utc, Duration}; +use sha2::{Sha256, Digest}; + +/// Authentication token type +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TokenType { + Bearer, + Basic, + ApiKey, + OAuth2, + JWT, + Custom, +} + +/// Authentication scope +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct AuthScope { + pub service: String, + pub resource: Option, + pub permissions: Vec, +} + +/// Cached authentication token +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedToken { + pub token_type: TokenType, + pub token_value: String, + pub scope: AuthScope, + pub created_at: DateTime, + pub expires_at: Option>, + pub refresh_token: Option, + pub metadata: HashMap, +} + +impl CachedToken { + /// Check if token is expired + pub fn is_expired(&self) -> bool { + if let Some(expires_at) = self.expires_at { + Utc::now() >= expires_at + } else { + false + } + } + + /// Check if token needs refresh (expires within threshold) + pub fn needs_refresh(&self, threshold_seconds: i64) -> bool { + if let Some(expires_at) = self.expires_at { + let refresh_time = expires_at - Duration::seconds(threshold_seconds); + Utc::now() >= refresh_time + } else { + false + } + } + + /// Get remaining lifetime in seconds + pub fn remaining_lifetime_seconds(&self) -> Option { + self.expires_at.map(|expires_at| { + let duration = expires_at - Utc::now(); + duration.num_seconds().max(0) + }) + } +} + +/// Authentication credential +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthCredential { + pub credential_type: String, + pub username: Option, + pub password_hash: Option, // Store hashed password + pub api_key: Option, + pub client_id: Option, + pub client_secret: Option, + pub metadata: HashMap, +} + +/// Authentication cache entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthCacheEntry { + pub key: String, + pub tokens: Vec, + pub credential: Option, + pub last_accessed: DateTime, + pub access_count: u64, +} + +/// Authentication cache configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthCacheConfig { + pub enabled: bool, + pub max_entries: usize, + pub default_ttl_seconds: u64, + pub refresh_threshold_seconds: i64, + pub persist_to_disk: bool, + pub encryption_enabled: bool, + pub cleanup_interval_seconds: u64, +} + +impl Default for AuthCacheConfig { + fn default() -> Self { + Self { + enabled: true, + max_entries: 1000, + default_ttl_seconds: 3600, // 1 hour + refresh_threshold_seconds: 300, // 5 minutes + persist_to_disk: false, + encryption_enabled: true, + cleanup_interval_seconds: 600, // 10 minutes + } + } +} + +/// Authentication cache statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthCacheStats { + pub total_entries: usize, + pub total_tokens: usize, + pub expired_tokens: usize, + pub cache_hits: u64, + pub cache_misses: u64, + pub refresh_count: u64, + pub eviction_count: u64, +} + +/// Token refresh callback +pub type TokenRefreshCallback = Arc futures::future::BoxFuture<'static, Result> + Send + Sync>; + +/// Authentication cache manager +pub struct AuthCacheManager { + config: Arc>, + cache: Arc>>, + stats: Arc>, + refresh_callbacks: Arc>>, + cleanup_handle: Arc>>>, + notification_manager: Option>, +} + +impl AuthCacheManager { + /// Create a new authentication cache manager + pub fn new() -> Self { + let manager = Self { + config: Arc::new(RwLock::new(AuthCacheConfig::default())), + cache: Arc::new(RwLock::new(HashMap::new())), + stats: Arc::new(RwLock::new(AuthCacheStats { + total_entries: 0, + total_tokens: 0, + expired_tokens: 0, + cache_hits: 0, + cache_misses: 0, + refresh_count: 0, + eviction_count: 0, + })), + refresh_callbacks: Arc::new(RwLock::new(HashMap::new())), + cleanup_handle: Arc::new(RwLock::new(None)), + notification_manager: None, + }; + + // Start cleanup task + let cleanup_manager = manager.clone_for_cleanup(); + tokio::spawn(async move { + cleanup_manager.start_cleanup_task().await; + }); + + manager + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Get configuration + pub async fn get_config(&self) -> AuthCacheConfig { + self.config.read().await.clone() + } + + /// Update configuration + pub async fn update_config(&self, config: AuthCacheConfig) { + *self.config.write().await = config; + } + + /// Store token in cache + pub async fn store_token(&self, key: &str, token: CachedToken) -> Result<(), String> { + let config = self.config.read().await; + if !config.enabled { + return Ok(()); + } + + let mut cache = self.cache.write().await; + let mut stats = self.stats.write().await; + + // Get or create cache entry + let entry = cache.entry(key.to_string()).or_insert_with(|| { + stats.total_entries += 1; + AuthCacheEntry { + key: key.to_string(), + tokens: Vec::new(), + credential: None, + last_accessed: Utc::now(), + access_count: 0, + } + }); + + // Remove expired tokens + let expired_count = entry.tokens.iter().filter(|t| t.is_expired()).count(); + stats.expired_tokens += expired_count; + entry.tokens.retain(|t| !t.is_expired()); + + // Add new token + entry.tokens.push(token); + stats.total_tokens += 1; + entry.last_accessed = Utc::now(); + + // Check cache size limit + if cache.len() > config.max_entries { + self.evict_oldest_entry(&mut cache, &mut stats); + } + + Ok(()) + } + + /// Get token from cache + pub async fn get_token(&self, key: &str, scope: &AuthScope) -> Option { + let config = self.config.read().await; + if !config.enabled { + return None; + } + + let mut cache = self.cache.write().await; + let mut stats = self.stats.write().await; + + if let Some(entry) = cache.get_mut(key) { + entry.last_accessed = Utc::now(); + entry.access_count += 1; + + // Find matching token + for token in &entry.tokens { + if !token.is_expired() && self.token_matches_scope(token, scope) { + stats.cache_hits += 1; + + // Check if needs refresh + if token.needs_refresh(config.refresh_threshold_seconds) { + // Trigger refresh in background + if let Some(refresh_callback) = self.refresh_callbacks.read().await.get(key) { + let token_clone = token.clone(); + let callback = refresh_callback.clone(); + let key_clone = key.to_string(); + let manager = self.clone_for_refresh(); + + tokio::spawn(async move { + if let Ok(refreshed_token) = callback(token_clone).await { + let _ = manager.store_token(&key_clone, refreshed_token).await; + manager.stats.write().await.refresh_count += 1; + } + }); + } + } + + return Some(token.clone()); + } + } + } + + stats.cache_misses += 1; + None + } + + /// Store credential in cache + pub async fn store_credential(&self, key: &str, credential: AuthCredential) -> Result<(), String> { + let config = self.config.read().await; + if !config.enabled { + return Ok(()); + } + + let mut cache = self.cache.write().await; + let mut stats = self.stats.write().await; + + let entry = cache.entry(key.to_string()).or_insert_with(|| { + stats.total_entries += 1; + AuthCacheEntry { + key: key.to_string(), + tokens: Vec::new(), + credential: None, + last_accessed: Utc::now(), + access_count: 0, + } + }); + + entry.credential = Some(credential); + entry.last_accessed = Utc::now(); + + Ok(()) + } + + /// Get credential from cache + pub async fn get_credential(&self, key: &str) -> Option { + let config = self.config.read().await; + if !config.enabled { + return None; + } + + let mut cache = self.cache.write().await; + + if let Some(entry) = cache.get_mut(key) { + entry.last_accessed = Utc::now(); + entry.access_count += 1; + return entry.credential.clone(); + } + + None + } + + /// Register token refresh callback + pub async fn register_refresh_callback(&self, key: &str, callback: TokenRefreshCallback) { + self.refresh_callbacks.write().await.insert(key.to_string(), callback); + } + + /// Clear specific cache entry + pub async fn clear_entry(&self, key: &str) { + let mut cache = self.cache.write().await; + if cache.remove(key).is_some() { + self.stats.write().await.total_entries = cache.len(); + } + } + + /// Clear all cache entries + pub async fn clear_all(&self) { + let mut cache = self.cache.write().await; + cache.clear(); + + let mut stats = self.stats.write().await; + stats.total_entries = 0; + stats.total_tokens = 0; + stats.expired_tokens = 0; + } + + /// Get cache statistics + pub async fn get_stats(&self) -> AuthCacheStats { + self.stats.read().await.clone() + } + + /// List all cache entries + pub async fn list_entries(&self) -> Vec<(String, DateTime, u64)> { + self.cache.read().await + .values() + .map(|entry| (entry.key.clone(), entry.last_accessed, entry.access_count)) + .collect() + } + + /// Export cache to JSON (for persistence) + pub async fn export_cache(&self) -> Result { + let cache = self.cache.read().await; + let entries: Vec<_> = cache.values().cloned().collect(); + + serde_json::to_string_pretty(&entries) + .map_err(|e| format!("Failed to serialize cache: {}", e)) + } + + /// Import cache from JSON + pub async fn import_cache(&self, json_data: &str) -> Result<(), String> { + let entries: Vec = serde_json::from_str(json_data) + .map_err(|e| format!("Failed to deserialize cache: {}", e))?; + + let mut cache = self.cache.write().await; + let mut stats = self.stats.write().await; + + for entry in entries { + cache.insert(entry.key.clone(), entry); + } + + stats.total_entries = cache.len(); + stats.total_tokens = cache.values() + .map(|e| e.tokens.len()) + .sum(); + + Ok(()) + } + + /// Hash password for secure storage + pub fn hash_password(password: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + // Helper methods + fn token_matches_scope(&self, token: &CachedToken, scope: &AuthScope) -> bool { + token.scope.service == scope.service && + token.scope.resource == scope.resource && + scope.permissions.iter().all(|p| token.scope.permissions.contains(p)) + } + + fn evict_oldest_entry(&self, cache: &mut HashMap, stats: &mut AuthCacheStats) { + if let Some((key, _)) = cache.iter() + .min_by_key(|(_, entry)| entry.last_accessed) { + let key = key.clone(); + cache.remove(&key); + stats.eviction_count += 1; + stats.total_entries = cache.len(); + } + } + + async fn start_cleanup_task(&self) { + let config = self.config.read().await; + let cleanup_interval = Duration::seconds(config.cleanup_interval_seconds as i64); + drop(config); + + loop { + tokio::time::sleep(cleanup_interval.to_std().unwrap()).await; + + let config = self.config.read().await; + if !config.enabled { + continue; + } + drop(config); + + // Clean up expired tokens + let mut cache = self.cache.write().await; + let mut stats = self.stats.write().await; + let mut total_expired = 0; + + for entry in cache.values_mut() { + let expired_count = entry.tokens.iter().filter(|t| t.is_expired()).count(); + total_expired += expired_count; + entry.tokens.retain(|t| !t.is_expired()); + } + + stats.expired_tokens += total_expired; + stats.total_tokens = cache.values() + .map(|e| e.tokens.len()) + .sum(); + + // Remove empty entries + cache.retain(|_, entry| !entry.tokens.is_empty() || entry.credential.is_some()); + stats.total_entries = cache.len(); + } + } + + fn clone_for_cleanup(&self) -> Self { + Self { + config: self.config.clone(), + cache: self.cache.clone(), + stats: self.stats.clone(), + refresh_callbacks: self.refresh_callbacks.clone(), + cleanup_handle: self.cleanup_handle.clone(), + notification_manager: self.notification_manager.clone(), + } + } + + fn clone_for_refresh(&self) -> Self { + Self { + config: self.config.clone(), + cache: self.cache.clone(), + stats: self.stats.clone(), + refresh_callbacks: self.refresh_callbacks.clone(), + cleanup_handle: self.cleanup_handle.clone(), + notification_manager: self.notification_manager.clone(), + } + } +} + +/// Create a cache key from components +pub fn create_cache_key(service: &str, username: Option<&str>, resource: Option<&str>) -> String { + let mut components = vec![service]; + if let Some(user) = username { + components.push(user); + } + if let Some(res) = resource { + components.push(res); + } + components.join(":") +} + +/// Authentication cache error +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthCacheError { + pub code: String, + pub message: String, + pub details: Option>, +} \ No newline at end of file diff --git a/tauri/src-tauri/src/backend_manager.rs b/tauri/src-tauri/src/backend_manager.rs new file mode 100644 index 00000000..d7ad669a --- /dev/null +++ b/tauri/src-tauri/src/backend_manager.rs @@ -0,0 +1,523 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use std::path::PathBuf; +use chrono::{DateTime, Utc}; +use tokio::process::Command; + +/// Backend type enumeration +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum BackendType { + Rust, + NodeJS, + Python, + Go, + Custom, +} + +impl BackendType { + pub fn as_str(&self) -> &str { + match self { + BackendType::Rust => "rust", + BackendType::NodeJS => "nodejs", + BackendType::Python => "python", + BackendType::Go => "go", + BackendType::Custom => "custom", + } + } + + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "rust" => BackendType::Rust, + "nodejs" | "node" => BackendType::NodeJS, + "python" => BackendType::Python, + "go" => BackendType::Go, + _ => BackendType::Custom, + } + } +} + +/// Backend status +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BackendStatus { + NotInstalled, + Installing, + Installed, + Starting, + Running, + Stopping, + Stopped, + Error, +} + +/// Backend configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendConfig { + pub backend_type: BackendType, + pub name: String, + pub version: String, + pub executable_path: Option, + pub working_directory: Option, + pub environment_variables: HashMap, + pub arguments: Vec, + pub port: Option, + pub features: BackendFeatures, + pub requirements: BackendRequirements, +} + +/// Backend features +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendFeatures { + pub terminal_sessions: bool, + pub file_browser: bool, + pub port_forwarding: bool, + pub authentication: bool, + pub websocket_support: bool, + pub rest_api: bool, + pub graphql_api: bool, + pub metrics: bool, +} + +/// Backend requirements +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendRequirements { + pub runtime: Option, + pub runtime_version: Option, + pub dependencies: Vec, + pub system_packages: Vec, + pub min_memory_mb: Option, + pub min_disk_space_mb: Option, +} + +/// Backend instance information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendInstance { + pub id: String, + pub backend_type: BackendType, + pub status: BackendStatus, + pub pid: Option, + pub port: u16, + pub started_at: Option>, + pub last_health_check: Option>, + pub health_status: HealthStatus, + pub metrics: BackendMetrics, +} + +/// Health status +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum HealthStatus { + Healthy, + Degraded, + Unhealthy, + Unknown, +} + +/// Backend metrics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendMetrics { + pub cpu_usage_percent: Option, + pub memory_usage_mb: Option, + pub request_count: u64, + pub error_count: u64, + pub average_response_time_ms: Option, + pub active_connections: u32, +} + +/// Backend manager +pub struct BackendManager { + configs: Arc>>, + instances: Arc>>, + active_backend: Arc>>, + notification_manager: Option>, +} + +impl BackendManager { + /// Create a new backend manager + pub fn new() -> Self { + let manager = Self { + configs: Arc::new(RwLock::new(HashMap::new())), + instances: Arc::new(RwLock::new(HashMap::new())), + active_backend: Arc::new(RwLock::new(Some(BackendType::Rust))), + notification_manager: None, + }; + + // Initialize default backend configurations + tokio::spawn({ + let configs = manager.configs.clone(); + async move { + let default_configs = Self::initialize_default_configs(); + *configs.write().await = default_configs; + } + }); + + manager + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Initialize default backend configurations + fn initialize_default_configs() -> HashMap { + let mut configs = HashMap::new(); + + // Rust backend (built-in) + configs.insert(BackendType::Rust, BackendConfig { + backend_type: BackendType::Rust, + name: "Rust (Built-in)".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + executable_path: None, + working_directory: None, + environment_variables: HashMap::new(), + arguments: vec![], + port: Some(4020), + features: BackendFeatures { + terminal_sessions: true, + file_browser: true, + port_forwarding: true, + authentication: true, + websocket_support: 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), + }, + }); + + // Node.js backend + configs.insert(BackendType::NodeJS, BackendConfig { + backend_type: BackendType::NodeJS, + name: "Node.js Server".to_string(), + version: "1.0.0".to_string(), + executable_path: Some(PathBuf::from("node")), + working_directory: None, + environment_variables: HashMap::new(), + arguments: vec!["server.js".to_string()], + port: Some(4021), + features: BackendFeatures { + terminal_sessions: true, + file_browser: true, + port_forwarding: false, + authentication: true, + websocket_support: true, + 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), + }, + }); + + // Python backend + configs.insert(BackendType::Python, BackendConfig { + backend_type: BackendType::Python, + name: "Python Server".to_string(), + version: "1.0.0".to_string(), + executable_path: Some(PathBuf::from("python3")), + working_directory: None, + environment_variables: HashMap::new(), + arguments: vec!["-m".to_string(), "vibetunnel_server".to_string()], + port: Some(4022), + features: BackendFeatures { + terminal_sessions: true, + file_browser: true, + port_forwarding: false, + authentication: true, + websocket_support: 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), + }, + }); + + configs + } + + /// Get available backends + pub async fn get_available_backends(&self) -> Vec { + self.configs.read().await.values().cloned().collect() + } + + /// Get backend configuration + pub async fn get_backend_config(&self, backend_type: BackendType) -> Option { + self.configs.read().await.get(&backend_type).cloned() + } + + /// Check if backend is installed + pub async fn is_backend_installed(&self, backend_type: BackendType) -> bool { + match backend_type { + BackendType::Rust => true, // Built-in + BackendType::NodeJS => self.check_nodejs_installed().await, + BackendType::Python => self.check_python_installed().await, + BackendType::Go => self.check_go_installed().await, + BackendType::Custom => false, + } + } + + /// Install backend + pub async fn install_backend(&self, backend_type: BackendType) -> Result<(), String> { + match backend_type { + BackendType::Rust => Ok(()), // Already installed + BackendType::NodeJS => self.install_nodejs_backend().await, + BackendType::Python => self.install_python_backend().await, + BackendType::Go => Err("Go backend not yet implemented".to_string()), + BackendType::Custom => Err("Custom backend installation not supported".to_string()), + } + } + + /// Start backend + pub async fn start_backend(&self, backend_type: BackendType) -> Result { + // Check if backend is installed + if !self.is_backend_installed(backend_type).await { + return Err(format!("{:?} backend is not installed", backend_type)); + } + + // Get backend configuration + let config = self.get_backend_config(backend_type).await + .ok_or_else(|| "Backend configuration not found".to_string())?; + + // Generate instance ID + let instance_id = uuid::Uuid::new_v4().to_string(); + + // Create backend instance + let instance = BackendInstance { + id: instance_id.clone(), + backend_type, + status: BackendStatus::Starting, + pid: None, + port: config.port.unwrap_or(4020), + started_at: None, + last_health_check: None, + health_status: HealthStatus::Unknown, + metrics: BackendMetrics { + cpu_usage_percent: None, + memory_usage_mb: None, + request_count: 0, + error_count: 0, + average_response_time_ms: None, + active_connections: 0, + }, + }; + + // Store instance + self.instances.write().await.insert(instance_id.clone(), instance); + + // Start backend process + match backend_type { + BackendType::Rust => { + // Rust backend is handled internally + self.update_instance_status(&instance_id, BackendStatus::Running).await; + *self.active_backend.write().await = Some(BackendType::Rust); + Ok(instance_id) + } + _ => { + // Start external backend process + self.start_external_backend(&instance_id, config).await + } + } + } + + /// Stop backend + pub async fn stop_backend(&self, instance_id: &str) -> Result<(), String> { + let instance = self.instances.read().await + .get(instance_id) + .cloned() + .ok_or_else(|| "Backend instance not found".to_string())?; + + match instance.backend_type { + BackendType::Rust => { + // Rust backend is handled internally + self.update_instance_status(instance_id, BackendStatus::Stopped).await; + Ok(()) + } + _ => { + // Stop external backend process + self.stop_external_backend(instance_id).await + } + } + } + + /// Switch active backend + pub async fn switch_backend(&self, backend_type: BackendType) -> Result<(), String> { + // Stop current backend if different + let current_backend = *self.active_backend.read().await; + if let Some(current) = current_backend { + if current != backend_type { + // Find and stop current backend instances + let instance_id = { + let instances = self.instances.read().await; + instances.iter() + .find(|(_, instance)| instance.backend_type == current && instance.status == BackendStatus::Running) + .map(|(id, _)| id.clone()) + }; + if let Some(id) = instance_id { + self.stop_backend(&id).await?; + } + } + } + + // Start new backend + self.start_backend(backend_type).await?; + + // Update active backend + *self.active_backend.write().await = Some(backend_type); + + // Notify about backend switch + if let Some(notification_manager) = &self.notification_manager { + let _ = notification_manager.notify_success( + "Backend Switched", + &format!("Switched to {:?} backend", backend_type) + ).await; + } + + Ok(()) + } + + /// Get active backend + pub async fn get_active_backend(&self) -> Option { + *self.active_backend.read().await + } + + /// Get backend instances + pub async fn get_backend_instances(&self) -> Vec { + self.instances.read().await.values().cloned().collect() + } + + /// Get backend health + pub async fn check_backend_health(&self, instance_id: &str) -> Result { + let instance = self.instances.read().await + .get(instance_id) + .cloned() + .ok_or_else(|| "Backend instance not found".to_string())?; + + if instance.status != BackendStatus::Running { + return Ok(HealthStatus::Unknown); + } + + // Perform health check based on backend type + let health_status = match instance.backend_type { + BackendType::Rust => HealthStatus::Healthy, // Always healthy for built-in + _ => self.check_external_backend_health(&instance).await?, + }; + + // Update instance health status + if let Some(instance) = self.instances.write().await.get_mut(instance_id) { + instance.health_status = health_status; + instance.last_health_check = Some(Utc::now()); + } + + Ok(health_status) + } + + // Helper methods + async fn check_nodejs_installed(&self) -> bool { + Command::new("node") + .arg("--version") + .output() + .await + .map(|output| output.status.success()) + .unwrap_or(false) + } + + async fn check_python_installed(&self) -> bool { + Command::new("python3") + .arg("--version") + .output() + .await + .map(|output| output.status.success()) + .unwrap_or(false) + } + + async fn check_go_installed(&self) -> bool { + Command::new("go") + .arg("version") + .output() + .await + .map(|output| output.status.success()) + .unwrap_or(false) + } + + async fn install_nodejs_backend(&self) -> Result<(), String> { + // TODO: Implement Node.js backend installation + // This would involve: + // 1. Creating package.json + // 2. Installing dependencies + // 3. Copying server files + Err("Node.js backend installation not yet implemented".to_string()) + } + + async fn install_python_backend(&self) -> Result<(), String> { + // TODO: Implement Python backend installation + // This would involve: + // 1. Creating virtual environment + // 2. Installing pip dependencies + // 3. Copying server files + Err("Python backend installation not yet implemented".to_string()) + } + + async fn start_external_backend(&self, _instance_id: &str, _config: BackendConfig) -> Result { + // TODO: Implement external backend startup + Err("External backend startup not yet implemented".to_string()) + } + + async fn stop_external_backend(&self, _instance_id: &str) -> Result<(), String> { + // TODO: Implement external backend shutdown + Err("External backend shutdown not yet implemented".to_string()) + } + + async fn check_external_backend_health(&self, _instance: &BackendInstance) -> Result { + // TODO: Implement health check for external backends + Ok(HealthStatus::Unknown) + } + + async fn update_instance_status(&self, instance_id: &str, status: BackendStatus) { + if let Some(instance) = self.instances.write().await.get_mut(instance_id) { + instance.status = status; + if status == BackendStatus::Running { + instance.started_at = Some(Utc::now()); + } + } + } +} + +/// Backend statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendStats { + pub total_backends: usize, + pub installed_backends: usize, + pub running_instances: usize, + pub active_backend: Option, + pub health_summary: HashMap, +} \ No newline at end of file diff --git a/tauri/src-tauri/src/cast.rs b/tauri/src-tauri/src/cast.rs new file mode 100644 index 00000000..c7a770f2 --- /dev/null +++ b/tauri/src-tauri/src/cast.rs @@ -0,0 +1,364 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::Mutex; +use chrono::{DateTime, Utc}; + +/// Asciinema cast v2 format header +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CastHeader { + pub version: u8, + pub width: u16, + pub height: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idle_time_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, +} + +/// Event types for Asciinema cast format +#[derive(Debug, Clone, Copy)] +pub enum EventType { + Output, + Input, +} + +impl EventType { + fn as_str(&self) -> &'static str { + match self { + EventType::Output => "o", + EventType::Input => "i", + } + } +} + +/// A single event in the cast file +#[derive(Debug)] +pub struct CastEvent { + pub timestamp: f64, + pub event_type: EventType, + pub data: String, +} + +/// Handles recording terminal sessions in Asciinema cast format +pub struct CastRecorder { + header: CastHeader, + start_time: DateTime, + events: Arc>>, + file_writer: Option>>>, + is_recording: Arc>, +} + +impl CastRecorder { + /// Create a new cast recorder + pub fn new( + width: u16, + height: u16, + title: Option, + command: Option, + ) -> Self { + let now = Utc::now(); + let header = CastHeader { + version: 2, + width, + height, + timestamp: Some(now.timestamp()), + duration: None, + idle_time_limit: None, + command, + title, + env: None, + }; + + Self { + header, + start_time: now, + events: Arc::new(Mutex::new(Vec::new())), + file_writer: None, + is_recording: Arc::new(Mutex::new(false)), + } + } + + /// Start recording to a file + pub async fn start_recording(&mut self, path: impl AsRef) -> Result<(), String> { + let mut is_recording = self.is_recording.lock().await; + if *is_recording { + return Err("Already recording".to_string()); + } + + // Create file and write header + let file = File::create(path.as_ref()) + .map_err(|e| format!("Failed to create cast file: {}", e))?; + let mut writer = BufWriter::new(file); + + // Write header as first line + let header_json = serde_json::to_string(&self.header) + .map_err(|e| format!("Failed to serialize header: {}", e))?; + writeln!(writer, "{}", header_json) + .map_err(|e| format!("Failed to write header: {}", e))?; + + // Write any existing events + let events = self.events.lock().await; + for event in events.iter() { + self.write_event_to_file(&mut writer, event)?; + } + + writer.flush() + .map_err(|e| format!("Failed to flush writer: {}", e))?; + + self.file_writer = Some(Arc::new(Mutex::new(writer))); + *is_recording = true; + Ok(()) + } + + /// Stop recording + pub async fn stop_recording(&mut self) -> Result<(), String> { + let mut is_recording = self.is_recording.lock().await; + if !*is_recording { + return Ok(()); + } + + if let Some(writer_arc) = self.file_writer.take() { + let mut writer = writer_arc.lock().await; + writer.flush() + .map_err(|e| format!("Failed to flush final data: {}", e))?; + } + + *is_recording = false; + Ok(()) + } + + /// Add output data to the recording + pub async fn add_output(&self, data: &[u8]) -> Result<(), String> { + self.add_event(EventType::Output, data).await + } + + /// Add input data to the recording + pub async fn add_input(&self, data: &[u8]) -> Result<(), String> { + self.add_event(EventType::Input, data).await + } + + /// Add an event to the recording + async fn add_event(&self, event_type: EventType, data: &[u8]) -> Result<(), String> { + let timestamp = Utc::now() + .signed_duration_since(self.start_time) + .num_milliseconds() as f64 / 1000.0; + + // Convert data to string (handling potential UTF-8 errors) + let data_string = String::from_utf8_lossy(data).to_string(); + + let event = CastEvent { + timestamp, + event_type, + data: data_string, + }; + + // If we have a file writer, write immediately + if let Some(writer_arc) = &self.file_writer { + let mut writer = writer_arc.lock().await; + self.write_event_to_file(&mut writer, &event)?; + writer.flush() + .map_err(|e| format!("Failed to flush event: {}", e))?; + } + + // Also store in memory + let mut events = self.events.lock().await; + events.push(event); + + Ok(()) + } + + /// Write an event to the file + fn write_event_to_file( + &self, + writer: &mut BufWriter, + event: &CastEvent, + ) -> Result<(), String> { + // Format: [timestamp, event_type, data] + let event_array = serde_json::json!([ + event.timestamp, + event.event_type.as_str(), + event.data + ]); + + writeln!(writer, "{}", event_array) + .map_err(|e| format!("Failed to write event: {}", e))?; + + Ok(()) + } + + /// Save all recorded events to a file + pub async fn save_to_file(&self, path: impl AsRef) -> Result<(), String> { + let file = File::create(path.as_ref()) + .map_err(|e| format!("Failed to create cast file: {}", e))?; + let mut writer = BufWriter::new(file); + + // Calculate duration + let events = self.events.lock().await; + let duration = events.last().map(|e| e.timestamp); + + // Update header with duration + let mut header = self.header.clone(); + header.duration = duration; + + // Write header + let header_json = serde_json::to_string(&header) + .map_err(|e| format!("Failed to serialize header: {}", e))?; + writeln!(writer, "{}", header_json) + .map_err(|e| format!("Failed to write header: {}", e))?; + + // Write events + for event in events.iter() { + self.write_event_to_file(&mut writer, event)?; + } + + writer.flush() + .map_err(|e| format!("Failed to flush file: {}", e))?; + + Ok(()) + } + + /// Get the current recording duration + pub async fn get_duration(&self) -> f64 { + let events = self.events.lock().await; + events.last().map(|e| e.timestamp).unwrap_or(0.0) + } + + /// Check if currently recording + pub async fn is_recording(&self) -> bool { + *self.is_recording.lock().await + } + + /// Update terminal dimensions + pub async fn resize(&mut self, width: u16, height: u16) { + self.header.width = width; + self.header.height = height; + // Note: In a real implementation, you might want to add a resize event + } +} + +/// Manages cast recordings for multiple sessions +pub struct CastManager { + recorders: Arc>>>>, +} + +impl CastManager { + pub fn new() -> Self { + Self { + recorders: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Create a new recorder for a session + pub async fn create_recorder( + &self, + session_id: String, + width: u16, + height: u16, + title: Option, + command: Option, + ) -> Result<(), String> { + let mut recorders = self.recorders.lock().await; + if recorders.contains_key(&session_id) { + return Err("Recorder already exists for this session".to_string()); + } + + let recorder = CastRecorder::new(width, height, title, command); + recorders.insert(session_id, Arc::new(Mutex::new(recorder))); + Ok(()) + } + + /// Get a recorder for a session + pub async fn get_recorder(&self, session_id: &str) -> Option>> { + self.recorders.lock().await.get(session_id).cloned() + } + + /// Remove a recorder for a session + pub async fn remove_recorder(&self, session_id: &str) -> Result<(), String> { + let mut recorders = self.recorders.lock().await; + if let Some(recorder_arc) = recorders.remove(session_id) { + let mut recorder = recorder_arc.lock().await; + recorder.stop_recording().await?; + } + Ok(()) + } + + /// Start recording for a session + pub async fn start_recording( + &self, + session_id: &str, + path: impl AsRef, + ) -> Result<(), String> { + if let Some(recorder_arc) = self.get_recorder(session_id).await { + let mut recorder = recorder_arc.lock().await; + recorder.start_recording(path).await + } else { + Err("No recorder found for session".to_string()) + } + } + + /// Stop recording for a session + pub async fn stop_recording(&self, session_id: &str) -> Result<(), String> { + if let Some(recorder_arc) = self.get_recorder(session_id).await { + let mut recorder = recorder_arc.lock().await; + recorder.stop_recording().await + } else { + Err("No recorder found for session".to_string()) + } + } + + /// Add output to a session's recording + pub async fn add_output(&self, session_id: &str, data: &[u8]) -> Result<(), String> { + if let Some(recorder_arc) = self.get_recorder(session_id).await { + let recorder = recorder_arc.lock().await; + recorder.add_output(data).await + } else { + Ok(()) // Silently ignore if no recorder + } + } + + /// Add input to a session's recording + pub async fn add_input(&self, session_id: &str, data: &[u8]) -> Result<(), String> { + if let Some(recorder_arc) = self.get_recorder(session_id).await { + let recorder = recorder_arc.lock().await; + recorder.add_input(data).await + } else { + Ok(()) // Silently ignore if no recorder + } + } + + /// Save a session's recording to file + pub async fn save_recording( + &self, + session_id: &str, + path: impl AsRef, + ) -> Result<(), String> { + if let Some(recorder_arc) = self.get_recorder(session_id).await { + let recorder = recorder_arc.lock().await; + recorder.save_to_file(path).await + } else { + Err("No recorder found for session".to_string()) + } + } + + /// Check if a session is being recorded + pub async fn is_recording(&self, session_id: &str) -> bool { + if let Some(recorder_arc) = self.get_recorder(session_id).await { + let recorder = recorder_arc.lock().await; + recorder.is_recording().await + } else { + false + } + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/commands.rs b/tauri/src-tauri/src/commands.rs index 60a0b7ba..f16aeab7 100644 --- a/tauri/src-tauri/src/commands.rs +++ b/tauri/src-tauri/src/commands.rs @@ -125,9 +125,9 @@ pub async fn start_server( // Start HTTP server with auth if configured let mut http_server = if settings.dashboard.enable_password && !settings.dashboard.password.is_empty() { let auth_config = crate::auth::AuthConfig::new(true, Some(settings.dashboard.password)); - HttpServer::with_auth(state.terminal_manager.clone(), auth_config) + HttpServer::with_auth(state.terminal_manager.clone(), state.session_monitor.clone(), auth_config) } else { - HttpServer::new(state.terminal_manager.clone()) + HttpServer::new(state.terminal_manager.clone(), state.session_monitor.clone()) }; // Start server with appropriate access mode @@ -246,9 +246,10 @@ pub async fn restart_server( // Start a new server let terminal_manager = state.terminal_manager.clone(); + let session_monitor = state.session_monitor.clone(); let settings = crate::settings::Settings::load().unwrap_or_default(); - let mut new_server = HttpServer::new(terminal_manager); + let mut new_server = HttpServer::new(terminal_manager, session_monitor); new_server.start_with_mode(match settings.dashboard.access_mode.as_str() { "network" => "network", _ => "localhost" @@ -274,9 +275,11 @@ pub async fn show_server_console( "server-console", tauri::WebviewUrl::App("server-console.html".into()) ) - .title("Server Console") - .inner_size(800.0, 600.0) + .title("Server Console - VibeTunnel") + .inner_size(900.0, 600.0) .resizable(true) + .decorations(true) + .center() .build() .map_err(|e| e.to_string())?; } @@ -286,27 +289,10 @@ pub async fn show_server_console( #[tauri::command] pub async fn show_welcome_screen( - app_handle: tauri::AppHandle, + state: State<'_, AppState>, ) -> Result<(), String> { - // Check if welcome window already exists - if let Some(window) = app_handle.get_webview_window("welcome") { - window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; - } else { - // Create new welcome window - tauri::WebviewWindowBuilder::new( - &app_handle, - "welcome", - tauri::WebviewUrl::App("welcome.html".into()) - ) - .title("Welcome to VibeTunnel") - .inner_size(700.0, 500.0) - .resizable(false) - .build() - .map_err(|e| e.to_string())?; - } - - Ok(()) + let welcome_manager = &state.welcome_manager; + welcome_manager.show_welcome_window().await } #[tauri::command] @@ -346,4 +332,1701 @@ pub async fn update_dock_icon_visibility(app_handle: tauri::AppHandle) -> Result } } Ok(()) +} + +// Terminal Recording Commands +#[derive(Debug, Serialize, Deserialize)] +pub struct StartRecordingOptions { + pub session_id: String, + pub title: Option, + pub output_path: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RecordingStatus { + pub is_recording: bool, + pub duration: f64, +} + +#[tauri::command] +pub async fn start_terminal_recording( + options: StartRecordingOptions, + state: State<'_, AppState>, +) -> Result<(), String> { + let cast_manager = &state.cast_manager; + + // Get terminal info for metadata + let terminal_manager = &state.terminal_manager; + let sessions = terminal_manager.list_sessions().await; + let session = sessions.iter() + .find(|s| s.id == options.session_id) + .ok_or_else(|| "Session not found".to_string())?; + + // Create recorder if it doesn't exist + cast_manager.create_recorder( + options.session_id.clone(), + session.cols, + session.rows, + options.title.or(Some(session.name.clone())), + None, // command + ).await.ok(); // Ignore if already exists + + // Start recording + if let Some(path) = options.output_path { + cast_manager.start_recording(&options.session_id, path).await + } else { + // Use default path with timestamp + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let filename = format!("vibetunnel_recording_{}.cast", timestamp); + let path = std::env::temp_dir().join(filename); + cast_manager.start_recording(&options.session_id, path).await + } +} + +#[tauri::command] +pub async fn stop_terminal_recording( + session_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let cast_manager = &state.cast_manager; + cast_manager.stop_recording(&session_id).await +} + +#[tauri::command] +pub async fn save_terminal_recording( + session_id: String, + output_path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let cast_manager = &state.cast_manager; + cast_manager.save_recording(&session_id, output_path).await +} + +#[tauri::command] +pub async fn get_recording_status( + session_id: String, + state: State<'_, AppState>, +) -> Result { + let cast_manager = &state.cast_manager; + let is_recording = cast_manager.is_recording(&session_id).await; + + let duration = if let Some(recorder) = cast_manager.get_recorder(&session_id).await { + let rec = recorder.lock().await; + rec.get_duration().await + } else { + 0.0 + }; + + Ok(RecordingStatus { + is_recording, + duration, + }) +} + +// TTY Forwarding Commands +#[derive(Debug, Serialize, Deserialize)] +pub struct StartTTYForwardOptions { + pub local_port: u16, + pub remote_host: Option, + pub remote_port: Option, + pub shell: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TTYForwardInfo { + pub id: String, + pub local_port: u16, + pub remote_host: String, + pub remote_port: u16, + pub connected: bool, + pub client_count: usize, +} + +#[tauri::command] +pub async fn start_tty_forward( + options: StartTTYForwardOptions, + state: State<'_, AppState>, +) -> Result { + let tty_forward_manager = &state.tty_forward_manager; + + let remote_host = options.remote_host.unwrap_or_else(|| "localhost".to_string()); + let remote_port = options.remote_port.unwrap_or(22); + + tty_forward_manager.start_forward( + options.local_port, + remote_host, + remote_port, + options.shell, + ).await +} + +#[tauri::command] +pub async fn stop_tty_forward( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let tty_forward_manager = &state.tty_forward_manager; + tty_forward_manager.stop_forward(&id).await +} + +#[tauri::command] +pub async fn list_tty_forwards( + state: State<'_, AppState>, +) -> Result, String> { + let tty_forward_manager = &state.tty_forward_manager; + let forwards = tty_forward_manager.list_forwards().await; + + Ok(forwards.into_iter().map(|f| TTYForwardInfo { + id: f.id, + local_port: f.local_port, + remote_host: f.remote_host, + remote_port: f.remote_port, + connected: f.connected, + client_count: f.client_count, + }).collect()) +} + +#[tauri::command] +pub async fn get_tty_forward( + id: String, + state: State<'_, AppState>, +) -> Result, String> { + let tty_forward_manager = &state.tty_forward_manager; + + Ok(tty_forward_manager.get_forward(&id).await.map(|f| TTYForwardInfo { + id: f.id, + local_port: f.local_port, + remote_host: f.remote_host, + remote_port: f.remote_port, + connected: f.connected, + client_count: f.client_count, + })) +} + +// Session Monitoring Commands +#[tauri::command] +pub async fn get_session_stats( + state: State<'_, AppState>, +) -> Result { + let session_monitor = &state.session_monitor; + Ok(session_monitor.get_stats().await) +} + +#[tauri::command] +pub async fn get_monitored_sessions( + state: State<'_, AppState>, +) -> Result, String> { + let session_monitor = &state.session_monitor; + Ok(session_monitor.get_sessions().await) +} + +#[tauri::command] +pub async fn start_session_monitoring( + state: State<'_, AppState>, +) -> Result<(), String> { + let session_monitor = &state.session_monitor; + session_monitor.start_monitoring().await; + Ok(()) +} + +// Port Conflict Resolution Commands +#[tauri::command] +pub async fn check_port_availability( + port: u16, +) -> Result { + Ok(crate::port_conflict::PortConflictResolver::is_port_available(port).await) +} + +#[tauri::command] +pub async fn detect_port_conflict( + port: u16, +) -> Result, String> { + Ok(crate::port_conflict::PortConflictResolver::detect_conflict(port).await) +} + +#[tauri::command] +pub async fn resolve_port_conflict( + conflict: crate::port_conflict::PortConflict, +) -> Result<(), String> { + crate::port_conflict::PortConflictResolver::resolve_conflict(&conflict).await +} + +#[tauri::command] +pub async fn force_kill_process( + conflict: crate::port_conflict::PortConflict, +) -> Result<(), String> { + crate::port_conflict::PortConflictResolver::force_kill_process(&conflict).await +} + +#[tauri::command] +pub async fn find_available_ports( + near_port: u16, + count: usize, +) -> Result, String> { + let mut available_ports = Vec::new(); + let start = near_port.saturating_sub(10).max(1024); + let end = near_port.saturating_add(100).min(65535); + + for port in start..=end { + if port != near_port && crate::port_conflict::PortConflictResolver::is_port_available(port).await { + available_ports.push(port); + if available_ports.len() >= count { + break; + } + } + } + + Ok(available_ports) +} + +// Network Utilities Commands +#[tauri::command] +pub async fn get_local_ip_address() -> Result, String> { + Ok(crate::network_utils::NetworkUtils::get_local_ip_address()) +} + +#[tauri::command] +pub async fn get_all_ip_addresses() -> Result, String> { + Ok(crate::network_utils::NetworkUtils::get_all_ip_addresses()) +} + +#[tauri::command] +pub async fn get_network_interfaces() -> Result, String> { + Ok(crate::network_utils::NetworkUtils::get_all_interfaces()) +} + +#[tauri::command] +pub async fn get_hostname() -> Result, String> { + Ok(crate::network_utils::NetworkUtils::get_hostname()) +} + +#[tauri::command] +pub async fn test_network_connectivity( + host: String, + port: u16, +) -> Result { + Ok(crate::network_utils::NetworkUtils::test_connectivity(&host, port).await) +} + +#[tauri::command] +pub async fn get_network_stats() -> Result { + Ok(crate::network_utils::NetworkUtils::get_network_stats()) +} + +// Notification Commands +#[derive(Debug, Serialize, Deserialize)] +pub struct ShowNotificationOptions { + pub notification_type: crate::notification_manager::NotificationType, + pub priority: crate::notification_manager::NotificationPriority, + pub title: String, + pub body: String, + pub actions: Vec, + #[serde(default)] + pub metadata: HashMap, +} + +#[tauri::command] +pub async fn show_notification( + options: ShowNotificationOptions, + state: State<'_, AppState>, +) -> Result { + let notification_manager = &state.notification_manager; + notification_manager.show_notification( + options.notification_type, + options.priority, + options.title, + options.body, + options.actions, + options.metadata, + ).await +} + +#[tauri::command] +pub async fn get_notifications( + state: State<'_, AppState>, +) -> Result, String> { + let notification_manager = &state.notification_manager; + Ok(notification_manager.get_notifications().await) +} + +#[tauri::command] +pub async fn get_notification_history( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let notification_manager = &state.notification_manager; + Ok(notification_manager.get_history(limit).await) +} + +#[tauri::command] +pub async fn mark_notification_as_read( + notification_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let notification_manager = &state.notification_manager; + notification_manager.mark_as_read(¬ification_id).await +} + +#[tauri::command] +pub async fn mark_all_notifications_as_read( + state: State<'_, AppState>, +) -> Result<(), String> { + let notification_manager = &state.notification_manager; + notification_manager.mark_all_as_read().await +} + +#[tauri::command] +pub async fn clear_notification( + notification_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let notification_manager = &state.notification_manager; + notification_manager.clear_notification(¬ification_id).await +} + +#[tauri::command] +pub async fn clear_all_notifications( + state: State<'_, AppState>, +) -> Result<(), String> { + let notification_manager = &state.notification_manager; + notification_manager.clear_all_notifications().await +} + +#[tauri::command] +pub async fn get_unread_notification_count( + state: State<'_, AppState>, +) -> Result { + let notification_manager = &state.notification_manager; + Ok(notification_manager.get_unread_count().await) +} + +#[tauri::command] +pub async fn update_notification_settings( + settings: crate::notification_manager::NotificationSettings, + state: State<'_, AppState>, +) -> Result<(), String> { + let notification_manager = &state.notification_manager; + notification_manager.update_settings(settings).await; + Ok(()) +} + +#[tauri::command] +pub async fn get_notification_settings( + state: State<'_, AppState>, +) -> Result { + let notification_manager = &state.notification_manager; + Ok(notification_manager.get_settings().await) +} + +// Welcome/Tutorial Commands +#[tauri::command] +pub async fn get_welcome_state( + state: State<'_, AppState>, +) -> Result { + let welcome_manager = &state.welcome_manager; + Ok(welcome_manager.get_state().await) +} + +#[tauri::command] +pub async fn should_show_welcome( + state: State<'_, AppState>, +) -> Result { + let welcome_manager = &state.welcome_manager; + Ok(welcome_manager.should_show_welcome().await) +} + +#[tauri::command] +pub async fn get_tutorials( + state: State<'_, AppState>, +) -> Result, String> { + let welcome_manager = &state.welcome_manager; + Ok(welcome_manager.get_tutorials().await) +} + +#[tauri::command] +pub async fn get_tutorial_category( + category_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let welcome_manager = &state.welcome_manager; + Ok(welcome_manager.get_tutorial_category(&category_id).await) +} + +#[tauri::command] +pub async fn complete_tutorial_step( + step_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let welcome_manager = &state.welcome_manager; + welcome_manager.complete_step(&step_id).await +} + +#[tauri::command] +pub async fn skip_tutorial( + state: State<'_, AppState>, +) -> Result<(), String> { + let welcome_manager = &state.welcome_manager; + welcome_manager.skip_tutorial().await +} + +#[tauri::command] +pub async fn reset_tutorial( + state: State<'_, AppState>, +) -> Result<(), String> { + let welcome_manager = &state.welcome_manager; + welcome_manager.reset_tutorial().await +} + +#[tauri::command] +pub async fn get_tutorial_progress( + state: State<'_, AppState>, +) -> Result { + let welcome_manager = &state.welcome_manager; + Ok(welcome_manager.get_progress().await) +} + +#[tauri::command] +pub async fn show_welcome_window( + state: State<'_, AppState>, +) -> Result<(), String> { + let welcome_manager = &state.welcome_manager; + welcome_manager.show_welcome_window().await +} + +// Advanced Settings Commands +#[tauri::command] +pub async fn get_recording_settings() -> Result { + let settings = crate::settings::Settings::load().unwrap_or_default(); + Ok(settings.recording.unwrap_or(crate::settings::RecordingSettings { + enabled: true, + output_directory: None, + format: "asciinema".to_string(), + include_timing: true, + compress_output: false, + max_file_size_mb: Some(100), + auto_save: false, + filename_template: Some("vibetunnel_%Y%m%d_%H%M%S".to_string()), + })) +} + +#[tauri::command] +pub async fn save_recording_settings( + recording: crate::settings::RecordingSettings, +) -> Result<(), String> { + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.recording = Some(recording); + settings.save() +} + +#[tauri::command] +pub async fn get_all_advanced_settings() -> Result, String> { + let settings = crate::settings::Settings::load().unwrap_or_default(); + let mut all_settings = HashMap::new(); + + // Convert all settings sections to JSON values + all_settings.insert("recording".to_string(), + serde_json::to_value(&settings.recording).unwrap_or(serde_json::Value::Null)); + all_settings.insert("tty_forward".to_string(), + serde_json::to_value(&settings.tty_forward).unwrap_or(serde_json::Value::Null)); + all_settings.insert("monitoring".to_string(), + serde_json::to_value(&settings.monitoring).unwrap_or(serde_json::Value::Null)); + all_settings.insert("network".to_string(), + serde_json::to_value(&settings.network).unwrap_or(serde_json::Value::Null)); + all_settings.insert("port".to_string(), + serde_json::to_value(&settings.port).unwrap_or(serde_json::Value::Null)); + all_settings.insert("notifications".to_string(), + serde_json::to_value(&settings.notifications).unwrap_or(serde_json::Value::Null)); + all_settings.insert("terminal_integrations".to_string(), + serde_json::to_value(&settings.terminal_integrations).unwrap_or(serde_json::Value::Null)); + all_settings.insert("updates".to_string(), + serde_json::to_value(&settings.updates).unwrap_or(serde_json::Value::Null)); + all_settings.insert("security".to_string(), + serde_json::to_value(&settings.security).unwrap_or(serde_json::Value::Null)); + all_settings.insert("debug".to_string(), + serde_json::to_value(&settings.debug).unwrap_or(serde_json::Value::Null)); + + Ok(all_settings) +} + +#[tauri::command] +pub async fn update_advanced_settings( + section: String, + value: serde_json::Value, +) -> Result<(), String> { + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + + match section.as_str() { + "recording" => { + settings.recording = serde_json::from_value(value) + .map_err(|e| format!("Invalid recording settings: {}", e))?; + } + "tty_forward" => { + settings.tty_forward = serde_json::from_value(value) + .map_err(|e| format!("Invalid TTY forward settings: {}", e))?; + } + "monitoring" => { + settings.monitoring = serde_json::from_value(value) + .map_err(|e| format!("Invalid monitoring settings: {}", e))?; + } + "network" => { + settings.network = serde_json::from_value(value) + .map_err(|e| format!("Invalid network settings: {}", e))?; + } + "port" => { + settings.port = serde_json::from_value(value) + .map_err(|e| format!("Invalid port settings: {}", e))?; + } + "notifications" => { + settings.notifications = serde_json::from_value(value) + .map_err(|e| format!("Invalid notification settings: {}", e))?; + } + "terminal_integrations" => { + settings.terminal_integrations = serde_json::from_value(value) + .map_err(|e| format!("Invalid terminal integration settings: {}", e))?; + } + "updates" => { + settings.updates = serde_json::from_value(value) + .map_err(|e| format!("Invalid update settings: {}", e))?; + } + "security" => { + settings.security = serde_json::from_value(value) + .map_err(|e| format!("Invalid security settings: {}", e))?; + } + "debug" => { + settings.debug = serde_json::from_value(value) + .map_err(|e| format!("Invalid debug settings: {}", e))?; + } + _ => return Err(format!("Unknown settings section: {}", section)), + } + + settings.save() +} + +#[tauri::command] +pub async fn reset_settings_section(section: String) -> Result<(), String> { + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + let defaults = crate::settings::Settings::default(); + + match section.as_str() { + "recording" => settings.recording = defaults.recording, + "tty_forward" => settings.tty_forward = defaults.tty_forward, + "monitoring" => settings.monitoring = defaults.monitoring, + "network" => settings.network = defaults.network, + "port" => settings.port = defaults.port, + "notifications" => settings.notifications = defaults.notifications, + "terminal_integrations" => settings.terminal_integrations = defaults.terminal_integrations, + "updates" => settings.updates = defaults.updates, + "security" => settings.security = defaults.security, + "debug" => settings.debug = defaults.debug, + "all" => settings = defaults, + _ => return Err(format!("Unknown settings section: {}", section)), + } + + settings.save() +} + +#[tauri::command] +pub async fn export_settings() -> Result { + let settings = crate::settings::Settings::load().unwrap_or_default(); + toml::to_string_pretty(&settings) + .map_err(|e| format!("Failed to export settings: {}", e)) +} + +#[tauri::command] +pub async fn import_settings(toml_content: String) -> Result<(), String> { + let settings: crate::settings::Settings = toml::from_str(&toml_content) + .map_err(|e| format!("Failed to parse settings: {}", e))?; + settings.save() +} + +// Permissions Commands +#[tauri::command] +pub async fn check_all_permissions( + state: State<'_, AppState>, +) -> Result, String> { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.check_all_permissions().await) +} + +#[tauri::command] +pub async fn check_permission( + permission_type: crate::permissions::PermissionType, + state: State<'_, AppState>, +) -> Result { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.check_permission(permission_type).await) +} + +#[tauri::command] +pub async fn request_permission( + permission_type: crate::permissions::PermissionType, + state: State<'_, AppState>, +) -> Result { + let permissions_manager = &state.permissions_manager; + permissions_manager.request_permission(permission_type).await +} + +#[tauri::command] +pub async fn get_permission_info( + permission_type: crate::permissions::PermissionType, + state: State<'_, AppState>, +) -> Result, String> { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.get_permission_info(permission_type).await) +} + +#[tauri::command] +pub async fn get_all_permissions( + state: State<'_, AppState>, +) -> Result, String> { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.get_all_permissions().await) +} + +#[tauri::command] +pub async fn get_required_permissions( + state: State<'_, AppState>, +) -> Result, String> { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.get_required_permissions().await) +} + +#[tauri::command] +pub async fn get_missing_required_permissions( + state: State<'_, AppState>, +) -> Result, String> { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.get_missing_required_permissions().await) +} + +#[tauri::command] +pub async fn all_required_permissions_granted( + state: State<'_, AppState>, +) -> Result { + let permissions_manager = &state.permissions_manager; + Ok(permissions_manager.all_required_permissions_granted().await) +} + +#[tauri::command] +pub async fn open_system_permission_settings( + permission_type: crate::permissions::PermissionType, + state: State<'_, AppState>, +) -> Result<(), String> { + let permissions_manager = &state.permissions_manager; + permissions_manager.open_system_settings(permission_type).await +} + +#[tauri::command] +pub async fn get_permission_stats( + state: State<'_, AppState>, +) -> Result { + let permissions_manager = &state.permissions_manager; + let all_permissions = permissions_manager.get_all_permissions().await; + + let stats = crate::permissions::PermissionStats { + total_permissions: all_permissions.len(), + granted_permissions: all_permissions.iter().filter(|p| p.status == crate::permissions::PermissionStatus::Granted).count(), + denied_permissions: all_permissions.iter().filter(|p| p.status == crate::permissions::PermissionStatus::Denied).count(), + required_permissions: all_permissions.iter().filter(|p| p.required).count(), + missing_required: all_permissions.iter().filter(|p| p.required && p.status != crate::permissions::PermissionStatus::Granted).count(), + platform: std::env::consts::OS.to_string(), + }; + + Ok(stats) +} + +// Update Manager Commands +#[tauri::command] +pub async fn check_for_updates( + state: State<'_, AppState>, +) -> Result, String> { + let update_manager = &state.update_manager; + update_manager.check_for_updates().await +} + +#[tauri::command] +pub async fn download_update( + state: State<'_, AppState>, +) -> Result<(), String> { + let update_manager = &state.update_manager; + update_manager.download_update().await +} + +#[tauri::command] +pub async fn install_update( + state: State<'_, AppState>, +) -> Result<(), String> { + let update_manager = &state.update_manager; + update_manager.install_update().await +} + +#[tauri::command] +pub async fn cancel_update( + state: State<'_, AppState>, +) -> Result<(), String> { + let update_manager = &state.update_manager; + update_manager.cancel_update().await +} + +#[tauri::command] +pub async fn get_update_state( + state: State<'_, AppState>, +) -> Result { + let update_manager = &state.update_manager; + Ok(update_manager.get_state().await) +} + +#[tauri::command] +pub async fn get_updater_settings( + state: State<'_, AppState>, +) -> Result { + let update_manager = &state.update_manager; + Ok(update_manager.get_settings().await) +} + +#[tauri::command] +pub async fn update_updater_settings( + settings: crate::updater::UpdaterSettings, + state: State<'_, AppState>, +) -> Result<(), String> { + let update_manager = &state.update_manager; + update_manager.update_settings(settings).await +} + +#[tauri::command] +pub async fn switch_update_channel( + channel: crate::updater::UpdateChannel, + state: State<'_, AppState>, +) -> Result<(), String> { + let update_manager = &state.update_manager; + update_manager.switch_channel(channel).await +} + +#[tauri::command] +pub async fn get_update_history( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let update_manager = &state.update_manager; + Ok(update_manager.get_update_history(limit).await) +} + +// Backend Manager Commands +#[tauri::command] +pub async fn get_available_backends( + state: State<'_, AppState>, +) -> Result, String> { + let backend_manager = &state.backend_manager; + Ok(backend_manager.get_available_backends().await) +} + +#[tauri::command] +pub async fn get_backend_config( + backend_type: crate::backend_manager::BackendType, + state: State<'_, AppState>, +) -> Result, String> { + let backend_manager = &state.backend_manager; + Ok(backend_manager.get_backend_config(backend_type).await) +} + +#[tauri::command] +pub async fn is_backend_installed( + backend_type: crate::backend_manager::BackendType, + state: State<'_, AppState>, +) -> Result { + let backend_manager = &state.backend_manager; + Ok(backend_manager.is_backend_installed(backend_type).await) +} + +#[tauri::command] +pub async fn install_backend( + backend_type: crate::backend_manager::BackendType, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend_manager = &state.backend_manager; + backend_manager.install_backend(backend_type).await +} + +#[tauri::command] +pub async fn start_backend( + backend_type: crate::backend_manager::BackendType, + state: State<'_, AppState>, +) -> Result { + let backend_manager = &state.backend_manager; + backend_manager.start_backend(backend_type).await +} + +#[tauri::command] +pub async fn stop_backend( + instance_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend_manager = &state.backend_manager; + backend_manager.stop_backend(&instance_id).await +} + +#[tauri::command] +pub async fn switch_backend( + backend_type: crate::backend_manager::BackendType, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend_manager = &state.backend_manager; + backend_manager.switch_backend(backend_type).await +} + +#[tauri::command] +pub async fn get_active_backend( + state: State<'_, AppState>, +) -> Result, String> { + let backend_manager = &state.backend_manager; + Ok(backend_manager.get_active_backend().await) +} + +#[tauri::command] +pub async fn get_backend_instances( + state: State<'_, AppState>, +) -> Result, String> { + let backend_manager = &state.backend_manager; + Ok(backend_manager.get_backend_instances().await) +} + +#[tauri::command] +pub async fn check_backend_health( + instance_id: String, + state: State<'_, AppState>, +) -> Result { + let backend_manager = &state.backend_manager; + backend_manager.check_backend_health(&instance_id).await +} + +#[tauri::command] +pub async fn get_backend_stats( + state: State<'_, AppState>, +) -> Result { + let backend_manager = &state.backend_manager; + + let backends = backend_manager.get_available_backends().await; + let instances = backend_manager.get_backend_instances().await; + let active_backend = backend_manager.get_active_backend().await; + + let mut health_summary = std::collections::HashMap::new(); + for instance in &instances { + *health_summary.entry(instance.health_status).or_insert(0) += 1; + } + + let mut installed_count = 0; + for backend in &backends { + if backend_manager.is_backend_installed(backend.backend_type).await { + installed_count += 1; + } + } + + Ok(crate::backend_manager::BackendStats { + total_backends: backends.len(), + installed_backends: installed_count, + running_instances: instances.iter().filter(|i| i.status == crate::backend_manager::BackendStatus::Running).count(), + active_backend, + health_summary, + }) +} + +// Debug Features Commands +#[derive(Debug, Serialize, Deserialize)] +pub struct LogDebugMessageOptions { + pub level: crate::debug_features::LogLevel, + pub component: String, + pub message: String, + pub metadata: HashMap, +} + +#[tauri::command] +pub async fn get_debug_settings( + state: State<'_, AppState>, +) -> Result { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.get_settings().await) +} + +#[tauri::command] +pub async fn update_debug_settings( + settings: crate::debug_features::DebugSettings, + state: State<'_, AppState>, +) -> Result<(), String> { + let debug_features_manager = &state.debug_features_manager; + debug_features_manager.update_settings(settings).await; + Ok(()) +} + +#[tauri::command] +pub async fn log_debug_message( + options: LogDebugMessageOptions, + state: State<'_, AppState>, +) -> Result<(), String> { + let debug_features_manager = &state.debug_features_manager; + debug_features_manager.log( + options.level, + &options.component, + &options.message, + options.metadata, + ).await; + Ok(()) +} + +#[tauri::command] +pub async fn record_performance_metric( + name: String, + value: f64, + unit: String, + tags: HashMap, + state: State<'_, AppState>, +) -> Result<(), String> { + let debug_features_manager = &state.debug_features_manager; + debug_features_manager.record_metric(&name, value, &unit, tags).await; + Ok(()) +} + +#[tauri::command] +pub async fn take_memory_snapshot( + state: State<'_, AppState>, +) -> Result { + let debug_features_manager = &state.debug_features_manager; + debug_features_manager.take_memory_snapshot().await +} + +#[tauri::command] +pub async fn get_debug_logs( + limit: Option, + level: Option, + state: State<'_, AppState>, +) -> Result, String> { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.get_logs(limit, level).await) +} + +#[tauri::command] +pub async fn get_performance_metrics( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.get_performance_metrics(limit).await) +} + +#[tauri::command] +pub async fn get_memory_snapshots( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.get_memory_snapshots(limit).await) +} + +#[tauri::command] +pub async fn get_network_requests( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.get_network_requests(limit).await) +} + +#[tauri::command] +pub async fn run_api_tests( + tests: Vec, + state: State<'_, AppState>, +) -> Result, String> { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.run_api_tests(tests).await) +} + +// API Testing Commands +#[tauri::command] +pub async fn get_api_test_config( + state: State<'_, AppState>, +) -> Result { + let api_testing_manager = &state.api_testing_manager; + Ok(api_testing_manager.get_config().await) +} + +#[tauri::command] +pub async fn update_api_test_config( + config: crate::api_testing::APITestRunnerConfig, + state: State<'_, AppState>, +) -> Result<(), String> { + let api_testing_manager = &state.api_testing_manager; + api_testing_manager.update_config(config).await; + Ok(()) +} + +#[tauri::command] +pub async fn add_api_test_suite( + suite: crate::api_testing::APITestSuite, + state: State<'_, AppState>, +) -> Result<(), String> { + let api_testing_manager = &state.api_testing_manager; + api_testing_manager.add_test_suite(suite).await; + Ok(()) +} + +#[tauri::command] +pub async fn get_api_test_suite( + suite_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let api_testing_manager = &state.api_testing_manager; + Ok(api_testing_manager.get_test_suite(&suite_id).await) +} + +#[tauri::command] +pub async fn list_api_test_suites( + state: State<'_, AppState>, +) -> Result, String> { + let api_testing_manager = &state.api_testing_manager; + Ok(api_testing_manager.list_test_suites().await) +} + +#[tauri::command] +pub async fn run_single_api_test( + test: crate::api_testing::APITest, + variables: HashMap, + state: State<'_, AppState>, +) -> Result { + let api_testing_manager = &state.api_testing_manager; + Ok(api_testing_manager.run_test(&test, &variables).await) +} + +#[tauri::command] +pub async fn run_api_test_suite( + suite_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let api_testing_manager = &state.api_testing_manager; + Ok(api_testing_manager.run_test_suite(&suite_id).await) +} + +#[tauri::command] +pub async fn get_api_test_history( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let api_testing_manager = &state.api_testing_manager; + Ok(api_testing_manager.get_test_history(limit).await) +} + +#[tauri::command] +pub async fn clear_api_test_history( + state: State<'_, AppState>, +) -> Result<(), String> { + let api_testing_manager = &state.api_testing_manager; + api_testing_manager.clear_test_history().await; + Ok(()) +} + +#[tauri::command] +pub async fn import_postman_collection( + json_data: String, + state: State<'_, AppState>, +) -> Result { + let api_testing_manager = &state.api_testing_manager; + api_testing_manager.import_postman_collection(&json_data).await +} + +#[tauri::command] +pub async fn export_api_test_suite( + suite_id: String, + state: State<'_, AppState>, +) -> Result { + let api_testing_manager = &state.api_testing_manager; + api_testing_manager.export_test_suite(&suite_id).await +} + +#[tauri::command] +pub async fn run_benchmarks( + configs: Vec, + state: State<'_, AppState>, +) -> Result, String> { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.run_benchmarks(configs).await) +} + +#[tauri::command] +pub async fn generate_diagnostic_report( + state: State<'_, AppState>, +) -> Result { + let debug_features_manager = &state.debug_features_manager; + Ok(debug_features_manager.generate_diagnostic_report().await) +} + +#[tauri::command] +pub async fn clear_debug_data( + state: State<'_, AppState>, +) -> Result<(), String> { + let debug_features_manager = &state.debug_features_manager; + debug_features_manager.clear_all_data().await; + Ok(()) +} + +#[tauri::command] +pub async fn set_debug_mode( + enabled: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let debug_features_manager = &state.debug_features_manager; + debug_features_manager.set_debug_mode(enabled).await; + Ok(()) +} + +#[tauri::command] +pub async fn get_debug_stats( + state: State<'_, AppState>, +) -> Result { + let debug_features_manager = &state.debug_features_manager; + + let logs = debug_features_manager.get_logs(None, None).await; + let mut logs_by_level = HashMap::new(); + for log in &logs { + let level = format!("{:?}", log.level); + *logs_by_level.entry(level).or_insert(0) += 1; + } + + let metrics = debug_features_manager.get_performance_metrics(None).await; + let snapshots = debug_features_manager.get_memory_snapshots(None).await; + let requests = debug_features_manager.get_network_requests(None).await; + + Ok(crate::debug_features::DebugStats { + total_logs: logs.len(), + logs_by_level, + total_metrics: metrics.len(), + total_snapshots: snapshots.len(), + total_requests: requests.len(), + total_test_results: 0, // TODO: Track test results + total_benchmarks: 0, // TODO: Track benchmarks + }) +} + +// Auth Cache Commands +#[derive(Debug, Serialize, Deserialize)] +pub struct StoreTokenOptions { + pub key: String, + pub token: crate::auth_cache::CachedToken, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetTokenOptions { + pub key: String, + pub scope: crate::auth_cache::AuthScope, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct StoreCredentialOptions { + pub key: String, + pub credential: crate::auth_cache::AuthCredential, +} + +#[tauri::command] +pub async fn get_auth_cache_config( + state: State<'_, AppState>, +) -> Result { + let auth_cache_manager = &state.auth_cache_manager; + Ok(auth_cache_manager.get_config().await) +} + +#[tauri::command] +pub async fn update_auth_cache_config( + config: crate::auth_cache::AuthCacheConfig, + state: State<'_, AppState>, +) -> Result<(), String> { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.update_config(config).await; + Ok(()) +} + +#[tauri::command] +pub async fn store_auth_token( + options: StoreTokenOptions, + state: State<'_, AppState>, +) -> Result<(), String> { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.store_token(&options.key, options.token).await +} + +#[tauri::command] +pub async fn get_auth_token( + options: GetTokenOptions, + state: State<'_, AppState>, +) -> Result, String> { + let auth_cache_manager = &state.auth_cache_manager; + Ok(auth_cache_manager.get_token(&options.key, &options.scope).await) +} + +#[tauri::command] +pub async fn store_auth_credential( + options: StoreCredentialOptions, + state: State<'_, AppState>, +) -> Result<(), String> { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.store_credential(&options.key, options.credential).await +} + +#[tauri::command] +pub async fn get_auth_credential( + key: String, + state: State<'_, AppState>, +) -> Result, String> { + let auth_cache_manager = &state.auth_cache_manager; + Ok(auth_cache_manager.get_credential(&key).await) +} + +#[tauri::command] +pub async fn clear_auth_cache_entry( + key: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.clear_entry(&key).await; + Ok(()) +} + +#[tauri::command] +pub async fn clear_all_auth_cache( + state: State<'_, AppState>, +) -> Result<(), String> { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.clear_all().await; + Ok(()) +} + +#[tauri::command] +pub async fn get_auth_cache_stats( + state: State<'_, AppState>, +) -> Result { + let auth_cache_manager = &state.auth_cache_manager; + Ok(auth_cache_manager.get_stats().await) +} + +#[tauri::command] +pub async fn list_auth_cache_entries( + state: State<'_, AppState>, +) -> Result, u64)>, String> { + let auth_cache_manager = &state.auth_cache_manager; + Ok(auth_cache_manager.list_entries().await) +} + +#[tauri::command] +pub async fn export_auth_cache( + state: State<'_, AppState>, +) -> Result { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.export_cache().await +} + +#[tauri::command] +pub async fn import_auth_cache( + json_data: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let auth_cache_manager = &state.auth_cache_manager; + auth_cache_manager.import_cache(&json_data).await +} + +#[tauri::command] +pub fn hash_password(password: String) -> String { + crate::auth_cache::AuthCacheManager::hash_password(&password) +} + +#[tauri::command] +pub fn create_auth_cache_key( + service: String, + username: Option, + resource: Option, +) -> String { + crate::auth_cache::create_cache_key( + &service, + username.as_deref(), + resource.as_deref(), + ) +} + +// Terminal Integrations Commands +#[tauri::command] +pub async fn detect_installed_terminals( + state: State<'_, AppState>, +) -> Result, String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + Ok(terminal_integrations_manager.detect_terminals().await) +} + +#[tauri::command] +pub async fn get_default_terminal( + state: State<'_, AppState>, +) -> Result { + let terminal_integrations_manager = &state.terminal_integrations_manager; + Ok(terminal_integrations_manager.get_default_terminal().await) +} + +#[tauri::command] +pub async fn set_default_terminal( + emulator: crate::terminal_integrations::TerminalEmulator, + state: State<'_, AppState>, +) -> Result<(), String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + terminal_integrations_manager.set_default_terminal(emulator).await +} + +#[tauri::command] +pub async fn launch_terminal_emulator( + emulator: Option, + options: crate::terminal_integrations::TerminalLaunchOptions, + state: State<'_, AppState>, +) -> Result<(), String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + terminal_integrations_manager.launch_terminal(emulator, options).await +} + +#[tauri::command] +pub async fn get_terminal_config( + emulator: crate::terminal_integrations::TerminalEmulator, + state: State<'_, AppState>, +) -> Result, String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + Ok(terminal_integrations_manager.get_terminal_config(emulator).await) +} + +#[tauri::command] +pub async fn update_terminal_config( + config: crate::terminal_integrations::TerminalConfig, + state: State<'_, AppState>, +) -> Result<(), String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + terminal_integrations_manager.update_terminal_config(config).await; + Ok(()) +} + +#[tauri::command] +pub async fn list_detected_terminals( + state: State<'_, AppState>, +) -> Result, String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + Ok(terminal_integrations_manager.list_detected_terminals().await) +} + +#[tauri::command] +pub async fn create_terminal_ssh_url( + emulator: crate::terminal_integrations::TerminalEmulator, + user: String, + host: String, + port: u16, + state: State<'_, AppState>, +) -> Result, String> { + let terminal_integrations_manager = &state.terminal_integrations_manager; + Ok(terminal_integrations_manager.create_ssh_url(emulator, &user, &host, port).await) +} + +#[tauri::command] +pub async fn get_terminal_integration_stats( + state: State<'_, AppState>, +) -> Result { + let terminal_integrations_manager = &state.terminal_integrations_manager; + + let detected = terminal_integrations_manager.list_detected_terminals().await; + let default = terminal_integrations_manager.get_default_terminal().await; + + let mut terminals_by_platform = HashMap::new(); + for info in &detected { + if let Some(config) = &info.config { + for platform in &config.platform { + terminals_by_platform + .entry(platform.clone()) + .or_insert_with(Vec::new) + .push(info.emulator); + } + } + } + + Ok(crate::terminal_integrations::TerminalIntegrationStats { + total_terminals: detected.len(), + installed_terminals: detected.iter().filter(|t| t.installed).count(), + default_terminal: default, + terminals_by_platform, + }) +} + +// Settings UI Commands +#[tauri::command] +pub async fn get_all_settings() -> Result, String> { + let settings = crate::settings::Settings::load().unwrap_or_default(); + let mut all_settings = HashMap::new(); + + all_settings.insert("general".to_string(), + serde_json::to_value(&settings.general).unwrap_or(serde_json::Value::Null)); + all_settings.insert("dashboard".to_string(), + serde_json::to_value(&settings.dashboard).unwrap_or(serde_json::Value::Null)); + all_settings.insert("advanced".to_string(), + serde_json::to_value(&settings.advanced).unwrap_or(serde_json::Value::Null)); + all_settings.insert("recording".to_string(), + serde_json::to_value(&settings.recording).unwrap_or(serde_json::Value::Null)); + all_settings.insert("tty_forward".to_string(), + serde_json::to_value(&settings.tty_forward).unwrap_or(serde_json::Value::Null)); + all_settings.insert("monitoring".to_string(), + serde_json::to_value(&settings.monitoring).unwrap_or(serde_json::Value::Null)); + all_settings.insert("network".to_string(), + serde_json::to_value(&settings.network).unwrap_or(serde_json::Value::Null)); + all_settings.insert("port".to_string(), + serde_json::to_value(&settings.port).unwrap_or(serde_json::Value::Null)); + all_settings.insert("notifications".to_string(), + serde_json::to_value(&settings.notifications).unwrap_or(serde_json::Value::Null)); + all_settings.insert("terminal_integrations".to_string(), + serde_json::to_value(&settings.terminal_integrations).unwrap_or(serde_json::Value::Null)); + all_settings.insert("updates".to_string(), + serde_json::to_value(&settings.updates).unwrap_or(serde_json::Value::Null)); + all_settings.insert("security".to_string(), + serde_json::to_value(&settings.security).unwrap_or(serde_json::Value::Null)); + all_settings.insert("debug".to_string(), + serde_json::to_value(&settings.debug).unwrap_or(serde_json::Value::Null)); + + Ok(all_settings) +} + +#[tauri::command] +pub async fn update_setting(section: String, key: String, value: String) -> Result<(), String> { + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + + // Parse the JSON value + let json_value: serde_json::Value = serde_json::from_str(&value) + .map_err(|e| format!("Invalid JSON value: {}", e))?; + + match section.as_str() { + "general" => { + match key.as_str() { + "launch_at_login" => settings.general.launch_at_login = json_value.as_bool().unwrap_or(false), + "show_dock_icon" => settings.general.show_dock_icon = json_value.as_bool().unwrap_or(true), + "default_terminal" => settings.general.default_terminal = json_value.as_str().unwrap_or("system").to_string(), + "default_shell" => settings.general.default_shell = json_value.as_str().unwrap_or("default").to_string(), + "show_welcome_on_startup" => settings.general.show_welcome_on_startup = json_value.as_bool(), + "theme" => settings.general.theme = json_value.as_str().map(|s| s.to_string()), + "language" => settings.general.language = json_value.as_str().map(|s| s.to_string()), + "check_updates_automatically" => settings.general.check_updates_automatically = json_value.as_bool(), + _ => return Err(format!("Unknown general setting: {}", key)), + } + } + "dashboard" => { + match key.as_str() { + "server_port" => settings.dashboard.server_port = json_value.as_u64().unwrap_or(4020) as u16, + "enable_password" => settings.dashboard.enable_password = json_value.as_bool().unwrap_or(false), + "password" => settings.dashboard.password = json_value.as_str().unwrap_or("").to_string(), + "access_mode" => settings.dashboard.access_mode = json_value.as_str().unwrap_or("localhost").to_string(), + "auto_cleanup" => settings.dashboard.auto_cleanup = json_value.as_bool().unwrap_or(true), + "session_limit" => settings.dashboard.session_limit = json_value.as_u64().map(|v| v as u32), + "idle_timeout_minutes" => settings.dashboard.idle_timeout_minutes = json_value.as_u64().map(|v| v as u32), + "enable_cors" => settings.dashboard.enable_cors = json_value.as_bool(), + _ => return Err(format!("Unknown dashboard setting: {}", key)), + } + } + "advanced" => { + match key.as_str() { + "server_mode" => settings.advanced.server_mode = json_value.as_str().unwrap_or("rust").to_string(), + "debug_mode" => settings.advanced.debug_mode = json_value.as_bool().unwrap_or(false), + "log_level" => settings.advanced.log_level = json_value.as_str().unwrap_or("info").to_string(), + "session_timeout" => settings.advanced.session_timeout = json_value.as_u64().unwrap_or(0) as u32, + "ngrok_auth_token" => settings.advanced.ngrok_auth_token = json_value.as_str().map(|s| s.to_string()), + "ngrok_region" => settings.advanced.ngrok_region = json_value.as_str().map(|s| s.to_string()), + "ngrok_subdomain" => settings.advanced.ngrok_subdomain = json_value.as_str().map(|s| s.to_string()), + "enable_telemetry" => settings.advanced.enable_telemetry = json_value.as_bool(), + "experimental_features" => settings.advanced.experimental_features = json_value.as_bool(), + _ => return Err(format!("Unknown advanced setting: {}", key)), + } + } + "debug" => { + // Ensure debug settings exist + if settings.debug.is_none() { + settings.debug = Some(crate::settings::DebugSettings { + enable_debug_menu: false, + show_performance_stats: false, + enable_verbose_logging: false, + log_to_file: false, + log_file_path: None, + max_log_file_size_mb: None, + enable_dev_tools: false, + show_internal_errors: false, + }); + } + + if let Some(ref mut debug) = settings.debug { + match key.as_str() { + "enable_debug_menu" => debug.enable_debug_menu = json_value.as_bool().unwrap_or(false), + "show_performance_stats" => debug.show_performance_stats = json_value.as_bool().unwrap_or(false), + "enable_verbose_logging" => debug.enable_verbose_logging = json_value.as_bool().unwrap_or(false), + "log_to_file" => debug.log_to_file = json_value.as_bool().unwrap_or(false), + "log_file_path" => debug.log_file_path = json_value.as_str().map(|s| s.to_string()), + "max_log_file_size_mb" => debug.max_log_file_size_mb = json_value.as_u64().map(|v| v as u32), + "enable_dev_tools" => debug.enable_dev_tools = json_value.as_bool().unwrap_or(false), + "show_internal_errors" => debug.show_internal_errors = json_value.as_bool().unwrap_or(false), + _ => return Err(format!("Unknown debug setting: {}", key)), + } + } + } + _ => return Err(format!("Unknown settings section: {}", section)), + } + + settings.save() +} + +#[tauri::command] +pub async fn set_dashboard_password(password: String, state: State<'_, AppState>) -> Result<(), String> { + // Update settings + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.dashboard.password = password.clone(); + settings.dashboard.enable_password = !password.is_empty(); + settings.save()?; + + // Update the running server's auth configuration if it's running + let server = state.http_server.read().await; + if server.is_some() { + drop(server); + // Restart server to apply new auth settings + restart_server(state).await?; + } + + Ok(()) +} + +#[tauri::command] +pub async fn restart_server_with_port(port: u16, state: State<'_, AppState>) -> Result<(), String> { + // Update settings with new port + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.dashboard.server_port = port; + settings.save()?; + + // Restart the server + restart_server(state).await +} + +#[tauri::command] +pub async fn update_server_bind_address(address: String, state: State<'_, AppState>) -> Result<(), String> { + // Update settings + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.dashboard.access_mode = if address == "127.0.0.1" { "localhost" } else { "network" }.to_string(); + settings.save()?; + + // Restart server to apply new bind address + restart_server(state).await +} + +#[tauri::command] +pub async fn set_dock_icon_visibility(visible: bool, app_handle: tauri::AppHandle) -> Result<(), String> { + // Update settings + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.general.show_dock_icon = visible; + settings.save()?; + + // Apply the change + update_dock_icon_visibility(app_handle).await +} + +#[tauri::command] +pub async fn set_log_level(level: String) -> Result<(), String> { + // Update settings + let mut settings = crate::settings::Settings::load().unwrap_or_default(); + settings.advanced.log_level = level.clone(); + settings.save()?; + + // TODO: Apply the log level change to the running logger + tracing::info!("Log level changed to: {}", level); + + Ok(()) +} + +#[tauri::command] +pub async fn test_api_endpoint(endpoint: String, state: State<'_, AppState>) -> Result { + let server = state.http_server.read().await; + + if let Some(http_server) = server.as_ref() { + let port = http_server.port(); + let url = format!("http://localhost:{}{}", port, endpoint); + + // Create a simple HTTP client request + let client = reqwest::Client::new(); + let response = client.get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status(); + let body = response.text().await.unwrap_or_else(|_| "Failed to read body".to_string()); + + // Try to parse as JSON, fallback to text + let json_body = serde_json::from_str::(&body) + .unwrap_or_else(|_| serde_json::json!({ "body": body })); + + Ok(serde_json::json!({ + "status": status.as_u16(), + "endpoint": endpoint, + "response": json_body, + })) + } else { + Err("Server is not running".to_string()) + } +} + +#[derive(Debug, Serialize, Clone)] +pub struct ServerLog { + pub timestamp: String, + pub level: String, + pub message: String, +} + +#[tauri::command] +pub async fn get_server_logs(limit: usize) -> Result, String> { + // TODO: Implement actual log collection from the server + // For now, return dummy logs for the UI + let logs = vec![ + ServerLog { + timestamp: chrono::Utc::now().to_rfc3339(), + level: "info".to_string(), + message: "Server started on port 4020".to_string(), + }, + ServerLog { + timestamp: chrono::Utc::now().to_rfc3339(), + level: "info".to_string(), + message: "Health check endpoint accessed".to_string(), + }, + ]; + + Ok(logs.into_iter().take(limit).collect()) +} + +#[tauri::command] +pub async fn export_logs(app_handle: tauri::AppHandle) -> Result<(), String> { + // Get logs + let logs = get_server_logs(1000).await?; + + // Convert to text format + let log_text = logs.into_iter() + .map(|log| format!("[{}] {} - {}", log.timestamp, log.level.to_uppercase(), log.message)) + .collect::>() + .join("\n"); + + // Save to file + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let filename = format!("vibetunnel_logs_{}.txt", timestamp); + + use tauri::api::dialog::blocking::FileDialogBuilder; + if let Some(path) = FileDialogBuilder::new() + .set_file_name(&filename) + .set_title("Export Logs") + .save_file() { + std::fs::write(path, log_text).map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[tauri::command] +pub async fn get_local_ip() -> Result { + get_local_ip_address().await.map(|opt| opt.unwrap_or_else(|| "127.0.0.1".to_string())) +} + +#[tauri::command] +pub async fn detect_terminals() -> Result, String> { + Ok(crate::terminal_detector::detect_terminals()) } \ No newline at end of file diff --git a/tauri/src-tauri/src/debug_features.rs b/tauri/src-tauri/src/debug_features.rs new file mode 100644 index 00000000..5dc4eef8 --- /dev/null +++ b/tauri/src-tauri/src/debug_features.rs @@ -0,0 +1,648 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::{HashMap, VecDeque}; +use chrono::{DateTime, Utc}; +use std::path::PathBuf; + +/// Debug feature types +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum DebugFeature { + APITesting, + PerformanceMonitoring, + MemoryProfiling, + NetworkInspector, + EventLogger, + StateInspector, + LogViewer, + CrashReporter, + BenchmarkRunner, + DiagnosticReport, +} + +/// Debug log level +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +/// Debug log entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + pub timestamp: DateTime, + pub level: LogLevel, + pub component: String, + pub message: String, + pub metadata: HashMap, +} + +/// Performance metric +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceMetric { + pub name: String, + pub value: f64, + pub unit: String, + pub timestamp: DateTime, + pub tags: HashMap, +} + +/// Memory snapshot +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemorySnapshot { + pub timestamp: DateTime, + pub heap_used_mb: f64, + pub heap_total_mb: f64, + pub external_mb: f64, + pub process_rss_mb: f64, + pub details: HashMap, +} + +/// Network request log +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkRequest { + pub id: String, + pub timestamp: DateTime, + pub method: String, + pub url: String, + pub status: Option, + pub duration_ms: Option, + pub request_headers: HashMap, + pub response_headers: HashMap, + pub request_body: Option, + pub response_body: Option, + pub error: Option, +} + +/// API test case +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestCase { + pub id: String, + pub name: String, + pub endpoint: String, + pub method: String, + pub headers: HashMap, + pub body: Option, + pub expected_status: u16, + pub expected_body: Option, + pub timeout_ms: u64, +} + +/// API test result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APITestResult { + pub test_id: String, + pub success: bool, + pub actual_status: Option, + pub actual_body: Option, + pub duration_ms: u64, + pub error: Option, + pub timestamp: DateTime, +} + +/// Benchmark configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkConfig { + pub name: String, + pub iterations: u32, + pub warmup_iterations: u32, + pub timeout_ms: u64, + pub collect_memory: bool, + pub collect_cpu: bool, +} + +/// Benchmark result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkResult { + pub name: String, + pub iterations: u32, + pub mean_ms: f64, + pub median_ms: f64, + pub min_ms: f64, + pub max_ms: f64, + pub std_dev_ms: f64, + pub memory_usage_mb: Option, + pub cpu_usage_percent: Option, + pub timestamp: DateTime, +} + +/// Diagnostic report +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnosticReport { + pub timestamp: DateTime, + pub system_info: SystemInfo, + pub app_info: AppInfo, + pub performance_summary: PerformanceSummary, + pub error_summary: ErrorSummary, + pub recommendations: Vec, +} + +/// System information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemInfo { + pub os: String, + pub arch: String, + pub cpu_count: usize, + pub total_memory_mb: u64, + pub available_memory_mb: u64, + pub disk_space_mb: u64, + pub node_version: Option, + pub rust_version: String, +} + +/// Application information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppInfo { + pub version: String, + pub build_date: String, + pub uptime_seconds: u64, + pub active_sessions: usize, + pub total_requests: u64, + pub error_count: u64, +} + +/// Performance summary +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceSummary { + pub avg_response_time_ms: f64, + pub p95_response_time_ms: f64, + pub p99_response_time_ms: f64, + pub requests_per_second: f64, + pub cpu_usage_percent: f64, + pub memory_usage_mb: f64, +} + +/// Error summary +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorSummary { + pub total_errors: u64, + pub errors_by_type: HashMap, + pub recent_errors: Vec, +} + +/// Debug settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugSettings { + pub enabled: bool, + pub log_level: LogLevel, + pub max_log_entries: usize, + pub enable_performance_monitoring: bool, + pub enable_memory_profiling: bool, + pub enable_network_inspector: bool, + pub enable_crash_reporting: bool, + pub log_to_file: bool, + pub log_file_path: Option, + pub performance_sample_interval_ms: u64, + pub memory_sample_interval_ms: u64, +} + +impl Default for DebugSettings { + fn default() -> Self { + Self { + enabled: cfg!(debug_assertions), + log_level: LogLevel::Info, + max_log_entries: 10000, + enable_performance_monitoring: false, + enable_memory_profiling: false, + enable_network_inspector: false, + enable_crash_reporting: true, + log_to_file: false, + log_file_path: None, + performance_sample_interval_ms: 1000, + memory_sample_interval_ms: 5000, + } + } +} + +/// Debug features manager +pub struct DebugFeaturesManager { + settings: Arc>, + logs: Arc>>, + performance_metrics: Arc>>, + memory_snapshots: Arc>>, + network_requests: Arc>>, + api_test_results: Arc>>>, + benchmark_results: Arc>>, + notification_manager: Option>, +} + +impl DebugFeaturesManager { + /// Create a new debug features manager + pub fn new() -> Self { + Self { + settings: Arc::new(RwLock::new(DebugSettings::default())), + logs: Arc::new(RwLock::new(VecDeque::new())), + performance_metrics: Arc::new(RwLock::new(VecDeque::new())), + memory_snapshots: Arc::new(RwLock::new(VecDeque::new())), + network_requests: Arc::new(RwLock::new(HashMap::new())), + api_test_results: Arc::new(RwLock::new(HashMap::new())), + benchmark_results: Arc::new(RwLock::new(Vec::new())), + notification_manager: None, + } + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Get debug settings + pub async fn get_settings(&self) -> DebugSettings { + self.settings.read().await.clone() + } + + /// Update debug settings + pub async fn update_settings(&self, settings: DebugSettings) { + *self.settings.write().await = settings; + } + + /// Log a message + pub async fn log(&self, level: LogLevel, component: &str, message: &str, metadata: HashMap) { + let settings = self.settings.read().await; + + // Check if logging is enabled and level is appropriate + if !settings.enabled || level < settings.log_level { + return; + } + + let entry = LogEntry { + timestamp: Utc::now(), + level, + component: component.to_string(), + message: message.to_string(), + metadata, + }; + + // Add to in-memory log + let mut logs = self.logs.write().await; + logs.push_back(entry.clone()); + + // Limit log size + while logs.len() > settings.max_log_entries { + logs.pop_front(); + } + + // Log to file if enabled + if settings.log_to_file { + if let Some(path) = &settings.log_file_path { + let _ = self.write_log_to_file(&entry, path).await; + } + } + } + + /// Record a performance metric + pub async fn record_metric(&self, name: &str, value: f64, unit: &str, tags: HashMap) { + let settings = self.settings.read().await; + + if !settings.enabled || !settings.enable_performance_monitoring { + return; + } + + let metric = PerformanceMetric { + name: name.to_string(), + value, + unit: unit.to_string(), + timestamp: Utc::now(), + tags, + }; + + let mut metrics = self.performance_metrics.write().await; + metrics.push_back(metric); + + // Keep only last 1000 metrics + while metrics.len() > 1000 { + metrics.pop_front(); + } + } + + /// Take a memory snapshot + pub async fn take_memory_snapshot(&self) -> Result { + let settings = self.settings.read().await; + + if !settings.enabled || !settings.enable_memory_profiling { + return Err("Memory profiling is disabled".to_string()); + } + + // TODO: Implement actual memory profiling + let snapshot = MemorySnapshot { + timestamp: Utc::now(), + heap_used_mb: 0.0, + heap_total_mb: 0.0, + external_mb: 0.0, + process_rss_mb: 0.0, + details: HashMap::new(), + }; + + let mut snapshots = self.memory_snapshots.write().await; + snapshots.push_back(snapshot.clone()); + + // Keep only last 100 snapshots + while snapshots.len() > 100 { + snapshots.pop_front(); + } + + Ok(snapshot) + } + + /// Log a network request + pub async fn log_network_request(&self, request: NetworkRequest) { + let settings = self.settings.read().await; + + if !settings.enabled || !settings.enable_network_inspector { + return; + } + + let mut requests = self.network_requests.write().await; + requests.insert(request.id.clone(), request); + + // Keep only last 500 requests + if requests.len() > 500 { + // Remove oldest entries + let mut ids: Vec<_> = requests.keys().cloned().collect(); + ids.sort(); + for id in ids.iter().take(requests.len() - 500) { + requests.remove(id); + } + } + } + + /// Run API tests + pub async fn run_api_tests(&self, tests: Vec) -> Vec { + let mut results = Vec::new(); + + for test in tests { + let result = self.run_single_api_test(&test).await; + results.push(result.clone()); + + // Store result + let mut test_results = self.api_test_results.write().await; + test_results.entry(test.id.clone()) + .or_insert_with(Vec::new) + .push(result); + } + + results + } + + /// Run a single API test + async fn run_single_api_test(&self, test: &APITestCase) -> APITestResult { + let start = std::time::Instant::now(); + + // TODO: Implement actual API testing + let duration_ms = start.elapsed().as_millis() as u64; + + APITestResult { + test_id: test.id.clone(), + success: false, + actual_status: None, + actual_body: None, + duration_ms, + error: Some("API testing not yet implemented".to_string()), + timestamp: Utc::now(), + } + } + + /// Run benchmarks + pub async fn run_benchmarks(&self, configs: Vec) -> Vec { + let mut results = Vec::new(); + + for config in configs { + let result = self.run_single_benchmark(&config).await; + results.push(result.clone()); + + // Store result + self.benchmark_results.write().await.push(result); + } + + results + } + + /// Run a single benchmark + async fn run_single_benchmark(&self, config: &BenchmarkConfig) -> BenchmarkResult { + // TODO: Implement actual benchmarking + BenchmarkResult { + name: config.name.clone(), + iterations: config.iterations, + mean_ms: 0.0, + median_ms: 0.0, + min_ms: 0.0, + max_ms: 0.0, + std_dev_ms: 0.0, + memory_usage_mb: None, + cpu_usage_percent: None, + timestamp: Utc::now(), + } + } + + /// Generate diagnostic report + pub async fn generate_diagnostic_report(&self) -> DiagnosticReport { + let system_info = self.get_system_info().await; + let app_info = self.get_app_info().await; + let performance_summary = self.get_performance_summary().await; + let error_summary = self.get_error_summary().await; + let recommendations = self.generate_recommendations(&system_info, &app_info, &performance_summary, &error_summary); + + DiagnosticReport { + timestamp: Utc::now(), + system_info, + app_info, + performance_summary, + error_summary, + recommendations, + } + } + + /// Get recent logs + pub async fn get_logs(&self, limit: Option, level: Option) -> Vec { + let logs = self.logs.read().await; + let iter = logs.iter().rev(); + + let filtered: Vec<_> = if let Some(min_level) = level { + iter.filter(|log| log.level >= min_level).cloned().collect() + } else { + iter.cloned().collect() + }; + + match limit { + Some(n) => filtered.into_iter().take(n).collect(), + None => filtered, + } + } + + /// Get performance metrics + pub async fn get_performance_metrics(&self, limit: Option) -> Vec { + let metrics = self.performance_metrics.read().await; + match limit { + Some(n) => metrics.iter().rev().take(n).cloned().collect(), + None => metrics.iter().cloned().collect(), + } + } + + /// Get memory snapshots + pub async fn get_memory_snapshots(&self, limit: Option) -> Vec { + let snapshots = self.memory_snapshots.read().await; + match limit { + Some(n) => snapshots.iter().rev().take(n).cloned().collect(), + None => snapshots.iter().cloned().collect(), + } + } + + /// Get network requests + pub async fn get_network_requests(&self, limit: Option) -> Vec { + let requests = self.network_requests.read().await; + let mut sorted: Vec<_> = requests.values().cloned().collect(); + sorted.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + match limit { + Some(n) => sorted.into_iter().take(n).collect(), + None => sorted, + } + } + + /// Clear all debug data + pub async fn clear_all_data(&self) { + self.logs.write().await.clear(); + self.performance_metrics.write().await.clear(); + self.memory_snapshots.write().await.clear(); + self.network_requests.write().await.clear(); + self.api_test_results.write().await.clear(); + self.benchmark_results.write().await.clear(); + } + + /// Enable/disable debug mode + pub async fn set_debug_mode(&self, enabled: bool) { + self.settings.write().await.enabled = enabled; + + if let Some(notification_manager) = &self.notification_manager { + let message = if enabled { + "Debug mode enabled" + } else { + "Debug mode disabled" + }; + let _ = notification_manager.notify_success("Debug Mode", message).await; + } + } + + // Helper methods + async fn write_log_to_file(&self, entry: &LogEntry, path: &PathBuf) -> Result<(), String> { + use tokio::io::AsyncWriteExt; + + let log_line = format!( + "[{}] [{}] [{}] {}\n", + entry.timestamp.format("%Y-%m-%d %H:%M:%S%.3f"), + format!("{:?}", entry.level), + entry.component, + entry.message + ); + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .await + .map_err(|e| e.to_string())?; + + file.write_all(log_line.as_bytes()).await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + async fn get_system_info(&self) -> SystemInfo { + SystemInfo { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + cpu_count: num_cpus::get(), + total_memory_mb: 0, // TODO: Get actual memory + available_memory_mb: 0, + disk_space_mb: 0, + node_version: None, + rust_version: "1.70.0".to_string(), // TODO: Get actual rust version + } + } + + async fn get_app_info(&self) -> AppInfo { + AppInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + build_date: chrono::Utc::now().to_rfc3339(), // TODO: Get actual build date + uptime_seconds: 0, // TODO: Track uptime + active_sessions: 0, + total_requests: 0, + error_count: 0, + } + } + + async fn get_performance_summary(&self) -> PerformanceSummary { + PerformanceSummary { + avg_response_time_ms: 0.0, + p95_response_time_ms: 0.0, + p99_response_time_ms: 0.0, + requests_per_second: 0.0, + cpu_usage_percent: 0.0, + memory_usage_mb: 0.0, + } + } + + async fn get_error_summary(&self) -> ErrorSummary { + let logs = self.logs.read().await; + let errors: Vec<_> = logs.iter() + .filter(|log| log.level == LogLevel::Error) + .cloned() + .collect(); + + let mut errors_by_type = HashMap::new(); + for error in &errors { + let error_type = error.metadata.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + *errors_by_type.entry(error_type).or_insert(0) += 1; + } + + ErrorSummary { + total_errors: errors.len() as u64, + errors_by_type, + recent_errors: errors.into_iter().rev().take(10).collect(), + } + } + + fn generate_recommendations(&self, system: &SystemInfo, _app: &AppInfo, perf: &PerformanceSummary, errors: &ErrorSummary) -> Vec { + let mut recommendations = Vec::new(); + + if perf.cpu_usage_percent > 80.0 { + 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) { + recommendations.push("High memory usage detected. Check for memory leaks.".to_string()); + } + + if errors.total_errors > 100 { + recommendations.push("High error rate detected. Review error logs for patterns.".to_string()); + } + + if perf.avg_response_time_ms > 1000.0 { + recommendations.push("Slow response times detected. Consider caching or query optimization.".to_string()); + } + + recommendations + } +} + +/// Debug statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugStats { + pub total_logs: usize, + pub logs_by_level: HashMap, + pub total_metrics: usize, + pub total_snapshots: usize, + pub total_requests: usize, + pub total_test_results: usize, + pub total_benchmarks: usize, +} + +// Re-export num_cpus if needed +extern crate num_cpus; \ No newline at end of file diff --git a/tauri/src-tauri/src/fs_api.rs b/tauri/src-tauri/src/fs_api.rs new file mode 100644 index 00000000..5ef2d9f9 --- /dev/null +++ b/tauri/src-tauri/src/fs_api.rs @@ -0,0 +1,428 @@ +use axum::{ + extract::{Path, Query, State as AxumState}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tokio::fs; +use tokio::io::AsyncReadExt; + +#[derive(Debug, Deserialize)] +pub struct FileQuery { + pub path: String, +} + +#[derive(Debug, Serialize)] +pub struct FileMetadata { + pub name: String, + pub path: String, + pub size: u64, + pub is_dir: bool, + pub is_file: bool, + pub is_symlink: bool, + pub readonly: bool, + pub hidden: bool, + pub created: Option, + pub modified: Option, + pub accessed: Option, + #[cfg(unix)] + pub permissions: Option, + pub mime_type: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MoveRequest { + pub from: String, + pub to: String, +} + +#[derive(Debug, Deserialize)] +pub struct CopyRequest { + pub from: String, + pub to: String, + pub overwrite: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WriteFileRequest { + pub path: String, + pub content: String, + pub encoding: Option, +} + +#[derive(Debug, Serialize)] +pub struct OperationResult { + pub success: bool, + pub message: String, +} + +/// Expand tilde to home directory +fn expand_path(path: &str) -> Result { + if path.starts_with('~') { + let home = dirs::home_dir() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(home.join(path.strip_prefix("~/").unwrap_or(""))) + } else { + Ok(PathBuf::from(path)) + } +} + +/// Get detailed file metadata +pub async fn get_file_info( + Query(params): Query, +) -> Result, StatusCode> { + let path = expand_path(¶ms.path)?; + + let metadata = fs::metadata(&path).await + .map_err(|_| StatusCode::NOT_FOUND)?; + + let name = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + + let is_symlink = fs::symlink_metadata(&path).await + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + + let hidden = name.starts_with('.'); + + let created = metadata.created() + .map(|t| { + let datetime: chrono::DateTime = t.into(); + datetime.to_rfc3339() + }) + .ok(); + + let modified = metadata.modified() + .map(|t| { + let datetime: chrono::DateTime = t.into(); + datetime.to_rfc3339() + }) + .ok(); + + let accessed = metadata.accessed() + .map(|t| { + let datetime: chrono::DateTime = t.into(); + datetime.to_rfc3339() + }) + .ok(); + + #[cfg(unix)] + let permissions = { + use std::os::unix::fs::PermissionsExt; + Some(format!("{:o}", metadata.permissions().mode() & 0o777)) + }; + + let mime_type = if metadata.is_file() { + // Simple MIME type detection based on extension + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + Some(match ext { + "txt" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + "json" => "application/json", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "pdf" => "application/pdf", + "zip" => "application/zip", + _ => "application/octet-stream", + }.to_string()) + } else { + None + }; + + Ok(Json(FileMetadata { + name, + path: path.to_string_lossy().to_string(), + size: metadata.len(), + is_dir: metadata.is_dir(), + is_file: metadata.is_file(), + is_symlink, + readonly: metadata.permissions().readonly(), + hidden, + created, + modified, + accessed, + #[cfg(unix)] + permissions, + #[cfg(not(unix))] + permissions: None, + mime_type, + })) +} + +/// Read file contents +pub async fn read_file( + Query(params): Query, +) -> Result { + let path = expand_path(¶ms.path)?; + + // Check if file exists and is a file + let metadata = fs::metadata(&path).await + .map_err(|_| StatusCode::NOT_FOUND)?; + + if !metadata.is_file() { + return Err(StatusCode::BAD_REQUEST); + } + + // Read file contents + let mut file = fs::File::open(&path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut contents = Vec::new(); + file.read_to_end(&mut contents).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Determine content type + let content_type = path.extension() + .and_then(|e| e.to_str()) + .and_then(|ext| match ext { + "txt" => Some("text/plain"), + "html" | "htm" => Some("text/html"), + "css" => Some("text/css"), + "js" => Some("application/javascript"), + "json" => Some("application/json"), + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "pdf" => Some("application/pdf"), + _ => None, + }) + .unwrap_or("application/octet-stream"); + + Ok(( + [(header::CONTENT_TYPE, content_type)], + contents, + ).into_response()) +} + +/// Write file contents +pub async fn write_file( + Json(req): Json, +) -> Result, StatusCode> { + let path = expand_path(&req.path)?; + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + // Write file + let content = if req.encoding.as_deref() == Some("base64") { + base64::decode(&req.content) + .map_err(|_| StatusCode::BAD_REQUEST)? + } else { + req.content.into_bytes() + }; + + fs::write(&path, content).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(OperationResult { + success: true, + message: format!("File written successfully: {}", path.display()), + })) +} + +/// Delete file or directory +pub async fn delete_file( + Query(params): Query, +) -> Result, StatusCode> { + let path = expand_path(¶ms.path)?; + + // Check if path exists + let metadata = fs::metadata(&path).await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Delete based on type + if metadata.is_dir() { + fs::remove_dir_all(&path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } else { + fs::remove_file(&path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + Ok(Json(OperationResult { + success: true, + message: format!("Deleted: {}", path.display()), + })) +} + +/// Move/rename file or directory +pub async fn move_file( + Json(req): Json, +) -> Result, StatusCode> { + let from_path = expand_path(&req.from)?; + let to_path = expand_path(&req.to)?; + + // Check if source exists + if !from_path.exists() { + return Err(StatusCode::NOT_FOUND); + } + + // Check if destination already exists + if to_path.exists() { + return Err(StatusCode::CONFLICT); + } + + // Ensure destination parent directory exists + if let Some(parent) = to_path.parent() { + fs::create_dir_all(parent).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + // Move the file/directory + fs::rename(&from_path, &to_path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(OperationResult { + success: true, + message: format!("Moved from {} to {}", from_path.display(), to_path.display()), + })) +} + +/// Copy file or directory +pub async fn copy_file( + Json(req): Json, +) -> Result, StatusCode> { + let from_path = expand_path(&req.from)?; + let to_path = expand_path(&req.to)?; + + // Check if source exists + let metadata = fs::metadata(&from_path).await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Check if destination already exists + if to_path.exists() && !req.overwrite.unwrap_or(false) { + return Err(StatusCode::CONFLICT); + } + + // Ensure destination parent directory exists + if let Some(parent) = to_path.parent() { + fs::create_dir_all(parent).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + // Copy based on type + if metadata.is_file() { + fs::copy(&from_path, &to_path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } else if metadata.is_dir() { + // Recursive directory copy + copy_dir_recursive(&from_path, &to_path).await?; + } + + Ok(Json(OperationResult { + success: true, + message: format!("Copied from {} to {}", from_path.display(), to_path.display()), + })) +} + +/// Recursively copy a directory +async fn copy_dir_recursive(from: &PathBuf, to: &PathBuf) -> Result<(), StatusCode> { + fs::create_dir_all(to).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut entries = fs::read_dir(from).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + while let Some(entry) = entries.next_entry().await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? { + + let from_path = entry.path(); + let to_path = to.join(entry.file_name()); + + let metadata = entry.metadata().await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if metadata.is_file() { + fs::copy(&from_path, &to_path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } else if metadata.is_dir() { + Box::pin(copy_dir_recursive(&from_path, &to_path)).await?; + } + } + + Ok(()) +} + +/// Search for files matching a pattern +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub path: String, + pub pattern: String, + pub max_depth: Option, +} + +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub path: String, + pub name: String, + pub is_dir: bool, + pub size: u64, +} + +pub async fn search_files( + Query(params): Query, +) -> Result>, StatusCode> { + let base_path = expand_path(¶ms.path)?; + let pattern = params.pattern.to_lowercase(); + let max_depth = params.max_depth.unwrap_or(5); + + let mut results = Vec::new(); + search_recursive(&base_path, &pattern, 0, max_depth, &mut results).await?; + + Ok(Json(results)) +} + +async fn search_recursive( + path: &PathBuf, + pattern: &str, + depth: u32, + max_depth: u32, + results: &mut Vec, +) -> Result<(), StatusCode> { + if depth > max_depth { + return Ok(()); + } + + let mut entries = fs::read_dir(path).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + while let Some(entry) = entries.next_entry().await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? { + + let entry_path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name.to_lowercase().contains(pattern) { + let metadata = entry.metadata().await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + results.push(SearchResult { + path: entry_path.to_string_lossy().to_string(), + name: file_name, + is_dir: metadata.is_dir(), + size: metadata.len(), + }); + } + + // Recurse into directories + if entry.file_type().await + .map(|t| t.is_dir()) + .unwrap_or(false) { + Box::pin(search_recursive(&entry_path, pattern, depth + 1, max_depth, results)).await?; + } + } + + Ok(()) +} \ No newline at end of file diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs index cbc86237..22db7788 100644 --- a/tauri/src-tauri/src/lib.rs +++ b/tauri/src-tauri/src/lib.rs @@ -9,6 +9,20 @@ 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 auth_cache; +pub mod terminal_integrations; #[cfg(mobile)] pub fn init() { diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 555fbc3e..cb3e8169 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -19,6 +19,23 @@ 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 auth_cache; +mod terminal_integrations; +mod app_mover; +mod terminal_spawn_service; +mod fs_api; use commands::*; use state::AppState; @@ -40,8 +57,8 @@ fn open_settings_window(app: AppHandle) -> Result<(), String> { ) .title("VibeTunnel Settings") .inner_size(800.0, 600.0) - .resizable(false) - .decorations(false) + .resizable(true) + .decorations(true) .center() .build() .map_err(|e| e.to_string())?; @@ -108,8 +125,197 @@ fn main() { cli_installer::install_cli, cli_installer::uninstall_cli, cli_installer::check_cli_installed, + start_terminal_recording, + stop_terminal_recording, + save_terminal_recording, + get_recording_status, + start_tty_forward, + stop_tty_forward, + list_tty_forwards, + get_tty_forward, + get_session_stats, + get_monitored_sessions, + start_session_monitoring, + check_port_availability, + detect_port_conflict, + resolve_port_conflict, + force_kill_process, + find_available_ports, + get_local_ip_address, + get_all_ip_addresses, + get_network_interfaces, + get_hostname, + test_network_connectivity, + get_network_stats, + show_notification, + get_notifications, + get_notification_history, + mark_notification_as_read, + mark_all_notifications_as_read, + clear_notification, + clear_all_notifications, + get_unread_notification_count, + update_notification_settings, + get_notification_settings, + get_welcome_state, + should_show_welcome, + get_tutorials, + get_tutorial_category, + complete_tutorial_step, + skip_tutorial, + reset_tutorial, + get_tutorial_progress, + show_welcome_window, + get_recording_settings, + save_recording_settings, + get_all_advanced_settings, + update_advanced_settings, + reset_settings_section, + export_settings, + import_settings, + check_all_permissions, + check_permission, + request_permission, + get_permission_info, + get_all_permissions, + get_required_permissions, + get_missing_required_permissions, + all_required_permissions_granted, + open_system_permission_settings, + get_permission_stats, + check_for_updates, + download_update, + install_update, + cancel_update, + get_update_state, + get_updater_settings, + update_updater_settings, + switch_update_channel, + get_update_history, + get_available_backends, + get_backend_config, + is_backend_installed, + install_backend, + start_backend, + stop_backend, + switch_backend, + get_active_backend, + get_backend_instances, + check_backend_health, + get_backend_stats, + get_debug_settings, + update_debug_settings, + log_debug_message, + record_performance_metric, + take_memory_snapshot, + get_debug_logs, + get_performance_metrics, + get_memory_snapshots, + get_network_requests, + run_api_tests, + run_benchmarks, + generate_diagnostic_report, + clear_debug_data, + set_debug_mode, + get_debug_stats, + get_api_test_config, + update_api_test_config, + add_api_test_suite, + get_api_test_suite, + list_api_test_suites, + run_single_api_test, + run_api_test_suite, + get_api_test_history, + clear_api_test_history, + import_postman_collection, + export_api_test_suite, + get_auth_cache_config, + update_auth_cache_config, + store_auth_token, + get_auth_token, + store_auth_credential, + get_auth_credential, + clear_auth_cache_entry, + clear_all_auth_cache, + get_auth_cache_stats, + list_auth_cache_entries, + export_auth_cache, + import_auth_cache, + hash_password, + create_auth_cache_key, + detect_installed_terminals, + get_default_terminal, + set_default_terminal, + launch_terminal_emulator, + get_terminal_config, + update_terminal_config, + list_detected_terminals, + create_terminal_ssh_url, + get_terminal_integration_stats, + // Settings UI Commands + get_all_settings, + update_setting, + set_dashboard_password, + restart_server_with_port, + update_server_bind_address, + set_dock_icon_visibility, + set_log_level, + test_api_endpoint, + get_server_logs, + export_logs, + get_local_ip, + detect_terminals, + // App Mover Commands + app_mover::prompt_move_to_applications, + app_mover::is_in_applications_folder, + // Terminal Spawn Service Commands + terminal_spawn_service::spawn_terminal_for_session, + terminal_spawn_service::spawn_terminal_with_command, + terminal_spawn_service::spawn_custom_terminal, ]) .setup(|app| { + // Set app handle in managers + let state = app.state::(); + let notification_manager = state.notification_manager.clone(); + let welcome_manager = state.welcome_manager.clone(); + let permissions_manager = state.permissions_manager.clone(); + let update_manager = state.update_manager.clone(); + let app_handle = app.handle().clone(); + let app_handle2 = app.handle().clone(); + let app_handle3 = app.handle().clone(); + let app_handle4 = app.handle().clone(); + let app_handle_for_move = app.handle().clone(); + tauri::async_runtime::spawn(async move { + notification_manager.set_app_handle(app_handle).await; + welcome_manager.set_app_handle(app_handle2).await; + permissions_manager.set_app_handle(app_handle3).await; + update_manager.set_app_handle(app_handle4).await; + + // Load welcome state and check if should show welcome + let _ = welcome_manager.load_state().await; + if welcome_manager.should_show_welcome().await { + let _ = welcome_manager.show_welcome_window().await; + } + + // Check permissions on startup + let _ = permissions_manager.check_all_permissions().await; + + // Check if app should be moved to Applications folder (macOS only) + #[cfg(target_os = "macos")] + { + let app_handle_move = app_handle_for_move.clone(); + tokio::spawn(async move { + // Small delay to let the app fully initialize + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + let _ = app_mover::check_and_prompt_move(app_handle_move).await; + }); + } + + // Load updater settings and start auto-check + let _ = update_manager.load_settings().await; + update_manager.clone().start_auto_check().await; + }); + // 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 tray_icon = if let Ok(icon_data) = std::fs::read(&icon_path) { @@ -370,9 +576,16 @@ async fn start_server_with_monitoring(app_handle: AppHandle) { // Update tray menu with server status update_tray_menu_status(&app_handle, status.port, 0); + + // Show notification + let _ = state.notification_manager.notify_server_status(true, status.port).await; } Err(e) => { tracing::error!("Failed to start server: {}", e); + let _ = state.notification_manager.notify_error( + "Server Start Failed", + &format!("Failed to start server: {}", e) + ).await; } } @@ -416,11 +629,18 @@ async fn start_server_with_monitoring(app_handle: AppHandle) { // Notify frontend of server restart if let Some(window) = monitoring_app.get_webview_window("main") { - let _ = window.emit("server:restarted", status); + let _ = window.emit("server:restarted", &status); } + + // Show notification + let _ = monitoring_state.notification_manager.notify_server_status(true, status.port).await; } Err(e) => { tracing::error!("Failed to restart server: {}", e); + let _ = monitoring_state.notification_manager.notify_error( + "Server Restart Failed", + &format!("Failed to restart server: {}", e) + ).await; } } } @@ -436,11 +656,18 @@ async fn start_server_with_monitoring(app_handle: AppHandle) { // Notify frontend of server restart if let Some(window) = monitoring_app.get_webview_window("main") { - let _ = window.emit("server:restarted", status); + let _ = window.emit("server:restarted", &status); } + + // Show notification + let _ = monitoring_state.notification_manager.notify_server_status(true, status.port).await; } Err(e) => { tracing::error!("Failed to start server: {}", e); + let _ = monitoring_state.notification_manager.notify_error( + "Server Start Failed", + &format!("Failed to start server: {}", e) + ).await; } } } @@ -501,9 +728,9 @@ async fn start_server_internal(state: &AppState) -> Result // Start HTTP server with auth if configured let mut http_server = if settings.dashboard.enable_password && !settings.dashboard.password.is_empty() { let auth_config = crate::auth::AuthConfig::new(true, Some(settings.dashboard.password)); - HttpServer::with_auth(state.terminal_manager.clone(), auth_config) + HttpServer::with_auth(state.terminal_manager.clone(), state.session_monitor.clone(), auth_config) } else { - HttpServer::new(state.terminal_manager.clone()) + HttpServer::new(state.terminal_manager.clone(), state.session_monitor.clone()) }; // Start server with appropriate access mode diff --git a/tauri/src-tauri/src/network_utils.rs b/tauri/src-tauri/src/network_utils.rs new file mode 100644 index 00000000..b9e30daa --- /dev/null +++ b/tauri/src-tauri/src/network_utils.rs @@ -0,0 +1,289 @@ +use serde::{Serialize, Deserialize}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use tracing::error; + +/// Network interface information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkInterface { + pub name: String, + pub addresses: Vec, + pub is_up: bool, + pub is_loopback: bool, +} + +/// IP address with type information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpAddress { + pub address: String, + pub is_ipv4: bool, + pub is_ipv6: bool, + pub is_private: bool, +} + +/// Network utilities +pub struct NetworkUtils; + +impl NetworkUtils { + /// Get the primary local IP address + pub fn get_local_ip_address() -> Option { + // Try to get network interfaces + let interfaces = Self::get_all_interfaces(); + + // First, try to find a private network address (192.168.x.x, 10.x.x.x, 172.16-31.x.x) + for interface in &interfaces { + if interface.is_loopback || !interface.is_up { + continue; + } + + for addr in &interface.addresses { + if addr.is_ipv4 && addr.is_private { + return Some(addr.address.clone()); + } + } + } + + // If no private address found, return any non-loopback IPv4 + for interface in &interfaces { + if interface.is_loopback || !interface.is_up { + continue; + } + + for addr in &interface.addresses { + if addr.is_ipv4 { + return Some(addr.address.clone()); + } + } + } + + None + } + + /// Get all IP addresses + pub fn get_all_ip_addresses() -> Vec { + let interfaces = Self::get_all_interfaces(); + let mut addresses = Vec::new(); + + for interface in interfaces { + if interface.is_loopback { + continue; + } + + for addr in interface.addresses { + addresses.push(addr.address); + } + } + + addresses + } + + /// Get all network interfaces + pub fn get_all_interfaces() -> Vec { + #[cfg(unix)] + { + Self::get_interfaces_unix() + } + + #[cfg(windows)] + { + Self::get_interfaces_windows() + } + + #[cfg(not(any(unix, windows)))] + { + Vec::new() + } + } + + #[cfg(unix)] + fn get_interfaces_unix() -> Vec { + use nix::ifaddrs::getifaddrs; + + let mut interfaces = std::collections::HashMap::new(); + + match getifaddrs() { + Ok(addrs) => { + for ifaddr in addrs { + let name = ifaddr.interface_name.clone(); + let flags = ifaddr.flags; + + let interface = interfaces.entry(name.clone()).or_insert_with(|| NetworkInterface { + 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(sockaddr) = address.as_sockaddr_in() { + let ip = IpAddr::V4(Ipv4Addr::from(sockaddr.ip())); + interface.addresses.push(IpAddress { + address: ip.to_string(), + is_ipv4: true, + is_ipv6: false, + is_private: Self::is_private_ip(&ip), + }); + } else if let Some(sockaddr) = address.as_sockaddr_in6() { + let ip = IpAddr::V6(sockaddr.ip()); + interface.addresses.push(IpAddress { + address: ip.to_string(), + is_ipv4: false, + is_ipv6: true, + is_private: Self::is_private_ip(&ip), + }); + } + } + } + } + Err(e) => { + error!("Failed to get network interfaces: {}", e); + } + } + + interfaces.into_values().collect() + } + + #[cfg(windows)] + fn get_interfaces_windows() -> Vec { + use ipconfig::get_adapters; + + let mut interfaces = Vec::new(); + + match get_adapters() { + Ok(adapters) => { + for adapter in adapters { + let mut addresses = Vec::new(); + + // Get IPv4 addresses + for addr in adapter.ipv4_addresses() { + addresses.push(IpAddress { + address: addr.to_string(), + is_ipv4: true, + is_ipv6: false, + is_private: Self::is_private_ipv4(addr), + }); + } + + // Get IPv6 addresses + for addr in adapter.ipv6_addresses() { + addresses.push(IpAddress { + address: addr.to_string(), + is_ipv4: false, + is_ipv6: true, + is_private: Self::is_private_ipv6(addr), + }); + } + + interfaces.push(NetworkInterface { + name: adapter.friendly_name().to_string(), + addresses, + is_up: adapter.oper_status() == ipconfig::OperStatus::IfOperStatusUp, + is_loopback: adapter.if_type() == ipconfig::IfType::SoftwareLoopback, + }); + } + } + Err(e) => { + error!("Failed to get network interfaces: {}", e); + } + } + + interfaces + } + + /// Check if an IP address is private + fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => Self::is_private_ipv4(ipv4), + IpAddr::V6(ipv6) => Self::is_private_ipv6(ipv6), + } + } + + /// Check if an IPv4 address is private + fn is_private_ipv4(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + + // 10.0.0.0/8 + if octets[0] == 10 { + return true; + } + + // 172.16.0.0/12 + if octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31) { + return true; + } + + // 192.168.0.0/16 + if octets[0] == 192 && octets[1] == 168 { + return true; + } + + false + } + + /// Check if an IPv6 address is private + fn is_private_ipv6(ip: &Ipv6Addr) -> bool { + // Check for link-local addresses (fe80::/10) + let segments = ip.segments(); + if segments[0] & 0xffc0 == 0xfe80 { + return true; + } + + // Check for unique local addresses (fc00::/7) + if segments[0] & 0xfe00 == 0xfc00 { + return true; + } + + false + } + + /// Get hostname + pub fn get_hostname() -> Option { + hostname::get() + .ok() + .and_then(|name| name.into_string().ok()) + } + + /// Test network connectivity to a host + pub async fn test_connectivity(host: &str, port: u16) -> bool { + use tokio::net::TcpStream; + use tokio::time::timeout; + use std::time::Duration; + + let addr = format!("{}:{}", host, port); + match timeout(Duration::from_secs(3), TcpStream::connect(&addr)).await { + Ok(Ok(_)) => true, + _ => false, + } + } + + /// Get network statistics + pub fn get_network_stats() -> NetworkStats { + NetworkStats { + hostname: Self::get_hostname(), + primary_ip: Self::get_local_ip_address(), + all_ips: Self::get_all_ip_addresses(), + interface_count: Self::get_all_interfaces().len(), + } + } +} + +/// Network statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkStats { + pub hostname: Option, + pub primary_ip: Option, + pub all_ips: Vec, + pub interface_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_private_ipv4() { + 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(&"192.168.1.1".parse().unwrap())); + assert!(!NetworkUtils::is_private_ipv4(&"8.8.8.8".parse().unwrap())); + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/notification_manager.rs b/tauri/src-tauri/src/notification_manager.rs new file mode 100644 index 00000000..21971d79 --- /dev/null +++ b/tauri/src-tauri/src/notification_manager.rs @@ -0,0 +1,383 @@ +use serde::{Serialize, Deserialize}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_notification::NotificationExt; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use chrono::{DateTime, Utc}; + +/// Notification type enumeration +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum NotificationType { + Info, + Success, + Warning, + Error, + ServerStatus, + UpdateAvailable, + PermissionRequired, + SessionEvent, +} + +/// Notification priority levels +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum NotificationPriority { + Low, + Normal, + High, + Critical, +} + +/// Notification structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub id: String, + pub notification_type: NotificationType, + pub priority: NotificationPriority, + pub title: String, + pub body: String, + pub timestamp: DateTime, + pub read: bool, + pub actions: Vec, + pub metadata: HashMap, +} + +/// Notification action +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationAction { + pub id: String, + pub label: String, + pub action_type: String, +} + +/// Notification settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationSettings { + pub enabled: bool, + pub show_in_system: bool, + pub play_sound: bool, + pub enabled_types: HashMap, +} + +impl Default for NotificationSettings { + fn default() -> Self { + let mut enabled_types = HashMap::new(); + enabled_types.insert(NotificationType::Info, true); + enabled_types.insert(NotificationType::Success, true); + enabled_types.insert(NotificationType::Warning, true); + enabled_types.insert(NotificationType::Error, true); + enabled_types.insert(NotificationType::ServerStatus, true); + enabled_types.insert(NotificationType::UpdateAvailable, true); + enabled_types.insert(NotificationType::PermissionRequired, true); + enabled_types.insert(NotificationType::SessionEvent, false); + + Self { + enabled: true, + show_in_system: true, + play_sound: true, + enabled_types, + } + } +} + +/// Notification manager +pub struct NotificationManager { + app_handle: Arc>>, + notifications: Arc>>, + settings: Arc>, + notification_history: Arc>>, + max_history_size: usize, +} + +impl NotificationManager { + /// Create a new notification manager + pub fn new() -> Self { + Self { + app_handle: Arc::new(RwLock::new(None)), + notifications: Arc::new(RwLock::new(HashMap::new())), + settings: Arc::new(RwLock::new(NotificationSettings::default())), + notification_history: Arc::new(RwLock::new(Vec::new())), + max_history_size: 100, + } + } + + /// Set the app handle + pub async fn set_app_handle(&self, app_handle: AppHandle) { + *self.app_handle.write().await = Some(app_handle); + } + + /// Update notification settings + pub async fn update_settings(&self, settings: NotificationSettings) { + *self.settings.write().await = settings; + } + + /// Get notification settings + pub async fn get_settings(&self) -> NotificationSettings { + self.settings.read().await.clone() + } + + /// Show a notification + pub async fn show_notification( + &self, + notification_type: NotificationType, + priority: NotificationPriority, + title: String, + body: String, + actions: Vec, + metadata: HashMap, + ) -> Result { + let settings = self.settings.read().await; + + // Check if notifications are enabled + if !settings.enabled { + return Ok("notifications_disabled".to_string()); + } + + // Check if this notification type is enabled + if let Some(&enabled) = settings.enabled_types.get(¬ification_type) { + if !enabled { + return Ok("notification_type_disabled".to_string()); + } + } + + let notification_id = uuid::Uuid::new_v4().to_string(); + let notification = Notification { + id: notification_id.clone(), + notification_type, + priority, + title: title.clone(), + body: body.clone(), + timestamp: Utc::now(), + read: false, + actions, + metadata, + }; + + // Store notification + self.notifications.write().await.insert(notification_id.clone(), notification.clone()); + + // Add to history + let mut history = self.notification_history.write().await; + history.push(notification.clone()); + + // Trim history if it exceeds max size + if history.len() > self.max_history_size { + let drain_count = history.len() - self.max_history_size; + history.drain(0..drain_count); + } + + // Show system notification if enabled + if settings.show_in_system { + match self.show_system_notification(&title, &body, notification_type).await { + Ok(_) => {}, + Err(e) => { + tracing::error!("Failed to show system notification: {}", e); + } + } + } + + // Emit notification event to frontend + if let Some(app_handle) = self.app_handle.read().await.as_ref() { + app_handle.emit("notification:new", ¬ification) + .map_err(|e| format!("Failed to emit notification event: {}", e))?; + } + + Ok(notification_id) + } + + /// Show a system notification using Tauri's notification plugin + async fn show_system_notification( + &self, + title: &str, + body: &str, + notification_type: NotificationType, + ) -> Result<(), String> { + let app_handle_guard = self.app_handle.read().await; + let app_handle = app_handle_guard.as_ref() + .ok_or_else(|| "App handle not set".to_string())?; + + let mut builder = app_handle.notification() + .builder() + .title(title) + .body(body); + + // Set icon based on notification type + let icon = match notification_type { + NotificationType::Success => Some("✅"), + NotificationType::Warning => Some("⚠️"), + NotificationType::Error => Some("❌"), + NotificationType::UpdateAvailable => Some("🔄"), + NotificationType::PermissionRequired => Some("🔐"), + NotificationType::ServerStatus => Some("🖥️"), + NotificationType::SessionEvent => Some("💻"), + NotificationType::Info => Some("ℹ️"), + }; + + if let Some(icon_str) = icon { + builder = builder.icon(icon_str); + } + + builder.show() + .map_err(|e| format!("Failed to show notification: {}", e))?; + + Ok(()) + } + + /// Mark notification as read + pub async fn mark_as_read(&self, notification_id: &str) -> Result<(), String> { + let mut notifications = self.notifications.write().await; + if let Some(notification) = notifications.get_mut(notification_id) { + notification.read = true; + + // Update history + let mut history = self.notification_history.write().await; + if let Some(hist_notification) = history.iter_mut().find(|n| n.id == notification_id) { + hist_notification.read = true; + } + + Ok(()) + } else { + Err("Notification not found".to_string()) + } + } + + /// Mark all notifications as read + pub async fn mark_all_as_read(&self) -> Result<(), String> { + let mut notifications = self.notifications.write().await; + for notification in notifications.values_mut() { + notification.read = true; + } + + let mut history = self.notification_history.write().await; + for notification in history.iter_mut() { + notification.read = true; + } + + Ok(()) + } + + /// Get all notifications + pub async fn get_notifications(&self) -> Vec { + self.notifications.read().await.values().cloned().collect() + } + + /// Get unread notification count + pub async fn get_unread_count(&self) -> usize { + self.notifications.read().await + .values() + .filter(|n| !n.read) + .count() + } + + /// Get notification history + pub async fn get_history(&self, limit: Option) -> Vec { + let history = self.notification_history.read().await; + match limit { + Some(l) => history.iter().rev().take(l).cloned().collect(), + None => history.clone(), + } + } + + /// Clear notification + pub async fn clear_notification(&self, notification_id: &str) -> Result<(), String> { + self.notifications.write().await.remove(notification_id); + Ok(()) + } + + /// Clear all notifications + pub async fn clear_all_notifications(&self) -> Result<(), String> { + self.notifications.write().await.clear(); + Ok(()) + } + + /// Show server status notification + pub async fn notify_server_status(&self, running: bool, port: u16) -> Result { + let (title, body) = if running { + ( + "Server Started".to_string(), + format!("VibeTunnel server is now running on port {}", port), + ) + } else { + ( + "Server Stopped".to_string(), + "VibeTunnel server has been stopped".to_string(), + ) + }; + + self.show_notification( + NotificationType::ServerStatus, + NotificationPriority::Normal, + title, + body, + vec![], + HashMap::new(), + ).await + } + + /// Show update available notification + pub async fn notify_update_available(&self, version: &str, download_url: &str) -> Result { + let mut metadata = HashMap::new(); + metadata.insert("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( + NotificationType::UpdateAvailable, + NotificationPriority::High, + "Update Available".to_string(), + format!("VibeTunnel {} is now available. Click to download.", version), + vec![ + NotificationAction { + id: "download".to_string(), + label: "Download".to_string(), + action_type: "open_url".to_string(), + } + ], + metadata, + ).await + } + + /// Show permission required notification + pub async fn notify_permission_required(&self, permission: &str, reason: &str) -> Result { + let mut metadata = HashMap::new(); + metadata.insert("permission".to_string(), serde_json::Value::String(permission.to_string())); + + self.show_notification( + NotificationType::PermissionRequired, + NotificationPriority::High, + "Permission Required".to_string(), + format!("{} permission is required: {}", permission, reason), + vec![ + NotificationAction { + id: "grant".to_string(), + label: "Grant Permission".to_string(), + action_type: "request_permission".to_string(), + } + ], + metadata, + ).await + } + + /// Show error notification + pub async fn notify_error(&self, title: &str, error_message: &str) -> Result { + self.show_notification( + NotificationType::Error, + NotificationPriority::High, + title.to_string(), + error_message.to_string(), + vec![], + HashMap::new(), + ).await + } + + /// Show success notification + pub async fn notify_success(&self, title: &str, message: &str) -> Result { + self.show_notification( + NotificationType::Success, + NotificationPriority::Normal, + title.to_string(), + message.to_string(), + vec![], + HashMap::new(), + ).await + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/permissions.rs b/tauri/src-tauri/src/permissions.rs new file mode 100644 index 00000000..4063380f --- /dev/null +++ b/tauri/src-tauri/src/permissions.rs @@ -0,0 +1,529 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use tauri::AppHandle; + +/// Permission type enumeration +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PermissionType { + ScreenRecording, + Accessibility, + NetworkAccess, + FileSystemFull, + FileSystemRestricted, + TerminalAccess, + NotificationAccess, + CameraAccess, + MicrophoneAccess, + AutoStart, +} + +/// Permission status +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum PermissionStatus { + Granted, + Denied, + NotDetermined, + Restricted, + NotApplicable, +} + +/// Permission information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionInfo { + pub permission_type: PermissionType, + pub status: PermissionStatus, + pub required: bool, + pub platform_specific: bool, + pub description: String, + pub last_checked: Option>, + pub request_count: u32, +} + +/// Permission request result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionRequestResult { + pub permission_type: PermissionType, + pub status: PermissionStatus, + pub message: Option, + pub requires_restart: bool, + pub requires_system_settings: bool, +} + +/// Platform-specific permission settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformPermissions { + pub macos: HashMap, + pub windows: HashMap, + pub linux: HashMap, +} + +/// Permissions manager +pub struct PermissionsManager { + permissions: Arc>>, + app_handle: Arc>>, + notification_manager: Option>, +} + +impl PermissionsManager { + /// Create a new permissions manager + pub fn new() -> Self { + let manager = Self { + permissions: Arc::new(RwLock::new(HashMap::new())), + app_handle: Arc::new(RwLock::new(None)), + notification_manager: None, + }; + + // Initialize default permissions + tokio::spawn({ + let permissions = manager.permissions.clone(); + async move { + let default_permissions = Self::initialize_permissions(); + *permissions.write().await = default_permissions; + } + }); + + manager + } + + /// Set the app handle + pub async fn set_app_handle(&self, app_handle: AppHandle) { + *self.app_handle.write().await = Some(app_handle); + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Initialize default permissions based on platform + fn initialize_permissions() -> HashMap { + let mut permissions = HashMap::new(); + + // Get current platform + let platform = std::env::consts::OS; + + match platform { + "macos" => { + permissions.insert(PermissionType::ScreenRecording, PermissionInfo { + permission_type: PermissionType::ScreenRecording, + status: PermissionStatus::NotDetermined, + required: false, + platform_specific: true, + description: "Required for recording terminal sessions with system UI".to_string(), + last_checked: None, + request_count: 0, + }); + + permissions.insert(PermissionType::Accessibility, PermissionInfo { + permission_type: PermissionType::Accessibility, + status: PermissionStatus::NotDetermined, + required: false, + platform_specific: true, + description: "Required for advanced terminal integration features".to_string(), + last_checked: None, + request_count: 0, + }); + + permissions.insert(PermissionType::NotificationAccess, PermissionInfo { + permission_type: PermissionType::NotificationAccess, + status: PermissionStatus::NotDetermined, + required: false, + platform_specific: true, + description: "Required to show system notifications".to_string(), + last_checked: None, + request_count: 0, + }); + } + "windows" => { + permissions.insert(PermissionType::TerminalAccess, PermissionInfo { + permission_type: PermissionType::TerminalAccess, + status: PermissionStatus::NotDetermined, + required: true, + platform_specific: true, + description: "Required to create and manage terminal sessions".to_string(), + last_checked: None, + request_count: 0, + }); + + permissions.insert(PermissionType::AutoStart, PermissionInfo { + permission_type: PermissionType::AutoStart, + status: PermissionStatus::NotDetermined, + required: false, + platform_specific: true, + description: "Required to start VibeTunnel with Windows".to_string(), + last_checked: None, + request_count: 0, + }); + } + "linux" => { + permissions.insert(PermissionType::FileSystemFull, PermissionInfo { + permission_type: PermissionType::FileSystemFull, + status: PermissionStatus::Granted, + required: true, + platform_specific: false, + description: "Required for saving recordings and configurations".to_string(), + last_checked: None, + request_count: 0, + }); + } + _ => {} + } + + // Add common permissions + permissions.insert(PermissionType::NetworkAccess, PermissionInfo { + permission_type: PermissionType::NetworkAccess, + status: PermissionStatus::Granted, + required: true, + platform_specific: false, + description: "Required for web server and remote access features".to_string(), + last_checked: None, + request_count: 0, + }); + + permissions.insert(PermissionType::FileSystemRestricted, PermissionInfo { + permission_type: PermissionType::FileSystemRestricted, + status: PermissionStatus::Granted, + required: true, + platform_specific: false, + description: "Required for basic application functionality".to_string(), + last_checked: None, + request_count: 0, + }); + + permissions + } + + /// Check all permissions + pub async fn check_all_permissions(&self) -> Vec { + let mut permissions = self.permissions.write().await; + + for (permission_type, info) in permissions.iter_mut() { + info.status = self.check_permission_internal(*permission_type).await; + info.last_checked = Some(Utc::now()); + } + + permissions.values().cloned().collect() + } + + /// Check specific permission + pub async fn check_permission(&self, permission_type: PermissionType) -> PermissionStatus { + let status = self.check_permission_internal(permission_type).await; + + // Update stored status + if let Some(info) = self.permissions.write().await.get_mut(&permission_type) { + info.status = status; + info.last_checked = Some(Utc::now()); + } + + status + } + + /// Internal permission checking logic + async fn check_permission_internal(&self, permission_type: PermissionType) -> PermissionStatus { + let platform = std::env::consts::OS; + + match (platform, permission_type) { + #[cfg(target_os = "macos")] + ("macos", PermissionType::ScreenRecording) => { + self.check_screen_recording_permission_macos().await + } + #[cfg(target_os = "macos")] + ("macos", PermissionType::Accessibility) => { + self.check_accessibility_permission_macos().await + } + #[cfg(target_os = "macos")] + ("macos", PermissionType::NotificationAccess) => { + self.check_notification_permission_macos().await + } + #[cfg(target_os = "windows")] + ("windows", PermissionType::TerminalAccess) => { + self.check_terminal_permission_windows().await + } + #[cfg(target_os = "windows")] + ("windows", PermissionType::AutoStart) => { + self.check_auto_start_permission_windows().await + } + _ => PermissionStatus::NotApplicable, + } + } + + /// Request permission + pub async fn request_permission(&self, permission_type: PermissionType) -> Result { + // Update request count + if let Some(info) = self.permissions.write().await.get_mut(&permission_type) { + info.request_count += 1; + } + + let platform = std::env::consts::OS; + + match (platform, permission_type) { + #[cfg(target_os = "macos")] + ("macos", PermissionType::ScreenRecording) => { + self.request_screen_recording_permission_macos().await + } + #[cfg(target_os = "macos")] + ("macos", PermissionType::Accessibility) => { + self.request_accessibility_permission_macos().await + } + #[cfg(target_os = "macos")] + ("macos", PermissionType::NotificationAccess) => { + self.request_notification_permission_macos().await + } + _ => Ok(PermissionRequestResult { + permission_type, + status: PermissionStatus::NotApplicable, + message: Some("Permission not applicable on this platform".to_string()), + requires_restart: false, + requires_system_settings: false, + }), + } + } + + /// Get permission info + pub async fn get_permission_info(&self, permission_type: PermissionType) -> Option { + self.permissions.read().await.get(&permission_type).cloned() + } + + /// Get all permissions + pub async fn get_all_permissions(&self) -> Vec { + self.permissions.read().await.values().cloned().collect() + } + + /// Get required permissions + pub async fn get_required_permissions(&self) -> Vec { + self.permissions.read().await + .values() + .filter(|info| info.required) + .cloned() + .collect() + } + + /// Get missing required permissions + pub async fn get_missing_required_permissions(&self) -> Vec { + self.permissions.read().await + .values() + .filter(|info| info.required && info.status != PermissionStatus::Granted) + .cloned() + .collect() + } + + /// Check if all required permissions are granted + pub async fn all_required_permissions_granted(&self) -> bool { + !self.permissions.read().await + .values() + .any(|info| info.required && info.status != PermissionStatus::Granted) + } + + /// Open system settings for permission + pub async fn open_system_settings(&self, permission_type: PermissionType) -> Result<(), String> { + let platform = std::env::consts::OS; + + match (platform, permission_type) { + #[cfg(target_os = "macos")] + ("macos", PermissionType::ScreenRecording) => { + self.open_screen_recording_settings_macos().await + } + #[cfg(target_os = "macos")] + ("macos", PermissionType::Accessibility) => { + self.open_accessibility_settings_macos().await + } + #[cfg(target_os = "macos")] + ("macos", PermissionType::NotificationAccess) => { + self.open_notification_settings_macos().await + } + #[cfg(target_os = "windows")] + ("windows", PermissionType::AutoStart) => { + self.open_startup_settings_windows().await + } + _ => Err("No system settings available for this permission".to_string()), + } + } + + // Platform-specific implementations + #[cfg(target_os = "macos")] + async fn check_screen_recording_permission_macos(&self) -> PermissionStatus { + // Use CGDisplayStream API to check screen recording permission + use std::process::Command; + + let output = Command::new("osascript") + .arg("-e") + .arg("tell application \"System Events\" to get properties") + .output(); + + match output { + Ok(output) if output.status.success() => PermissionStatus::Granted, + _ => PermissionStatus::NotDetermined, + } + } + + #[cfg(target_os = "macos")] + async fn request_screen_recording_permission_macos(&self) -> Result { + // Show notification about needing to grant permission + if let Some(notification_manager) = &self.notification_manager { + let _ = notification_manager.notify_permission_required( + "Screen Recording", + "VibeTunnel needs screen recording permission to capture terminal sessions" + ).await; + } + + // Open system preferences + let _ = self.open_screen_recording_settings_macos().await; + + Ok(PermissionRequestResult { + permission_type: PermissionType::ScreenRecording, + status: PermissionStatus::NotDetermined, + message: Some("Please grant screen recording permission in System Settings".to_string()), + requires_restart: true, + requires_system_settings: true, + }) + } + + #[cfg(target_os = "macos")] + async fn open_screen_recording_settings_macos(&self) -> Result<(), String> { + use std::process::Command; + + Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + .spawn() + .map_err(|e| format!("Failed to open system preferences: {}", e))?; + + Ok(()) + } + + #[cfg(target_os = "macos")] + async fn check_accessibility_permission_macos(&self) -> PermissionStatus { + use std::process::Command; + + let output = Command::new("osascript") + .arg("-e") + .arg("tell application \"System Events\" to get UI elements enabled") + .output(); + + match output { + Ok(output) if output.status.success() => { + let result = String::from_utf8_lossy(&output.stdout); + if result.trim() == "true" { + PermissionStatus::Granted + } else { + PermissionStatus::Denied + } + } + _ => PermissionStatus::NotDetermined, + } + } + + #[cfg(target_os = "macos")] + async fn request_accessibility_permission_macos(&self) -> Result { + let _ = self.open_accessibility_settings_macos().await; + + Ok(PermissionRequestResult { + permission_type: PermissionType::Accessibility, + status: PermissionStatus::NotDetermined, + message: Some("Please grant accessibility permission in System Settings".to_string()), + requires_restart: false, + requires_system_settings: true, + }) + } + + #[cfg(target_os = "macos")] + async fn open_accessibility_settings_macos(&self) -> Result<(), String> { + use std::process::Command; + + Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") + .spawn() + .map_err(|e| format!("Failed to open system preferences: {}", e))?; + + Ok(()) + } + + #[cfg(target_os = "macos")] + async fn check_notification_permission_macos(&self) -> PermissionStatus { + // For now, assume granted as Tauri handles this + PermissionStatus::Granted + } + + #[cfg(target_os = "macos")] + async fn request_notification_permission_macos(&self) -> Result { + Ok(PermissionRequestResult { + permission_type: PermissionType::NotificationAccess, + status: PermissionStatus::Granted, + message: Some("Notification permission is handled by the system".to_string()), + requires_restart: false, + requires_system_settings: false, + }) + } + + #[cfg(target_os = "macos")] + async fn open_notification_settings_macos(&self) -> Result<(), String> { + use std::process::Command; + + Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.notifications") + .spawn() + .map_err(|e| format!("Failed to open system preferences: {}", e))?; + + Ok(()) + } + + #[cfg(target_os = "windows")] + async fn check_terminal_permission_windows(&self) -> PermissionStatus { + // On Windows, terminal access is generally granted + PermissionStatus::Granted + } + + #[cfg(target_os = "windows")] + async fn check_auto_start_permission_windows(&self) -> PermissionStatus { + // Check if auto-start is configured + use crate::auto_launch; + + match auto_launch::get_auto_launch().await { + Ok(enabled) => { + if enabled { + PermissionStatus::Granted + } else { + PermissionStatus::Denied + } + } + Err(_) => PermissionStatus::NotDetermined, + } + } + + #[cfg(target_os = "windows")] + async fn open_startup_settings_windows(&self) -> Result<(), String> { + use std::process::Command; + + Command::new("cmd") + .args(&["/c", "start", "ms-settings:startupapps"]) + .spawn() + .map_err(|e| format!("Failed to open startup settings: {}", e))?; + + Ok(()) + } + + /// Show permission required notification + pub async fn notify_permission_required(&self, permission_info: &PermissionInfo) -> Result<(), String> { + if let Some(notification_manager) = &self.notification_manager { + notification_manager.notify_permission_required( + &format!("{:?}", permission_info.permission_type), + &permission_info.description + ).await?; + } + + Ok(()) + } +} + +/// Permission statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionStats { + pub total_permissions: usize, + pub granted_permissions: usize, + pub denied_permissions: usize, + pub required_permissions: usize, + pub missing_required: usize, + pub platform: String, +} \ No newline at end of file diff --git a/tauri/src-tauri/src/port_conflict.rs b/tauri/src-tauri/src/port_conflict.rs new file mode 100644 index 00000000..98332aee --- /dev/null +++ b/tauri/src-tauri/src/port_conflict.rs @@ -0,0 +1,470 @@ +use std::process::Command; +use std::net::TcpListener; +use serde::{Serialize, Deserialize}; +use tracing::{info, error}; + +/// Information about a process using a port +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessDetails { + pub pid: u32, + pub name: String, + pub path: Option, + pub parent_pid: Option, +} + +impl ProcessDetails { + /// Check if this is a VibeTunnel process + pub fn is_vibetunnel(&self) -> bool { + if let Some(path) = &self.path { + return path.contains("vibetunnel") || path.contains("VibeTunnel"); + } + self.name.contains("vibetunnel") || self.name.contains("VibeTunnel") + } + + /// Check if this is one of our managed servers + pub fn is_managed_server(&self) -> bool { + self.name == "vibetunnel" || + self.name.contains("node") && self.path.as_ref().map(|p| p.contains("VibeTunnel")).unwrap_or(false) + } +} + +/// Information about a port conflict +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortConflict { + pub port: u16, + pub process: ProcessDetails, + pub root_process: Option, + pub suggested_action: ConflictAction, + pub alternative_ports: Vec, +} + +/// Suggested action for resolving a port conflict +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ConflictAction { + KillOurInstance { pid: u32, process_name: String }, + SuggestAlternativePort, + ReportExternalApp { name: String }, +} + +/// Port conflict resolver +pub struct PortConflictResolver; + +impl PortConflictResolver { + /// Check if a port is available + pub async fn is_port_available(port: u16) -> bool { + TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok() + } + + /// Detect what process is using a port + pub async fn detect_conflict(port: u16) -> Option { + // First check if port is actually in use + if Self::is_port_available(port).await { + return None; + } + + // Platform-specific conflict detection + #[cfg(target_os = "macos")] + return Self::detect_conflict_macos(port).await; + + #[cfg(target_os = "linux")] + return Self::detect_conflict_linux(port).await; + + #[cfg(target_os = "windows")] + return Self::detect_conflict_windows(port).await; + } + + #[cfg(target_os = "macos")] + async fn detect_conflict_macos(port: u16) -> Option { + // Use lsof to find process using the port + let output = Command::new("/usr/sbin/lsof") + .args(&["-i", &format!(":{}", port), "-n", "-P", "-F"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let process_info = Self::parse_lsof_output(&stdout)?; + + // Get root process + let root_process = Self::find_root_process(&process_info).await; + + // Find alternative ports + let alternatives = Self::find_available_ports(port, 3).await; + + // Determine action + let action = Self::determine_action(&process_info, &root_process); + + Some(PortConflict { + port, + process: process_info, + root_process, + suggested_action: action, + alternative_ports: alternatives, + }) + } + + #[cfg(target_os = "linux")] + async fn detect_conflict_linux(port: u16) -> Option { + // Try lsof first + if let Ok(output) = Command::new("lsof") + .args(&["-i", &format!(":{}", port), "-n", "-P", "-F"]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(process_info) = Self::parse_lsof_output(&stdout) { + let root_process = Self::find_root_process(&process_info).await; + let alternatives = Self::find_available_ports(port, 3).await; + let action = Self::determine_action(&process_info, &root_process); + + return Some(PortConflict { + port, + process: process_info, + root_process, + suggested_action: action, + alternative_ports: alternatives, + }); + } + } + } + + // Fallback to netstat + if let Ok(output) = Command::new("netstat") + .args(&["-tulpn"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse netstat output (simplified) + for line in stdout.lines() { + if line.contains(&format!(":{}", port)) && line.contains("LISTEN") { + // Extract PID from line (format: "tcp ... LISTEN 1234/process") + if let Some(pid_part) = line.split_whitespace().last() { + if let Some(pid_str) = pid_part.split('/').next() { + if let Ok(pid) = pid_str.parse::() { + let name = pid_part.split('/').nth(1).unwrap_or("unknown").to_string(); + let process_info = ProcessDetails { + pid, + name, + path: None, + parent_pid: None, + }; + + let alternatives = Self::find_available_ports(port, 3).await; + let action = Self::determine_action(&process_info, &None); + + return Some(PortConflict { + port, + process: process_info, + root_process: None, + suggested_action: action, + alternative_ports: alternatives, + }); + } + } + } + } + } + } + + None + } + + #[cfg(target_os = "windows")] + async fn detect_conflict_windows(port: u16) -> Option { + // Use netstat to find process using the port + let output = Command::new("netstat") + .args(&["-ano", "-p", "tcp"]) + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse netstat output to find the PID + for line in stdout.lines() { + if line.contains(&format!(":{}", port)) && line.contains("LISTENING") { + // Extract PID from the last column + if let Some(pid_str) = line.split_whitespace().last() { + if let Ok(pid) = pid_str.parse::() { + // Get process name using tasklist + if let Ok(tasklist_output) = Command::new("tasklist") + .args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) + .output() + { + let tasklist_stdout = String::from_utf8_lossy(&tasklist_output.stdout); + if let Some(line) = tasklist_stdout.lines().next() { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() > 0 { + let name = parts[0].trim_matches('"').to_string(); + let process_info = ProcessDetails { + pid, + name, + path: None, + parent_pid: None, + }; + + let alternatives = Self::find_available_ports(port, 3).await; + let action = Self::determine_action(&process_info, &None); + + return Some(PortConflict { + port, + process: process_info, + root_process: None, + suggested_action: action, + alternative_ports: alternatives, + }); + } + } + } + } + } + } + } + + None + } + + /// Parse lsof output + fn parse_lsof_output(output: &str) -> Option { + let mut pid: Option = None; + let mut name: Option = None; + let mut ppid: Option = None; + + // Parse lsof field output format + for line in output.lines() { + if line.starts_with('p') { + pid = line[1..].parse().ok(); + } else if line.starts_with('c') { + name = Some(line[1..].to_string()); + } else if line.starts_with('R') { + ppid = line[1..].parse().ok(); + } + } + + if let (Some(pid), Some(name)) = (pid, name) { + // Get additional process info + let path = Self::get_process_path(pid); + + Some(ProcessDetails { + pid, + name, + path, + parent_pid: ppid, + }) + } else { + None + } + } + + /// Get process path + fn get_process_path(pid: u32) -> Option { + #[cfg(unix)] + { + if let Ok(output) = Command::new("ps") + .args(&["-p", &pid.to_string(), "-o", "comm="]) + .output() + { + let path = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + if !path.is_empty() { + return Some(path); + } + } + } + + None + } + + /// Find root process + async fn find_root_process(process: &ProcessDetails) -> Option { + let mut current = process.clone(); + let mut visited = std::collections::HashSet::new(); + + while let Some(parent_pid) = current.parent_pid { + if parent_pid <= 1 || visited.contains(&parent_pid) { + break; + } + visited.insert(current.pid); + + // Get parent process info + if let Some(parent_info) = Self::get_process_info(parent_pid).await { + // If parent is VibeTunnel, it's our root + if parent_info.is_vibetunnel() { + return Some(parent_info); + } + current = parent_info; + } else { + break; + } + } + + None + } + + /// Get process info by PID + async fn get_process_info(pid: u32) -> Option { + #[cfg(unix)] + { + if let Ok(output) = Command::new("ps") + .args(&["-p", &pid.to_string(), "-o", "pid=,ppid=,comm="]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + let parts: Vec<&str> = stdout.trim().split_whitespace().collect(); + + if parts.len() >= 3 { + let pid = parts[0].parse().ok()?; + let ppid = parts[1].parse().ok(); + let name = parts[2..].join(" "); + let path = Self::get_process_path(pid); + + return Some(ProcessDetails { + pid, + name, + path, + parent_pid: ppid, + }); + } + } + } + + #[cfg(windows)] + { + // Windows implementation would use WMI or similar + // For now, return None + } + + None + } + + /// Find available ports near a given port + async fn find_available_ports(near_port: u16, count: usize) -> Vec { + let mut available_ports = Vec::new(); + let start = near_port.saturating_sub(10).max(1024); + let end = near_port.saturating_add(100).min(65535); + + for port in start..=end { + if port != near_port && Self::is_port_available(port).await { + available_ports.push(port); + if available_ports.len() >= count { + break; + } + } + } + + available_ports + } + + /// Determine action for conflict resolution + fn determine_action(process: &ProcessDetails, root_process: &Option) -> ConflictAction { + // If it's our managed server, kill it + if process.is_managed_server() { + return ConflictAction::KillOurInstance { + pid: process.pid, + process_name: process.name.clone(), + }; + } + + // If root process is VibeTunnel, kill the whole app + if let Some(root) = root_process { + if root.is_vibetunnel() { + return ConflictAction::KillOurInstance { + pid: root.pid, + process_name: root.name.clone(), + }; + } + } + + // If the process itself is VibeTunnel + if process.is_vibetunnel() { + return ConflictAction::KillOurInstance { + pid: process.pid, + process_name: process.name.clone(), + }; + } + + // Otherwise, it's an external app + ConflictAction::ReportExternalApp { + name: process.name.clone(), + } + } + + /// Resolve a port conflict + pub async fn resolve_conflict(conflict: &PortConflict) -> Result<(), String> { + match &conflict.suggested_action { + ConflictAction::KillOurInstance { pid, process_name } => { + info!("Killing conflicting process: {} (PID: {})", process_name, pid); + + #[cfg(unix)] + { + let output = Command::new("kill") + .args(&["-9", &pid.to_string()]) + .output() + .map_err(|e| format!("Failed to execute kill command: {}", e))?; + + if !output.status.success() { + return Err(format!("Failed to kill process {}", pid)); + } + } + + #[cfg(windows)] + { + let output = Command::new("taskkill") + .args(&["/F", "/PID", &pid.to_string()]) + .output() + .map_err(|e| format!("Failed to execute taskkill command: {}", e))?; + + if !output.status.success() { + return Err(format!("Failed to kill process {}", pid)); + } + } + + // Wait for port to be released + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + Ok(()) + } + ConflictAction::SuggestAlternativePort | ConflictAction::ReportExternalApp { .. } => { + // These require user action + Err("This conflict requires user action to resolve".to_string()) + } + } + } + + /// Force kill a process + pub async fn force_kill_process(conflict: &PortConflict) -> Result<(), String> { + info!("Force killing process: {} (PID: {})", conflict.process.name, conflict.process.pid); + + #[cfg(unix)] + { + let output = Command::new("kill") + .args(&["-9", &conflict.process.pid.to_string()]) + .output() + .map_err(|e| format!("Failed to execute kill command: {}", e))?; + + if !output.status.success() { + error!("Failed to kill process with regular permissions"); + return Err(format!("Failed to kill process {}", conflict.process.pid)); + } + } + + #[cfg(windows)] + { + let output = Command::new("taskkill") + .args(&["/F", "/PID", &conflict.process.pid.to_string()]) + .output() + .map_err(|e| format!("Failed to execute taskkill command: {}", e))?; + + if !output.status.success() { + return Err(format!("Failed to kill process {}", conflict.process.pid)); + } + } + + // Wait for port to be released + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + Ok(()) + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/server.rs b/tauri/src-tauri/src/server.rs index 3622c4a5..503f258d 100644 --- a/tauri/src-tauri/src/server.rs +++ b/tauri/src-tauri/src/server.rs @@ -23,17 +23,20 @@ use std::fs; use crate::terminal::TerminalManager; use crate::auth::{AuthConfig, auth_middleware, check_auth, login}; +use crate::session_monitor::SessionMonitor; // Combined app state for Axum #[derive(Clone)] struct AppState { terminal_manager: Arc, auth_config: Arc, + session_monitor: Arc, } pub struct HttpServer { terminal_manager: Arc, auth_config: Arc, + session_monitor: Arc, port: u16, shutdown_tx: Option>, handle: Option>, @@ -97,20 +100,22 @@ impl HttpServer { self.port } - pub fn new(terminal_manager: Arc) -> Self { + pub fn new(terminal_manager: Arc, session_monitor: Arc) -> Self { Self { terminal_manager, auth_config: Arc::new(AuthConfig::new(false, None)), + session_monitor, port: 0, shutdown_tx: None, handle: None, } } - pub fn with_auth(terminal_manager: Arc, auth_config: AuthConfig) -> Self { + pub fn with_auth(terminal_manager: Arc, session_monitor: Arc, auth_config: AuthConfig) -> Self { Self { terminal_manager, auth_config: Arc::new(auth_config), + session_monitor, port: 0, shutdown_tx: None, handle: None, @@ -203,6 +208,7 @@ impl HttpServer { let app_state = AppState { terminal_manager: self.terminal_manager.clone(), auth_config: self.auth_config.clone(), + session_monitor: self.session_monitor.clone(), }; // Don't serve static files in Tauri - the frontend is served by Tauri itself @@ -223,8 +229,16 @@ impl HttpServer { .route("/api/sessions/:id/input", post(send_input)) .route("/api/sessions/:id/stream", get(terminal_stream)) .route("/api/sessions/:id/snapshot", get(get_snapshot)) + .route("/api/sessions/events", get(session_events_stream)) .route("/api/ws/:id", get(terminal_websocket)) .route("/api/fs/browse", get(browse_directory)) + .route("/api/fs/info", get(crate::fs_api::get_file_info)) + .route("/api/fs/read", get(crate::fs_api::read_file)) + .route("/api/fs/write", post(crate::fs_api::write_file)) + .route("/api/fs/delete", delete(crate::fs_api::delete_file)) + .route("/api/fs/move", post(crate::fs_api::move_file)) + .route("/api/fs/copy", post(crate::fs_api::copy_file)) + .route("/api/fs/search", get(crate::fs_api::search_files)) .route("/api/mkdir", post(create_directory)) .route("/api/cleanup-exited", post(cleanup_exited)) .layer(middleware::from_fn_with_state( @@ -456,7 +470,7 @@ async fn terminal_stream( // Poll for terminal output let mut poll_interval = interval(Duration::from_millis(10)); - let mut exit_sent = false; + let exit_sent = false; loop { poll_interval.tick().await; @@ -469,7 +483,7 @@ async fn terminal_stream( yield Ok(Event::default() .event("data") .data(exit_event)); - exit_sent = true; + let _ = exit_sent; // Prevent duplicate exit events break; } @@ -495,7 +509,6 @@ async fn terminal_stream( yield Ok(Event::default() .event("data") .data(exit_event)); - exit_sent = true; } break; } @@ -506,6 +519,26 @@ async fn terminal_stream( Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } +// Session monitoring SSE endpoint +async fn session_events_stream( + AxumState(state): AxumState, +) -> Result>>, StatusCode> { + // Clone the session monitor Arc to avoid lifetime issues + let session_monitor = state.session_monitor.clone(); + + // Start monitoring if not already started + session_monitor.start_monitoring().await; + + // Create SSE stream from session monitor + let stream = session_monitor.create_sse_stream() + .map(|data| { + data.map(|json| Event::default().data(json)) + .map_err(|_| unreachable!()) + }); + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + // File system endpoints async fn browse_directory( Query(params): Query, diff --git a/tauri/src-tauri/src/session_monitor.rs b/tauri/src-tauri/src/session_monitor.rs new file mode 100644 index 00000000..7a853dc5 --- /dev/null +++ b/tauri/src-tauri/src/session_monitor.rs @@ -0,0 +1,267 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{RwLock, mpsc}; +use tokio::time::{interval, Duration}; +use chrono::Utc; +use serde::{Serialize, Deserialize}; +use serde_json; +use uuid::Uuid; + +/// Information about a terminal session +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub id: String, + pub name: String, + pub pid: u32, + pub rows: u16, + pub cols: u16, + pub created_at: String, + pub last_activity: String, + pub is_active: bool, + pub client_count: usize, +} + +/// Session state change event +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SessionEvent { + SessionCreated { + session: SessionInfo, + }, + SessionUpdated { + session: SessionInfo, + }, + SessionClosed { + id: String, + }, + SessionActivity { + id: String, + timestamp: String, + }, +} + +/// Session monitoring service +pub struct SessionMonitor { + sessions: Arc>>, + event_subscribers: Arc>>>, + terminal_manager: Arc, +} + +impl SessionMonitor { + pub fn new(terminal_manager: Arc) -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + event_subscribers: Arc::new(RwLock::new(HashMap::new())), + terminal_manager, + } + } + + /// Start monitoring sessions + pub async fn start_monitoring(&self) { + let sessions = self.sessions.clone(); + let subscribers = self.event_subscribers.clone(); + let terminal_manager = self.terminal_manager.clone(); + + tokio::spawn(async move { + let mut monitor_interval = interval(Duration::from_secs(5)); + + loop { + monitor_interval.tick().await; + + // Get current sessions from terminal manager + let current_sessions = terminal_manager.list_sessions().await; + let mut sessions_map = sessions.write().await; + let mut updated_sessions = HashMap::new(); + + // Check for new or updated sessions + for session in current_sessions { + let session_info = SessionInfo { + id: session.id.clone(), + name: session.name.clone(), + pid: session.pid, + rows: session.rows, + cols: session.cols, + created_at: session.created_at.clone(), + last_activity: Utc::now().to_rfc3339(), + is_active: true, + client_count: 0, // TODO: Track actual client count + }; + + // Check if this is a new session + if !sessions_map.contains_key(&session.id) { + // Broadcast session created event + Self::broadcast_event( + &subscribers, + SessionEvent::SessionCreated { + session: session_info.clone() + } + ).await; + } else { + // Check if session was updated + if let Some(existing) = sessions_map.get(&session.id) { + if existing.rows != session_info.rows || + existing.cols != session_info.cols { + // Broadcast session updated event + Self::broadcast_event( + &subscribers, + SessionEvent::SessionUpdated { + session: session_info.clone() + } + ).await; + } + } + } + + updated_sessions.insert(session.id.clone(), session_info); + } + + // Check for closed sessions + let closed_sessions: Vec = sessions_map.keys() + .filter(|id| !updated_sessions.contains_key(*id)) + .cloned() + .collect(); + + for session_id in closed_sessions { + // Broadcast session closed event + Self::broadcast_event( + &subscribers, + SessionEvent::SessionClosed { + id: session_id.clone() + } + ).await; + } + + // Update the sessions map + *sessions_map = updated_sessions; + } + }); + } + + /// Subscribe to session events + pub async fn subscribe(&self) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + let subscriber_id = Uuid::new_v4().to_string(); + + self.event_subscribers.write().await.insert(subscriber_id, tx); + + rx + } + + /// Unsubscribe from session events + pub async fn unsubscribe(&self, subscriber_id: &str) { + self.event_subscribers.write().await.remove(subscriber_id); + } + + /// Get current session count + pub async fn get_session_count(&self) -> usize { + self.sessions.read().await.len() + } + + /// Get all sessions + pub async fn get_sessions(&self) -> Vec { + self.sessions.read().await.values().cloned().collect() + } + + /// Get a specific session + pub async fn get_session(&self, id: &str) -> Option { + self.sessions.read().await.get(id).cloned() + } + + /// Notify activity for a session + pub async fn notify_activity(&self, session_id: &str) { + if let Some(session) = self.sessions.write().await.get_mut(session_id) { + session.last_activity = Utc::now().to_rfc3339(); + + // Broadcast activity event + Self::broadcast_event( + &self.event_subscribers, + SessionEvent::SessionActivity { + id: session_id.to_string(), + timestamp: session.last_activity.clone(), + } + ).await; + } + } + + /// Broadcast an event to all subscribers + async fn broadcast_event( + subscribers: &Arc>>>, + event: SessionEvent, + ) { + let subscribers_read = subscribers.read().await; + let mut dead_subscribers = Vec::new(); + + for (id, tx) in subscribers_read.iter() { + if tx.send(event.clone()).is_err() { + dead_subscribers.push(id.clone()); + } + } + + // Remove dead subscribers + if !dead_subscribers.is_empty() { + drop(subscribers_read); + let mut subscribers_write = subscribers.write().await; + for id in dead_subscribers { + subscribers_write.remove(&id); + } + } + } + + /// Create an SSE stream for session events + pub fn create_sse_stream(self: Arc) -> impl futures::Stream> + Send + 'static { + async_stream::stream! { + // Subscribe to events + let (tx, mut rx) = mpsc::unbounded_channel(); + let subscriber_id = Uuid::new_v4().to_string(); + self.event_subscribers.write().await.insert(subscriber_id.clone(), tx); + + // Send initial sessions + let session_list = self.sessions.read().await.values().cloned().collect::>(); + let initial_event = serde_json::json!({ + "type": "initial", + "sessions": session_list, + "count": session_list.len() + }); + + yield Ok(format!("data: {}\n\n", initial_event)); + + // Send events as they come + while let Some(event) = rx.recv().await { + if let Ok(json) = serde_json::to_string(&event) { + yield Ok(format!("data: {}\n\n", json)); + } + } + + // Clean up subscriber on drop + self.event_subscribers.write().await.remove(&subscriber_id); + } + } +} + +/// Session statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionStats { + pub total_sessions: usize, + pub active_sessions: usize, + pub total_clients: usize, + pub uptime_seconds: u64, + pub sessions_created_today: usize, +} + +impl SessionMonitor { + /// Get session statistics + pub async fn get_stats(&self) -> SessionStats { + let sessions = self.sessions.read().await; + let active_sessions = sessions.values().filter(|s| s.is_active).count(); + let total_clients = sessions.values().map(|s| s.client_count).sum(); + + // TODO: Track more detailed statistics + SessionStats { + total_sessions: sessions.len(), + active_sessions, + total_clients, + uptime_seconds: 0, // TODO: Track uptime + sessions_created_today: 0, // TODO: Track daily stats + } + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/settings.rs b/tauri/src-tauri/src/settings.rs index ef0c6cae..b0ed52fd 100644 --- a/tauri/src-tauri/src/settings.rs +++ b/tauri/src-tauri/src/settings.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use directories::ProjectDirs; use tauri::{Manager, State}; use crate::state::AppState; +use std::collections::HashMap; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GeneralSettings { @@ -10,6 +11,10 @@ pub struct GeneralSettings { pub show_dock_icon: bool, pub default_terminal: String, pub default_shell: String, + pub show_welcome_on_startup: Option, + pub theme: Option, + pub language: Option, + pub check_updates_automatically: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -19,6 +24,10 @@ pub struct DashboardSettings { pub password: String, pub access_mode: String, pub auto_cleanup: bool, + pub session_limit: Option, + pub idle_timeout_minutes: Option, + pub enable_cors: Option, + pub allowed_origins: Option>, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -28,6 +37,135 @@ pub struct AdvancedSettings { pub log_level: String, pub session_timeout: u32, pub ngrok_auth_token: Option, + pub ngrok_region: Option, + pub ngrok_subdomain: Option, + pub enable_telemetry: Option, + pub experimental_features: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RecordingSettings { + pub enabled: bool, + pub output_directory: Option, + pub format: String, + pub include_timing: bool, + pub compress_output: bool, + pub max_file_size_mb: Option, + pub auto_save: bool, + pub filename_template: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TTYForwardSettings { + pub enabled: bool, + pub default_port: u16, + pub bind_address: String, + pub max_connections: u32, + pub buffer_size: u32, + pub keep_alive: bool, + pub authentication: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MonitoringSettings { + pub enabled: bool, + pub collect_metrics: bool, + pub metric_interval_seconds: u32, + pub max_history_size: u32, + pub alert_on_high_cpu: bool, + pub alert_on_high_memory: bool, + pub cpu_threshold_percent: Option, + pub memory_threshold_percent: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NetworkSettings { + pub preferred_interface: Option, + pub enable_ipv6: bool, + pub dns_servers: Option>, + pub proxy_settings: Option, + pub connection_timeout_seconds: u32, + pub retry_attempts: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProxySettings { + pub enabled: bool, + pub proxy_type: String, + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub bypass_list: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PortSettings { + pub auto_resolve_conflicts: bool, + pub preferred_port_range_start: u16, + pub preferred_port_range_end: u16, + pub excluded_ports: Option>, + pub conflict_resolution_strategy: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NotificationSettings { + pub enabled: bool, + pub show_in_system: bool, + pub play_sound: bool, + pub notification_types: HashMap, + pub do_not_disturb_enabled: Option, + pub do_not_disturb_start: Option, + pub do_not_disturb_end: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TerminalIntegrationSettings { + pub enabled_terminals: HashMap, + pub terminal_configs: HashMap, + pub default_terminal_override: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TerminalConfig { + pub path: Option, + pub args: Option>, + pub env: Option>, + pub working_directory: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UpdateSettings { + pub channel: String, + pub check_frequency: String, + pub auto_download: bool, + pub auto_install: bool, + pub show_release_notes: bool, + pub include_pre_releases: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SecuritySettings { + pub enable_encryption: bool, + pub encryption_algorithm: Option, + pub require_authentication: bool, + pub session_token_expiry_hours: Option, + pub allowed_ip_addresses: Option>, + pub blocked_ip_addresses: Option>, + pub rate_limiting_enabled: bool, + pub rate_limit_requests_per_minute: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DebugSettings { + pub enable_debug_menu: bool, + pub show_performance_stats: bool, + pub enable_verbose_logging: bool, + pub log_to_file: bool, + pub log_file_path: Option, + pub max_log_file_size_mb: Option, + pub enable_dev_tools: bool, + pub show_internal_errors: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -35,16 +173,47 @@ pub struct Settings { pub general: GeneralSettings, pub dashboard: DashboardSettings, pub advanced: AdvancedSettings, + pub recording: Option, + pub tty_forward: Option, + pub monitoring: Option, + pub network: Option, + pub port: Option, + pub notifications: Option, + pub terminal_integrations: Option, + pub updates: Option, + pub security: Option, + pub debug: Option, } impl Default for Settings { fn default() -> Self { + let mut default_notification_types = HashMap::new(); + default_notification_types.insert("info".to_string(), true); + default_notification_types.insert("success".to_string(), true); + default_notification_types.insert("warning".to_string(), true); + default_notification_types.insert("error".to_string(), true); + default_notification_types.insert("server_status".to_string(), true); + default_notification_types.insert("update_available".to_string(), true); + + let mut enabled_terminals = HashMap::new(); + enabled_terminals.insert("Terminal".to_string(), true); + enabled_terminals.insert("iTerm2".to_string(), true); + enabled_terminals.insert("Hyper".to_string(), true); + enabled_terminals.insert("Alacritty".to_string(), true); + enabled_terminals.insert("Warp".to_string(), true); + enabled_terminals.insert("Ghostty".to_string(), false); + enabled_terminals.insert("WezTerm".to_string(), false); + Self { general: GeneralSettings { launch_at_login: false, show_dock_icon: true, default_terminal: "system".to_string(), default_shell: "default".to_string(), + show_welcome_on_startup: Some(true), + theme: Some("auto".to_string()), + language: Some("en".to_string()), + check_updates_automatically: Some(true), }, dashboard: DashboardSettings { server_port: 4020, @@ -52,6 +221,10 @@ impl Default for Settings { password: String::new(), access_mode: "localhost".to_string(), auto_cleanup: true, + session_limit: Some(10), + idle_timeout_minutes: Some(30), + enable_cors: Some(true), + allowed_origins: Some(vec!["*".to_string()]), }, advanced: AdvancedSettings { server_mode: "rust".to_string(), @@ -59,7 +232,97 @@ impl Default for Settings { log_level: "info".to_string(), session_timeout: 0, ngrok_auth_token: None, + ngrok_region: Some("us".to_string()), + ngrok_subdomain: None, + enable_telemetry: Some(false), + experimental_features: Some(false), }, + recording: Some(RecordingSettings { + enabled: true, + output_directory: None, + format: "asciinema".to_string(), + include_timing: true, + compress_output: false, + max_file_size_mb: Some(100), + auto_save: false, + filename_template: Some("vibetunnel_%Y%m%d_%H%M%S".to_string()), + }), + tty_forward: Some(TTYForwardSettings { + enabled: false, + default_port: 8022, + bind_address: "127.0.0.1".to_string(), + max_connections: 5, + buffer_size: 4096, + keep_alive: true, + authentication: None, + }), + monitoring: Some(MonitoringSettings { + enabled: true, + collect_metrics: true, + metric_interval_seconds: 5, + max_history_size: 1000, + alert_on_high_cpu: false, + alert_on_high_memory: false, + cpu_threshold_percent: Some(80), + memory_threshold_percent: Some(80), + }), + network: Some(NetworkSettings { + preferred_interface: None, + enable_ipv6: true, + dns_servers: None, + proxy_settings: None, + connection_timeout_seconds: 30, + retry_attempts: 3, + }), + port: Some(PortSettings { + auto_resolve_conflicts: true, + preferred_port_range_start: 4000, + preferred_port_range_end: 5000, + excluded_ports: None, + conflict_resolution_strategy: "increment".to_string(), + }), + notifications: Some(NotificationSettings { + enabled: true, + show_in_system: true, + play_sound: true, + notification_types: default_notification_types, + do_not_disturb_enabled: Some(false), + do_not_disturb_start: None, + do_not_disturb_end: None, + }), + terminal_integrations: Some(TerminalIntegrationSettings { + enabled_terminals, + terminal_configs: HashMap::new(), + default_terminal_override: None, + }), + updates: Some(UpdateSettings { + channel: "stable".to_string(), + check_frequency: "weekly".to_string(), + auto_download: false, + auto_install: false, + show_release_notes: true, + include_pre_releases: false, + }), + security: Some(SecuritySettings { + enable_encryption: false, + encryption_algorithm: Some("aes-256-gcm".to_string()), + require_authentication: false, + session_token_expiry_hours: Some(24), + allowed_ip_addresses: None, + blocked_ip_addresses: None, + rate_limiting_enabled: false, + rate_limit_requests_per_minute: Some(60), + }), + debug: Some(DebugSettings { + enable_debug_menu: false, + show_performance_stats: false, + enable_verbose_logging: false, + log_to_file: false, + log_file_path: None, + max_log_file_size_mb: Some(50), + enable_dev_tools: false, + show_internal_errors: false, + }), } } } diff --git a/tauri/src-tauri/src/state.rs b/tauri/src-tauri/src/state.rs index 05a388e5..5930447b 100644 --- a/tauri/src-tauri/src/state.rs +++ b/tauri/src-tauri/src/state.rs @@ -4,6 +4,19 @@ 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::auth_cache::AuthCacheManager; +use crate::terminal_integrations::TerminalIntegrationsManager; +use crate::terminal_spawn_service::TerminalSpawnService; #[derive(Clone)] pub struct AppState { @@ -12,16 +25,78 @@ pub struct AppState { pub ngrok_manager: Arc, pub server_monitoring: Arc, pub server_target_port: Arc>>, + pub cast_manager: Arc, + pub tty_forward_manager: Arc, + pub session_monitor: Arc, + pub notification_manager: Arc, + pub welcome_manager: Arc, + pub permissions_manager: Arc, + pub update_manager: Arc, + pub backend_manager: Arc, + pub debug_features_manager: Arc, + pub api_testing_manager: Arc, + pub auth_cache_manager: Arc, + pub terminal_integrations_manager: Arc, + pub terminal_spawn_service: Arc, } impl AppState { pub fn new() -> Self { + let mut terminal_manager = TerminalManager::new(); + let cast_manager = Arc::new(CastManager::new()); + + // Connect terminal manager to cast manager + terminal_manager.set_cast_manager(cast_manager.clone()); + + let terminal_manager = Arc::new(terminal_manager); + let session_monitor = Arc::new(SessionMonitor::new(terminal_manager.clone())); + let notification_manager = Arc::new(NotificationManager::new()); + let mut permissions_manager = PermissionsManager::new(); + permissions_manager.set_notification_manager(notification_manager.clone()); + + let current_version = env!("CARGO_PKG_VERSION").to_string(); + let mut update_manager = UpdateManager::new(current_version); + update_manager.set_notification_manager(notification_manager.clone()); + + let mut backend_manager = BackendManager::new(); + backend_manager.set_notification_manager(notification_manager.clone()); + + let mut debug_features_manager = DebugFeaturesManager::new(); + debug_features_manager.set_notification_manager(notification_manager.clone()); + + let mut api_testing_manager = APITestingManager::new(); + api_testing_manager.set_notification_manager(notification_manager.clone()); + + let mut auth_cache_manager = AuthCacheManager::new(); + auth_cache_manager.set_notification_manager(notification_manager.clone()); + + let mut terminal_integrations_manager = TerminalIntegrationsManager::new(); + terminal_integrations_manager.set_notification_manager(notification_manager.clone()); + + let terminal_integrations_manager = Arc::new(terminal_integrations_manager); + let terminal_spawn_service = Arc::new(TerminalSpawnService::new( + terminal_integrations_manager.clone() + )); + Self { - terminal_manager: Arc::new(TerminalManager::new()), + terminal_manager, http_server: Arc::new(RwLock::new(None)), ngrok_manager: Arc::new(NgrokManager::new()), server_monitoring: Arc::new(AtomicBool::new(true)), server_target_port: Arc::new(RwLock::new(None)), + cast_manager, + tty_forward_manager: Arc::new(TTYForwardManager::new()), + session_monitor, + notification_manager, + welcome_manager: Arc::new(WelcomeManager::new()), + permissions_manager: Arc::new(permissions_manager), + update_manager: Arc::new(update_manager), + backend_manager: Arc::new(backend_manager), + debug_features_manager: Arc::new(debug_features_manager), + api_testing_manager: Arc::new(api_testing_manager), + auth_cache_manager: Arc::new(auth_cache_manager), + terminal_integrations_manager, + terminal_spawn_service, } } } \ No newline at end of file diff --git a/tauri/src-tauri/src/terminal.rs b/tauri/src-tauri/src/terminal.rs index 58e491cc..87235d68 100644 --- a/tauri/src-tauri/src/terminal.rs +++ b/tauri/src-tauri/src/terminal.rs @@ -7,10 +7,12 @@ use bytes::Bytes; use uuid::Uuid; use chrono::Utc; use tracing::{info, error, debug}; +use crate::cast::CastManager; #[derive(Clone)] pub struct TerminalManager { sessions: Arc>>>>, + cast_manager: Option>, } pub struct TerminalSession { @@ -33,9 +35,14 @@ impl TerminalManager { pub fn new() -> Self { Self { sessions: Arc::new(RwLock::new(HashMap::new())), + cast_manager: None, } } + pub fn set_cast_manager(&mut self, cast_manager: Arc) { + self.cast_manager = Some(cast_manager); + } + pub async fn create_session( &self, name: String, @@ -107,6 +114,8 @@ impl TerminalManager { // Start reader thread let output_tx_clone = output_tx.clone(); + let cast_manager_clone = self.cast_manager.clone(); + let session_id_clone = id.clone(); let reader_thread = std::thread::spawn(move || { let mut reader = reader; let mut buffer = [0u8; 4096]; @@ -119,6 +128,17 @@ impl TerminalManager { } Ok(n) => { let data = Bytes::copy_from_slice(&buffer[..n]); + + // Record output to cast file if recording + if let Some(cast_manager) = &cast_manager_clone { + let cm = cast_manager.clone(); + let sid = session_id_clone.clone(); + let data_clone = data.clone(); + tokio::spawn(async move { + let _ = cm.add_output(&sid, &data_clone).await; + }); + } + if output_tx_clone.send(data).is_err() { debug!("Output channel closed"); break; @@ -201,6 +221,11 @@ impl TerminalManager { let mut sessions = self.sessions.write().await; if let Some(session_arc) = sessions.remove(id) { + // Stop recording if active + if let Some(cast_manager) = &self.cast_manager { + let _ = cast_manager.remove_recorder(id).await; + } + // Session will be dropped when it goes out of scope drop(session_arc); @@ -228,6 +253,14 @@ impl TerminalManager { session.rows = rows; session.cols = cols; + // Update recorder dimensions if recording + if let Some(cast_manager) = &self.cast_manager { + if let Some(recorder) = cast_manager.get_recorder(id).await { + let mut rec = recorder.lock().await; + rec.resize(cols, rows).await; + } + } + debug!("Resized terminal {} to {}x{}", id, cols, rows); Ok(()) } else { @@ -239,6 +272,11 @@ impl TerminalManager { if let Some(session_arc) = self.get_session(id).await { let mut session = session_arc.write().await; + // Record input to cast file if recording + if let Some(cast_manager) = &self.cast_manager { + let _ = cast_manager.add_input(id, data).await; + } + session.writer .write_all(data) .map_err(|e| format!("Failed to write to PTY: {}", e))?; diff --git a/tauri/src-tauri/src/terminal_integrations.rs b/tauri/src-tauri/src/terminal_integrations.rs new file mode 100644 index 00000000..a8eb1bf6 --- /dev/null +++ b/tauri/src-tauri/src/terminal_integrations.rs @@ -0,0 +1,678 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +/// Terminal emulator type +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TerminalEmulator { + SystemDefault, + Terminal, // macOS Terminal.app + ITerm2, // iTerm2 + Hyper, // Hyper + Alacritty, // Alacritty + Kitty, // Kitty + WezTerm, // WezTerm + Ghostty, // Ghostty + WindowsTerminal, // Windows Terminal + ConEmu, // ConEmu + Cmder, // Cmder + Gnome, // GNOME Terminal + Konsole, // KDE Konsole + Xterm, // XTerm + Custom, // Custom terminal +} + +impl TerminalEmulator { + pub fn display_name(&self) -> &str { + match self { + TerminalEmulator::SystemDefault => "System Default", + TerminalEmulator::Terminal => "Terminal", + TerminalEmulator::ITerm2 => "iTerm2", + TerminalEmulator::Hyper => "Hyper", + TerminalEmulator::Alacritty => "Alacritty", + TerminalEmulator::Kitty => "Kitty", + TerminalEmulator::WezTerm => "WezTerm", + TerminalEmulator::Ghostty => "Ghostty", + TerminalEmulator::WindowsTerminal => "Windows Terminal", + TerminalEmulator::ConEmu => "ConEmu", + TerminalEmulator::Cmder => "Cmder", + TerminalEmulator::Gnome => "GNOME Terminal", + TerminalEmulator::Konsole => "Konsole", + TerminalEmulator::Xterm => "XTerm", + TerminalEmulator::Custom => "Custom", + } + } +} + +/// Terminal integration configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalConfig { + pub emulator: TerminalEmulator, + pub name: String, + pub executable_path: PathBuf, + pub args_template: Vec, + pub env_vars: HashMap, + pub features: TerminalFeatures, + pub platform: Vec, // ["macos", "windows", "linux"] +} + +/// Terminal features +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalFeatures { + pub supports_tabs: bool, + pub supports_splits: bool, + pub supports_profiles: bool, + pub supports_themes: bool, + pub supports_scripting: bool, + pub supports_url_scheme: bool, + pub supports_remote_control: bool, +} + +/// Terminal launch options +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalLaunchOptions { + pub working_directory: Option, + pub command: Option, + pub args: Vec, + pub env_vars: HashMap, + pub title: Option, + pub profile: Option, + pub tab: bool, + pub split: Option, + pub window_size: Option<(u32, u32)>, +} + +/// Split direction +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum SplitDirection { + Horizontal, + Vertical, +} + +/// Terminal integration info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalIntegrationInfo { + pub emulator: TerminalEmulator, + pub installed: bool, + pub version: Option, + pub path: Option, + pub is_default: bool, + pub config: Option, +} + +/// Terminal URL scheme +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalURLScheme { + pub scheme: String, + pub supports_ssh: bool, + pub supports_local: bool, + pub template: String, +} + +/// Terminal integrations manager +pub struct TerminalIntegrationsManager { + configs: Arc>>, + detected_terminals: Arc>>, + default_terminal: Arc>, + url_schemes: Arc>>, + notification_manager: Option>, +} + +impl TerminalIntegrationsManager { + /// Create a new terminal integrations manager + pub fn new() -> Self { + let manager = Self { + configs: Arc::new(RwLock::new(HashMap::new())), + detected_terminals: Arc::new(RwLock::new(HashMap::new())), + default_terminal: Arc::new(RwLock::new(TerminalEmulator::SystemDefault)), + url_schemes: Arc::new(RwLock::new(HashMap::new())), + notification_manager: None, + }; + + // Initialize default configurations + tokio::spawn({ + let configs = manager.configs.clone(); + let url_schemes = manager.url_schemes.clone(); + async move { + let default_configs = Self::initialize_default_configs(); + *configs.write().await = default_configs; + + let default_schemes = Self::initialize_url_schemes(); + *url_schemes.write().await = default_schemes; + } + }); + + manager + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Initialize default terminal configurations + fn initialize_default_configs() -> HashMap { + let mut configs = HashMap::new(); + + // WezTerm configuration + configs.insert(TerminalEmulator::WezTerm, TerminalConfig { + emulator: TerminalEmulator::WezTerm, + name: "WezTerm".to_string(), + executable_path: PathBuf::from("/Applications/WezTerm.app/Contents/MacOS/wezterm"), + args_template: vec![ + "start".to_string(), + "--cwd".to_string(), + "{working_directory}".to_string(), + "--".to_string(), + "{command}".to_string(), + "{args}".to_string(), + ], + env_vars: HashMap::new(), + features: TerminalFeatures { + supports_tabs: true, + supports_splits: true, + supports_profiles: true, + supports_themes: true, + supports_scripting: true, + supports_url_scheme: false, + supports_remote_control: true, + }, + platform: vec!["macos".to_string(), "windows".to_string(), "linux".to_string()], + }); + + // Ghostty configuration + configs.insert(TerminalEmulator::Ghostty, TerminalConfig { + emulator: TerminalEmulator::Ghostty, + name: "Ghostty".to_string(), + executable_path: PathBuf::from("/Applications/Ghostty.app/Contents/MacOS/ghostty"), + args_template: vec![ + "--working-directory".to_string(), + "{working_directory}".to_string(), + "--command".to_string(), + "{command}".to_string(), + "{args}".to_string(), + ], + env_vars: HashMap::new(), + features: TerminalFeatures { + supports_tabs: true, + supports_splits: true, + supports_profiles: true, + supports_themes: true, + supports_scripting: false, + supports_url_scheme: false, + supports_remote_control: false, + }, + platform: vec!["macos".to_string()], + }); + + // iTerm2 configuration + configs.insert(TerminalEmulator::ITerm2, TerminalConfig { + emulator: TerminalEmulator::ITerm2, + name: "iTerm2".to_string(), + executable_path: PathBuf::from("/Applications/iTerm.app/Contents/MacOS/iTerm2"), + args_template: vec![], + env_vars: HashMap::new(), + features: TerminalFeatures { + supports_tabs: true, + supports_splits: true, + supports_profiles: true, + supports_themes: true, + supports_scripting: true, + supports_url_scheme: true, + supports_remote_control: true, + }, + platform: vec!["macos".to_string()], + }); + + // Alacritty configuration + configs.insert(TerminalEmulator::Alacritty, TerminalConfig { + emulator: TerminalEmulator::Alacritty, + name: "Alacritty".to_string(), + executable_path: PathBuf::from("/Applications/Alacritty.app/Contents/MacOS/alacritty"), + args_template: vec![ + "--working-directory".to_string(), + "{working_directory}".to_string(), + "-e".to_string(), + "{command}".to_string(), + "{args}".to_string(), + ], + env_vars: HashMap::new(), + features: TerminalFeatures { + supports_tabs: false, + supports_splits: 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()], + }); + + // Kitty configuration + configs.insert(TerminalEmulator::Kitty, TerminalConfig { + emulator: TerminalEmulator::Kitty, + name: "Kitty".to_string(), + executable_path: PathBuf::from("/Applications/kitty.app/Contents/MacOS/kitty"), + args_template: vec![ + "--directory".to_string(), + "{working_directory}".to_string(), + "{command}".to_string(), + "{args}".to_string(), + ], + env_vars: HashMap::new(), + features: TerminalFeatures { + supports_tabs: true, + supports_splits: true, + supports_profiles: true, + supports_themes: true, + supports_scripting: true, + supports_url_scheme: false, + supports_remote_control: true, + }, + platform: vec!["macos".to_string(), "linux".to_string()], + }); + + configs + } + + /// Initialize URL schemes + fn initialize_url_schemes() -> HashMap { + let mut schemes = HashMap::new(); + + schemes.insert(TerminalEmulator::ITerm2, TerminalURLScheme { + scheme: "iterm2".to_string(), + supports_ssh: true, + supports_local: true, + template: "iterm2://ssh/{user}@{host}:{port}".to_string(), + }); + + schemes + } + + /// Detect installed terminals + pub async fn detect_terminals(&self) -> Vec { + let mut detected = Vec::new(); + let configs = self.configs.read().await; + + for (emulator, config) in configs.iter() { + let info = self.check_terminal_installation(emulator, config).await; + if info.installed { + detected.push(info.clone()); + self.detected_terminals.write().await.insert(*emulator, info); + } + } + + // Check system default + let default_info = self.detect_system_default().await; + detected.insert(0, default_info); + + detected + } + + /// Check if a specific terminal is installed + async fn check_terminal_installation(&self, emulator: &TerminalEmulator, config: &TerminalConfig) -> TerminalIntegrationInfo { + let installed = config.executable_path.exists(); + let version = if installed { + self.get_terminal_version(emulator, &config.executable_path).await + } else { + None + }; + + TerminalIntegrationInfo { + emulator: *emulator, + installed, + version, + path: if installed { Some(config.executable_path.clone()) } else { None }, + is_default: false, + config: if installed { Some(config.clone()) } else { None }, + } + } + + /// Get terminal version + async fn get_terminal_version(&self, emulator: &TerminalEmulator, path: &PathBuf) -> Option { + match emulator { + TerminalEmulator::WezTerm => { + Command::new(path) + .arg("--version") + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|v| v.trim().to_string()) + } + TerminalEmulator::Alacritty => { + Command::new(path) + .arg("--version") + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|v| v.trim().to_string()) + } + _ => None, + } + } + + /// Detect system default terminal + async fn detect_system_default(&self) -> TerminalIntegrationInfo { + #[cfg(target_os = "macos")] + { + TerminalIntegrationInfo { + emulator: TerminalEmulator::Terminal, + installed: true, + version: None, + path: Some(PathBuf::from("/System/Applications/Utilities/Terminal.app")), + is_default: true, + config: None, + } + } + + #[cfg(target_os = "windows")] + { + TerminalIntegrationInfo { + emulator: TerminalEmulator::WindowsTerminal, + installed: true, + version: None, + path: None, + is_default: true, + config: None, + } + } + + #[cfg(target_os = "linux")] + { + TerminalIntegrationInfo { + emulator: TerminalEmulator::Gnome, + installed: true, + version: None, + path: None, + is_default: true, + config: None, + } + } + } + + /// Get default terminal + pub async fn get_default_terminal(&self) -> TerminalEmulator { + *self.default_terminal.read().await + } + + /// Set default terminal + pub async fn set_default_terminal(&self, emulator: TerminalEmulator) -> Result<(), String> { + // Check if terminal is installed + let detected = self.detected_terminals.read().await; + if emulator != TerminalEmulator::SystemDefault && !detected.contains_key(&emulator) { + return Err("Terminal not installed".to_string()); + } + + *self.default_terminal.write().await = emulator; + + // Notify user + if let Some(notification_manager) = &self.notification_manager { + let _ = notification_manager.notify_success( + "Default Terminal Changed", + &format!("Default terminal set to {}", emulator.display_name()) + ).await; + } + + Ok(()) + } + + /// Launch terminal + pub async fn launch_terminal( + &self, + emulator: Option, + options: TerminalLaunchOptions, + ) -> Result<(), String> { + let emulator = emulator.unwrap_or(*self.default_terminal.read().await); + + match emulator { + TerminalEmulator::SystemDefault => self.launch_system_terminal(options).await, + _ => self.launch_specific_terminal(emulator, options).await, + } + } + + /// Launch system terminal + async fn launch_system_terminal(&self, options: TerminalLaunchOptions) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + self.launch_macos_terminal(options).await + } + + #[cfg(target_os = "windows")] + { + self.launch_windows_terminal(options).await + } + + #[cfg(target_os = "linux")] + { + self.launch_linux_terminal(options).await + } + } + + /// Launch specific terminal + async fn launch_specific_terminal( + &self, + emulator: TerminalEmulator, + options: TerminalLaunchOptions, + ) -> Result<(), String> { + let configs = self.configs.read().await; + let config = configs.get(&emulator) + .ok_or_else(|| "Terminal configuration not found".to_string())?; + + let mut command = Command::new(&config.executable_path); + + // Build command arguments + for arg_template in &config.args_template { + let arg = self.replace_template_variables(arg_template, &options); + if !arg.is_empty() { + command.arg(arg); + } + } + + // Set environment variables + for (key, value) in &config.env_vars { + command.env(key, value); + } + for (key, value) in &options.env_vars { + command.env(key, value); + } + + // Set working directory + if let Some(cwd) = &options.working_directory { + command.current_dir(cwd); + } + + // Launch terminal + command.spawn() + .map_err(|e| format!("Failed to launch terminal: {}", e))?; + + Ok(()) + } + + /// Launch macOS terminal + #[cfg(target_os = "macos")] + async fn launch_macos_terminal(&self, options: TerminalLaunchOptions) -> Result<(), String> { + use std::process::Command; + + let mut script = String::from("tell application \"Terminal\"\n"); + script.push_str(" activate\n"); + + if options.tab { + script.push_str(" tell application \"System Events\" to keystroke \"t\" using command down\n"); + } + + if let Some(cwd) = options.working_directory { + script.push_str(&format!(" do script \"cd '{}'\" in front window\n", cwd.display())); + } + + if let Some(command) = options.command { + let full_command = if options.args.is_empty() { + command + } else { + format!("{} {}", command, options.args.join(" ")) + }; + script.push_str(&format!(" do script \"{}\" in front window\n", full_command)); + } + + script.push_str("end tell\n"); + + Command::new("osascript") + .arg("-e") + .arg(script) + .spawn() + .map_err(|e| format!("Failed to launch Terminal: {}", e))?; + + Ok(()) + } + + /// Launch Windows terminal + #[cfg(target_os = "windows")] + async fn launch_windows_terminal(&self, options: TerminalLaunchOptions) -> Result<(), String> { + use std::process::Command; + + let mut command = Command::new("wt.exe"); + + if let Some(cwd) = options.working_directory { + command.args(&["-d", cwd.to_str().unwrap_or(".")]); + } + + if options.tab { + command.arg("new-tab"); + } + + if let Some(cmd) = options.command { + command.args(&["--", &cmd]); + for arg in options.args { + command.arg(arg); + } + } + + command.spawn() + .map_err(|e| format!("Failed to launch Windows Terminal: {}", e))?; + + Ok(()) + } + + /// Launch Linux terminal + #[cfg(target_os = "linux")] + async fn launch_linux_terminal(&self, options: TerminalLaunchOptions) -> Result<(), String> { + use std::process::Command; + + // Try common terminal emulators + let terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "xterm"]; + + for terminal in &terminals { + if let Ok(output) = Command::new("which").arg(terminal).output() { + if output.status.success() { + let mut command = Command::new(terminal); + + if let Some(cwd) = &options.working_directory { + match *terminal { + "gnome-terminal" => { + command.arg("--working-directory").arg(cwd); + } + "konsole" => { + command.arg("--workdir").arg(cwd); + } + _ => {} + } + } + + if let Some(cmd) = &options.command { + match *terminal { + "gnome-terminal" => { + command.arg("--").arg(cmd); + } + "konsole" => { + command.arg("-e").arg(cmd); + } + _ => { + command.arg("-e").arg(cmd); + } + } + for arg in &options.args { + command.arg(arg); + } + } + + return command.spawn() + .map_err(|e| format!("Failed to launch terminal: {}", e)) + .map(|_| ()); + } + } + } + + Err("No suitable terminal emulator found".to_string()) + } + + /// Create SSH URL + pub async fn create_ssh_url( + &self, + emulator: TerminalEmulator, + user: &str, + host: &str, + port: u16, + ) -> Option { + let schemes = self.url_schemes.read().await; + schemes.get(&emulator).map(|scheme| { + scheme.template + .replace("{user}", user) + .replace("{host}", host) + .replace("{port}", &port.to_string()) + }) + } + + /// Get terminal configuration + pub async fn get_terminal_config(&self, emulator: TerminalEmulator) -> Option { + self.configs.read().await.get(&emulator).cloned() + } + + /// Update terminal configuration + pub async fn update_terminal_config(&self, config: TerminalConfig) { + self.configs.write().await.insert(config.emulator, config); + } + + /// List detected terminals + pub async fn list_detected_terminals(&self) -> Vec { + self.detected_terminals.read().await.values().cloned().collect() + } + + // Helper methods + fn replace_template_variables(&self, template: &str, options: &TerminalLaunchOptions) -> String { + let mut result = template.to_string(); + + if let Some(cwd) = &options.working_directory { + result = result.replace("{working_directory}", cwd.to_str().unwrap_or("")); + } + + if let Some(command) = &options.command { + result = result.replace("{command}", command); + } + + result = result.replace("{args}", &options.args.join(" ")); + + if let Some(title) = &options.title { + result = result.replace("{title}", title); + } + + // Remove empty placeholders + result = result.replace("{working_directory}", ""); + result = result.replace("{command}", ""); + result = result.replace("{args}", ""); + result = result.replace("{title}", ""); + + result.trim().to_string() + } +} + +/// Terminal integration statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalIntegrationStats { + pub total_terminals: usize, + pub installed_terminals: usize, + pub default_terminal: TerminalEmulator, + pub terminals_by_platform: HashMap>, +} \ No newline at end of file diff --git a/tauri/src-tauri/src/terminal_spawn_service.rs b/tauri/src-tauri/src/terminal_spawn_service.rs new file mode 100644 index 00000000..a489d091 --- /dev/null +++ b/tauri/src-tauri/src/terminal_spawn_service.rs @@ -0,0 +1,182 @@ +use tokio::sync::{mpsc, Mutex}; +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +/// Request to spawn a terminal +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalSpawnRequest { + pub session_id: String, + pub terminal_type: Option, + pub command: Option, + pub working_directory: Option, + pub environment: Option>, +} + +/// Response from terminal spawn +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalSpawnResponse { + pub success: bool, + pub error: Option, + pub terminal_pid: Option, +} + +/// Terminal Spawn Service - manages background terminal spawning +pub struct TerminalSpawnService { + request_tx: mpsc::Sender, + terminal_integrations_manager: Arc, +} + +impl TerminalSpawnService { + pub fn new( + terminal_integrations_manager: Arc, + ) -> Self { + let (tx, mut rx) = mpsc::channel::(100); + + let manager_clone = terminal_integrations_manager.clone(); + + // Spawn background worker to handle terminal spawn requests + tokio::spawn(async move { + while let Some(request) = rx.recv().await { + let manager = manager_clone.clone(); + tokio::spawn(async move { + let _ = Self::handle_spawn_request(request, manager).await; + }); + } + }); + + Self { + request_tx: tx, + terminal_integrations_manager, + } + } + + /// Queue a terminal spawn request + pub async fn spawn_terminal(&self, request: TerminalSpawnRequest) -> Result<(), String> { + self.request_tx.send(request).await + .map_err(|e| format!("Failed to queue terminal spawn: {}", e)) + } + + /// Handle a spawn request + async fn handle_spawn_request( + request: TerminalSpawnRequest, + terminal_integrations_manager: Arc, + ) -> Result { + // Determine which terminal to use + let terminal_type = if let Some(terminal) = &request.terminal_type { + // Parse terminal type + match terminal.as_str() { + "Terminal" => crate::terminal_integrations::TerminalEmulator::Terminal, + "iTerm2" => crate::terminal_integrations::TerminalEmulator::ITerm2, + "Hyper" => crate::terminal_integrations::TerminalEmulator::Hyper, + "Alacritty" => crate::terminal_integrations::TerminalEmulator::Alacritty, + "Warp" => crate::terminal_integrations::TerminalEmulator::Warp, + "Kitty" => crate::terminal_integrations::TerminalEmulator::Kitty, + "WezTerm" => crate::terminal_integrations::TerminalEmulator::WezTerm, + "Ghostty" => crate::terminal_integrations::TerminalEmulator::Ghostty, + _ => terminal_integrations_manager.get_default_terminal().await, + } + } else { + terminal_integrations_manager.get_default_terminal().await + }; + + // Build launch options + let mut launch_options = crate::terminal_integrations::TerminalLaunchOptions { + command: request.command, + working_directory: request.working_directory, + environment: request.environment, + title: Some(format!("VibeTunnel Session {}", request.session_id)), + wait_for_exit: Some(false), + new_window: Some(true), + tab_mode: Some(false), + profile: None, + }; + + // If no command specified, create a VibeTunnel session command + if launch_options.command.is_none() { + // Get server status to build the correct URL + let port = 4020; // Default port, should get from settings + launch_options.command = Some(format!("vt connect localhost:{}/{}", port, request.session_id)); + } + + // Launch the terminal + match terminal_integrations_manager.launch_terminal(Some(terminal_type), launch_options).await { + Ok(_) => Ok(TerminalSpawnResponse { + success: true, + error: None, + terminal_pid: None, // We don't track PIDs in the current implementation + }), + Err(e) => Ok(TerminalSpawnResponse { + success: false, + error: Some(e), + terminal_pid: None, + }), + } + } + + /// Spawn terminal for a specific session + pub async fn spawn_terminal_for_session( + &self, + session_id: String, + terminal_type: Option, + ) -> Result<(), String> { + let request = TerminalSpawnRequest { + session_id, + terminal_type, + command: None, + working_directory: None, + environment: None, + }; + + self.spawn_terminal(request).await + } + + /// Spawn terminal with custom command + pub async fn spawn_terminal_with_command( + &self, + command: String, + working_directory: Option, + terminal_type: Option, + ) -> Result<(), String> { + let request = TerminalSpawnRequest { + session_id: uuid::Uuid::new_v4().to_string(), + terminal_type, + command: Some(command), + working_directory, + environment: None, + }; + + self.spawn_terminal(request).await + } +} + +// Commands for Tauri +#[tauri::command] +pub async fn spawn_terminal_for_session( + session_id: String, + terminal_type: Option, + state: tauri::State<'_, crate::state::AppState>, +) -> Result<(), String> { + let spawn_service = &state.terminal_spawn_service; + spawn_service.spawn_terminal_for_session(session_id, terminal_type).await +} + +#[tauri::command] +pub async fn spawn_terminal_with_command( + command: String, + working_directory: Option, + terminal_type: Option, + state: tauri::State<'_, crate::state::AppState>, +) -> Result<(), String> { + let spawn_service = &state.terminal_spawn_service; + spawn_service.spawn_terminal_with_command(command, working_directory, terminal_type).await +} + +#[tauri::command] +pub async fn spawn_custom_terminal( + request: TerminalSpawnRequest, + state: tauri::State<'_, crate::state::AppState>, +) -> Result<(), String> { + let spawn_service = &state.terminal_spawn_service; + spawn_service.spawn_terminal(request).await +} \ No newline at end of file diff --git a/tauri/src-tauri/src/tty_forward.rs b/tauri/src-tauri/src/tty_forward.rs new file mode 100644 index 00000000..fbef241f --- /dev/null +++ b/tauri/src-tauri/src/tty_forward.rs @@ -0,0 +1,377 @@ +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; + +/// Represents a forwarded TTY session +pub struct ForwardedSession { + pub id: String, + pub local_port: u16, + pub remote_host: String, + pub remote_port: u16, + pub connected: bool, + pub client_count: usize, +} + +/// Manages TTY forwarding sessions +pub struct TTYForwardManager { + sessions: Arc>>, + listeners: Arc>>>, +} + +impl TTYForwardManager { + pub fn new() -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + listeners: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Start a TTY forwarding session + pub async fn start_forward( + &self, + local_port: u16, + remote_host: String, + remote_port: u16, + shell: Option, + ) -> Result { + let id = Uuid::new_v4().to_string(); + + // Create TCP listener + let listener = TcpListener::bind(format!("127.0.0.1:{}", local_port)) + .await + .map_err(|e| format!("Failed to bind to port {}: {}", local_port, e))?; + + let actual_port = listener.local_addr() + .map_err(|e| format!("Failed to get local address: {}", e))? + .port(); + + // Create session + let session = ForwardedSession { + id: id.clone(), + local_port: actual_port, + remote_host: remote_host.clone(), + remote_port, + connected: false, + client_count: 0, + }; + + // Store session + self.sessions.write().await.insert(id.clone(), session); + + // Create shutdown channel + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + self.listeners.write().await.insert(id.clone(), shutdown_tx); + + // Start listening for connections + let sessions = self.sessions.clone(); + let session_id = id.clone(); + let shell = shell.unwrap_or_else(|| { + std::env::var("SHELL").unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + "cmd.exe".to_string() + } else { + "/bin/bash".to_string() + } + }) + }); + + tokio::spawn(async move { + Self::accept_connections( + listener, + sessions, + session_id, + remote_host, + remote_port, + shell, + shutdown_rx, + ).await; + }); + + info!("Started TTY forward on port {} (ID: {})", actual_port, id); + Ok(id) + } + + /// Accept incoming connections and forward them + async fn accept_connections( + listener: TcpListener, + sessions: Arc>>, + session_id: String, + _remote_host: String, + _remote_port: u16, + shell: String, + mut shutdown_rx: oneshot::Receiver<()>, + ) { + loop { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok((stream, addr)) => { + info!("New TTY forward connection from {}", addr); + + // Update client count + if let Some(session) = sessions.write().await.get_mut(&session_id) { + session.client_count += 1; + session.connected = true; + } + + // Handle the connection + let sessions_clone = sessions.clone(); + let session_id_clone = session_id.clone(); + let shell_clone = shell.clone(); + + tokio::spawn(async move { + if let Err(e) = Self::handle_client( + stream, + sessions_clone.clone(), + session_id_clone.clone(), + shell_clone, + ).await { + error!("Error handling TTY forward client: {}", e); + } + + // Decrease client count + if let Some(session) = sessions_clone.write().await.get_mut(&session_id_clone) { + session.client_count = session.client_count.saturating_sub(1); + if session.client_count == 0 { + session.connected = false; + } + } + }); + } + Err(e) => { + error!("Failed to accept connection: {}", e); + } + } + } + _ = &mut shutdown_rx => { + info!("Shutting down TTY forward listener for session {}", session_id); + break; + } + } + } + } + + /// Handle a single client connection + async fn handle_client( + stream: TcpStream, + _sessions: Arc>>, + _session_id: String, + shell: String, + ) -> Result<(), String> { + // Set up PTY + let pty_system = native_pty_system(); + let pty_pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("Failed to open PTY: {}", e))?; + + // Spawn shell + let cmd = CommandBuilder::new(&shell); + let child = pty_pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn shell: {}", e))?; + + // Get reader and writer + let mut reader = pty_pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone reader: {}", e))?; + + let mut writer = pty_pair + .master + .take_writer() + .map_err(|e| format!("Failed to take writer: {}", e))?; + + // Create channels for bidirectional communication + let (tx_to_pty, mut rx_from_tcp) = mpsc::unbounded_channel::(); + let (tx_to_tcp, mut rx_from_pty) = mpsc::unbounded_channel::(); + + // Split the TCP stream + let (mut tcp_reader, mut tcp_writer) = stream.into_split(); + + // Task 1: Read from TCP and write to PTY + let tcp_to_pty = tokio::spawn(async move { + let mut tcp_buf = [0u8; 4096]; + loop { + match tcp_reader.read(&mut tcp_buf).await { + Ok(0) => break, // Connection closed + Ok(n) => { + let data = Bytes::copy_from_slice(&tcp_buf[..n]); + if tx_to_pty.send(data).is_err() { + break; + } + } + Err(e) => { + error!("Error reading from TCP: {}", e); + break; + } + } + } + }); + + // Task 2: Read from PTY and write to TCP + let pty_to_tcp = tokio::spawn(async move { + while let Some(data) = rx_from_pty.recv().await { + if tcp_writer.write_all(&data).await.is_err() { + break; + } + if tcp_writer.flush().await.is_err() { + break; + } + } + }); + + // Task 3: PTY reader thread + let reader_handle = std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + let data = Bytes::copy_from_slice(&buffer[..n]); + // Since we're in a thread, we can't use blocking_send on unbounded channel + // We'll use a different approach + if tx_to_tcp.send(data).is_err() { + break; + } + } + Err(e) => { + error!("Error reading from PTY: {}", e); + break; + } + } + } + }); + + // Task 4: PTY writer thread + let writer_handle = std::thread::spawn(move || { + // Create a blocking runtime for the thread + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + while let Some(data) = rx_from_tcp.recv().await { + if writer.write_all(&data).is_err() { + break; + } + if writer.flush().is_err() { + break; + } + } + }); + }); + + // Wait for any task to complete + tokio::select! { + _ = tcp_to_pty => {}, + _ = pty_to_tcp => {}, + } + + // Clean up + drop(child); + let _ = reader_handle.join(); + let _ = writer_handle.join(); + + Ok(()) + } + + /// Stop a TTY forwarding session + pub async fn stop_forward(&self, id: &str) -> Result<(), String> { + // Remove session + self.sessions.write().await.remove(id); + + // Send shutdown signal + if let Some(shutdown_tx) = self.listeners.write().await.remove(id) { + let _ = shutdown_tx.send(()); + } + + info!("Stopped TTY forward session: {}", id); + Ok(()) + } + + /// List all active forwarding sessions + pub async fn list_forwards(&self) -> Vec { + self.sessions.read().await + .values() + .map(|s| ForwardedSession { + id: s.id.clone(), + local_port: s.local_port, + remote_host: s.remote_host.clone(), + remote_port: s.remote_port, + connected: s.connected, + client_count: s.client_count, + }) + .collect() + } + + /// Get a specific forwarding session + pub async fn get_forward(&self, id: &str) -> Option { + self.sessions.read().await.get(id).map(|s| ForwardedSession { + id: s.id.clone(), + 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 +pub async fn handle_terminal_spawn( + port: u16, + _shell: Option, +) -> Result<(), String> { + // Listen for HTTP requests on the specified port + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)) + .await + .map_err(|e| format!("Failed to bind spawn listener: {}", e))?; + + info!("Terminal spawn service listening on port {}", port); + + loop { + let (stream, addr) = listener.accept() + .await + .map_err(|e| format!("Failed to accept spawn connection: {}", e))?; + + info!("Terminal spawn request from {}", addr); + + // Handle the spawn request + tokio::spawn(async move { + if let Err(e) = handle_spawn_request(stream, None).await { + error!("Error handling spawn request: {}", e); + } + }); + } +} + +/// Handle a single terminal spawn request +async fn handle_spawn_request( + mut stream: TcpStream, + _shell: Option, +) -> Result<(), String> { + // Simple HTTP response + let response = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nTerminal spawned\r\n"; + stream.write_all(response) + .await + .map_err(|e| format!("Failed to write response: {}", e))?; + + // TODO: Implement actual terminal spawning logic + // This would integrate with the system's terminal emulator + + Ok(()) +} \ No newline at end of file diff --git a/tauri/src-tauri/src/updater.rs b/tauri/src-tauri/src/updater.rs new file mode 100644 index 00000000..623acb82 --- /dev/null +++ b/tauri/src-tauri/src/updater.rs @@ -0,0 +1,523 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use chrono::{DateTime, Utc, TimeZone}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::UpdaterExt; + +/// Update channel type +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum UpdateChannel { + Stable, + Beta, + Nightly, + Custom, +} + +impl UpdateChannel { + pub fn as_str(&self) -> &str { + match self { + UpdateChannel::Stable => "stable", + UpdateChannel::Beta => "beta", + UpdateChannel::Nightly => "nightly", + UpdateChannel::Custom => "custom", + } + } + + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "stable" => UpdateChannel::Stable, + "beta" => UpdateChannel::Beta, + "nightly" => UpdateChannel::Nightly, + _ => UpdateChannel::Custom, + } + } +} + +/// Update status +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum UpdateStatus { + Idle, + Checking, + Available, + Downloading, + Ready, + Installing, + Error, + NoUpdate, +} + +/// Update information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateInfo { + pub version: String, + pub notes: String, + pub pub_date: Option>, + pub download_size: Option, + pub signature: Option, + pub download_url: String, + pub channel: UpdateChannel, +} + +/// Update progress +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProgress { + pub downloaded: u64, + pub total: u64, + pub percentage: f32, + pub bytes_per_second: Option, + pub eta_seconds: Option, +} + +/// Update settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdaterSettings { + pub channel: UpdateChannel, + pub check_on_startup: bool, + pub check_interval_hours: u32, + pub auto_download: bool, + pub auto_install: bool, + pub show_release_notes: bool, + pub include_pre_releases: bool, + pub custom_endpoint: Option, + pub proxy: Option, +} + +impl Default for UpdaterSettings { + fn default() -> Self { + Self { + channel: UpdateChannel::Stable, + check_on_startup: true, + check_interval_hours: 24, + auto_download: false, + auto_install: false, + show_release_notes: true, + include_pre_releases: false, + custom_endpoint: None, + proxy: None, + } + } +} + +/// Update manager state +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateState { + pub status: UpdateStatus, + pub current_version: String, + pub available_update: Option, + pub progress: Option, + pub last_check: Option>, + pub last_error: Option, + pub update_history: Vec, +} + +/// Update history entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateHistoryEntry { + pub version: String, + pub from_version: String, + pub channel: UpdateChannel, + pub installed_at: DateTime, + pub success: bool, + pub notes: Option, +} + +/// Update manager +pub struct UpdateManager { + app_handle: Arc>>, + settings: Arc>, + state: Arc>, + notification_manager: Option>, +} + +impl UpdateManager { + /// Create a new update manager + pub fn new(current_version: String) -> Self { + Self { + app_handle: Arc::new(RwLock::new(None)), + settings: Arc::new(RwLock::new(UpdaterSettings::default())), + state: Arc::new(RwLock::new(UpdateState { + status: UpdateStatus::Idle, + current_version, + available_update: None, + progress: None, + last_check: None, + last_error: None, + update_history: Vec::new(), + })), + notification_manager: None, + } + } + + /// Set the app handle + pub async fn set_app_handle(&self, app_handle: AppHandle) { + *self.app_handle.write().await = Some(app_handle); + } + + /// Set the notification manager + pub fn set_notification_manager(&mut self, notification_manager: Arc) { + self.notification_manager = Some(notification_manager); + } + + /// Load settings from configuration + pub async fn load_settings(&self) -> Result<(), String> { + if let Ok(settings) = crate::settings::Settings::load() { + if let Some(update_settings) = settings.updates { + let mut updater_settings = self.settings.write().await; + updater_settings.channel = UpdateChannel::from_str(&update_settings.channel); + updater_settings.check_on_startup = true; + updater_settings.check_interval_hours = match update_settings.check_frequency.as_str() { + "daily" => 24, + "weekly" => 168, + "monthly" => 720, + _ => 24, + }; + updater_settings.auto_download = update_settings.auto_download; + updater_settings.auto_install = update_settings.auto_install; + updater_settings.show_release_notes = update_settings.show_release_notes; + updater_settings.include_pre_releases = update_settings.include_pre_releases; + } + } + Ok(()) + } + + /// Get update settings + pub async fn get_settings(&self) -> UpdaterSettings { + self.settings.read().await.clone() + } + + /// Update settings + pub async fn update_settings(&self, settings: UpdaterSettings) -> Result<(), String> { + *self.settings.write().await = settings.clone(); + + // Save to persistent settings + if let Ok(mut app_settings) = crate::settings::Settings::load() { + app_settings.updates = Some(crate::settings::UpdateSettings { + channel: settings.channel.as_str().to_string(), + check_frequency: match settings.check_interval_hours { + 1..=23 => "daily".to_string(), + 24..=167 => "daily".to_string(), + 168..=719 => "weekly".to_string(), + _ => "monthly".to_string(), + }, + auto_download: settings.auto_download, + auto_install: settings.auto_install, + show_release_notes: settings.show_release_notes, + include_pre_releases: settings.include_pre_releases, + }); + app_settings.save()?; + } + + Ok(()) + } + + /// Get current update state + pub async fn get_state(&self) -> UpdateState { + self.state.read().await.clone() + } + + /// Check for updates + pub async fn check_for_updates(&self) -> Result, String> { + // Update status + { + let mut state = self.state.write().await; + state.status = UpdateStatus::Checking; + state.last_error = None; + } + + // Emit checking event + self.emit_update_event("checking", None).await; + + let app_handle_guard = self.app_handle.read().await; + let app_handle = app_handle_guard.as_ref() + .ok_or_else(|| "App handle not set".to_string())?; + + // Get the updater instance + let updater = app_handle.updater_builder(); + + // Configure updater based on settings + let settings = self.settings.read().await; + + // Build updater with channel-specific endpoint + let updater_result = match settings.channel { + UpdateChannel::Stable => updater.endpoints(vec![ + "https://releases.vibetunnel.com/stable/{{target}}/{{arch}}/{{current_version}}".parse().unwrap() + ]), + UpdateChannel::Beta => updater.endpoints(vec![ + "https://releases.vibetunnel.com/beta/{{target}}/{{arch}}/{{current_version}}".parse().unwrap() + ]), + UpdateChannel::Nightly => updater.endpoints(vec![ + "https://releases.vibetunnel.com/nightly/{{target}}/{{arch}}/{{current_version}}".parse().unwrap() + ]), + UpdateChannel::Custom => { + if let Some(endpoint) = &settings.custom_endpoint { + match endpoint.parse() { + Ok(url) => updater.endpoints(vec![url]), + Err(_) => return Err("Invalid custom endpoint URL".to_string()), + } + } else { + return Err("Custom endpoint not configured".to_string()); + } + } + }; + + // Build and check + match updater_result { + Ok(updater_builder) => match updater_builder.build() { + Ok(updater) => { + match updater.check().await { + Ok(Some(update)) => { + let update_info = UpdateInfo { + version: update.version.clone(), + 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())), + download_size: None, // TODO: Get from update + signature: None, + download_url: String::new(), // Will be set by updater + channel: settings.channel, + }; + + // 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; + 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) => { + let error_msg = format!("Failed to build updater: {}", e); + + let mut state = self.state.write().await; + state.status = UpdateStatus::Error; + state.last_error = Some(error_msg.clone()); + + Err(error_msg) + } + }, + Err(e) => { + let error_msg = format!("Failed to configure updater endpoints: {}", e); + + let mut state = self.state.write().await; + state.status = UpdateStatus::Error; + state.last_error = Some(error_msg.clone()); + + Err(error_msg) + } + } + } + + /// Download update + pub async fn download_update(&self) -> Result<(), String> { + let update_available = { + let state = self.state.read().await; + state.available_update.is_some() + }; + + if !update_available { + return Err("No update available to download".to_string()); + } + + // Update status + { + let mut state = self.state.write().await; + state.status = UpdateStatus::Downloading; + state.progress = Some(UpdateProgress { + downloaded: 0, + total: 0, + percentage: 0.0, + bytes_per_second: None, + eta_seconds: None, + }); + } + + self.emit_update_event("downloading", None).await; + + // TODO: Implement actual download with progress tracking + // For now, simulate download completion + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // Update status to ready + { + let mut state = self.state.write().await; + state.status = UpdateStatus::Ready; + state.progress = None; + } + + self.emit_update_event("ready", None).await; + + // Auto-install if enabled + let settings = self.settings.read().await; + if settings.auto_install { + let _ = self.install_update().await; + } + + Ok(()) + } + + /// Install update + pub async fn install_update(&self) -> Result<(), String> { + let update_info = { + let state = self.state.read().await; + if state.status != UpdateStatus::Ready { + return Err("Update not ready for installation".to_string()); + } + state.available_update.clone() + }; + + let update_info = update_info.ok_or_else(|| "No update available".to_string())?; + + // Update status + { + let mut state = self.state.write().await; + state.status = UpdateStatus::Installing; + } + + self.emit_update_event("installing", None).await; + + // Add to history + { + let mut state = self.state.write().await; + let from_version = state.current_version.clone(); + state.update_history.push(UpdateHistoryEntry { + version: update_info.version.clone(), + from_version, + channel: update_info.channel, + installed_at: Utc::now(), + success: true, + notes: Some(update_info.notes.clone()), + }); + } + + // TODO: Implement actual installation + // For now, return success + + self.emit_update_event("installed", None).await; + + Ok(()) + } + + /// Cancel update + pub async fn cancel_update(&self) -> Result<(), String> { + let mut state = self.state.write().await; + + match state.status { + UpdateStatus::Downloading => { + // TODO: Cancel download + state.status = UpdateStatus::Available; + state.progress = None; + Ok(()) + } + _ => Err("No update in progress to cancel".to_string()), + } + } + + /// Switch update channel + pub async fn switch_channel(&self, channel: UpdateChannel) -> Result<(), String> { + let mut settings = self.settings.write().await; + settings.channel = channel; + drop(settings); + + // Save settings + self.update_settings(self.get_settings().await).await?; + + // Clear current update info when switching channels + let mut state = self.state.write().await; + state.available_update = None; + state.status = UpdateStatus::Idle; + + Ok(()) + } + + /// Get update history + pub async fn get_update_history(&self, limit: Option) -> Vec { + let state = self.state.read().await; + match limit { + Some(l) => state.update_history.iter().rev().take(l).cloned().collect(), + None => state.update_history.clone(), + } + } + + /// Start automatic update checking + pub async fn start_auto_check(self: Arc) { + let settings = self.settings.read().await; + if !settings.check_on_startup { + return; + } + + let check_interval = std::time::Duration::from_secs(settings.check_interval_hours as u64 * 3600); + drop(settings); + + tokio::spawn(async move { + loop { + let _ = self.check_for_updates().await; + tokio::time::sleep(check_interval).await; + } + }); + } + + /// Emit update event + async fn emit_update_event(&self, event_type: &str, update_info: Option<&UpdateInfo>) { + if let Some(app_handle) = self.app_handle.read().await.as_ref() { + let event_data = serde_json::json!({ + "type": event_type, + "update": update_info, + "state": self.get_state().await, + }); + + let _ = app_handle.emit("updater:event", event_data); + } + } +} + +/// Update check result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCheckResult { + pub available: bool, + pub current_version: String, + pub latest_version: Option, + pub channel: UpdateChannel, + pub checked_at: DateTime, +} \ No newline at end of file diff --git a/tauri/src-tauri/src/welcome.rs b/tauri/src-tauri/src/welcome.rs new file mode 100644 index 00000000..501cf6fb --- /dev/null +++ b/tauri/src-tauri/src/welcome.rs @@ -0,0 +1,459 @@ +use serde::{Serialize, Deserialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use tauri::{AppHandle, Manager, Emitter}; + +/// Tutorial step structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TutorialStep { + pub id: String, + pub title: String, + pub description: String, + pub content: String, + pub action: Option, + pub completed: bool, + pub order: u32, +} + +/// Tutorial action that can be triggered +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TutorialAction { + pub action_type: String, + pub payload: HashMap, +} + +/// Welcome state tracking +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WelcomeState { + pub first_launch: bool, + pub tutorial_completed: bool, + pub tutorial_skipped: bool, + pub completed_steps: Vec, + pub last_seen_version: Option, + pub onboarding_date: Option>, +} + +impl Default for WelcomeState { + fn default() -> Self { + Self { + first_launch: true, + tutorial_completed: false, + tutorial_skipped: false, + completed_steps: Vec::new(), + last_seen_version: None, + onboarding_date: None, + } + } +} + +/// Tutorial category +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TutorialCategory { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + pub steps: Vec, +} + +/// Welcome manager +pub struct WelcomeManager { + state: Arc>, + tutorials: Arc>>, + app_handle: Arc>>, +} + +impl WelcomeManager { + /// Create a new welcome manager + pub fn new() -> Self { + let manager = Self { + state: Arc::new(RwLock::new(WelcomeState::default())), + tutorials: Arc::new(RwLock::new(Vec::new())), + app_handle: Arc::new(RwLock::new(None)), + }; + + // Initialize default tutorials + tokio::spawn({ + let tutorials = manager.tutorials.clone(); + async move { + let default_tutorials = Self::create_default_tutorials(); + *tutorials.write().await = default_tutorials; + } + }); + + manager + } + + /// Set the app handle + pub async fn set_app_handle(&self, app_handle: AppHandle) { + *self.app_handle.write().await = Some(app_handle); + } + + /// Load welcome state from storage + pub async fn load_state(&self) -> Result<(), String> { + // Try to load from settings or local storage + if let Ok(settings) = crate::settings::Settings::load() { + // Check if this is first launch based on settings + let mut state = self.state.write().await; + state.first_launch = settings.general.show_welcome_on_startup.unwrap_or(true); + + // Mark first launch as false for next time + if state.first_launch { + state.onboarding_date = Some(Utc::now()); + } + } + Ok(()) + } + + /// Save welcome state + pub async fn save_state(&self) -> Result<(), String> { + let state = self.state.read().await; + + // Update settings to reflect welcome state + if let Ok(mut settings) = crate::settings::Settings::load() { + settings.general.show_welcome_on_startup = Some(!state.tutorial_completed && !state.tutorial_skipped); + settings.save().map_err(|e| e.to_string())?; + } + + Ok(()) + } + + /// Check if should show welcome screen + pub async fn should_show_welcome(&self) -> bool { + let state = self.state.read().await; + state.first_launch && !state.tutorial_completed && !state.tutorial_skipped + } + + /// Get current welcome state + pub async fn get_state(&self) -> WelcomeState { + self.state.read().await.clone() + } + + /// Get all tutorial categories + pub async fn get_tutorials(&self) -> Vec { + self.tutorials.read().await.clone() + } + + /// Get specific tutorial category + pub async fn get_tutorial_category(&self, category_id: &str) -> Option { + self.tutorials.read().await + .iter() + .find(|c| c.id == category_id) + .cloned() + } + + /// Complete a tutorial step + pub async fn complete_step(&self, step_id: &str) -> Result<(), String> { + let mut state = self.state.write().await; + + if !state.completed_steps.contains(&step_id.to_string()) { + state.completed_steps.push(step_id.to_string()); + + // Check if all steps are completed + let tutorials = self.tutorials.read().await; + let total_steps: usize = tutorials.iter() + .map(|c| c.steps.len()) + .sum(); + + if state.completed_steps.len() >= total_steps { + state.tutorial_completed = true; + } + + // Save state + drop(state); + drop(tutorials); + self.save_state().await?; + + // Emit progress event + if let Some(app_handle) = self.app_handle.read().await.as_ref() { + let _ = app_handle.emit("tutorial:step_completed", step_id); + } + } + + Ok(()) + } + + /// Skip tutorial + pub async fn skip_tutorial(&self) -> Result<(), String> { + let mut state = self.state.write().await; + state.tutorial_skipped = true; + state.first_launch = false; + drop(state); + + self.save_state().await?; + + Ok(()) + } + + /// Reset tutorial progress + pub async fn reset_tutorial(&self) -> Result<(), String> { + let mut state = self.state.write().await; + state.completed_steps.clear(); + state.tutorial_completed = false; + state.tutorial_skipped = false; + drop(state); + + self.save_state().await?; + + Ok(()) + } + + /// Show welcome window + pub async fn show_welcome_window(&self) -> Result<(), String> { + if let Some(app_handle) = self.app_handle.read().await.as_ref() { + // Check if welcome window already exists + if let Some(window) = app_handle.get_webview_window("welcome") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + } else { + // Create new welcome window + tauri::WebviewWindowBuilder::new( + app_handle, + "welcome", + tauri::WebviewUrl::App("welcome.html".into()) + ) + .title("Welcome to VibeTunnel") + .inner_size(800.0, 600.0) + .center() + .resizable(false) + .build() + .map_err(|e| e.to_string())?; + } + } else { + return Err("App handle not set".to_string()); + } + + Ok(()) + } + + /// Create default tutorial content + fn create_default_tutorials() -> Vec { + vec![ + TutorialCategory { + id: "getting_started".to_string(), + name: "Getting Started".to_string(), + description: "Learn the basics of VibeTunnel".to_string(), + icon: "🚀".to_string(), + steps: vec![ + TutorialStep { + id: "welcome".to_string(), + title: "Welcome to VibeTunnel".to_string(), + description: "Your powerful terminal session manager".to_string(), + content: r#"VibeTunnel lets you create, manage, and share terminal sessions with ease. + +Key features: +• Create multiple terminal sessions +• Share sessions via web interface +• Record terminal sessions +• Secure remote access with ngrok +• Cross-platform support"#.to_string(), + action: None, + completed: false, + order: 1, + }, + TutorialStep { + id: "create_session".to_string(), + title: "Creating Your First Session".to_string(), + description: "Learn how to create a terminal session".to_string(), + content: r#"To create a new terminal session: + +1. Click the "New Terminal" button +2. Choose your preferred shell +3. Set the session name (optional) +4. Click "Create" + +Your session will appear in the sidebar."#.to_string(), + action: Some(TutorialAction { + action_type: "create_terminal".to_string(), + payload: HashMap::new(), + }), + completed: false, + order: 2, + }, + TutorialStep { + id: "start_server".to_string(), + title: "Starting the Web Server".to_string(), + description: "Share your sessions via web interface".to_string(), + content: r#"The web server lets you access your terminals from any browser: + +1. Click "Start Server" in the toolbar +2. Choose your access mode: + • Localhost - Access only from this machine + • Network - Access from your local network + • Ngrok - Access from anywhere (requires auth token) +3. Share the URL with others or access it yourself"#.to_string(), + action: Some(TutorialAction { + action_type: "start_server".to_string(), + payload: HashMap::new(), + }), + completed: false, + order: 3, + }, + ], + }, + TutorialCategory { + id: "advanced_features".to_string(), + name: "Advanced Features".to_string(), + description: "Discover powerful features".to_string(), + icon: "⚡".to_string(), + steps: vec![ + TutorialStep { + id: "recording".to_string(), + title: "Recording Sessions".to_string(), + description: "Record and replay terminal sessions".to_string(), + content: r#"Record your terminal sessions in Asciinema format: + +1. Right-click on a session +2. Select "Start Recording" +3. Perform your terminal tasks +4. Stop recording when done +5. Save or share the recording + +Recordings can be played back later or shared with others."#.to_string(), + action: None, + completed: false, + order: 1, + }, + TutorialStep { + id: "port_forwarding".to_string(), + title: "TTY Forwarding".to_string(), + description: "Forward terminal sessions over TCP".to_string(), + content: r#"TTY forwarding allows remote terminal access: + +1. Go to Settings > Advanced +2. Enable TTY Forwarding +3. Configure the local port +4. Connect using: telnet localhost + +This is useful for accessing terminals from other applications."#.to_string(), + action: None, + completed: false, + order: 2, + }, + TutorialStep { + id: "cli_tool".to_string(), + title: "Command Line Interface".to_string(), + description: "Use VibeTunnel from the terminal".to_string(), + content: r#"Install the CLI tool for quick access: + +1. Go to Settings > Advanced +2. Click "Install CLI Tool" +3. Open a new terminal +4. Run: vt --help + +Common commands: +• vt new - Create new session +• vt list - List sessions +• vt attach - Attach to session"#.to_string(), + action: Some(TutorialAction { + action_type: "install_cli".to_string(), + payload: HashMap::new(), + }), + completed: false, + order: 3, + }, + ], + }, + TutorialCategory { + id: "security".to_string(), + name: "Security & Settings".to_string(), + description: "Configure security and preferences".to_string(), + icon: "🔒".to_string(), + steps: vec![ + TutorialStep { + id: "password_protection".to_string(), + title: "Password Protection".to_string(), + description: "Secure your web interface".to_string(), + content: r#"Protect your sessions with a password: + +1. Go to Settings > Dashboard +2. Enable "Password Protection" +3. Set a strong password +4. Save settings + +Anyone accessing the web interface will need this password."#.to_string(), + action: Some(TutorialAction { + action_type: "open_settings".to_string(), + payload: HashMap::new(), + }), + completed: false, + order: 1, + }, + TutorialStep { + id: "auto_launch".to_string(), + title: "Auto Launch".to_string(), + description: "Start VibeTunnel with your system".to_string(), + content: r#"Configure VibeTunnel to start automatically: + +1. Go to Settings > General +2. Enable "Launch at startup" +3. Choose startup behavior: + • Start minimized + • Show dock icon + • Auto-start server + +VibeTunnel will be ready whenever you need it."#.to_string(), + action: None, + completed: false, + order: 2, + }, + ], + }, + ] + } + + /// Get tutorial progress + pub async fn get_progress(&self) -> TutorialProgress { + let state = self.state.read().await; + let tutorials = self.tutorials.read().await; + + let total_steps: usize = tutorials.iter() + .map(|c| c.steps.len()) + .sum(); + + let completed_steps = state.completed_steps.len(); + let percentage = if total_steps > 0 { + (completed_steps as f32 / total_steps as f32 * 100.0) as u32 + } else { + 0 + }; + + TutorialProgress { + total_steps, + completed_steps, + percentage, + categories: tutorials.iter().map(|category| { + let category_completed = category.steps.iter() + .filter(|s| state.completed_steps.contains(&s.id)) + .count(); + + CategoryProgress { + category_id: category.id.clone(), + category_name: category.name.clone(), + total_steps: category.steps.len(), + completed_steps: category_completed, + } + }).collect(), + } + } +} + +/// Tutorial progress tracking +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TutorialProgress { + pub total_steps: usize, + pub completed_steps: usize, + pub percentage: u32, + pub categories: Vec, +} + +/// Category progress +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CategoryProgress { + pub category_id: String, + pub category_name: String, + pub total_steps: usize, + pub completed_steps: usize, +} \ No newline at end of file diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index ef03de4e..5cf5096b 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "build": { "beforeDevCommand": "", "beforeBuildCommand": "", - "frontendDist": "../dist" + "frontendDist": "../public" }, "app": { "windows": [{ @@ -39,7 +39,7 @@ "icons/menu-bar-icon@2x.png", "icons/tray-icon.png", "icons/tray-icon@2x.png", - "../public/**" + "public/**" ], "macOS": { "frameworks": [],