diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md index 39f02bb3..00cf4cab 100644 --- a/apps/cli/docs/terminal-client.md +++ b/apps/cli/docs/terminal-client.md @@ -37,13 +37,17 @@ crossdesk-cli 123456789 --server your-server.example --port 9099 流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。Init 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。 +交互界面区分 `Connecting to server`(连接信令)、`Connecting to device`(连接设备)、`Connected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `P2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。 + 保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。 ## ANSI、Kitty 与 SSH ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。 -画面按字符单元比例等比缩放,留黑边及状态栏,默认假定字符宽高比 0.5;TTY 提供像素尺寸时使用其比例。每次只输出变化行,合并连续颜色设置;缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认预留两行及一列,避免自动换行。 +画面按字符单元比例等比缩放,留黑边及状态栏,默认假定字符宽高比 0.5;TTY 提供像素尺寸时使用其比例。每次只输出变化行,合并连续颜色设置;缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。 + +快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 ``、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。 ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。 @@ -75,7 +79,7 @@ crossdesk-cli 123456789 --renderer ansi --fps 8 转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),以 1MiB/s 令牌预算分块非阻塞写入;不能开始新画面时保留最新解码帧。ANSI 差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。 -统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。 +统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。 ## 输入与退出 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index 389fcb96..23ad1bb0 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -93,11 +93,15 @@ struct Tty { Write(out, end.data(), end.size()); } }; -Viewport Size() { +Viewport Size(int *status_rows = nullptr) { auto ws = GetTerminalSize(0); + const int reserved_rows = std::clamp(int(ws.rows) - 1, 0, 3); + if (status_rows) + *status_rows = reserved_rows; Viewport v; v.columns = std::clamp(int(ws.columns) - 1, 1, 240); - v.rows = std::clamp(int(ws.rows) - 2, 1, 100); + v.rows = std::clamp(int(ws.rows) - reserved_rows, 1, 100); + v.row_offset = reserved_rows > 0 ? 1 : 0; if (ws.columns && ws.rows) { v.pixel_width = int(ws.pixel_width) * v.columns / ws.columns; v.pixel_height = int(ws.pixel_height) * v.rows / ws.rows; @@ -197,6 +201,20 @@ AnsiDetail Detail(const std::string &name) { return AnsiDetail::Quadrant; throw std::invalid_argument("ansi-detail must be half or quadrant"); } +std::string WaitingScreen(const Viewport &v, const std::string &title, + const std::string &message) { + const std::string lines[] = {title, message, "Ctrl+] then q to quit"}; + const int count = std::min(v.rows, 3); + const int top = v.row_offset + (v.rows - count) / 2 + 1; + std::string out; + for (int i = 0; i < count; ++i) { + const auto text = lines[i].substr(0, v.columns); + const auto column = (v.columns - text.size()) / 2 + 1; + out += "\033[" + std::to_string(top + i) + ";1H\033[0m\033[K\033[" + + std::to_string(column) + "G" + text; + } + return out; +} int View(SessionOptions options, const std::string &device, const std::string &renderer, bool control, int fd, AnsiDetail detail, double zoom, bool native) { @@ -231,20 +249,40 @@ int View(SessionOptions options, const std::string &device, input.control = control; input.zoom = zoom; input.native = native; + const auto started = Clock::now(); s.Connect(options, device, std::move(password)); s.SetControl(control); std::optional displayed, pending; bool pending_frame = false; - Viewport last_view = Size(); + int last_status_rows = 3; + Viewport last_view = Size(&last_status_rows); std::optional last_frame; uint64_t view_revision = input.view_revision; bool redraw = true; uint64_t submitted = 0; - const auto started = Clock::now(); + std::optional connected_at, first_frame_at, + first_output_at; + bool waiting_visible = false; double queue_ms = 0; auto next = Clock::now(), status_tick = next; + const auto pump_output = [&] { + if (output.Pump() && pending_frame) { + displayed = std::move(pending); + pending.reset(); + pending_frame = false; + ++submitted; + if (!first_output_at) { + first_output_at = Clock::now(); + status_tick = *first_output_at; + } + } + }; while (!interrupted && !input.exit) { s.Tick(); + if (s.Connected() && !connected_at) { + connected_at = Clock::now(); + status_tick = *connected_at; + } char bytes[8192]; auto n = Read(0, bytes, sizeof(bytes)); const bool previous_command = input.command_mode; @@ -255,16 +293,12 @@ int View(SessionOptions options, const std::string &device, if (previous_command != input.command_mode || previous_control != input.control || previous_error != input.error) status_tick = Clock::now(); - if (output.Pump() && pending_frame) { - displayed = std::move(pending); - pending.reset(); - pending_frame = false; - ++submitted; - } + pump_output(); if (s.Finished()) break; auto now = Clock::now(); - auto v = Size(); + int status_rows; + auto v = Size(&status_rows); v.detail = detail; v.native = input.native; v.pixel_mode = kitty; @@ -278,43 +312,71 @@ int View(SessionOptions options, const std::string &device, if (output.Idle() && (v.columns != last_view.columns || v.rows != last_view.rows || v.pixel_width != last_view.pixel_width || - v.pixel_height != last_view.pixel_height)) { + v.pixel_height != last_view.pixel_height || + status_rows != last_status_rows)) { output.Put("\033[2J"); displayed.reset(); last_view = v; + last_status_rows = status_rows; redraw = true; + status_tick = now; } if (output.Idle() && now >= status_tick) { auto st = s.Status(); const auto last_state = st["state"].get(); - auto line = (input.error.empty() ? last_state : input.error) + " " + - (input.command_mode - ? (input.control ? "LOCAL COMMAND / CONTROL PAUSED" - : "LOCAL COMMAND / READ ONLY") - : input.control ? "CONTROL" : "READ ONLY") + - " " + - (kitty ? "KITTY" - : detail == AnsiDetail::Quadrant ? "QUAD" - : "HALF") + - (input.native ? " native x" : " fit x") + - std::to_string(int(input.zoom)) + - (last_frame ? " " + std::to_string(last_frame->width) + "x" + - std::to_string(last_frame->height) - : "") + - " | Ctrl+] commands"; - const std::string hint = - input.command_mode ? "Esc/Ctrl+C return | c toggle q quit | " - "arrows/ijkl pan +/- zoom f fit d detail" - : input.control - ? "Keys/mouse -> remote | Ctrl+] then c release, q quit" - : "c control q quit ? help | i up j left k down l right | " - "+/- zoom f fit d detail"; - output.Put("\033[" + std::to_string(v.rows + 1) + ";1H\033[0m\033[K" + - line.substr(0, v.columns) + "\033[" + - std::to_string(v.rows + 2) + ";1H\033[K" + - std::string(hint + " | out=" + std::to_string(submitted) + - " bytes=" + std::to_string(output.bytes)) - .substr(0, v.columns)); + std::string progress = + first_output_at ? "Displaying" + : s.Connected() ? "Connected, waiting for first frame" + : last_state == "registering" ? "Connecting to server" + : "Connecting to device"; + const auto elapsed = + std::to_string(std::chrono::duration_cast( + now - connected_at.value_or(started)) + .count()) + + "s"; + if (!first_output_at) + progress += " (" + elapsed + ")"; + StatusBar bar; + bar.progress = progress; + bar.compact_progress = first_output_at ? "LIVE" + : s.Connected() ? "WAIT " + elapsed + : "CONNECT " + elapsed; + bar.error = input.error; + bar.control = input.control; + bar.command_mode = input.command_mode; + bar.view = std::string(kitty ? "KITTY" + : detail == AnsiDetail::Quadrant ? "QUAD" + : "HALF") + + (input.native ? " | Detail x" : " | Fit x") + + std::to_string(int(input.zoom)) + + (last_frame ? " | " + std::to_string(last_frame->width) + "x" + + std::to_string(last_frame->height) + : ""); + const auto bps = st["network"]["video_bps"].get(); + const auto traversal = st["network"]["traversal"].get(); + bar.network = + std::string(traversal == P2P ? "P2P " + : traversal == Relay ? "RELAY " + : "Video ") + + (bps < 1000 ? std::to_string(bps) + " bps" + : bps < 1000000 ? std::to_string(bps / 1000) + " kbps" + : std::to_string(bps / 1000000) + "." + + std::to_string(bps / 100000 % 10) + " Mbps"); + std::string screen; + if (!last_frame && !first_output_at) { + screen = WaitingScreen( + v, progress, + s.Connected() ? "The connection is established; waiting for video." + : "Establishing a secure connection..."); + waiting_visible = true; + } + const auto lines = StatusLines(bar, v.columns); + for (int row = 0; row < status_rows; ++row) + screen += "\033[" + + std::to_string(row == 0 ? 1 : v.row_offset + v.rows + row) + + ";1H\033[0m\033[K" + (row == 0 ? "\033[7m" : "") + + lines[row] + "\033[0m"; + output.Put(std::move(screen)); status_tick = now + std::chrono::seconds(1); } if (output.Idle() && now >= next) { @@ -324,6 +386,8 @@ int View(SessionOptions options, const std::string &device, frame && frame->generation == s.Generation()) { last_frame = std::move(frame); fresh = true; + if (!first_frame_at) + first_frame_at = last_frame->queued; } if (last_frame && last_frame->generation != s.Generation()) last_frame.reset(); @@ -333,8 +397,15 @@ int View(SessionOptions options, const std::string &device, now - last_frame->queued) .count(); pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v); - output.Put(kitty ? Kitty(*last_frame, v) - : Difference(*pending, displayed)); + // Clear waiting text and Kitty margins without erasing the status bars. + std::string clear; + if (waiting_visible) + for (int row = 0; row < v.rows; ++row) + clear += "\033[" + std::to_string(v.row_offset + row + 1) + + ";1H\033[0m\033[2K"; + output.Put(clear + (kitty ? Kitty(*last_frame, v) + : Difference(*pending, displayed))); + waiting_visible = false; redraw = false; pending_frame = true; if (output.Idle()) { @@ -350,12 +421,23 @@ int View(SessionOptions options, const std::string &device, s.Disconnect(); auto until = Clock::now() + std::chrono::seconds(2); while (!output.Idle() && Clock::now() < until) { - output.Pump(); + pump_output(); output.Wait(-1); } // These diagnostics go to the private file, never through the active screen. + const auto elapsed_ms = + [&](const std::optional &time) -> json { + return time + ? json(std::chrono::duration(*time - started) + .count()) + : json(nullptr); + }; std::cerr << json{{"session", final}, + {"startup_ms", + {{"connected", elapsed_ms(connected_at)}, + {"first_frame", elapsed_ms(first_frame_at)}, + {"first_output", elapsed_ms(first_output_at)}}}, {"mouse_reports", input.mouse_reports}, {"submitted", submitted}, {"output_bytes", output.bytes}, diff --git a/libs/terminal/include/terminal_renderer.h b/libs/terminal/include/terminal_renderer.h index 8590ef23..c83deeca 100644 --- a/libs/terminal/include/terminal_renderer.h +++ b/libs/terminal/include/terminal_renderer.h @@ -19,6 +19,7 @@ struct Viewport { double zoom = 1, center_x = .5, center_y = .5; int pixel_width = 0, pixel_height = 0; bool native = false, pixel_mode = false; + int row_offset = 0; // Terminal rows reserved above the video area. }; struct Rect { int x, y, width, height; @@ -51,6 +52,12 @@ inline constexpr char kKittyQuery[] = "\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\"; inline constexpr char kKittyDelete[] = "\033_Ga=d,d=I,i=31,q=2\033\\"; bool KittyReply(const std::string &response); +struct StatusBar { + std::string progress, compact_progress, error, view, network; + bool control = false, command_mode = false; +}; +// Three padded ASCII rows. Shortcuts are compacted as whole groups to fit. +std::vector StatusLines(const StatusBar &, int columns); // Incremental parser: protocol replies and unknown escapes are consumed // locally. Key taps always release immediately; paste uses the UTF-8 text // protocol. In command_mode, control records the mode to restore while the diff --git a/libs/terminal/src/terminal_renderer.cpp b/libs/terminal/src/terminal_renderer.cpp index 51c17107..d7d38a72 100644 --- a/libs/terminal/src/terminal_renderer.cpp +++ b/libs/terminal/src/terminal_renderer.cpp @@ -26,6 +26,7 @@ static uint32_t Pixel(const DecodedFrame &f, int x, int y) { } static void Validate(const DecodedFrame &f, const Viewport &v) { if (v.columns < 1 || v.rows < 1 || v.columns > 240 || v.rows > 100 || + v.row_offset < 0 || v.row_offset > 100 || !std::isfinite(v.cell_aspect) || v.cell_aspect < .2 || v.cell_aspect > 2 || !std::isfinite(v.zoom) || v.zoom < 1 || v.zoom > 32 || !std::isfinite(v.center_x) || !std::isfinite(v.center_y) || @@ -179,6 +180,7 @@ Grid Convert(const DecodedFrame &f, Viewport v) { } std::optional> MapPointer(const Grid &g, int x, int y) { x *= g.samples_x; + y -= g.viewport.row_offset; y *= g.samples_y; const auto &r = g.content; if (!r.width || !r.height || !g.source_width || !g.source_height || x < r.x || @@ -254,6 +256,7 @@ std::string Difference(const Grid &g, const std::optional &previous) { const bool same = previous && previous->viewport.columns == g.viewport.columns && previous->viewport.rows == g.viewport.rows && + previous->viewport.row_offset == g.viewport.row_offset && previous->viewport.colors == g.viewport.colors && previous->viewport.utf8 == g.viewport.utf8; for (int y = 0; y < g.viewport.rows; ++y) { @@ -261,7 +264,8 @@ std::string Difference(const Grid &g, const std::optional &previous) { if (!same || !std::equal(start, start + g.viewport.columns, previous->cells.begin() + size_t(y) * g.viewport.columns)) - out += "\033[" + std::to_string(y + 1) + ";1H" + Line(g, y); + out += "\033[" + std::to_string(g.viewport.row_offset + y + 1) + + ";1H" + Line(g, y); } return out; } @@ -323,7 +327,7 @@ std::string Kitty(const DecodedFrame &f, const Viewport &requested) { w = std::max(2, w * 3 / 4); h = std::max(2, h * 3 / 4); } - std::string out = "\033[H"; + std::string out = "\033[" + std::to_string(v.row_offset + 1) + ";1H"; for (size_t i = 0; i < b64.size(); i += 4096) { out += "\033_G"; if (i == 0) diff --git a/libs/terminal/src/terminal_status.cpp b/libs/terminal/src/terminal_status.cpp new file mode 100644 index 00000000..1fcdc7b7 --- /dev/null +++ b/libs/terminal/src/terminal_status.cpp @@ -0,0 +1,151 @@ +#include "terminal_renderer.h" +#include + +namespace crossdesk::terminal { +namespace { +std::string Clip(const std::string &text, size_t width) { + if (text.size() <= width) + return text; + return width > 3 ? text.substr(0, width - 3) + "..." : text.substr(0, width); +} + +std::string Compact(const std::vector &choices, size_t width) { + for (const auto &text : choices) + if (text.size() <= width) + return text; + return {}; +} + +std::string Label(const std::string &label) { + std::string text = label; + text.resize(5, ' '); + return text + " | "; +} + +std::string CompactRow(const std::string &label, + const std::vector &choices, size_t width) { + const auto prefix = width >= 12 ? Label(label) : std::string{}; + return prefix + Compact(choices, width - prefix.size()); +} + +std::string Columns(const std::string &label, + const std::vector &cells, size_t width, + size_t count) { + std::string line = Label(label); + const size_t content = width - line.size() - (count - 1) * 3; + for (size_t i = 0; i < cells.size(); ++i) { + const size_t start = 8 + i * content / count + i * 3; + if (i) { + line.resize(start - 3, ' '); + line += " | "; + } + line += cells[i]; + } + return line; +} +} // namespace + +std::vector StatusLines(const StatusBar &bar, int columns) { + const size_t width = std::clamp(columns, 1, 240); + const size_t margin = width >= 20 ? 1 : 0; + const size_t inner = width - 2 * margin; + const std::string mode = + bar.command_mode + ? (bar.control + ? (inner < 36 ? "LOCAL PAUSED" : "LOCAL / CONTROL PAUSED") + : (inner < 36 ? "LOCAL READ" : "LOCAL / READ ONLY")) + : bar.control ? "CONTROL" + : "READ ONLY"; + const auto progress = + bar.error.empty() ? bar.progress : "Error: " + bar.error; + auto left = mode + " | " + progress; + auto right = bar.view + (bar.network.empty() ? "" : " | " + bar.network); + if (left.size() + right.size() + 2 > inner) + right = bar.network; + if (left.size() + right.size() + 2 > inner && bar.error.empty()) + left = mode + " | " + bar.compact_progress; + if (left.size() + right.size() + 2 > inner) + right.clear(); + std::string status = Clip(left, inner); + if (!right.empty()) { + status.resize(inner - right.size(), ' '); + status += right; + } + + std::string keys, view; + if (inner >= 63) { + const size_t count = inner >= 99 ? 4 : inner >= 77 ? 3 : 2; + if (bar.command_mode) { + keys = + Columns("LOCAL", + count >= 3 ? std::vector{" Return", + " Toggle control", + " Quit"} + : std::vector{" Return", + " Toggle; Quit"}, + inner, count); + } else if (bar.control) { + keys = Columns("KEYS", {" Commands", "Input -> remote"}, inner, + count); + } else { + keys = Columns("KEYS", + count >= 3 + ? std::vector{" Control", " Quit", + " Commands"} + : std::vector{" Control; Quit", + " Commands"}, + inner, count); + } + if (bar.control && !bar.command_mode) { + view = Columns("THEN", {" Toggle control", " Quit"}, inner, count); + } else { + view = Columns( + "VIEW", + count == 4 + ? std::vector{" Pan", "<+/-> Zoom", + " Fit", " Detail"} + : count == 3 + ? std::vector{" Pan", "<+/-> Zoom", + " Fit; Detail"} + : std::vector{" Pan; <+/-> Zoom", + " Fit; Detail"}, + inner, count); + } + } else { + keys = CompactRow( + bar.command_mode ? "LOCAL" : "KEYS", + bar.command_mode + ? std::vector{" Return | Toggle | Quit", + " Return | Quit", + " Back Quit", " Quit", "", + "q"} + : bar.control + ? std::vector{" Commands | Input -> remote", + " Commands", " Menu", + ""} + : std::vector< + std::string>{" Control | Quit | Commands", + " Control | Quit", " Ctrl Quit", + " Quit", "", "q"}, + inner); + view = CompactRow( + bar.control && !bar.command_mode ? "THEN" : "VIEW", + bar.control && !bar.command_mode + ? std::vector{" Toggle control | Quit", + " Toggle; Quit", " / ", + "", "q"} + : std::vector{" Pan | <+/-> Zoom | Fit | " + " Detail", + "<+/-> Zoom | Fit | Detail", + " Fit | Detail", " Fit", "", + "f"}, + inner); + } + std::vector lines{status, keys, view}; + for (auto &line : lines) { + line.insert(0, margin, ' '); + line.resize(width, ' '); + } + return lines; +} +} // namespace crossdesk::terminal