chromedp/client/client.go

352 lines
7.3 KiB
Go

// Package client provides the low level Chrome Debugging Protocol JSON types
// and related funcs.
package client
//go:generate go run gen.go
import (
"context"
"encoding/json"
"io/ioutil"
"net"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/mailru/easyjson"
)
const (
// DefaultEndpoint is the default endpoint to connect to.
DefaultEndpoint = "http://localhost:9222/json"
// DefaultWatchInterval is the default check duration.
DefaultWatchInterval = 100 * time.Millisecond
// DefaultWatchTimeout is the default watch timeout.
DefaultWatchTimeout = 5 * time.Second
)
// Error is a client error.
type Error string
// Error satisfies the error interface.
func (err Error) Error() string {
return string(err)
}
const (
// ErrUnsupportedProtocolType is the unsupported protocol type error.
ErrUnsupportedProtocolType Error = "unsupported protocol type"
// ErrUnsupportedProtocolVersion is the unsupported protocol version error.
ErrUnsupportedProtocolVersion Error = "unsupported protocol version"
)
// Target is the common interface for a Chrome Debugging Protocol target.
type Target interface {
String() string
GetID() string
GetType() TargetType
GetDevtoolsURL() string
GetWebsocketURL() string
}
// Client is a Chrome Debugging Protocol client.
type Client struct {
url string
check time.Duration
timeout time.Duration
ver, typ string
rw sync.RWMutex
}
// New creates a new Chrome Debugging Protocol client.
func New(opts ...Option) *Client {
c := &Client{
url: DefaultEndpoint,
check: DefaultWatchInterval,
timeout: DefaultWatchTimeout,
}
// apply opts
for _, o := range opts {
o(c)
}
return c
}
// doReq executes a request.
func (c *Client) doReq(ctxt context.Context, action string, v interface{}) error {
// create request
req, err := http.NewRequest("GET", c.url+"/"+action, nil)
if err != nil {
return err
}
req = req.WithContext(ctxt)
cl := &http.Client{}
// execute
res, err := cl.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if v != nil {
// load body
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return err
}
// unmarshal
if z, ok := v.(easyjson.Unmarshaler); ok {
return easyjson.Unmarshal(body, z)
}
return json.Unmarshal(body, v)
}
return nil
}
// ListTargets returns a list of all targets.
func (c *Client) ListTargets(ctxt context.Context) ([]Target, error) {
var err error
var l []json.RawMessage
err = c.doReq(ctxt, "list", &l)
if err != nil {
return nil, err
}
t := make([]Target, len(l))
for i, v := range l {
t[i], err = c.newTarget(ctxt, v)
if err != nil {
return nil, err
}
}
return t, nil
}
// ListTargetsWithType returns a list of Targets with the specified target
// type.
func (c *Client) ListTargetsWithType(ctxt context.Context, typ TargetType) ([]Target, error) {
var err error
targets, err := c.ListTargets(ctxt)
if err != nil {
return nil, err
}
var ret []Target
for _, t := range targets {
if t.GetType() == typ {
ret = append(ret, t)
}
}
return ret, nil
}
// ListPageTargets lists the available Page targets.
func (c *Client) ListPageTargets(ctxt context.Context) ([]Target, error) {
return c.ListTargetsWithType(ctxt, Page)
}
var browserRE = regexp.MustCompile(`(?i)^(chrome|chromium|microsoft edge|safari)`)
// loadProtocolInfo loads the protocol information from the remote URL.
func (c *Client) loadProtocolInfo(ctxt context.Context) (string, string, error) {
c.rw.Lock()
defer c.rw.Unlock()
if c.ver == "" || c.typ == "" {
v, err := c.VersionInfo(ctxt)
if err != nil {
return "", "", err
}
if m := browserRE.FindAllStringSubmatch(v["Browser"], -1); len(m) != 0 {
c.typ = strings.ToLower(m[0][0])
}
c.ver = v["Protocol-Version"]
}
return c.ver, c.typ, nil
}
// newTarget creates a new target.
func (c *Client) newTarget(ctxt context.Context, buf []byte) (Target, error) {
var err error
ver, typ, err := c.loadProtocolInfo(ctxt)
if err != nil {
return nil, err
}
if ver != "1.1" && ver != "1.2" && ver != "1.3" {
return nil, ErrUnsupportedProtocolVersion
}
switch typ {
case "chrome", "chromium", "microsoft edge", "safari", "":
x := new(Chrome)
if buf != nil {
err = easyjson.Unmarshal(buf, x)
if err != nil {
return nil, err
}
}
return x, nil
}
return nil, ErrUnsupportedProtocolType
}
// NewPageTargetWithURL creates a new page target with the specified url.
func (c *Client) NewPageTargetWithURL(ctxt context.Context, urlstr string) (Target, error) {
var err error
t, err := c.newTarget(ctxt, nil)
if err != nil {
return nil, err
}
u := "new"
if urlstr != "" {
u += "?" + urlstr
}
err = c.doReq(ctxt, u, t)
if err != nil {
return nil, err
}
return t, nil
}
// NewPageTarget creates a new page target.
func (c *Client) NewPageTarget(ctxt context.Context) (Target, error) {
return c.NewPageTargetWithURL(ctxt, "")
}
// ActivateTarget activates a target.
func (c *Client) ActivateTarget(ctxt context.Context, t Target) error {
return c.doReq(ctxt, "activate/"+t.GetID(), nil)
}
// CloseTarget activates a target.
func (c *Client) CloseTarget(ctxt context.Context, t Target) error {
return c.doReq(ctxt, "close/"+t.GetID(), nil)
}
// VersionInfo returns information about the remote debugging protocol.
func (c *Client) VersionInfo(ctxt context.Context) (map[string]string, error) {
var err error
v := map[string]string{}
err = c.doReq(ctxt, "version", &v)
if err != nil {
return nil, err
}
return v, nil
}
// WatchPageTargets watches for new page targets.
func (c *Client) WatchPageTargets(ctxt context.Context) <-chan Target {
if ctxt == nil {
ctxt = context.Background()
}
ch := make(chan Target)
go func() {
defer close(ch)
encountered := make(map[string]bool)
check := func() error {
targets, err := c.ListPageTargets(ctxt)
if err != nil {
return err
}
for _, t := range targets {
if !encountered[t.GetID()] {
ch <- t
}
encountered[t.GetID()] = true
}
return nil
}
var err error
lastGood := time.Now()
for {
err = check()
if err == nil {
lastGood = time.Now()
} else if time.Now().After(lastGood.Add(c.timeout)) {
return
}
select {
case <-time.After(c.check):
continue
case <-ctxt.Done():
return
}
}
}()
return ch
}
// Option is a Chrome Debugging Protocol client option.
type Option func(*Client)
// URL is a client option to specify the remote Chrome instance to connect to.
func URL(urlstr string) Option {
return func(c *Client) {
// since chrome 66+, dev tools requires the host name to be either an
// IP address, or "localhost"
if strings.HasPrefix(strings.ToLower(urlstr), "http://") {
host, port, path := urlstr[7:], "", ""
if i := strings.Index(host, "/"); i != -1 {
host, path = host[:i], host[i:]
}
if i := strings.Index(host, ":"); i != -1 {
host, port = host[:i], host[i:]
}
if addr, err := net.ResolveIPAddr("ip", host); err == nil {
urlstr = "http://" + addr.IP.String() + port + path
}
}
c.url = urlstr
}
}
// WatchInterval is a client option that specifies the check interval duration.
func WatchInterval(check time.Duration) Option {
return func(c *Client) {
c.check = check
}
}
// WatchTimeout is a client option that specifies the watch timeout duration.
func WatchTimeout(timeout time.Duration) Option {
return func(c *Client) {
c.timeout = timeout
}
}