#include "terminal_io.h" #include #include #include #include #include #include #include #include using namespace crossdesk; using namespace crossdesk::terminal; using namespace crossdesk::cli; using nlohmann::json; using Clock = std::chrono::steady_clock; namespace { std::string Env(const char *name) { const auto *p = std::getenv(name); return p ? p : ""; } struct Descriptor { int value; explicit Descriptor(int v) : value(v) {} ~Descriptor() { if (value >= 0) Close(value); } }; class Output { public: explicit Output(int fd) : fd_(fd), nonblocking_(fd, true), last_(Clock::now()) {} bool Idle() const { return pending_.empty(); } void Put(std::string text) { if (!Idle() || text.size() > 2 * 1024 * 1024) throw std::runtime_error("output budget exceeded"); pending_ = std::move(text); offset_ = 0; } bool Pump() { auto now = Clock::now(); credit_ = std::min( 1048576.0, credit_ + std::chrono::duration(now - last_).count() * 1048576); last_ = now; if (Idle()) return false; auto size = std::min({pending_.size() - offset_, size_t(16384), size_t(credit_)}); if (!size) return false; auto n = Write(fd_, pending_.data() + offset_, size); if (n < 0 && errno != EAGAIN && errno != EINTR) throw std::runtime_error("output closed"); if (n > 0) { offset_ += n; credit_ -= n; bytes += n; } if (offset_ == pending_.size()) { pending_.clear(); return true; } return false; } void Wait(int input_fd, int timeout_ms = 5) const { // Wake for writable output instead of adding 5ms after each short PTY // write. When rate-limited, wait for input/time to replenish the budget. WaitIo(input_fd, !Idle() && credit_ >= 1 ? fd_ : -1, timeout_ms); } uint64_t bytes = 0; private: int fd_; NonBlocking nonblocking_; std::string pending_; size_t offset_ = 0; double credit_ = 1048576; Clock::time_point last_; }; struct Tty { int out; RawTerminal terminal; Tty(int input, int output) : out(output), terminal(input, output) { // Fall back to button/drag reporting if all-motion tracking is unsupported. const char start[] = "\033[?1049h\033[?25l\033[?7l\033[?1000h\033[?1002h" "\033[?1003h\033[?1006h\033[?1004h\033[?2004h\033[2J"; Write(out, start, sizeof(start) - 1); } ~Tty() { // Cancel any incomplete string/control sequence before restoration. std::string end = "\030\033\\" + std::string(kKittyDelete) + "\033[0m\033[?1003l\033[?1002l\033[?1000l" "\033[?1006l\033[?1004l\033[?2004l\033[" "?7h\033[?25h\033[?1049l"; Write(out, end.data(), end.size()); } }; Viewport Size(int *status_rows = nullptr, const TerminalMetrics *metrics = nullptr, double cell_aspect = 0) { 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) - 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; } if (ws.pixel_width && ws.pixel_height && ws.columns && ws.rows) v.cell_aspect = std::clamp(double(ws.pixel_width) * ws.rows / (double(ws.pixel_height) * ws.columns), .2, 2.0); // A visible Windows console's font metrics are authoritative. ConPTY has // no local font geometry, so use the display terminal's replies there. #ifdef _WIN32 if (metrics && (!ws.pixel_width || !ws.pixel_height)) #else if (metrics) #endif metrics->Apply(v); if (cell_aspect > 0) v.cell_aspect = cell_aspect; auto locale = Env("LC_ALL"); if (locale.empty()) locale = Env("LC_CTYPE"); if (locale.empty()) locale = Env("LANG"); v.utf8 = locale.find("UTF-8") != std::string::npos || locale.find("utf8") != std::string::npos || locale.find("utf-8") != std::string::npos; auto color = Env("COLORTERM"); v.colors = color == "truecolor" || color == "24bit" ? 24 : Env("TERM").find("256color") != std::string::npos ? 256 : 8; #ifdef _WIN32 v.utf8 = true; v.colors = 24; #endif return v; } std::string SecretFd(int fd) { if (fd < 3 || fd > 1024) throw std::runtime_error("password_fd must be a dedicated descriptor >= 3"); std::string secret; auto deadline = Clock::now() + std::chrono::seconds(5); while (Clock::now() < deadline && !interrupted) { if (!WaitReadable(fd, 20)) continue; char c; auto n = Read(fd, &c, 1); if (n == 0) return secret; if (n < 0) { if (errno == EINTR) continue; throw std::runtime_error("credential read failed"); } if (c == '\n') return secret; if (c == '\0' || secret.size() >= 256) throw std::runtime_error("invalid credentials"); secret += c; } throw std::runtime_error("credential channel timeout"); } int Integer(const json &j, const char *key, int lo, int hi, int fallback) { if (!j.contains(key)) return fallback; if (!j[key].is_number_integer()) throw std::invalid_argument(std::string(key) + " must be an integer"); auto n = j[key].get(); if (n < lo || n > hi) throw std::invalid_argument(std::string(key) + " out of range"); return int(n); } bool Boolean(const json &j, const char *key, bool fallback) { if (!j.contains(key)) return fallback; if (!j[key].is_boolean()) throw std::invalid_argument(std::string(key) + " must be boolean"); return j[key]; } double Real(const json &j, const char *key, double lo, double hi, double fallback) { if (!j.contains(key)) return fallback; if (!j[key].is_number()) throw std::invalid_argument(std::string(key) + " must be a number"); const double value = j[key].get(); if (!std::isfinite(value) || value < lo || value > hi) throw std::invalid_argument(std::string(key) + " out of range"); return value; } void Quality(SessionOptions &options, const std::string &name) { if (name == "low") { options.quality = 0; options.preview_width = 640; options.preview_height = 360; options.max_bitrate_bps = 1000000; } else if (name == "balanced") { options.quality = 1; options.preview_width = 1280; options.preview_height = 720; options.max_bitrate_bps = 2500000; } else if (name == "high") { options.quality = 2; options.preview_width = 1920; options.preview_height = 1080; options.max_bitrate_bps = 6000000; } else if (name == "ultra") { options.quality = 2; options.preview_width = 3840; options.preview_height = 2160; options.max_bitrate_bps = 12000000; } else throw std::invalid_argument("quality must be low, balanced, high or ultra"); } AnsiDetail Detail(const std::string &name) { if (name == "half") return AnsiDetail::Half; if (name == "quadrant") 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, double cell_aspect) { if (!IsTty(0) || !IsTty(fd) || Env("TERM") == "dumb") throw std::runtime_error("connecting needs an interactive ANSI terminal; use " "bridge --stdio for a host"); auto password = Password(); if (interrupted) return 130; Tty tty(0, fd); Output output(fd); bool kitty = false; if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) { output.Put(std::string(kKittyQuery) + "\033[c"); std::string reply; auto until = Clock::now() + std::chrono::milliseconds(250); while (Clock::now() < until && !interrupted) { output.Pump(); WaitReadable(0, 10); char b[1024]; auto n = Read(0, b, sizeof(b)); if (n > 0 && reply.size() < 8192) reply.append(b, n); } kitty = KittyReply(reply); } if (renderer == "kitty" && !kitty) throw std::runtime_error( "Kitty probe failed or multiplexer active; use --renderer ansi"); ControllerSession s; Input input; 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; int last_status_rows = 3; Viewport last_view = Size(&last_status_rows, &input.terminal_metrics, cell_aspect); std::optional last_frame; uint64_t view_revision = input.view_revision; bool redraw = true; uint64_t submitted = 0; 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; auto size_query_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; const bool previous_control = input.control; const auto previous_error = input.error; input.Feed(n > 0 ? std::string(bytes, n) : "", s, displayed ? &*displayed : nullptr); if (previous_command != input.command_mode || previous_control != input.control || previous_error != input.error) status_tick = Clock::now(); pump_output(); if (s.Finished()) break; auto now = Clock::now(); int status_rows; auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect); v.detail = detail; v.native = input.native; v.pixel_mode = kitty; v.zoom = input.zoom; v.center_x = input.center_x; v.center_y = input.center_y; if (input.view_revision != view_revision) { view_revision = input.view_revision; redraw = true; } const bool grid_resized = v.columns != last_view.columns || v.rows != last_view.rows; if (output.Idle() && (grid_resized || v.cell_aspect != last_view.cell_aspect || v.pixel_width != last_view.pixel_width || 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 (grid_resized) size_query_tick = now; } // Query through the terminal byte stream so this also works over SSH. // Never wait for a reply or interrupt a partially written video frame. if (output.Idle() && now >= size_query_tick) { input.terminal_metrics.BeginQuery(); output.Put(kTerminalSizeQuery); size_query_tick = now + std::chrono::seconds(2); } if (output.Idle() && now >= status_tick) { auto st = s.Status(); const auto last_state = st["state"].get(); 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; if (first_output_at && input.control && !input.command_mode) { const auto reports = std::to_string(input.mouse_reports); bar.progress += " | Mouse RX " + reports; bar.compact_progress += " M:" + reports; } 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) { next = now + std::chrono::milliseconds(1000 / options.fps); bool fresh = false; if (auto frame = s.TakeFrame(last_frame ? &*last_frame : nullptr); 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(); if (last_frame && (fresh || redraw)) { if (fresh) queue_ms = std::chrono::duration( now - last_frame->queued) .count(); pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v); // 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()) { displayed = std::move(pending); pending.reset(); pending_frame = false; } } } output.Wait(0); } auto final = s.Status(); s.Disconnect(); auto until = Clock::now() + std::chrono::seconds(2); while (!output.Idle() && Clock::now() < until) { 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}, {"mouse_legacy_reports", input.mouse_legacy_reports}, {"mouse_forwarded", input.mouse_forwarded}, {"mouse_send_failures", input.mouse_send_failures}, {"terminal_geometry", {{"cell_aspect", last_view.cell_aspect}, {"pixel_width", last_view.pixel_width}, {"pixel_height", last_view.pixel_height}, {"reported", input.terminal_metrics.Known()}, {"manual_aspect", cell_aspect > 0}}}, {"submitted", submitted}, {"output_bytes", output.bytes}, {"last_queue_ms", queue_ms}, {"elapsed_seconds", std::chrono::duration(Clock::now() - started).count()}, {"submitted_fps", submitted / std::max(.001, std::chrono::duration( Clock::now() - started) .count())}} .dump() << "\n"; if (final["state"] == "failed") throw std::runtime_error(final["error"].get()); return interrupted ? 130 : 0; } int Bridge(int fd, SessionOptions options, int password_fd) { Output output(fd); ControllerSession s; Input input; Viewport viewport; viewport.detail = AnsiDetail::Quadrant; auto status = [&] { auto value = s.Status(); value["command_mode"] = input.command_mode; value["resume_control"] = input.control; value["exit_requested"] = input.exit; value["cell_aspect"] = viewport.cell_aspect; return value; }; NonBlocking input_mode(0); std::deque replies; std::string buffer; bool hello = false, quit = false, eof = false, writing_frame = false; uint64_t submitted_frames = 0; const auto started = Clock::now(); uint64_t awaiting = 0; std::optional current, pending_grid; std::optional last_frame; uint64_t frame_sequence = 0, view_revision = 0; bool redraw = true; auto next = Clock::now(), status_tick = next; auto respond = [&](json j) { if (replies.size() >= 64) throw std::runtime_error("bridge response queue exceeded"); replies.push_back(std::move(j)); }; auto quit_deadline = Clock::time_point::max(); while (!interrupted) { s.Tick(); input.Feed("", s, current ? &*current : nullptr); if (output.Pump() && writing_frame) { ++submitted_frames; writing_frame = false; } if (!quit) { char bytes[8192]; auto n = Read(0, bytes, sizeof(bytes)); if (n > 0) { buffer.append(bytes, n); if (buffer.size() > 65536) { respond( {{"id", nullptr}, {"ok", false}, {"error", "message_too_large"}}); quit = true; } } else if (n == 0) { eof = true; } else if (errno != EAGAIN && errno != EINTR) { quit = true; } for (int count = 0; !quit && count < 16; ++count) { auto end = buffer.find('\n'); if (end == std::string::npos) break; auto raw = buffer.substr(0, end); buffer.erase(0, end + 1); auto j = ParseBoundedJson(raw); json id = nullptr; try { if (!j.is_object()) throw std::invalid_argument("invalid_json"); if (!j.contains("id") || !j["id"].is_number_integer()) throw std::invalid_argument("integer_request_id_required"); id = j["id"]; std::string method = j.at("method").get(); if (method == "hello") { if (Integer(j, "version", 1, 1, 0) != 1) throw std::invalid_argument("unsupported_version"); hello = true; } else if (!hello) throw std::invalid_argument("hello_required"); else if (method == "viewport") { auto next_view = viewport; auto next_options = options; next_view.columns = Integer(j, "columns", 1, 240, 80); next_view.rows = Integer(j, "rows", 1, 100, 23); next_view.colors = Integer(j, "colors", 8, 256, 24); if (next_view.colors != 8 && next_view.colors != 24 && next_view.colors != 256) throw std::invalid_argument("unsupported_colors"); next_view.utf8 = Boolean(j, "utf8", true); next_view.cell_aspect = Real(j, "cell_aspect", .2, 2, viewport.cell_aspect); if (j.contains("detail")) next_view.detail = Detail(j.at("detail").get()); next_options.fps = Integer(j, "fps", 1, 30, 8); if (j.contains("quality")) Quality(next_options, j.at("quality").get()); int next_zoom = Integer(j, "zoom", 1, 32, int(input.zoom)); bool next_native = Boolean(j, "native", input.native); s.SetPreview(next_options.fps, next_options.preview_width, next_options.preview_height, next_options.quality, next_options.max_bitrate_bps); viewport = next_view; options = next_options; input.zoom = next_zoom; input.native = next_native; redraw = true; current.reset(); awaiting = 0; } else if (method == "connect") { if (s.Connected() || (!s.Finished() && s.Status()["state"] != "idle")) throw std::invalid_argument("session_active"); options.display = Integer(j, "display", 0, 63, 0); auto password = SecretFd(password_fd); s.Connect(options, j.at("device").get(), std::move(password)); input.control = false; input.command_mode = false; input.center_x = input.center_y = .5; last_frame.reset(); redraw = true; current.reset(); awaiting = 0; } else if (method == "control") { input.control = Boolean(j, "enabled", false); s.SetControl(input.control && !input.command_mode); } else if (method == "input") { auto text = j.at("data").get(); if (text.size() > 16384) throw std::invalid_argument("input_too_large"); input.Feed(text, s, current ? &*current : nullptr); if (input.exit) quit = true; if (!input.error.empty()) throw std::invalid_argument(input.error); } else if (method == "ack") { if (!j.at("frame_id").is_number_unsigned() || !j.at("generation").is_number_unsigned()) throw std::invalid_argument("invalid_frame_ack"); if (j["generation"] == s.Generation() && j["frame_id"] == awaiting) { current = std::move(pending_grid); pending_grid.reset(); awaiting = 0; } } else if (method == "display") { if (!s.SelectDisplay(Integer(j, "index", 0, 63, 0))) throw std::invalid_argument("display_unavailable"); current.reset(); awaiting = 0; } else if (method == "disconnect") { s.Disconnect(); input.control = false; input.command_mode = false; last_frame.reset(); current.reset(); awaiting = 0; } else if (method == "exit") { quit = true; } else if (method != "status") throw std::invalid_argument("unsupported_method"); respond( {{"id", id}, {"ok", true}, {"version", 1}, {"status", status()}}); } catch (const std::exception &) { respond({{"id", id}, {"ok", false}, {"error", "invalid_request_or_unavailable_operation"}, {"status", status()}}); } } } if (eof && buffer.find('\n') == std::string::npos) { if (!buffer.empty()) { respond( {{"id", nullptr}, {"ok", false}, {"error", "incomplete_message"}}); buffer.clear(); } quit = true; } auto now = Clock::now(); if (quit && quit_deadline == Clock::time_point::max()) { s.Disconnect(); quit_deadline = now + std::chrono::seconds(2); } if (output.Idle()) { if (!replies.empty()) { output.Put(replies.front().dump() + "\n"); replies.pop_front(); } else if (quit) break; else if (hello && now >= status_tick) { output.Put(json{{"event", "state"}, {"status", status()}, {"output_bytes", output.bytes}, {"submitted_frames", submitted_frames}, {"elapsed_seconds", std::chrono::duration(now - started).count()}} .dump() + "\n"); status_tick = now + std::chrono::seconds(1); } else if (hello && !awaiting && now >= next) { next = now + std::chrono::milliseconds(1000 / options.fps); bool fresh = false; if (auto f = s.TakeFrame(last_frame ? &*last_frame : nullptr); f && f->generation == s.Generation()) { last_frame = std::move(f); fresh = true; } if (last_frame && last_frame->generation != s.Generation()) last_frame.reset(); if (input.view_revision != view_revision) { view_revision = input.view_revision; redraw = true; } if (last_frame && (fresh || redraw)) { viewport.zoom = input.zoom; viewport.native = input.native; viewport.center_x = input.center_x; viewport.center_y = input.center_y; pending_grid = Convert(*last_frame, viewport); redraw = false; writing_frame = true; awaiting = ++frame_sequence; output.Put( json{{"event", "frame"}, {"frame_id", awaiting}, {"decoded_frame_id", last_frame->frame_id}, {"generation", last_frame->generation}, {"columns", viewport.columns}, {"rows", viewport.rows}, {"cell_aspect", viewport.cell_aspect}, {"source_width", last_frame->width}, {"source_height", last_frame->height}, {"zoom", viewport.zoom}, {"native", viewport.native}, {"source_rect", {pending_grid->source.x, pending_grid->source.y, pending_grid->source.width, pending_grid->source.height}}, {"detail", viewport.detail == AnsiDetail::Quadrant ? "quadrant" : "half"}, {"lines", Lines(*pending_grid)}, {"queue_ms", fresh ? std::chrono::duration( now - last_frame->queued) .count() : 0}} .dump() + "\n"); } } } if (now >= quit_deadline) break; output.Wait(eof ? -1 : 0); } s.Disconnect(); return interrupted ? 130 : 0; } const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30] [--display 0..63] [--control] [--server host] [--port 9099] [--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native] [--cell-aspect auto|0.2..2] crossdesk-cli view <9-digit-device-id> [options] (compatibility alias) crossdesk-cli doctor [--json] crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099] Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal. Read-only: c enables control; q or Ctrl+C quits; ? shows local commands. Ctrl+] enters persistent local mode: c selects control on return; q quits; Esc/Ctrl+C returns. View keys: click/d native detail, wheel/+/- zoom, arrows/ijkl pan (i up, j left, k down, l right), f fit. Local mode stays active after commands, paste or focus loss; remote input remains paused. Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode and forwards Ctrl+] if control is enabled. Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160. Cell aspect is character width/height: auto detects terminal geometry; use --cell-aspect to calibrate. Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale. --fps controls local output; negotiated previews use supported 1/8/15 FPS. bridge: private NDJSON v1, credentials on a separate inherited descriptor. No IP direct connection, audio playback, clipboard or file transfer support. )"; } // namespace int main(int argc, char **argv) { try { std::setlocale(LC_ALL, ""); InitializeIo(); if (argc < 2 || std::string(argv[1]) == "--help") { std::cout << help; return 0; } std::string command = argv[1]; if (command == "doctor") { if (argc > 3 || (argc == 3 && std::string(argv[2]) != "--json")) throw std::invalid_argument("doctor accepts only --json"); auto v = Size(); json d = {{"version", 1}, {"platform", Platform()}, {"stdin_tty", IsTty(0)}, {"stdout_tty", IsTty(1)}, {"term", SafeText(Env("TERM"))}, {"terminal_program", SafeText(Env("TERM_PROGRAM"))}, {"input_shortcuts", "local_commands_v2"}, {"transport_diagnostics", true}, {"utf8", v.utf8}, {"colors", v.colors}, {"cell_aspect", v.cell_aspect}, {"pixel_width", v.pixel_width}, {"pixel_height", v.pixel_height}, {"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()}, {"software_decode", true}, {"kitty", "probe_on_view"}, {"audio_playback", false}, {"bridge_version", 1}}; std::cout << d.dump(argc == 3 ? -1 : 2) << "\n"; return 0; } int i = 2; if (command != "view" && command != "bridge") { if (command.find_first_not_of("0123456789") != std::string::npos) throw std::invalid_argument( "expected a 9-digit device ID or a command; use --help"); command = "view"; i = 1; } SessionOptions options; std::string renderer = "auto", device; AnsiDetail detail = AnsiDetail::Quadrant; double zoom = 1, cell_aspect = 0; bool control = false, stdio = false, native = false; int secret_fd = 3; if (command == "view") { if (i >= argc) throw std::invalid_argument("device ID required"); device = argv[i++]; if (device.size() != 9 || device.find_first_not_of("0123456789") != std::string::npos) throw std::invalid_argument("device ID must contain 9 digits"); } auto number = [](const std::string &s, int lo, int hi) { size_t used = 0; int n = std::stoi(s, &used); if (used != s.size() || n < lo || n > hi) throw std::invalid_argument("numeric option out of range"); return n; }; for (; i < argc; ++i) { std::string a = argv[i]; if (a == "--control" && command == "view") control = true; else if (a == "--stdio" && command == "bridge") stdio = true; else { if (i + 1 >= argc) throw std::invalid_argument("option value missing"); std::string value = argv[++i]; if (a == "--server") options.host = value; else if (a == "--port") options.port = number(value, 1, 65535); else if (a == "--fps" && command == "view") options.fps = number(value, 1, 30); else if (a == "--display" && command == "view") options.display = number(value, 0, 63); else if (a == "--renderer" && command == "view") renderer = value; else if (a == "--quality") Quality(options, value); else if (a == "--ansi-detail" && command == "view") detail = Detail(value); else if (a == "--cell-aspect" && command == "view") { if (value == "auto") { cell_aspect = 0; } else { size_t used = 0; cell_aspect = std::stod(value, &used); if (used != value.size() || !std::isfinite(cell_aspect) || cell_aspect < .2 || cell_aspect > 2) throw std::invalid_argument("cell aspect must be auto or 0.2..2"); } } else if (a == "--zoom" && command == "view") { native = value == "native"; zoom = native ? 1 : number(value, 1, 32); } else if (a == "--password-fd" && command == "bridge") secret_fd = number(value, 3, 1024); else throw std::invalid_argument("unsupported option"); } } if (renderer != "auto" && renderer != "ansi" && renderer != "kitty") throw std::invalid_argument("unknown renderer"); if (command == "bridge" && !stdio) throw std::invalid_argument("bridge requires --stdio"); Descriptor output(Duplicate(1)); if (output.value < 0) throw std::runtime_error("cannot preserve output channel"); // All library stdout writes go to diagnostics. Only Output uses the saved // fd. options.log_directory = Logs(); if (command == "view") { Descriptor logs(OpenLog(options.log_directory + "/client.log")); if (logs.value < 0 || Redirect(logs.value, 2) < 0) throw std::runtime_error("cannot open diagnostic log"); } if (Redirect(2, 1) < 0) throw std::runtime_error("cannot isolate protocol output"); std::cerr << json{{"event", "startup"}, {"executable", SafeText(argv[0], 4096)}, {"terminal_program", SafeText(Env("TERM_PROGRAM"))}, {"input_shortcuts", "local_commands_v2"}, {"log_directory", options.log_directory}} .dump() << "\n"; try { return command == "view" ? View(options, device, renderer, control, output.value, detail, zoom, native, cell_aspect) : Bridge(output.value, options, secret_fd); } catch (const std::exception &e) { if (command == "view") { std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n"; if (std::string(e.what()) == "connection_lost") msg += "The remote connection was lost after connecting.\r\n"; msg += "Diagnostics: " + SafeText(options.log_directory) + "\r\n"; Write(output.value, msg.data(), msg.size()); } throw; } } catch (const std::exception &e) { std::cerr << "crossdesk-cli: " << SafeText(e.what()) << "\n"; return 1; } }