omniterm 0.2.27

Web-based tmux terminal manager — one browser tab to watch and drive your AI coding agents
//! 终端 WS 入口:共享协议类型 + 按 `runtime_kind` 分发到各引擎的 attach 实现。
//!
//! 各引擎的 attach 链路在 `src/engine/*/terminal_ws.rs`,本文件不含引擎特定逻辑。

use axum::{
    extract::{Path, Query, State, WebSocketUpgrade},
    http::HeaderMap,
    response::IntoResponse,
};
use serde::{Deserialize, Serialize};

use crate::AppState;

#[derive(Debug, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum ClientControl {
    #[serde(rename = "resize")]
    Resize { cols: u16, rows: u16 },
    #[serde(rename = "ping")]
    Ping,
    /// 前端渲染积压丢帧后请求重同步:diff 帧相对上一帧编码基线,丢弃的
    /// 中间帧无法重建,服务端收到后作废 diff 记忆,下一帧发全帧。
    #[serde(rename = "resync")]
    Resync,
    /// 历史视口窗口请求(方案 C Phase 1,仅 pty 引擎消费):滚轮接管后前端
    /// 请求以 `y`(行,0 = live 屏)为顶的历史窗口帧。负值无意义,处理侧
    /// 钳制;上界由 encode_viewport_frame 钳到实际 history_size。
    ///
    /// `refresh`(2026-09-12 有状态锚 D2,取代 09-03 的 fp 字段):`false` =
    /// 用户主动滚动,按 y 定位并存锚;`true` = 输出触发的保锚重拉,后端直接
    /// 按存储锚出窗、忽略 y(锚缺失时回退按 y 定位,降级不断连)。
    /// `serde(default)` 缺省 false = 滚动语义:旧前端缓存发出的刷新请求降级
    /// 为按偏移定位(滑移),与 09-08 A2 的 Option 降级惯例一致。
    /// 见 `docs/dev/plans/2026-09-12-pty-viewport-stateful-anchor.md` D1/D2。
    #[serde(rename = "viewport_request")]
    ViewportRequest {
        y: i32,
        #[serde(default)]
        refresh: bool,
    },
}

#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum ServerControl<'a> {
    #[serde(rename = "attached")]
    Attached { session: &'a str },
    /// 预留:WS 心跳应答。接线归 `docs/dev/plans/2026-09-19-ws-idle-disconnect-heartbeat.md`
    /// Phase 1(Phase 0 四格探针出结论前不实现,见其纪律节)。
    #[serde(rename = "pong")]
    #[allow(dead_code)]
    Pong,
    #[serde(rename = "error")]
    Error { message: &'a str },
    /// 已知缺口:前端 useTerminal 已处理 `exit` 帧(按退出码渲染状态行),
    /// 后端从未构造——子进程退出目前仅以 WS 关闭 + 断线重连呈现。是否接线
    /// 见 `docs/dev/plans/backlog/dead-code-triage.md` #15。
    #[serde(rename = "exit")]
    #[allow(dead_code)]
    Exit { code: Option<i32> },
    #[serde(rename = "agent_state")]
    AgentState {
        agent_kind: Option<&'a str>,
        state: &'a str,
        attention_reason: Option<&'a str>,
        agent_event: Option<&'a str>,
        agent_nonce: Option<&'a str>,
    },
}

#[derive(Debug, Deserialize)]
pub struct TerminalQuery {
    pub cols: Option<u16>,
    pub rows: Option<u16>,
}

/// WebSocket upgrade handler for terminal connections.
/// Accepts optional `cols` and `rows` query params for initial PTY size.
///
/// CSWSH 防御(`enforce_ws_origin`,见 `ws::origin_guard`):在 `on_upgrade`
/// 之前校验 Origin 与 Host 一致性,跨站页面(evil.com 借受害者已登录 cookie
/// 发起握手)403 拒绝;无 Origin / 无 Host 的**非浏览器**客户端放行。
pub async fn ws_terminal_handler(
    ws: WebSocketUpgrade,
    Path(session_id): Path<String>,
    Query(query): Query<TerminalQuery>,
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Some(rejected) = crate::ws::enforce_ws_origin(&headers) {
        return rejected;
    }
    ws.on_upgrade(move |socket| async move {
        let runtime_kind: Option<(String,)> =
            sqlx::query_as("SELECT runtime_kind FROM sessions WHERE id = ?")
                .bind(&session_id)
                .fetch_optional(&state.db)
                .await
                .ok()
                .flatten();

        crate::engine::run_terminal_session(
            runtime_kind.map(|(kind,)| kind),
            socket,
            session_id,
            query,
            state,
        )
        .await
    })
}

/// WebSocket upgrade handler for external (not-yet-adopted) sessions.
/// Connects directly to the multiplexer session by name, without requiring a DB record.
///
/// 同样必须做 Origin 校验——本入口甚至不需要 DB 记录即可接管 tmux 会话,
/// 是 CSWSH 风险更高的面。
pub async fn ws_external_terminal_handler(
    ws: WebSocketUpgrade,
    Path(session_name): Path<String>,
    Query(query): Query<TerminalQuery>,
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Some(rejected) = crate::ws::enforce_ws_origin(&headers) {
        return rejected;
    }
    ws.on_upgrade(move |socket| {
        crate::engine::run_external_terminal_session(socket, session_name, query, state)
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn viewport_request_parses() {
        // 用户主动滚动:refresh 缺省 = false(滚动语义)
        let ctrl: ClientControl =
            serde_json::from_str(r#"{"type":"viewport_request","y":42}"#).unwrap();
        assert_eq!(ctrl, ClientControl::ViewportRequest { y: 42, refresh: false });
        // 输出触发的保锚重拉:显式 refresh = true
        let ctrl: ClientControl =
            serde_json::from_str(r#"{"type":"viewport_request","y":42,"refresh":true}"#).unwrap();
        assert_eq!(ctrl, ClientControl::ViewportRequest { y: 42, refresh: true });
        // 显式 false 与缺省等价
        let ctrl: ClientControl =
            serde_json::from_str(r#"{"type":"viewport_request","y":0,"refresh":false}"#).unwrap();
        assert_eq!(ctrl, ClientControl::ViewportRequest { y: 0, refresh: false });
    }

    #[test]
    fn legacy_control_frames_still_parse() {
        let resync: ClientControl = serde_json::from_str(r#"{"type":"resync"}"#).unwrap();
        assert_eq!(resync, ClientControl::Resync);
        let resize: ClientControl =
            serde_json::from_str(r#"{"type":"resize","cols":80,"rows":24}"#).unwrap();
        assert_eq!(resize, ClientControl::Resize { cols: 80, rows: 24 });
    }
}