1640 lines
43 KiB
Go
1640 lines
43 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/launcher"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
)
|
|
|
|
// Daemon is the main server that manages browser connections
|
|
type Daemon struct {
|
|
browser *rod.Browser
|
|
tabs map[string]*rod.Page
|
|
iframePages map[string]*rod.Page // Maps tab ID to iframe page context
|
|
currentTab string // ID of the current/last used tab
|
|
tabHistory []string // Stack of tab IDs in order of activation (LIFO)
|
|
mu sync.Mutex
|
|
server *http.Server
|
|
}
|
|
|
|
// Command represents a command sent from the client to the daemon
|
|
type Command struct {
|
|
Action string `json:"action"`
|
|
Params map[string]string `json:"params"`
|
|
}
|
|
|
|
// Response represents a response from the daemon to the client
|
|
type Response struct {
|
|
Success bool `json:"success"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// checkChromeRunning checks if Chrome is running on the debug port
|
|
func checkChromeRunning(port int) bool {
|
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 2*time.Second)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
conn.Close()
|
|
return true
|
|
}
|
|
|
|
// checkChromeDevTools checks if Chrome DevTools protocol is responding
|
|
func checkChromeDevTools(port int) bool {
|
|
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", port))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode == 200
|
|
}
|
|
|
|
// NewDaemon creates a new daemon instance
|
|
func NewDaemon(host string, port int) (*Daemon, error) {
|
|
// Check if Chrome is running on the debug port
|
|
chromePort := 9222 // Default Chrome debug port
|
|
|
|
if !checkChromeRunning(chromePort) {
|
|
return nil, fmt.Errorf("Chromium is not running with remote debugging enabled on port %d.\n\nTo start Chromium with remote debugging:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &\n # or\n google-chrome --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug &\n\nNote: The --user-data-dir flag is required to avoid conflicts with existing browser instances.", chromePort, chromePort, chromePort)
|
|
}
|
|
|
|
// Check if Chromium DevTools protocol is responding
|
|
if !checkChromeDevTools(chromePort) {
|
|
return nil, fmt.Errorf("Something is listening on port %d but it's not Chromium DevTools protocol.\n\nThis might be:\n1. Chromium running without --remote-debugging-port=%d\n2. Another application using port %d\n\nTry stopping the process on port %d and starting Chromium with:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &", chromePort, chromePort, chromePort, chromePort, chromePort)
|
|
}
|
|
|
|
// Connect to the existing browser instance
|
|
u := launcher.MustResolveURL("")
|
|
browser := rod.New().ControlURL(u)
|
|
|
|
err := browser.Connect()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Chromium DevTools is responding on port %d but rod connection failed: %w\n\nThis is unexpected. Try restarting Chromium with:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &", chromePort, err, chromePort)
|
|
}
|
|
|
|
daemon := &Daemon{
|
|
browser: browser,
|
|
tabs: make(map[string]*rod.Page),
|
|
iframePages: make(map[string]*rod.Page),
|
|
tabHistory: make([]string, 0),
|
|
}
|
|
|
|
// Create HTTP server
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/command", daemon.handleCommand)
|
|
mux.HandleFunc("/status", daemon.handleStatus)
|
|
|
|
daemon.server = &http.Server{
|
|
Addr: fmt.Sprintf("%s:%d", host, port),
|
|
Handler: mux,
|
|
}
|
|
|
|
return daemon, nil
|
|
}
|
|
|
|
// Start starts the daemon server
|
|
func (d *Daemon) Start() error {
|
|
log.Printf("Starting daemon server on %s", d.server.Addr)
|
|
return d.server.ListenAndServe()
|
|
}
|
|
|
|
// Stop stops the daemon server
|
|
func (d *Daemon) Stop() error {
|
|
log.Println("Stopping daemon server")
|
|
return d.server.Close()
|
|
}
|
|
|
|
// handleStatus handles status requests
|
|
func (d *Daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
d.mu.Lock()
|
|
tabCount := len(d.tabs)
|
|
tabs := make(map[string]string)
|
|
currentTab := d.currentTab
|
|
tabHistory := make([]string, len(d.tabHistory))
|
|
copy(tabHistory, d.tabHistory)
|
|
|
|
// Get info about each tab
|
|
for id, page := range d.tabs {
|
|
try := func() string {
|
|
info, err := page.Info()
|
|
if err != nil {
|
|
return "<unavailable>"
|
|
}
|
|
return info.URL
|
|
}
|
|
tabs[id] = try()
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
response := Response{
|
|
Success: true,
|
|
Data: map[string]interface{}{
|
|
"status": "running",
|
|
"tab_count": tabCount,
|
|
"tabs": tabs,
|
|
"current_tab": currentTab,
|
|
"tab_history": tabHistory,
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// handleCommand handles command requests
|
|
func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var cmd Command
|
|
err := json.NewDecoder(r.Body).Decode(&cmd)
|
|
if err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var response Response
|
|
|
|
switch cmd.Action {
|
|
case "open-tab":
|
|
timeoutStr := cmd.Params["timeout"]
|
|
|
|
// Parse timeout (default to 5 seconds if not specified)
|
|
timeout := 5
|
|
if timeoutStr != "" {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
tabID, err := d.openTab(timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true, Data: tabID}
|
|
}
|
|
|
|
case "load-url":
|
|
tabID := cmd.Params["tab"]
|
|
url := cmd.Params["url"]
|
|
timeoutStr := cmd.Params["timeout"]
|
|
|
|
// Parse timeout (default to 5 seconds if not specified)
|
|
timeout := 5
|
|
if timeoutStr != "" {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.loadURL(tabID, url, timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "fill-form":
|
|
tabID := cmd.Params["tab"]
|
|
selector := cmd.Params["selector"]
|
|
value := cmd.Params["value"]
|
|
|
|
// Parse timeouts
|
|
selectionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["selection-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
selectionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
actionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["action-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
actionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.fillFormField(tabID, selector, value, selectionTimeout, actionTimeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "upload-file":
|
|
tabID := cmd.Params["tab"]
|
|
selector := cmd.Params["selector"]
|
|
filePath := cmd.Params["file"]
|
|
|
|
// Parse timeouts
|
|
selectionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["selection-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
selectionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
actionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["action-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
actionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.uploadFile(tabID, selector, filePath, selectionTimeout, actionTimeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "submit-form":
|
|
tabID := cmd.Params["tab"]
|
|
selector := cmd.Params["selector"]
|
|
|
|
// Parse timeouts
|
|
selectionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["selection-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
selectionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
actionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["action-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
actionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.submitForm(tabID, selector, selectionTimeout, actionTimeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "get-source":
|
|
tabID := cmd.Params["tab"]
|
|
timeoutStr := cmd.Params["timeout"]
|
|
|
|
// Parse timeout (default to 5 seconds if not specified)
|
|
timeout := 5
|
|
if timeoutStr != "" {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
source, err := d.getPageSource(tabID, timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true, Data: source}
|
|
}
|
|
|
|
case "get-element":
|
|
tabID := cmd.Params["tab"]
|
|
selector := cmd.Params["selector"]
|
|
|
|
// Parse timeouts
|
|
selectionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["selection-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
selectionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
html, err := d.getElementHTML(tabID, selector, selectionTimeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true, Data: html}
|
|
}
|
|
|
|
case "close-tab":
|
|
tabID := cmd.Params["tab"]
|
|
timeoutStr := cmd.Params["timeout"]
|
|
|
|
// Parse timeout (default to 5 seconds if not specified)
|
|
timeout := 5
|
|
if timeoutStr != "" {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.closeTab(tabID, timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "wait-navigation":
|
|
tabID := cmd.Params["tab"]
|
|
timeout := 5 // Default timeout
|
|
if timeoutStr, ok := cmd.Params["timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
err := d.waitNavigation(tabID, timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "click-element":
|
|
tabID := cmd.Params["tab"]
|
|
selector := cmd.Params["selector"]
|
|
|
|
// Parse timeouts
|
|
selectionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["selection-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
selectionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
actionTimeout := 5 // Default: 5 seconds
|
|
if timeoutStr, ok := cmd.Params["action-timeout"]; ok {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
actionTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.clickElement(tabID, selector, selectionTimeout, actionTimeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "eval-js":
|
|
tabID := cmd.Params["tab"]
|
|
jsCode := cmd.Params["code"]
|
|
timeoutStr := cmd.Params["timeout"]
|
|
|
|
// Parse timeout (default to 5 seconds if not specified)
|
|
timeout := 5
|
|
if timeoutStr != "" {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
result, err := d.evalJS(tabID, jsCode, timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true, Data: result}
|
|
}
|
|
|
|
case "switch-iframe":
|
|
tabID := cmd.Params["tab"]
|
|
selector := cmd.Params["selector"]
|
|
|
|
err := d.switchToIframe(tabID, selector)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "switch-main":
|
|
tabID := cmd.Params["tab"]
|
|
|
|
err := d.switchToMain(tabID)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
case "screenshot":
|
|
tabID := cmd.Params["tab"]
|
|
outputPath := cmd.Params["output"]
|
|
fullPageStr := cmd.Params["full-page"]
|
|
timeoutStr := cmd.Params["timeout"]
|
|
|
|
// Parse full-page flag
|
|
fullPage := false
|
|
if fullPageStr == "true" {
|
|
fullPage = true
|
|
}
|
|
|
|
// Parse timeout (default to 5 seconds if not specified)
|
|
timeout := 5
|
|
if timeoutStr != "" {
|
|
if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 {
|
|
timeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
err := d.takeScreenshot(tabID, outputPath, fullPage, timeout)
|
|
if err != nil {
|
|
response = Response{Success: false, Error: err.Error()}
|
|
} else {
|
|
response = Response{Success: true}
|
|
}
|
|
|
|
default:
|
|
response = Response{Success: false, Error: "Unknown action"}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// openTab opens a new tab and returns its ID
|
|
func (d *Daemon) openTab(timeout int) (string, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// Create a context with timeout if specified
|
|
if timeout > 0 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan struct {
|
|
page *rod.Page
|
|
tabID string
|
|
err error
|
|
}, 1)
|
|
|
|
// Execute the tab creation in a goroutine
|
|
go func() {
|
|
page, err := d.browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
|
|
var tabID string
|
|
if err == nil {
|
|
tabID = string(page.TargetID)
|
|
}
|
|
done <- struct {
|
|
page *rod.Page
|
|
tabID string
|
|
err error
|
|
}{page, tabID, err}
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case res := <-done:
|
|
if res.err != nil {
|
|
return "", fmt.Errorf("failed to create new tab: %w", res.err)
|
|
}
|
|
|
|
// Store the tab and update history
|
|
d.tabs[res.tabID] = res.page
|
|
d.tabHistory = append(d.tabHistory, res.tabID)
|
|
d.currentTab = res.tabID
|
|
|
|
return res.tabID, nil
|
|
case <-ctx.Done():
|
|
return "", fmt.Errorf("opening tab timed out after %d seconds", timeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
page, err := d.browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create new tab: %w", err)
|
|
}
|
|
|
|
// Use the page ID as the tab ID
|
|
tabID := string(page.TargetID)
|
|
d.tabs[tabID] = page
|
|
|
|
// Add to tab history stack and set as current tab
|
|
d.tabHistory = append(d.tabHistory, tabID)
|
|
d.currentTab = tabID
|
|
|
|
return tabID, nil
|
|
}
|
|
}
|
|
|
|
// getTabID returns the tab ID to use, falling back to the current tab if none is provided
|
|
func (d *Daemon) getTabID(tabID string) (string, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// If no tab ID is provided, use the current tab
|
|
if tabID == "" {
|
|
if d.currentTab == "" {
|
|
return "", fmt.Errorf("no current tab available, please open a tab first")
|
|
}
|
|
return d.currentTab, nil
|
|
}
|
|
|
|
// Otherwise, use the provided tab ID
|
|
return tabID, nil
|
|
}
|
|
|
|
// updateTabHistory updates the tab history stack when a tab is activated
|
|
func (d *Daemon) updateTabHistory(tabID string) {
|
|
// Set as current tab
|
|
d.currentTab = tabID
|
|
|
|
// Remove the tab from history if it's already there
|
|
for i, id := range d.tabHistory {
|
|
if id == tabID {
|
|
// Remove this tab from history
|
|
d.tabHistory = append(d.tabHistory[:i], d.tabHistory[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add the tab to the end of history (most recent)
|
|
d.tabHistory = append(d.tabHistory, tabID)
|
|
}
|
|
|
|
// findPageByID finds a page by its ID without updating the current tab or cache
|
|
func (d *Daemon) findPageByID(tabID string) (*rod.Page, error) {
|
|
// If not in memory, try to get the page from the browser
|
|
pages, err := d.browser.Pages()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get browser pages: %w", err)
|
|
}
|
|
|
|
// Find the page with the matching ID
|
|
for _, p := range pages {
|
|
if string(p.TargetID) == tabID {
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("tab not found: %s", tabID)
|
|
}
|
|
|
|
// getTab returns a tab by its ID, checking for iframe context first
|
|
func (d *Daemon) getTab(tabID string) (*rod.Page, error) {
|
|
// Get the tab ID to use (may be the current tab)
|
|
actualTabID, err := d.getTabID(tabID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// First check if we have an iframe context for this tab
|
|
if iframePage, exists := d.iframePages[actualTabID]; exists {
|
|
// Update tab history and current tab
|
|
d.updateTabHistory(actualTabID)
|
|
return iframePage, nil
|
|
}
|
|
|
|
// Check in-memory cache for main page
|
|
page, exists := d.tabs[actualTabID]
|
|
if exists {
|
|
// Update tab history and current tab
|
|
d.updateTabHistory(actualTabID)
|
|
return page, nil
|
|
}
|
|
|
|
// If not in memory, try to find it
|
|
page, err = d.findPageByID(actualTabID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If found, cache it for future use
|
|
if page != nil {
|
|
d.tabs[actualTabID] = page
|
|
// Update tab history and current tab
|
|
d.updateTabHistory(actualTabID)
|
|
return page, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("tab not found: %s", actualTabID)
|
|
}
|
|
|
|
// closeTab closes a tab by its ID
|
|
func (d *Daemon) closeTab(tabID string, timeout int) error {
|
|
// Get the tab ID to use (may be the current tab)
|
|
actualTabID, err := d.getTabID(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// First remove from our internal map to avoid future references
|
|
d.mu.Lock()
|
|
page, exists := d.tabs[actualTabID]
|
|
delete(d.tabs, actualTabID)
|
|
|
|
// Remove the tab from history
|
|
for i, id := range d.tabHistory {
|
|
if id == actualTabID {
|
|
// Remove this tab from history
|
|
d.tabHistory = append(d.tabHistory[:i], d.tabHistory[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// If we closed the current tab, set it to the previous tab in history
|
|
if d.currentTab == actualTabID {
|
|
if len(d.tabHistory) > 0 {
|
|
// Set current tab to the most recent tab in history
|
|
d.currentTab = d.tabHistory[len(d.tabHistory)-1]
|
|
} else {
|
|
// No tabs left in history, clear the current tab
|
|
d.currentTab = ""
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
// If the page doesn't exist in our cache, try to find it
|
|
if !exists {
|
|
var err error
|
|
page, err = d.findPageByID(actualTabID)
|
|
if err != nil {
|
|
// If we can't find the page, it might already be closed
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if timeout > 0 {
|
|
// Use timeout for closing the tab
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the close in a goroutine
|
|
go func() {
|
|
err := page.Close()
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
// Log the error but don't return it, as we've already removed it from our map
|
|
fmt.Printf("Warning: failed to close tab %s: %v\n", actualTabID, err)
|
|
}
|
|
return nil
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("closing tab timed out after %d seconds", timeout)
|
|
}
|
|
} else {
|
|
// No timeout - try to close the page, but don't fail if it's already closed
|
|
err = page.Close()
|
|
if err != nil {
|
|
// Log the error but don't return it, as we've already removed it from our map
|
|
fmt.Printf("Warning: failed to close tab %s: %v\n", actualTabID, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// loadURL loads a URL in a tab
|
|
func (d *Daemon) loadURL(tabID, url string, timeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if timeout > 0 {
|
|
// Use timeout for the URL loading
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the navigation in a goroutine
|
|
go func() {
|
|
err := page.Navigate(url)
|
|
if err != nil {
|
|
done <- fmt.Errorf("failed to navigate to URL: %w", err)
|
|
return
|
|
}
|
|
|
|
// Wait for the page to be loaded
|
|
err = page.WaitLoad()
|
|
if err != nil {
|
|
done <- fmt.Errorf("failed to wait for page load: %w", err)
|
|
return
|
|
}
|
|
|
|
done <- nil
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("loading URL timed out after %d seconds", timeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
err = page.Navigate(url)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to navigate to URL: %w", err)
|
|
}
|
|
|
|
// Wait for the page to be loaded
|
|
err = page.WaitLoad()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to wait for page load: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// isPageStable checks if a page is stable and not currently loading
|
|
func (d *Daemon) isPageStable(page *rod.Page) (bool, error) {
|
|
// Check if page is loading
|
|
result, err := page.Eval(`() => document.readyState === 'complete'`)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
isComplete := result.Value.Bool()
|
|
|
|
if !isComplete {
|
|
return false, nil
|
|
}
|
|
|
|
// Additional check: ensure no pending network requests
|
|
// This is a simple heuristic - if the page has been stable for a short time
|
|
err = page.WaitStable(500 * time.Millisecond)
|
|
if err != nil {
|
|
return false, nil // Page is not stable
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// detectNavigationInProgress monitors the page for a short period to detect if navigation starts
|
|
func (d *Daemon) detectNavigationInProgress(page *rod.Page, monitorDuration time.Duration) (bool, error) {
|
|
// Get current URL and readyState
|
|
currentURL, err := page.Eval(`() => window.location.href`)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
currentReadyState, err := page.Eval(`() => document.readyState`)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
startURL := currentURL.Value.Str()
|
|
startReadyState := currentReadyState.Value.Str()
|
|
|
|
// Monitor for changes over the specified duration
|
|
ctx, cancel := context.WithTimeout(context.Background(), monitorDuration)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// No navigation detected during monitoring period
|
|
return false, nil
|
|
case <-ticker.C:
|
|
// Check if URL or readyState changed
|
|
newURL, err := page.Eval(`() => window.location.href`)
|
|
if err != nil {
|
|
continue // Ignore errors during monitoring
|
|
}
|
|
|
|
newReadyState, err := page.Eval(`() => document.readyState`)
|
|
if err != nil {
|
|
continue // Ignore errors during monitoring
|
|
}
|
|
|
|
if newURL.Value.Str() != startURL {
|
|
// URL changed, navigation is happening
|
|
return true, nil
|
|
}
|
|
|
|
if newReadyState.Value.Str() != startReadyState && newReadyState.Value.Str() == "loading" {
|
|
// Page started loading
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitNavigation waits for a navigation event to happen
|
|
func (d *Daemon) waitNavigation(tabID string, timeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// First, check if the page is already stable and loaded
|
|
// If so, we don't need to wait for navigation
|
|
isStable, err := d.isPageStable(page)
|
|
if err == nil && isStable {
|
|
// Page is already stable, no navigation happening
|
|
return nil
|
|
}
|
|
|
|
// Check if navigation is actually in progress by monitoring for a short period
|
|
navigationDetected, err := d.detectNavigationInProgress(page, 2*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect navigation state: %w", err)
|
|
}
|
|
|
|
if !navigationDetected {
|
|
// No navigation detected, check if page is stable now
|
|
isStable, err := d.isPageStable(page)
|
|
if err == nil && isStable {
|
|
return nil
|
|
}
|
|
// If we can't determine stability, proceed with waiting
|
|
}
|
|
|
|
// Navigation is in progress, wait for it to complete
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Wait for navigation with timeout
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
done <- fmt.Errorf("navigation wait panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
// Wait for navigation event
|
|
page.WaitNavigation(proto.PageLifecycleEventNameLoad)()
|
|
|
|
// Wait for the page to be fully loaded
|
|
err := page.WaitLoad()
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("navigation wait failed: %w", err)
|
|
}
|
|
return nil
|
|
case <-ctx.Done():
|
|
// Timeout occurred, check if page is now stable
|
|
isStable, err := d.isPageStable(page)
|
|
if err == nil && isStable {
|
|
// Page is stable, consider navigation complete
|
|
return nil
|
|
}
|
|
return fmt.Errorf("navigation wait timed out after %d seconds", timeout)
|
|
}
|
|
}
|
|
|
|
// getPageSource returns the entire source code of a page
|
|
func (d *Daemon) getPageSource(tabID string, timeout int) (string, error) {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if timeout > 0 {
|
|
// Use timeout for getting page source
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan struct {
|
|
html string
|
|
err error
|
|
}, 1)
|
|
|
|
// Execute the HTML retrieval in a goroutine
|
|
go func() {
|
|
html, err := page.HTML()
|
|
done <- struct {
|
|
html string
|
|
err error
|
|
}{html, err}
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case res := <-done:
|
|
if res.err != nil {
|
|
return "", fmt.Errorf("failed to get page HTML: %w", res.err)
|
|
}
|
|
return res.html, nil
|
|
case <-ctx.Done():
|
|
return "", fmt.Errorf("getting page source timed out after %d seconds", timeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
html, err := page.HTML()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get page HTML: %w", err)
|
|
}
|
|
|
|
return html, nil
|
|
}
|
|
}
|
|
|
|
// getElementHTML returns the HTML of an element at the specified selector
|
|
func (d *Daemon) getElementHTML(tabID, selector string, selectionTimeout int) (string, error) {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Find the element with optional timeout
|
|
var element *rod.Element
|
|
if selectionTimeout > 0 {
|
|
// Use timeout if specified
|
|
element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
element, err = page.Element(selector)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find element: %w", err)
|
|
}
|
|
}
|
|
|
|
// Get the HTML of the element
|
|
html, err := element.HTML()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get element HTML: %w", err)
|
|
}
|
|
|
|
return html, nil
|
|
}
|
|
|
|
// fillFormField fills a form field with the specified value
|
|
func (d *Daemon) fillFormField(tabID, selector, value string, selectionTimeout, actionTimeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the element with optional timeout
|
|
var element *rod.Element
|
|
if selectionTimeout > 0 {
|
|
// Use timeout if specified
|
|
element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
element, err = page.Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find element: %w", err)
|
|
}
|
|
}
|
|
|
|
// Get the element type
|
|
tagName, err := element.Eval(`() => this.tagName.toLowerCase()`)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get element type: %w", err)
|
|
}
|
|
|
|
// Get the element type attribute
|
|
inputType, err := element.Eval(`() => this.type ? this.type.toLowerCase() : ''`)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get element type attribute: %w", err)
|
|
}
|
|
|
|
// Handle different input types
|
|
tagNameStr := tagName.Value.String()
|
|
typeStr := inputType.Value.String()
|
|
|
|
// Handle checkbox and radio inputs
|
|
if tagNameStr == "input" && (typeStr == "checkbox" || typeStr == "radio") {
|
|
// Convert value to boolean
|
|
checked := false
|
|
if value == "true" || value == "1" || value == "yes" || value == "on" || value == "checked" {
|
|
checked = true
|
|
}
|
|
|
|
// Set the checked state with optional timeout
|
|
if actionTimeout > 0 {
|
|
// Use timeout for the action
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the action in a goroutine
|
|
go func() {
|
|
_, err := element.Eval(fmt.Sprintf(`() => { this.checked = %t; return true; }`, checked))
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set checkbox state: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("setting checkbox state timed out after %d seconds", actionTimeout)
|
|
}
|
|
|
|
// Create a channel for the event trigger
|
|
done = make(chan error, 1)
|
|
|
|
// Trigger change event with timeout
|
|
go func() {
|
|
_, err := element.Eval(`() => {
|
|
const event = new Event('change', { bubbles: true });
|
|
this.dispatchEvent(event);
|
|
return true;
|
|
}`)
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to trigger change event: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("triggering change event timed out after %d seconds", actionTimeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
_, err := element.Eval(fmt.Sprintf(`() => { this.checked = %t; return true; }`, checked))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set checkbox state: %w", err)
|
|
}
|
|
|
|
// Trigger change event
|
|
_, err = element.Eval(`() => {
|
|
const event = new Event('change', { bubbles: true });
|
|
this.dispatchEvent(event);
|
|
return true;
|
|
}`)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to trigger change event: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// For regular text inputs
|
|
if actionTimeout > 0 {
|
|
// Use timeout for the action
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Clear the field first with timeout
|
|
go func() {
|
|
_ = element.SelectAllText()
|
|
err := element.Input("")
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to clear field: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("clearing field timed out after %d seconds", actionTimeout)
|
|
}
|
|
|
|
// Create a channel for the input action
|
|
done = make(chan error, 1)
|
|
|
|
// Input the value with timeout
|
|
go func() {
|
|
err := element.Input(value)
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to input value: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("inputting value timed out after %d seconds", actionTimeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
// Clear the field first
|
|
_ = element.SelectAllText()
|
|
err = element.Input("")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to clear field: %w", err)
|
|
}
|
|
|
|
// Input the value
|
|
err = element.Input(value)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to input value: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// uploadFile uploads a file to a file input element
|
|
func (d *Daemon) uploadFile(tabID, selector, filePath string, selectionTimeout, actionTimeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the element with optional timeout
|
|
var element *rod.Element
|
|
if selectionTimeout > 0 {
|
|
// Use timeout if specified
|
|
element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find file input element (timeout after %ds): %w", selectionTimeout, err)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
element, err = page.Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find file input element: %w", err)
|
|
}
|
|
}
|
|
|
|
// Set the file with optional timeout
|
|
if actionTimeout > 0 {
|
|
// Use timeout for the action
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the action in a goroutine
|
|
go func() {
|
|
err := element.SetFiles([]string{filePath})
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set file: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("setting file timed out after %d seconds", actionTimeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
err = element.SetFiles([]string{filePath})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set file: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// submitForm submits a form
|
|
func (d *Daemon) submitForm(tabID, selector string, selectionTimeout, actionTimeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the element with optional timeout
|
|
var form *rod.Element
|
|
if selectionTimeout > 0 {
|
|
// Use timeout if specified
|
|
form, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find form element (timeout after %ds): %w", selectionTimeout, err)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
form, err = page.Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find form element: %w", err)
|
|
}
|
|
}
|
|
|
|
// Get the current URL to detect navigation
|
|
currentURL := page.MustInfo().URL
|
|
|
|
// Create a context for navigation timeout
|
|
var ctx context.Context
|
|
var cancel context.CancelFunc
|
|
|
|
// Submit the form with optional timeout
|
|
if actionTimeout > 0 {
|
|
// Use timeout for the action
|
|
submitCtx, submitCancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second)
|
|
defer submitCancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the action in a goroutine
|
|
go func() {
|
|
_, err := form.Eval(`() => { try { this.submit(); return true; } catch(e) { return false; } }`)
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
// Log the error but continue
|
|
fmt.Printf("Warning: error during form submission: %v\n", err)
|
|
}
|
|
case <-submitCtx.Done():
|
|
return fmt.Errorf("form submission timed out after %d seconds", actionTimeout)
|
|
}
|
|
|
|
// Wait for navigation to complete (with the same timeout)
|
|
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second)
|
|
} else {
|
|
// No timeout for submission
|
|
try := func() (bool, error) {
|
|
_, err := form.Eval(`() => { try { this.submit(); return true; } catch(e) { return false; } }`)
|
|
return err == nil, err
|
|
}
|
|
|
|
// Try to submit the form, but don't fail if it's already been submitted
|
|
_, err = try()
|
|
if err != nil {
|
|
// Log the error but continue
|
|
fmt.Printf("Warning: error during form submission: %v\n", err)
|
|
}
|
|
|
|
// Wait for navigation to complete (with default timeout)
|
|
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
|
}
|
|
defer cancel()
|
|
|
|
// Wait for the page to navigate away from the current URL
|
|
waitNav := func() error {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Check if the page has navigated
|
|
try := func() (string, error) {
|
|
info, err := page.Info()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return info.URL, nil
|
|
}
|
|
|
|
newURL, err := try()
|
|
if err != nil {
|
|
// Page might be navigating, wait a bit more
|
|
continue
|
|
}
|
|
|
|
if newURL != currentURL {
|
|
// Navigation completed
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for navigation but don't fail if it times out
|
|
err = waitNav()
|
|
if err != nil {
|
|
// Log the error but don't fail
|
|
fmt.Printf("Warning: navigation after form submission may not have completed: %v\n", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// clickElement clicks on an element
|
|
func (d *Daemon) clickElement(tabID, selector string, selectionTimeout, actionTimeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the element with optional timeout
|
|
var element *rod.Element
|
|
if selectionTimeout > 0 {
|
|
// Use timeout if specified
|
|
element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
element, err = page.Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find element: %w", err)
|
|
}
|
|
}
|
|
|
|
// Make sure the element is visible and scrolled into view
|
|
err = element.ScrollIntoView()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to scroll element into view: %w", err)
|
|
}
|
|
|
|
// Click the element with optional timeout
|
|
if actionTimeout > 0 {
|
|
// Use timeout for the click action
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the click in a goroutine
|
|
go func() {
|
|
err := element.Click(proto.InputMouseButtonLeft, 1) // 1 click
|
|
done <- err
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to click element: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("click action timed out after %d seconds", actionTimeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
err = element.Click(proto.InputMouseButtonLeft, 1) // 1 click
|
|
if err != nil {
|
|
return fmt.Errorf("failed to click element: %w", err)
|
|
}
|
|
}
|
|
|
|
// Wait a moment for any navigation to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Wait for any potential page load or DOM changes
|
|
err = page.WaitStable(1 * time.Second)
|
|
if err != nil {
|
|
// This is not a critical error, so we'll just log it
|
|
log.Printf("Warning: page not stable after click: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// evalJS executes JavaScript code in a tab and returns the result
|
|
func (d *Daemon) evalJS(tabID, jsCode string, timeout int) (string, error) {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Create a comprehensive wrapper that handles both expressions and statements
|
|
// and properly formats the result
|
|
wrappedCode := `() => {
|
|
var result;
|
|
try {
|
|
// Try to evaluate as an expression first
|
|
result = eval(` + "`" + jsCode + "`" + `);
|
|
} catch(e) {
|
|
// If that fails, try to execute as statements
|
|
try {
|
|
eval(` + "`" + jsCode + "`" + `);
|
|
result = undefined;
|
|
} catch(e2) {
|
|
throw e; // Re-throw the original error
|
|
}
|
|
}
|
|
|
|
// Format the result for return
|
|
if (typeof result === 'undefined') return 'undefined';
|
|
if (result === null) return 'null';
|
|
if (typeof result === 'string') return result;
|
|
if (typeof result === 'number' || typeof result === 'boolean') return String(result);
|
|
try {
|
|
return JSON.stringify(result);
|
|
} catch(e) {
|
|
return String(result);
|
|
}
|
|
}`
|
|
|
|
// Execute the wrapped JavaScript code with timeout
|
|
if timeout > 0 {
|
|
// Use timeout for the JavaScript execution
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan struct {
|
|
result string
|
|
err error
|
|
}, 1)
|
|
|
|
// Execute the JavaScript in a goroutine
|
|
go func() {
|
|
result, err := page.Eval(wrappedCode)
|
|
var resultStr string
|
|
if err == nil {
|
|
// Convert the result to a string representation
|
|
if result.Value.Nil() {
|
|
resultStr = "null"
|
|
} else {
|
|
resultStr = result.Value.String()
|
|
}
|
|
}
|
|
done <- struct {
|
|
result string
|
|
err error
|
|
}{resultStr, err}
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case res := <-done:
|
|
if res.err != nil {
|
|
return "", fmt.Errorf("failed to execute JavaScript: %w", res.err)
|
|
}
|
|
return res.result, nil
|
|
case <-ctx.Done():
|
|
return "", fmt.Errorf("JavaScript execution timed out after %d seconds", timeout)
|
|
}
|
|
} else {
|
|
// No timeout
|
|
result, err := page.Eval(wrappedCode)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to execute JavaScript: %w", err)
|
|
}
|
|
|
|
// Convert the result to a string representation
|
|
if result.Value.Nil() {
|
|
return "null", nil
|
|
}
|
|
|
|
return result.Value.String(), nil
|
|
}
|
|
}
|
|
|
|
// takeScreenshot takes a screenshot of a tab and saves it to a file
|
|
func (d *Daemon) takeScreenshot(tabID, outputPath string, fullPage bool, timeout int) error {
|
|
page, err := d.getTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if timeout > 0 {
|
|
// Use timeout for taking screenshot
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a channel to signal completion
|
|
done := make(chan error, 1)
|
|
|
|
// Execute the screenshot in a goroutine
|
|
go func() {
|
|
// Take screenshot and save it
|
|
screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{
|
|
Format: proto.PageCaptureScreenshotFormatPng,
|
|
})
|
|
|
|
if err != nil {
|
|
done <- fmt.Errorf("failed to capture screenshot: %w", err)
|
|
return
|
|
}
|
|
|
|
// Write the screenshot to file
|
|
err = os.WriteFile(outputPath, screenshotBytes, 0644)
|
|
if err != nil {
|
|
done <- fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err)
|
|
return
|
|
}
|
|
|
|
done <- nil
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save screenshot: %w", err)
|
|
}
|
|
return nil
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("taking screenshot timed out after %d seconds", timeout)
|
|
}
|
|
} else {
|
|
// No timeout - take screenshot directly
|
|
screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{
|
|
Format: proto.PageCaptureScreenshotFormatPng,
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to capture screenshot: %w", err)
|
|
}
|
|
|
|
// Write the screenshot to file
|
|
err = os.WriteFile(outputPath, screenshotBytes, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// switchToIframe switches the context to an iframe for subsequent commands
|
|
func (d *Daemon) switchToIframe(tabID, selector string) error {
|
|
// Get the main page first (not iframe context)
|
|
actualTabID, err := d.getTabID(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// Get the main page (bypass iframe context)
|
|
mainPage, exists := d.tabs[actualTabID]
|
|
if !exists {
|
|
// Try to find it
|
|
mainPage, err = d.findPageByID(actualTabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mainPage == nil {
|
|
return fmt.Errorf("tab not found: %s", actualTabID)
|
|
}
|
|
d.tabs[actualTabID] = mainPage
|
|
}
|
|
|
|
// Find the iframe element
|
|
iframeElement, err := mainPage.Element(selector)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find iframe element: %w", err)
|
|
}
|
|
|
|
// Get the iframe's page context
|
|
iframePage, err := iframeElement.Frame()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get iframe context: %w", err)
|
|
}
|
|
|
|
// Store the iframe page context
|
|
d.iframePages[actualTabID] = iframePage
|
|
|
|
return nil
|
|
}
|
|
|
|
// switchToMain switches back to the main page context
|
|
func (d *Daemon) switchToMain(tabID string) error {
|
|
// Get the tab ID to use (may be the current tab)
|
|
actualTabID, err := d.getTabID(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// Remove the iframe context for this tab
|
|
delete(d.iframePages, actualTabID)
|
|
|
|
return nil
|
|
}
|