package browser import ( "fmt" "net" "os" "os/exec" "regexp" "strings" "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 } // 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) } // chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux. var chromePaths = []string{ "chrome.exe", "google-chrome", "chromium-browser", "chromium", "/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", } // findChrome localiza el ejecutable de Chrome en el sistema. func findChrome() (string, error) { for _, p := range chromePaths { 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 de Windows") } // 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) addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err == nil { conn.Close() return nil } time.Sleep(200 * time.Millisecond) } return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s", addr, 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 } 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-extensions", "--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.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 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 }