Add web terminal for remote tablet/phone access to project terminals
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
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:
@@ -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