2026-02-27 04:29:51 +00:00
|
|
|
use tauri::{AppHandle, Emitter, State};
|
|
|
|
|
|
2026-04-15 06:54:59 -07:00
|
|
|
use crate::commands::aws_commands;
|
2026-03-12 09:26:58 -07:00
|
|
|
use crate::models::{Backend, BedrockAuthMethod, Project};
|
2026-02-27 04:29:51 +00:00
|
|
|
use crate::AppState;
|
|
|
|
|
|
2026-03-04 11:41:42 -08:00
|
|
|
/// 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).
|
2026-04-16 08:46:03 -07:00
|
|
|
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
2026-03-12 09:26:58 -07:00
|
|
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
2026-03-04 11:41:42 -08:00
|
|
|
&& project
|
|
|
|
|
.bedrock_config
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
if !is_bedrock_profile {
|
2026-03-13 08:58:13 -07:00
|
|
|
let mut cmd = vec!["claude".to_string()];
|
|
|
|
|
if project.full_permissions {
|
|
|
|
|
cmd.push("--dangerously-skip-permissions".to_string());
|
|
|
|
|
}
|
2026-04-16 08:46:03 -07:00
|
|
|
if let Some(name) = session_name {
|
|
|
|
|
if !name.is_empty() {
|
|
|
|
|
cmd.push("-n".to_string());
|
|
|
|
|
cmd.push(name.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-13 08:58:13 -07:00
|
|
|
return cmd;
|
2026-03-04 11:41:42 -08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 06:54:59 -07:00
|
|
|
let profile = aws_commands::resolve_profile_for_project(
|
|
|
|
|
project,
|
|
|
|
|
state.settings_store.get().global_aws.aws_profile.as_deref(),
|
|
|
|
|
);
|
2026-03-04 11:41:42 -08:00
|
|
|
|
|
|
|
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
|
|
|
|
// then exec's into claude.
|
2026-04-16 08:46:03 -07:00
|
|
|
let name_flag = session_name
|
|
|
|
|
.filter(|n| !n.is_empty())
|
|
|
|
|
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
|
|
|
|
.unwrap_or_default();
|
2026-03-13 08:58:13 -07:00
|
|
|
let claude_cmd = if project.full_permissions {
|
2026-04-16 08:46:03 -07:00
|
|
|
format!("exec claude --dangerously-skip-permissions{}", name_flag)
|
2026-03-13 08:58:13 -07:00
|
|
|
} else {
|
2026-04-16 08:46:03 -07:00
|
|
|
format!("exec claude{}", name_flag)
|
2026-03-13 08:58:13 -07:00
|
|
|
};
|
|
|
|
|
|
2026-03-04 11:41:42 -08:00
|
|
|
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."
|
2026-03-11 12:24:16 -07:00
|
|
|
# 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..."
|
2026-03-04 11:41:42 -08:00
|
|
|
echo ""
|
2026-03-11 12:24:16 -07:00
|
|
|
triple-c-sso-refresh
|
2026-03-04 11:41:42 -08:00
|
|
|
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
|
2026-03-13 08:58:13 -07:00
|
|
|
{claude_cmd}
|
2026-03-04 11:41:42 -08:00
|
|
|
"#,
|
2026-03-13 08:58:13 -07:00
|
|
|
profile = profile,
|
|
|
|
|
claude_cmd = claude_cmd
|
2026-03-04 11:41:42 -08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
vec![
|
|
|
|
|
"bash".to_string(),
|
|
|
|
|
"-c".to_string(),
|
|
|
|
|
script,
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn open_terminal_session(
|
|
|
|
|
project_id: String,
|
|
|
|
|
session_id: String,
|
2026-03-06 06:32:53 -08:00
|
|
|
session_type: Option<String>,
|
2026-04-16 08:46:03 -07:00
|
|
|
session_name: Option<String>,
|
2026-02-27 04:29:51 +00:00
|
|
|
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())?;
|
|
|
|
|
|
2026-03-06 06:32:53 -08:00
|
|
|
let cmd = match session_type.as_deref() {
|
|
|
|
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
2026-04-16 08:46:03 -07:00
|
|
|
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
2026-03-06 06:32:53 -08:00
|
|
|
};
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
|
|
|
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<u8>,
|
|
|
|
|
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> {
|
2026-03-05 06:11:33 -08:00
|
|
|
// 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
|
2026-02-27 04:29:51 +00:00
|
|
|
state.exec_manager.close_session(&session_id).await;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-03-01 10:52:08 -08:00
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn paste_image_to_terminal(
|
|
|
|
|
session_id: String,
|
|
|
|
|
image_data: Vec<u8>,
|
|
|
|
|
state: State<'_, AppState>,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-03-05 06:11:33 -08:00
|
|
|
|
2026-06-30 14:11:09 -07:00
|
|
|
/// 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<String, String> {
|
|
|
|
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
|
|
|
|
|
2026-06-30 14:37:33 -07:00
|
|
|
let meta = tokio::fs::metadata(&host_path)
|
|
|
|
|
.await
|
2026-06-30 14:11:09 -07:00
|
|
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
|
|
|
|
if meta.is_dir() {
|
|
|
|
|
return Err(format!("{} is a directory — drop individual files", host_path));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 15:05:25 -07:00
|
|
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
|
|
|
|
// tar before upload, so cap the size of a dropped file.
|
2026-06-30 14:28:00 -07:00
|
|
|
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)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 14:11:09 -07:00
|
|
|
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());
|
|
|
|
|
|
2026-06-30 14:37:33 -07:00
|
|
|
// 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?;
|
|
|
|
|
|
2026-06-30 14:11:09 -07:00
|
|
|
let file_name = format!("triple-c-drops/{}", base);
|
2026-06-30 15:05:25 -07:00
|
|
|
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
2026-06-30 14:11:09 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 06:11:33 -08:00
|
|
|
#[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<u8>,
|
|
|
|
|
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(())
|
|
|
|
|
}
|