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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user