Add web terminal for remote tablet/phone access to project terminals
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m56s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m56s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Adds an axum HTTP+WebSocket server that runs alongside the Tauri app, serving a standalone xterm.js-based terminal UI accessible from any browser on the local network. Shares the existing ExecSessionManager via Arc-wrapped stores, with token-based authentication and automatic session cleanup on disconnect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
143
app/src-tauri/src/commands/web_terminal_commands.rs
Normal file
143
app/src-tauri/src/commands/web_terminal_commands.rs
Normal file
@@ -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<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||
}
|
||||
|
||||
fn get_local_ip() -> Option<String> {
|
||||
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<WebTerminalInfo, String> {
|
||||
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<WebTerminalInfo, String> {
|
||||
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<WebTerminalInfo, String> {
|
||||
// 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))
|
||||
}
|
||||
@@ -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<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
pub mcp_store: Arc<McpStore>,
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
}
|
||||
|
||||
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::<AppState>();
|
||||
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::<AppState>();
|
||||
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");
|
||||
|
||||
@@ -74,6 +74,32 @@ pub struct AppSettings {
|
||||
pub default_microphone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dismissed_image_digest: Option<String>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
app/src-tauri/src/web_terminal/mod.rs
Normal file
4
app/src-tauri/src/web_terminal/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod server;
|
||||
mod ws_handler;
|
||||
|
||||
pub use server::WebTerminalServer;
|
||||
155
app/src-tauri/src/web_terminal/server.rs
Normal file
155
app/src-tauri/src/web_terminal/server.rs
Normal file
@@ -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<ExecSessionManager>,
|
||||
pub projects_store: Arc<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<ExecSessionManager>,
|
||||
projects_store: Arc<ProjectsStore>,
|
||||
settings_store: Arc<SettingsStore>,
|
||||
) -> Result<Self, String> {
|
||||
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<String>) -> bool {
|
||||
match token {
|
||||
Some(t) => t == &state.access_token,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler.
|
||||
async fn ws_upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||
Query(query): Query<TokenQuery>,
|
||||
) -> 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<Arc<WebTerminalState>>,
|
||||
Query(query): Query<TokenQuery>,
|
||||
) -> 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<ProjectInfo> = 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()
|
||||
}
|
||||
516
app/src-tauri/src/web_terminal/terminal.html
Normal file
516
app/src-tauri/src/web_terminal/terminal.html
Normal file
@@ -0,0 +1,516 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Triple-C Web Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #24283b;
|
||||
--bg-tertiary: #2f3347;
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--accent-hover: #89b4fa;
|
||||
--border: #3b3f57;
|
||||
--success: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--error: #f7768e;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Top Bar ─────────────────────────────── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--error);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.connected { background: var(--success); }
|
||||
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
select, button {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
select:focus, button:focus { outline: none; border-color: var(--accent); }
|
||||
button:hover { background: var(--border); }
|
||||
button:active { background: var(--accent); color: var(--bg-primary); }
|
||||
|
||||
.btn-new {
|
||||
font-weight: 600;
|
||||
min-width: 44px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* ── Tab Bar ─────────────────────────────── */
|
||||
.tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 0 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
}
|
||||
.tab-close:hover { background: var(--error); color: white; }
|
||||
|
||||
/* ── Terminal Area ───────────────────────── */
|
||||
.terminal-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
}
|
||||
.terminal-container.active { display: block; }
|
||||
|
||||
/* ── Empty State ─────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="topbar">
|
||||
<span class="topbar-title">Triple-C</span>
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<select id="projectSelect" style="flex:1; max-width:240px;">
|
||||
<option value="">Select project...</option>
|
||||
</select>
|
||||
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
|
||||
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tabbar" id="tabbar"></div>
|
||||
|
||||
<!-- Terminal Area -->
|
||||
<div class="terminal-area" id="terminalArea">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div>Select a project and open a terminal session</div>
|
||||
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── State ──────────────────────────────────
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const TOKEN = params.get('token') || '';
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
|
||||
let activeSessionId = null;
|
||||
|
||||
// ── DOM refs ───────────────────────────────
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const projectSelect = document.getElementById('projectSelect');
|
||||
const btnClaude = document.getElementById('btnClaude');
|
||||
const btnBash = document.getElementById('btnBash');
|
||||
const tabbar = document.getElementById('tabbar');
|
||||
const terminalArea = document.getElementById('terminalArea');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
// ── WebSocket ──────────────────────────────
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
statusDot.className = 'status-dot connected';
|
||||
clearTimeout(reconnectTimer);
|
||||
send({ type: 'list_projects' });
|
||||
// Start keepalive
|
||||
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
statusDot.className = 'status-dot reconnecting';
|
||||
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message handling ───────────────────────
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'projects':
|
||||
updateProjectList(msg.projects);
|
||||
break;
|
||||
case 'opened':
|
||||
onSessionOpened(msg.session_id, msg.project_name);
|
||||
break;
|
||||
case 'output':
|
||||
onSessionOutput(msg.session_id, msg.data);
|
||||
break;
|
||||
case 'exit':
|
||||
onSessionExit(msg.session_id);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('Server error:', msg.message);
|
||||
// Show in active terminal if available
|
||||
if (activeSessionId && sessions[activeSessionId]) {
|
||||
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||
}
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjectList(projects) {
|
||||
const current = projectSelect.value;
|
||||
projectSelect.innerHTML = '<option value="">Select project...</option>';
|
||||
projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} (${p.status})`;
|
||||
opt.disabled = p.status !== 'running';
|
||||
projectSelect.appendChild(opt);
|
||||
});
|
||||
// Restore selection if still valid
|
||||
if (current) projectSelect.value = current;
|
||||
}
|
||||
|
||||
// ── Session management ─────────────────────
|
||||
let pendingSessionType = null;
|
||||
|
||||
function openSession(type) {
|
||||
const projectId = projectSelect.value;
|
||||
if (!projectId) {
|
||||
alert('Please select a running project first.');
|
||||
return;
|
||||
}
|
||||
pendingSessionType = type;
|
||||
send({
|
||||
type: 'open',
|
||||
project_id: projectId,
|
||||
session_type: type,
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionOpened(sessionId, projectName) {
|
||||
const sessionType = pendingSessionType || 'claude';
|
||||
pendingSessionType = null;
|
||||
|
||||
// Create terminal
|
||||
const term = new Terminal({
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
selectionBackground: '#33467c',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
},
|
||||
fontSize: 14,
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// Create container div
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-container';
|
||||
container.id = `term-${sessionId}`;
|
||||
terminalArea.appendChild(container);
|
||||
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
// Send initial resize
|
||||
send({
|
||||
type: 'resize',
|
||||
session_id: sessionId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
});
|
||||
|
||||
// Handle user input
|
||||
term.onData(data => {
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
const b64 = btoa(String.fromCharCode(...bytes));
|
||||
send({
|
||||
type: 'input',
|
||||
session_id: sessionId,
|
||||
data: b64,
|
||||
});
|
||||
});
|
||||
|
||||
// Store session
|
||||
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
|
||||
|
||||
// Add tab and switch to it
|
||||
addTab(sessionId, projectName, sessionType);
|
||||
switchToSession(sessionId);
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
function onSessionOutput(sessionId, b64data) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return;
|
||||
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
|
||||
session.term.write(bytes);
|
||||
}
|
||||
|
||||
function onSessionExit(sessionId) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return;
|
||||
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
|
||||
}
|
||||
|
||||
function closeSession(sessionId) {
|
||||
send({ type: 'close', session_id: sessionId });
|
||||
removeSession(sessionId);
|
||||
}
|
||||
|
||||
function removeSession(sessionId) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
session.term.dispose();
|
||||
session.container.remove();
|
||||
delete sessions[sessionId];
|
||||
|
||||
// Remove tab
|
||||
const tab = document.getElementById(`tab-${sessionId}`);
|
||||
if (tab) tab.remove();
|
||||
|
||||
// Switch to another session or show empty state
|
||||
const remaining = Object.keys(sessions);
|
||||
if (remaining.length > 0) {
|
||||
switchToSession(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
activeSessionId = null;
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab bar ────────────────────────────────
|
||||
function addTab(sessionId, projectName, sessionType) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'tab';
|
||||
tab.id = `tab-${sessionId}`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${projectName} (${sessionType})`;
|
||||
tab.appendChild(label);
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.className = 'tab-close';
|
||||
close.textContent = '\u00d7';
|
||||
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
|
||||
tab.appendChild(close);
|
||||
|
||||
tab.onclick = () => switchToSession(sessionId);
|
||||
tabbar.appendChild(tab);
|
||||
}
|
||||
|
||||
function switchToSession(sessionId) {
|
||||
activeSessionId = sessionId;
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
const tab = document.getElementById(`tab-${sessionId}`);
|
||||
if (tab) tab.classList.add('active');
|
||||
|
||||
// Show/hide terminal containers
|
||||
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
|
||||
const container = document.getElementById(`term-${sessionId}`);
|
||||
if (container) {
|
||||
container.classList.add('active');
|
||||
const session = sessions[sessionId];
|
||||
if (session) {
|
||||
// Fit after making visible
|
||||
requestAnimationFrame(() => {
|
||||
session.fitAddon.fit();
|
||||
session.term.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize handling ────────────────────────
|
||||
function handleResize() {
|
||||
if (activeSessionId && sessions[activeSessionId]) {
|
||||
const session = sessions[activeSessionId];
|
||||
session.fitAddon.fit();
|
||||
send({
|
||||
type: 'resize',
|
||||
session_id: activeSessionId,
|
||||
cols: session.term.cols,
|
||||
rows: session.term.rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(handleResize, 100);
|
||||
});
|
||||
|
||||
// ── Event listeners ────────────────────────
|
||||
btnClaude.onclick = () => openSession('claude');
|
||||
btnBash.onclick = () => openSession('bash');
|
||||
|
||||
// ── Init ───────────────────────────────────
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
324
app/src-tauri/src/web_terminal/ws_handler.rs
Normal file
324
app/src-tauri/src/web_terminal/ws_handler.rs
Normal file
@@ -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<String>,
|
||||
},
|
||||
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<ProjectEntry>,
|
||||
},
|
||||
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<WebTerminalState>) {
|
||||
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::<ServerMessage>();
|
||||
|
||||
// Track session IDs owned by this connection for cleanup
|
||||
let owned_sessions: Arc<tokio::sync::Mutex<Vec<String>>> =
|
||||
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<ProjectEntry> = 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<String> {
|
||||
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<ServerMessage>,
|
||||
owned_sessions: &Arc<tokio::sync::Mutex<Vec<String>>>,
|
||||
) -> 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<u8>| {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user