use tauri::{AppHandle, Emitter, State}; use crate::commands::aws_commands; use crate::models::{Backend, BedrockAuthMethod, Project}; use crate::AppState; /// Build the command to run in the container terminal. /// /// For Bedrock Profile projects, wraps `claude` in a bash script that validates /// the AWS session first. If the SSO session is expired, runs `aws sso login` /// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec { let is_bedrock_profile = project.backend == Backend::Bedrock && project .bedrock_config .as_ref() .map(|b| b.auth_method == BedrockAuthMethod::Profile) .unwrap_or(false); let permission_args = project.effective_permission_mode().cli_args(); if !is_bedrock_profile { let mut cmd = vec!["claude".to_string()]; cmd.extend(permission_args); if let Some(name) = session_name { if !name.is_empty() { cmd.push("-n".to_string()); cmd.push(name.to_string()); } } return cmd; } let profile = aws_commands::resolve_profile_for_project( project, state.settings_store.get().global_aws.aws_profile.as_deref(), ); // Build a bash wrapper that validates credentials, re-auths if needed, // then exec's into claude. let name_flag = session_name .filter(|n| !n.is_empty()) .map(|n| format!(" -n '{}'", n.replace('\'', "'\\''"))) .unwrap_or_default(); // The args are interpolated into a shell script string, so single-quote // each one (same escaping style as name_flag above). let permission_flags: String = permission_args .iter() .map(|a| format!(" '{}'", a.replace('\'', "'\\''"))) .collect(); let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag); 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." # Check if this profile uses SSO (has sso_start_url or sso_session configured) 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, ] } #[tauri::command] pub async fn open_terminal_session( project_id: String, session_id: String, session_type: Option, session_name: Option, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; let container_id = project .container_id .as_ref() .ok_or_else(|| "Container not running".to_string())?; let cmd = match session_type.as_deref() { Some("bash") => vec!["bash".to_string(), "-l".to_string()], _ => build_terminal_cmd(&project, &state, session_name.as_deref()), }; let output_event = format!("terminal-output-{}", session_id); let exit_event = format!("terminal-exit-{}", session_id); let app_handle_output = app_handle.clone(); let app_handle_exit = app_handle.clone(); state .exec_manager .create_session( container_id, &session_id, cmd, move |data| { let _ = app_handle_output.emit(&output_event, data); }, Box::new(move || { let _ = app_handle_exit.emit(&exit_event, ()); }), ) .await } #[tauri::command] pub async fn terminal_input( session_id: String, data: Vec, state: State<'_, AppState>, ) -> Result<(), String> { state.exec_manager.send_input(&session_id, data).await } #[tauri::command] pub async fn terminal_resize( session_id: String, cols: u16, rows: u16, state: State<'_, AppState>, ) -> Result<(), String> { state.exec_manager.resize(&session_id, cols, rows).await } #[tauri::command] pub async fn close_terminal_session( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { // Close audio bridge if it exists let audio_session_id = format!("audio-{}", session_id); state.exec_manager.close_session(&audio_session_id).await; // Close terminal session state.exec_manager.close_session(&session_id).await; Ok(()) } #[tauri::command] pub async fn paste_image_to_terminal( session_id: String, image_data: Vec, state: State<'_, AppState>, ) -> Result { let container_id = state.exec_manager.get_container_id(&session_id).await?; let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); let file_name = format!("clipboard_{}.png", timestamp); state .exec_manager .write_file_to_container(&container_id, &file_name, &image_data) .await } /// Copy a host file (e.g. dragged onto the terminal) into the container so /// Claude Code can read it, and return the in-container path. Mirrors the /// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its /// original name. Returns an error for paths that aren't readable regular files /// (e.g. a dropped directory). #[tauri::command] pub async fn upload_host_file_to_terminal( session_id: String, host_path: String, state: State<'_, AppState>, ) -> Result { let container_id = state.exec_manager.get_container_id(&session_id).await?; let meta = tokio::fs::metadata(&host_path) .await .map_err(|e| format!("Cannot access {}: {}", host_path, e))?; if meta.is_dir() { return Err(format!("{} is a directory — drop individual files", host_path)); } // Guard against ballooning host RAM: the file is packed into an in-memory // tar before upload, so cap the size of a dropped file. const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB if meta.len() > MAX_DROP_BYTES { return Err(format!( "File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.", meta.len() as f64 / (1024.0 * 1024.0), MAX_DROP_BYTES / (1024 * 1024) )); } let base = std::path::Path::new(&host_path) .file_name() .map(|s| s.to_string_lossy().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "dropped-file".to_string()); // Ensure the destination directory exists rather than relying on Docker's // archive extractor to create the parent for the uploaded tar entry. crate::docker::exec::exec_oneshot( &container_id, vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()], ) .await?; let file_name = format!("triple-c-drops/{}", base); crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await } #[tauri::command] pub async fn start_audio_bridge( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { // Get container_id from the terminal session let container_id = state.exec_manager.get_container_id(&session_id).await?; // Create audio bridge exec session with ID "audio-{session_id}" // The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted let audio_session_id = format!("audio-{}", session_id); let cmd = vec![ "bash".to_string(), "-c".to_string(), "FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(), ]; state .exec_manager .create_session_with_tty( &container_id, &audio_session_id, cmd, false, |_data| { /* ignore output from the audio bridge */ }, Box::new(|| { /* no exit handler needed */ }), ) .await } #[tauri::command] pub async fn send_audio_data( session_id: String, data: Vec, state: State<'_, AppState>, ) -> Result<(), String> { let audio_session_id = format!("audio-{}", session_id); state.exec_manager.send_input(&audio_session_id, data).await } #[tauri::command] pub async fn stop_audio_bridge( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { let audio_session_id = format!("audio-{}", session_id); state.exec_manager.close_session(&audio_session_id).await; Ok(()) }