diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 03f97ba..9ab48a3 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -213,6 +213,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -664,14 +719,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -688,17 +767,34 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -709,6 +805,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -841,6 +968,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -1309,6 +1442,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "gio" version = "0.18.4" @@ -2085,6 +2230,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "local-ip-address" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2143,6 +2299,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -2246,6 +2408,35 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2916,6 +3107,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -3594,6 +3807,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3660,7 +3884,7 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.117", @@ -3698,6 +3922,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4459,6 +4694,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4581,6 +4828,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4619,6 +4867,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4670,6 +4919,8 @@ dependencies = [ name = "triple-c" version = "0.2.0" dependencies = [ + "axum", + "base64 0.22.1", "bollard", "chrono", "dirs", @@ -4677,7 +4928,9 @@ dependencies = [ "futures-util", "iana-time-zone", "keyring", + "local-ip-address", "log", + "rand 0.9.2", "reqwest 0.12.28", "serde", "serde_json", @@ -4689,6 +4942,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-store", "tokio", + "tower-http", "uuid", ] @@ -4698,6 +4952,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index f13ae16..da1d389 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -31,6 +31,11 @@ tar = "0.4" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } iana-time-zone = "0.1" sha2 = "0.10" +axum = { version = "0.8", features = ["ws"] } +tower-http = { version = "0.6", features = ["cors"] } +base64 = "0.22" +rand = "0.9" +local-ip-address = "0.6" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 995e5c2..8503563 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -7,3 +7,4 @@ pub mod project_commands; pub mod settings_commands; pub mod terminal_commands; pub mod update_commands; +pub mod web_terminal_commands; diff --git a/app/src-tauri/src/commands/web_terminal_commands.rs b/app/src-tauri/src/commands/web_terminal_commands.rs new file mode 100644 index 0000000..8c2acc6 --- /dev/null +++ b/app/src-tauri/src/commands/web_terminal_commands.rs @@ -0,0 +1,143 @@ +use serde::Serialize; +use tauri::State; + +use crate::web_terminal::WebTerminalServer; +use crate::AppState; + +#[derive(Serialize)] +pub struct WebTerminalInfo { + pub running: bool, + pub port: u16, + pub access_token: String, + pub local_ip: Option, + pub url: Option, +} + +fn generate_token() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random::()).collect(); + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes) +} + +fn get_local_ip() -> Option { + local_ip_address::local_ip().ok().map(|ip| ip.to_string()) +} + +fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo { + let local_ip = get_local_ip(); + let url = if running { + local_ip + .as_ref() + .map(|ip| format!("http://{}:{}?token={}", ip, port, token)) + } else { + None + }; + WebTerminalInfo { + running, + port, + access_token: token.to_string(), + local_ip, + url, + } +} + +#[tauri::command] +pub async fn start_web_terminal(state: State<'_, AppState>) -> Result { + let mut server_guard = state.web_terminal_server.lock().await; + if server_guard.is_some() { + return Err("Web terminal server is already running".to_string()); + } + + let mut settings = state.settings_store.get(); + + // Auto-generate token if not set + if settings.web_terminal.access_token.is_none() { + settings.web_terminal.access_token = Some(generate_token()); + settings.web_terminal.enabled = true; + state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?; + } + + let token = settings.web_terminal.access_token.clone().unwrap_or_default(); + let port = settings.web_terminal.port; + + let server = WebTerminalServer::start( + port, + token.clone(), + state.exec_manager.clone(), + state.projects_store.clone(), + state.settings_store.clone(), + ) + .await?; + + *server_guard = Some(server); + + // Mark as enabled in settings + if !settings.web_terminal.enabled { + settings.web_terminal.enabled = true; + let _ = state.settings_store.update(settings); + } + + Ok(build_info(true, port, &token)) +} + +#[tauri::command] +pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> { + let mut server_guard = state.web_terminal_server.lock().await; + if let Some(server) = server_guard.take() { + server.stop(); + } + + // Mark as disabled in settings + let mut settings = state.settings_store.get(); + if settings.web_terminal.enabled { + settings.web_terminal.enabled = false; + let _ = state.settings_store.update(settings); + } + + Ok(()) +} + +#[tauri::command] +pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result { + let server_guard = state.web_terminal_server.lock().await; + let settings = state.settings_store.get(); + let token = settings.web_terminal.access_token.clone().unwrap_or_default(); + let running = server_guard.is_some(); + Ok(build_info(running, settings.web_terminal.port, &token)) +} + +#[tauri::command] +pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result { + // Stop current server if running + { + let mut server_guard = state.web_terminal_server.lock().await; + if let Some(server) = server_guard.take() { + server.stop(); + } + } + + // Generate new token and save + let new_token = generate_token(); + let mut settings = state.settings_store.get(); + settings.web_terminal.access_token = Some(new_token.clone()); + state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?; + + // Restart if was enabled + if settings.web_terminal.enabled { + let server = WebTerminalServer::start( + settings.web_terminal.port, + new_token.clone(), + state.exec_manager.clone(), + state.projects_store.clone(), + state.settings_store.clone(), + ) + .await?; + let mut server_guard = state.web_terminal_server.lock().await; + *server_guard = Some(server); + return Ok(build_info(true, settings.web_terminal.port, &new_token)); + } + + Ok(build_info(false, settings.web_terminal.port, &new_token)) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 889dfcb..7bd8953 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,44 +3,55 @@ mod docker; mod logging; mod models; mod storage; +pub mod web_terminal; + +use std::sync::Arc; use docker::exec::ExecSessionManager; use storage::projects_store::ProjectsStore; use storage::settings_store::SettingsStore; use storage::mcp_store::McpStore; use tauri::Manager; +use web_terminal::WebTerminalServer; pub struct AppState { - pub projects_store: ProjectsStore, - pub settings_store: SettingsStore, - pub mcp_store: McpStore, - pub exec_manager: ExecSessionManager, + pub projects_store: Arc, + pub settings_store: Arc, + pub mcp_store: Arc, + pub exec_manager: Arc, + pub web_terminal_server: Arc>>, } pub fn run() { logging::init(); - let projects_store = match ProjectsStore::new() { + let projects_store = Arc::new(match ProjectsStore::new() { Ok(s) => s, Err(e) => { log::error!("Failed to initialize projects store: {}", e); panic!("Failed to initialize projects store: {}", e); } - }; - let settings_store = match SettingsStore::new() { + }); + let settings_store = Arc::new(match SettingsStore::new() { Ok(s) => s, Err(e) => { log::error!("Failed to initialize settings store: {}", e); panic!("Failed to initialize settings store: {}", e); } - }; - let mcp_store = match McpStore::new() { + }); + let mcp_store = Arc::new(match McpStore::new() { Ok(s) => s, Err(e) => { log::error!("Failed to initialize MCP store: {}", e); panic!("Failed to initialize MCP store: {}", e); } - }; + }); + let exec_manager = Arc::new(ExecSessionManager::new()); + + // Clone Arcs for the setup closure (web terminal auto-start) + let projects_store_setup = projects_store.clone(); + let settings_store_setup = settings_store.clone(); + let exec_manager_setup = exec_manager.clone(); tauri::Builder::default() .plugin(tauri_plugin_store::Builder::default().build()) @@ -50,9 +61,10 @@ pub fn run() { projects_store, settings_store, mcp_store, - exec_manager: ExecSessionManager::new(), + exec_manager, + web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), }) - .setup(|app| { + .setup(move |app| { match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) { Ok(icon) => { if let Some(window) = app.get_webview_window("main") { @@ -63,12 +75,54 @@ pub fn run() { log::error!("Failed to load window icon: {}", e); } } + + // Auto-start web terminal server if enabled in settings + let settings = settings_store_setup.get(); + if settings.web_terminal.enabled { + if let Some(token) = &settings.web_terminal.access_token { + let token = token.clone(); + let port = settings.web_terminal.port; + let exec_mgr = exec_manager_setup.clone(); + let proj_store = projects_store_setup.clone(); + let set_store = settings_store_setup.clone(); + let state = app.state::(); + let web_server_mutex = state.web_terminal_server.clone(); + + tauri::async_runtime::spawn(async move { + match WebTerminalServer::start( + port, + token, + exec_mgr, + proj_store, + set_store, + ) + .await + { + Ok(server) => { + let mut guard = web_server_mutex.lock().await; + *guard = Some(server); + log::info!("Web terminal auto-started on port {}", port); + } + Err(e) => { + log::error!("Failed to auto-start web terminal: {}", e); + } + } + }); + } + } + Ok(()) }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { let state = window.state::(); tauri::async_runtime::block_on(async { + // Stop web terminal server + let mut server_guard = state.web_terminal_server.lock().await; + if let Some(server) = server_guard.take() { + server.stop(); + } + // Close all exec sessions state.exec_manager.close_all_sessions().await; }); } @@ -122,6 +176,11 @@ pub fn run() { commands::update_commands::check_image_update, // Help commands::help_commands::get_help_content, + // Web Terminal + commands::web_terminal_commands::start_web_terminal, + commands::web_terminal_commands::stop_web_terminal, + commands::web_terminal_commands::get_web_terminal_status, + commands::web_terminal_commands::regenerate_web_terminal_token, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 4f2d5f7..bb0ff18 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -74,6 +74,32 @@ pub struct AppSettings { pub default_microphone: Option, #[serde(default)] pub dismissed_image_digest: Option, + #[serde(default)] + pub web_terminal: WebTerminalSettings, +} + +fn default_web_terminal_port() -> u16 { + 7681 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebTerminalSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_web_terminal_port")] + pub port: u16, + #[serde(default)] + pub access_token: Option, +} + +impl Default for WebTerminalSettings { + fn default() -> Self { + Self { + enabled: false, + port: 7681, + access_token: None, + } + } } impl Default for AppSettings { @@ -93,6 +119,7 @@ impl Default for AppSettings { timezone: None, default_microphone: None, dismissed_image_digest: None, + web_terminal: WebTerminalSettings::default(), } } } diff --git a/app/src-tauri/src/web_terminal/mod.rs b/app/src-tauri/src/web_terminal/mod.rs new file mode 100644 index 0000000..5a24004 --- /dev/null +++ b/app/src-tauri/src/web_terminal/mod.rs @@ -0,0 +1,4 @@ +pub mod server; +mod ws_handler; + +pub use server::WebTerminalServer; diff --git a/app/src-tauri/src/web_terminal/server.rs b/app/src-tauri/src/web_terminal/server.rs new file mode 100644 index 0000000..c3af982 --- /dev/null +++ b/app/src-tauri/src/web_terminal/server.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use axum::extract::{Query, State as AxumState, WebSocketUpgrade}; +use axum::response::{Html, IntoResponse}; +use axum::routing::get; +use axum::Router; +use serde::{Deserialize, Serialize}; +use tokio::sync::watch; +use tower_http::cors::CorsLayer; + +use crate::docker::exec::ExecSessionManager; +use crate::storage::projects_store::ProjectsStore; +use crate::storage::settings_store::SettingsStore; + +use super::ws_handler; + +/// Shared state passed to all axum handlers. +pub struct WebTerminalState { + pub exec_manager: Arc, + pub projects_store: Arc, + pub settings_store: Arc, + pub access_token: String, +} + +/// Manages the lifecycle of the axum HTTP+WS server. +pub struct WebTerminalServer { + shutdown_tx: watch::Sender<()>, + port: u16, +} + +#[derive(Deserialize)] +pub struct TokenQuery { + pub token: Option, +} + +#[derive(Serialize)] +struct ProjectInfo { + id: String, + name: String, + status: String, +} + +impl WebTerminalServer { + /// Start the web terminal server on the given port. + pub async fn start( + port: u16, + access_token: String, + exec_manager: Arc, + projects_store: Arc, + settings_store: Arc, + ) -> Result { + let (shutdown_tx, shutdown_rx) = watch::channel(()); + + let shared_state = Arc::new(WebTerminalState { + exec_manager, + projects_store, + settings_store, + access_token, + }); + + let app = Router::new() + .route("/", get(serve_html)) + .route("/ws", get(ws_upgrade)) + .route("/api/projects", get(list_projects)) + .layer(CorsLayer::permissive()) + .with_state(shared_state); + + let addr = format!("0.0.0.0:{}", port); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| format!("Failed to bind web terminal to {}: {}", addr, e))?; + + log::info!("Web terminal server listening on {}", addr); + + let mut shutdown_rx_clone = shutdown_rx.clone(); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx_clone.changed().await; + }) + .await + .unwrap_or_else(|e| { + log::error!("Web terminal server error: {}", e); + }); + log::info!("Web terminal server shut down"); + }); + + Ok(Self { shutdown_tx, port }) + } + + /// Stop the server gracefully. + pub fn stop(&self) { + log::info!("Stopping web terminal server on port {}", self.port); + let _ = self.shutdown_tx.send(()); + } + + pub fn port(&self) -> u16 { + self.port + } +} + +/// Serve the embedded HTML page. +async fn serve_html() -> Html<&'static str> { + Html(include_str!("terminal.html")) +} + +/// Validate token from query params. +fn validate_token(state: &WebTerminalState, token: &Option) -> bool { + match token { + Some(t) => t == &state.access_token, + None => false, + } +} + +/// WebSocket upgrade handler. +async fn ws_upgrade( + ws: WebSocketUpgrade, + AxumState(state): AxumState>, + Query(query): Query, +) -> impl IntoResponse { + if !validate_token(&state, &query.token) { + return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response(); + } + ws.on_upgrade(move |socket| ws_handler::handle_connection(socket, state)) + .into_response() +} + +/// List running projects (REST endpoint). +async fn list_projects( + AxumState(state): AxumState>, + Query(query): Query, +) -> impl IntoResponse { + if !validate_token(&state, &query.token) { + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(serde_json::json!({"error": "Invalid token"})), + ) + .into_response(); + } + + let projects = state.projects_store.list(); + let infos: Vec = projects + .into_iter() + .map(|p| ProjectInfo { + id: p.id, + name: p.name, + status: serde_json::to_value(&p.status) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()), + }) + .collect(); + + axum::Json(infos).into_response() +} diff --git a/app/src-tauri/src/web_terminal/terminal.html b/app/src-tauri/src/web_terminal/terminal.html new file mode 100644 index 0000000..f7982ae --- /dev/null +++ b/app/src-tauri/src/web_terminal/terminal.html @@ -0,0 +1,516 @@ + + + + + +Triple-C Web Terminal + + + + + + + + + +
+ Triple-C + + + + +
+ + +
+ + +
+
+
Select a project and open a terminal session
+
Use the buttons above to start a Claude or Bash session
+
+
+ + + + diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs new file mode 100644 index 0000000..6619e80 --- /dev/null +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus}; + +use super::server::WebTerminalState; + +// ── Wire protocol types ────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientMessage { + ListProjects, + Open { + project_id: String, + session_type: Option, + }, + Input { + session_id: String, + data: String, // base64 + }, + Resize { + session_id: String, + cols: u16, + rows: u16, + }, + Close { + session_id: String, + }, + Ping, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ServerMessage { + Projects { + projects: Vec, + }, + Opened { + session_id: String, + project_name: String, + }, + Output { + session_id: String, + data: String, // base64 + }, + Exit { + session_id: String, + }, + Error { + message: String, + }, + Pong, +} + +#[derive(Serialize)] +struct ProjectEntry { + id: String, + name: String, + status: String, +} + +// ── Connection handler ─────────────────────────────────────────────── + +pub async fn handle_connection(socket: WebSocket, state: Arc) { + let (mut ws_tx, mut ws_rx) = socket.split(); + + // Channel for sending messages from session output tasks → WS writer + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Track session IDs owned by this connection for cleanup + let owned_sessions: Arc>> = + Arc::new(tokio::sync::Mutex::new(Vec::new())); + + // Writer task: serializes ServerMessages and sends as WS text frames + let writer_handle = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if let Ok(json) = serde_json::to_string(&msg) { + if ws_tx.send(Message::Text(json.into())).await.is_err() { + break; + } + } + } + }); + + // Reader loop: parse incoming messages and dispatch + while let Some(Ok(msg)) = ws_rx.next().await { + let text = match &msg { + Message::Text(t) => t.to_string(), + Message::Close(_) => break, + _ => continue, + }; + + let client_msg: ClientMessage = match serde_json::from_str(&text) { + Ok(m) => m, + Err(e) => { + let _ = out_tx.send(ServerMessage::Error { + message: format!("Invalid message: {}", e), + }); + continue; + } + }; + + match client_msg { + ClientMessage::Ping => { + let _ = out_tx.send(ServerMessage::Pong); + } + + ClientMessage::ListProjects => { + let projects = state.projects_store.list(); + let entries: Vec = projects + .into_iter() + .map(|p| ProjectEntry { + id: p.id, + name: p.name, + status: serde_json::to_value(&p.status) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()), + }) + .collect(); + let _ = out_tx.send(ServerMessage::Projects { projects: entries }); + } + + ClientMessage::Open { + project_id, + session_type, + } => { + let result = handle_open( + &state, + &project_id, + session_type.as_deref(), + &out_tx, + &owned_sessions, + ) + .await; + if let Err(e) = result { + let _ = out_tx.send(ServerMessage::Error { message: e }); + } + } + + ClientMessage::Input { session_id, data } => { + match BASE64.decode(&data) { + Ok(bytes) => { + if let Err(e) = state.exec_manager.send_input(&session_id, bytes).await { + let _ = out_tx.send(ServerMessage::Error { + message: format!("Input error: {}", e), + }); + } + } + Err(e) => { + let _ = out_tx.send(ServerMessage::Error { + message: format!("Base64 decode error: {}", e), + }); + } + } + } + + ClientMessage::Resize { + session_id, + cols, + rows, + } => { + if let Err(e) = state.exec_manager.resize(&session_id, cols, rows).await { + let _ = out_tx.send(ServerMessage::Error { + message: format!("Resize error: {}", e), + }); + } + } + + ClientMessage::Close { session_id } => { + state.exec_manager.close_session(&session_id).await; + // Remove from owned list + owned_sessions + .lock() + .await + .retain(|id| id != &session_id); + } + } + } + + // Connection closed — clean up all owned sessions + log::info!("Web terminal WebSocket disconnected, cleaning up sessions"); + let sessions = owned_sessions.lock().await.clone(); + for session_id in sessions { + state.exec_manager.close_session(&session_id).await; + } + + writer_handle.abort(); +} + +/// Build the command for a terminal session, mirroring terminal_commands.rs logic. +fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec { + let is_bedrock_profile = project.backend == Backend::Bedrock + && project + .bedrock_config + .as_ref() + .map(|b| b.auth_method == BedrockAuthMethod::Profile) + .unwrap_or(false); + + if !is_bedrock_profile { + let mut cmd = vec!["claude".to_string()]; + if project.full_permissions { + cmd.push("--dangerously-skip-permissions".to_string()); + } + return cmd; + } + + let profile = project + .bedrock_config + .as_ref() + .and_then(|b| b.aws_profile.clone()) + .or_else(|| settings_store.get().global_aws.aws_profile.clone()) + .unwrap_or_else(|| "default".to_string()); + + let claude_cmd = if project.full_permissions { + "exec claude --dangerously-skip-permissions" + } else { + "exec claude" + }; + + let script = format!( + r#" +echo "Validating AWS session for profile '{profile}'..." +if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then + echo "AWS session valid." +else + echo "AWS session expired or invalid." + if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \ + aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then + echo "Starting SSO login..." + echo "" + triple-c-sso-refresh + if [ $? -ne 0 ]; then + echo "" + echo "SSO login failed or was cancelled. Starting Claude anyway..." + echo "You may see authentication errors." + echo "" + fi + else + echo "Profile '{profile}' does not use SSO. Check your AWS credentials." + echo "Starting Claude anyway..." + echo "" + fi +fi +{claude_cmd} +"#, + profile = profile, + claude_cmd = claude_cmd + ); + + vec!["bash".to_string(), "-c".to_string(), script] +} + +/// Open a new terminal session for a project. +async fn handle_open( + state: &WebTerminalState, + project_id: &str, + session_type: Option<&str>, + out_tx: &mpsc::UnboundedSender, + owned_sessions: &Arc>>, +) -> Result<(), String> { + let project = state + .projects_store + .get(project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + if project.status != ProjectStatus::Running { + return Err(format!("Project '{}' is not running", project.name)); + } + + let container_id = project + .container_id + .as_ref() + .ok_or_else(|| "Container not running".to_string())?; + + let cmd = match session_type { + Some("bash") => vec!["bash".to_string(), "-l".to_string()], + _ => build_terminal_cmd(&project, &state.settings_store), + }; + + let session_id = uuid::Uuid::new_v4().to_string(); + let project_name = project.name.clone(); + + // Set up output routing through the WS channel + let out_tx_output = out_tx.clone(); + let session_id_output = session_id.clone(); + let on_output = move |data: Vec| { + let encoded = BASE64.encode(&data); + let _ = out_tx_output.send(ServerMessage::Output { + session_id: session_id_output.clone(), + data: encoded, + }); + }; + + let out_tx_exit = out_tx.clone(); + let session_id_exit = session_id.clone(); + let on_exit = Box::new(move || { + let _ = out_tx_exit.send(ServerMessage::Exit { + session_id: session_id_exit, + }); + }); + + state + .exec_manager + .create_session(container_id, &session_id, cmd, on_output, on_exit) + .await?; + + // Track this session for cleanup on disconnect + owned_sessions.lock().await.push(session_id.clone()); + + let _ = out_tx.send(ServerMessage::Opened { + session_id, + project_name, + }); + + Ok(()) +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index b5f161f..8b0ab7d 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -8,6 +8,7 @@ import EnvVarsModal from "../projects/EnvVarsModal"; import { detectHostTimezone } from "../../lib/tauri-commands"; import type { EnvVar } from "../../lib/types"; import Tooltip from "../ui/Tooltip"; +import WebTerminalSettings from "./WebTerminalSettings"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); @@ -116,6 +117,9 @@ export default function SettingsPanel() { + {/* Web Terminal */} + + {/* Updates section */}
diff --git a/app/src/components/settings/WebTerminalSettings.tsx b/app/src/components/settings/WebTerminalSettings.tsx new file mode 100644 index 0000000..ef87e34 --- /dev/null +++ b/app/src/components/settings/WebTerminalSettings.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from "react"; +import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands"; +import type { WebTerminalInfo } from "../../lib/types"; +import Tooltip from "../ui/Tooltip"; + +export default function WebTerminalSettings() { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + getWebTerminalStatus().then(setInfo).catch(console.error); + }, []); + + const handleToggle = async () => { + setLoading(true); + try { + if (info?.running) { + await stopWebTerminal(); + const updated = await getWebTerminalStatus(); + setInfo(updated); + } else { + const updated = await startWebTerminal(); + setInfo(updated); + } + } catch (e) { + console.error("Web terminal toggle failed:", e); + } finally { + setLoading(false); + } + }; + + const handleRegenerate = async () => { + try { + const updated = await regenerateWebTerminalToken(); + setInfo(updated); + } catch (e) { + console.error("Token regeneration failed:", e); + } + }; + + const handleCopyUrl = async () => { + if (info?.url) { + await navigator.clipboard.writeText(info.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleCopyToken = async () => { + if (info?.access_token) { + await navigator.clipboard.writeText(info.access_token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
+ +

+ Serves a browser-based terminal UI on your local network for remote access to running projects. +

+ +
+ {/* Toggle */} +
+ + + {info?.running + ? `Running on port ${info.port}` + : "Stopped"} + +
+ + {/* URL + Copy */} + {info?.running && info.url && ( +
+ + {info.url} + + +
+ )} + + {/* Token */} + {info && ( +
+ Token: + + {info.access_token ? `${info.access_token.slice(0, 12)}...` : "None"} + + + +
+ )} +
+
+ ); +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 4fcc8fc..6172b73 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -88,3 +88,13 @@ export const checkImageUpdate = () => // Help export const getHelpContent = () => invoke("get_help_content"); + +// Web Terminal +export const startWebTerminal = () => + invoke("start_web_terminal"); +export const stopWebTerminal = () => + invoke("stop_web_terminal"); +export const getWebTerminalStatus = () => + invoke("get_web_terminal_status"); +export const regenerateWebTerminalToken = () => + invoke("regenerate_web_terminal_token"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index dbf878a..a86d696 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -118,6 +118,21 @@ export interface AppSettings { timezone: string | null; default_microphone: string | null; dismissed_image_digest: string | null; + web_terminal: WebTerminalSettings; +} + +export interface WebTerminalSettings { + enabled: boolean; + port: number; + access_token: string | null; +} + +export interface WebTerminalInfo { + running: boolean; + port: number; + access_token: string; + local_ip: string | null; + url: string | null; } export interface UpdateInfo {