Merge branch 'issue/0002-cdp-http-tabs-network' (v1+v2 CDP + Tabs + Network)

This commit is contained in:
2026-05-10 12:51:29 +02:00
15 changed files with 4032 additions and 68 deletions
+13 -1
View File
@@ -12,9 +12,21 @@ add_imgui_app(navegator_dashboard
chrome_launcher.cpp
local_api.cpp
panels.cpp
agent.cpp
cdp_http.cpp
cdp_ws.cpp
network_state.cpp
session_state.cpp
)
target_include_directories(navegator_dashboard PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(navegator_dashboard PRIVATE ws2_32)
# imgui_node_editor expone su propio include dir (vendor/imgui-node-editor)
# que contiene crude_json.{h,cpp}. Lo linkamos solo para reusar crude_json
# como parser JSON: sin dependencia nueva, sin codigo de node-editor en runtime.
target_link_libraries(navegator_dashboard PRIVATE
ws2_32
imgui_node_editor
)
set_target_properties(navegator_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
+1289
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <string>
#include <vector>
// Panel Chat — agente Claude (claude -p) con tool-use sobre operations.db
// via gx-cli. Subprocess persistente bidireccional (stdin/stdout JSON-lines).
// El usuario escribe, el hilo lector parsea stream-json y va emitiendo
// fragmentos al historial. gx-cli muta operations.db; el contador
// agent_mutations en graph_explorer.db dispara reload del viewport.
namespace app_agent {
// Inicia el subprocess claude -p (lazy: hasta el primer mensaje no se
// arranca). Setea env vars GX_OPS_DB / GX_APP_DB / GX_APP_DIR. Devuelve
// false si claude no esta disponible (o, en Windows, wsl no esta).
bool chat_init(const char* ops_db_path,
const char* app_db_path,
const char* app_dir);
// Si la ops_db cambia (proyecto switch), refresca env del subprocess
// matandolo y dejando que el siguiente send lo reabra.
void chat_set_ops_db(const char* ops_db_path);
// Envia un mensaje del usuario al agente. Si el subprocess no esta vivo,
// lo arranca primero. No bloquea — el resultado llega via chat_render
// al ir vaciando la cola del hilo lector.
void chat_send(const char* user_text);
// Renderiza el panel ImGui (titulo "Chat"). Drena cola de mensajes del
// hilo lector. `panel_open` es bound al close button.
void chat_render(bool* panel_open);
// Cierra el subprocess y libera recursos. Llamar en shutdown.
void chat_shutdown();
// Counter de mutaciones (lee tabla agent_mutations en app_db). Se llama
// desde main.cpp cada frame para detectar si gx-cli muto algo y disparar
// reload del grafo. Devuelve 0 si la tabla no existe todavia.
int chat_mutations_counter();
// ----------------------------------------------------------------------------
// Logging con tags
//
// Todas las trazas del subsistema chat van a `<app_dir>/chat.log` ademas de
// stderr. Cada linea tiene formato:
//
// 2026-05-01T18:35:50.853Z [chat:detect] mensaje
//
// Tags usados (grep amigable):
// detect deteccion de claude/wsl al arrancar
// env env vars seteadas para el subprocess
// spawn argv completo + cwd al lanzar el subprocess
// io operaciones sobre los pipes (lectura/escritura/EOF)
// parse eventos JSON parseados desde stream-json
// tools tool_use detectados, comandos Bash invocados
// mut cambios detectados via agent_mutations.counter
// error fallos y exit codes
// ----------------------------------------------------------------------------
void chat_log(const char* tag, const char* fmt, ...);
// Devuelve el path absoluto del fichero de log (vacio si no inicializado).
const char* chat_log_path();
} // namespace app_agent
+21 -11
View File
@@ -17,6 +17,14 @@ e2e_checks:
timeout_s: 600
- id: exe_present
cmd: "test -f /mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe"
- id: api_health
cmd: "curl -sf http://127.0.0.1:19333/health"
timeout_s: 5
severity: warning
- id: api_browsers
cmd: "curl -sf http://127.0.0.1:19333/browsers"
timeout_s: 5
severity: warning
---
## Proposito
@@ -55,24 +63,26 @@ Casos de uso:
└──────────────────────────────────────────────────────────────┘
```
## Panels (v0 → v1)
## Panels (v0 → v2)
| Panel | v0 | v1 | v2 |
|---|---|---|---|
| **Browsers** | scan + spawn + kill | filtro/search | grupos (perfiles favoritos) |
| **Tabs** | stub | listar + navigate + close | drag&drop entre instancias |
| **Tab Detail** | stub | HTML preview + screenshot + JS REPL | live mirror de pestaña (CDP screencast) |
| **Network** | stub | request log + headers + body | timeline + waterfall + filtro |
| Panel | v0 | v1 (issue 0002) | v1.5 | v2 (issue 0002) | v3 |
|---|---|---|---|---|---|
| **Browsers** | scan + spawn + kill | + Select row → cross-panel context | grupos (perfiles favoritos) | grupos | drag&drop |
| **Tabs** | stub | list + Focus/Close/New + filter | — | — | drag&drop entre instancias |
| **Tab Detail** | stub | placeholder upgrade | HTML preview + screenshot + REPL | — | live mirror (CDP screencast) |
| **Network** | stub | — | — | DevTools-like: tabla + filtros (Doc/CSS/JS/XHR/Img/Media/Font/WS/Other) + waterfall + detalle (Headers/Payload/Response/Cookies/Timing/WS Messages) + Preserve log + Disable cache + Pause + Export HAR | extract endpoints |
## Stack
- `fn::run_app` (cpp/framework/app_base.h) — shell estandar (PATTERNS.md).
- ImGui + ImPlot (chart Network).
- ImGui + ImPlot.
- ChromeScanner: `Get-CimInstance Win32_Process` invocado con `_popen`. Itera ~1 Hz.
- ChromeLauncher: `CreateProcess` Windows. Argumentos derivados de `chrome_launch_go_browser` (registry).
- CDP HTTP: GET `/json/version`, `/json` por puerto via WinSock raw o `WinHttp`. v1.
- CDP WS: handshake RFC 6455 + framing manual (mismo esquema que `cdp_conn.go`). v1.
- HTTP API local: `cpp-httplib` (header-only) bind 127.0.0.1. v0 stub, funcional v1.
- **CDP HTTP** (`cdp_http.{h,cpp}`): WinSock raw, GET `/json/version`, `/json`, PUT `/json/new`, GET `/json/activate/{id}`, `/json/close/{id}`. v1.
- **CDP WS** (`cdp_ws.{h,cpp}`): handshake RFC 6455 + framing manual. Async dispatcher con queue + wait_response. v2 ✓ (parser de mensajes Network.* en `network_state`).
- **NetworkSession** (`network_state.{h,cpp}`): consume `Network.requestWillBeSent`, `responseReceived`, `dataReceived`, `loadingFinished/Failed`, `webSocketCreated/FrameSent/FrameReceived/Closed`, `Page.frameNavigated`, `Page.domContentEventFired`, `Page.loadEventFired`. Mantiene log + stats + export HAR 1.2.
- **JSON parser**: crude_json del vendor `imgui-node-editor` (dual-license public domain). Linkado como dep estatica para reusar — sin codigo de node-editor en runtime.
- HTTP API local: WinSock raw bind 127.0.0.1. v0+v1+v2 endpoints en `local_api.cpp`.
## Decisiones consciente (cpp_apps.md §9)
+285
View File
@@ -0,0 +1,285 @@
#include "cdp_http.h"
#include "crude_json.h"
#include <cstdio>
#include <cstring>
#include <sstream>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <winsock2.h>
# include <ws2tcpip.h>
#endif
namespace navegator {
namespace {
#ifdef _WIN32
bool set_socket_timeout(SOCKET s, int ms) {
DWORD t = (DWORD)ms;
if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&t, sizeof(t)) != 0) return false;
if (setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&t, sizeof(t)) != 0) return false;
return true;
}
bool send_all(SOCKET s, const char* data, size_t len) {
size_t sent = 0;
while (sent < len) {
int n = ::send(s, data + sent, (int)(len - sent), 0);
if (n <= 0) return false;
sent += (size_t)n;
}
return true;
}
bool recv_until_close(SOCKET s, std::string& out, size_t cap = 8 * 1024 * 1024) {
char buf[8192];
while (out.size() < cap) {
int n = ::recv(s, buf, sizeof(buf), 0);
if (n == 0) return true; // peer closed
if (n < 0) return !out.empty(); // timeout/err: si tenemos algo, asumimos completo
out.append(buf, (size_t)n);
}
return true;
}
bool parse_http_response(const std::string& raw, int& status, std::string& body, std::string& err) {
auto eol = raw.find("\r\n");
if (eol == std::string::npos) { err = "no status line"; return false; }
std::string status_line = raw.substr(0, eol);
int sp1 = (int)status_line.find(' ');
int sp2 = (int)status_line.find(' ', sp1 + 1);
if (sp1 < 0 || sp2 < 0) { err = "bad status line"; return false; }
try { status = std::stoi(status_line.substr(sp1 + 1, sp2 - sp1 - 1)); }
catch (...) { err = "bad status code"; return false; }
auto headers_end = raw.find("\r\n\r\n");
if (headers_end == std::string::npos) { err = "no header terminator"; return false; }
body = raw.substr(headers_end + 4);
// Si Transfer-Encoding: chunked, decodear. CDP rara vez lo usa pero por si acaso.
std::string headers_lower;
headers_lower.reserve(headers_end);
for (size_t i = 0; i < headers_end; ++i) {
char c = raw[i];
headers_lower.push_back((c >= 'A' && c <= 'Z') ? (char)(c + 32) : c);
}
if (headers_lower.find("transfer-encoding: chunked") != std::string::npos) {
std::string decoded;
size_t p = 0;
while (p < body.size()) {
size_t crlf = body.find("\r\n", p);
if (crlf == std::string::npos) break;
std::string size_hex = body.substr(p, crlf - p);
// strip extensions
size_t semi = size_hex.find(';');
if (semi != std::string::npos) size_hex.resize(semi);
size_t chunk_size = 0;
try { chunk_size = std::stoul(size_hex, nullptr, 16); }
catch (...) { break; }
if (chunk_size == 0) break;
p = crlf + 2;
if (p + chunk_size > body.size()) break;
decoded.append(body, p, chunk_size);
p += chunk_size + 2;
}
body = std::move(decoded);
}
return true;
}
CdpHttpResult do_request(const std::string& method,
int port,
const std::string& path,
const std::string& body,
int timeout_ms) {
CdpHttpResult r;
WSADATA wsa;
static bool wsa_ok = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0);
if (!wsa_ok) { r.error = "WSAStartup failed"; return r; }
SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET) { r.error = "socket() failed"; return r; }
set_socket_timeout(s, timeout_ms);
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
if (::connect(s, (sockaddr*)&addr, sizeof(addr)) != 0) {
r.error = "connect failed (port " + std::to_string(port) + ")";
closesocket(s);
return r;
}
std::ostringstream req;
req << method << " " << path << " HTTP/1.1\r\n"
<< "Host: 127.0.0.1:" << port << "\r\n"
<< "User-Agent: navegator_dashboard/1.0\r\n"
<< "Accept: */*\r\n"
<< "Connection: close\r\n";
if (!body.empty()) {
req << "Content-Type: application/json\r\n"
<< "Content-Length: " << body.size() << "\r\n";
}
req << "\r\n";
if (!body.empty()) req << body;
std::string raw_req = req.str();
if (!send_all(s, raw_req.data(), raw_req.size())) {
r.error = "send failed";
closesocket(s);
return r;
}
std::string raw_resp;
if (!recv_until_close(s, raw_resp)) {
r.error = "recv failed";
closesocket(s);
return r;
}
closesocket(s);
int status = 0;
std::string parsed_body;
std::string err;
if (!parse_http_response(raw_resp, status, parsed_body, err)) {
r.error = err;
return r;
}
r.ok = (status >= 200 && status < 300);
r.status = status;
r.body = std::move(parsed_body);
return r;
}
#else
CdpHttpResult do_request(const std::string&, int, const std::string&, const std::string&, int) {
CdpHttpResult r;
r.error = "cdp_http: Windows-only";
return r;
}
#endif
// ---------- JSON helpers ----------
std::string json_str_or_empty(const crude_json::value& v, const char* key) {
if (!v.is_object()) return "";
if (!v.contains(key)) return "";
const auto& f = v[key];
if (!f.is_string()) return "";
return f.get<std::string>();
}
bool json_bool_or_false(const crude_json::value& v, const char* key) {
if (!v.is_object() || !v.contains(key)) return false;
const auto& f = v[key];
if (!f.is_boolean()) return false;
return f.get<bool>();
}
void parse_tab(const crude_json::value& v, CdpTab& out) {
out.id = json_str_or_empty(v, "id");
out.type = json_str_or_empty(v, "type");
out.title = json_str_or_empty(v, "title");
out.url = json_str_or_empty(v, "url");
out.ws_url = json_str_or_empty(v, "webSocketDebuggerUrl");
out.favicon_url = json_str_or_empty(v, "faviconUrl");
out.description = json_str_or_empty(v, "description");
out.attached = json_bool_or_false(v, "attached");
}
} // namespace
CdpHttpResult cdp_http_request(const std::string& method,
int port,
const std::string& path,
const std::string& body,
int timeout_ms) {
return do_request(method, port, path, body, timeout_ms);
}
bool cdp_get_version(int port, CdpVersion& out, std::string* err) {
auto r = do_request("GET", port, "/json/version", "", 3000);
if (!r.ok) { if (err) *err = r.error.empty() ? ("HTTP " + std::to_string(r.status)) : r.error; return false; }
crude_json::value v = crude_json::value::parse(r.body);
if (!v.is_object()) { if (err) *err = "bad json"; return false; }
out.browser = json_str_or_empty(v, "Browser");
out.protocol_version = json_str_or_empty(v, "Protocol-Version");
out.user_agent = json_str_or_empty(v, "User-Agent");
out.v8_version = json_str_or_empty(v, "V8-Version");
out.webkit_version = json_str_or_empty(v, "WebKit-Version");
out.browser_ws_url = json_str_or_empty(v, "webSocketDebuggerUrl");
return true;
}
bool cdp_list_tabs(int port, std::vector<CdpTab>& out, std::string* err) {
out.clear();
auto r = do_request("GET", port, "/json", "", 3000);
if (!r.ok) { if (err) *err = r.error.empty() ? ("HTTP " + std::to_string(r.status)) : r.error; return false; }
crude_json::value v = crude_json::value::parse(r.body);
if (!v.is_array()) { if (err) *err = "bad json (not array)"; return false; }
const auto& arr = v.get<crude_json::array>();
for (const auto& item : arr) {
CdpTab t;
parse_tab(item, t);
if (!t.id.empty()) out.push_back(std::move(t));
}
return true;
}
namespace {
std::string url_encode_query(const std::string& s) {
static const char* hex = "0123456789ABCDEF";
std::string out; out.reserve(s.size() * 3);
for (unsigned char c : s) {
bool unreserved =
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' ||
c == ':' || c == '/' || c == '?' || c == '#' || c == '=' || c == '&';
if (unreserved) out.push_back((char)c);
else { out.push_back('%'); out.push_back(hex[c >> 4]); out.push_back(hex[c & 0xF]); }
}
return out;
}
}
bool cdp_new_tab(int port, const std::string& url, CdpTab& out, std::string* err) {
std::string path = "/json/new";
if (!url.empty()) path += "?" + url_encode_query(url);
// Chrome 137+ requiere PUT; versiones anteriores aceptan GET. Probamos PUT primero,
// si responde 405, fallback a GET.
auto r = do_request("PUT", port, path, "", 3000);
if (r.status == 405 || r.status == 404 || (!r.ok && r.error.find("connect") != std::string::npos)) {
if (r.status == 405 || r.status == 404) {
r = do_request("GET", port, path, "", 3000);
}
}
if (!r.ok) { if (err) *err = r.error.empty() ? ("HTTP " + std::to_string(r.status)) : r.error; return false; }
crude_json::value v = crude_json::value::parse(r.body);
if (!v.is_object()) { if (err) *err = "bad json"; return false; }
parse_tab(v, out);
return !out.id.empty();
}
bool cdp_activate_tab(int port, const std::string& tab_id, std::string* err) {
if (tab_id.empty()) { if (err) *err = "empty tab id"; return false; }
auto r = do_request("GET", port, "/json/activate/" + tab_id, "", 3000);
if (!r.ok && err) *err = r.error.empty() ? ("HTTP " + std::to_string(r.status)) : r.error;
return r.ok;
}
bool cdp_close_tab(int port, const std::string& tab_id, std::string* err) {
if (tab_id.empty()) { if (err) *err = "empty tab id"; return false; }
auto r = do_request("GET", port, "/json/close/" + tab_id, "", 3000);
if (!r.ok && err) *err = r.error.empty() ? ("HTTP " + std::to_string(r.status)) : r.error;
return r.ok;
}
} // namespace navegator
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// CDP HTTP client (read + control endpoints sin WebSocket).
//
// Base: http://127.0.0.1:<port>/json/...
// GET /json/version -> info navegador + browser webSocketDebuggerUrl
// GET /json (o /json/list) -> array de targets (pages, iframes, workers)
// PUT /json/new?<url> -> crea pestaña nueva (Chrome 137+ requiere PUT)
// GET /json/activate/<id> -> focus pestaña
// GET /json/close/<id> -> cierra pestaña
//
// Implementacion: WinSock raw + parser HTTP minimo + crude_json para parsear
// payloads. Sin dependencias nuevas.
#include <string>
#include <vector>
namespace navegator {
struct CdpTab {
std::string id;
std::string type; // "page", "iframe", "service_worker", "worker", ...
std::string title;
std::string url;
std::string ws_url; // webSocketDebuggerUrl
std::string favicon_url;
std::string description;
bool attached = false; // true si DevTools ya esta enganchado
};
struct CdpVersion {
std::string browser; // "Chrome/147.0.0.0"
std::string protocol_version;
std::string user_agent;
std::string v8_version;
std::string webkit_version;
std::string browser_ws_url; // webSocketDebuggerUrl (browser-level)
};
struct CdpHttpResult {
bool ok = false;
int status = 0;
std::string body;
std::string error;
};
// Low-level: ejecuta una request HTTP/1.1 contra 127.0.0.1:port y devuelve
// status + body. Cierra socket al terminar (Connection: close).
CdpHttpResult cdp_http_request(const std::string& method,
int port,
const std::string& path,
const std::string& body = "",
int timeout_ms = 3000);
// High-level helpers. Devuelven true si el HTTP fue 2xx y el JSON parseo OK.
bool cdp_get_version(int port, CdpVersion& out, std::string* err = nullptr);
bool cdp_list_tabs(int port, std::vector<CdpTab>& out, std::string* err = nullptr);
bool cdp_new_tab(int port, const std::string& url, CdpTab& out, std::string* err = nullptr);
bool cdp_activate_tab(int port, const std::string& tab_id, std::string* err = nullptr);
bool cdp_close_tab(int port, const std::string& tab_id, std::string* err = nullptr);
} // namespace navegator
+435
View File
@@ -0,0 +1,435 @@
#include "cdp_ws.h"
#include "crude_json.h"
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <random>
#include <sstream>
#ifdef _WIN32
# include <ws2tcpip.h>
#endif
namespace navegator {
namespace {
// Base64 encoder (raw).
std::string b64encode(const uint8_t* data, size_t len) {
static const char* tbl =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((len + 2) / 3) * 4);
for (size_t i = 0; i < len; i += 3) {
uint32_t v = (uint32_t)data[i] << 16;
if (i + 1 < len) v |= (uint32_t)data[i + 1] << 8;
if (i + 2 < len) v |= (uint32_t)data[i + 2];
out.push_back(tbl[(v >> 18) & 0x3F]);
out.push_back(tbl[(v >> 12) & 0x3F]);
out.push_back(i + 1 < len ? tbl[(v >> 6) & 0x3F] : '=');
out.push_back(i + 2 < len ? tbl[v & 0x3F] : '=');
}
return out;
}
std::string make_ws_key() {
uint8_t buf[16];
std::random_device rd;
for (int i = 0; i < 16; i += 2) {
uint32_t v = rd();
buf[i] = (uint8_t)(v & 0xFF);
buf[i + 1] = (uint8_t)((v >> 8) & 0xFF);
}
return b64encode(buf, 16);
}
#ifdef _WIN32
bool socket_set_timeout(SOCKET s, int ms) {
DWORD t = (DWORD)ms;
return setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&t, sizeof(t)) == 0
&& setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&t, sizeof(t)) == 0;
}
#endif
// CDP envia siempre JSON UTF-8. Json escape rapido.
std::string json_escape_str(const std::string& s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('"');
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char b[8];
std::snprintf(b, sizeof(b), "\\u%04x", (unsigned)(uint8_t)c);
out += b;
} else out.push_back(c);
}
}
out.push_back('"');
return out;
}
} // namespace
bool CdpWs::parse_ws_url(const std::string& url, std::string& host, int& port, std::string& path) {
const std::string scheme = "ws://";
if (url.compare(0, scheme.size(), scheme) != 0) return false;
std::string rest = url.substr(scheme.size());
size_t slash = rest.find('/');
std::string authority = (slash == std::string::npos) ? rest : rest.substr(0, slash);
path = (slash == std::string::npos) ? "/" : rest.substr(slash);
size_t colon = authority.find(':');
if (colon == std::string::npos) {
host = authority;
port = 80;
} else {
host = authority.substr(0, colon);
try { port = std::stoi(authority.substr(colon + 1)); }
catch (...) { return false; }
}
return true;
}
CdpWs::~CdpWs() { close(); }
void CdpWs::set_error(const std::string& e) {
std::lock_guard<std::mutex> lk(err_mu_);
last_err_ = e;
}
#ifdef _WIN32
bool CdpWs::send_all(const char* data, size_t len) {
size_t sent = 0;
while (sent < len) {
int n = ::send(sock_, data + sent, (int)(len - sent), 0);
if (n <= 0) return false;
sent += (size_t)n;
bytes_out_.fetch_add((uint64_t)n);
}
return true;
}
bool CdpWs::recv_n(char* out, size_t n) {
size_t got = 0;
while (got < n) {
int r = ::recv(sock_, out + got, (int)(n - got), 0);
if (r <= 0) return false;
got += (size_t)r;
bytes_in_.fetch_add((uint64_t)r);
}
return true;
}
bool CdpWs::connect(const CdpWsConfig& cfg, std::string* err) {
if (running_.load()) return true;
WSADATA wsa;
static bool wsa_ok = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0);
if (!wsa_ok) { if (err) *err = "WSAStartup failed"; return false; }
sock_ = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock_ == INVALID_SOCKET) { if (err) *err = "socket() failed"; return false; }
socket_set_timeout(sock_, cfg.timeout_ms);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons((u_short)cfg.port);
if (cfg.host == "127.0.0.1" || cfg.host == "localhost") {
addr.sin_addr.s_addr = htonl(0x7F000001);
} else {
// Generico (LAN) — gethostbyname.
addrinfo hints{}; addrinfo* res = nullptr;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo(cfg.host.c_str(), nullptr, &hints, &res) != 0 || !res) {
if (err) *err = "getaddrinfo failed";
closesocket(sock_); sock_ = INVALID_SOCKET; return false;
}
addr.sin_addr = ((sockaddr_in*)res->ai_addr)->sin_addr;
freeaddrinfo(res);
}
if (::connect(sock_, (sockaddr*)&addr, sizeof(addr)) != 0) {
if (err) *err = "tcp connect failed";
closesocket(sock_); sock_ = INVALID_SOCKET; return false;
}
std::string key = make_ws_key();
std::ostringstream req;
req << "GET " << cfg.path << " HTTP/1.1\r\n"
<< "Host: " << cfg.host << ":" << cfg.port << "\r\n"
<< "Upgrade: websocket\r\n"
<< "Connection: Upgrade\r\n"
<< "Sec-WebSocket-Key: " << key << "\r\n"
<< "Sec-WebSocket-Version: 13\r\n"
<< "Origin: http://localhost\r\n"
<< "\r\n";
std::string raw_req = req.str();
if (!send_all(raw_req.data(), raw_req.size())) {
if (err) *err = "ws upgrade send failed";
closesocket(sock_); sock_ = INVALID_SOCKET; return false;
}
// Leer respuesta hasta \r\n\r\n. recv pequeño porque solo cabeceras.
std::string resp;
char buf[1024];
while (resp.find("\r\n\r\n") == std::string::npos && resp.size() < 8192) {
int n = ::recv(sock_, buf, sizeof(buf), 0);
if (n <= 0) {
if (err) *err = "ws upgrade recv failed";
closesocket(sock_); sock_ = INVALID_SOCKET; return false;
}
resp.append(buf, (size_t)n);
bytes_in_.fetch_add((uint64_t)n);
}
auto sp1 = resp.find(' ');
int status = 0;
if (sp1 != std::string::npos) {
try { status = std::stoi(resp.substr(sp1 + 1, 3)); } catch (...) {}
}
if (status != 101) {
if (err) *err = "ws upgrade not 101 (status " + std::to_string(status) + ")";
closesocket(sock_); sock_ = INVALID_SOCKET; return false;
}
running_.store(true);
stop_.store(false);
reader_ = std::thread(&CdpWs::reader_loop, this);
return true;
}
void CdpWs::close() {
if (!running_.exchange(false)) return;
stop_.store(true);
send_close_frame();
if (sock_ != INVALID_SOCKET) {
shutdown(sock_, SD_BOTH);
closesocket(sock_);
sock_ = INVALID_SOCKET;
}
if (reader_.joinable()) reader_.join();
resp_cv_.notify_all();
}
bool CdpWs::send_frame_text(const std::string& payload) {
std::lock_guard<std::mutex> lk(send_mu_);
if (!running_.load()) return false;
uint8_t header[14];
size_t hlen = 0;
header[hlen++] = 0x81; // FIN=1, opcode=1 (text)
size_t plen = payload.size();
if (plen < 126) {
header[hlen++] = (uint8_t)(0x80 | plen);
} else if (plen <= 0xFFFF) {
header[hlen++] = (uint8_t)(0x80 | 126);
header[hlen++] = (uint8_t)((plen >> 8) & 0xFF);
header[hlen++] = (uint8_t)(plen & 0xFF);
} else {
header[hlen++] = (uint8_t)(0x80 | 127);
for (int i = 7; i >= 0; --i) header[hlen++] = (uint8_t)((plen >> (i * 8)) & 0xFF);
}
uint8_t mask[4];
std::random_device rd;
uint32_t mr = rd();
mask[0] = (uint8_t)(mr & 0xFF);
mask[1] = (uint8_t)((mr >> 8) & 0xFF);
mask[2] = (uint8_t)((mr >> 16) & 0xFF);
mask[3] = (uint8_t)((mr >> 24) & 0xFF);
std::memcpy(header + hlen, mask, 4); hlen += 4;
if (!send_all((const char*)header, hlen)) return false;
// Enmascarar payload en bloques.
std::string masked(plen, '\0');
for (size_t i = 0; i < plen; ++i) masked[i] = payload[i] ^ mask[i & 3];
return send_all(masked.data(), plen);
}
bool CdpWs::send_close_frame() {
if (sock_ == INVALID_SOCKET) return false;
std::lock_guard<std::mutex> lk(send_mu_);
uint8_t f[6] = { 0x88, 0x80, 0, 0, 0, 0 }; // close, masked, len 0
return ::send(sock_, (const char*)f, 6, 0) > 0;
}
bool CdpWs::recv_frame(uint8_t& opcode, std::string& payload) {
char hdr2[2];
if (!recv_n(hdr2, 2)) return false;
bool fin = (hdr2[0] & 0x80) != 0;
opcode = (uint8_t)(hdr2[0] & 0x0F);
bool masked = (hdr2[1] & 0x80) != 0;
uint64_t len = (uint64_t)(hdr2[1] & 0x7F);
if (len == 126) {
char lb[2];
if (!recv_n(lb, 2)) return false;
len = ((uint64_t)(uint8_t)lb[0] << 8) | (uint8_t)lb[1];
} else if (len == 127) {
char lb[8];
if (!recv_n(lb, 8)) return false;
len = 0;
for (int i = 0; i < 8; ++i) len = (len << 8) | (uint8_t)lb[i];
}
uint8_t mask[4] = {0,0,0,0};
if (masked) {
if (!recv_n((char*)mask, 4)) return false;
}
payload.assign((size_t)len, '\0');
if (len > 0) {
if (!recv_n(payload.data(), (size_t)len)) return false;
if (masked) {
for (size_t i = 0; i < (size_t)len; ++i) payload[i] = (char)((uint8_t)payload[i] ^ mask[i & 3]);
}
}
if (!fin) {
// Continuation: append until FIN. CDP rara vez fragmenta pero por compat.
std::string tail;
uint8_t op2 = 0;
while (true) {
char h2[2];
if (!recv_n(h2, 2)) return false;
bool fin2 = (h2[0] & 0x80) != 0;
op2 = (uint8_t)(h2[0] & 0x0F);
bool masked2 = (h2[1] & 0x80) != 0;
uint64_t len2 = (uint64_t)(h2[1] & 0x7F);
if (len2 == 126) {
char lb[2];
if (!recv_n(lb, 2)) return false;
len2 = ((uint64_t)(uint8_t)lb[0] << 8) | (uint8_t)lb[1];
} else if (len2 == 127) {
char lb[8];
if (!recv_n(lb, 8)) return false;
len2 = 0;
for (int i = 0; i < 8; ++i) len2 = (len2 << 8) | (uint8_t)lb[i];
}
uint8_t m2[4] = {0,0,0,0};
if (masked2 && !recv_n((char*)m2, 4)) return false;
std::string p2((size_t)len2, '\0');
if (len2 > 0) {
if (!recv_n(p2.data(), (size_t)len2)) return false;
if (masked2) for (size_t i = 0; i < (size_t)len2; ++i) p2[i] = (char)((uint8_t)p2[i] ^ m2[i & 3]);
}
payload.append(p2);
if (fin2) break;
}
}
return true;
}
void CdpWs::reader_loop() {
while (running_.load() && !stop_.load()) {
uint8_t opcode = 0;
std::string payload;
if (!recv_frame(opcode, payload)) {
if (running_.load()) set_error("recv_frame failed");
break;
}
frames_in_.fetch_add(1);
if (opcode == 0x1) {
// Text frame. Si trae "id":N y "result|error", suelta wait_response.
// Hacemos parse parcial barato: buscar substring '"id":'. Si esta,
// parsear id y guardar.
int found_id = -1;
auto idp = payload.find("\"id\":");
if (idp != std::string::npos) {
size_t p = idp + 5;
while (p < payload.size() && (payload[p] == ' ' || payload[p] == '\t')) ++p;
int sign = 1;
if (p < payload.size() && payload[p] == '-') { sign = -1; ++p; }
int v = 0; bool any = false;
while (p < payload.size() && payload[p] >= '0' && payload[p] <= '9') {
v = v * 10 + (payload[p] - '0'); ++p; any = true;
}
if (any) found_id = sign * v;
}
if (found_id >= 0 &&
(payload.find("\"result\"") != std::string::npos ||
payload.find("\"error\"") != std::string::npos)) {
{
std::lock_guard<std::mutex> lk(resp_mu_);
responses_[found_id] = payload;
}
resp_cv_.notify_all();
}
// Empujar a la cola pase lo que pase: el panel decide si filtrar.
{
std::lock_guard<std::mutex> lk(queue_mu_);
if (queue_.size() < 100000) queue_.push(std::move(payload));
}
} else if (opcode == 0x9) {
// Ping -> pong.
std::lock_guard<std::mutex> lk(send_mu_);
uint8_t pong[6] = { 0x8A, 0x80, 0, 0, 0, 0 };
::send(sock_, (const char*)pong, 6, 0);
} else if (opcode == 0x8) {
// Close: termina.
break;
}
// opcode 0xA (pong) o binary: ignorar.
}
running_.store(false);
}
#else // !_WIN32 stubs
bool CdpWs::send_all(const char*, size_t) { return false; }
bool CdpWs::recv_n(char*, size_t) { return false; }
bool CdpWs::connect(const CdpWsConfig&, std::string* err) { if (err) *err = "Windows-only"; return false; }
void CdpWs::close() {}
bool CdpWs::send_frame_text(const std::string&) { return false; }
bool CdpWs::send_close_frame() { return false; }
bool CdpWs::recv_frame(uint8_t&, std::string&) { return false; }
void CdpWs::reader_loop() {}
#endif
int CdpWs::send_command(const std::string& method, const std::string& params_json) {
if (!running_.load()) return -1;
int id = next_id_.fetch_add(1);
std::ostringstream os;
os << "{\"id\":" << id << ",\"method\":" << json_escape_str(method);
if (!params_json.empty()) {
os << ",\"params\":" << params_json;
}
os << "}";
if (!send_frame_text(os.str())) return -1;
return id;
}
std::vector<std::string> CdpWs::drain(size_t max) {
std::vector<std::string> out;
std::lock_guard<std::mutex> lk(queue_mu_);
while (!queue_.empty() && out.size() < max) {
out.push_back(std::move(queue_.front()));
queue_.pop();
}
return out;
}
bool CdpWs::wait_response(int id, std::string& out_json, int timeout_ms) {
std::unique_lock<std::mutex> lk(resp_mu_);
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
while (true) {
auto it = responses_.find(id);
if (it != responses_.end()) {
out_json = std::move(it->second);
responses_.erase(it);
return true;
}
if (!running_.load()) return false;
if (resp_cv_.wait_until(lk, deadline) == std::cv_status::timeout) return false;
}
}
} // namespace navegator
+109
View File
@@ -0,0 +1,109 @@
#pragma once
// CDP WebSocket client minimo (RFC 6455).
//
// Conexion 1:1 con un target CDP (page/iframe/worker). Usa el url
// `webSocketDebuggerUrl` que devuelve `/json`. Solo loopback (127.0.0.1),
// asi que sin TLS y handshake simplificado.
//
// Modelo:
// - connect(): handshake HTTP upgrade. Spawn reader thread.
// - send_command(method, params_json): envia text frame {"id":N,"method":...,
// "params":...}, retorna id. No bloquea.
// - wait_response(id, out, ms): bloquea hasta que llega la respuesta con ese
// id. Para llamadas one-shot.
// - on_message_callback: se invoca por cada frame text recibido (responses
// y events). El UI thread lo drena.
#include <atomic>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
#include <unordered_map>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <winsock2.h>
#endif
namespace navegator {
struct CdpWsConfig {
std::string host = "127.0.0.1";
int port = 0;
std::string path; // ej. "/devtools/page/ABC123"
int timeout_ms = 5000;
};
class CdpWs {
public:
CdpWs() = default;
~CdpWs();
CdpWs(const CdpWs&) = delete;
CdpWs& operator=(const CdpWs&) = delete;
// Parsea ws://host:port/path. Devuelve false si no es ws://.
static bool parse_ws_url(const std::string& url, std::string& host, int& port, std::string& path);
bool connect(const CdpWsConfig& cfg, std::string* err = nullptr);
void close();
bool is_connected() const { return running_.load(); }
// Envia un comando CDP. Devuelve el id asignado o -1 si error.
int send_command(const std::string& method, const std::string& params_json = "");
// Drena la cola de mensajes recibidos. Devuelve hasta `max` y los retira.
// Llamar desde UI thread cada frame.
std::vector<std::string> drain(size_t max = 256);
// Bloquea esperando la respuesta del id dado. Devuelve false en timeout.
// Solo util para llamadas sincronas (eval, getDocument, etc.) — para el
// panel Network preferimos drain().
bool wait_response(int id, std::string& out_json, int timeout_ms);
// Estadisticas (mostrar en UI).
uint64_t bytes_in() const { return bytes_in_.load(); }
uint64_t bytes_out() const { return bytes_out_.load(); }
uint64_t frames_in() const { return frames_in_.load(); }
std::string last_error() const { std::lock_guard<std::mutex> lk(err_mu_); return last_err_; }
private:
void reader_loop();
bool send_frame_text(const std::string& payload);
bool send_close_frame();
bool recv_frame(uint8_t& opcode, std::string& payload);
bool send_all(const char* data, size_t len);
bool recv_n(char* out, size_t n);
void set_error(const std::string& e);
#ifdef _WIN32
SOCKET sock_ = INVALID_SOCKET;
#else
int sock_ = -1;
#endif
std::thread reader_;
std::atomic<bool> running_{false};
std::atomic<bool> stop_{false};
std::atomic<int> next_id_{1};
std::atomic<uint64_t> bytes_in_{0};
std::atomic<uint64_t> bytes_out_{0};
std::atomic<uint64_t> frames_in_{0};
std::mutex queue_mu_;
std::queue<std::string> queue_;
std::mutex resp_mu_;
std::condition_variable resp_cv_;
std::unordered_map<int, std::string> responses_;
std::mutex send_mu_; // serializa envio para no entrelazar frames
mutable std::mutex err_mu_;
std::string last_err_;
};
} // namespace navegator
+154
View File
@@ -5,11 +5,14 @@
#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>
@@ -219,12 +222,163 @@ Response handle_not_found(const std::string& 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);
}
+20 -3
View File
@@ -1,6 +1,6 @@
// navegator_dashboard — cuadro de mandos para gestionar instancias Chrome con CDP.
//
// v0: Browsers panel funcional + 3 stubs (Tabs, Tab Detail, Network).
// v0: Browsers panel funcional + 3 stubs (Tabs, Tab Detail, Network) + Agent (chat).
// Ver projects/navegator/apps/navegator_dashboard/app.md para arquitectura completa.
#include "app_base.h"
@@ -9,6 +9,10 @@
#include "imgui.h"
#include "local_api.h"
#include "agent.h"
#include <cstdlib>
#include <string>
namespace navegator {
void render_browsers_panel(bool* p_open);
@@ -22,12 +26,14 @@ bool show_browsers = true;
bool show_tabs = true;
bool show_tab_detail = false;
bool show_network = false;
bool show_agent = false;
constexpr fn_ui::PanelToggle k_panels[] = {
{"Browsers", "Ctrl+1", &show_browsers},
{"Tabs", "Ctrl+2", &show_tabs},
{"Tab Detail", "Ctrl+3", &show_tab_detail},
{"Network", "Ctrl+4", &show_network},
{"Agent", "Ctrl+5", &show_agent},
};
} // namespace
@@ -37,6 +43,7 @@ static void render_dashboard() {
if (show_tabs) navegator::render_tabs_panel(&show_tabs);
if (show_tab_detail) navegator::render_tab_detail_panel(&show_tab_detail);
if (show_network) navegator::render_network_panel(&show_network);
if (show_agent) app_agent::chat_render(&show_agent);
}
int main() {
@@ -44,8 +51,8 @@ int main() {
cfg.title = "Navegator Dashboard";
cfg.about = {
"Navegator Dashboard",
"0.1.0",
"Cuadro de mandos para Chrome con remote debugging — v0 Browsers panel."
"0.3.0",
"Cuadro de mandos Chrome (CDP) — Browsers + Tabs + Network DevTools-like + agente."
};
cfg.panels = k_panels;
cfg.panel_count = sizeof(k_panels) / sizeof(k_panels[0]);
@@ -55,5 +62,15 @@ int main() {
// Endpoints: /health, /browsers, /spawn, /kill — ver local_api.h.
navegator::start_api_server(19333);
// Chat agente (Claude). Inicializacion lazy del subprocess: chat_init
// detecta claude pero no spawnea hasta primer mensaje. ops_db/app_db
// estan vacios (navegator no tiene operations.db); app_dir = exe_dir
// para que chat.log se ubique junto al exe.
std::string app_dir = fn::exe_dir();
// app_db apuntando a fichero (no real) dentro de local_files/ asi el
// log_path se calcula como local_files/chat.log.
std::string fake_app_db = std::string(fn::local_dir()) + "/_chat.db";
app_agent::chat_init("", fake_app_db.c_str(), app_dir.c_str());
return fn::run_app(cfg, render_dashboard);
}
+507
View File
@@ -0,0 +1,507 @@
#include "network_state.h"
#include "crude_json.h"
#include <algorithm>
#include <cstdio>
#include <sstream>
namespace navegator {
const char* resource_type_label(ResourceType t) {
switch (t) {
case ResourceType::Document: return "doc";
case ResourceType::Stylesheet: return "css";
case ResourceType::Image: return "img";
case ResourceType::Media: return "media";
case ResourceType::Font: return "font";
case ResourceType::Script: return "js";
case ResourceType::TextTrack: return "track";
case ResourceType::XHR: return "xhr";
case ResourceType::Fetch: return "fetch";
case ResourceType::EventSource: return "eventsource";
case ResourceType::WebSocket: return "ws";
case ResourceType::Manifest: return "manifest";
case ResourceType::SignedExchange: return "sxg";
case ResourceType::Ping: return "ping";
case ResourceType::CSPViolationReport: return "csp";
case ResourceType::Preflight: return "preflight";
default: return "other";
}
}
ResourceType parse_resource_type(const std::string& s) {
if (s == "Document") return ResourceType::Document;
if (s == "Stylesheet") return ResourceType::Stylesheet;
if (s == "Image") return ResourceType::Image;
if (s == "Media") return ResourceType::Media;
if (s == "Font") return ResourceType::Font;
if (s == "Script") return ResourceType::Script;
if (s == "TextTrack") return ResourceType::TextTrack;
if (s == "XHR") return ResourceType::XHR;
if (s == "Fetch") return ResourceType::Fetch;
if (s == "EventSource") return ResourceType::EventSource;
if (s == "WebSocket") return ResourceType::WebSocket;
if (s == "Manifest") return ResourceType::Manifest;
if (s == "SignedExchange") return ResourceType::SignedExchange;
if (s == "Ping") return ResourceType::Ping;
if (s == "CSPViolationReport") return ResourceType::CSPViolationReport;
if (s == "Preflight") return ResourceType::Preflight;
return ResourceType::Other;
}
namespace {
const crude_json::value& at(const crude_json::value& v, const char* key) {
static const crude_json::value null_v;
if (!v.is_object() || !v.contains(key)) return null_v;
return v[key];
}
std::string str_or(const crude_json::value& v, const char* key, const std::string& def = "") {
const auto& f = at(v, key);
return f.is_string() ? f.get<std::string>() : def;
}
double num_or(const crude_json::value& v, const char* key, double def = 0.0) {
const auto& f = at(v, key);
return f.is_number() ? f.get<double>() : def;
}
bool bool_or(const crude_json::value& v, const char* key, bool def = false) {
const auto& f = at(v, key);
return f.is_boolean() ? f.get<bool>() : def;
}
void parse_headers(const crude_json::value& obj, std::vector<HeaderKV>& out) {
out.clear();
if (!obj.is_object()) return;
const auto& m = obj.get<crude_json::object>();
out.reserve(m.size());
for (const auto& kv : m) {
HeaderKV h;
h.name = kv.first;
if (kv.second.is_string()) h.value = kv.second.get<std::string>();
else if (kv.second.is_number()) {
char b[32];
std::snprintf(b, sizeof(b), "%g", kv.second.get<double>());
h.value = b;
}
out.push_back(std::move(h));
}
}
std::string json_escape_str(const std::string& s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('"');
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char b[8];
std::snprintf(b, sizeof(b), "\\u%04x", (unsigned)(uint8_t)c);
out += b;
} else out.push_back(c);
}
}
out.push_back('"');
return out;
}
} // namespace
NetworkSession::~NetworkSession() { close(); }
bool NetworkSession::open(const std::string& ws_url, std::string* err) {
close();
std::string host, path;
int port = 0;
if (!CdpWs::parse_ws_url(ws_url, host, port, path)) {
last_err_ = "bad ws url: " + ws_url;
if (err) *err = last_err_;
return false;
}
ws_url_ = ws_url;
ws_ = std::make_unique<CdpWs>();
CdpWsConfig cfg;
cfg.host = host;
cfg.port = port;
cfg.path = path;
std::string e;
if (!ws_->connect(cfg, &e)) {
last_err_ = "ws connect failed: " + e;
if (err) *err = last_err_;
ws_.reset();
return false;
}
// Habilitar dominios.
ws_->send_command("Network.enable",
"{\"maxTotalBufferSize\":10000000,\"maxResourceBufferSize\":5000000,\"maxPostDataSize\":65536}");
ws_->send_command("Page.enable");
ws_->send_command("Runtime.enable");
if (cache_disabled_.load()) {
ws_->send_command("Network.setCacheDisabled", "{\"cacheDisabled\":true}");
}
{
std::lock_guard<std::mutex> lk(mu_);
clear_log_locked();
t0_ = std::chrono::steady_clock::now();
}
return true;
}
void NetworkSession::close() {
if (ws_) {
ws_->close();
ws_.reset();
}
ws_url_.clear();
}
void NetworkSession::clear_log() {
std::lock_guard<std::mutex> lk(mu_);
clear_log_locked();
}
void NetworkSession::clear_log_locked() {
requests_.clear();
by_id_.clear();
stats_ = {};
}
void NetworkSession::set_cache_disabled(bool v) {
cache_disabled_.store(v);
if (ws_ && ws_->is_connected()) {
std::ostringstream os;
os << "{\"cacheDisabled\":" << (v ? "true" : "false") << "}";
ws_->send_command("Network.setCacheDisabled", os.str());
}
}
void NetworkSession::pump() {
if (!ws_) return;
auto msgs = ws_->drain(2048);
for (auto& m : msgs) on_message(m);
}
void NetworkSession::request_body(const std::string& request_id) {
if (!ws_ || request_id.empty()) return;
std::shared_ptr<NetworkRequest> req;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(request_id);
if (it == by_id_.end()) return;
req = it->second;
if (req->body_fetched) return;
req->body_fetched = true; // optimistic; on response set text
}
std::string params = "{\"requestId\":" + json_escape_str(request_id) + "}";
ws_->send_command("Network.getResponseBody", params);
}
void NetworkSession::on_message(const std::string& json) {
crude_json::value v = crude_json::value::parse(json);
if (!v.is_object()) return;
// Si trae result + id => respuesta a un comando enviado por nosotros.
if (v.contains("id") && v.contains("result")) {
const auto& result = v["result"];
if (result.is_object() && result.contains("body")) {
// Network.getResponseBody. Necesitamos id->requestId map. Lo
// hacemos buscando el primer request sin body fetched. Mejor:
// CDP no devuelve requestId aqui, asi que adoptamos heuristica:
// marcamos en request_body() un campo pending y lo ataremos al
// siguiente result. Para v1 nos conformamos con dejar el body
// fuera hasta que matcheemos manualmente.
// (Limitacion conocida — mejora futura: enviar getResponseBody
// con sus propios ids y guardar id->requestId.)
}
return;
}
if (!v.contains("method")) return;
const std::string method = v["method"].is_string() ? v["method"].get<std::string>() : "";
const auto& params = at(v, "params");
if (!params.is_object()) return;
const double now_secs =
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0_).count();
if (method == "Network.requestWillBeSent") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::shared_ptr<NetworkRequest> req;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) {
req = std::make_shared<NetworkRequest>();
req->id = rid;
req->t_started = now_secs;
requests_.push_back(req);
by_id_[rid] = req;
stats_.total_requests++;
} else {
req = it->second;
}
}
req->loader_id = str_or(params, "loaderId");
req->frame_id = str_or(params, "frameId");
req->document_url = str_or(params, "documentURL");
req->type = parse_resource_type(str_or(params, "type"));
req->ts_request_will_be_sent = num_or(params, "timestamp");
const auto& reqv = at(params, "request");
if (reqv.is_object()) {
req->url = str_or(reqv, "url");
req->url_fragment = str_or(reqv, "urlFragment");
req->method = str_or(reqv, "method");
req->post_data = str_or(reqv, "postData");
req->has_post_data = bool_or(reqv, "hasPostData") || !req->post_data.empty();
parse_headers(at(reqv, "headers"), req->request_headers);
}
const auto& init = at(params, "initiator");
if (init.is_object()) {
req->initiator_type = str_or(init, "type");
req->initiator_url = str_or(init, "url");
req->initiator_line = (int)num_or(init, "lineNumber", -1);
}
// Page.frameNavigated => limpiar log si !preserve_log. Hecho mas abajo.
}
else if (method == "Network.requestWillBeSentExtraInfo") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
parse_headers(at(params, "headers"), it->second->request_headers);
}
else if (method == "Network.responseReceived") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::shared_ptr<NetworkRequest> req;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
req = it->second;
}
req->ts_response_received = num_or(params, "timestamp");
req->t_response = now_secs;
const auto& resp = at(params, "response");
if (resp.is_object()) {
req->status = (int)num_or(resp, "status");
req->status_text = str_or(resp, "statusText");
req->mime_type = str_or(resp, "mimeType");
req->remote_ip = str_or(resp, "remoteIPAddress");
req->remote_port = (int)num_or(resp, "remotePort");
req->protocol = str_or(resp, "protocol");
req->from_cache = bool_or(resp, "fromDiskCache") || bool_or(resp, "fromPrefetchCache");
req->from_disk_cache = bool_or(resp, "fromDiskCache");
req->from_service_worker= bool_or(resp, "fromServiceWorker");
parse_headers(at(resp, "headers"), req->response_headers);
req->encoded_data_length = (int64_t)num_or(resp, "encodedDataLength", req->encoded_data_length);
}
}
else if (method == "Network.responseReceivedExtraInfo") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
parse_headers(at(params, "headers"), it->second->response_headers);
if (params.contains("statusCode")) it->second->status = (int)num_or(params, "statusCode");
}
else if (method == "Network.dataReceived") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
int64_t enc = (int64_t)num_or(params, "encodedDataLength");
int64_t dat = (int64_t)num_or(params, "dataLength");
it->second->encoded_data_length += enc;
it->second->data_received_bytes += dat;
stats_.transferred += enc;
}
else if (method == "Network.loadingFinished") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
auto& r = it->second;
r->finished = true;
r->ts_loading_finished = num_or(params, "timestamp");
r->t_finished = now_secs;
int64_t total_enc = (int64_t)num_or(params, "encodedDataLength");
if (total_enc > r->encoded_data_length) {
stats_.transferred += (total_enc - r->encoded_data_length);
r->encoded_data_length = total_enc;
}
r->response_body_length = r->data_received_bytes;
stats_.resources += r->response_body_length;
stats_.finish_time = now_secs;
}
else if (method == "Network.loadingFailed") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
auto& r = it->second;
r->failed = true;
r->canceled = bool_or(params, "canceled");
r->error_text = str_or(params, "errorText");
r->ts_loading_failed = num_or(params, "timestamp");
r->t_finished = now_secs;
}
else if (method == "Network.webSocketCreated") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
std::shared_ptr<NetworkRequest> req;
if (it == by_id_.end()) {
req = std::make_shared<NetworkRequest>();
req->id = rid;
req->t_started = now_secs;
req->type = ResourceType::WebSocket;
req->url = str_or(params, "url");
requests_.push_back(req);
by_id_[rid] = req;
stats_.total_requests++;
} else {
req = it->second;
req->type = ResourceType::WebSocket;
if (req->url.empty()) req->url = str_or(params, "url");
}
}
else if (method == "Network.webSocketFrameSent" || method == "Network.webSocketFrameReceived") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
WsFrame f;
f.outgoing = (method == "Network.webSocketFrameSent");
f.time = num_or(params, "timestamp");
const auto& resp = at(params, "response");
if (resp.is_object()) {
f.opcode = (int)num_or(resp, "opcode");
f.masked = (int)bool_or(resp, "mask");
f.payload = str_or(resp, "payloadData");
}
if (it->second->ws_frames.size() < 2000) {
it->second->ws_frames.push_back(std::move(f));
}
}
else if (method == "Network.webSocketClosed") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it != by_id_.end()) {
it->second->finished = true;
it->second->t_finished = now_secs;
}
}
else if (method == "Page.frameNavigated") {
if (!preserve_log_.load()) {
const auto& frame = at(params, "frame");
// Solo limpiar si es el frame top-level (sin parentId).
if (frame.is_object() && !frame.contains("parentId")) {
std::lock_guard<std::mutex> lk(mu_);
clear_log_locked();
t0_ = std::chrono::steady_clock::now();
}
}
}
else if (method == "Page.domContentEventFired") {
std::lock_guard<std::mutex> lk(mu_);
stats_.dom_content_loaded = now_secs;
}
else if (method == "Page.loadEventFired") {
std::lock_guard<std::mutex> lk(mu_);
stats_.load_event = now_secs;
}
}
std::vector<std::shared_ptr<NetworkRequest>> NetworkSession::snapshot() const {
std::lock_guard<std::mutex> lk(mu_);
return requests_;
}
NetworkStats NetworkSession::stats() const {
std::lock_guard<std::mutex> lk(mu_);
return stats_;
}
std::string NetworkSession::export_har_json() const {
// HAR 1.2 minimo. log.entries[].request/response/timings.
std::lock_guard<std::mutex> lk(mu_);
std::ostringstream os;
os << "{\"log\":{\"version\":\"1.2\",\"creator\":{\"name\":\"navegator_dashboard\",\"version\":\"1.0\"},\"entries\":[";
bool first = true;
for (const auto& r : requests_) {
if (!first) os << ",";
first = false;
os << "{";
os << "\"startedDateTime\":\"" << r->t_started << "\",";
os << "\"time\":" << ((r->t_finished > 0 ? r->t_finished : r->t_response) - r->t_started) * 1000.0 << ",";
// request
os << "\"request\":{\"method\":" << json_escape_str(r->method)
<< ",\"url\":" << json_escape_str(r->url)
<< ",\"httpVersion\":" << json_escape_str(r->protocol)
<< ",\"headers\":[";
bool fh = true;
for (const auto& h : r->request_headers) {
if (!fh) os << ",";
fh = false;
os << "{\"name\":" << json_escape_str(h.name) << ",\"value\":" << json_escape_str(h.value) << "}";
}
os << "],\"queryString\":[],\"cookies\":[],\"headersSize\":-1,\"bodySize\":";
os << (r->has_post_data ? (int64_t)r->post_data.size() : (int64_t)0);
if (r->has_post_data) {
os << ",\"postData\":{\"mimeType\":\"\",\"text\":" << json_escape_str(r->post_data) << "}";
}
os << "},";
// response
os << "\"response\":{\"status\":" << r->status
<< ",\"statusText\":" << json_escape_str(r->status_text)
<< ",\"httpVersion\":" << json_escape_str(r->protocol)
<< ",\"headers\":[";
bool fr = true;
for (const auto& h : r->response_headers) {
if (!fr) os << ",";
fr = false;
os << "{\"name\":" << json_escape_str(h.name) << ",\"value\":" << json_escape_str(h.value) << "}";
}
os << "],\"cookies\":[],\"content\":{\"size\":" << r->response_body_length
<< ",\"mimeType\":" << json_escape_str(r->mime_type) << "},"
<< "\"redirectURL\":\"\",\"headersSize\":-1,\"bodySize\":" << r->encoded_data_length << "},";
os << "\"cache\":{},\"timings\":{\"send\":-1,\"wait\":-1,\"receive\":-1},";
os << "\"_resourceType\":" << json_escape_str(resource_type_label(r->type));
os << ",\"_initiator\":" << json_escape_str(r->initiator_type)
<< ",\"_initiatorUrl\":" << json_escape_str(r->initiator_url);
if (r->failed) {
os << ",\"_failed\":true,\"_errorText\":" << json_escape_str(r->error_text);
}
os << "}";
}
os << "]}}";
return os.str();
}
} // namespace navegator
+176
View File
@@ -0,0 +1,176 @@
#pragma once
// Estado del panel Network — log de peticiones HTTP/WS por sesion CDP.
//
// Una NetworkSession por (port + tab_id) activa. Mantiene WebSocket vivo,
// drena eventos en background, los procesa en el UI thread cuando se llama
// `pump()`, y los guarda en `requests` para mostrar en la tabla.
//
// Eventos consumidos:
// Network.requestWillBeSent -> crea/actualiza request
// Network.requestWillBeSentExtraInfo -> headers crudos request
// Network.responseReceived -> response headers, status, mime, type
// Network.responseReceivedExtraInfo -> headers crudos response
// Network.dataReceived -> bytes recibidos (acumulado)
// Network.loadingFinished -> request terminada OK + tamaño total
// Network.loadingFailed -> request fallo + errorText
// Network.webSocketCreated -> nueva conexion WS
// Network.webSocketFrameSent -> frame WS saliente
// Network.webSocketFrameReceived -> frame WS entrante
// Network.webSocketClosed -> WS cerrado
// Page.frameNavigated -> permite limpiar log si "preserve_log" off
#include "cdp_ws.h"
#include <chrono>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace navegator {
enum class ResourceType {
Document, Stylesheet, Image, Media, Font, Script,
TextTrack, XHR, Fetch, EventSource, WebSocket, Manifest,
SignedExchange, Ping, CSPViolationReport, Preflight, Other
};
const char* resource_type_label(ResourceType t);
ResourceType parse_resource_type(const std::string& s);
struct HeaderKV {
std::string name;
std::string value;
};
struct WsFrame {
bool outgoing = false;
int opcode = 1; // 1=text, 2=binary
double time = 0.0; // wall clock seconds
std::string payload; // text or "(binary N bytes)"
int masked = 0;
};
struct NetworkRequest {
std::string id; // requestId
std::string loader_id;
std::string frame_id;
std::string document_url;
std::string url;
std::string url_fragment;
std::string method;
ResourceType type = ResourceType::Other;
int status = 0;
std::string status_text;
std::string mime_type;
std::string remote_ip;
int remote_port = 0;
std::string protocol;
std::string initiator_type; // parser, script, preload, ...
std::string initiator_url;
int initiator_line = 0;
std::vector<HeaderKV> request_headers;
std::vector<HeaderKV> response_headers;
std::string post_data;
bool has_post_data = false;
bool from_cache = false;
bool from_disk_cache = false;
bool from_service_worker = false;
int64_t encoded_data_length = 0; // bytes on the wire
int64_t data_received_bytes = 0; // sum of dataReceived
int64_t response_body_length = 0;
bool finished = false;
bool failed = false;
std::string error_text;
bool canceled = false;
// Timestamps en CDP "MonotonicTime" (segundos desde un origen arbitrario).
double ts_request_will_be_sent = 0.0;
double ts_response_received = 0.0;
double ts_loading_finished = 0.0;
double ts_loading_failed = 0.0;
// Wallclock cuando se vio cada cosa (steady_clock seconds since session start).
double t_started = 0.0;
double t_response = 0.0;
double t_finished = 0.0;
// Lazy-fetched response body (Network.getResponseBody). Vacio si no se ha pedido.
bool body_fetched = false;
bool body_base64 = false;
std::string body_text;
// WS frames si type == WebSocket
std::vector<WsFrame> ws_frames;
};
struct NetworkStats {
int total_requests = 0;
int64_t transferred = 0; // suma encoded_data_length
int64_t resources = 0; // suma response_body_length
double finish_time = 0.0; // ultimo t_finished
double dom_content_loaded = -1; // Page.domContentLoaded eventFired
double load_event = -1; // Page.loadEventFired
};
class NetworkSession {
public:
NetworkSession() = default;
~NetworkSession();
// Abre WS a `ws_url` y emite Network.enable + Page.enable. Devuelve false si fallo.
bool open(const std::string& ws_url, std::string* err = nullptr);
void close();
bool is_open() const { return ws_ && ws_->is_connected(); }
const std::string& ws_url() const { return ws_url_; }
const std::string& last_error() const { return last_err_; }
// Drena eventos del WS y actualiza el estado interno. Llamar cada frame UI.
void pump();
// Reset log preservando socket (Clear button con preserve_log=false).
void clear_log();
// Pide cuerpo de respuesta para un request (Network.getResponseBody). Async:
// cuando llega lo actualiza en NetworkRequest. Idempotente.
void request_body(const std::string& request_id);
// Toggle: al navegar limpia o no.
void set_preserve_log(bool v) { preserve_log_.store(v); }
bool preserve_log() const { return preserve_log_.load(); }
// Toggle: Network.setCacheDisabled.
void set_cache_disabled(bool v);
bool cache_disabled() const { return cache_disabled_.load(); }
// Lectura del log (UI thread). Devuelve copia-snapshot (vector de punteros
// estables porque almacenamos shared_ptr).
std::vector<std::shared_ptr<NetworkRequest>> snapshot() const;
NetworkStats stats() const;
// Drag-and-drop import/export HAR.
std::string export_har_json() const;
private:
std::unique_ptr<CdpWs> ws_;
std::string ws_url_;
std::string last_err_;
std::atomic<bool> preserve_log_{true};
std::atomic<bool> cache_disabled_{false};
mutable std::mutex mu_;
std::vector<std::shared_ptr<NetworkRequest>> requests_;
std::unordered_map<std::string, std::shared_ptr<NetworkRequest>> by_id_;
NetworkStats stats_;
std::chrono::steady_clock::time_point t0_ = std::chrono::steady_clock::now();
// Procesa una linea JSON entrante.
void on_message(const std::string& json);
// Reset clear_log() implementacion privada.
void clear_log_locked();
};
} // namespace navegator
+800 -53
View File
@@ -1,6 +1,10 @@
// Panels v0:
// - Browsers: scan + spawn + kill (funcional)
// - Tabs / Tab Detail / Network: stubs anunciando v1.
// Panels v1+v2:
// - Browsers : scan + spawn + kill + seleccion (click selecciona instancia)
// - Tabs : lista pestañas via CDP HTTP /json + Focus/Close/New/Select
// - Tab Detail : Runtime.evaluate REPL minimo (placeholder upgradeable)
// - Network : panel DevTools-like — tabla + filtros + detalle por tab
//
// Estado cross-panel via g_session() (session_state.h).
#include "imgui.h"
#include "core/icons_tabler.h"
@@ -9,14 +13,22 @@
#include "chrome_scanner.h"
#include "chrome_launcher.h"
#include "local_api.h"
#include "cdp_http.h"
#include "session_state.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <map>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <ctime>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
@@ -25,7 +37,9 @@
namespace navegator {
// ---------- Estado compartido del panel Browsers ----------
// ===========================================================================
// Browsers panel
// ===========================================================================
namespace {
struct BrowsersState {
@@ -34,9 +48,8 @@ struct BrowsersState {
std::chrono::steady_clock::time_point last_scan;
std::atomic<bool> scanning{false};
std::atomic<bool> ever_scanned{false};
std::atomic<int> selected{-1};
char new_profile[128] = "default";
int new_port = 19222;
int new_port = 19222;
bool new_headless = false;
std::string last_error;
};
@@ -58,7 +71,6 @@ void rescan_async() {
}
std::string default_user_data_dir(const std::string& profile) {
// Resolver USERPROFILE si esta disponible.
#ifdef _WIN32
char buf[MAX_PATH] = {0};
DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf));
@@ -69,9 +81,7 @@ std::string default_user_data_dir(const std::string& profile) {
#endif
}
} // namespace
// ---------- Browsers panel ----------
} // anon
void render_browsers_panel(bool* p_open) {
if (!ImGui::Begin(TI_BROWSER " Browsers", p_open)) {
@@ -79,7 +89,6 @@ void render_browsers_panel(bool* p_open) {
return;
}
// Auto-rescan cada 2s.
auto now = std::chrono::steady_clock::now();
{
std::lock_guard<std::mutex> lk(g_browsers.mu);
@@ -88,26 +97,19 @@ void render_browsers_panel(bool* p_open) {
}
}
// API status badge.
if (g_api_running.load()) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::success);
ImGui::Text("API: 127.0.0.1:%d", g_api_port.load());
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("(reqs: %d)", g_api_request_count.load());
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
} else {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextUnformatted("API: down");
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
ImGui::Separator();
// Toolbar.
if (ImGui::Button(TI_REFRESH " Rescan")) rescan_async();
ImGui::SameLine();
ImGui::TextDisabled("|");
@@ -126,14 +128,13 @@ void render_browsers_panel(bool* p_open) {
ImGui::SameLine();
if (ImGui::Button(TI_PLAYER_PLAY " Launch")) {
LaunchOpts o;
o.port = g_browsers.new_port;
o.port = g_browsers.new_port;
o.headless = g_browsers.new_headless;
std::string profile = g_browsers.new_profile;
if (profile.empty()) profile = "default";
o.user_data_dir = default_user_data_dir(profile);
auto r = launch_chrome(o);
g_browsers.last_error = r.ok ? "" : r.error;
// Forzar rescan inmediato (con pequeño delay para que Chrome aparezca en CIM).
std::thread([]{
std::this_thread::sleep_for(std::chrono::milliseconds(800));
rescan_async();
@@ -158,12 +159,13 @@ void render_browsers_panel(bool* p_open) {
ImGui::Separator();
// Tabla.
int sel_port = 0;
{
std::lock_guard<std::mutex> lk(g_session().mu);
sel_port = g_session().selected_port;
}
std::lock_guard<std::mutex> lk(g_browsers.mu);
// Anti-flicker: solo mostrar "Scanning..." en el primer scan (cuando aun
// no tenemos datos). Una vez tenemos al menos un resultado, mantener el
// empty-state estable; el badge "(reqs:N)" + el rescan async siguen
// corriendo en background sin tocar la UI.
if (!g_browsers.ever_scanned.load() && g_browsers.instances.empty()) {
ImGui::TextUnformatted("Scanning...");
} else if (g_browsers.instances.empty()) {
@@ -173,18 +175,30 @@ void render_browsers_panel(bool* p_open) {
const ImGuiTableFlags flags =
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_Sortable;
if (ImGui::BeginTable("##browsers", 6, flags)) {
ImGui::TableSetupColumn("PID", ImGuiTableColumnFlags_WidthFixed, 64);
ImGui::TableSetupColumn("Port", ImGuiTableColumnFlags_WidthFixed, 64);
if (ImGui::BeginTable("##browsers", 7, flags)) {
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 22);
ImGui::TableSetupColumn("PID", ImGuiTableColumnFlags_WidthFixed, 64);
ImGui::TableSetupColumn("Port", ImGuiTableColumnFlags_WidthFixed, 64);
ImGui::TableSetupColumn("Profile");
ImGui::TableSetupColumn("Mode", ImGuiTableColumnFlags_WidthFixed, 80);
ImGui::TableSetupColumn("Mode", ImGuiTableColumnFlags_WidthFixed, 80);
ImGui::TableSetupColumn("user-data-dir");
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 110);
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 130);
ImGui::TableHeadersRow();
int idx = 0;
for (const auto& inst : g_browsers.instances) {
ImGui::TableNextRow();
ImGui::PushID(idx);
ImGui::TableNextColumn();
bool is_sel = (sel_port == inst.port);
if (is_sel) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::primary);
ImGui::TextUnformatted(TI_CHECK);
ImGui::PopStyleColor();
} else {
ImGui::TextUnformatted("");
}
ImGui::TableNextColumn();
ImGui::Text("%u", inst.pid);
ImGui::TableNextColumn();
@@ -202,20 +216,20 @@ void render_browsers_panel(bool* p_open) {
ImGui::TableNextColumn();
ImGui::TextUnformatted(inst.user_data_dir.c_str());
ImGui::TableNextColumn();
ImGui::PushID(idx);
if (ImGui::SmallButton(is_sel ? "Selected" : "Select")) {
g_session().select_browser(inst.port);
}
ImGui::SameLine();
if (ImGui::SmallButton("Kill")) {
if (!inst.user_data_dir.empty()) {
kill_chromes_by_userdata(inst.user_data_dir);
}
if (sel_port == inst.port) g_session().clear_selection();
std::thread([]{
std::this_thread::sleep_for(std::chrono::milliseconds(500));
rescan_async();
}).detach();
}
ImGui::SameLine();
if (ImGui::SmallButton("Inspect")) {
g_browsers.selected = idx;
}
ImGui::PopID();
++idx;
}
@@ -225,39 +239,772 @@ void render_browsers_panel(bool* p_open) {
ImGui::End();
}
// ---------- Tabs panel (stub) ----------
// ===========================================================================
// Tabs panel
// ===========================================================================
namespace {
struct TabsUiState {
std::atomic<bool> refreshing{false};
char new_url_input[1024] = "https://example.com";
char filter[128] = "";
};
TabsUiState g_tabs_ui;
void refresh_tabs_async(int port) {
if (port <= 0) return;
if (g_tabs_ui.refreshing.exchange(true)) return;
std::thread([port]{
std::vector<CdpTab> v;
std::string err;
bool ok = cdp_list_tabs(port, v, &err);
{
std::lock_guard<std::mutex> lk(g_session().mu);
if (g_session().selected_port == port) {
g_session().tabs = std::move(v);
g_session().tabs_error = ok ? "" : err;
g_session().last_tabs_refresh = std::chrono::steady_clock::now();
}
}
g_tabs_ui.refreshing.store(false);
}).detach();
}
} // anon
void render_tabs_panel(bool* p_open) {
if (!ImGui::Begin(TI_LIST " Tabs", p_open)) {
ImGui::End();
return;
}
ImGui::TextDisabled("Coming in v1");
ImGui::TextWrapped("Listara las pestañas de la instancia seleccionada en Browsers via "
"CDP /json y permitira navigate/close/focus.");
int port = 0;
std::string sel_tab_id;
{
std::lock_guard<std::mutex> lk(g_session().mu);
port = g_session().selected_port;
sel_tab_id = g_session().selected_tab_id;
}
if (port <= 0) {
ImGui::TextDisabled("Select a browser in the Browsers panel.");
ImGui::End();
return;
}
auto now = std::chrono::steady_clock::now();
{
std::lock_guard<std::mutex> lk(g_session().mu);
if (now - g_session().last_tabs_refresh > std::chrono::seconds(2) &&
!g_tabs_ui.refreshing.load()) {
refresh_tabs_async(port);
}
}
ImGui::Text("Browser :%d", port);
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
if (ImGui::Button(TI_REFRESH " Refresh")) refresh_tabs_async(port);
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::SetNextItemWidth(280);
ImGui::InputTextWithHint("##new_url", "https://...", g_tabs_ui.new_url_input, sizeof(g_tabs_ui.new_url_input));
ImGui::SameLine();
if (ImGui::Button(TI_PLUS " New tab")) {
std::string url = g_tabs_ui.new_url_input;
std::thread([port, url]{
CdpTab t; std::string err;
cdp_new_tab(port, url, t, &err);
refresh_tabs_async(port);
}).detach();
}
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::SetNextItemWidth(200);
ImGui::InputTextWithHint("##filter", "filter title/url", g_tabs_ui.filter, sizeof(g_tabs_ui.filter));
{
std::lock_guard<std::mutex> lk(g_session().mu);
if (!g_session().tabs_error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextWrapped("Error: %s", g_session().tabs_error.c_str());
ImGui::PopStyleColor();
}
}
ImGui::Separator();
std::vector<CdpTab> tabs_copy;
{
std::lock_guard<std::mutex> lk(g_session().mu);
tabs_copy = g_session().tabs;
}
if (tabs_copy.empty()) {
ImGui::TextDisabled("No tabs (or CDP HTTP not reachable on :%d).", port);
ImGui::End();
return;
}
std::string filter_str = g_tabs_ui.filter;
std::transform(filter_str.begin(), filter_str.end(), filter_str.begin(), ::tolower);
const ImGuiTableFlags flags =
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY;
if (ImGui::BeginTable("##tabs", 6, flags, ImVec2(0, 0))) {
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 22);
ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 70);
ImGui::TableSetupColumn("Title");
ImGui::TableSetupColumn("URL");
ImGui::TableSetupColumn("Att.", ImGuiTableColumnFlags_WidthFixed, 40);
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 200);
ImGui::TableHeadersRow();
int idx = 0;
for (const auto& t : tabs_copy) {
if (!filter_str.empty()) {
std::string lt = t.title; std::transform(lt.begin(), lt.end(), lt.begin(), ::tolower);
std::string lu = t.url; std::transform(lu.begin(), lu.end(), lu.begin(), ::tolower);
if (lt.find(filter_str) == std::string::npos &&
lu.find(filter_str) == std::string::npos) continue;
}
bool is_sel = (sel_tab_id == t.id);
ImGui::TableNextRow();
ImGui::PushID(idx);
ImGui::TableNextColumn();
if (is_sel) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::primary);
ImGui::TextUnformatted(TI_CHECK);
ImGui::PopStyleColor();
}
ImGui::TableNextColumn();
ImGui::TextUnformatted(t.type.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(t.title.empty() ? "(no title)" : t.title.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(t.url.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(t.attached ? "yes" : "");
ImGui::TableNextColumn();
if (ImGui::SmallButton("Select")) {
if (!t.ws_url.empty()) g_session().select_tab(t.id, t.ws_url);
}
ImGui::SameLine();
if (ImGui::SmallButton("Focus")) {
std::string id = t.id;
std::thread([port, id]{
cdp_activate_tab(port, id, nullptr);
refresh_tabs_async(port);
}).detach();
}
ImGui::SameLine();
if (ImGui::SmallButton("Close")) {
std::string id = t.id;
std::thread([port, id]{
cdp_close_tab(port, id, nullptr);
refresh_tabs_async(port);
}).detach();
if (is_sel) g_session().select_tab("", "");
}
ImGui::PopID();
++idx;
}
ImGui::EndTable();
}
ImGui::End();
}
// ---------- Tab Detail panel (stub) ----------
// ===========================================================================
// Tab Detail panel (placeholder funcional)
// ===========================================================================
namespace {
struct TabDetailUiState {
char repl_input[4096] = "1+1";
std::string repl_output;
std::mutex mu;
};
TabDetailUiState g_tab_detail_ui;
void tab_detail_eval_async(const std::string& expr) {
NetworkSession* net = nullptr;
{
std::lock_guard<std::mutex> lk(g_session().mu);
net = g_session().net.get();
if (!net) return;
}
// No tenemos acceso directo al CdpWs desde NetworkSession publicamente.
// Para v1.5 — Tab Detail dedicado abrira su propio CdpWs. De momento,
// el panel solo muestra info estatica + tip.
(void)expr;
}
} // anon
void render_tab_detail_panel(bool* p_open) {
if (!ImGui::Begin(TI_FILE_INFO " Tab Detail", p_open)) {
ImGui::End();
return;
}
ImGui::TextDisabled("Coming in v1");
ImGui::TextWrapped("HTML preview, screenshot live y REPL Runtime.evaluate sobre la pestaña "
"seleccionada.");
ImGui::End();
}
// ---------- Network panel (stub) ----------
void render_network_panel(bool* p_open) {
if (!ImGui::Begin(TI_ACTIVITY " Network", p_open)) {
std::string sel_id, sel_ws;
int port = 0;
{
std::lock_guard<std::mutex> lk(g_session().mu);
port = g_session().selected_port;
sel_id = g_session().selected_tab_id;
sel_ws = g_session().selected_tab_ws_url;
}
if (sel_id.empty()) {
ImGui::TextDisabled("Select a tab in the Tabs panel.");
ImGui::End();
return;
}
ImGui::TextDisabled("Coming in v1");
ImGui::TextWrapped("Log de peticiones HTTP/WS en vivo via CDP Network.* events. "
"Headers, body, timing, filtros.");
ImGui::Text("Browser :%d", port);
ImGui::Text("Tab id %s", sel_id.c_str());
ImGui::TextWrapped("WS %s", sel_ws.c_str());
ImGui::Separator();
ImGui::TextWrapped(
"Tab Detail (HTML preview + screenshot + Runtime.evaluate REPL) llega "
"en v1.5 (issue 0003). El WebSocket esta vivo via Network panel — el "
"REPL re-utilizara la misma conexion en una proxima iteracion.");
ImGui::End();
}
// ===========================================================================
// Network panel (DevTools-like)
// ===========================================================================
namespace {
// Filtros chips (tipo recurso). Bitmask sobre ResourceType.
struct NetUiState {
char filter_text[256] = "";
bool invert_filter = false;
bool hide_data_urls = true;
bool only_blocked = false;
// chips mask. true = mostrar este tipo. start: All on.
bool type_doc = true;
bool type_css = true;
bool type_js = true;
bool type_xhr = true; // XHR + Fetch
bool type_img = true;
bool type_media = true;
bool type_font = true;
bool type_ws = true;
bool type_other = true;
bool all_types = true;
bool paused = false;
int selected_index = -1; // index en snapshot filtrado
std::string selected_id; // requestId estable
int detail_tab = 0; // 0 headers, 1 payload, 2 response, 3 cookies, 4 timing, 5 ws
};
NetUiState g_net_ui;
bool type_passes(const NetUiState& s, ResourceType t) {
if (s.all_types) return true;
switch (t) {
case ResourceType::Document: return s.type_doc;
case ResourceType::Stylesheet: return s.type_css;
case ResourceType::Script: return s.type_js;
case ResourceType::Image: return s.type_img;
case ResourceType::Media: return s.type_media;
case ResourceType::Font: return s.type_font;
case ResourceType::XHR:
case ResourceType::Fetch: return s.type_xhr;
case ResourceType::WebSocket:
case ResourceType::EventSource: return s.type_ws;
default: return s.type_other;
}
}
ImVec4 status_color(int status) {
if (status == 0) return fn_tokens::colors::text_muted;
if (status >= 500) return fn_tokens::colors::error;
if (status >= 400) return fn_tokens::colors::warning;
if (status >= 300) return fn_tokens::colors::info;
return fn_tokens::colors::success;
}
std::string short_name_from_url(const std::string& url) {
if (url.empty()) return "";
size_t scheme_end = url.find("://");
size_t path_start = (scheme_end == std::string::npos) ? 0 : scheme_end + 3;
size_t qmark = url.find('?', path_start);
std::string path = url.substr(path_start, qmark == std::string::npos ? std::string::npos : qmark - path_start);
size_t slash = path.find('/');
if (slash == std::string::npos) return path;
std::string after = path.substr(slash);
size_t last = after.find_last_of('/');
if (last == std::string::npos || last == after.size() - 1) {
// ends with "/" -> use host
return path.substr(0, slash);
}
return after.substr(last + 1);
}
std::string fmt_size(int64_t b) {
char buf[64];
if (b < 1024) std::snprintf(buf, sizeof(buf), "%lld B", (long long)b);
else if (b < 1024 * 1024) std::snprintf(buf, sizeof(buf), "%.1f kB", b / 1024.0);
else std::snprintf(buf, sizeof(buf), "%.2f MB", b / (1024.0 * 1024.0));
return buf;
}
std::string fmt_dur_ms(double s) {
char buf[32];
if (s <= 0) return "";
if (s < 1.0) std::snprintf(buf, sizeof(buf), "%.0f ms", s * 1000.0);
else std::snprintf(buf, sizeof(buf), "%.2f s", s);
return buf;
}
void copy_to_clipboard(const std::string& s) {
ImGui::SetClipboardText(s.c_str());
}
std::string build_curl(const NetworkRequest& r) {
std::ostringstream os;
os << "curl -X " << (r.method.empty() ? "GET" : r.method) << " '" << r.url << "'";
for (const auto& h : r.request_headers) {
if (h.name.size() >= 1 && h.name[0] == ':') continue; // pseudo h2
os << " -H '" << h.name << ": " << h.value << "'";
}
if (r.has_post_data && !r.post_data.empty()) {
os << " --data-raw '" << r.post_data << "'";
}
return os.str();
}
std::string build_fetch(const NetworkRequest& r) {
std::ostringstream os;
os << "fetch('" << r.url << "', { method: '" << (r.method.empty() ? "GET" : r.method) << "', headers: {";
bool first = true;
for (const auto& h : r.request_headers) {
if (h.name.size() >= 1 && h.name[0] == ':') continue;
if (!first) os << ", ";
first = false;
os << "'" << h.name << "': '" << h.value << "'";
}
os << "}";
if (r.has_post_data && !r.post_data.empty()) {
os << ", body: '" << r.post_data << "'";
}
os << "})";
return os.str();
}
void draw_filter_chips() {
auto chip = [](const char* label, bool* state, ImVec4 color) {
if (*state) {
ImGui::PushStyleColor(ImGuiCol_Button, color);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, color);
}
if (ImGui::SmallButton(label)) *state = !*state;
if (*state) ImGui::PopStyleColor(2);
ImGui::SameLine();
};
if (ImGui::SmallButton(g_net_ui.all_types ? "All*" : "All")) {
g_net_ui.all_types = !g_net_ui.all_types;
}
ImGui::SameLine();
ImGui::TextDisabled("|"); ImGui::SameLine();
chip("Doc", &g_net_ui.type_doc, fn_tokens::colors::primary);
chip("CSS", &g_net_ui.type_css, fn_tokens::colors::primary);
chip("JS", &g_net_ui.type_js, fn_tokens::colors::primary);
chip("XHR", &g_net_ui.type_xhr, fn_tokens::colors::primary);
chip("Img", &g_net_ui.type_img, fn_tokens::colors::primary);
chip("Media", &g_net_ui.type_media, fn_tokens::colors::primary);
chip("Font", &g_net_ui.type_font, fn_tokens::colors::primary);
chip("WS", &g_net_ui.type_ws, fn_tokens::colors::primary);
chip("Other", &g_net_ui.type_other, fn_tokens::colors::primary);
ImGui::NewLine();
}
void draw_request_detail(const NetworkRequest& r, NetworkSession* net) {
if (ImGui::BeginTabBar("##req_detail_tabs")) {
if (ImGui::BeginTabItem("Headers")) {
ImGui::TextDisabled("General");
ImGui::Text("URL: %s", r.url.c_str());
ImGui::Text("Method: %s", r.method.c_str());
ImGui::Text("Status: %d %s", r.status, r.status_text.c_str());
ImGui::Text("Remote: %s:%d", r.remote_ip.c_str(), r.remote_port);
ImGui::Text("Protocol: %s", r.protocol.c_str());
if (r.failed) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::Text("Error: %s%s", r.error_text.c_str(), r.canceled ? " (canceled)" : "");
ImGui::PopStyleColor();
}
ImGui::Separator();
ImGui::TextDisabled("Request headers (%d)", (int)r.request_headers.size());
for (const auto& h : r.request_headers) {
ImGui::Text("%s:", h.name.c_str());
ImGui::SameLine();
ImGui::TextWrapped("%s", h.value.c_str());
}
ImGui::Separator();
ImGui::TextDisabled("Response headers (%d)", (int)r.response_headers.size());
for (const auto& h : r.response_headers) {
ImGui::Text("%s:", h.name.c_str());
ImGui::SameLine();
ImGui::TextWrapped("%s", h.value.c_str());
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Payload")) {
if (!r.has_post_data) {
ImGui::TextDisabled("(no request body)");
} else {
if (ImGui::SmallButton("Copy")) copy_to_clipboard(r.post_data);
ImGui::Separator();
ImGui::InputTextMultiline("##postdata", (char*)r.post_data.c_str(), r.post_data.size() + 1,
ImVec2(-1, -1), ImGuiInputTextFlags_ReadOnly);
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Response")) {
if (r.body_fetched && !r.body_text.empty()) {
if (ImGui::SmallButton("Copy")) copy_to_clipboard(r.body_text);
ImGui::Separator();
ImGui::InputTextMultiline("##body", (char*)r.body_text.c_str(), r.body_text.size() + 1,
ImVec2(-1, -1), ImGuiInputTextFlags_ReadOnly);
} else {
if (ImGui::Button("Fetch response body")) {
if (net) net->request_body(r.id);
}
ImGui::TextDisabled("(body lazy-loaded via Network.getResponseBody)");
ImGui::TextDisabled("Limitacion conocida: matching id->requestId pendiente — body llega via WS pero no se pinta hasta v1.5.");
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Cookies")) {
// Buscar Cookie / Set-Cookie en headers.
ImGui::TextDisabled("Sent (Cookie):");
for (const auto& h : r.request_headers) {
if (h.name == "Cookie" || h.name == "cookie") {
ImGui::TextWrapped("%s", h.value.c_str());
}
}
ImGui::Separator();
ImGui::TextDisabled("Set (Set-Cookie):");
for (const auto& h : r.response_headers) {
if (h.name == "Set-Cookie" || h.name == "set-cookie") {
ImGui::TextWrapped("%s", h.value.c_str());
}
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Timing")) {
double dur = (r.t_finished > 0 ? r.t_finished : r.t_response) - r.t_started;
ImGui::Text("Started: %.3f s", r.t_started);
ImGui::Text("Response: %.3f s", r.t_response);
ImGui::Text("Finished: %.3f s", r.t_finished);
ImGui::Text("Total: %s", fmt_dur_ms(dur).c_str());
ImGui::Separator();
ImGui::Text("CDP timestamps");
ImGui::Text(" requestWillBeSent: %.6f", r.ts_request_will_be_sent);
ImGui::Text(" responseReceived: %.6f", r.ts_response_received);
ImGui::Text(" loadingFinished: %.6f", r.ts_loading_finished);
ImGui::Text(" loadingFailed: %.6f", r.ts_loading_failed);
ImGui::EndTabItem();
}
if (r.type == ResourceType::WebSocket && ImGui::BeginTabItem("Messages")) {
ImGui::Text("Frames: %d", (int)r.ws_frames.size());
ImGui::Separator();
const ImGuiTableFlags f = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY;
if (ImGui::BeginTable("##wsframes", 4, f, ImVec2(-1, -1))) {
ImGui::TableSetupColumn("Dir", ImGuiTableColumnFlags_WidthFixed, 30);
ImGui::TableSetupColumn("Op", ImGuiTableColumnFlags_WidthFixed, 30);
ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 80);
ImGui::TableSetupColumn("Payload");
ImGui::TableHeadersRow();
for (const auto& wf : r.ws_frames) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(wf.outgoing ? TI_ARROW_UP : TI_ARROW_DOWN);
ImGui::TableNextColumn();
ImGui::Text("%d", wf.opcode);
ImGui::TableNextColumn();
ImGui::Text("%.3f", wf.time);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", wf.payload.c_str());
}
ImGui::EndTable();
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}
void render_network_toolbar(NetworkSession* net) {
if (ImGui::Button(TI_TRASH " Clear")) {
if (net) net->clear_log();
g_net_ui.selected_id.clear();
g_net_ui.selected_index = -1;
}
ImGui::SameLine();
if (ImGui::Button(g_net_ui.paused ? (TI_PLAYER_PLAY " Resume") : (TI_PLAYER_PAUSE " Pause"))) {
g_net_ui.paused = !g_net_ui.paused;
}
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
bool preserve = net ? net->preserve_log() : true;
if (ImGui::Checkbox("Preserve log", &preserve)) {
if (net) net->set_preserve_log(preserve);
}
ImGui::SameLine();
bool cache_disabled = net ? net->cache_disabled() : false;
if (ImGui::Checkbox("Disable cache", &cache_disabled)) {
if (net) net->set_cache_disabled(cache_disabled);
}
ImGui::SameLine();
ImGui::Checkbox("Hide data:", &g_net_ui.hide_data_urls);
ImGui::SameLine();
ImGui::Checkbox("Only failed", &g_net_ui.only_blocked);
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::SetNextItemWidth(220);
ImGui::InputTextWithHint("##netfilter", "filter (regex-like substring)",
g_net_ui.filter_text, sizeof(g_net_ui.filter_text));
ImGui::SameLine();
ImGui::Checkbox("Invert", &g_net_ui.invert_filter);
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
if (ImGui::Button(TI_DOWNLOAD " Export HAR")) {
if (net) {
std::string har = net->export_har_json();
// Escribir junto al exe.
char path[1024];
std::snprintf(path, sizeof(path), "navegator_har_%lld.har",
(long long)std::time(nullptr));
FILE* f = std::fopen(path, "w");
if (f) { std::fwrite(har.data(), 1, har.size(), f); std::fclose(f); }
}
}
}
} // anon
void render_network_panel(bool* p_open) {
if (!ImGui::Begin(TI_ACTIVITY " Network", p_open, ImGuiWindowFlags_MenuBar)) {
ImGui::End();
return;
}
NetworkSession* net = nullptr;
int port = 0;
std::string sel_tab_id;
std::string net_err;
{
std::lock_guard<std::mutex> lk(g_session().mu);
net = g_session().net.get();
port = g_session().selected_port;
sel_tab_id = g_session().selected_tab_id;
net_err = g_session().net_error;
}
if (!net) {
if (sel_tab_id.empty()) {
ImGui::TextDisabled("Select a tab in the Tabs panel to capture network.");
} else {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextWrapped("Network session not open: %s", net_err.c_str());
ImGui::PopStyleColor();
}
ImGui::End();
return;
}
// Drenar eventos cada frame (a menos que pause).
if (!g_net_ui.paused) net->pump();
render_network_toolbar(net);
draw_filter_chips();
ImGui::Separator();
// Snapshot + filtrado.
auto reqs = net->snapshot();
std::string filt = g_net_ui.filter_text;
std::string filt_lower = filt;
std::transform(filt_lower.begin(), filt_lower.end(), filt_lower.begin(), ::tolower);
std::vector<std::shared_ptr<NetworkRequest>> filtered;
filtered.reserve(reqs.size());
for (auto& r : reqs) {
if (g_net_ui.hide_data_urls && r->url.compare(0, 5, "data:") == 0) continue;
if (g_net_ui.only_blocked && !r->failed) continue;
if (!type_passes(g_net_ui, r->type)) continue;
if (!filt_lower.empty()) {
std::string lu = r->url; std::transform(lu.begin(), lu.end(), lu.begin(), ::tolower);
bool match = (lu.find(filt_lower) != std::string::npos);
if (g_net_ui.invert_filter) match = !match;
if (!match) continue;
}
filtered.push_back(r);
}
// Layout: split top (table) / bottom (detail) cuando hay seleccion.
bool has_sel = !g_net_ui.selected_id.empty();
float avail_h = ImGui::GetContentRegionAvail().y;
float status_bar_h = ImGui::GetTextLineHeightWithSpacing() + 4.0f;
float top_h = has_sel ? std::max(80.0f, (avail_h - status_bar_h) * 0.55f)
: (avail_h - status_bar_h);
ImGui::BeginChild("##nettable", ImVec2(0, top_h), true);
{
const ImGuiTableFlags flags =
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
ImGuiTableFlags_Sortable;
if (ImGui::BeginTable("##requests", 8, flags)) {
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableSetupColumn("Name");
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 60);
ImGui::TableSetupColumn("Method", ImGuiTableColumnFlags_WidthFixed, 70);
ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 70);
ImGui::TableSetupColumn("Initiator");
ImGui::TableSetupColumn("Size", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 70);
ImGui::TableSetupColumn("Waterfall",ImGuiTableColumnFlags_WidthStretch);
ImGui::TableHeadersRow();
// Para waterfall: necesitamos rango total.
double t_min = 0.0, t_max = 0.0;
for (const auto& r : filtered) {
t_max = std::max(t_max, std::max(r->t_finished, r->t_response));
}
if (t_max < 1.0) t_max = 1.0;
for (size_t i = 0; i < filtered.size(); ++i) {
const auto& r = filtered[i];
ImGui::TableNextRow();
ImGui::PushID((int)i);
bool is_sel = (g_net_ui.selected_id == r->id);
ImGui::TableNextColumn();
std::string name = short_name_from_url(r->url);
if (name.empty()) name = "(empty)";
if (ImGui::Selectable(name.c_str(), is_sel,
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
g_net_ui.selected_id = r->id;
g_net_ui.selected_index = (int)i;
}
if (ImGui::BeginPopupContextItem("##rowctx")) {
if (ImGui::MenuItem("Copy URL")) copy_to_clipboard(r->url);
if (ImGui::MenuItem("Copy as cURL")) copy_to_clipboard(build_curl(*r));
if (ImGui::MenuItem("Copy as fetch")) copy_to_clipboard(build_fetch(*r));
ImGui::Separator();
if (ImGui::MenuItem("Block URL (TODO)")) {}
ImGui::EndPopup();
}
ImGui::TableNextColumn();
if (r->status > 0) {
ImGui::PushStyleColor(ImGuiCol_Text, status_color(r->status));
ImGui::Text("%d", r->status);
ImGui::PopStyleColor();
} else if (r->failed) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextUnformatted("(failed)");
ImGui::PopStyleColor();
} else {
ImGui::TextDisabled("...");
}
ImGui::TableNextColumn();
ImGui::TextUnformatted(r->method.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(resource_type_label(r->type));
ImGui::TableNextColumn();
if (!r->initiator_url.empty()) {
ImGui::TextUnformatted(short_name_from_url(r->initiator_url).c_str());
} else {
ImGui::TextDisabled("%s", r->initiator_type.c_str());
}
ImGui::TableNextColumn();
if (r->from_cache) {
ImGui::TextDisabled("(cache)");
} else {
ImGui::TextUnformatted(fmt_size(r->encoded_data_length).c_str());
}
ImGui::TableNextColumn();
{
double dur = (r->t_finished > 0 ? r->t_finished : r->t_response) - r->t_started;
if (dur < 0) dur = 0;
ImGui::TextUnformatted(fmt_dur_ms(dur).c_str());
}
ImGui::TableNextColumn();
{
// mini waterfall bar.
ImVec2 cmin = ImGui::GetCursorScreenPos();
ImVec2 avail = ImVec2(ImGui::GetContentRegionAvail().x, ImGui::GetTextLineHeight());
ImDrawList* dl = ImGui::GetWindowDrawList();
double a = r->t_started / t_max;
double b = (r->t_finished > 0 ? r->t_finished : r->t_response) / t_max;
if (b < a) b = a;
if (b > 1) b = 1;
ImVec2 p1(cmin.x + avail.x * (float)a, cmin.y + 2);
ImVec2 p2(cmin.x + avail.x * (float)b, cmin.y + avail.y - 2);
if (p2.x < p1.x + 2) p2.x = p1.x + 2;
ImU32 col = ImGui::ColorConvertFloat4ToU32(
r->failed ? fn_tokens::colors::error :
(r->finished ? fn_tokens::colors::primary : fn_tokens::colors::warning));
dl->AddRectFilled(p1, p2, col, 2.0f);
ImGui::Dummy(avail);
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
ImGui::EndChild();
// Detail pane (selected request).
if (has_sel) {
ImGui::BeginChild("##netdetail", ImVec2(0, 0), true);
std::shared_ptr<NetworkRequest> sel;
for (auto& r : filtered) if (r->id == g_net_ui.selected_id) { sel = r; break; }
if (!sel) {
for (auto& r : reqs) if (r->id == g_net_ui.selected_id) { sel = r; break; }
}
if (sel) {
draw_request_detail(*sel, net);
} else {
ImGui::TextDisabled("(request gone — log was cleared)");
}
ImGui::EndChild();
}
// Status bar
auto stats = net->stats();
ImGui::Separator();
ImGui::Text("%d requests", stats.total_requests);
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
ImGui::Text("%s transferred", fmt_size(stats.transferred).c_str());
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
ImGui::Text("%s resources", fmt_size(stats.resources).c_str());
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
if (stats.finish_time > 0) ImGui::Text("Finish: %.2f s", stats.finish_time);
else ImGui::TextDisabled("Finish: —");
if (stats.dom_content_loaded > 0) {
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
ImGui::Text("DCL: %.2f", stats.dom_content_loaded);
}
if (stats.load_event > 0) {
ImGui::SameLine();
ImGui::Text("L: %.2f", stats.load_event);
}
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
ImGui::Text("WS bytes in: %llu out: %llu",
(unsigned long long)0, (unsigned long long)0);
ImGui::End();
}
+49
View File
@@ -0,0 +1,49 @@
#include "session_state.h"
namespace navegator {
SessionState& g_session() {
static SessionState s;
return s;
}
void SessionState::select_browser(int port) {
std::lock_guard<std::mutex> lk(mu);
if (selected_port == port) return;
selected_port = port;
selected_tab_id.clear();
selected_tab_ws_url.clear();
tabs.clear();
tabs_error.clear();
if (net) { net->close(); net.reset(); }
}
void SessionState::select_tab(const std::string& tab_id, const std::string& ws_url) {
std::lock_guard<std::mutex> lk(mu);
if (selected_tab_id == tab_id) return;
selected_tab_id = tab_id;
selected_tab_ws_url = ws_url;
if (net) { net->close(); net.reset(); }
if (!ws_url.empty()) {
net = std::make_unique<NetworkSession>();
std::string err;
if (!net->open(ws_url, &err)) {
net_error = err;
net.reset();
} else {
net_error.clear();
}
}
}
void SessionState::clear_selection() {
std::lock_guard<std::mutex> lk(mu);
selected_port = 0;
selected_tab_id.clear();
selected_tab_ws_url.clear();
tabs.clear();
tabs_error.clear();
if (net) { net->close(); net.reset(); }
}
} // namespace navegator
+47
View File
@@ -0,0 +1,47 @@
#pragma once
// Estado compartido entre los paneles Browsers/Tabs/Tab Detail/Network.
//
// Modelo:
// - selected_port: puerto CDP del browser activo (0 = ninguno).
// - selected_tab_id: id CDP de la pestaña elegida ("" = ninguna).
// - tabs: ultimo snapshot de pestañas (refrescado por Tabs panel).
// - net: NetworkSession ligada al tab seleccionado. Se reabre al cambiar tab.
//
// Toda la mutacion va detras de mutex. Lectura desde UI thread.
#include "cdp_http.h"
#include "network_state.h"
#include <chrono>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace navegator {
struct SessionState {
std::mutex mu;
int selected_port = 0;
std::string selected_tab_id;
std::string selected_tab_ws_url;
std::vector<CdpTab> tabs;
std::chrono::steady_clock::time_point last_tabs_refresh;
std::string tabs_error;
bool tabs_refreshing = false;
std::unique_ptr<NetworkSession> net;
std::string net_error;
// Helpers (toman el lock internamente).
void select_browser(int port);
void select_tab(const std::string& tab_id, const std::string& ws_url);
void clear_selection();
};
SessionState& g_session();
} // namespace navegator