[ui] improve CLI connection status and shortcut layout

This commit is contained in:
dijunkun
2026-10-30 16:21:56 +08:00
parent d984aa55de
commit c5ee7d0691
5 changed files with 295 additions and 47 deletions
+6 -2
View File
@@ -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 |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。
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 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
## 输入与退出
+125 -43
View File
@@ -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<Grid> displayed, pending;
bool pending_frame = false;
Viewport last_view = Size();
int last_status_rows = 3;
Viewport last_view = Size(&last_status_rows);
std::optional<DecodedFrame> last_frame;
uint64_t view_revision = input.view_revision;
bool redraw = true;
uint64_t submitted = 0;
const auto started = Clock::now();
std::optional<Clock::time_point> 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<std::string>();
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<std::chrono::seconds>(
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<uint64_t>();
const auto traversal = st["network"]["traversal"].get<int>();
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<Clock::time_point> &time) -> json {
return time
? json(std::chrono::duration<double, std::milli>(*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},