This commit is contained in:
Josh at WLTechBlog
2025-08-12 10:19:13 -05:00
parent 70d9ed30de
commit d6209cd34f
16 changed files with 4118 additions and 0 deletions

264
browser/browser.go Normal file
View File

@@ -0,0 +1,264 @@
package browser
import (
"errors"
"fmt"
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
// Manager handles the connection to a Chrome browser instance
type Manager struct {
browser *rod.Browser
tabs map[string]*rod.Page
mu sync.Mutex
isNewBrowser bool // Tracks if we launched a new browser or connected to existing one
storage *TabStorage // Persistent storage for tab IDs
}
// NewManager creates a new browser manager
// If launchNew is true, it will launch a new browser instance
// Otherwise, it will try to connect to an existing browser instance
func NewManager(launchNew bool) (*Manager, error) {
var browser *rod.Browser
var isNewBrowser bool
// Initialize tab storage
storage, err := NewTabStorage()
if err != nil {
return nil, fmt.Errorf("failed to initialize tab storage: %w", err)
}
if launchNew {
// Launch a new browser instance
u := launcher.New().MustLaunch()
browser = rod.New().ControlURL(u).MustConnect()
isNewBrowser = true
} else {
// Connect to an existing browser instance
// This assumes Chrome is running with --remote-debugging-port=9222
u := launcher.MustResolveURL("")
browser = rod.New().ControlURL(u)
err := browser.Connect()
if err != nil {
return nil, fmt.Errorf("failed to connect to browser: %w\nMake sure Chrome is running with --remote-debugging-port=9222", err)
}
isNewBrowser = false
}
return &Manager{
browser: browser,
tabs: make(map[string]*rod.Page),
isNewBrowser: isNewBrowser,
storage: storage,
}, nil
}
// OpenTab opens a new tab and returns its ID
func (m *Manager) OpenTab() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
page, err := m.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)
m.tabs[tabID] = page
// Save the tab ID to persistent storage
err = m.storage.SaveTab(tabID)
if err != nil {
return "", fmt.Errorf("failed to save tab ID: %w", err)
}
return tabID, nil
}
// GetTab returns a tab by its ID
func (m *Manager) GetTab(tabID string) (*rod.Page, error) {
m.mu.Lock()
defer m.mu.Unlock()
// First check in-memory cache
page, exists := m.tabs[tabID]
if exists {
return page, nil
}
// If not in memory, check persistent storage
storedID, exists := m.storage.GetTab(tabID)
if !exists {
return nil, errors.New("tab not found")
}
// Try to get the page from the browser
pages, err := m.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) == storedID {
// Cache it for future use
m.tabs[tabID] = p
return p, nil
}
}
// If we get here, the tab no longer exists
m.storage.RemoveTab(tabID)
return nil, errors.New("tab not found or was closed")
}
// CloseTab closes a tab by its ID
func (m *Manager) CloseTab(tabID string) error {
m.mu.Lock()
defer m.mu.Unlock()
// First check in-memory cache
page, exists := m.tabs[tabID]
if !exists {
// If not in memory, check persistent storage
storedID, exists := m.storage.GetTab(tabID)
if !exists {
return errors.New("tab not found")
}
// Try to get the page from the browser
pages, err := m.browser.Pages()
if err != nil {
return fmt.Errorf("failed to get browser pages: %w", err)
}
// Find the page with the matching ID
for _, p := range pages {
if string(p.TargetID) == storedID {
page = p
exists = true
break
}
}
if !exists {
// If we get here, the tab no longer exists, so just remove it from storage
m.storage.RemoveTab(tabID)
return errors.New("tab not found or was already closed")
}
}
err := page.Close()
if err != nil {
return fmt.Errorf("failed to close tab: %w", err)
}
// Remove from in-memory cache and persistent storage
delete(m.tabs, tabID)
m.storage.RemoveTab(tabID)
return nil
}
// Close closes the browser connection and all tabs
func (m *Manager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
// Clear the tabs map
m.tabs = make(map[string]*rod.Page)
// Only close the browser if we launched it
if m.isNewBrowser {
return m.browser.Close()
}
// For existing browsers, just disconnect without closing
return nil
}
// LoadURL loads a URL in a tab
func (m *Manager) LoadURL(tabID, url string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
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
}
// WaitNavigation waits for a navigation event to happen
func (m *Manager) WaitNavigation(tabID string, timeout int) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Set a timeout for the navigation wait
page = page.Timeout(time.Duration(timeout) * time.Second)
// Wait for navigation
page.WaitNavigation(proto.PageLifecycleEventNameLoad)()
// Wait for the page to be fully loaded
err = page.WaitLoad()
if err != nil {
return fmt.Errorf("navigation wait failed: %w", err)
}
return nil
}
// GetPageSource returns the entire source code of a page
func (m *Manager) GetPageSource(tabID string) (string, error) {
page, err := m.GetTab(tabID)
if err != nil {
return "", err
}
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 (m *Manager) GetElementHTML(tabID, selector string) (string, error) {
page, err := m.GetTab(tabID)
if err != nil {
return "", err
}
// Find the element
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
}

97
browser/form.go Normal file
View File

@@ -0,0 +1,97 @@
package browser
import (
"fmt"
"os"
"path/filepath"
)
// FillFormField fills a form field with the specified value
func (m *Manager) FillFormField(tabID, selector, value string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Find the element
element, err := page.Element(selector)
if err != nil {
return fmt.Errorf("failed to find element: %w", err)
}
// 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 (m *Manager) UploadFile(tabID, selector, filePath string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Check if the file exists
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
_, err = os.Stat(absPath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
// Find the file input element
element, err := page.Element(selector)
if err != nil {
return fmt.Errorf("failed to find file input element: %w", err)
}
// Set the file
err = element.SetFiles([]string{absPath})
if err != nil {
return fmt.Errorf("failed to set file: %w", err)
}
return nil
}
// SubmitForm submits a form
func (m *Manager) SubmitForm(tabID, selector string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Find the form element
form, err := page.Element(selector)
if err != nil {
return fmt.Errorf("failed to find form element: %w", err)
}
// Submit the form
_, err = form.Eval(`() => this.submit()`)
if err != nil {
return fmt.Errorf("failed to submit form: %w", err)
}
// Wait for the page to load after form submission
err = page.WaitLoad()
if err != nil {
return fmt.Errorf("failed to wait for page load after form submission: %w", err)
}
return nil
}

107
browser/storage.go Normal file
View File

@@ -0,0 +1,107 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// TabStorage manages persistent storage of tab IDs
type TabStorage struct {
Tabs map[string]string // Maps tab IDs to their internal IDs
mu sync.Mutex
}
// NewTabStorage creates a new tab storage
func NewTabStorage() (*TabStorage, error) {
storage := &TabStorage{
Tabs: make(map[string]string),
}
// Load existing tabs from storage
err := storage.load()
if err != nil {
// If the file doesn't exist, that's fine - we'll create it
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load tab storage: %w", err)
}
}
return storage, nil
}
// SaveTab saves a tab ID to storage
func (s *TabStorage) SaveTab(tabID string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.Tabs[tabID] = tabID
return s.save()
}
// GetTab gets a tab ID from storage
func (s *TabStorage) GetTab(tabID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
internalID, exists := s.Tabs[tabID]
return internalID, exists
}
// RemoveTab removes a tab ID from storage
func (s *TabStorage) RemoveTab(tabID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.Tabs, tabID)
return s.save()
}
// getStoragePath returns the path to the storage file
func getStoragePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}
// Create .cremote directory if it doesn't exist
storageDir := filepath.Join(homeDir, ".cremote")
err = os.MkdirAll(storageDir, 0755)
if err != nil {
return "", fmt.Errorf("failed to create storage directory: %w", err)
}
return filepath.Join(storageDir, "tabs.json"), nil
}
// load loads the tab storage from disk
func (s *TabStorage) load() error {
path, err := getStoragePath()
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, &s.Tabs)
}
// save saves the tab storage to disk
func (s *TabStorage) save() error {
path, err := getStoragePath()
if err != nil {
return err
}
data, err := json.MarshalIndent(s.Tabs, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal tab storage: %w", err)
}
return os.WriteFile(path, data, 0644)
}