diff --git a/CMakeLists.txt b/CMakeLists.txt index 505f777..da56a69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/agent.cpp b/agent.cpp new file mode 100644 index 0000000..d3df655 --- /dev/null +++ b/agent.cpp @@ -0,0 +1,1289 @@ +#include "agent.h" + +#include "app_base.h" +#include "imgui.h" +#include "core/icons_tabler.h" +#include "core/selectable_text.h" +#include "core/logger.h" + +// sqlite3 not needed in navegator (counter stub returns 0) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include +#else + #include + #include + #include + #include + #include + #include + #include +#endif + +namespace app_agent { + +// ---------------------------------------------------------------------------- +// Logger con tags +// ---------------------------------------------------------------------------- + +namespace { + +std::mutex g_log_mu; +std::string g_log_path; + +std::string iso_now_ms() { + using namespace std::chrono; + auto now = system_clock::now(); + auto t = system_clock::to_time_t(now); + auto ms = duration_cast(now.time_since_epoch()).count() % 1000; + char buf[40]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t)); + char out[64]; + std::snprintf(out, sizeof(out), "%s.%03lldZ", buf, (long long)ms); + return out; +} + +} // namespace + +void chat_log(const char* tag, const char* fmt, ...) { + if (!tag) tag = "chat"; + char body[2048]; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(body, sizeof(body), fmt, ap); + va_end(ap); + std::string ts = iso_now_ms(); + std::string ln = ts + " [chat:" + tag + "] " + body + "\n"; + + { + std::lock_guard lk(g_log_mu); + std::fputs(ln.c_str(), stderr); + std::fflush(stderr); + if (!g_log_path.empty()) { + FILE* f = std::fopen(g_log_path.c_str(), "ab"); + if (f) { + std::fputs(ln.c_str(), f); + std::fclose(f); + } + } + } + // Mirror al logger global (ventana Settings → Logs...) — asi el usuario + // puede ver los eventos de chat sin abrir chat.log en disco. + if (std::strcmp(tag, "error") == 0) { + fn_log::log_error("[chat:%s] %s", tag, body); + } else { + fn_log::log_info ("[chat:%s] %s", tag, body); + } +} + +const char* chat_log_path() { + return g_log_path.c_str(); +} + +namespace { + +// ---------------------------------------------------------------------------- +// Pequeño extractor JSON (sin dependencias). Solo lo justo para sacar el +// valor string asociado a una clave dentro de un objeto JSON. Maneja escapes +// basicos y devuelve "" si no encuentra la clave. +// ---------------------------------------------------------------------------- + +std::string json_unescape(const char* s, size_t n) { + std::string out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + char c = s[i]; + if (c == '\\' && i + 1 < n) { + char nx = s[++i]; + switch (nx) { + case 'n': out += '\n'; break; + case 't': out += '\t'; break; + case 'r': out += '\r'; break; + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'u': { + if (i + 4 < n) { + unsigned cp = 0; + for (int k = 0; k < 4; ++k) { + char h = s[i + 1 + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= (h - '0'); + else if (h >= 'a' && h <= 'f') cp |= (h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= (h - 'A' + 10); + } + i += 4; + // utf-8 encode + if (cp < 0x80) { + out += (char)cp; + } else if (cp < 0x800) { + out += (char)(0xC0 | (cp >> 6)); + out += (char)(0x80 | (cp & 0x3F)); + } else { + out += (char)(0xE0 | (cp >> 12)); + out += (char)(0x80 | ((cp >> 6) & 0x3F)); + out += (char)(0x80 | (cp & 0x3F)); + } + } + break; + } + default: out += nx; break; + } + } else { + out += c; + } + } + return out; +} + +// Devuelve el offset del valor (excluyendo comillas si string) o -1 si no +// existe. Solo busca claves a nivel "shallow" — no recursivo. Suficiente +// para el formato de stream-json de claude -p. +bool json_find_string(const std::string& obj, const char* key, std::string* out) { + std::string pat = "\""; + pat += key; + pat += "\""; + size_t pos = obj.find(pat); + while (pos != std::string::npos) { + size_t i = pos + pat.size(); + while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t')) ++i; + if (i >= obj.size() || obj[i] != ':') { + pos = obj.find(pat, pos + 1); + continue; + } + ++i; + while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t')) ++i; + if (i >= obj.size() || obj[i] != '"') return false; // no es string + ++i; + size_t start = i; + while (i < obj.size()) { + if (obj[i] == '\\' && i + 1 < obj.size()) { i += 2; continue; } + if (obj[i] == '"') break; + ++i; + } + if (i >= obj.size()) return false; + *out = json_unescape(obj.data() + start, i - start); + return true; + } + return false; +} + +std::string make_uuid_v4() { + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis; + uint64_t a = dis(gen), b = dis(gen); + char buf[40]; + std::snprintf(buf, sizeof(buf), + "%08x-%04x-4%03x-%04x-%012llx", + (unsigned)(a & 0xFFFFFFFF), + (unsigned)((a >> 32) & 0xFFFF), + (unsigned)((a >> 48) & 0x0FFF), + (unsigned)(0x8000 | (b & 0x3FFF)), + (unsigned long long)((b >> 16) & 0xFFFFFFFFFFFFULL)); + return buf; +} + +// ---------------------------------------------------------------------------- +// Estado del chat +// ---------------------------------------------------------------------------- + +struct ChatMessage { + enum Kind { USER, ASSISTANT, TOOL_USE, TOOL_RESULT, SYSTEM, ERROR_MSG }; + Kind kind; + std::string text; // user/assistant: texto. tool_*: descripcion. + std::string tool_name; // para TOOL_USE + std::string tool_input; // resumen del input +}; + +struct State { + std::string ops_db; + std::string app_db; + std::string app_dir; + std::string claude_cmd; // "claude" o "wsl" + std::vector claude_pre_args; // ["claude"] si wsl, vacio si nativo + std::string mcp_config_path; // path Linux del mcp.json para --mcp-config + bool ready = false; + bool first_turn_done = false; + std::string session_id; // uuid + + std::mutex mu; + std::vector history; + std::atomic busy{false}; + std::thread worker; + + char input_buf[8192] = {}; + bool scroll_to_bottom = false; + bool show_raw = false; + std::vector raw_lines; +}; + +State* g_st = nullptr; + +// ---------------------------------------------------------------------------- +// Detectar disponibilidad de claude +// ---------------------------------------------------------------------------- + +// Captura stdout+stderr de un comando corto y devuelve {exit_code, output}. +// Si falla creando el proceso, devuelve {-1, ""}. +struct ProbeResult { int rc; std::string out; }; + +#ifdef _WIN32 +ProbeResult probe_capture(const std::string& cmdline) { + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE r=nullptr, w=nullptr; + if (!CreatePipe(&r, &w, &sa, 0)) return {-1, ""}; + SetHandleInformation(r, HANDLE_FLAG_INHERIT, 0); + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + si.hStdOutput = w; + si.hStdError = w; + PROCESS_INFORMATION pi{}; + std::string mutable_cmd = cmdline; + BOOL ok = CreateProcessA( + nullptr, mutable_cmd.data(), nullptr, nullptr, TRUE, + CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); + CloseHandle(w); + if (!ok) { + CloseHandle(r); + DWORD err = GetLastError(); + chat_log("detect", "CreateProcess fallo (err=%lu) para: %s", + (unsigned long)err, cmdline.c_str()); + return {-1, ""}; + } + std::string out; + char buf[1024]; + DWORD n = 0; + while (ReadFile(r, buf, sizeof(buf), &n, nullptr) && n > 0) { + out.append(buf, n); + } + CloseHandle(r); + DWORD code = 0; + WaitForSingleObject(pi.hProcess, 5000); // 5s timeout + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return {(int)code, out}; +} +#else +ProbeResult probe_capture(const std::string& cmdline) { + std::string c = cmdline + " 2>&1"; + FILE* p = popen(c.c_str(), "r"); + if (!p) return {-1, ""}; + std::string out; + char buf[1024]; + size_t n; + while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) { + out.append(buf, n); + } + int rc = pclose(p); + if (rc == -1) return {-1, out}; + if (WIFEXITED(rc)) rc = WEXITSTATUS(rc); + return {rc, out}; +} +#endif + +void detect_claude() { + if (!g_st) return; +#ifdef _WIN32 + // Probe 1: wsl --status (verifica que WSL existe). + auto p_status = probe_capture("wsl.exe --status"); + chat_log("detect", "wsl.exe --status -> rc=%d, out=%.200s", + p_status.rc, p_status.out.c_str()); + + // Probe 2: bash login shell carga ~/.profile asi que claude (en + // ~/.local/bin/) entra en PATH. Capturamos la ruta absoluta para usarla + // luego con `wsl.exe --exec ` y bypassear el problema de quoting + // de wsl.exe en modo `--`. + auto p_path = probe_capture( + "wsl.exe -- bash -lc \"command -v claude\""); + chat_log("detect", "wsl bash -lc 'command -v claude' -> rc=%d, out=%.200s", + p_path.rc, p_path.out.c_str()); + + // Trim del path resultante (puede tener \r\n) + std::string claude_path = p_path.out; + while (!claude_path.empty() && + (claude_path.back() == '\n' || claude_path.back() == '\r' || + claude_path.back() == ' ' || claude_path.back() == '\t')) { + claude_path.pop_back(); + } + + bool wsl_works = (p_status.rc == 0); + bool claude_works = (p_path.rc == 0) && !claude_path.empty(); + + if (claude_works) { + g_st->claude_cmd = "wsl.exe"; + // --exec NO usa shell, evita el problema de wsl.exe que reconcatena + // y rompe el quoting de los args (perdiamos las comillas de + // --append-system-prompt y de "$@" → exit 127). Path absoluto al + // binario claude detectado en el probe anterior. + g_st->claude_pre_args = {"--exec", claude_path}; + g_st->ready = true; + chat_log("detect", "OK — claude en %s, usaremos wsl.exe --exec", + claude_path.c_str()); + } else if (wsl_works) { + // wsl funciona pero no localizamos claude. Como fallback intentamos + // un path estandar conocido — el primer send dira si funciono. + g_st->claude_cmd = "wsl.exe"; + g_st->claude_pre_args = {"--exec", "/home/lucas/.local/bin/claude"}; + g_st->ready = true; + chat_log("detect", "wsl OK pero no localizamos claude — " + "fallback a /home/lucas/.local/bin/claude"); + } else { + g_st->ready = false; + chat_log("detect", "wsl --status fallo — panel Chat deshabilitado"); + } +#else + auto p = probe_capture("command -v claude"); + chat_log("detect", "command -v claude -> rc=%d, out=%.200s", + p.rc, p.out.c_str()); + if (p.rc == 0) { + g_st->claude_cmd = "claude"; + g_st->claude_pre_args = {}; + g_st->ready = true; + auto v = probe_capture("claude --version"); + chat_log("detect", "claude --version -> rc=%d, out=%.200s", + v.rc, v.out.c_str()); + } else { + g_st->ready = false; + chat_log("detect", "claude no esta en PATH — panel deshabilitado"); + } +#endif +} + +// ---------------------------------------------------------------------------- +// Subprocess: enviar mensaje y leer stream-json hasta EOF +// ---------------------------------------------------------------------------- + +#ifndef _WIN32 + +// Lanza claude -p, escribe `prompt` en stdin, lee stdout linea a linea. +// Cada linea bien-formada (JSON object) se pasa a `on_line`. Devuelve el +// exit code, o -1 si fork/exec falla. +int run_claude_streaming( + const std::vector& argv, + const std::string& prompt, + void (*on_line)(const std::string&)) +{ + int in_pipe[2], out_pipe[2]; + if (pipe(in_pipe) != 0 || pipe(out_pipe) != 0) return -1; + + pid_t pid = fork(); + if (pid < 0) { + chat_log("error", "fork() fallo: errno=%d (%s)", errno, std::strerror(errno)); + close(in_pipe[0]); close(in_pipe[1]); + close(out_pipe[0]); close(out_pipe[1]); + return -1; + } + if (pid == 0) { + // child + dup2(in_pipe[0], STDIN_FILENO); + dup2(out_pipe[1], STDOUT_FILENO); + // stderr al stderr del padre (debugging visible en consola) + close(in_pipe[0]); close(in_pipe[1]); + close(out_pipe[0]); close(out_pipe[1]); + std::vector args; + for (auto& a : argv) args.push_back(const_cast(a.c_str())); + args.push_back(nullptr); + execvp(args[0], args.data()); + _exit(127); + } + // parent + close(in_pipe[0]); + close(out_pipe[1]); + // escribe prompt y cierra + if (!prompt.empty()) { + ssize_t n = write(in_pipe[1], prompt.data(), prompt.size()); + chat_log("io", "stdin write %zd/%zu bytes", + (ssize_t)n, prompt.size()); + } + close(in_pipe[1]); + + size_t total_in = 0; + int lines = 0; + std::string buf; + char chunk[4096]; + for (;;) { + ssize_t r = read(out_pipe[0], chunk, sizeof(chunk)); + if (r <= 0) break; + total_in += (size_t)r; + buf.append(chunk, (size_t)r); + size_t nl; + while ((nl = buf.find('\n')) != std::string::npos) { + std::string line = buf.substr(0, nl); + buf.erase(0, nl + 1); + if (!line.empty()) { on_line(line); ++lines; } + } + } + if (!buf.empty()) { on_line(buf); ++lines; } + chat_log("io", "stdout EOF — total=%zu bytes, %d lineas", total_in, lines); + close(out_pipe[0]); + int status = 0; + waitpid(pid, &status, 0); + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) { + chat_log("error", "subprocess signaled %d", WTERMSIG(status)); + } + return -1; +} + +#else // _WIN32 + +int run_claude_streaming( + const std::vector& argv, + const std::string& prompt, + void (*on_line)(const std::string&)) +{ + // Construye command line con escape minimo + std::string cmd; + for (size_t i = 0; i < argv.size(); ++i) { + if (i) cmd += ' '; + const std::string& a = argv[i]; + bool need_q = a.empty() || a.find_first_of(" \t\"") != std::string::npos; + if (need_q) { + cmd += '"'; + for (char c : a) { + if (c == '"') cmd += "\\\""; + else cmd += c; + } + cmd += '"'; + } else { + cmd += a; + } + } + + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE in_r=nullptr, in_w=nullptr, out_r=nullptr, out_w=nullptr; + if (!CreatePipe(&in_r, &in_w, &sa, 0)) return -1; + SetHandleInformation(in_w, HANDLE_FLAG_INHERIT, 0); + if (!CreatePipe(&out_r, &out_w, &sa, 0)) { + CloseHandle(in_r); CloseHandle(in_w); + return -1; + } + SetHandleInformation(out_r, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = in_r; + si.hStdOutput = out_w; + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + PROCESS_INFORMATION pi{}; + chat_log("spawn", "CreateProcess cmdline=%s", cmd.c_str()); + BOOL ok = CreateProcessA( + nullptr, cmd.data(), nullptr, nullptr, TRUE, + CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); + CloseHandle(in_r); + CloseHandle(out_w); + if (!ok) { + DWORD err = GetLastError(); + chat_log("error", "CreateProcess fallo, GetLastError=%lu", + (unsigned long)err); + CloseHandle(in_w); CloseHandle(out_r); + return -1; + } + + if (!prompt.empty()) { + DWORD wn = 0; + WriteFile(in_w, prompt.data(), (DWORD)prompt.size(), &wn, nullptr); + chat_log("io", "stdin write %lu/%zu bytes", + (unsigned long)wn, prompt.size()); + } + CloseHandle(in_w); + + size_t total_in = 0; + int lines = 0; + std::string buf; + char chunk[4096]; + for (;;) { + DWORD rn = 0; + BOOL r = ReadFile(out_r, chunk, sizeof(chunk), &rn, nullptr); + if (!r || rn == 0) break; + total_in += (size_t)rn; + buf.append(chunk, (size_t)rn); + size_t nl; + while ((nl = buf.find('\n')) != std::string::npos) { + std::string line = buf.substr(0, nl); + // strip trailing CR + if (!line.empty() && line.back() == '\r') line.pop_back(); + buf.erase(0, nl + 1); + if (!line.empty()) { on_line(line); ++lines; } + } + } + if (!buf.empty()) { on_line(buf); ++lines; } + chat_log("io", "stdout EOF — total=%zu bytes, %d lineas", + total_in, lines); + CloseHandle(out_r); + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 0; + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return (int)code; +} + +#endif + +// ---------------------------------------------------------------------------- +// Procesado de eventos stream-json +// ---------------------------------------------------------------------------- + +void on_stream_line(const std::string& line) { + if (!g_st) return; + // Filtrar lineas no-JSON (logs spurios) — empiezan con '{' + if (line.empty() || line[0] != '{') return; + + { + std::lock_guard lk(g_st->mu); + if (g_st->raw_lines.size() > 500) { + g_st->raw_lines.erase(g_st->raw_lines.begin(), + g_st->raw_lines.begin() + 200); + } + g_st->raw_lines.push_back(line); + } + + std::string type; + if (!json_find_string(line, "type", &type)) return; + + // System init: capturar session_id si todavia no lo tenemos + if (type == "system") { + std::string sid, model; + json_find_string(line, "session_id", &sid); + json_find_string(line, "model", &model); + chat_log("parse", "system init: session=%s model=%s", + sid.c_str(), model.c_str()); + if (!sid.empty()) { + std::lock_guard lk(g_st->mu); + if (g_st->session_id.empty()) g_st->session_id = sid; + } + return; + } + + // Assistant message: extraer text content + if (type == "assistant") { + // El campo "text" puede aparecer dentro del array content[]. + // Buscamos todos los "text":"..." que no sean del campo de tool_input. + std::string s = line; + size_t pos = 0; + while (true) { + size_t k = s.find("\"text\"", pos); + if (k == std::string::npos) break; + size_t i = k + 6; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; + if (i >= s.size() || s[i] != ':') { pos = k + 1; continue; } + ++i; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; + if (i >= s.size() || s[i] != '"') { pos = k + 1; continue; } + ++i; + size_t start = i; + while (i < s.size()) { + if (s[i] == '\\' && i + 1 < s.size()) { i += 2; continue; } + if (s[i] == '"') break; + ++i; + } + std::string text = json_unescape(s.data() + start, i - start); + if (!text.empty()) { + std::lock_guard lk(g_st->mu); + ChatMessage m; + m.kind = ChatMessage::ASSISTANT; + m.text = text; + g_st->history.push_back(std::move(m)); + g_st->scroll_to_bottom = true; + } + pos = i + 1; + } + // Detectar tool_use + if (line.find("\"type\":\"tool_use\"") != std::string::npos) { + std::string tname; + json_find_string(line, "name", &tname); + // Resumen del input — toma el primer campo string + std::string summary; + std::string command; + json_find_string(line, "command", &command); + // claude usa input:{...}; sacar la parte legible + size_t ip = line.find("\"input\":"); + if (ip != std::string::npos) { + size_t end = line.find("}", ip); + if (end != std::string::npos) { + summary = line.substr(ip, end - ip + 1); + if (summary.size() > 240) summary = summary.substr(0, 240) + "..."; + } + } + chat_log("tools", "tool_use name=%s cmd=%s", + tname.c_str(), command.c_str()); + std::lock_guard lk(g_st->mu); + ChatMessage m; + m.kind = ChatMessage::TOOL_USE; + m.tool_name = tname; + m.tool_input = summary; + g_st->history.push_back(std::move(m)); + g_st->scroll_to_bottom = true; + } + return; + } + + if (type == "user") { + // Mensajes user wrappean tool_result. Si contiene "tool_use_id" y + // "content", lo mostramos como TOOL_RESULT. + if (line.find("\"tool_use_id\"") != std::string::npos) { + std::string content; + // El "content" puede ser string o array — intento string primero + if (!json_find_string(line, "content", &content)) { + // saca lo que haya entre el primer "text":" y " + size_t k = line.find("\"text\":"); + if (k != std::string::npos) { + size_t s = line.find('"', k + 7); + size_t e = (s == std::string::npos) ? std::string::npos + : line.find('"', s + 1); + if (e != std::string::npos) + content = json_unescape(line.data() + s + 1, e - s - 1); + } + } + if (content.size() > 1500) content = content.substr(0, 1500) + "\n... (truncado)"; + std::lock_guard lk(g_st->mu); + ChatMessage m; + m.kind = ChatMessage::TOOL_RESULT; + m.text = content; + g_st->history.push_back(std::move(m)); + g_st->scroll_to_bottom = true; + } + return; + } + + if (type == "result") { + std::string sub; + json_find_string(line, "subtype", &sub); + chat_log("parse", "result subtype=%s", sub.c_str()); + if (sub == "error_max_turns" || sub == "error_during_execution") { + std::string err = "(claude error: " + sub + ")"; + std::lock_guard lk(g_st->mu); + ChatMessage m; + m.kind = ChatMessage::ERROR_MSG; + m.text = err; + g_st->history.push_back(std::move(m)); + g_st->scroll_to_bottom = true; + } + return; + } +} + +// ---------------------------------------------------------------------------- +// System prompt para el agente +// ---------------------------------------------------------------------------- + +std::string build_system_prompt() { + return + "Eres el Agente — copiloto del usuario sobre navegator_dashboard. El\n" + "usuario controla N instancias de Chrome con --remote-debugging-port\n" + "lanzadas por el dashboard. Tu trabajo: ayudar a manejar esos\n" + "navegadores (lanzar, navegar, extraer, automatizar).\n\n" + "PROHIBIDO ABSOLUTO:\n" + "- NO ejecutar `navegator_dashboard.exe`, `$GX_APP_DIR/*.exe`, ni\n" + " ningun otro .exe del directorio del dashboard. Ese ejecutable ES\n" + " el dashboard que tu controlas — relanzarlo abre otra instancia\n" + " GUI inutil. SIEMPRE actua via la API HTTP o CDP HTTP de Chrome.\n" + "- NO usar `which gx-cli` ni `gx-cli` — son del otro proyecto\n" + " (graph_explorer), no estan en este equipo.\n" + "- NO inventar endpoints. Solo existen los 4 listados abajo.\n\n" + "ENTORNO:\n" + "- API HTTP local del dashboard (los UNICOS endpoints disponibles):\n" + " GET http://127.0.0.1:19333/health\n" + " GET http://127.0.0.1:19333/browsers lista chromes\n" + " POST http://127.0.0.1:19333/spawn?profile=X&port=N&headless=0|1\n" + " POST http://127.0.0.1:19333/kill?profile=X (o user_data_dir=...)\n" + " No hay /navigate ni /tabs ni /eval — esos van directo a CDP HTTP.\n" + "- CDP HTTP (cada Chrome instancia expone esto en su --remote-debugging-port):\n" + " GET http://127.0.0.1:/json/version info navegador\n" + " GET http://127.0.0.1:/json lista de tabs\n" + " PUT http://127.0.0.1:/json/new? nueva tab abriendo URL\n" + " GET http://127.0.0.1:/json/close/ cierra tab\n" + " GET http://127.0.0.1:/json/activate/ foco tab\n" + " Para acciones complejas (eval JS, screenshot, click) sobre una\n" + " tab necesitas WebSocket CDP — eso queda fuera de tu alcance hoy\n" + " (no tienes cdp-cli desplegado). Limitate a HTTP.\n\n" + "WORKFLOW:\n" + "1. Listar Chromes: `curl -sf http://127.0.0.1:19333/browsers`.\n" + "2. Lanzar Chrome nuevo: `curl -sf -X POST 'http://127.0.0.1:19333/spawn?profile=NOMBRE&port=19299'`.\n" + "3. Para abrir URL en una instancia ya viva, usa CDP HTTP /json/new:\n" + " `curl -sf -X PUT 'http://127.0.0.1:/json/new?'`.\n" + "4. Para listar tabs: `curl -sf http://127.0.0.1:/json`.\n" + "5. Para cerrar tab: `curl -sf 'http://127.0.0.1:/json/close/'`.\n" + "6. Cleanup: `curl -sf -X POST 'http://127.0.0.1:19333/kill?profile=NOMBRE'`.\n\n" + "REGLAS:\n" + "- Antes de spawn, comprueba /browsers — si el profile existe, reusa\n" + " su puerto (no lances duplicado).\n" + "- Puertos: usa siempre >= 19222 (el 9222 suele estar reservado en Windows).\n" + "- No mates Chromes que no hayas lanzado salvo orden explicita.\n" + "- Scraping → headless=1 (no roba foco).\n" + "- Ambiguedad → ejecuta con perfil 'tmp' y describe.\n" + "- Resume conciso. El usuario VE el dashboard. No narres lo obvio.\n" + " Importante: errores, hallazgos, sugerencias de proximo paso.\n\n" + "Firmas como Agente cuando tenga sentido marcar identidad."; +} + +// Convierte un path Windows (UNC \\wsl.localhost\\... o C:\...) al +// equivalente WSL (/home/... o /mnt/c/...). En POSIX es no-op. Necesario +// para escribir el mcp.json: claude corre en WSL y lee paths Linux. +std::string to_linux_path(const std::string& p) { +#ifndef _WIN32 + return p; +#else + if (p.empty()) return p; + if (p[0] == '/') return p; + auto is_sep = [](char c) { return c == '\\' || c == '/'; }; + + // UNC: \\\\rest + if (p.size() >= 2 && is_sep(p[0]) && is_sep(p[1])) { + size_t i = 2; + while (i < p.size() && !is_sep(p[i])) ++i; + std::string server = p.substr(2, i - 2); + for (auto& c : server) c = (char)std::tolower((unsigned char)c); + if (server == "wsl.localhost" || server == "wsl$") { + if (i < p.size()) ++i; + while (i < p.size() && !is_sep(p[i])) ++i; + std::string rest = p.substr(i); + for (char& c : rest) if (c == '\\') c = '/'; + return rest.empty() ? std::string("/") : rest; + } + std::string out = p; + for (char& c : out) if (c == '\\') c = '/'; + return out; + } + + // Drive letter: X:\... -> /mnt/x/... + if (p.size() >= 3 && std::isalpha((unsigned char)p[0]) && p[1] == ':' && + is_sep(p[2])) { + std::string out = "/mnt/"; + out.push_back((char)std::tolower((unsigned char)p[0])); + for (size_t i = 2; i < p.size(); ++i) { + out.push_back(p[i] == '\\' ? '/' : p[i]); + } + return out; + } + return p; +#endif +} + +// Escribe el fichero mcp.json que claude leera al arrancar para spawnar +// el MCP server gx-cli. Devuelve el path para --mcp-config (Linux dentro +// de WSL en Windows; native en Linux). El contenido del fichero usa paths +// Linux porque claude corre dentro de WSL. +std::string write_mcp_config() { + if (!g_st) return ""; + std::string config_path_native; // path para fopen + std::string config_path_for_claude; // path que se pasa a --mcp-config + + // Ponemos mcp.json junto a app_db (mismo dir que chat.log). + { + std::string p = g_st->app_db; + size_t slash = p.find_last_of("/\\"); + size_t dir_end = (slash == std::string::npos) ? 0 : slash + 1; + config_path_native = p.substr(0, dir_end) + "mcp.json"; + } +#ifdef _WIN32 + // claude corre en WSL — necesita el path Linux del config file. + // Pero to_linux_path solo convierte paths ABSOLUTOS. Si app_db era + // relativo (caso normal cuando cwd = directorio del exe), config_path + // tambien lo es. Resolvemos primero a absoluto Windows. + { + char buf[MAX_PATH * 2]; + DWORD n = GetFullPathNameA(config_path_native.c_str(), + (DWORD)sizeof(buf), buf, nullptr); + if (n > 0 && n < sizeof(buf)) { + config_path_native = buf; // ahora absoluto: C:\Users\...\mcp.json + } + } + config_path_for_claude = to_linux_path(config_path_native); +#else + config_path_for_claude = config_path_native; +#endif + + // gx-cli vive en /assets/ tras el deploy con la + // convencion assets/. Fallback al app_dir del repo en modo dev. + std::string app_dir_linux = to_linux_path(g_st->app_dir); + std::string gxcli_path; + { + std::string assets_gx = fn::asset_path( +#ifdef _WIN32 + "gx-cli.exe" +#else + "gx-cli" +#endif + ); + std::error_code _ec; + if (std::filesystem::exists(assets_gx, _ec)) { + gxcli_path = to_linux_path(assets_gx); + } else { + gxcli_path = app_dir_linux + "/gx-cli"; + } + } + + // No metemos env: en el config para que herede del proceso padre + // (claude, que ya tiene GX_OPS_DB/GX_APP_DB/GX_APP_DIR via WSLENV). + std::ostringstream js; + js << "{\n" + << " \"mcpServers\": {\n" + << " \"graph_explorer\": {\n" + << " \"command\": \"" << gxcli_path << "\",\n" + << " \"args\": [\"mcp-server\"]\n" + << " }\n" + << " }\n" + << "}\n"; + + FILE* f = std::fopen(config_path_native.c_str(), "wb"); + if (!f) { + chat_log("error", "no pude escribir mcp.json en %s", + config_path_native.c_str()); + return ""; + } + std::string content = js.str(); + std::fwrite(content.data(), 1, content.size(), f); + std::fclose(f); + chat_log("init", "mcp.json escrito en %s (claude lo lee como %s)", + config_path_native.c_str(), config_path_for_claude.c_str()); + chat_log("init", "MCP server: %s mcp-server", gxcli_path.c_str()); + return config_path_for_claude; +} + +// ---------------------------------------------------------------------------- +// Worker: ejecuta una vuelta completa y termina +// ---------------------------------------------------------------------------- + +void worker_send(std::string user_text) { + if (!g_st) return; + if (g_st->session_id.empty()) g_st->session_id = make_uuid_v4(); + chat_log("spawn", "turno %d, session_id=%s, prompt_len=%zu bytes", + g_st->first_turn_done ? 2 : 1, + g_st->session_id.c_str(), user_text.size()); + + std::vector argv; + argv.push_back(g_st->claude_cmd); + for (auto& a : g_st->claude_pre_args) argv.push_back(a); + argv.push_back("-p"); + argv.push_back("--input-format"); argv.push_back("text"); + argv.push_back("--output-format"); argv.push_back("stream-json"); + argv.push_back("--verbose"); + argv.push_back("--allowed-tools"); + // mcp__graph_explorer__* expone las 17 tools tipadas. Bash queda como + // fallback para casos raros; el system prompt empuja a usar las MCP. + argv.push_back("Bash Read Glob Grep WebFetch"); + argv.push_back("--permission-mode"); + argv.push_back("bypassPermissions"); + if (!g_st->mcp_config_path.empty()) { + argv.push_back("--mcp-config"); + argv.push_back(g_st->mcp_config_path); + } + if (g_st->first_turn_done) { + argv.push_back("--resume"); argv.push_back(g_st->session_id); + } else { + argv.push_back("--session-id"); argv.push_back(g_st->session_id); + argv.push_back("--append-system-prompt"); + argv.push_back(build_system_prompt()); + // En la primera vuelta inyectamos las env-vars como contexto explicito + // porque no podemos confiar en que claude las herede en todos los + // entornos (Windows wsl.exe las propaga, Linux nativo tambien, pero + // documentarlo en el mensaje hace al agente independiente). + } + + // Setenv para el subprocess (claude hereda env, lo pasa a Bash tool calls). +#ifndef _WIN32 + setenv("NAVD_API_URL", "http://127.0.0.1:19333", 1); + setenv("NAVD_APP_DIR", g_st->app_dir.c_str(), 1); + // GX_* mantenidos por compat con codigo upstream del modulo (no aplican + // pero no estorban — counter de mutaciones queda en 0 de todas formas). + setenv("GX_OPS_DB", g_st->ops_db.c_str(), 1); + setenv("GX_APP_DB", g_st->app_db.c_str(), 1); + setenv("GX_APP_DIR", g_st->app_dir.c_str(), 1); + // Asegura que cdp-cli esta en PATH anteponiendo app_dir. + std::string path = g_st->app_dir; + if (const char* p = std::getenv("PATH")) { + path += ":"; + path += p; + } + setenv("PATH", path.c_str(), 1); + chat_log("env", "NAVD_API_URL=http://127.0.0.1:19333"); + chat_log("env", "NAVD_APP_DIR=%s", g_st->app_dir.c_str()); +#else + // WSLENV /p traduce paths Windows -> WSL automaticamente, pero SOLO si + // el path es absoluto. Los relativos pasan literales (con backslash) y + // dentro de WSL no resuelven a nada — por eso el agente abria la BD + // equivocada. Resolvemos con GetFullPathNameA antes de setear. + auto abs_win = [](const std::string& p) -> std::string { + if (p.empty()) return p; + char buf[MAX_PATH * 2]; + DWORD n = GetFullPathNameA(p.c_str(), + (DWORD)sizeof(buf), buf, nullptr); + if (n == 0 || n >= sizeof(buf)) return p; + return std::string(buf); + }; + std::string ops_abs = abs_win(g_st->ops_db); + std::string appdb_abs = abs_win(g_st->app_db); + std::string appdir_abs; + // app_dir suele ser ya UNC \\wsl.localhost\... o ruta WSL — no queremos + // que GetFullPathNameA lo "windowsifique". Si empieza por \\ lo dejamos. + if (g_st->app_dir.size() >= 2 && + g_st->app_dir[0] == '\\' && g_st->app_dir[1] == '\\') { + appdir_abs = g_st->app_dir; + } else { + appdir_abs = abs_win(g_st->app_dir); + } + + SetEnvironmentVariableA("NAVD_API_URL", "http://127.0.0.1:19333"); + SetEnvironmentVariableA("NAVD_APP_DIR", appdir_abs.c_str()); + SetEnvironmentVariableA("GX_OPS_DB", ops_abs.c_str()); + SetEnvironmentVariableA("GX_APP_DB", appdb_abs.c_str()); + SetEnvironmentVariableA("GX_APP_DIR", appdir_abs.c_str()); + SetEnvironmentVariableA( + "WSLENV", "NAVD_API_URL:NAVD_APP_DIR/p:GX_OPS_DB/p:GX_APP_DB/p:GX_APP_DIR/p"); + chat_log("env", "GX_OPS_DB=%s", ops_abs.c_str()); + chat_log("env", "GX_APP_DB=%s", appdb_abs.c_str()); + chat_log("env", "GX_APP_DIR=%s", appdir_abs.c_str()); + chat_log("env", "WSLENV=GX_OPS_DB/p:GX_APP_DB/p:GX_APP_DIR/p"); +#endif + + // Log del argv completo + { + std::string joined; + for (size_t i = 0; i < argv.size(); ++i) { + if (i) joined += ' '; + joined += argv[i]; + } + chat_log("spawn", "argv: %s", joined.c_str()); + } + + // En la primera vuelta inyectamos el contexto del entorno como prefijo + // del primer mensaje. NO imprimimos los paths Windows tal cual — WSLENV + // los traducira a /mnt/c/... dentro de WSL antes de que el agente vea + // las env vars. Lo importante es decirle al agente: usa las env vars + // GX_OPS_DB / GX_APP_DB / GX_APP_DIR que ya estan seteadas, NO inventes + // paths. + std::string prompt = user_text; + if (!g_st->first_turn_done) { + std::ostringstream ctx; + ctx << "[contexto del entorno — no respondas a esto, solo recuerdalo]\n" + << "Las variables de entorno GX_OPS_DB, GX_APP_DB y GX_APP_DIR\n" + << "estan seteadas en TU shell (bash). gx-cli las lee\n" + << "automaticamente, asi que NUNCA pases --db ni configures paths\n" + << "manualmente. Verifica con: `env | grep GX_`. La BD apuntada\n" + << "por GX_OPS_DB es la que el usuario VE en el viewport.\n" + << "Comando estandar: $GX_APP_DIR/gx-cli ...\n\n" + << "[mensaje del usuario]\n" + << user_text; + prompt = ctx.str(); + } + + auto t0 = std::chrono::steady_clock::now(); + int rc = run_claude_streaming(argv, prompt, on_stream_line); + auto dt_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + chat_log("spawn", "subprocess exit rc=%d, duration=%lldms", rc, (long long)dt_ms); + if (rc != 0) { + std::lock_guard lk(g_st->mu); + ChatMessage m; + m.kind = ChatMessage::ERROR_MSG; + m.text = "claude -p exit code " + std::to_string(rc) + + " — revisa " + g_log_path; + g_st->history.push_back(std::move(m)); + g_st->scroll_to_bottom = true; + } + g_st->first_turn_done = true; + g_st->busy.store(false); +} + +} // namespace + +// ---------------------------------------------------------------------------- +// API publica +// ---------------------------------------------------------------------------- + +bool chat_init(const char* ops_db_path, const char* app_db_path, + const char* app_dir) +{ + if (!g_st) g_st = new State(); + g_st->ops_db = ops_db_path ? ops_db_path : ""; + g_st->app_db = app_db_path ? app_db_path : ""; + g_st->app_dir = app_dir ? app_dir : ""; + + // Path del log: junto a app_db (= graph_explorer.db, sibling del exe en + // Windows). Si app_db esta vacio, fallback a stderr-only. + if (!g_st->app_db.empty()) { + std::string p = g_st->app_db; + // strip ".db" / sustituir extension + size_t slash = p.find_last_of("/\\"); + size_t dir_end = (slash == std::string::npos) ? 0 : slash + 1; + g_log_path = p.substr(0, dir_end) + "agent.log"; + // arranca con un separador para distinguir sesiones + FILE* f = std::fopen(g_log_path.c_str(), "ab"); + if (f) { + std::fputs("\n========================================\n", f); + std::fclose(f); + } + } + chat_log("init", "ops_db=%s", g_st->ops_db.c_str()); + chat_log("init", "app_db=%s", g_st->app_db.c_str()); + chat_log("init", "app_dir=%s", g_st->app_dir.c_str()); + chat_log("init", "log_path=%s", g_log_path.c_str()); + + detect_claude(); + + // navegator: NO escribimos mcp.json. cdp-cli aun no tiene mcp-server + // subcomando. claude usa Bash para invocar curl + cdp-cli directos — + // suficiente y simple. Si en v2 cdp-cli expone mcp-server, conectar aqui. + g_st->mcp_config_path = ""; + + return g_st->ready; +} + +void chat_set_ops_db(const char* ops_db_path) { + if (!g_st) return; + if (!ops_db_path) return; + if (g_st->ops_db == ops_db_path) return; + g_st->ops_db = ops_db_path; + // resetear sesion para que el nuevo grafo se contextualice en turno 1 + { + std::lock_guard lk(g_st->mu); + g_st->session_id.clear(); + g_st->first_turn_done = false; + g_st->history.push_back({ChatMessage::SYSTEM, + "[ops_db cambiada → nueva sesion]", "", ""}); + } + // Regenerar mcp.json — el server hereda env del proceso claude que se + // arranca por turno, asi que con env-vars actualizadas en worker_send + // el MCP server vera los nuevos paths. El fichero mcp.json en si no + // contiene los paths, asi que tecnicamente no hace falta reescribir; + // lo regeneramos por si el path del propio mcp.json cambio. + if (g_st->ready) { + g_st->mcp_config_path = write_mcp_config(); + } +} + +void chat_send(const char* user_text) { + if (!g_st || !user_text || !*user_text) return; + chat_log("send", "user msg, %zu bytes, ready=%d busy=%d", + std::strlen(user_text), (int)g_st->ready, + (int)g_st->busy.load()); + if (!g_st->ready) { + std::lock_guard lk(g_st->mu); + g_st->history.push_back({ChatMessage::ERROR_MSG, + std::string("claude no detectado — revisa ") + g_log_path + + ". En Windows necesitas Claude Code instalado en WSL.", + "", ""}); + return; + } + if (g_st->busy.exchange(true)) { + chat_log("send", "ignorado: ya hay una vuelta en curso"); + return; + } + + { + std::lock_guard lk(g_st->mu); + g_st->history.push_back({ChatMessage::USER, user_text, "", ""}); + g_st->scroll_to_bottom = true; + } + if (g_st->worker.joinable()) g_st->worker.join(); + g_st->worker = std::thread(worker_send, std::string(user_text)); +} + +int chat_mutations_counter() { + // Lee el mtime del marker file `.mutations.marker` junto a graph_explorer.db. + // Devuelve mtime en segundos como int — si cambia entre polls, el caller + // sabe que hay nueva mutacion. Si el fichero no existe, devuelve 0. + // + // Ventaja sobre tabla SQLite: stat() funciona cross-filesystem-boundary + // (NTFS <-> 9p) sin contencion. SQLite WAL no. + if (!g_st || g_st->app_db.empty()) return 0; + std::string marker = g_st->app_db; + size_t slash = marker.find_last_of("/\\"); + if (slash != std::string::npos) { + marker = marker.substr(0, slash + 1) + ".mutations.marker"; + } else { + marker = ".mutations.marker"; + } +#ifdef _WIN32 + WIN32_FILE_ATTRIBUTE_DATA fad; + if (!GetFileAttributesExA(marker.c_str(), GetFileExInfoStandard, &fad)) { + return 0; + } + // FILETIME es 100-nanoseg desde 1601 — suficiente como int monotono. + return (int)((((uint64_t)fad.ftLastWriteTime.dwHighDateTime << 32) + | fad.ftLastWriteTime.dwLowDateTime) / 10000000ULL); +#else + struct stat st; + if (::stat(marker.c_str(), &st) != 0) return 0; + return (int)st.st_mtime; +#endif +} + +void chat_render(bool* panel_open) { + if (!g_st) return; + if (panel_open && !*panel_open) return; + + if (!ImGui::Begin(TI_MESSAGE_CIRCLE " Agent", panel_open)) { + ImGui::End(); + return; + } + + // Status line + toolbar (Copy all, Clear-via-system). + auto copy_all_to_clipboard = []() { + std::lock_guard lk(g_st->mu); + std::string buf; + buf.reserve(8192); + for (const auto& m : g_st->history) { + const char* role = "?"; + switch (m.kind) { + case ChatMessage::USER: role = "You"; break; + case ChatMessage::ASSISTANT: role = "Agent"; break; + case ChatMessage::TOOL_USE: role = m.tool_name.empty() ? "tool" : m.tool_name.c_str(); break; + case ChatMessage::TOOL_RESULT: role = "result"; break; + case ChatMessage::SYSTEM: role = "system"; break; + case ChatMessage::ERROR_MSG: role = "error"; break; + } + buf += "## "; + buf += role; + buf += "\n"; + if (m.kind == ChatMessage::TOOL_USE && !m.tool_input.empty()) { + buf += m.tool_input; + } else { + buf += m.text; + } + buf += "\n\n"; + } + ImGui::SetClipboardText(buf.c_str()); + }; + + if (!g_st->ready) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.3f, 1.0f), + TI_ALERT_TRIANGLE " claude no detectado"); + ImGui::SameLine(); + if (ImGui::SmallButton(TI_COPY " Copy all")) copy_all_to_clipboard(); + ImGui::Separator(); + } else { + ImGui::TextDisabled("ops_db: %s", + g_st->ops_db.empty() ? "(none)" : g_st->ops_db.c_str()); + ImGui::SameLine(); + ImGui::Checkbox("raw", &g_st->show_raw); + ImGui::SameLine(); + if (ImGui::SmallButton(TI_COPY " Copy all")) copy_all_to_clipboard(); + ImGui::Separator(); + } + + // History + float input_h = 80.0f; + ImGui::BeginChild("##history", ImVec2(0, -input_h - 4.0f), true); + { + std::lock_guard lk(g_st->mu); + for (const auto& m : g_st->history) { + switch (m.kind) { + case ChatMessage::USER: + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), + TI_USER " You"); + fn_ui::selectable_text_wrapped_force(m.text.c_str()); + break; + case ChatMessage::ASSISTANT: + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1.0f), + TI_ROBOT " Agent"); + fn_ui::selectable_text_wrapped_force(m.text.c_str()); + break; + case ChatMessage::TOOL_USE: + ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), + TI_TOOL " %s", m.tool_name.empty() + ? "(tool)" : m.tool_name.c_str()); + if (!m.tool_input.empty()) { + fn_ui::selectable_text_wrapped_force(m.tool_input.c_str()); + } + break; + case ChatMessage::TOOL_RESULT: + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + TI_CORNER_DOWN_LEFT " result"); + fn_ui::selectable_text_wrapped_force(m.text.c_str()); + break; + case ChatMessage::SYSTEM: + ImGui::PushStyleColor(ImGuiCol_Text, + ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::TextWrapped("%s", m.text.c_str()); + ImGui::PopStyleColor(); + break; + case ChatMessage::ERROR_MSG: + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(1.0f, 0.4f, 0.3f, 1.0f)); + fn_ui::selectable_text_wrapped_force(m.text.c_str()); + ImGui::PopStyleColor(); + break; + } + ImGui::Spacing(); + } + if (g_st->show_raw) { + ImGui::Separator(); + ImGui::TextDisabled("== raw stream-json =="); + for (const auto& l : g_st->raw_lines) { + ImGui::TextWrapped("%s", l.c_str()); + } + } + } + if (g_st->scroll_to_bottom) { + ImGui::SetScrollHereY(1.0f); + g_st->scroll_to_bottom = false; + } + ImGui::EndChild(); + + // Spinner + if (g_st->busy.load()) { + ImGui::Text(TI_LOADER " thinking..."); + } else { + ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight())); + } + + // Input + ImGui::PushItemWidth(-80.0f); + bool send = ImGui::InputTextMultiline( + "##chat_input", g_st->input_buf, sizeof(g_st->input_buf), + ImVec2(0, input_h - 4.0f), + ImGuiInputTextFlags_EnterReturnsTrue + | ImGuiInputTextFlags_CtrlEnterForNewLine); + ImGui::PopItemWidth(); + ImGui::SameLine(); + if (ImGui::Button("Send", ImVec2(72.0f, input_h - 4.0f))) send = true; + + if (send && !g_st->busy.load() && g_st->input_buf[0]) { + chat_send(g_st->input_buf); + g_st->input_buf[0] = 0; + } + + ImGui::End(); +} + +void chat_shutdown() { + if (!g_st) return; + if (g_st->worker.joinable()) { + // No bloqueamos forzosamente — claude -p termina solo al cerrar stdin + g_st->worker.join(); + } + delete g_st; + g_st = nullptr; +} + +} // namespace app_agent diff --git a/agent.h b/agent.h new file mode 100644 index 0000000..aef1a1f --- /dev/null +++ b/agent.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +// 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 `/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 diff --git a/app.md b/app.md index c1556d2..c491504 100644 --- a/app.md +++ b/app.md @@ -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) diff --git a/cdp_http.cpp b/cdp_http.cpp new file mode 100644 index 0000000..1075900 --- /dev/null +++ b/cdp_http.cpp @@ -0,0 +1,285 @@ +#include "cdp_http.h" + +#include "crude_json.h" + +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +# include +#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(); +} + +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(); +} + +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& 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(); + 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 diff --git a/cdp_http.h b/cdp_http.h new file mode 100644 index 0000000..c6ba002 --- /dev/null +++ b/cdp_http.h @@ -0,0 +1,62 @@ +#pragma once + +// CDP HTTP client (read + control endpoints sin WebSocket). +// +// Base: http://127.0.0.1:/json/... +// GET /json/version -> info navegador + browser webSocketDebuggerUrl +// GET /json (o /json/list) -> array de targets (pages, iframes, workers) +// PUT /json/new? -> crea pestaña nueva (Chrome 137+ requiere PUT) +// GET /json/activate/ -> focus pestaña +// GET /json/close/ -> cierra pestaña +// +// Implementacion: WinSock raw + parser HTTP minimo + crude_json para parsear +// payloads. Sin dependencias nuevas. + +#include +#include + +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& 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 diff --git a/cdp_ws.cpp b/cdp_ws.cpp new file mode 100644 index 0000000..7058cbf --- /dev/null +++ b/cdp_ws.cpp @@ -0,0 +1,435 @@ +#include "cdp_ws.h" + +#include "crude_json.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# include +#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 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 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 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 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 lk(queue_mu_); + if (queue_.size() < 100000) queue_.push(std::move(payload)); + } + } else if (opcode == 0x9) { + // Ping -> pong. + std::lock_guard 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 CdpWs::drain(size_t max) { + std::vector out; + std::lock_guard 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 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 diff --git a/cdp_ws.h b/cdp_ws.h new file mode 100644 index 0000000..82ec96c --- /dev/null +++ b/cdp_ws.h @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +#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 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 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 running_{false}; + std::atomic stop_{false}; + std::atomic next_id_{1}; + std::atomic bytes_in_{0}; + std::atomic bytes_out_{0}; + std::atomic frames_in_{0}; + + std::mutex queue_mu_; + std::queue queue_; + + std::mutex resp_mu_; + std::condition_variable resp_cv_; + std::unordered_map responses_; + + std::mutex send_mu_; // serializa envio para no entrelazar frames + mutable std::mutex err_mu_; + std::string last_err_; +}; + +} // namespace navegator diff --git a/local_api.cpp b/local_api.cpp index 90f21a7..0f699c5 100644 --- a/local_api.cpp +++ b/local_api.cpp @@ -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 #include #include #include +#include #include #include #include @@ -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 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& 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 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& 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); } diff --git a/main.cpp b/main.cpp index 96bf29e..714a91f 100644 --- a/main.cpp +++ b/main.cpp @@ -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 +#include 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); } diff --git a/network_state.cpp b/network_state.cpp new file mode 100644 index 0000000..48b5494 --- /dev/null +++ b/network_state.cpp @@ -0,0 +1,507 @@ +#include "network_state.h" + +#include "crude_json.h" + +#include +#include +#include + +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() : 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() : 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() : def; +} + +void parse_headers(const crude_json::value& obj, std::vector& out) { + out.clear(); + if (!obj.is_object()) return; + const auto& m = obj.get(); + 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(); + else if (kv.second.is_number()) { + char b[32]; + std::snprintf(b, sizeof(b), "%g", kv.second.get()); + 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(); + 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 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 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 req; + { + std::lock_guard 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() : ""; + const auto& params = at(v, "params"); + if (!params.is_object()) return; + + const double now_secs = + std::chrono::duration(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 req; + { + std::lock_guard lk(mu_); + auto it = by_id_.find(rid); + if (it == by_id_.end()) { + req = std::make_shared(); + 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 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 req; + { + std::lock_guard 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 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 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 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 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 lk(mu_); + auto it = by_id_.find(rid); + std::shared_ptr req; + if (it == by_id_.end()) { + req = std::make_shared(); + 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 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 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 lk(mu_); + clear_log_locked(); + t0_ = std::chrono::steady_clock::now(); + } + } + } + else if (method == "Page.domContentEventFired") { + std::lock_guard lk(mu_); + stats_.dom_content_loaded = now_secs; + } + else if (method == "Page.loadEventFired") { + std::lock_guard lk(mu_); + stats_.load_event = now_secs; + } +} + +std::vector> NetworkSession::snapshot() const { + std::lock_guard lk(mu_); + return requests_; +} + +NetworkStats NetworkSession::stats() const { + std::lock_guard lk(mu_); + return stats_; +} + +std::string NetworkSession::export_har_json() const { + // HAR 1.2 minimo. log.entries[].request/response/timings. + std::lock_guard 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 diff --git a/network_state.h b/network_state.h new file mode 100644 index 0000000..e498db4 --- /dev/null +++ b/network_state.h @@ -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 +#include +#include +#include +#include +#include + +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 request_headers; + std::vector 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 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> snapshot() const; + NetworkStats stats() const; + + // Drag-and-drop import/export HAR. + std::string export_har_json() const; + +private: + std::unique_ptr ws_; + std::string ws_url_; + std::string last_err_; + std::atomic preserve_log_{true}; + std::atomic cache_disabled_{false}; + + mutable std::mutex mu_; + std::vector> requests_; + std::unordered_map> 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 diff --git a/panels.cpp b/panels.cpp index a15a952..3f01402 100644 --- a/panels.cpp +++ b/panels.cpp @@ -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 #include #include #include +#include +#include +#include #include +#include #include #include #include +#include #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 scanning{false}; std::atomic ever_scanned{false}; - std::atomic 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 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 lk(g_session().mu); + sel_port = g_session().selected_port; + } + std::lock_guard 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 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 v; + std::string err; + bool ok = cdp_list_tabs(port, v, &err); + { + std::lock_guard 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 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 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 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 tabs_copy; + { + std::lock_guard 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 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 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 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> 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 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(); } diff --git a/session_state.cpp b/session_state.cpp new file mode 100644 index 0000000..ee38453 --- /dev/null +++ b/session_state.cpp @@ -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 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 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(); + 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 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 diff --git a/session_state.h b/session_state.h new file mode 100644 index 0000000..e0627bb --- /dev/null +++ b/session_state.h @@ -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 +#include +#include +#include +#include + +namespace navegator { + +struct SessionState { + std::mutex mu; + + int selected_port = 0; + std::string selected_tab_id; + std::string selected_tab_ws_url; + + std::vector tabs; + std::chrono::steady_clock::time_point last_tabs_refresh; + std::string tabs_error; + bool tabs_refreshing = false; + + std::unique_ptr 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