Files
egutierrez cc1b324ffb feat(navegator_dashboard): v1+v2 — CDP HTTP client, real Tabs panel, full Network panel
CDP HTTP client (cdp_http.h/cpp): WinSock raw + crude_json. GET /json/version,
/json (list tabs), PUT /json/new (Chrome 137+), GET /json/activate/{id},
/json/close/{id}.

CDP WebSocket client (cdp_ws.h/cpp): RFC 6455 handshake + framing manual,
masked client frames, async dispatcher con queue + wait_response. Soporta
fragmentacion (FIN=0 + continuation), ping/pong, close frame. Stats bytes
in/out + frames in.

Cross-panel session (session_state.h/cpp): selected_browser_port +
selected_tab_id. Cambiar tab cierra/abre NetworkSession.

Tabs panel: real. List + filtro titulo/URL + Refresh + New tab + Focus +
Close + Select (alimenta Network panel).

Network panel: DevTools-like.
  - Tabla: Name | Status (color) | Method | Type | Initiator | Size | Time | Waterfall
  - Filtros: text + invert + chips (Doc/CSS/JS/XHR/Img/Media/Font/WS/Other) + All toggle
  - Toggles: Preserve log, Disable cache, Hide data:, Only failed, Pause/Resume
  - Detalle por request: Headers (general + req + res) | Payload | Response (lazy
    Network.getResponseBody) | Cookies | Timing | WS Messages (frames in/out)
  - Right-click row: Copy URL / Copy as cURL / Copy as fetch
  - Status bar: N requests | bytes transferred | resources | Finish | DCL | Load
  - Export HAR 1.2 a archivo junto al exe

NetworkSession parsea Network.requestWillBeSent + ExtraInfo, responseReceived
+ ExtraInfo, dataReceived, loadingFinished, loadingFailed, webSocketCreated,
webSocketFrameSent/Received/Closed, Page.frameNavigated (autoclear si !preserve),
domContentEventFired, loadEventFired.

API local extendida (local_api.cpp):
  - GET  /browser/{port}/version
  - GET  /browser/{port}/tabs
  - POST /browser/{port}/tab/new?url=
  - POST /browser/{port}/tab/{id}/focus
  - POST /browser/{port}/tab/{id}/close
  - GET  /browser/{port}/har  (HAR 1.2 export de la sesion activa)

Build:
  - CMakeLists.txt linka imgui_node_editor solo para reusar crude_json (sin
    codigo de node-editor en runtime).
  - 15 MB exe Windows. Cross-compile mingw-w64 OK.

app.md: bump version 0.2.0 -> 0.3.0, panels matrix actualizado, e2e_checks
añade api_health + api_browsers (warning).

Issue 0002 (sub-issue del roadmap navegator_dashboard 0001).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:51:23 +02:00

510 lines
16 KiB
C++

// local_api.cpp — Servidor HTTP minimo (WinSock) para que scripts/agentes
// hablen con el dashboard. Sin dependencias externas: parser de request +
// dispatch + JSON escapado a mano. ~250 LoC.
#include "local_api.h"
#include "chrome_scanner.h"
#include "chrome_launcher.h"
#include "cdp_http.h"
#include "session_state.h"
#include <atomic>
#include <cstdio>
#include <cstring>
#include <map>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <winsock2.h>
# include <ws2tcpip.h>
# pragma comment(lib, "Ws2_32.lib")
#endif
namespace navegator {
std::atomic<bool> g_api_running{false};
std::atomic<int> g_api_port{0};
std::atomic<int> g_api_request_count{0};
namespace {
// ---------- JSON helpers ----------
std::string json_escape(const std::string& s) {
std::string out; out.reserve(s.size() + 8);
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)c);
out += buf;
} else {
out += c;
}
}
}
return out;
}
std::string instance_to_json(const ChromeInstance& i) {
std::ostringstream os;
os << "{\"pid\":" << i.pid
<< ",\"port\":" << i.port
<< ",\"profile\":\"" << json_escape(i.profile_name) << "\""
<< ",\"user_data_dir\":\"" << json_escape(i.user_data_dir) << "\""
<< ",\"headless\":" << (i.headless ? "true" : "false")
<< "}";
return os.str();
}
// ---------- URL decoder + query parser ----------
std::string url_decode(const std::string& s) {
std::string out; out.reserve(s.size());
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '+') {
out += ' ';
} else if (s[i] == '%' && i + 2 < s.size()) {
int hi = 0, lo = 0;
auto hex = [](char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return 0;
};
hi = hex(s[i + 1]);
lo = hex(s[i + 2]);
out += (char)((hi << 4) | lo);
i += 2;
} else {
out += s[i];
}
}
return out;
}
std::map<std::string, std::string> parse_query(const std::string& q) {
std::map<std::string, std::string> m;
size_t start = 0;
while (start < q.size()) {
size_t amp = q.find('&', start);
std::string pair = q.substr(start, amp == std::string::npos ? std::string::npos : amp - start);
size_t eq = pair.find('=');
if (eq != std::string::npos) {
m[url_decode(pair.substr(0, eq))] = url_decode(pair.substr(eq + 1));
} else if (!pair.empty()) {
m[url_decode(pair)] = "";
}
if (amp == std::string::npos) break;
start = amp + 1;
}
return m;
}
// ---------- Default user_data_dir resolver ----------
std::string default_user_data_dir(const std::string& profile) {
#ifdef _WIN32
char buf[MAX_PATH] = {0};
DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf));
std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public";
return base + "\\AppData\\Local\\navegator_profiles\\" + profile;
#else
(void)profile;
return "";
#endif
}
// ---------- Endpoint handlers ----------
struct Response {
int status = 200;
std::string content_type = "application/json";
std::string body;
};
Response handle_health() {
Response r;
r.body = "{\"ok\":true,\"app\":\"navegator_dashboard\"}";
return r;
}
Response handle_browsers() {
Response r;
auto list = scan_chrome_instances();
std::ostringstream os;
os << "[";
for (size_t i = 0; i < list.size(); ++i) {
if (i) os << ",";
os << instance_to_json(list[i]);
}
os << "]";
r.body = os.str();
return r;
}
Response handle_spawn(const std::map<std::string, std::string>& q) {
Response r;
LaunchOpts o;
auto profile_it = q.find("profile");
std::string profile = (profile_it != q.end() && !profile_it->second.empty())
? profile_it->second : "default";
auto port_it = q.find("port");
if (port_it != q.end() && !port_it->second.empty()) {
try { o.port = std::stoi(port_it->second); } catch (...) {}
}
auto headless_it = q.find("headless");
if (headless_it != q.end()) {
const std::string& v = headless_it->second;
o.headless = (v == "1" || v == "true" || v == "yes");
}
auto udd_it = q.find("user_data_dir");
o.user_data_dir = (udd_it != q.end() && !udd_it->second.empty())
? udd_it->second : default_user_data_dir(profile);
auto url_it = q.find("url");
if (url_it != q.end()) o.start_url = url_it->second;
auto res = launch_chrome(o);
std::ostringstream os;
os << "{\"ok\":" << (res.ok ? "true" : "false")
<< ",\"pid\":" << res.pid
<< ",\"port\":" << o.port
<< ",\"profile\":\"" << json_escape(profile) << "\""
<< ",\"user_data_dir\":\"" << json_escape(o.user_data_dir) << "\"";
if (!res.ok) {
os << ",\"error\":\"" << json_escape(res.error) << "\"";
r.status = 500;
}
os << "}";
r.body = os.str();
return r;
}
Response handle_kill(const std::map<std::string, std::string>& q) {
Response r;
std::string udd;
auto udd_it = q.find("user_data_dir");
if (udd_it != q.end() && !udd_it->second.empty()) {
udd = udd_it->second;
} else {
auto p_it = q.find("profile");
if (p_it != q.end() && !p_it->second.empty()) {
udd = default_user_data_dir(p_it->second);
}
}
if (udd.empty()) {
r.status = 400;
r.body = "{\"ok\":false,\"error\":\"need profile= or user_data_dir=\"}";
return r;
}
int killed = kill_chromes_by_userdata(udd);
std::ostringstream os;
os << "{\"ok\":" << (killed >= 0 ? "true" : "false")
<< ",\"killed\":" << (killed < 0 ? 0 : killed)
<< ",\"user_data_dir\":\"" << json_escape(udd) << "\"}";
r.body = os.str();
return r;
}
Response handle_not_found(const std::string& path) {
Response r;
r.status = 404;
r.body = std::string("{\"ok\":false,\"error\":\"not found: ") + json_escape(path) + "\"}";
return r;
}
// ---------- /browser/{port}/... handlers ----------
Response handle_browser_version(int port) {
Response r;
CdpVersion v; std::string err;
if (!cdp_get_version(port, v, &err)) {
r.status = 502;
r.body = std::string("{\"ok\":false,\"error\":\"") + json_escape(err) + "\"}";
return r;
}
std::ostringstream os;
os << "{\"ok\":true"
<< ",\"browser\":\"" << json_escape(v.browser) << "\""
<< ",\"protocolVersion\":\"" << json_escape(v.protocol_version) << "\""
<< ",\"userAgent\":\"" << json_escape(v.user_agent) << "\""
<< ",\"v8Version\":\"" << json_escape(v.v8_version) << "\""
<< ",\"webkitVersion\":\"" << json_escape(v.webkit_version) << "\""
<< ",\"webSocketDebuggerUrl\":\"" << json_escape(v.browser_ws_url) << "\""
<< "}";
r.body = os.str();
return r;
}
Response handle_browser_tabs(int port) {
Response r;
std::vector<CdpTab> tabs; std::string err;
if (!cdp_list_tabs(port, tabs, &err)) {
r.status = 502;
r.body = std::string("{\"ok\":false,\"error\":\"") + json_escape(err) + "\"}";
return r;
}
std::ostringstream os;
os << "[";
for (size_t i = 0; i < tabs.size(); ++i) {
if (i) os << ",";
const auto& t = tabs[i];
os << "{\"id\":\"" << json_escape(t.id) << "\""
<< ",\"type\":\"" << json_escape(t.type) << "\""
<< ",\"title\":\"" << json_escape(t.title) << "\""
<< ",\"url\":\"" << json_escape(t.url) << "\""
<< ",\"webSocketDebuggerUrl\":\"" << json_escape(t.ws_url) << "\""
<< ",\"attached\":" << (t.attached ? "true" : "false")
<< "}";
}
os << "]";
r.body = os.str();
return r;
}
Response handle_browser_tab_new(int port, const std::map<std::string, std::string>& q) {
Response r;
std::string url;
auto it = q.find("url");
if (it != q.end()) url = it->second;
CdpTab t; std::string err;
if (!cdp_new_tab(port, url, t, &err)) {
r.status = 502;
r.body = std::string("{\"ok\":false,\"error\":\"") + json_escape(err) + "\"}";
return r;
}
std::ostringstream os;
os << "{\"ok\":true,\"id\":\"" << json_escape(t.id) << "\""
<< ",\"webSocketDebuggerUrl\":\"" << json_escape(t.ws_url) << "\""
<< ",\"url\":\"" << json_escape(t.url) << "\"}";
r.body = os.str();
return r;
}
Response handle_browser_tab_focus(int port, const std::string& tab_id) {
Response r;
std::string err;
if (!cdp_activate_tab(port, tab_id, &err)) {
r.status = 502;
r.body = std::string("{\"ok\":false,\"error\":\"") + json_escape(err) + "\"}";
return r;
}
r.body = "{\"ok\":true}";
return r;
}
Response handle_browser_tab_close(int port, const std::string& tab_id) {
Response r;
std::string err;
if (!cdp_close_tab(port, tab_id, &err)) {
r.status = 502;
r.body = std::string("{\"ok\":false,\"error\":\"") + json_escape(err) + "\"}";
return r;
}
r.body = "{\"ok\":true}";
return r;
}
Response handle_browser_har(int port) {
Response r;
NetworkSession* net = nullptr;
int sel_port = 0;
{
std::lock_guard<std::mutex> lk(g_session().mu);
sel_port = g_session().selected_port;
net = g_session().net.get();
}
if (sel_port != port || !net) {
r.status = 409;
r.body = "{\"ok\":false,\"error\":\"no active network session for this port (use UI to Select tab first)\"}";
return r;
}
r.body = net->export_har_json();
return r;
}
// Routes /browser/{port}/... — devuelve true si el path matchea (out llena la
// resp). Si false, fallback al dispatch root.
bool route_browser(const std::string& method,
const std::string& path,
const std::map<std::string, std::string>& q,
Response& out) {
if (path.compare(0, 9, "/browser/") != 0) return false;
size_t pos = 9;
size_t slash = path.find('/', pos);
std::string port_s = (slash == std::string::npos) ? path.substr(pos) : path.substr(pos, slash - pos);
int port = 0;
try { port = std::stoi(port_s); } catch (...) { return false; }
if (port <= 0) return false;
std::string rest = (slash == std::string::npos) ? "" : path.substr(slash);
// /browser/{port}/version
if (method == "GET" && rest == "/version") { out = handle_browser_version(port); return true; }
// /browser/{port}/tabs
if (method == "GET" && rest == "/tabs") { out = handle_browser_tabs(port); return true; }
// /browser/{port}/tab/new
if (method == "POST" && rest == "/tab/new"){ out = handle_browser_tab_new(port, q); return true; }
// /browser/{port}/tab/{id}/focus|close
if (rest.compare(0, 5, "/tab/") == 0) {
std::string remainder = rest.substr(5);
size_t s2 = remainder.find('/');
if (s2 != std::string::npos) {
std::string tab_id = remainder.substr(0, s2);
std::string action = remainder.substr(s2 + 1);
if (method == "POST" && action == "focus") { out = handle_browser_tab_focus(port, tab_id); return true; }
if (method == "POST" && action == "close") { out = handle_browser_tab_close(port, tab_id); return true; }
}
}
// /browser/{port}/har
if (method == "GET" && rest == "/har") { out = handle_browser_har(port); return true; }
return false;
}
Response dispatch(const std::string& method, const std::string& path, const std::string& query) {
auto q = parse_query(query);
if (method == "GET" && path == "/health") return handle_health();
if (method == "GET" && path == "/browsers") return handle_browsers();
if (method == "POST" && path == "/spawn") return handle_spawn(q);
if (method == "POST" && path == "/kill") return handle_kill(q);
Response br;
if (route_browser(method, path, q, br)) return br;
return handle_not_found(path);
}
// ---------- HTTP request parsing ----------
struct Request {
std::string method;
std::string path;
std::string query;
};
bool parse_request_line(const std::string& line, Request& req) {
size_t sp1 = line.find(' ');
if (sp1 == std::string::npos) return false;
size_t sp2 = line.find(' ', sp1 + 1);
if (sp2 == std::string::npos) return false;
req.method = line.substr(0, sp1);
std::string url = line.substr(sp1 + 1, sp2 - sp1 - 1);
size_t qm = url.find('?');
if (qm == std::string::npos) {
req.path = url;
req.query = "";
} else {
req.path = url.substr(0, qm);
req.query = url.substr(qm + 1);
}
return true;
}
#ifdef _WIN32
void send_all(SOCKET s, const std::string& data) {
size_t sent = 0;
while (sent < data.size()) {
int n = ::send(s, data.data() + sent, (int)(data.size() - sent), 0);
if (n <= 0) return;
sent += (size_t)n;
}
}
void handle_connection(SOCKET client) {
// Leer hasta encontrar \r\n\r\n (cabeceras). Body POST: ignoramos para v0
// (los params van en query string, mas simple).
std::string buf;
char tmp[4096];
for (int i = 0; i < 16; ++i) {
int n = ::recv(client, tmp, sizeof(tmp), 0);
if (n <= 0) break;
buf.append(tmp, n);
if (buf.find("\r\n\r\n") != std::string::npos) break;
if (buf.size() > 65536) break;
}
Request req;
Response resp;
size_t end_line = buf.find("\r\n");
if (end_line == std::string::npos || !parse_request_line(buf.substr(0, end_line), req)) {
resp.status = 400;
resp.body = "{\"ok\":false,\"error\":\"bad request\"}";
} else {
resp = dispatch(req.method, req.path, req.query);
}
std::ostringstream out;
const char* status_text = "OK";
if (resp.status == 400) status_text = "Bad Request";
else if (resp.status == 404) status_text = "Not Found";
else if (resp.status == 500) status_text = "Internal Server Error";
out << "HTTP/1.1 " << resp.status << " " << status_text << "\r\n"
<< "Content-Type: " << resp.content_type << "\r\n"
<< "Content-Length: " << resp.body.size() << "\r\n"
<< "Access-Control-Allow-Origin: *\r\n"
<< "Connection: close\r\n"
<< "\r\n"
<< resp.body;
send_all(client, out.str());
closesocket(client);
g_api_request_count.fetch_add(1);
}
void server_loop(int port) {
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return;
SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listener == INVALID_SOCKET) { WSACleanup(); return; }
BOOL yes = TRUE;
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, (const char*)&yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons((u_short)port);
addr.sin_addr.s_addr = htonl(0x7F000001); // 127.0.0.1 (loopback)
if (::bind(listener, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
closesocket(listener); WSACleanup();
g_api_running.store(false);
return;
}
if (::listen(listener, 16) == SOCKET_ERROR) {
closesocket(listener); WSACleanup();
g_api_running.store(false);
return;
}
g_api_running.store(true);
g_api_port.store(port);
while (true) {
sockaddr_in client_addr{};
int alen = sizeof(client_addr);
SOCKET client = ::accept(listener, (sockaddr*)&client_addr, &alen);
if (client == INVALID_SOCKET) continue;
std::thread(handle_connection, client).detach();
}
}
#endif
} // namespace
void start_api_server(int port) {
#ifdef _WIN32
if (g_api_running.load()) return;
std::thread([port]{ server_loop(port); }).detach();
#else
(void)port;
#endif
}
} // namespace navegator