blob: ab095937ee6618bf8f3283c98e0af30f721b3bf3 [file] [log] [blame] [edit]
package devtool
import (
"context"
"encoding/json"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"strings"
"sync"
"github.com/mafredri/cdp/internal/errors"
)
// DevToolsOption represents a function that sets a DevTools option.
type DevToolsOption func(*DevTools)
// WithClient returns a DevToolsOption that sets the http Client used
// for HTTP GET requests.
func WithClient(client *http.Client) DevToolsOption {
return func(d *DevTools) {
d.client = client
}
}
// DevTools represents a devtools endpoint for managing and querying
// information about targets.
type DevTools struct {
url string
client *http.Client
mu sync.Mutex // Protects following.
lookup bool
}
// New returns a DevTools instance that uses URL.
func New(url string, opts ...DevToolsOption) *DevTools {
devtools := &DevTools{url: url}
for _, o := range opts {
o(devtools)
}
if devtools.client == nil {
devtools.client = &http.Client{}
}
return devtools
}
// Type represents the type of Target.
type Type string
// Type enums.
const (
BackgroundPage Type = "background_page"
Node Type = "node"
Other Type = "other"
Page Type = "page"
ServiceWorker Type = "service_worker"
)
// Target represents a devtools target, e.g. a browser tab.
type Target struct {
Description string `json:"description"`
DevToolsFrontendURL string `json:"devtoolsFrontendUrl"`
ID string `json:"id"`
Title string `json:"title"`
Type Type `json:"type"`
URL string `json:"url"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
// Create a new Target, usually a page with about:blank as URL.
func (d *DevTools) Create(ctx context.Context) (*Target, error) {
return d.CreateURL(ctx, "")
}
// CreateURL is like Create but opens the provided URL. The URL must be
// valid and begin with "http://" or "https://".
func (d *DevTools) CreateURL(ctx context.Context, openURL string) (*Target, error) {
var escapedQueryURL string
if openURL != "" {
if parsed, err := url.Parse(openURL); err != nil || !parsed.IsAbs() {
return nil, errors.New("devtool: CreateURL: invalid openURL: " + openURL)
}
escapedQueryURL = "?" + url.QueryEscape(openURL)
}
resp, err := d.httpGet(ctx, "/json/new"+escapedQueryURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
// Returned by Headless Chrome that does
// not support the "/json/new" endpoint.
case http.StatusInternalServerError:
err2 := parseError("CreateUrl: StatusInternalServerError", resp.Body)
v, err := d.Version(ctx)
if err != nil {
return nil, err2
}
if v.WebSocketDebuggerURL != "" {
// This version is too new since it has a debugger URL set.
return nil, err2
}
return fallbackHeadlessCreateURL(ctx, d, openURL)
case http.StatusOK:
t := new(Target)
return t, json.NewDecoder(resp.Body).Decode(t)
default:
return nil, parseError("CreateURL", resp.Body)
}
}
// Get the first Target that matches Type.
func (d *DevTools) Get(ctx context.Context, typ Type) (*Target, error) {
list, err := d.List(ctx)
if err != nil {
return nil, err
}
for _, t := range list {
if t.Type == typ {
return t, nil
}
}
return nil, errors.New("devtool: Get: could not find target of type: " + string(typ))
}
// List returns a list with all devtools Targets.
func (d *DevTools) List(ctx context.Context) ([]*Target, error) {
resp, err := d.httpGet(ctx, "/json/list")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, parseError("List", resp.Body)
}
var t []*Target
return t, json.NewDecoder(resp.Body).Decode(&t)
}
// Activate brings focus to the Target.
func (d *DevTools) Activate(ctx context.Context, t *Target) error {
resp, err := d.httpGet(ctx, "/json/activate/"+t.ID)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return parseError("Activate", resp.Body)
}
return nil
}
// Close the Target.
func (d *DevTools) Close(ctx context.Context, t *Target) error {
resp, err := d.httpGet(ctx, "/json/close/"+t.ID)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return parseError("Close", resp.Body)
}
return nil
}
// Version contains the version information for the DevTools endpoint.
type Version struct {
// Present in Chrome, Edge, Node, etc.
Browser string `json:"Browser"`
Protocol string `json:"Protocol-Version"`
// Present in Chrome, Edge.
UserAgent string `json:"User-Agent"`
V8 string `json:"V8-Version"`
WebKit string `json:"WebKit-Version"`
// Present on Android.
AndroidPackage string `json:"Android-Package"`
// Present in Chrome >= 62. Generic browser websocket URL.
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
// Version returns the version information for the DevTools endpoint.
func (d *DevTools) Version(ctx context.Context) (*Version, error) {
resp, err := d.httpGet(ctx, "/json/version")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, parseError("Version", resp.Body)
}
v := new(Version)
return v, json.NewDecoder(resp.Body).Decode(&v)
}
func (d *DevTools) httpGet(ctx context.Context, path string) (*http.Response, error) {
if ctx == nil {
ctx = context.Background()
}
err := d.resolveHost(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, d.url+path, nil)
if err != nil {
return nil, err
}
return d.client.Do(req.WithContext(ctx))
}
// resolveHost does a lookup on the hostname in d.url and tries to
// replace it with a valid IP address. Ever since Chrome 66, the
// DevTools endpoint disallows hostnames other than "localhost".
//
// Example error:
// < HTTP/1.1 500 Internal Server Error
// < Content-Length:63
// < Content-Type:text/html
// <
// Host header is specified and is not an IP address or localhost.
func (d *DevTools) resolveHost(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.lookup {
return nil
}
d.lookup = true
u, err := url.Parse(d.url)
if err != nil {
return err
}
host := strings.Split(u.Host, ":")
origHost := host[0]
if origHost == "localhost" {
return nil // Nothing to do, localhost is allowed.
}
addrs, err := net.DefaultResolver.LookupHost(ctx, origHost)
if err != nil {
return err
}
newURL := ""
for _, a := range addrs {
host[0] = a
u.Host = strings.Join(host, ":")
try := u.String()
// The selection of "/json/version" here is arbitrary,
// it just needs to exist and not have side-effects.
req, err := http.NewRequest(http.MethodGet, try+"/json/version", nil)
if err != nil {
return err
}
resp, err := d.client.Do(req.WithContext(ctx))
if err == nil && resp.StatusCode == 200 {
newURL = try
break
}
}
if newURL == "" {
return errors.New("could not resolve IP for " + origHost)
}
d.url = newURL
return nil
}
func parseError(from string, r io.Reader) error {
m, err := ioutil.ReadAll(r)
if err != nil {
return err
}
return errors.New("devtool: " + from + ": " + string(m))
}