cremote/daemon/daemon.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
}