chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# navegator_dashboard — Windows-only por diseño.
|
||||
# Linux: skip silenciosamente para que cpp/build/ no falle.
|
||||
|
||||
if(NOT WIN32)
|
||||
message(STATUS "navegator_dashboard: skipping (Windows-only).")
|
||||
return()
|
||||
endif()
|
||||
|
||||
add_imgui_app(navegator_dashboard
|
||||
main.cpp
|
||||
chrome_scanner.cpp
|
||||
chrome_launcher.cpp
|
||||
local_api.cpp
|
||||
panels.cpp
|
||||
)
|
||||
|
||||
target_include_directories(navegator_dashboard PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(navegator_dashboard PRIVATE ws2_32)
|
||||
|
||||
set_target_properties(navegator_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: navegator_dashboard
|
||||
lang: cpp
|
||||
domain: tools
|
||||
description: "Cuadro de mandos para gestionar instancias Chrome con remote debugging. Lista navegadores corriendo (visibles + headless), permite lanzar/matar perfiles, inspeccionar pestañas, ejecutar JS, ver peticiones de red. Puente WSL→Windows que centraliza el control que hoy hacemos por scripts dispersos."
|
||||
tags: [imgui, browser, cdp, dashboard, windows, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
framework: "imgui"
|
||||
entry_point: "main.cpp"
|
||||
dir_path: "projects/navegator/apps/navegator_dashboard"
|
||||
repo_url: ""
|
||||
|
||||
e2e_checks:
|
||||
- id: build_windows
|
||||
cmd: "cd /home/lucas/fn_registry && ./fn run compile_cpp_app navegator_dashboard"
|
||||
timeout_s: 600
|
||||
- id: exe_present
|
||||
cmd: "test -f /mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe"
|
||||
---
|
||||
|
||||
## Proposito
|
||||
|
||||
Centralizar el control de **todas** las instancias de Chrome con `--remote-debugging-port` que se lancen en el equipo. Sustituye los scripts sueltos `start.sh`/`stop.sh`/`nav.sh` de `projects/navegator/scripts/` por una UI unificada + API HTTP local.
|
||||
|
||||
Casos de uso:
|
||||
|
||||
- Ver de un vistazo cuantos Chrome estan vivos, en que puerto, con que profile, headless o no.
|
||||
- Lanzar/matar perfiles desde la UI sin tocar terminal.
|
||||
- Listar pestañas de cada instancia, navegar, cerrar, capturar HTML/screenshot.
|
||||
- Inspeccionar peticiones de red en vivo (CDP `Network.*` events).
|
||||
- Exponer todo eso como API HTTP local para que scripts/agentes/cdp-cli/graph_explorer hablen con un solo endpoint en vez de cada uno re-implementar el control.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
┌───────────── navegator_dashboard.exe (Windows) ─────────────┐
|
||||
│ │
|
||||
│ UI ImGui (panels) HTTP API (127.0.0.1:1923X) │
|
||||
│ ├── Browsers GET /browsers │
|
||||
│ ├── Tabs POST /browser/spawn │
|
||||
│ ├── Tab Detail POST /browser/{port}/kill │
|
||||
│ └── Network GET /browser/{port}/tabs │
|
||||
│ POST /browser/{port}/navigate │
|
||||
│ Worker threads: GET /browser/{port}/tab/{id}/html│
|
||||
│ ├── ChromeScanner GET /browser/{port}/network/log │
|
||||
│ ├── CdpHttpClient ... │
|
||||
│ └── CdpWsStream │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓ scan/spawn/kill ↓ /json + ws
|
||||
┌─────────── chrome.exe N instancias (Windows) ───────────────┐
|
||||
│ perfil=default port=19222 [visible] │
|
||||
│ perfil=osint port=19223 [visible] │
|
||||
│ perfil=headless_scrape port=19224 [headless] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Panels (v0 → v1)
|
||||
|
||||
| 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 |
|
||||
|
||||
## Stack
|
||||
|
||||
- `fn::run_app` (cpp/framework/app_base.h) — shell estandar (PATTERNS.md).
|
||||
- ImGui + ImPlot (chart Network).
|
||||
- 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.
|
||||
|
||||
## Decisiones consciente (cpp_apps.md §9)
|
||||
|
||||
- `viewports`: `true` (default) — la app puede arrastrar paneles fuera del main.
|
||||
- `init_gl_loader`: `false` — solo ImGui, sin OpenGL custom.
|
||||
- `local_files/`: la app guarda su `app_settings.ini` + `navegator_dashboard.db` con historial de instancias y perfiles favoritos.
|
||||
- Modo CLI: `--api-only --port N` para correr sin UI (daemon puro). v1+.
|
||||
|
||||
## Plataforma
|
||||
|
||||
- **Windows-only**. La app vive en WSL como codigo pero solo se compila como binario Windows (mingw-w64). En Linux, `CMakeLists.txt` hace `return()` antes de `add_imgui_app` para que `cpp/build/` no falle.
|
||||
- Razon: 90% de la logica usa Win32 (CreateProcess, WMI, taskkill). Linux Chrome es secundario y ya esta cubierto por `cdp-cli` directo.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- v0 (este commit): skeleton + Browsers panel funcional + 3 stubs.
|
||||
- v1: CDP HTTP/WS in-process + Tabs + Tab Detail + Network panel.
|
||||
- v2: HTTP API local + integracion con `cdp-cli` (cdp-cli puede delegar al dashboard si esta vivo).
|
||||
- v3: streaming live de pestañas (CDP `Page.startScreencast`) — arquitectura ya prevista en issue 0038.
|
||||
@@ -0,0 +1,132 @@
|
||||
#include "chrome_launcher.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace navegator {
|
||||
|
||||
namespace {
|
||||
|
||||
#ifdef _WIN32
|
||||
const char* kChromePaths[] = {
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
};
|
||||
|
||||
bool file_exists_w(const char* path) {
|
||||
DWORD attrs = GetFileAttributesA(path);
|
||||
return (attrs != INVALID_FILE_ATTRIBUTES) && !(attrs & FILE_ATTRIBUTE_DIRECTORY);
|
||||
}
|
||||
|
||||
std::string find_chrome() {
|
||||
for (const char* p : kChromePaths) {
|
||||
if (file_exists_w(p)) return p;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Quote para argv en CreateProcess (rules de MSVCR / Chromium):
|
||||
// envolver en " y escapar las " internas con \" y los \ que preceden a " con \\.
|
||||
std::string quote_arg(const std::string& a) {
|
||||
if (a.empty()) return "\"\"";
|
||||
bool needs = a.find_first_of(" \t\"") != std::string::npos;
|
||||
if (!needs) return a;
|
||||
std::string out = "\"";
|
||||
int backslashes = 0;
|
||||
for (char c : a) {
|
||||
if (c == '\\') {
|
||||
++backslashes;
|
||||
out += c;
|
||||
} else if (c == '"') {
|
||||
// Duplicar las \\ acumuladas + escapar la "
|
||||
for (int i = 0; i < backslashes; ++i) out += '\\';
|
||||
backslashes = 0;
|
||||
out += "\\\"";
|
||||
} else {
|
||||
backslashes = 0;
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
// Cerrar: duplicar \\ pendientes.
|
||||
for (int i = 0; i < backslashes; ++i) out += '\\';
|
||||
out += '"';
|
||||
return out;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
LaunchResult launch_chrome(const LaunchOpts& opts) {
|
||||
LaunchResult r;
|
||||
#ifndef _WIN32
|
||||
(void)opts;
|
||||
r.error = "launch_chrome: solo Windows en v0";
|
||||
return r;
|
||||
#else
|
||||
std::string chrome = opts.chrome_path.empty() ? find_chrome() : opts.chrome_path;
|
||||
if (chrome.empty()) {
|
||||
r.error = "chrome.exe no encontrado en Program Files";
|
||||
return r;
|
||||
}
|
||||
if (opts.user_data_dir.empty()) {
|
||||
r.error = "user_data_dir obligatorio para aislar perfil";
|
||||
return r;
|
||||
}
|
||||
|
||||
// Crear el directorio si no existe (ignoramos errores, Chrome se queja si no puede).
|
||||
CreateDirectoryA(opts.user_data_dir.c_str(), nullptr);
|
||||
|
||||
char port_buf[32];
|
||||
std::snprintf(port_buf, sizeof(port_buf), "--remote-debugging-port=%d", opts.port);
|
||||
|
||||
std::string cmd;
|
||||
cmd = quote_arg(chrome);
|
||||
cmd += " ";
|
||||
cmd += quote_arg(port_buf);
|
||||
cmd += " --remote-allow-origins=*";
|
||||
cmd += " ";
|
||||
cmd += quote_arg(std::string("--user-data-dir=") + opts.user_data_dir);
|
||||
cmd += " --no-first-run --no-default-browser-check";
|
||||
if (opts.headless) {
|
||||
cmd += " --headless=new --disable-gpu";
|
||||
}
|
||||
if (!opts.start_url.empty()) {
|
||||
cmd += " ";
|
||||
cmd += quote_arg(opts.start_url);
|
||||
} else {
|
||||
cmd += " about:blank";
|
||||
}
|
||||
|
||||
STARTUPINFOA si{};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
|
||||
// CreateProcess modifica el commandline buffer — copia mutable.
|
||||
std::string mutable_cmd = cmd;
|
||||
BOOL ok = CreateProcessA(
|
||||
nullptr,
|
||||
mutable_cmd.data(),
|
||||
nullptr, nullptr, FALSE,
|
||||
CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS,
|
||||
nullptr, nullptr, &si, &pi);
|
||||
if (!ok) {
|
||||
DWORD err = GetLastError();
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "CreateProcess failed (err=%lu)", (unsigned long)err);
|
||||
r.error = buf;
|
||||
return r;
|
||||
}
|
||||
r.ok = true;
|
||||
r.pid = (uint32_t)pi.dwProcessId;
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
return r;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace navegator
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace navegator {
|
||||
|
||||
struct LaunchOpts {
|
||||
std::string chrome_path; // por defecto auto-detect
|
||||
int port = 19222; // remote-debugging-port
|
||||
std::string user_data_dir; // ruta Windows (C:\...) — obligatorio para aislar
|
||||
bool headless = false;
|
||||
std::string start_url; // URL inicial; vacio = about:blank
|
||||
};
|
||||
|
||||
struct LaunchResult {
|
||||
bool ok = false;
|
||||
uint32_t pid = 0;
|
||||
std::string error; // descripcion humana si ok=false
|
||||
};
|
||||
|
||||
// Lanza chrome.exe con los flags de remote debugging que YA descubrimos
|
||||
// que funcionan en Chrome 147 (ver projects/navegator/notes/2026-05-09-...):
|
||||
// --remote-debugging-port=<port>
|
||||
// --remote-allow-origins=* [obligatorio Chrome 111+]
|
||||
// --user-data-dir=<dir>
|
||||
// --no-first-run --no-default-browser-check
|
||||
// + flags si headless.
|
||||
//
|
||||
// NO usa --remote-debugging-address=0.0.0.0 (rompe bind en Chrome 147).
|
||||
LaunchResult launch_chrome(const LaunchOpts& opts);
|
||||
|
||||
} // namespace navegator
|
||||
@@ -0,0 +1,203 @@
|
||||
#include "chrome_scanner.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace navegator {
|
||||
|
||||
namespace {
|
||||
|
||||
#ifdef _WIN32
|
||||
// Ejecuta un comando con CreateProcess + pipe stdout, OCULTANDO la consola.
|
||||
// _popen siempre muestra cmd.exe en GUI apps WIN32_EXECUTABLE — para auto-rescan
|
||||
// cada 2s eso parpadea sin parar. CREATE_NO_WINDOW evita el flicker.
|
||||
std::string run_capture(const std::string& cmd) {
|
||||
HANDLE r_pipe = nullptr;
|
||||
HANDLE w_pipe = nullptr;
|
||||
SECURITY_ATTRIBUTES sa{};
|
||||
sa.nLength = sizeof(sa);
|
||||
sa.bInheritHandle = TRUE;
|
||||
if (!CreatePipe(&r_pipe, &w_pipe, &sa, 0)) return "";
|
||||
SetHandleInformation(r_pipe, HANDLE_FLAG_INHERIT, 0);
|
||||
|
||||
STARTUPINFOA si{};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
|
||||
si.wShowWindow = SW_HIDE;
|
||||
si.hStdOutput = w_pipe;
|
||||
si.hStdError = w_pipe;
|
||||
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||
|
||||
PROCESS_INFORMATION pi{};
|
||||
std::string mutable_cmd = cmd;
|
||||
BOOL ok = CreateProcessA(
|
||||
nullptr,
|
||||
mutable_cmd.data(),
|
||||
nullptr, nullptr, TRUE,
|
||||
CREATE_NO_WINDOW,
|
||||
nullptr, nullptr, &si, &pi);
|
||||
|
||||
CloseHandle(w_pipe); // padre no escribe
|
||||
|
||||
if (!ok) {
|
||||
CloseHandle(r_pipe);
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string out;
|
||||
char buf[4096];
|
||||
DWORD nread = 0;
|
||||
while (ReadFile(r_pipe, buf, sizeof(buf), &nread, nullptr) && nread > 0) {
|
||||
out.append(buf, nread);
|
||||
}
|
||||
CloseHandle(r_pipe);
|
||||
|
||||
WaitForSingleObject(pi.hProcess, 30000);
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
return out;
|
||||
}
|
||||
#else
|
||||
std::string run_capture(const std::string& cmd) {
|
||||
FILE* pipe = popen(cmd.c_str(), "r");
|
||||
if (!pipe) return "";
|
||||
std::string out;
|
||||
char buf[4096];
|
||||
while (fgets(buf, sizeof(buf), pipe)) out.append(buf);
|
||||
pclose(pipe);
|
||||
return out;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Extrae el valor de --flag=VALUE o --flag VALUE de una commandline.
|
||||
// Devuelve cadena vacia si no se encuentra.
|
||||
std::string extract_flag(const std::string& cmd, const std::string& flag) {
|
||||
// Busca "--flag=" primero.
|
||||
std::string needle_eq = flag + "=";
|
||||
auto p = cmd.find(needle_eq);
|
||||
if (p != std::string::npos) {
|
||||
size_t start = p + needle_eq.size();
|
||||
// Si empieza con comilla, leer hasta la siguiente.
|
||||
char quote = 0;
|
||||
if (start < cmd.size() && (cmd[start] == '"' || cmd[start] == '\'')) {
|
||||
quote = cmd[start];
|
||||
++start;
|
||||
}
|
||||
size_t end = start;
|
||||
while (end < cmd.size()) {
|
||||
if (quote) {
|
||||
if (cmd[end] == quote) break;
|
||||
} else {
|
||||
if (cmd[end] == ' ') break;
|
||||
}
|
||||
++end;
|
||||
}
|
||||
return cmd.substr(start, end - start);
|
||||
}
|
||||
// Fallback: "--flag VALUE".
|
||||
auto p2 = cmd.find(flag + " ");
|
||||
if (p2 != std::string::npos) {
|
||||
size_t start = p2 + flag.size() + 1;
|
||||
size_t end = cmd.find(' ', start);
|
||||
if (end == std::string::npos) end = cmd.size();
|
||||
return cmd.substr(start, end - start);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string basename_of_path(const std::string& path) {
|
||||
size_t s1 = path.find_last_of('\\');
|
||||
size_t s2 = path.find_last_of('/');
|
||||
size_t s = std::string::npos;
|
||||
if (s1 != std::string::npos) s = s1;
|
||||
if (s2 != std::string::npos && (s == std::string::npos || s2 > s)) s = s2;
|
||||
if (s == std::string::npos) return path;
|
||||
return path.substr(s + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<ChromeInstance> scan_chrome_instances() {
|
||||
#ifndef _WIN32
|
||||
return {};
|
||||
#else
|
||||
// Format: por cada chrome.exe que tenga --remote-debugging-port, escribimos
|
||||
// dos lineas: "PID:<n>" y "CMD:<commandline>", separadas por "---".
|
||||
// Quoting: el CommandLine puede tener caracteres raros, pero lo escribimos
|
||||
// tal cual y parseamos por prefijo. Sin JSON => sin dependencias.
|
||||
// Filtrar SOLO el master process: tiene --remote-debugging-port y NO tiene
|
||||
// --type= (los renderers/gpu-process/utility heredan el cmdline del padre
|
||||
// pero llevan ademas --type=renderer / --type=gpu-process / etc).
|
||||
const char* ps_script =
|
||||
"powershell.exe -NoProfile -Command \""
|
||||
"Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" "
|
||||
"| Where-Object { $_.CommandLine -like '*--remote-debugging-port=*' "
|
||||
" -and $_.CommandLine -notlike '*--type=*' } "
|
||||
"| ForEach-Object { "
|
||||
" Write-Output '---'; "
|
||||
" Write-Output ('PID:' + $_.ProcessId); "
|
||||
" Write-Output ('CMD:' + $_.CommandLine) "
|
||||
"}\"";
|
||||
std::string out = run_capture(ps_script);
|
||||
|
||||
std::vector<ChromeInstance> result;
|
||||
std::istringstream ss(out);
|
||||
std::string line;
|
||||
ChromeInstance cur;
|
||||
bool have_cur = false;
|
||||
while (std::getline(ss, line)) {
|
||||
// Trim CR si viene con \r\n.
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back();
|
||||
if (line == "---") {
|
||||
if (have_cur && cur.port > 0) result.push_back(cur);
|
||||
cur = ChromeInstance{};
|
||||
have_cur = true;
|
||||
} else if (line.rfind("PID:", 0) == 0) {
|
||||
try { cur.pid = (uint32_t)std::stoul(line.substr(4)); } catch (...) { cur.pid = 0; }
|
||||
} else if (line.rfind("CMD:", 0) == 0) {
|
||||
cur.command_line = line.substr(4);
|
||||
std::string port_str = extract_flag(cur.command_line, "--remote-debugging-port");
|
||||
try { cur.port = std::stoi(port_str); } catch (...) { cur.port = 0; }
|
||||
cur.user_data_dir = extract_flag(cur.command_line, "--user-data-dir");
|
||||
cur.profile_name = basename_of_path(cur.user_data_dir);
|
||||
cur.headless =
|
||||
cur.command_line.find("--headless") != std::string::npos;
|
||||
}
|
||||
}
|
||||
if (have_cur && cur.port > 0) result.push_back(cur);
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
|
||||
int kill_chromes_by_userdata(const std::string& user_data_dir_substr) {
|
||||
#ifndef _WIN32
|
||||
(void)user_data_dir_substr;
|
||||
return -1;
|
||||
#else
|
||||
if (user_data_dir_substr.empty()) return -1;
|
||||
// Escapamos comillas simples duplicandolas (PowerShell single-quote rule).
|
||||
std::string esc;
|
||||
esc.reserve(user_data_dir_substr.size() * 2);
|
||||
for (char c : user_data_dir_substr) {
|
||||
if (c == '\'') esc += "''";
|
||||
else esc += c;
|
||||
}
|
||||
std::string cmd =
|
||||
"powershell.exe -NoProfile -Command \""
|
||||
"$n = (Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" "
|
||||
"| Where-Object { $_.CommandLine -like '*" + esc + "*' } "
|
||||
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; 1 } "
|
||||
"| Measure-Object).Count; Write-Output $n\"";
|
||||
std::string out = run_capture(cmd);
|
||||
try { return std::stoi(out); } catch (...) { return -1; }
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace navegator
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
namespace navegator {
|
||||
|
||||
// Una instancia de chrome.exe detectada con --remote-debugging-port.
|
||||
struct ChromeInstance {
|
||||
uint32_t pid = 0;
|
||||
int port = 0; // valor de --remote-debugging-port, 0 si no se pudo parsear
|
||||
std::string user_data_dir; // valor de --user-data-dir (Windows path), vacio si no aplica
|
||||
std::string profile_name; // basename de user_data_dir, util como label
|
||||
bool headless = false; // si tiene --headless o --headless=new
|
||||
std::string command_line; // crudo, para diagnostico
|
||||
};
|
||||
|
||||
// Escanea procesos chrome.exe del sistema y devuelve solo los que tienen
|
||||
// --remote-debugging-port. Implementacion v0: invoca PowerShell con
|
||||
// Get-CimInstance. Es lenta (~500ms) — el panel debe llamar en thread aparte.
|
||||
//
|
||||
// Si la consulta falla, retorna lista vacia (no lanza). Linux: stub vacio.
|
||||
std::vector<ChromeInstance> scan_chrome_instances();
|
||||
|
||||
// Mata todos los chrome.exe cuya commandline contenga 'user_data_dir_substr'
|
||||
// como substring. Util para cerrar perfiles aislados sin afectar al Chrome
|
||||
// normal del usuario. Devuelve numero de procesos matados o -1 en error.
|
||||
int kill_chromes_by_userdata(const std::string& user_data_dir_substr);
|
||||
|
||||
} // namespace navegator
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
// local_api.cpp — Servidor HTTP minimo (WinSock) para que scripts/agentes
|
||||
// hablen con el dashboard. Sin dependencias externas: parser de request +
|
||||
// dispatch + JSON escapado a mano. ~250 LoC.
|
||||
|
||||
#include "local_api.h"
|
||||
#include "chrome_scanner.h"
|
||||
#include "chrome_launcher.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <winsock2.h>
|
||||
# include <ws2tcpip.h>
|
||||
# pragma comment(lib, "Ws2_32.lib")
|
||||
#endif
|
||||
|
||||
namespace navegator {
|
||||
|
||||
std::atomic<bool> g_api_running{false};
|
||||
std::atomic<int> g_api_port{0};
|
||||
std::atomic<int> g_api_request_count{0};
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------- JSON helpers ----------
|
||||
std::string json_escape(const std::string& s) {
|
||||
std::string out; out.reserve(s.size() + 8);
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if ((unsigned char)c < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)c);
|
||||
out += buf;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string instance_to_json(const ChromeInstance& i) {
|
||||
std::ostringstream os;
|
||||
os << "{\"pid\":" << i.pid
|
||||
<< ",\"port\":" << i.port
|
||||
<< ",\"profile\":\"" << json_escape(i.profile_name) << "\""
|
||||
<< ",\"user_data_dir\":\"" << json_escape(i.user_data_dir) << "\""
|
||||
<< ",\"headless\":" << (i.headless ? "true" : "false")
|
||||
<< "}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
// ---------- URL decoder + query parser ----------
|
||||
std::string url_decode(const std::string& s) {
|
||||
std::string out; out.reserve(s.size());
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '+') {
|
||||
out += ' ';
|
||||
} else if (s[i] == '%' && i + 2 < s.size()) {
|
||||
int hi = 0, lo = 0;
|
||||
auto hex = [](char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return 0;
|
||||
};
|
||||
hi = hex(s[i + 1]);
|
||||
lo = hex(s[i + 2]);
|
||||
out += (char)((hi << 4) | lo);
|
||||
i += 2;
|
||||
} else {
|
||||
out += s[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> parse_query(const std::string& q) {
|
||||
std::map<std::string, std::string> m;
|
||||
size_t start = 0;
|
||||
while (start < q.size()) {
|
||||
size_t amp = q.find('&', start);
|
||||
std::string pair = q.substr(start, amp == std::string::npos ? std::string::npos : amp - start);
|
||||
size_t eq = pair.find('=');
|
||||
if (eq != std::string::npos) {
|
||||
m[url_decode(pair.substr(0, eq))] = url_decode(pair.substr(eq + 1));
|
||||
} else if (!pair.empty()) {
|
||||
m[url_decode(pair)] = "";
|
||||
}
|
||||
if (amp == std::string::npos) break;
|
||||
start = amp + 1;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------- Default user_data_dir resolver ----------
|
||||
std::string default_user_data_dir(const std::string& profile) {
|
||||
#ifdef _WIN32
|
||||
char buf[MAX_PATH] = {0};
|
||||
DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf));
|
||||
std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public";
|
||||
return base + "\\AppData\\Local\\navegator_profiles\\" + profile;
|
||||
#else
|
||||
(void)profile;
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------- Endpoint handlers ----------
|
||||
struct Response {
|
||||
int status = 200;
|
||||
std::string content_type = "application/json";
|
||||
std::string body;
|
||||
};
|
||||
|
||||
Response handle_health() {
|
||||
Response r;
|
||||
r.body = "{\"ok\":true,\"app\":\"navegator_dashboard\"}";
|
||||
return r;
|
||||
}
|
||||
|
||||
Response handle_browsers() {
|
||||
Response r;
|
||||
auto list = scan_chrome_instances();
|
||||
std::ostringstream os;
|
||||
os << "[";
|
||||
for (size_t i = 0; i < list.size(); ++i) {
|
||||
if (i) os << ",";
|
||||
os << instance_to_json(list[i]);
|
||||
}
|
||||
os << "]";
|
||||
r.body = os.str();
|
||||
return r;
|
||||
}
|
||||
|
||||
Response handle_spawn(const std::map<std::string, std::string>& q) {
|
||||
Response r;
|
||||
LaunchOpts o;
|
||||
auto profile_it = q.find("profile");
|
||||
std::string profile = (profile_it != q.end() && !profile_it->second.empty())
|
||||
? profile_it->second : "default";
|
||||
auto port_it = q.find("port");
|
||||
if (port_it != q.end() && !port_it->second.empty()) {
|
||||
try { o.port = std::stoi(port_it->second); } catch (...) {}
|
||||
}
|
||||
auto headless_it = q.find("headless");
|
||||
if (headless_it != q.end()) {
|
||||
const std::string& v = headless_it->second;
|
||||
o.headless = (v == "1" || v == "true" || v == "yes");
|
||||
}
|
||||
auto udd_it = q.find("user_data_dir");
|
||||
o.user_data_dir = (udd_it != q.end() && !udd_it->second.empty())
|
||||
? udd_it->second : default_user_data_dir(profile);
|
||||
auto url_it = q.find("url");
|
||||
if (url_it != q.end()) o.start_url = url_it->second;
|
||||
|
||||
auto res = launch_chrome(o);
|
||||
std::ostringstream os;
|
||||
os << "{\"ok\":" << (res.ok ? "true" : "false")
|
||||
<< ",\"pid\":" << res.pid
|
||||
<< ",\"port\":" << o.port
|
||||
<< ",\"profile\":\"" << json_escape(profile) << "\""
|
||||
<< ",\"user_data_dir\":\"" << json_escape(o.user_data_dir) << "\"";
|
||||
if (!res.ok) {
|
||||
os << ",\"error\":\"" << json_escape(res.error) << "\"";
|
||||
r.status = 500;
|
||||
}
|
||||
os << "}";
|
||||
r.body = os.str();
|
||||
return r;
|
||||
}
|
||||
|
||||
Response handle_kill(const std::map<std::string, std::string>& q) {
|
||||
Response r;
|
||||
std::string udd;
|
||||
auto udd_it = q.find("user_data_dir");
|
||||
if (udd_it != q.end() && !udd_it->second.empty()) {
|
||||
udd = udd_it->second;
|
||||
} else {
|
||||
auto p_it = q.find("profile");
|
||||
if (p_it != q.end() && !p_it->second.empty()) {
|
||||
udd = default_user_data_dir(p_it->second);
|
||||
}
|
||||
}
|
||||
if (udd.empty()) {
|
||||
r.status = 400;
|
||||
r.body = "{\"ok\":false,\"error\":\"need profile= or user_data_dir=\"}";
|
||||
return r;
|
||||
}
|
||||
int killed = kill_chromes_by_userdata(udd);
|
||||
std::ostringstream os;
|
||||
os << "{\"ok\":" << (killed >= 0 ? "true" : "false")
|
||||
<< ",\"killed\":" << (killed < 0 ? 0 : killed)
|
||||
<< ",\"user_data_dir\":\"" << json_escape(udd) << "\"}";
|
||||
r.body = os.str();
|
||||
return r;
|
||||
}
|
||||
|
||||
Response handle_not_found(const std::string& path) {
|
||||
Response r;
|
||||
r.status = 404;
|
||||
r.body = std::string("{\"ok\":false,\"error\":\"not found: ") + json_escape(path) + "\"}";
|
||||
return r;
|
||||
}
|
||||
|
||||
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);
|
||||
return handle_not_found(path);
|
||||
}
|
||||
|
||||
// ---------- HTTP request parsing ----------
|
||||
struct Request {
|
||||
std::string method;
|
||||
std::string path;
|
||||
std::string query;
|
||||
};
|
||||
|
||||
bool parse_request_line(const std::string& line, Request& req) {
|
||||
size_t sp1 = line.find(' ');
|
||||
if (sp1 == std::string::npos) return false;
|
||||
size_t sp2 = line.find(' ', sp1 + 1);
|
||||
if (sp2 == std::string::npos) return false;
|
||||
req.method = line.substr(0, sp1);
|
||||
std::string url = line.substr(sp1 + 1, sp2 - sp1 - 1);
|
||||
size_t qm = url.find('?');
|
||||
if (qm == std::string::npos) {
|
||||
req.path = url;
|
||||
req.query = "";
|
||||
} else {
|
||||
req.path = url.substr(0, qm);
|
||||
req.query = url.substr(qm + 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
void send_all(SOCKET s, const std::string& data) {
|
||||
size_t sent = 0;
|
||||
while (sent < data.size()) {
|
||||
int n = ::send(s, data.data() + sent, (int)(data.size() - sent), 0);
|
||||
if (n <= 0) return;
|
||||
sent += (size_t)n;
|
||||
}
|
||||
}
|
||||
|
||||
void handle_connection(SOCKET client) {
|
||||
// Leer hasta encontrar \r\n\r\n (cabeceras). Body POST: ignoramos para v0
|
||||
// (los params van en query string, mas simple).
|
||||
std::string buf;
|
||||
char tmp[4096];
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
int n = ::recv(client, tmp, sizeof(tmp), 0);
|
||||
if (n <= 0) break;
|
||||
buf.append(tmp, n);
|
||||
if (buf.find("\r\n\r\n") != std::string::npos) break;
|
||||
if (buf.size() > 65536) break;
|
||||
}
|
||||
|
||||
Request req;
|
||||
Response resp;
|
||||
size_t end_line = buf.find("\r\n");
|
||||
if (end_line == std::string::npos || !parse_request_line(buf.substr(0, end_line), req)) {
|
||||
resp.status = 400;
|
||||
resp.body = "{\"ok\":false,\"error\":\"bad request\"}";
|
||||
} else {
|
||||
resp = dispatch(req.method, req.path, req.query);
|
||||
}
|
||||
|
||||
std::ostringstream out;
|
||||
const char* status_text = "OK";
|
||||
if (resp.status == 400) status_text = "Bad Request";
|
||||
else if (resp.status == 404) status_text = "Not Found";
|
||||
else if (resp.status == 500) status_text = "Internal Server Error";
|
||||
out << "HTTP/1.1 " << resp.status << " " << status_text << "\r\n"
|
||||
<< "Content-Type: " << resp.content_type << "\r\n"
|
||||
<< "Content-Length: " << resp.body.size() << "\r\n"
|
||||
<< "Access-Control-Allow-Origin: *\r\n"
|
||||
<< "Connection: close\r\n"
|
||||
<< "\r\n"
|
||||
<< resp.body;
|
||||
send_all(client, out.str());
|
||||
closesocket(client);
|
||||
g_api_request_count.fetch_add(1);
|
||||
}
|
||||
|
||||
void server_loop(int port) {
|
||||
WSADATA wsa;
|
||||
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return;
|
||||
|
||||
SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (listener == INVALID_SOCKET) { WSACleanup(); return; }
|
||||
BOOL yes = TRUE;
|
||||
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, (const char*)&yes, sizeof(yes));
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons((u_short)port);
|
||||
addr.sin_addr.s_addr = htonl(0x7F000001); // 127.0.0.1 (loopback)
|
||||
|
||||
if (::bind(listener, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
|
||||
closesocket(listener); WSACleanup();
|
||||
g_api_running.store(false);
|
||||
return;
|
||||
}
|
||||
if (::listen(listener, 16) == SOCKET_ERROR) {
|
||||
closesocket(listener); WSACleanup();
|
||||
g_api_running.store(false);
|
||||
return;
|
||||
}
|
||||
|
||||
g_api_running.store(true);
|
||||
g_api_port.store(port);
|
||||
|
||||
while (true) {
|
||||
sockaddr_in client_addr{};
|
||||
int alen = sizeof(client_addr);
|
||||
SOCKET client = ::accept(listener, (sockaddr*)&client_addr, &alen);
|
||||
if (client == INVALID_SOCKET) continue;
|
||||
std::thread(handle_connection, client).detach();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
void start_api_server(int port) {
|
||||
#ifdef _WIN32
|
||||
if (g_api_running.load()) return;
|
||||
std::thread([port]{ server_loop(port); }).detach();
|
||||
#else
|
||||
(void)port;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace navegator
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <atomic>
|
||||
|
||||
namespace navegator {
|
||||
|
||||
// Inicia un servidor HTTP local en 127.0.0.1:port en un thread detached.
|
||||
// Idempotente — si ya esta corriendo, no hace nada.
|
||||
//
|
||||
// Endpoints v1:
|
||||
// GET /health -> "ok"
|
||||
// GET /browsers -> JSON array
|
||||
// POST /spawn?profile=X&port=N&headless=0|1 -> JSON {ok,pid,port}
|
||||
// POST /kill?user_data_dir=... -> JSON {killed:N}
|
||||
// POST /kill?profile=NAME -> JSON {killed:N}
|
||||
//
|
||||
// El servidor solo bindea a 127.0.0.1 (loopback), no expone fuera del PC.
|
||||
// WSL2 con mirrored networking ya alcanza este bind desde WSL.
|
||||
void start_api_server(int port = 19333);
|
||||
|
||||
// Estado para mostrar en la UI (panel Info).
|
||||
extern std::atomic<bool> g_api_running;
|
||||
extern std::atomic<int> g_api_port;
|
||||
extern std::atomic<int> g_api_request_count;
|
||||
|
||||
} // namespace navegator
|
||||
@@ -0,0 +1,59 @@
|
||||
// navegator_dashboard — cuadro de mandos para gestionar instancias Chrome con CDP.
|
||||
//
|
||||
// v0: Browsers panel funcional + 3 stubs (Tabs, Tab Detail, Network).
|
||||
// Ver projects/navegator/apps/navegator_dashboard/app.md para arquitectura completa.
|
||||
|
||||
#include "app_base.h"
|
||||
#include "core/icons_tabler.h"
|
||||
#include "core/panel_menu.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include "local_api.h"
|
||||
|
||||
namespace navegator {
|
||||
void render_browsers_panel(bool* p_open);
|
||||
void render_tabs_panel(bool* p_open);
|
||||
void render_tab_detail_panel(bool* p_open);
|
||||
void render_network_panel(bool* p_open);
|
||||
}
|
||||
|
||||
namespace {
|
||||
bool show_browsers = true;
|
||||
bool show_tabs = true;
|
||||
bool show_tab_detail = false;
|
||||
bool show_network = 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},
|
||||
};
|
||||
} // namespace
|
||||
|
||||
static void render_dashboard() {
|
||||
ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport());
|
||||
if (show_browsers) navegator::render_browsers_panel(&show_browsers);
|
||||
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);
|
||||
}
|
||||
|
||||
int main() {
|
||||
fn::AppConfig cfg;
|
||||
cfg.title = "Navegator Dashboard";
|
||||
cfg.about = {
|
||||
"Navegator Dashboard",
|
||||
"0.1.0",
|
||||
"Cuadro de mandos para Chrome con remote debugging — v0 Browsers panel."
|
||||
};
|
||||
cfg.panels = k_panels;
|
||||
cfg.panel_count = sizeof(k_panels) / sizeof(k_panels[0]);
|
||||
cfg.init_gl_loader = false;
|
||||
|
||||
// HTTP API local (loopback). 127.0.0.1:19333.
|
||||
// Endpoints: /health, /browsers, /spawn, /kill — ver local_api.h.
|
||||
navegator::start_api_server(19333);
|
||||
|
||||
return fn::run_app(cfg, render_dashboard);
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
// Panels v0:
|
||||
// - Browsers: scan + spawn + kill (funcional)
|
||||
// - Tabs / Tab Detail / Network: stubs anunciando v1.
|
||||
|
||||
#include "imgui.h"
|
||||
#include "core/icons_tabler.h"
|
||||
#include "core/tokens.h"
|
||||
|
||||
#include "chrome_scanner.h"
|
||||
#include "chrome_launcher.h"
|
||||
#include "local_api.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace navegator {
|
||||
|
||||
// ---------- Estado compartido del panel Browsers ----------
|
||||
namespace {
|
||||
|
||||
struct BrowsersState {
|
||||
std::mutex mu;
|
||||
std::vector<ChromeInstance> instances;
|
||||
std::chrono::steady_clock::time_point last_scan;
|
||||
std::atomic<bool> scanning{false};
|
||||
std::atomic<bool> ever_scanned{false};
|
||||
std::atomic<int> selected{-1};
|
||||
char new_profile[128] = "default";
|
||||
int new_port = 19222;
|
||||
bool new_headless = false;
|
||||
std::string last_error;
|
||||
};
|
||||
|
||||
BrowsersState g_browsers;
|
||||
|
||||
void rescan_async() {
|
||||
if (g_browsers.scanning.exchange(true)) return;
|
||||
std::thread([]{
|
||||
auto v = scan_chrome_instances();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_browsers.mu);
|
||||
g_browsers.instances = std::move(v);
|
||||
g_browsers.last_scan = std::chrono::steady_clock::now();
|
||||
}
|
||||
g_browsers.ever_scanned.store(true);
|
||||
g_browsers.scanning.store(false);
|
||||
}).detach();
|
||||
}
|
||||
|
||||
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));
|
||||
std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public";
|
||||
return base + "\\AppData\\Local\\navegator_profiles\\" + profile;
|
||||
#else
|
||||
return std::string("/tmp/navegator_profiles/") + profile;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------- Browsers panel ----------
|
||||
|
||||
void render_browsers_panel(bool* p_open) {
|
||||
if (!ImGui::Begin(TI_BROWSER " Browsers", p_open)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-rescan cada 2s.
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_browsers.mu);
|
||||
if (now - g_browsers.last_scan > std::chrono::seconds(2) && !g_browsers.scanning.load()) {
|
||||
rescan_async();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Toolbar.
|
||||
if (ImGui::Button(TI_REFRESH " Rescan")) rescan_async();
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted("New profile:");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(160);
|
||||
ImGui::InputText("##profile", g_browsers.new_profile, sizeof(g_browsers.new_profile));
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted("Port:");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(80);
|
||||
ImGui::InputInt("##port", &g_browsers.new_port, 0, 0);
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Headless", &g_browsers.new_headless);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TI_PLAYER_PLAY " Launch")) {
|
||||
LaunchOpts o;
|
||||
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();
|
||||
}).detach();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TI_X " Kill all navegator")) {
|
||||
kill_chromes_by_userdata("navegator_profiles");
|
||||
std::thread([]{
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
rescan_async();
|
||||
}).detach();
|
||||
}
|
||||
|
||||
if (!g_browsers.last_error.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
||||
ImGui::TextWrapped("Error: %s", g_browsers.last_error.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Tabla.
|
||||
std::lock_guard<std::mutex> lk(g_browsers.mu);
|
||||
// Anti-flicker: solo mostrar "Scanning..." en el primer scan (cuando aun
|
||||
// no tenemos datos). Una vez tenemos al menos un resultado, mantener el
|
||||
// empty-state estable; el badge "(reqs:N)" + el rescan async siguen
|
||||
// corriendo en background sin tocar la UI.
|
||||
if (!g_browsers.ever_scanned.load() && g_browsers.instances.empty()) {
|
||||
ImGui::TextUnformatted("Scanning...");
|
||||
} else if (g_browsers.instances.empty()) {
|
||||
ImGui::TextDisabled("No Chrome instances with --remote-debugging-port detected.");
|
||||
ImGui::TextDisabled("Lanza una con el formulario de arriba o con scripts/start.sh.");
|
||||
} else {
|
||||
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);
|
||||
ImGui::TableSetupColumn("Profile");
|
||||
ImGui::TableSetupColumn("Mode", ImGuiTableColumnFlags_WidthFixed, 80);
|
||||
ImGui::TableSetupColumn("user-data-dir");
|
||||
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 110);
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
int idx = 0;
|
||||
for (const auto& inst : g_browsers.instances) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%u", inst.pid);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%d", inst.port);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(inst.profile_name.empty() ? "(none)" : inst.profile_name.c_str());
|
||||
ImGui::TableNextColumn();
|
||||
if (inst.headless) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::warning);
|
||||
ImGui::TextUnformatted("headless");
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
ImGui::TextUnformatted("visible");
|
||||
}
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(inst.user_data_dir.c_str());
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::PushID(idx);
|
||||
if (ImGui::SmallButton("Kill")) {
|
||||
if (!inst.user_data_dir.empty()) {
|
||||
kill_chromes_by_userdata(inst.user_data_dir);
|
||||
}
|
||||
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;
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ---------- Tabs panel (stub) ----------
|
||||
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.");
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ---------- Tab Detail panel (stub) ----------
|
||||
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)) {
|
||||
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::End();
|
||||
}
|
||||
|
||||
} // namespace navegator
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e_api.sh — Suite end-to-end de la API HTTP de navegator_dashboard.
|
||||
#
|
||||
# Asume que `navegator_dashboard.exe` esta corriendo con la API en 127.0.0.1:19333.
|
||||
# Si no esta, lanza la app primero (no la mata al final — dejarla viva para iterar).
|
||||
#
|
||||
# Cubre:
|
||||
# 1. /health responde OK
|
||||
# 2. /browsers retorna JSON array
|
||||
# 3. POST /spawn lanza chrome.exe + retorna {ok:true,pid,port}
|
||||
# 4. CDP responde en el puerto retornado
|
||||
# 5. cdp-cli puede navegar/evaluar sobre la instancia
|
||||
# 6. /browsers ahora contiene la instancia (1 entrada por chrome master, no N)
|
||||
# 7. POST /kill mata la instancia
|
||||
# 8. CDP deja de responder
|
||||
# 9. /browsers vuelve a no contenerla
|
||||
#
|
||||
# Exit 0 en exito, !=0 en fallo. Cada paso imprime "OK" o "FAIL: ..." a stderr.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
API="${NAVD_API:-http://127.0.0.1:19333}"
|
||||
EXE="${NAVD_EXE:-/mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe}"
|
||||
CDP_CLI="${NAVD_CDP_CLI:-/home/lucas/fn_registry/projects/osint_graph/apps/graph_explorer/cdp-cli/cdp-cli}"
|
||||
|
||||
PROFILE="e2e_$(date +%s)"
|
||||
PORT=19299
|
||||
|
||||
failures=0
|
||||
step() { echo; echo "==[ $1 ]==" >&2; }
|
||||
ok() { echo " OK: $1" >&2; }
|
||||
fail() { echo " FAIL: $1" >&2; failures=$((failures + 1)); }
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "0. dashboard reachable"
|
||||
# ---------------------------------------------------------
|
||||
if ! curl -sf --max-time 2 "$API/health" >/dev/null 2>&1; then
|
||||
if [[ -x "$EXE" ]]; then
|
||||
echo " app no responde, lanzando $EXE..." >&2
|
||||
powershell.exe -NoProfile -Command "Start-Process -FilePath '$(wslpath -w "$EXE" 2>/dev/null || echo "$EXE")'" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 20); do
|
||||
if curl -sf --max-time 1 "$API/health" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
fi
|
||||
if curl -sf --max-time 2 "$API/health" >/dev/null 2>&1; then
|
||||
ok "dashboard up"
|
||||
else
|
||||
fail "dashboard no responde en $API/health"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "1. /health"
|
||||
# ---------------------------------------------------------
|
||||
body="$(curl -sf --max-time 2 "$API/health")"
|
||||
echo " body: $body" >&2
|
||||
[[ "$body" == *'"ok":true'* ]] && ok "/health responde ok=true" || fail "/health body inesperado"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "2. /browsers (initial)"
|
||||
# ---------------------------------------------------------
|
||||
body="$(curl -sf --max-time 2 "$API/browsers")"
|
||||
echo " body: $body" >&2
|
||||
[[ "$body" == \[* ]] && ok "/browsers retorna array" || fail "/browsers no retorna JSON array"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "3. POST /spawn"
|
||||
# ---------------------------------------------------------
|
||||
body="$(curl -sf --max-time 10 -X POST "$API/spawn?profile=$PROFILE&port=$PORT")"
|
||||
echo " body: $body" >&2
|
||||
if [[ "$body" == *'"ok":true'* ]]; then
|
||||
ok "/spawn launched"
|
||||
SPAWNED_PID="$(echo "$body" | sed -nE 's/.*"pid":([0-9]+).*/\1/p')"
|
||||
echo " pid=$SPAWNED_PID port=$PORT profile=$PROFILE" >&2
|
||||
else
|
||||
fail "/spawn fallo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "4. CDP responde en puerto $PORT"
|
||||
# ---------------------------------------------------------
|
||||
ok_cdp=""
|
||||
for _ in $(seq 1 20); do
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
|
||||
ok_cdp=1; break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ -n "$ok_cdp" ]]; then
|
||||
browser="$(curl -sf "http://127.0.0.1:$PORT/json/version" 2>/dev/null | grep -i '"Browser"' | head -1 | tr -d '\r' || true)"
|
||||
ok "CDP up —${browser}"
|
||||
else
|
||||
fail "CDP no responde en $PORT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "5. cdp-cli navigate + evaluate"
|
||||
# ---------------------------------------------------------
|
||||
if [[ -x "$CDP_CLI" ]]; then
|
||||
"$CDP_CLI" navigate --port "$PORT" --url "data:text/html,<h1 id=t>e2e ok</h1>" >/dev/null 2>&1 \
|
||||
&& ok "navigate OK" || fail "navigate fallo"
|
||||
title="$("$CDP_CLI" evaluate --port "$PORT" --js "document.getElementById('t').innerText" 2>/dev/null || echo "")"
|
||||
[[ "$title" == "e2e ok" ]] && ok "evaluate retorno texto correcto" || fail "evaluate retorno: '$title'"
|
||||
else
|
||||
fail "cdp-cli no encontrado en $CDP_CLI"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "6. /browsers contiene instancia (filtro --type=)"
|
||||
# ---------------------------------------------------------
|
||||
sleep 2 # dar tiempo a que se note en el rescan async del dashboard
|
||||
body="$(curl -sf --max-time 5 "$API/browsers")"
|
||||
echo " body: $body" >&2
|
||||
count_match="$(echo "$body" | grep -c "\"profile\":\"$PROFILE\"" || true)"
|
||||
echo " matches profile $PROFILE: $count_match" >&2
|
||||
if [[ "$count_match" == "1" ]]; then
|
||||
ok "exactamente 1 entrada (master only, no renderers)"
|
||||
elif [[ "$count_match" -ge "1" ]]; then
|
||||
fail "esperaba 1, encontrado $count_match (filtro --type= roto?)"
|
||||
else
|
||||
fail "no encuentro el perfil $PROFILE en /browsers"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "7. POST /kill"
|
||||
# ---------------------------------------------------------
|
||||
body="$(curl -sf --max-time 5 -X POST "$API/kill?profile=$PROFILE")"
|
||||
echo " body: $body" >&2
|
||||
[[ "$body" == *'"ok":true'* ]] && ok "/kill respondio ok" || fail "/kill fallo"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "8. CDP ya no responde"
|
||||
# ---------------------------------------------------------
|
||||
sleep 2
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
|
||||
fail "CDP sigue vivo en $PORT despues de kill"
|
||||
else
|
||||
ok "CDP muerto"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
step "9. /browsers ya no contiene instancia"
|
||||
# ---------------------------------------------------------
|
||||
sleep 1
|
||||
body="$(curl -sf --max-time 5 "$API/browsers")"
|
||||
count_match="$(echo "$body" | grep -c "\"profile\":\"$PROFILE\"" || true)"
|
||||
[[ "$count_match" == "0" ]] && ok "instancia desaparecida" || fail "/browsers sigue listando perfil $PROFILE ($count_match veces)"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
echo
|
||||
if [[ $failures -eq 0 ]]; then
|
||||
echo "===== e2e_api.sh: ALL PASS =====" >&2
|
||||
exit 0
|
||||
else
|
||||
echo "===== e2e_api.sh: $failures FAILURES =====" >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user