package browser import ( "fmt" "net" "os" "os/exec" "regexp" "strings" "syscall" "time" ) // ChromeLaunchOpts configura el lanzamiento de Chrome con CDP. type ChromeLaunchOpts struct { // Port es el puerto de remote debugging. Por defecto 9222. Port int // UserDataDir es el directorio de perfil de Chrome. Por defecto /tmp/chrome-cdp-profile. // En WSL2 con chrome.exe, si el valor comienza con /tmp/ o /home/ se traduce // automaticamente a una ruta Windows via wslpath. Pasar una ruta Windows // (ej: C:\Users\...) la deja intacta. UserDataDir string // Headless activa el modo headless (--headless=new). Por defecto false. Headless bool // ChromePath es la ruta al ejecutable de Chrome. Si esta vacio, se busca automaticamente. ChromePath string // ExtraArgs permite pasar flags adicionales a Chrome. ExtraArgs []string // KeepExtensions, si es true, NO añade --disable-extensions (mantiene las // extensiones del perfil cargadas). Por defecto false (comportamiento actual). KeepExtensions bool // ProfileDirectory selecciona el perfil dentro del user-data-dir (--profile-directory). // Vacío = no se pasa el flag (Chrome usa su default o muestra el selector si hay varios perfiles). // Ej: "Default", "Automation". ProfileDirectory string // ReuseExisting, si es true y el puerto CDP ya responde a una conexion TCP, // NO lanza un Chrome nuevo: devuelve (0, nil) para que el caller reutilice el // navegador que ya está vivo en ese puerto. Evita acumular procesos chromium // duplicados (cada uno ~789 MiB RSS) cuando se llama repetidamente al mismo // puerto. El caller distingue el reuso por pid == 0. ReuseExisting bool } // reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.). var reWindowsPath = regexp.MustCompile(`(?i)^[A-Z]:\\`) // isWSL2 devuelve true si el proceso corre dentro de WSL2. // Lee /proc/version y busca "microsoft" o "WSL" (case-insensitive). func isWSL2() bool { b, err := os.ReadFile("/proc/version") if err != nil { return false } lower := strings.ToLower(string(b)) return strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl") } // isWindowsExe devuelve true si la ruta del ejecutable corresponde a un .exe // (chrome.exe en Windows via WSL2: puede ser /mnt/c/... o resolverse en PATH). func isWindowsExe(path string) bool { return strings.HasSuffix(strings.ToLower(path), ".exe") } // translateUserDataDirForWindows convierte una ruta Linux a ruta Windows via wslpath. // Ejemplo: "/tmp/foo" -> "C:\Users\lucas\AppData\Local\Temp\foo" (depende del sistema). // Devuelve error si wslpath no esta disponible. func translateUserDataDirForWindows(linuxPath string) (string, error) { out, err := exec.Command("wslpath", "-w", linuxPath).Output() if err != nil { return "", fmt.Errorf("wslpath -w %q: %w", linuxPath, err) } return strings.TrimSpace(string(out)), nil } // defaultWindowsUserDataDir devuelve la ruta Windows del perfil CDP por defecto, // usando la variable de entorno USERNAME de Windows si esta disponible. func defaultWindowsUserDataDir() (string, error) { // Intentar leer el home de Windows via wslpath del home de usuario // Si falla, usar C:\Users\Public\fn-chrome-cdp-profile como fallback. user := os.Getenv("USERNAME") // Windows user via WSL env passthrough if user == "" { user = os.Getenv("USER") } if user == "" { user = "Public" } linuxPath := fmt.Sprintf("/mnt/c/Users/%s/AppData/Local/fn-chrome-cdp-profile", user) return translateUserDataDirForWindows(linuxPath) } // chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia. // Las rutas absolutas a los binarios REALES van primero: saltan el wrapper // /usr/bin/chromium (un script que inyecta los flags de /etc/chromium.d/*, p.ej. // --user-data-dir y --remote-debugging-port globales que pisarian el aislamiento // del navegador del agente). Si no existen, se cae a los nombres de PATH — que // pueden resolver al wrapper, en cuyo caso el aislamiento depende de que nuestros // flags vayan al final (Chrome usa el ultimo --user-data-dir duplicado). var chromePathsLinux = []string{ "/usr/lib/chromium/chromium", "/usr/lib/chromium-browser/chromium-browser", "chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "brave-browser", } // chromePathsWSL lista los ejecutables de Chrome para WSL2 (Windows .exe primero). var chromePathsWSL = []string{ "chrome.exe", "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe", "/mnt/c/Users/Public/Desktop/chrome.exe", // binarios Linux como ultimo recurso en WSL "chromium", "chromium-browser", "google-chrome", "google-chrome-stable", } // findChrome localiza el ejecutable de Chrome en el sistema. // En Linux nativo busca primero binarios Linux; en WSL2 busca primero chrome.exe. func findChrome() (string, error) { var paths []string if isWSL2() { paths = chromePathsWSL } else { // Linux nativo: primero binarios nativos, despues .exe como ultimo recurso paths = append(chromePathsLinux, "chrome.exe", "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe", ) } for _, p := range paths { if path, err := exec.LookPath(p); err == nil { return path, nil } if _, err := os.Stat(p); err == nil { return p, nil } } return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas") } // dialCDP intenta una conexion TCP unica al puerto CDP. Devuelve true si el // puerto acepta la conexion (hay algo escuchando), false en caso contrario. // host vacio usa "127.0.0.1". func dialCDP(host string, port int, timeout time.Duration) bool { if host == "" { host = "127.0.0.1" } addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) conn, err := net.DialTimeout("tcp", addr, timeout) if err != nil { return false } conn.Close() return true } // cdpPortResponds indica si ya hay un proceso escuchando el puerto CDP en // 127.0.0.1. Es un sondeo TCP unico con timeout corto, usado por ChromeLaunch // (opts.ReuseExisting) para no relanzar un Chrome duplicado cuando el puerto ya // tiene uno vivo. func cdpPortResponds(port int) bool { return dialCDP("127.0.0.1", port, 300*time.Millisecond) } // waitCDPReady espera hasta que el puerto CDP responda conexiones TCP. // host puede estar vacio (usa "127.0.0.1"). func waitCDPReady(host string, port int, timeout time.Duration) error { if host == "" { host = "127.0.0.1" } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if dialCDP(host, port, 200*time.Millisecond) { return nil } time.Sleep(200 * time.Millisecond) } return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s", net.JoinHostPort(host, fmt.Sprintf("%d", port)), timeout) } // ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado. // Retorna el PID del proceso Chrome. Espera hasta 15s a que el puerto CDP este listo. // Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. // // WSL2 + chrome.exe: cuando se detecta WSL2 y el ejecutable es un .exe, // - El UserDataDir se traduce automaticamente a ruta Windows via wslpath // (si esta vacio o comienza con /tmp/ o /home/). // - Se inyecta --remote-debugging-address=0.0.0.0 para que Chrome sea // alcanzable desde WSL2 via 127.0.0.1 (el WSL networking reenvía localhost). func ChromeLaunch(opts ChromeLaunchOpts) (int, error) { if opts.Port == 0 { opts.Port = 9222 } // Anti-duplicado: si el caller pide reusar y ya hay un Chrome escuchando el // puerto CDP, no lanzamos otro. Devolvemos pid 0 para que el caller sepa que // debe adjuntarse al existente en vez de registrar un proceso nuevo. if opts.ReuseExisting && cdpPortResponds(opts.Port) { return 0, nil } chromePath := opts.ChromePath if chromePath == "" { var err error chromePath, err = findChrome() if err != nil { return 0, err } } // Detectar si estamos en WSL2 lanzando un exe de Windows wsl2WindowsMode := isWSL2() && isWindowsExe(chromePath) // Resolver UserDataDir userDataDir := opts.UserDataDir if wsl2WindowsMode { switch { case userDataDir == "" || strings.HasPrefix(userDataDir, "/tmp/") || strings.HasPrefix(userDataDir, "/home/"): // Traducir a ruta Windows if userDataDir == "" { var err error userDataDir, err = defaultWindowsUserDataDir() if err != nil { // Fallback seguro: usar una ruta Windows fija userDataDir = `C:\Users\Public\fn-chrome-cdp-profile` } } else { translated, err := translateUserDataDirForWindows(userDataDir) if err != nil { return 0, fmt.Errorf("chrome: traducir user-data-dir para Windows: %w", err) } userDataDir = translated } case reWindowsPath.MatchString(userDataDir): // Ya es una ruta Windows absoluta (C:\...), dejar intacta default: // Ruta que no es ni /tmp/ ni /home/ ni Windows absoluta: // intentar traducir igualmente. translated, err := translateUserDataDirForWindows(userDataDir) if err == nil { userDataDir = translated } } } else if userDataDir == "" { userDataDir = "/tmp/chrome-cdp-profile" } args := []string{ fmt.Sprintf("--remote-debugging-port=%d", opts.Port), fmt.Sprintf("--user-data-dir=%s", userDataDir), "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-client-side-phishing-detection", "--disable-default-apps", "--disable-hang-monitor", "--disable-popup-blocking", "--disable-prompt-on-repost", "--disable-sync", "--disable-translate", "--metrics-recording-only", "--safebrowsing-disable-auto-update", "--remote-allow-origins=*", } if !opts.KeepExtensions { args = append(args, "--disable-extensions") } if opts.ProfileDirectory != "" { args = append(args, fmt.Sprintf("--profile-directory=%s", opts.ProfileDirectory)) } if opts.Headless { args = append(args, "--headless=new", "--disable-gpu") } // En WSL2+Windows: inyectar --remote-debugging-address=0.0.0.0 si no esta ya presente hasBindAll := false for _, a := range opts.ExtraArgs { if a == "--remote-debugging-address=0.0.0.0" { hasBindAll = true break } } if wsl2WindowsMode && !hasBindAll { args = append(args, "--remote-debugging-address=0.0.0.0") hasBindAll = true } args = append(args, opts.ExtraArgs...) cmd := exec.Command(chromePath, args...) // Chrome necesita que no haya stderr/stdout bloqueados cmd.Stdout = nil cmd.Stderr = nil // En Linux nativo (no WSL+Windows), crear un grupo de proceso propio para que // el proceso sobreviva al fin del padre y para poder matar el arbol completo // (chromium lanza zygote, gpu-process, renderers como hijos). // No aplicar en WSL+Windows: chrome.exe se gestiona de forma distinta. if !wsl2WindowsMode { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } if err := cmd.Start(); err != nil { return 0, fmt.Errorf("chrome: arrancar proceso: %w", err) } pid := cmd.Process.Pid // Esperar a que el puerto CDP este listo. // Siempre esperamos, incluyendo el caso WSL2+Windows donde Chrome escucha en // 0.0.0.0 — el WSL networking reenvía localhost:9222 → Windows:9222. // Usamos 127.0.0.1 explicitamente para evitar resolución IPv6 en algunos entornos. _ = hasBindAll // ya no se usa para skipWait if err := waitCDPReady("127.0.0.1", opts.Port, 15*time.Second); err != nil { cmd.Process.Kill() return 0, err } return pid, nil }