From bab0836507244a69eebb1072f71784b3887c4c93 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 00:48:07 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20manejo=20de=20m=C3=BAltiples=20tabs/ven?= =?UTF-8?q?tanas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa gestión completa de tabs del navegador. Incluye: - GetTabs() para listar todos los tabs - NewTab() para crear nuevos tabs - CloseTab() y CloseOtherTabs() - SwitchToTab() para cambiar foco - WaitForNewTab() con callback de acción - GetTabByURL() y GetTabByTitle() para búsqueda - OnTabCreated() para eventos Usa CDP Target domain para comunicación. Archivo: pkg/browser/tabs.go --- pkg/browser/tabs.go | 311 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 pkg/browser/tabs.go diff --git a/pkg/browser/tabs.go b/pkg/browser/tabs.go new file mode 100644 index 0000000..dac3f41 --- /dev/null +++ b/pkg/browser/tabs.go @@ -0,0 +1,311 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "sync" +) + +// Tab representa un tab del navegador +type Tab struct { + ID string + URL string + Title string + Type string // "page" | "background_page" | ... + Attached bool +} + +// tabHandler almacena handlers para eventos de tabs +type tabHandler struct { + onCreate func(*Tab) +} + +var ( + tabHandlers = &tabHandler{} + tabMutex sync.RWMutex +) + +// GetTabs obtiene todos los tabs abiertos +func (b *Browser) GetTabs(ctx context.Context) ([]*Tab, error) { + var result struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + Attached bool `json:"attached"` + } `json:"targetInfos"` + } + + if err := b.cdpClient.Execute(ctx, "Target.getTargets", nil, &result); err != nil { + return nil, fmt.Errorf("failed to get targets: %w", err) + } + + var tabs []*Tab + for _, info := range result.TargetInfos { + if info.Type == "page" { + tabs = append(tabs, &Tab{ + ID: info.TargetID, + URL: info.URL, + Title: info.Title, + Type: info.Type, + Attached: info.Attached, + }) + } + } + + return tabs, nil +} + +// NewTab crea un nuevo tab y retorna su ID +func (b *Browser) NewTab(ctx context.Context, url string) (string, error) { + var result struct { + TargetID string `json:"targetId"` + } + + params := map[string]interface{}{ + "url": url, + } + + if err := b.cdpClient.Execute(ctx, "Target.createTarget", params, &result); err != nil { + return "", fmt.Errorf("failed to create tab: %w", err) + } + + return result.TargetID, nil +} + +// CloseTab cierra un tab específico +func (b *Browser) CloseTab(ctx context.Context, tabID string) error { + params := map[string]interface{}{ + "targetId": tabID, + } + + var result struct { + Success bool `json:"success"` + } + + if err := b.cdpClient.Execute(ctx, "Target.closeTarget", params, &result); err != nil { + return fmt.Errorf("failed to close tab: %w", err) + } + + if !result.Success { + return fmt.Errorf("failed to close tab: CDP returned success=false") + } + + return nil +} + +// SwitchToTab cambia el foco a un tab específico +func (b *Browser) SwitchToTab(ctx context.Context, tabID string) error { + // Activar tab + activateParams := map[string]interface{}{ + "targetId": tabID, + } + + if err := b.cdpClient.Execute(ctx, "Target.activateTarget", activateParams, nil); err != nil { + return fmt.Errorf("failed to activate tab: %w", err) + } + + // Attach al tab si no está attached + attachParams := map[string]interface{}{ + "targetId": tabID, + "flatten": true, + } + + var attachResult struct { + SessionID string `json:"sessionId"` + } + + if err := b.cdpClient.Execute(ctx, "Target.attachToTarget", attachParams, &attachResult); err != nil { + // Puede que ya esté attached, continuar + } + + // Actualizar targetID actual del browser + b.targetID = tabID + + return nil +} + +// GetCurrentTab obtiene el tab actual +func (b *Browser) GetCurrentTab(ctx context.Context) (*Tab, error) { + tabs, err := b.GetTabs(ctx) + if err != nil { + return nil, err + } + + // Buscar el tab con el targetID actual + for _, tab := range tabs { + if tab.ID == b.targetID { + return tab, nil + } + } + + // Si no encontramos, retornar el primero + if len(tabs) > 0 { + return tabs[0], nil + } + + return nil, fmt.Errorf("no tabs found") +} + +// WaitForNewTab espera a que se abra un nuevo tab y lo retorna +func (b *Browser) WaitForNewTab(ctx context.Context, action func()) (*Tab, error) { + // Obtener tabs actuales + currentTabs, err := b.GetTabs(ctx) + if err != nil { + return nil, err + } + + currentIDs := make(map[string]bool) + for _, tab := range currentTabs { + currentIDs[tab.ID] = true + } + + // Canal para recibir nuevo tab + newTabChan := make(chan *Tab, 1) + + // Registrar listener temporal para nuevos tabs + b.cdpClient.On("Target.targetCreated", func(params json.RawMessage) { + var event struct { + TargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + } `json:"targetInfo"` + } + + if err := json.Unmarshal(params, &event); err != nil { + return + } + + // Solo procesar tabs de tipo "page" + if event.TargetInfo.Type == "page" { + // Verificar que es un tab nuevo + if !currentIDs[event.TargetInfo.TargetID] { + newTab := &Tab{ + ID: event.TargetInfo.TargetID, + URL: event.TargetInfo.URL, + Title: event.TargetInfo.Title, + Type: event.TargetInfo.Type, + } + + select { + case newTabChan <- newTab: + default: + } + } + } + }) + + // Ejecutar acción que abrirá el tab + if action != nil { + action() + } + + // Esperar nuevo tab + select { + case newTab := <-newTabChan: + return newTab, nil + case <-ctx.Done(): + return nil, fmt.Errorf("timeout waiting for new tab: %w", ctx.Err()) + } +} + +// OnTabCreated registra callback para cuando se crea un nuevo tab +func (b *Browser) OnTabCreated(handler func(*Tab)) error { + tabMutex.Lock() + defer tabMutex.Unlock() + + tabHandlers.onCreate = handler + + // Registrar listener de eventos + b.cdpClient.On("Target.targetCreated", func(params json.RawMessage) { + var event struct { + TargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + } `json:"targetInfo"` + } + + if err := json.Unmarshal(params, &event); err != nil { + return + } + + if event.TargetInfo.Type == "page" { + tab := &Tab{ + ID: event.TargetInfo.TargetID, + URL: event.TargetInfo.URL, + Title: event.TargetInfo.Title, + Type: event.TargetInfo.Type, + } + + tabMutex.RLock() + if tabHandlers.onCreate != nil { + tabHandlers.onCreate(tab) + } + tabMutex.RUnlock() + } + }) + + return nil +} + +// CloseOtherTabs cierra todos los tabs excepto el actual +func (b *Browser) CloseOtherTabs(ctx context.Context) error { + currentTab, err := b.GetCurrentTab(ctx) + if err != nil { + return err + } + + tabs, err := b.GetTabs(ctx) + if err != nil { + return err + } + + for _, tab := range tabs { + if tab.ID != currentTab.ID { + if err := b.CloseTab(ctx, tab.ID); err != nil { + // Continuar cerrando otros tabs incluso si uno falla + continue + } + } + } + + return nil +} + +// GetTabByURL busca un tab por URL (coincidencia parcial) +func (b *Browser) GetTabByURL(ctx context.Context, urlPattern string) (*Tab, error) { + tabs, err := b.GetTabs(ctx) + if err != nil { + return nil, err + } + + for _, tab := range tabs { + if containsString(tab.URL, urlPattern) { + return tab, nil + } + } + + return nil, fmt.Errorf("no tab found with URL pattern: %s", urlPattern) +} + +// GetTabByTitle busca un tab por título (coincidencia parcial) +func (b *Browser) GetTabByTitle(ctx context.Context, titlePattern string) (*Tab, error) { + tabs, err := b.GetTabs(ctx) + if err != nil { + return nil, err + } + + for _, tab := range tabs { + if containsString(tab.Title, titlePattern) { + return tab, nil + } + } + + return nil, fmt.Errorf("no tab found with title pattern: %s", titlePattern) +}