From 08c2b5f20cbd048e13b8b3aaf91be66429c885b9 Mon Sep 17 00:00:00 2001 From: zoid Date: Thu, 30 May 2024 10:54:57 +0400 Subject: [PATCH] v2 --- README.md | 81 ++--- atoms.go | 26 -- browser.go | 121 ------- cdp/broker.go | 100 ++++++ cdp/model.go | 65 ++++ cdp/promise.go | 65 ++++ cdp/transport.go | 158 +++++++++ chrome/chrome.go | 144 ++++++++ chrome/main.go | 131 ------- element.go | 359 ------------------- emulation.go | 61 ---- errors.go | 68 ---- example/main.go | 109 ++++-- frame.go | 229 ------------ geom.go | 55 --- go.mod | 2 +- input.go | 318 +++++++---------- key/key.go | 294 +++++++++++++++ main.go | 67 ++++ mobile/main.go | 127 ------- network.go | 149 -------- node.go | 726 ++++++++++++++++++++++++++++++++++++++ optional.go | 62 ++++ page.go | 163 ++++++--- promise.go | 87 ----- protocol/runtime/types.go | 92 ++--- retry/main.go | 93 +++++ runtime.go | 258 ++++++++++++-- session.go | 444 +++++++++++++++++------ transport/client.go | 156 -------- transport/observer.go | 85 ----- transport/types.go | 59 ---- 32 files changed, 2748 insertions(+), 2206 deletions(-) delete mode 100644 atoms.go delete mode 100644 browser.go create mode 100644 cdp/broker.go create mode 100644 cdp/model.go create mode 100644 cdp/promise.go create mode 100644 cdp/transport.go create mode 100644 chrome/chrome.go delete mode 100644 chrome/main.go delete mode 100644 element.go delete mode 100644 emulation.go delete mode 100644 errors.go delete mode 100644 frame.go delete mode 100644 geom.go create mode 100644 key/key.go create mode 100644 main.go delete mode 100644 mobile/main.go delete mode 100644 network.go create mode 100644 node.go create mode 100644 optional.go delete mode 100644 promise.go create mode 100644 retry/main.go delete mode 100644 transport/client.go delete mode 100644 transport/observer.go delete mode 100644 transport/types.go diff --git a/README.md b/README.md index cdffa65..f9b072f 100644 --- a/README.md +++ b/README.md @@ -11,76 +11,65 @@ _Warning_ This is an experimental project, backward compatibility is not guarant Here is an example of using: ```go -package main - -import ( - "context" - "log" - "time" - - "github.com/ecwid/control" - "github.com/ecwid/control/chrome" -) - func main() { - chromium, err := chrome.Launch(context.TODO(), "--disable-popup-blocking") // you can specify more startup parameters for chrome - if err != nil { - panic(err) - } - defer chromium.Close() - ctrl := control.New(chromium.GetClient()) - - session, err := ctrl.CreatePageTarget("") + session, cancel, err := control.Take("--no-startup-window") if err != nil { panic(err) } + defer cancel() - var page = session.Page() // main frame - err = page.Navigate("https://surfparadise.ecwid.com/", control.LifecycleIdleNetwork, time.Second*60) - if err != nil { - panic(err) + retrier := retry.Static{ + Timeout: 10 * time.Second, + Delay: 500 * time.Millisecond, // delay between attempts } - items, err := page.QuerySelectorAll(".grid-product__title-inner") + session.Frame.MustNavigate("https://zoid.ecwid.com") + + var products []string + err = retry.Func(retrier, func() error { + products = []string{} + return session.Frame.QueryAll(".grid-product__title-inner").Then(func(nl control.NodeList) error { + return nl.Foreach(func(n *control.Node) error { + return n.GetText().Then(func(s string) error { + products = append(products, s) + return nil + }) + }) + }) + }) if err != nil { panic(err) } - for _, i := range items { - title, err := i.GetText() - if err != nil { - panic(err) - } - log.Print(title) + + // "must way" throws panic on an error + + for _, node := range session.Frame.MustQueryAll(".grid-product__title-inner") { + log.Println(node.MustGetText()) } } ``` You can call any CDP method implemented in protocol package using a session ```go -err = security.SetIgnoreCertificateErrors(sess, security.SetIgnoreCertificateErrorsArgs{ +err = security.SetIgnoreCertificateErrors(session, security.SetIgnoreCertificateErrorsArgs{ Ignore: true, }) ``` -or even call a custom method +or call a custom unimplemented method ```go -err = sess.Call("Security.setIgnoreCertificateErrors", sendStruct, receiveStruct) +err = session.Call("Security.setIgnoreCertificateErrors", sendStruct, receiveStruct) ``` -Subscribe on domain event +Subscribe on the domain event ```go -cancel := sess.Subscribe("Overlay.screenshotRequested", func(e observe.Value) { - v := overlay.ScreenshotRequested{} - _= json.Unmarshal(e.Params, &v) - doSomething(v.Viewport.Height) +future := control.Subscribe(session, "Target.targetCreated", func(t target.TargetCreated) bool { + return t.TargetInfo.Type == "page" }) -defer cancel() +defer future.Cancel() -// Subscribe on all incoming events -sess.Subscribe("*", func(e observe.Value) { - switch e.Method { - case "Overlay.screenshotRequested": - } -}) +// do something here ... -``` +ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10) +result /* target.TargetCreated */, err := future.Get(ctx) +``` \ No newline at end of file diff --git a/atoms.go b/atoms.go deleted file mode 100644 index 8632489..0000000 --- a/atoms.go +++ /dev/null @@ -1,26 +0,0 @@ -package control - -const ( - WebEventKeypress = "keypress" - WebEventKeyup = "keyup" - WebEventChange = "change" - WebEventInput = "input" - WebEventClick = "click" -) - -// Atom JS functions -const ( - functionClearText = `function(){("INPUT"===this.nodeName||"TEXTAREA"===this.nodeName)?this.value="":this.innerText=""}` - functionGetText = `function(){switch(this.tagName){case"INPUT":case"TEXTAREA":return this.value;case"SELECT":return Array.from(this.selectedOptions).map(b=>b.innerText).join();default:return this.innerText||this.textContent.trim();}}` - functionDispatchEvents = `function(l){for(const e of l)this.dispatchEvent(new Event(e,{'bubbles':!0}))}` - functionPreventMissClick = `function(){let b=this,c={capture:!0,once:!1},d=c=>{for(let d=c;d;d=d.parentNode)if(d===b)return!0;return!1},f=b=>{b.isTrusted&&(d(b.target)?_on_click("1"):(b.stopPropagation(),b.preventDefault(),_on_click((b.target.outerHTML||"").substr(0,256))),document.removeEventListener("click",f,c))};document.addEventListener("click",f,c)}` - functionSetAttr = `function(a,v){this.setAttribute(a,v)}` - functionGetAttr = `function(a){return this.getAttribute(a)}` - functionCheckbox = `function(v){this.checked=v}` - functionIsChecked = `function(){return this.checked}` - functionGetComputedStyle = `function(p,s){return getComputedStyle(this, p)[s]}` - functionSelect = `function(a){const b=Array.from(this.options);this.value=void 0;for(const c of b)if(c.selected=a.includes(c.value),c.selected&&!this.multiple)break}` - functionGetSelectedValues = `function(){return Array.from(this.options).filter(a=>a.selected).map(a=>a.value)}` - functionGetSelectedInnerText = `function(){return Array.from(this.options).filter(a=>a.selected).map(a=>a.innerText)}` - functionDOMIdle = `var d=function(e,t,n){var u,r=null;return function(){var i=this,o=arguments,s=n&&!r;return clearTimeout(r),r=setTimeout(function(){r=null,n||(u=e.apply(i,o))},t),s&&(u=e.apply(i,o)),u}};new Promise((e,t)=>{var n=d(function(){e()},%d);new MutationObserver(n).observe(document,{attributes:!0,childList:!0,subtree:!0}),n(),setTimeout(()=>t("timeout"),%d)});` -) diff --git a/browser.go b/browser.go deleted file mode 100644 index c08f522..0000000 --- a/browser.go +++ /dev/null @@ -1,121 +0,0 @@ -package control - -import ( - "context" - "sync" - - "github.com/ecwid/control/protocol/browser" - "github.com/ecwid/control/protocol/network" - "github.com/ecwid/control/protocol/page" - "github.com/ecwid/control/protocol/runtime" - "github.com/ecwid/control/protocol/target" - "github.com/ecwid/control/transport" -) - -type BrowserContext struct { - Client *transport.Client -} - -func New(client *transport.Client) BrowserContext { - return BrowserContext{Client: client} -} - -func (b BrowserContext) Call(method string, send, recv interface{}) error { - return b.Client.Call("", method, send, recv) -} - -func (b BrowserContext) Crash() error { - return browser.Crash(b) -} - -func (b BrowserContext) Close() error { - return b.Client.Close() -} - -func (b BrowserContext) SetDiscoverTargets(discover bool) error { - return target.SetDiscoverTargets(b, target.SetDiscoverTargetsArgs{Discover: discover}) -} - -func (b BrowserContext) runSession(targetID target.TargetID, sessionID target.SessionID) (session *Session, err error) { - session = &Session{ - id: sessionID, - tid: targetID, - browser: b, - eventPool: make(chan transport.Event, 20000), - publisher: transport.NewPublisher(), - executions: &sync.Map{}, - } - session.context, session.cancelCtx = context.WithCancel(b.Client.Context()) - session.Input = Input{s: session, mx: &sync.Mutex{}} - session.Network = Network{s: session} - session.Emulation = Emulation{s: session} - - go session.handleEventPool() - session.detach = b.Client.Register(session) - - if err = page.Enable(session); err != nil { - return nil, err - } - if err = runtime.Enable(session); err != nil { - return nil, err - } - if err = runtime.AddBinding(session, runtime.AddBindingArgs{Name: bindClick}); err != nil { - return nil, err - } - if err = page.SetLifecycleEventsEnabled(session, page.SetLifecycleEventsEnabledArgs{Enabled: true}); err != nil { - return nil, err - } - if err = target.SetDiscoverTargets(session, target.SetDiscoverTargetsArgs{Discover: true}); err != nil { - return nil, err - } - // maxPostDataSize - The Longest post body size (in bytes) that would be included in requestWillBeSent notification - if err = network.Enable(session, network.EnableArgs{MaxPostDataSize: 20 * 1024}); err != nil { - return nil, err - } - return -} - -func (b BrowserContext) AttachPageTarget(id target.TargetID) (*Session, error) { - val, err := target.AttachToTarget(b, target.AttachToTargetArgs{ - TargetId: id, - Flatten: true, - }) - if err != nil { - return nil, err - } - return b.runSession(id, val.SessionId) -} - -func (b BrowserContext) CreatePageTarget(url string) (*Session, error) { - if url == "" { - url = Blank // headless chrome crash when url is empty - } - r, err := target.CreateTarget(b, target.CreateTargetArgs{Url: url}) - if err != nil { - return nil, err - } - return b.AttachPageTarget(r.TargetId) -} - -func (b BrowserContext) ActivateTarget(id target.TargetID) error { - return target.ActivateTarget(b, target.ActivateTargetArgs{ - TargetId: id, - }) -} - -func (b BrowserContext) CloseTarget(id target.TargetID) (err error) { - err = target.CloseTarget(b, target.CloseTargetArgs{TargetId: id}) - /* Target.detachedFromTarget event may come before the response of CloseTarget call */ - if err == ErrDetachedFromTarget { - return nil - } - return err -} - -func (b BrowserContext) GetTargets() ([]*target.TargetInfo, error) { - val, err := target.GetTargets(b, target.GetTargetsArgs{}) - if err != nil { - return nil, err - } - return val.TargetInfos, nil -} diff --git a/cdp/broker.go b/cdp/broker.go new file mode 100644 index 0000000..d552cc3 --- /dev/null +++ b/cdp/broker.go @@ -0,0 +1,100 @@ +package cdp + +import ( + "sync" +) + +var BrokerChannelSize = 50000 + +type subscriber struct { + sessionID string + channel chan Message +} + +type broker struct { + cancel chan struct{} + messages chan Message + sub chan subscriber + unsub chan chan Message + lock *sync.Mutex +} + +func makeBroker() broker { + return broker{ + cancel: make(chan struct{}), + messages: make(chan Message), + sub: make(chan subscriber), + unsub: make(chan chan Message), + lock: &sync.Mutex{}, + } +} + +func (b broker) run() { + var value = map[chan Message]subscriber{} + for { + select { + + case sub := <-b.sub: + value[sub.channel] = sub + + case channel := <-b.unsub: + if _, ok := value[channel]; ok { + delete(value, channel) + close(channel) + } + + case <-b.cancel: + for msgCh := range value { + close(msgCh) + } + close(b.sub) + close(b.unsub) + close(b.messages) + return + + case message := <-b.messages: + for _, subscriber := range value { + if message.SessionID == "" || subscriber.sessionID == "" || message.SessionID == subscriber.sessionID { + subscriber.channel <- message + } + } + } + } +} + +func (b broker) subscribe(sessionID string) chan Message { + b.lock.Lock() + defer b.lock.Unlock() + + select { + case <-b.cancel: + return nil + default: + sub := subscriber{ + sessionID: sessionID, + channel: make(chan Message, BrokerChannelSize), + } + b.sub <- sub + return sub.channel + } +} + +func (b broker) unsubscribe(value chan Message) { + b.lock.Lock() + select { + case <-b.cancel: + default: + b.unsub <- value + } + b.lock.Unlock() +} + +func (b broker) publish(msg Message) { + b.messages <- msg +} + +func (b broker) Cancel() { + b.lock.Lock() + close(b.cancel) + b.lock.Unlock() +} diff --git a/cdp/model.go b/cdp/model.go new file mode 100644 index 0000000..95b87de --- /dev/null +++ b/cdp/model.go @@ -0,0 +1,65 @@ +package cdp + +import ( + "encoding/json" + "errors" +) + +type Request struct { + ID uint64 `json:"id"` + SessionID string `json:"sessionId,omitempty"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +func (r Request) String() string { + b, _ := json.Marshal(r) + return string(b) +} + +type Response struct { + ID uint64 `json:"id,omitempty"` + Result Untyped `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` + *Message +} + +func (r Response) String() string { + b, _ := json.Marshal(r) + return string(b) +} + +type Message struct { + SessionID string `json:"sessionId,omitempty"` + Method string `json:"method,omitempty"` + Params Untyped `json:"params,omitempty"` +} + +type Error struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data,omitempty"` +} + +func (e Error) Error() string { + return e.Message +} + +type Untyped []byte + +// MarshalJSON returns m as the JSON encoding of m. +func (m Untyped) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + return m, nil +} + +// UnmarshalJSON sets *m to a copy of data. +func (m *Untyped) UnmarshalJSON(data []byte) error { + if m == nil { + return errors.New("cdp.Untyped: UnmarshalJSON on nil pointer") + } + *m = append((*m)[0:0], data...) + return nil +} diff --git a/cdp/promise.go b/cdp/promise.go new file mode 100644 index 0000000..85b5725 --- /dev/null +++ b/cdp/promise.go @@ -0,0 +1,65 @@ +package cdp + +import ( + "context" + "errors" + "sync" +) + +var ErrPromiseCanceled = errors.New("promise canceled") + +type Future[T any] interface { + Get(context.Context) (T, error) + Cancel() +} + +func NewPromise[T any](executor func(resolve func(T), reject func(error)), finally func()) Future[T] { + value := &promise[T]{ + finally: finally, + fulfilled: make(chan struct{}, 1), + } + go executor(value.resolve, value.reject) + return value +} + +type promise[T any] struct { + once sync.Once + fulfilled chan struct{} + value T + err error + finally func() +} + +func (u *promise[T]) Get(parent context.Context) (T, error) { + defer u.Cancel() + select { + case <-parent.Done(): + return u.value, context.Cause(parent) + case <-u.fulfilled: + return u.value, u.err + } +} + +func (u *promise[T]) Cancel() { + u.reject(ErrPromiseCanceled) +} + +func (u *promise[T]) resolve(value T) { + u.once.Do(func() { + u.value = value + close(u.fulfilled) + if u.finally != nil { + u.finally() + } + }) +} + +func (u *promise[T]) reject(err error) { + u.once.Do(func() { + u.err = err + close(u.fulfilled) + if u.finally != nil { + u.finally() + } + }) +} diff --git a/cdp/transport.go b/cdp/transport.go new file mode 100644 index 0000000..7bde73a --- /dev/null +++ b/cdp/transport.go @@ -0,0 +1,158 @@ +package cdp + +import ( + "context" + "errors" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +var DefaultDialer = websocket.Dialer{ + ReadBufferSize: 8192, + WriteBufferSize: 8192, + HandshakeTimeout: 45 * time.Second, + Proxy: http.ProxyFromEnvironment, +} + +var ErrGracefullyClosed = errors.New("gracefully closed") + +type Transport struct { + context context.Context + cancel func(error) + conn *websocket.Conn + seq uint64 + pending map[uint64]*promise[Response] + mutex sync.Mutex + broker broker + logger *slog.Logger +} + +func DefaultDial(context context.Context, url string, logger *slog.Logger) (*Transport, error) { + return Dial(context, DefaultDialer, url, logger) +} + +func Dial(parent context.Context, dialer websocket.Dialer, url string, logger *slog.Logger) (*Transport, error) { + conn, _, err := dialer.Dial(url, nil) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancelCause(parent) + transport := &Transport{ + context: ctx, + cancel: cancel, + conn: conn, + seq: 1, + broker: makeBroker(), + pending: make(map[uint64]*promise[Response]), + logger: logger, + } + go transport.broker.run() + go func() { + var readerr error + for ; readerr == nil; readerr = transport.read() { + } + transport.cancel(readerr) + transport.gracefullyClose() + }() + return transport, nil +} + +func (t *Transport) Log(level slog.Level, msg string, args ...any) { + if t.logger != nil { + t.logger.Log(t.context, level, msg, args...) + } +} + +func (t *Transport) Context() context.Context { + return t.context +} + +func (t *Transport) Close() error { + select { + case <-t.context.Done(): + return context.Cause(t.context) + default: + _, err := t.Send(&Request{Method: "Browser.close"}).Get(t.context) + if err != nil { + return err + } + t.cancel(ErrGracefullyClosed) + return t.conn.Close() + } +} + +func (t *Transport) gracefullyClose() { + t.mutex.Lock() + defer t.mutex.Unlock() + t.broker.Cancel() + err := context.Cause(t.context) + for key, value := range t.pending { + value.reject(err) + delete(t.pending, key) + } +} + +func (t *Transport) Subscribe(sessionID string) (chan Message, func()) { + channel := t.broker.subscribe(sessionID) + return channel, func() { + if channel != nil { + t.broker.unsubscribe(channel) + } + } +} + +func (t *Transport) Send(request *Request) Future[Response] { + var promise = &promise[Response]{fulfilled: make(chan struct{}, 1)} + select { + case <-t.context.Done(): + promise.reject(context.Cause(t.context)) + return promise + default: + } + + t.mutex.Lock() + defer t.mutex.Unlock() + seq := t.seq + t.seq++ + t.pending[seq] = promise + request.ID = seq + t.Log(slog.LevelDebug, "send ->", "request", request.String()) + + if err := t.conn.WriteJSON(request); err != nil { + delete(t.pending, seq) + promise.reject(err) + } + return promise +} + +func (t *Transport) read() (err error) { + var response = Response{} + if err = t.conn.ReadJSON(&response); err != nil { + return err + } + t.Log(slog.LevelDebug, "recv <-", "response", response.String()) + + if response.ID == 0 && response.Message != nil { + t.broker.publish(*response.Message) + return nil + } + + t.mutex.Lock() + value, ok := t.pending[response.ID] + delete(t.pending, response.ID) + t.mutex.Unlock() + + if !ok { + return errors.New("unexpected response " + response.String()) + } + if response.Error != nil { + value.reject(response.Error) + return nil + } + value.resolve(response) + return nil +} diff --git a/chrome/chrome.go b/chrome/chrome.go new file mode 100644 index 0000000..241865e --- /dev/null +++ b/chrome/chrome.go @@ -0,0 +1,144 @@ +package chrome + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" +) + +var MaxTimeToStart = 10 * time.Second + +type Chrome struct { + WebSocketUrl string + UserDataDir string + StartArgs string + cmd *exec.Cmd +} + +type Target struct { + Description string `json:"description,omitempty"` + DevtoolsFrontendUrl string `json:"devtoolsFrontendUrl,omitempty"` + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Type string `json:"type,omitempty"` + Url string `json:"url,omitempty"` + WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl,omitempty"` +} + +func (c Chrome) NewTab(cli *http.Client, address string) (target Target, err error) { + u, err := url.Parse(c.WebSocketUrl) + if err != nil { + return target, err + } + request, err := http.NewRequest(http.MethodPut, fmt.Sprintf(`http://`+u.Host+`/json/new?`+address), nil) + if err != nil { + return target, err + } + r, err := cli.Do(request) + if err != nil { + return target, err + } + var b []byte + b, err = io.ReadAll(r.Body) + if err != nil { + return target, err + } + if err = r.Body.Close(); err != nil { + return + } + if err = json.Unmarshal(b, &target); err != nil { + return + } + return +} + +func (c Chrome) WaitCloseGracefully() error { + defer func() { + err := os.RemoveAll(c.UserDataDir) + if err != nil { + log.Println(err) + } + }() + return c.cmd.Wait() +} + +func bin() string { + for _, path := range []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "headless-shell", + "browser", + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + } { + if _, err := exec.LookPath(path); err == nil { + return path + } + } + panic("chrome binary not found") +} + +func Launch(ctx context.Context, userFlags ...string) (value Chrome, err error) { + if value.UserDataDir, err = os.MkdirTemp("", "chrome-control-*"); err != nil { + return value, err + } + // https://github.com/GoogleChrome/chrome-launcher/blob/master/docs/chrome-flags-for-tools.md + // https://docs.google.com/spreadsheets/d/1n-vw_PCPS45jX3Jt9jQaAhFqBY6Ge1vWF_Pa0k7dCk4/edit#gid=1265672696 + flags := []string{ + "--remote-debugging-port=0", + "--user-data-dir=" + value.UserDataDir, + } + if len(userFlags) > 0 { + flags = append(flags, userFlags...) + } + if os.Getuid() == 0 { + flags = append(flags, "--no-sandbox", "--disable-setuid-sandbox") + } + binary := bin() + value.StartArgs = fmt.Sprint(binary, strings.Join(flags, " ")) + value.cmd = exec.CommandContext(ctx, binary, flags...) + + stderr, err := value.cmd.StderrPipe() + if err != nil { + return value, err + } + + addr := make(chan string) + var std []string + go func() { + const prefix = "DevTools listening on" + var scanner = bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + std = append(std, line) + if s := strings.TrimPrefix(line, prefix); s != line { + addr <- strings.TrimSpace(s) + return + } + } + }() + + if err = value.cmd.Start(); err != nil { + return value, err + } + + select { + case value.WebSocketUrl = <-addr: + return value, nil + case <-time.After(MaxTimeToStart): + return value, fmt.Errorf("chrome stopped too early %s", strings.Join(std, "\n")) + } +} diff --git a/chrome/main.go b/chrome/main.go deleted file mode 100644 index 1ca5f5d..0000000 --- a/chrome/main.go +++ /dev/null @@ -1,131 +0,0 @@ -package chrome - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "strings" - "time" - - "github.com/ecwid/control/transport" -) - -// Browser ... -type Browser struct { - webSocketURL string - cmd *exec.Cmd - client *transport.Client - UserDataDir string -} - -func (c Browser) GetClient() *transport.Client { - return c.client -} - -// Close close browser -func (c Browser) Close() error { - // Close close browser and websocket connection - exited := make(chan int, 1) - go func() { - state, _ := c.cmd.Process.Wait() - exited <- state.ExitCode() - }() - _ = c.client.Close() - select { - case <-exited: - return nil - case <-time.After(time.Second * 10): - if err := c.cmd.Process.Kill(); err != nil { - return err - } - return errors.New("browser is not closing gracefully, process was killed") - } -} - -// Launch a new browser process -func Launch(ctx context.Context, userFlags ...string) (*Browser, error) { - browser := &Browser{} - var ( - path string - err error - ) - bin := []string{ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/usr/bin/google-chrome", - "headless-shell", - "browser", - "chromium", - "chromium-browser", - "google-chrome", - "google-chrome-stable", - "google-chrome-beta", - "google-chrome-unstable", - } - for _, c := range bin { - if _, err = exec.LookPath(c); err == nil { - path = c - break - } - } - - if browser.UserDataDir, err = os.MkdirTemp("", "chrome-control"); err != nil { - return nil, err - } - - // https://github.com/GoogleChrome/chrome-launcher/blob/master/docs/chrome-flags-for-tools.md - flags := []string{ - "--remote-debugging-port=0", - "--user-data-dir=" + browser.UserDataDir, - } - - if len(userFlags) > 0 { - flags = append(flags, userFlags...) - } - if os.Getuid() == 0 { - flags = append(flags, "--no-sandbox") - } - - browser.cmd = exec.CommandContext(ctx, path, flags...) - stderr, err := browser.cmd.StderrPipe() - if err != nil { - return nil, err - } - defer stderr.Close() - if err = browser.cmd.Start(); err != nil { - return nil, err - } - browser.webSocketURL, err = addrFromStderr(stderr) - if err != nil { - return nil, err - } - browser.client, err = transport.Dial(ctx, browser.webSocketURL) - return browser, err -} - -func addrFromStderr(rc io.ReadCloser) (string, error) { - const prefix = "DevTools listening on" - var ( - url = "" - scanner = bufio.NewScanner(rc) - lines []string - ) - for scanner.Scan() { - line := scanner.Text() - if s := strings.TrimPrefix(line, prefix); s != line { - url = strings.TrimSpace(s) - break - } - lines = append(lines, line) - } - if err := scanner.Err(); err != nil { - return "", err - } - if url == "" { - return "", fmt.Errorf("chrome stopped too early; stderr:\n%s", strings.Join(lines, "\n")) - } - return url, nil -} diff --git a/element.go b/element.go deleted file mode 100644 index 6d4e81a..0000000 --- a/element.go +++ /dev/null @@ -1,359 +0,0 @@ -package control - -import ( - "fmt" - "math" - "time" - - "github.com/ecwid/control/protocol/dom" - "github.com/ecwid/control/protocol/input" - "github.com/ecwid/control/protocol/runtime" -) - -func (f Frame) constructElement(object *runtime.RemoteObject) (*Element, error) { - val, err := dom.DescribeNode(f, dom.DescribeNodeArgs{ - ObjectId: object.ObjectId, - }) - if err != nil { - return nil, err - } - return &Element{node: val.Node, runtime: object, frame: &f}, nil -} - -type Element struct { - runtime *runtime.RemoteObject - node *dom.Node - frame *Frame -} - -func (e Element) Description() string { - return e.runtime.Description -} - -func (e Element) Node() *dom.Node { - return e.node -} - -func (e Element) QuerySelector(selector string) (*Element, error) { - val, err := e.CallFunction(`function(s){return this.querySelector(s)}`, true, false, NewSingleCallArgument(selector)) - if err != nil { - return nil, err - } - return e.frame.constructElement(val) -} - -func (e Element) CallFunction(function string, await, returnByValue bool, args []*runtime.CallArgument) (*runtime.RemoteObject, error) { - val, err := runtime.CallFunctionOn(e.frame, runtime.CallFunctionOnArgs{ - FunctionDeclaration: function, - ObjectId: e.runtime.ObjectId, - AwaitPromise: await, - ReturnByValue: returnByValue, - Arguments: args, - }) - if err != nil { - return nil, err - } - if val.ExceptionDetails != nil { - return nil, RuntimeError(*val.ExceptionDetails) - } - return val.Result, nil -} - -func NewSingleCallArgument(arg interface{}) []*runtime.CallArgument { - return []*runtime.CallArgument{{Value: arg}} -} - -func (e Element) dispatchEvents(events ...string) error { - _, err := e.CallFunction(functionDispatchEvents, true, false, NewSingleCallArgument(events)) - return err -} - -func (e Element) ScrollIntoView() error { - return dom.ScrollIntoViewIfNeeded(e.frame, dom.ScrollIntoViewIfNeededArgs{ - BackendNodeId: e.node.BackendNodeId, - }) -} - -func (e Element) GetText() (string, error) { - v, err := e.CallFunction(functionGetText, true, false, nil) - if err != nil { - return "null", err - } - return fmt.Sprint(v.Value), nil -} - -func (e Element) Clear() error { - _, err := e.CallFunction(functionClearText, true, false, nil) - return err -} - -func (e Element) InsertText(text string) error { - var err error - if err = e.ScrollIntoView(); err != nil { - return err - } - if err = e.Focus(); err != nil { - return err - } - if err = e.Clear(); err != nil { - return err - } - if err = e.frame.Session().Input.InsertText(text); err != nil { - return err - } - if err = e.dispatchEvents( - WebEventKeypress, - WebEventInput, - WebEventKeyup, - WebEventChange, - ); err != nil { - return err - } - return nil -} - -// Type ... -func (e *Element) Type(text string, delay time.Duration) error { - var err error - if err = e.ScrollIntoView(); err != nil { - return err - } - if err = e.Clear(); err != nil { - return err - } - if err = e.Focus(); err != nil { - return err - } - for _, c := range text { - if isKey(c) { - if err = e.frame.Session().Input.Press(keyDefinitions[c]); err != nil { - return err - } - } else { - if err = e.InsertText(string(c)); err != nil { - return err - } - } - time.Sleep(delay) - } - if text == "" { - return e.dispatchEvents( - WebEventKeypress, - WebEventInput, - WebEventKeyup, - WebEventChange, - ) - } - return nil -} - -func (e Element) GetContentQuad(viewportCorrection bool) (Quad, error) { - val, err := dom.GetContentQuads(e.frame, dom.GetContentQuadsArgs{ - BackendNodeId: e.node.BackendNodeId, - }) - if err != nil { - return nil, err - } - quads := convertQuads(val.Quads) - if len(quads) == 0 { // should be at least one - return nil, ErrNodeIsNotVisible - } - metric, err := e.frame.Session().GetLayoutMetrics() - if err != nil { - return nil, err - } - for _, quad := range quads { - /* correction is get sub-quad of element that in viewport - _______________ <- Viewport top - | 1 _______ 2 | - | |visible| | visible part of element - |__4|visible|3__| <- Viewport bottom - | |invisib| | this invisible part of element omits if viewportCorrection - |...............| - */ - if viewportCorrection { - for i := 0; i < len(quad); i++ { - quad[i].X = math.Min(math.Max(quad[i].X, 0), float64(metric.CssLayoutViewport.ClientWidth)) - quad[i].Y = math.Min(math.Max(quad[i].Y, 0), float64(metric.CssLayoutViewport.ClientHeight)) - } - } - if quad.Area() > 1 { - return quad, nil - } - } - return nil, ErrNodeIsOutOfViewport -} - -func (e Element) clickablePoint() (x float64, y float64, err error) { - r, err := e.GetContentQuad(true) - if err != nil { - return -1, -1, err - } - x, y = r.Middle() - return x, y, nil -} - -func (e Element) Click() error { - return e.ClickWith(MouseLeft, time.Millisecond*10) -} - -func (e Element) ClickWith(button input.MouseButton, delayToRelease time.Duration) error { - if err := e.ScrollIntoView(); err != nil { - return err - } - if _, err := e.CallFunction(functionPreventMissClick, true, false, nil); err != nil { - return err - } - var clickValue = make(chan string, 1) - defer close(clickValue) - cancel := e.frame.session.onBindingCalled(bindClick, func(p string) { - select { - case clickValue <- p: - default: - } - }) - defer cancel() - x, y, err := e.clickablePoint() - if err != nil { - return err - } - if err = e.frame.Session().Input.Click(button, x, y, delayToRelease); err != nil { - return err - } - const timeout = time.Millisecond * 1000 - var deadline = time.NewTimer(timeout) - defer deadline.Stop() - select { - case v := <-clickValue: - if v != "1" { - return ClickTargetOverlappedError{X: x, Y: y, outerHTML: v} - } - case <-deadline.C: - return ErrClickTimeout - } - return nil -} - -func (e Element) Focus() error { - return dom.Focus(e.frame, dom.FocusArgs{BackendNodeId: e.node.BackendNodeId}) -} - -func (e Element) Upload(files ...string) error { - return dom.SetFileInputFiles(e.frame, dom.SetFileInputFilesArgs{ - Files: files, - BackendNodeId: e.node.BackendNodeId, - }) -} - -func (e Element) Hover() error { - if err := e.ScrollIntoView(); err != nil { - return err - } - x, y, err := e.clickablePoint() - if err != nil { - return err - } - return e.frame.Session().Input.MouseMove(MouseNone, x, y) -} - -func (e Element) SetAttribute(attr string, value string) error { - _, err := e.CallFunction(functionSetAttr, true, false, []*runtime.CallArgument{ - {Value: attr}, - {Value: value}, - }) - return err -} - -func (e Element) GetAttribute(attr string) (string, error) { - v, err := e.CallFunction(functionGetAttr, true, false, NewSingleCallArgument(attr)) - if err != nil { - return "", err - } - return primitiveRemoteObject(*v).String() -} - -func (e Element) Checkbox(check bool) error { - if _, err := e.CallFunction(functionCheckbox, true, false, NewSingleCallArgument(check)); err != nil { - return err - } - return e.dispatchEvents(WebEventClick, WebEventInput, WebEventChange) -} - -func (e *Element) IsChecked() (bool, error) { - v, err := e.CallFunction(functionIsChecked, true, false, nil) - if err != nil { - return false, err - } - return primitiveRemoteObject(*v).Bool() -} - -func (e Element) GetRectangle() (*dom.Rect, error) { - q, err := e.GetContentQuad(false) - if err != nil { - return nil, err - } - rect := &dom.Rect{ - X: q[0].X, - Y: q[0].Y, - Width: q[1].X - q[0].X, - Height: q[3].Y - q[0].Y, - } - return rect, nil -} - -func (e Element) GetComputedStyle(style string, pseudoElt *string) (string, error) { - v, err := e.CallFunction(functionGetComputedStyle, true, false, []*runtime.CallArgument{ - {Value: pseudoElt}, - {Value: style}, - }) - if err != nil { - return "", err - } - return primitiveRemoteObject(*v).String() -} - -func (e Element) SelectValues(values ...string) error { - if "SELECT" != e.node.NodeName { - return fmt.Errorf("can't use element as SELECT, not applicable type %s", e.node.NodeName) - } - _, err := e.CallFunction(functionSelect, true, false, NewSingleCallArgument(values)) - if err != nil { - return err - } - return e.dispatchEvents(WebEventClick, WebEventInput, WebEventChange) -} - -func (e Element) GetSelectedValues() ([]string, error) { - v, err := e.CallFunction(functionGetSelectedValues, true, false, nil) - if err != nil { - return nil, err - } - return e.stringArray(v) -} - -func (e Element) GetSelectedText() ([]string, error) { - v, err := e.CallFunction(functionGetSelectedInnerText, true, false, nil) - if err != nil { - return nil, err - } - return e.stringArray(v) -} - -func (e Element) stringArray(v *runtime.RemoteObject) ([]string, error) { - descriptor, err := e.frame.getProperties(v.ObjectId, true, false) - if err != nil { - return nil, err - } - var options []string - for _, d := range descriptor { - if !d.Enumerable { - continue - } - val, err1 := primitiveRemoteObject(*d.Value).String() - if err1 != nil { - return nil, err1 - } - options = append(options, val) - } - return options, nil -} diff --git a/emulation.go b/emulation.go deleted file mode 100644 index ad07013..0000000 --- a/emulation.go +++ /dev/null @@ -1,61 +0,0 @@ -package control - -import ( - "github.com/ecwid/control/mobile" - "github.com/ecwid/control/protocol/common" - "github.com/ecwid/control/protocol/emulation" -) - -type Emulation struct { - s *Session -} - -// SetDeviceMetricsOverride ... -func (e Emulation) SetDeviceMetricsOverride(metrics emulation.SetDeviceMetricsOverrideArgs) error { - return emulation.SetDeviceMetricsOverride(e.s, metrics) -} - -// SetUserAgentOverride ... -func (e Emulation) SetUserAgentOverride(userAgent, acceptLanguage, platform string, userAgentMetadata *common.UserAgentMetadata) error { - return emulation.SetUserAgentOverride(e.s, emulation.SetUserAgentOverrideArgs{ - UserAgent: userAgent, - AcceptLanguage: acceptLanguage, - Platform: platform, - UserAgentMetadata: userAgentMetadata, - }) -} - -// ClearDeviceMetricsOverride ... -func (e Emulation) ClearDeviceMetricsOverride() error { - return emulation.ClearDeviceMetricsOverride(e.s) -} - -// SetScrollbarsHidden ... -func (e Emulation) SetScrollbarsHidden(hidden bool) error { - return emulation.SetScrollbarsHidden(e.s, emulation.SetScrollbarsHiddenArgs{ - Hidden: hidden, - }) -} - -// SetCPUThrottlingRate https://chromedevtools.github.io/devtools-protocol/tot/Emulation#method-setCPUThrottlingRate -func (e Emulation) SetCPUThrottlingRate(rate float64) error { - return emulation.SetCPUThrottlingRate(e.s, emulation.SetCPUThrottlingRateArgs{ - Rate: rate, - }) -} - -// SetDocumentCookieDisabled https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setDocumentCookieDisabled -func (e Emulation) SetDocumentCookieDisabled(disabled bool) error { - return emulation.SetDocumentCookieDisabled(e.s, emulation.SetDocumentCookieDisabledArgs{ - Disabled: disabled, - }) -} - -// Emulate emulate predefined device -func (e Emulation) Emulate(device *mobile.Device) error { - device.Metrics.DontSetVisibleSize = true - if err := e.SetDeviceMetricsOverride(device.Metrics); err != nil { - return err - } - return e.SetUserAgentOverride(device.UserAgent, "", "", nil) -} diff --git a/errors.go b/errors.go deleted file mode 100644 index f5f4d80..0000000 --- a/errors.go +++ /dev/null @@ -1,68 +0,0 @@ -package control - -import ( - "errors" - "fmt" - "time" - - "github.com/ecwid/control/protocol/common" - "github.com/ecwid/control/protocol/target" -) - -var ( - ErrNodeIsNotVisible = errors.New("node is not visible") - ErrNodeIsOutOfViewport = errors.New("node is out of viewport") - ErrAlreadyNavigated = errors.New("page already navigated to this address - nothing done") - ErrTargetDestroyed = errors.New("this session was destroyed") - ErrDetachedFromTarget = errors.New("detached from target") - ErrClickTimeout = errors.New("no click registered") - ErrExecutionContextDestroyed = errors.New("execution context was destroyed") -) - -type ErrTargetCrashed target.TargetCrashed - -func (e ErrTargetCrashed) Error() string { - return fmt.Sprintf("TargetID = %s, ErrorCode = %d, Status = %s", e.TargetId, e.ErrorCode, e.Status) -} - -type NoSuchElementError struct { - Selector string -} - -func (n NoSuchElementError) Error() string { - return fmt.Sprintf("no such element `%s`", n.Selector) -} - -type NoSuchFrameError struct { - id common.FrameId -} - -func (n NoSuchFrameError) Error() string { - return fmt.Sprintf("no such frame `%s`", n.id) -} - -type RemoteObjectCastError struct { - object primitiveRemoteObject - cast string -} - -func (r RemoteObjectCastError) Error() string { - return fmt.Sprintf("cast to `%s` failed for value `%s`", r.cast, r.object.Type) -} - -type FutureTimeoutError struct { - timeout time.Duration -} - -func (e FutureTimeoutError) Error() string { - return fmt.Sprintf("future timeout has expired (%s)", e.timeout) -} - -type ClickTargetOverlappedError struct { - X, Y float64 - outerHTML string -} - -func (e ClickTargetOverlappedError) Error() string { - return fmt.Sprintf("click at target is overlapped by `%s`", e.outerHTML) -} diff --git a/example/main.go b/example/main.go index f40aeeb..d89eb6e 100644 --- a/example/main.go +++ b/example/main.go @@ -1,64 +1,95 @@ package main import ( + "bytes" "context" + "encoding/json" + "fmt" "log" + "log/slog" "time" "github.com/ecwid/control" - "github.com/ecwid/control/chrome" - "github.com/ecwid/control/transport" + "github.com/ecwid/control/retry" ) -func main() { +type Handler struct { + h slog.Handler +} - chromium, err := chrome.Launch(context.TODO(), "--disable-popup-blocking") // you can specify more startup parameters for chrome +func (Handler) Enabled(c context.Context, l slog.Level) bool { + return l >= slog.LevelInfo +} + +func (h Handler) Handle(c context.Context, r slog.Record) error { + buf := bytes.Buffer{} + buf.WriteString(r.Time.Format(time.TimeOnly)) + buf.WriteByte(' ') + buf.WriteString(r.Level.String()) + buf.WriteByte(' ') + buf.WriteString(r.Message) + buf.WriteByte(' ') + body := make(map[string]any, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + body[a.Key] = a.Value.Any() + return true + }) + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + err := enc.Encode(body) if err != nil { - panic(err) + return err } - defer chromium.Close() - ctrl := control.New(chromium.GetClient()) - ctrl.Client.Timeout = time.Second * 60 + fmt.Print(buf.String()) + return nil +} - go func() { - s1, err := ctrl.CreatePageTarget("") - if err != nil { - panic(err) - } - cancel := s1.Subscribe("Page.domContentEventFired", func(e transport.Event) error { - v, err1 := s1.Page().GetNavigationEntry() - log.Println(v) - log.Println(err1) - return err1 - }) - defer cancel() - if err = s1.Page().Navigate("https://google.com/", control.LifecycleIdleNetwork, time.Second*60); err != nil { - panic(err) - } - }() +func (h Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h.h.WithAttrs(attrs) +} + +func (h Handler) WithGroup(name string) slog.Handler { + return h.h.WithGroup(name) +} - session, err := ctrl.CreatePageTarget("") +func main() { + sl := slog.New(Handler{h: slog.Default().Handler()}) + session, dfr, err := control.TakeWithContext(context.TODO(), sl, "--no-startup-window") if err != nil { panic(err) } + defer dfr() - var page = session.Page() // main frame - err = page.Navigate("https://surfparadise.ecwid.com/", control.LifecycleIdleNetwork, time.Second*60) + err = session.Frame.Navigate("https://zoid.ecwid.com") if err != nil { panic(err) } - _ = session.Activate() + retrier := retry.DefaultTiming - items, err := page.QuerySelectorAll(".grid-product__title-inner") - if err != nil { - panic(err) - } - for _, i := range items { - title, err := i.GetText() - if err != nil { - panic(err) - } - log.Print(title) - } + var values []string + err = retry.Func(retrier, func() error { + values = []string{} + return session.Frame.QueryAll(".grid-product__title-inner").Then(func(nl control.NodeList) error { + return nl.Foreach(func(n *control.Node) error { + return n.GetText().Then(func(s string) error { + values = append(values, s) + return nil + }) + }) + }) + }) + + log.Println(values, err) + + err = retry.FuncPanic(retrier, func() { + node := session.Frame.MustQuery(`.pager__count-pages`) + node.MustGetBoundingClientRect() + node.MustClick() + }) + log.Println(err) + + p := session.Frame.Evaluate(`new Promise((a,b) => a('ok'))`, false).MustGetValue().(control.RemoteObject) + a, b := session.Frame.AwaitPromise(p) + log.Println(a, b) } diff --git a/frame.go b/frame.go deleted file mode 100644 index d68cd5e..0000000 --- a/frame.go +++ /dev/null @@ -1,229 +0,0 @@ -package control - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/ecwid/control/protocol/common" - "github.com/ecwid/control/protocol/page" - "github.com/ecwid/control/protocol/runtime" - "github.com/ecwid/control/transport" -) - -type LifecycleEventType string - -const ( - LifecycleDOMContentLoaded LifecycleEventType = "DOMContentLoaded" - LifecycleIdleNetwork LifecycleEventType = "networkIdle" - LifecycleFirstContentfulPaint LifecycleEventType = "firstContentfulPaint" - LifecycleFirstMeaningfulPaint LifecycleEventType = "firstMeaningfulPaint" - LifecycleFirstMeaningfulPaintCandidate LifecycleEventType = "firstMeaningfulPaintCandidate" - LifecycleFirstPaint LifecycleEventType = "firstPaint" - LifecycleFirstTextPaint LifecycleEventType = "firstTextPaint" - LifecycleInit LifecycleEventType = "init" - LifecycleLoad LifecycleEventType = "load" - LifecycleNetworkAlmostIdle LifecycleEventType = "networkAlmostIdle" -) - -type Frame struct { - id common.FrameId // readonly - session *Session -} - -func (f Frame) Session() *Session { - return f.session -} - -func (f Frame) ID() common.FrameId { - return f.id -} - -func (f Frame) Call(method string, send, recv interface{}) error { - return f.Session().Call(method, send, recv) -} - -func (f Frame) GetLifecycleEvent(event LifecycleEventType) Future { - var initialized = false - return f.session.Observe("Page.lifecycleEvent", func(input transport.Event, resolve func(interface{}), reject func(error)) { - var v = page.LifecycleEvent{} - if err := json.Unmarshal(input.Params, &v); err != nil { - reject(err) - return - } - if v.FrameId == f.id && v.Name == "init" { - initialized = true - } - if initialized && v.FrameId == f.id && v.Name == string(event) { - resolve(v) - } - }) -} - -func (f Frame) Navigate(url string, waitEvent LifecycleEventType, timeout time.Duration) error { - future := f.GetLifecycleEvent(waitEvent) - defer future.Cancel() - nav, err := page.Navigate(f, page.NavigateArgs{ - Url: url, - FrameId: f.id, - }) - if err != nil { - return err - } - if nav.ErrorText != "" { - return errors.New(nav.ErrorText) - } - if nav.LoaderId == "" { - return ErrAlreadyNavigated - } - _, err = future.Get(timeout) - return err - -} - -// Reload refresh current page -func (f Frame) Reload(ignoreCache bool, scriptToEvaluateOnLoad string, eventType LifecycleEventType, timeout time.Duration) error { - future := f.GetLifecycleEvent(eventType) - defer future.Cancel() - err := page.Reload(f, page.ReloadArgs{ - IgnoreCache: ignoreCache, - ScriptToEvaluateOnLoad: scriptToEvaluateOnLoad, - }) - if err != nil { - return err - } - _, err = future.Get(timeout) - return err -} - -func safeSelector(v string) string { - v = strings.TrimSpace(v) - v = strings.ReplaceAll(v, `"`, `\"`) - return v -} - -func (f Frame) IsExist(selector string) bool { - selector = safeSelector(selector) - val, _ := f.evaluate(`document.querySelector("`+selector+`") != null`, true, false) - if val == nil { - return false - } - b, _ := primitiveRemoteObject(*val).Bool() - return b -} - -func (f Frame) QuerySelector(selector string) (*Element, error) { - selector = safeSelector(selector) - var object, err = f.evaluate(`document.querySelector("`+selector+`")`, true, false) - if err != nil { - return nil, err - } - if object.ObjectId == "" { - return nil, NoSuchElementError{Selector: selector} - } - return f.constructElement(object) -} - -func (f Frame) QuerySelectorAll(selector string) ([]*Element, error) { - selector = safeSelector(selector) - var array, err = f.evaluate(`document.querySelectorAll("`+selector+`")`, true, false) - if err != nil { - return nil, err - } - if array == nil || array.Description == "NodeList(0)" { - return nil, nil - } - list := make([]*Element, 0) - descriptor, err := f.getProperties(array.ObjectId, true, false) - if err != nil { - return nil, err - } - for _, d := range descriptor { - if !d.Enumerable { - continue - } - el, err1 := f.constructElement(d.Value) - if err1 != nil { - return nil, err1 - } - list = append(list, el) - } - return list, nil -} - -type RuntimeError runtime.ExceptionDetails - -func (r RuntimeError) Error() string { - b, _ := json.Marshal(r) - return fmt.Sprintf("%s", b) -} - -func (f Frame) Evaluate(expression string, await, returnByValue bool) (interface{}, error) { - val, err := f.evaluate(expression, await, returnByValue) - if err != nil { - return "", err - } - return val.Value, nil -} - -func (f Frame) evaluate(expression string, await, returnByValue bool) (*runtime.RemoteObject, error) { - var uid, ok = f.session.executions.Load(f.id) - if !ok { - return nil, ErrExecutionContextDestroyed - } - val, err := runtime.Evaluate(f, runtime.EvaluateArgs{ - Expression: expression, - IncludeCommandLineAPI: true, - UniqueContextId: uid.(string), - AwaitPromise: await, - ReturnByValue: returnByValue, - }) - if err != nil { - return nil, err - } - if val.ExceptionDetails != nil { - return nil, RuntimeError(*val.ExceptionDetails) - } - return val.Result, nil -} - -// GetNavigationEntry get current tab info -func (f Frame) GetNavigationEntry() (*page.NavigationEntry, error) { - val, err := page.GetNavigationHistory(f) - if err != nil { - return nil, err - } - if val.CurrentIndex == -1 { - return &page.NavigationEntry{Url: Blank}, nil - } - return val.Entries[val.CurrentIndex], nil -} - -// NavigateHistory -1 = Back, +1 = Forward -func (f Frame) NavigateHistory(delta int) error { - val, err := page.GetNavigationHistory(f) - if err != nil { - return err - } - move := val.CurrentIndex + delta - if move >= 0 && move < len(val.Entries) { - return page.NavigateToHistoryEntry(f, page.NavigateToHistoryEntryArgs{ - EntryId: val.Entries[move].Id, - }) - } - return nil -} - -func (f Frame) RequestDOMIdle(threshold, timeout time.Duration) error { - script := fmt.Sprintf(functionDOMIdle, threshold.Milliseconds(), timeout.Milliseconds()) - _, err := f.Evaluate(script, true, false) - switch v := err.(type) { - case RuntimeError: - if val, _ := v.Exception.Value.(string); val == "timeout" { - return FutureTimeoutError{timeout: timeout} - } - } - return err -} diff --git a/geom.go b/geom.go deleted file mode 100644 index 093823d..0000000 --- a/geom.go +++ /dev/null @@ -1,55 +0,0 @@ -package control - -import ( - "math" - - "github.com/ecwid/control/protocol/dom" -) - -// Point point -type Point struct { - X float64 - Y float64 -} - -// Quad quad -type Quad []Point - -func convertQuads(dq []dom.Quad) []Quad { - var p = make([]Quad, len(dq)) - for n, q := range dq { - p[n] = Quad{ - Point{q[0], q[1]}, - Point{q[2], q[3]}, - Point{q[4], q[5]}, - Point{q[6], q[7]}, - } - } - return p -} - -// Middle calc middle of quad -func (q Quad) Middle() (float64, float64) { - x := 0.0 - y := 0.0 - for i := 0; i < 4; i++ { - x += q[i].X - y += q[i].Y - } - return x / 4, y / 4 -} - -// Area calc area of quad -func (q Quad) Area() float64 { - var area float64 - var x1, x2, y1, y2 float64 - var vertices = len(q) - for i := 0; i < vertices; i++ { - x1 = q[i].X - y1 = q[i].Y - x2 = q[(i+1)%vertices].X - y2 = q[(i+1)%vertices].Y - area += (x1*y2 - x2*y1) / 2 - } - return math.Abs(area) -} diff --git a/go.mod b/go.mod index 937fd0f..75275e5 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/ecwid/control -go 1.15 +go 1.21 require github.com/gorilla/websocket v1.5.0 diff --git a/input.go b/input.go index 09848c6..9b36773 100644 --- a/input.go +++ b/input.go @@ -4,6 +4,8 @@ import ( "sync" "time" + "github.com/ecwid/control/key" + "github.com/ecwid/control/protocol" "github.com/ecwid/control/protocol/input" ) @@ -16,242 +18,180 @@ const ( MouseForward input.MouseButton = "forward" ) -type KeyDefinition struct { - KeyCode int - ShiftKeyCode int - Key string - ShiftKey string - Code string - Text string - ShiftText string - Location int +func NewMouse(caller protocol.Caller) Mouse { + return Mouse{ + caller: caller, + mutex: &sync.Mutex{}, + } +} + +type Mouse struct { + caller protocol.Caller + mutex *sync.Mutex +} + +func (m Mouse) Move(button input.MouseButton, point Point) error { + return input.DispatchMouseEvent(m.caller, input.DispatchMouseEventArgs{ + X: point.X, + Y: point.Y, + Type: "mouseMoved", + Button: button, + }) } -func isKey(r rune) bool { - _, ok := keyDefinitions[r] - return ok +func (m Mouse) Press(button input.MouseButton, point Point) error { + return input.DispatchMouseEvent(m.caller, input.DispatchMouseEventArgs{ + X: point.X, + Y: point.Y, + Type: "mousePressed", + Button: button, + ClickCount: 1, + }) } -var keyDefinitions = map[rune]KeyDefinition{ - '0': {KeyCode: 48, Key: "0", Code: "Digit0"}, - '1': {KeyCode: 49, Key: "1", Code: "Digit1"}, - '2': {KeyCode: 50, Key: "2", Code: "Digit2"}, - '3': {KeyCode: 51, Key: "3", Code: "Digit3"}, - '4': {KeyCode: 52, Key: "4", Code: "Digit4"}, - '5': {KeyCode: 53, Key: "5", Code: "Digit5"}, - '6': {KeyCode: 54, Key: "6", Code: "Digit6"}, - '7': {KeyCode: 55, Key: "7", Code: "Digit7"}, - '8': {KeyCode: 56, Key: "8", Code: "Digit8"}, - '9': {KeyCode: 57, Key: "9", Code: "Digit9"}, - '\r': {KeyCode: 13, Code: "Enter", Key: "Enter", Text: "\r"}, - '\n': {KeyCode: 13, Code: "Enter", Key: "Enter", Text: "\r"}, - ' ': {KeyCode: 32, Key: " ", Code: "Space"}, - 'a': {KeyCode: 65, Key: "a", Code: "KeyA"}, - 'b': {KeyCode: 66, Key: "b", Code: "KeyB"}, - 'c': {KeyCode: 67, Key: "c", Code: "KeyC"}, - 'd': {KeyCode: 68, Key: "d", Code: "KeyD"}, - 'e': {KeyCode: 69, Key: "e", Code: "KeyE"}, - 'f': {KeyCode: 70, Key: "f", Code: "KeyF"}, - 'g': {KeyCode: 71, Key: "g", Code: "KeyG"}, - 'h': {KeyCode: 72, Key: "h", Code: "KeyH"}, - 'i': {KeyCode: 73, Key: "i", Code: "KeyI"}, - 'j': {KeyCode: 74, Key: "j", Code: "KeyJ"}, - 'k': {KeyCode: 75, Key: "k", Code: "KeyK"}, - 'l': {KeyCode: 76, Key: "l", Code: "KeyL"}, - 'm': {KeyCode: 77, Key: "m", Code: "KeyM"}, - 'n': {KeyCode: 78, Key: "n", Code: "KeyN"}, - 'o': {KeyCode: 79, Key: "o", Code: "KeyO"}, - 'p': {KeyCode: 80, Key: "p", Code: "KeyP"}, - 'q': {KeyCode: 81, Key: "q", Code: "KeyQ"}, - 'r': {KeyCode: 82, Key: "r", Code: "KeyR"}, - 's': {KeyCode: 83, Key: "s", Code: "KeyS"}, - 't': {KeyCode: 84, Key: "t", Code: "KeyT"}, - 'u': {KeyCode: 85, Key: "u", Code: "KeyU"}, - 'v': {KeyCode: 86, Key: "v", Code: "KeyV"}, - 'w': {KeyCode: 87, Key: "w", Code: "KeyW"}, - 'x': {KeyCode: 88, Key: "x", Code: "KeyX"}, - 'y': {KeyCode: 89, Key: "y", Code: "KeyY"}, - 'z': {KeyCode: 90, Key: "z", Code: "KeyZ"}, - '*': {KeyCode: 106, Key: "*", Code: "NumpadMultiply", Location: 3}, - '+': {KeyCode: 107, Key: "+", Code: "NumpadAdd", Location: 3}, - '-': {KeyCode: 109, Key: "-", Code: "NumpadSubtract", Location: 3}, - '/': {KeyCode: 111, Key: "/", Code: "NumpadDivide", Location: 3}, - ';': {KeyCode: 186, Key: ";", Code: "Semicolon"}, - '=': {KeyCode: 187, Key: "=", Code: "Equal"}, - ',': {KeyCode: 188, Key: ",", Code: "Comma"}, - '.': {KeyCode: 190, Key: ".", Code: "Period"}, - '`': {KeyCode: 192, Key: "`", Code: "Backquote"}, - '[': {KeyCode: 219, Key: "[", Code: "BracketLeft"}, - '\\': {KeyCode: 220, Key: "\\", Code: "Backslash"}, - ']': {KeyCode: 221, Key: "]", Code: "BracketRight"}, - '\'': {KeyCode: 222, Key: "'", Code: "Quote"}, - ')': {KeyCode: 48, Key: ")", Code: "Digit0"}, - '!': {KeyCode: 49, Key: "!", Code: "Digit1"}, - '@': {KeyCode: 50, Key: "@", Code: "Digit2"}, - '#': {KeyCode: 51, Key: "#", Code: "Digit3"}, - '$': {KeyCode: 52, Key: "$", Code: "Digit4"}, - '%': {KeyCode: 53, Key: "%", Code: "Digit5"}, - '^': {KeyCode: 54, Key: "^", Code: "Digit6"}, - '&': {KeyCode: 55, Key: "&", Code: "Digit7"}, - '(': {KeyCode: 57, Key: "(", Code: "Digit9"}, - 'A': {KeyCode: 65, Key: "A", Code: "KeyA"}, - 'B': {KeyCode: 66, Key: "B", Code: "KeyB"}, - 'C': {KeyCode: 67, Key: "C", Code: "KeyC"}, - 'D': {KeyCode: 68, Key: "D", Code: "KeyD"}, - 'E': {KeyCode: 69, Key: "E", Code: "KeyE"}, - 'F': {KeyCode: 70, Key: "F", Code: "KeyF"}, - 'G': {KeyCode: 71, Key: "G", Code: "KeyG"}, - 'H': {KeyCode: 72, Key: "H", Code: "KeyH"}, - 'I': {KeyCode: 73, Key: "I", Code: "KeyI"}, - 'J': {KeyCode: 74, Key: "J", Code: "KeyJ"}, - 'K': {KeyCode: 75, Key: "K", Code: "KeyK"}, - 'L': {KeyCode: 76, Key: "L", Code: "KeyL"}, - 'M': {KeyCode: 77, Key: "M", Code: "KeyM"}, - 'N': {KeyCode: 78, Key: "N", Code: "KeyN"}, - 'O': {KeyCode: 79, Key: "O", Code: "KeyO"}, - 'P': {KeyCode: 80, Key: "P", Code: "KeyP"}, - 'Q': {KeyCode: 81, Key: "Q", Code: "KeyQ"}, - 'R': {KeyCode: 82, Key: "R", Code: "KeyR"}, - 'S': {KeyCode: 83, Key: "S", Code: "KeyS"}, - 'T': {KeyCode: 84, Key: "T", Code: "KeyT"}, - 'U': {KeyCode: 85, Key: "U", Code: "KeyU"}, - 'V': {KeyCode: 86, Key: "V", Code: "KeyV"}, - 'W': {KeyCode: 87, Key: "W", Code: "KeyW"}, - 'X': {KeyCode: 88, Key: "X", Code: "KeyX"}, - 'Y': {KeyCode: 89, Key: "Y", Code: "KeyY"}, - 'Z': {KeyCode: 90, Key: "Z", Code: "KeyZ"}, - ':': {KeyCode: 186, Key: ":", Code: "Semicolon"}, - '<': {KeyCode: 188, Key: "<", Code: "Comma"}, - '_': {KeyCode: 189, Key: "_", Code: "Minus"}, - '>': {KeyCode: 190, Key: ">", Code: "Period"}, - '?': {KeyCode: 191, Key: "?", Code: "Slash"}, - '~': {KeyCode: 192, Key: "~", Code: "Backquote"}, - '{': {KeyCode: 219, Key: "{", Code: "BracketLeft"}, - '|': {KeyCode: 220, Key: "|", Code: "Backslash"}, - '}': {KeyCode: 221, Key: "}", Code: "BracketRight"}, - '"': {KeyCode: 222, Key: "\"", Code: "Quote"}, +func (m Mouse) Release(button input.MouseButton, point Point) error { + return input.DispatchMouseEvent(m.caller, input.DispatchMouseEventArgs{ + X: point.X, + Y: point.Y, + Type: "mouseReleased", + Button: button, + ClickCount: 1, + }) } -type Input struct { - mx *sync.Mutex - s *Session +func (m Mouse) Down(button input.MouseButton, point Point) (err error) { + m.mutex.Lock() + defer m.mutex.Unlock() + if err = m.Move(MouseNone, point); err != nil { + return err + } + if err = m.Press(button, point); err != nil { + return err + } + return } -func (i Input) Click(button input.MouseButton, x, y float64, delay time.Duration) (err error) { - i.mx.Lock() - defer i.mx.Unlock() - if err = i.MouseMove(MouseNone, x, y); err != nil { +func (m Mouse) Click(button input.MouseButton, point Point, delay time.Duration) (err error) { + m.mutex.Lock() + defer m.mutex.Unlock() + if err = m.Move(MouseNone, point); err != nil { return err } - if err = i.MousePress(button, x, y); err != nil { + if err = m.Press(button, point); err != nil { return err } time.Sleep(delay) - if err = i.MouseRelease(button, x, y); err != nil { + if err = m.Release(button, point); err != nil { return err } return } -func (i Input) MouseMove(button input.MouseButton, x, y float64) error { - return input.DispatchMouseEvent(i.s, input.DispatchMouseEventArgs{ - X: x, - Y: y, - Type: "mouseMoved", - Button: button, - ClickCount: 1, - }) +type Keyboard struct { + caller protocol.Caller } -func (i Input) MousePress(button input.MouseButton, x, y float64) error { - return input.DispatchMouseEvent(i.s, input.DispatchMouseEventArgs{ - X: x, - Y: y, - Type: "mousePressed", - Button: button, - ClickCount: 1, +func NewKeyboard(caller protocol.Caller) Keyboard { + return Keyboard{caller: caller} +} + +func (k Keyboard) Down(key key.Definition) error { + if key.Text == "" && len(key.Key) == 1 { + key.Text = key.Key + } + return input.DispatchKeyEvent(k.caller, input.DispatchKeyEventArgs{ + Type: "keyDown", + WindowsVirtualKeyCode: key.KeyCode, + Code: key.Code, + Key: key.Key, + Text: key.Text, + Location: key.Location, }) } -func (i Input) MouseRelease(button input.MouseButton, x, y float64) error { - return input.DispatchMouseEvent(i.s, input.DispatchMouseEventArgs{ - X: x, - Y: y, - Type: "mouseReleased", - Button: button, - ClickCount: 1, +func (k Keyboard) Up(key key.Definition) error { + return input.DispatchKeyEvent(k.caller, input.DispatchKeyEventArgs{ + Type: "keyUp", + WindowsVirtualKeyCode: key.KeyCode, + Code: key.Code, + Key: key.Key, }) } -func (i Input) TouchStart(x, y float64) error { - return input.DispatchTouchEvent(i.s, input.DispatchTouchEventArgs{ +func (k Keyboard) Insert(text string) error { + return input.InsertText(k.caller, input.InsertTextArgs{Text: text}) +} + +func (k Keyboard) Press(key key.Definition, delay time.Duration) (err error) { + if err = k.Down(key); err != nil { + return err + } + if delay > 0 { + time.Sleep(delay) + } + return k.Up(key) +} + +type Touch struct { + caller protocol.Caller + mutex *sync.Mutex +} + +func NewTouch(caller protocol.Caller) Touch { + return Touch{ + caller: caller, + mutex: &sync.Mutex{}, + } +} + +func (t Touch) Start(x, y, radiusX, radiusY, force float64) error { + return input.DispatchTouchEvent(t.caller, input.DispatchTouchEventArgs{ Type: "touchStart", TouchPoints: []*input.TouchPoint{ { X: x, Y: y, - RadiusX: 0.5, - RadiusY: 0.5, - Force: 1, + RadiusX: radiusX, + RadiusY: radiusY, + Force: force, }, }, }) } -func (i Input) TouchEnd() error { - return input.DispatchTouchEvent(i.s, input.DispatchTouchEventArgs{ - Type: "touchEnd", - TouchPoints: []*input.TouchPoint{}, - }) +func (t Touch) Swipe(from, to Point) (err error) { + t.mutex.Lock() + defer t.mutex.Unlock() + if err = t.Start(from.X, from.Y, 1, 1, 1); err != nil { + return err + } + if err = t.Move(to.X, to.Y, 1, 1, 1); err != nil { + return err + } + if err = t.End(); err != nil { + return err + } + return nil } -func (i Input) TouchMove(x, y float64) error { - return input.DispatchTouchEvent(i.s, input.DispatchTouchEventArgs{ +func (t Touch) Move(x, y, radiusX, radiusY, force float64) error { + return input.DispatchTouchEvent(t.caller, input.DispatchTouchEventArgs{ Type: "touchMove", TouchPoints: []*input.TouchPoint{ { X: x, Y: y, - RadiusX: 0.5, - RadiusY: 0.5, - Force: 1, + RadiusX: radiusX, + RadiusY: radiusY, + Force: force, }, }, }) } -// Keyboard events -const ( - dispatchKeyEventKeyDown = "keyDown" - dispatchKeyEventKeyUp = "keyUp" -) - -func (i Input) InsertText(text string) error { - return input.InsertText(i.s, input.InsertTextArgs{Text: text}) -} - -func (i Input) PressKey(c rune) error { - return i.Press(KeyDefinition{KeyCode: int(c), Text: string(c)}) -} - -func (i Input) Press(key KeyDefinition) error { - if key.Text == "" { - key.Text = key.Key - } - err := input.DispatchKeyEvent(i.s, input.DispatchKeyEventArgs{ - Type: dispatchKeyEventKeyDown, - Key: key.Key, - Code: key.Code, - WindowsVirtualKeyCode: key.KeyCode, - Text: key.Text, - }) - if err != nil { - return err - } - return input.DispatchKeyEvent(i.s, input.DispatchKeyEventArgs{ - Type: dispatchKeyEventKeyUp, - Key: key.Key, - Code: key.Code, - Text: key.Text, +func (t Touch) End() error { + return input.DispatchTouchEvent(t.caller, input.DispatchTouchEventArgs{ + Type: "touchEnd", + TouchPoints: []*input.TouchPoint{}, }) } diff --git a/key/key.go b/key/key.go new file mode 100644 index 0000000..c02943d --- /dev/null +++ b/key/key.go @@ -0,0 +1,294 @@ +package key + +type Definition struct { + KeyCode int + ShiftKeyCode int + Key string + ShiftKey string + Code string + Text string + ShiftText string + Location int +} + +const ( + Control rune = iota + 255 + Abort + Help + Backspace + Tab + Enter + ShiftLeft + ShiftRight + ControlLeft + ControlRight + AltLeft + AltRight + Pause + CapsLock + Escape + Convert + NonConvert + Space + PageUp + PageDown + End + Home + ArrowLeft + ArrowUp + ArrowRight + ArrowDown + Select + Open + PrintScreen + Insert + Delete + MetaLeft + MetaRight + ContextMenu + F1 + F2 + F3 + F4 + F5 + F6 + F7 + F8 + F9 + F10 + F11 + F12 + F13 + F14 + F15 + F16 + F17 + F18 + F19 + F20 + F21 + F22 + F23 + F24 + NumLock + ScrollLock + AudioVolumeMute + AudioVolumeDown + AudioVolumeUp + MediaTrackNext + MediaTrackPrevious + MediaStop + MediaPlayPause + AltGraph + Props + Cancel + Clear + Shift + Alt + Accept + ModeChange + Print + Execute + Meta + Attn + CrSel + ExSel + EraseEof + Play + ZoomOut + SoftLeft + SoftRight + Camera + Call + EndCall + VolumeDown + VolumeUp +) + +var Keys = map[rune]Definition{ + Abort: {KeyCode: 3, Code: "Abort", Key: "Cancel"}, + Help: {KeyCode: 6, Code: "Help", Key: "Help"}, + Backspace: {KeyCode: 8, Code: "Backspace", Key: "Backspace"}, + Tab: {KeyCode: 9, Code: "Tab", Key: "Tab"}, + Enter: {KeyCode: 13, Code: "Enter", Key: "Enter", Text: "\r"}, + ShiftLeft: {KeyCode: 16, Code: "ShiftLeft", Key: "Shift", Location: 1}, + ShiftRight: {KeyCode: 16, Code: "ShiftRight", Key: "Shift", Location: 2}, + ControlLeft: {KeyCode: 17, Code: "ControlLeft", Key: "Control", Location: 1}, + ControlRight: {KeyCode: 17, Code: "ControlRight", Key: "Control", Location: 2}, + AltLeft: {KeyCode: 18, Code: "AltLeft", Key: "Alt", Location: 1}, + AltRight: {KeyCode: 18, Code: "AltRight", Key: "Alt", Location: 2}, + Pause: {KeyCode: 19, Code: "Pause", Key: "Pause"}, + CapsLock: {KeyCode: 20, Code: "CapsLock", Key: "CapsLock"}, + Escape: {KeyCode: 27, Code: "Escape", Key: "Escape"}, + Convert: {KeyCode: 28, Code: "Convert", Key: "Convert"}, + NonConvert: {KeyCode: 29, Code: "NonConvert", Key: "NonConvert"}, + Space: {KeyCode: 32, Code: "Space", Key: " "}, + PageUp: {KeyCode: 33, Code: "PageUp", Key: "PageUp"}, + PageDown: {KeyCode: 34, Code: "PageDown", Key: "PageDown"}, + End: {KeyCode: 35, Code: "End", Key: "End"}, + Home: {KeyCode: 36, Code: "Home", Key: "Home"}, + ArrowLeft: {KeyCode: 37, Code: "ArrowLeft", Key: "ArrowLeft"}, + ArrowUp: {KeyCode: 38, Code: "ArrowUp", Key: "ArrowUp"}, + ArrowRight: {KeyCode: 39, Code: "ArrowRight", Key: "ArrowRight"}, + ArrowDown: {KeyCode: 40, Code: "ArrowDown", Key: "ArrowDown"}, + Select: {KeyCode: 41, Code: "Select", Key: "Select"}, + Open: {KeyCode: 43, Code: "Open", Key: "Execute"}, + PrintScreen: {KeyCode: 44, Code: "PrintScreen", Key: "PrintScreen"}, + Insert: {KeyCode: 45, Code: "Insert", Key: "Insert"}, + Delete: {KeyCode: 46, Code: "Delete", Key: "Delete"}, + MetaLeft: {KeyCode: 91, Code: "MetaLeft", Key: "Meta", Location: 1}, + MetaRight: {KeyCode: 92, Code: "MetaRight", Key: "Meta", Location: 2}, + ContextMenu: {KeyCode: 93, Code: "ContextMenu", Key: "ContextMenu"}, + F1: {KeyCode: 112, Code: "F1", Key: "F1"}, + F2: {KeyCode: 113, Code: "F2", Key: "F2"}, + F3: {KeyCode: 114, Code: "F3", Key: "F3"}, + F4: {KeyCode: 115, Code: "F4", Key: "F4"}, + F5: {KeyCode: 116, Code: "F5", Key: "F5"}, + F6: {KeyCode: 117, Code: "F6", Key: "F6"}, + F7: {KeyCode: 118, Code: "F7", Key: "F7"}, + F8: {KeyCode: 119, Code: "F8", Key: "F8"}, + F9: {KeyCode: 120, Code: "F9", Key: "F9"}, + F10: {KeyCode: 121, Code: "F10", Key: "F10"}, + F11: {KeyCode: 122, Code: "F11", Key: "F11"}, + F12: {KeyCode: 123, Code: "F12", Key: "F12"}, + F13: {KeyCode: 124, Code: "F13", Key: "F13"}, + F14: {KeyCode: 125, Code: "F14", Key: "F14"}, + F15: {KeyCode: 126, Code: "F15", Key: "F15"}, + F16: {KeyCode: 127, Code: "F16", Key: "F16"}, + F17: {KeyCode: 128, Code: "F17", Key: "F17"}, + F18: {KeyCode: 129, Code: "F18", Key: "F18"}, + F19: {KeyCode: 130, Code: "F19", Key: "F19"}, + F20: {KeyCode: 131, Code: "F20", Key: "F20"}, + F21: {KeyCode: 132, Code: "F21", Key: "F21"}, + F22: {KeyCode: 133, Code: "F22", Key: "F22"}, + F23: {KeyCode: 134, Code: "F23", Key: "F23"}, + F24: {KeyCode: 135, Code: "F24", Key: "F24"}, + NumLock: {KeyCode: 144, Code: "NumLock", Key: "NumLock"}, + ScrollLock: {KeyCode: 145, Code: "ScrollLock", Key: "ScrollLock"}, + AudioVolumeMute: {KeyCode: 173, Code: "AudioVolumeMute", Key: "AudioVolumeMute"}, + AudioVolumeDown: {KeyCode: 174, Code: "AudioVolumeDown", Key: "AudioVolumeDown"}, + AudioVolumeUp: {KeyCode: 175, Code: "AudioVolumeUp", Key: "AudioVolumeUp"}, + MediaTrackNext: {KeyCode: 176, Code: "MediaTrackNext", Key: "MediaTrackNext"}, + MediaTrackPrevious: {KeyCode: 177, Code: "MediaTrackPrevious", Key: "MediaTrackPrevious"}, + MediaStop: {KeyCode: 178, Code: "MediaStop", Key: "MediaStop"}, + MediaPlayPause: {KeyCode: 179, Code: "MediaPlayPause", Key: "MediaPlayPause"}, + AltGraph: {KeyCode: 225, Code: "AltGraph", Key: "AltGraph"}, + Props: {KeyCode: 247, Code: "Props", Key: "CrSel"}, + Cancel: {KeyCode: 3, Key: "Cancel", Code: "Abort"}, + Clear: {KeyCode: 12, Key: "Clear", Code: "Numpad5", Location: 3}, + Shift: {KeyCode: 16, Key: "Shift", Code: "ShiftLeft", Location: 1}, + Alt: {KeyCode: 18, Key: "Alt", Code: "AltLeft", Location: 1}, + Accept: {KeyCode: 30, Key: "Accept"}, + ModeChange: {KeyCode: 31, Key: "ModeChange"}, + Print: {KeyCode: 42, Key: "Print"}, + Execute: {KeyCode: 43, Key: "Execute", Code: "Open"}, + Meta: {KeyCode: 91, Key: "Meta", Code: "MetaLeft", Location: 1}, + Attn: {KeyCode: 246, Key: "Attn"}, + CrSel: {KeyCode: 247, Key: "CrSel", Code: "Props"}, + ExSel: {KeyCode: 248, Key: "ExSel"}, + EraseEof: {KeyCode: 249, Key: "EraseEof"}, + Play: {KeyCode: 250, Key: "Play"}, + ZoomOut: {KeyCode: 251, Key: "ZoomOut"}, + Camera: {KeyCode: 44, Key: "Camera", Code: "Camera", Location: 4}, + EndCall: {KeyCode: 95, Key: "EndCall", Code: "EndCall", Location: 4}, + VolumeDown: {KeyCode: 182, Key: "VolumeDown", Code: "VolumeDown", Location: 4}, + VolumeUp: {KeyCode: 183, Key: "VolumeUp", Code: "VolumeUp", Location: 4}, + Control: {KeyCode: 17, Key: "Control", Code: "ControlLeft", Location: 1}, + '0': {KeyCode: 48, Key: "0", Code: "Digit0"}, + '1': {KeyCode: 49, Key: "1", Code: "Digit1"}, + '2': {KeyCode: 50, Key: "2", Code: "Digit2"}, + '3': {KeyCode: 51, Key: "3", Code: "Digit3"}, + '4': {KeyCode: 52, Key: "4", Code: "Digit4"}, + '5': {KeyCode: 53, Key: "5", Code: "Digit5"}, + '6': {KeyCode: 54, Key: "6", Code: "Digit6"}, + '7': {KeyCode: 55, Key: "7", Code: "Digit7"}, + '8': {KeyCode: 56, Key: "8", Code: "Digit8"}, + '9': {KeyCode: 57, Key: "9", Code: "Digit9"}, + '\r': {KeyCode: 13, Code: "Enter", Key: "Enter", Text: "\r"}, + '\n': {KeyCode: 13, Code: "Enter", Key: "Enter", Text: "\r"}, + ' ': {KeyCode: 32, Key: " ", Code: "Space"}, + 'a': {KeyCode: 65, Key: "a", Code: "KeyA"}, + 'b': {KeyCode: 66, Key: "b", Code: "KeyB"}, + 'c': {KeyCode: 67, Key: "c", Code: "KeyC"}, + 'd': {KeyCode: 68, Key: "d", Code: "KeyD"}, + 'e': {KeyCode: 69, Key: "e", Code: "KeyE"}, + 'f': {KeyCode: 70, Key: "f", Code: "KeyF"}, + 'g': {KeyCode: 71, Key: "g", Code: "KeyG"}, + 'h': {KeyCode: 72, Key: "h", Code: "KeyH"}, + 'i': {KeyCode: 73, Key: "i", Code: "KeyI"}, + 'j': {KeyCode: 74, Key: "j", Code: "KeyJ"}, + 'k': {KeyCode: 75, Key: "k", Code: "KeyK"}, + 'l': {KeyCode: 76, Key: "l", Code: "KeyL"}, + 'm': {KeyCode: 77, Key: "m", Code: "KeyM"}, + 'n': {KeyCode: 78, Key: "n", Code: "KeyN"}, + 'o': {KeyCode: 79, Key: "o", Code: "KeyO"}, + 'p': {KeyCode: 80, Key: "p", Code: "KeyP"}, + 'q': {KeyCode: 81, Key: "q", Code: "KeyQ"}, + 'r': {KeyCode: 82, Key: "r", Code: "KeyR"}, + 's': {KeyCode: 83, Key: "s", Code: "KeyS"}, + 't': {KeyCode: 84, Key: "t", Code: "KeyT"}, + 'u': {KeyCode: 85, Key: "u", Code: "KeyU"}, + 'v': {KeyCode: 86, Key: "v", Code: "KeyV"}, + 'w': {KeyCode: 87, Key: "w", Code: "KeyW"}, + 'x': {KeyCode: 88, Key: "x", Code: "KeyX"}, + 'y': {KeyCode: 89, Key: "y", Code: "KeyY"}, + 'z': {KeyCode: 90, Key: "z", Code: "KeyZ"}, + '*': {KeyCode: 106, Key: "*", Code: "NumpadMultiply", Location: 3}, + '+': {KeyCode: 107, Key: "+", Code: "NumpadAdd", Location: 3}, + '-': {KeyCode: 109, Key: "-", Code: "NumpadSubtract", Location: 3}, + '/': {KeyCode: 111, Key: "/", Code: "NumpadDivide", Location: 3}, + ';': {KeyCode: 186, Key: ";", Code: "Semicolon"}, + '=': {KeyCode: 187, Key: "=", Code: "Equal"}, + ',': {KeyCode: 188, Key: ",", Code: "Comma"}, + '.': {KeyCode: 190, Key: ".", Code: "Period"}, + '`': {KeyCode: 192, Key: "`", Code: "Backquote"}, + '[': {KeyCode: 219, Key: "[", Code: "BracketLeft"}, + '\\': {KeyCode: 220, Key: "\\", Code: "Backslash"}, + ']': {KeyCode: 221, Key: "]", Code: "BracketRight"}, + '\'': {KeyCode: 222, Key: "'", Code: "Quote"}, + ')': {KeyCode: 48, Key: ")", Code: "Digit0"}, + '!': {KeyCode: 49, Key: "!", Code: "Digit1"}, + '@': {KeyCode: 50, Key: "@", Code: "Digit2"}, + '#': {KeyCode: 51, Key: "#", Code: "Digit3"}, + '$': {KeyCode: 52, Key: "$", Code: "Digit4"}, + '%': {KeyCode: 53, Key: "%", Code: "Digit5"}, + '^': {KeyCode: 54, Key: "^", Code: "Digit6"}, + '&': {KeyCode: 55, Key: "&", Code: "Digit7"}, + '(': {KeyCode: 57, Key: "(", Code: "Digit9"}, + 'A': {KeyCode: 65, Key: "A", Code: "KeyA"}, + 'B': {KeyCode: 66, Key: "B", Code: "KeyB"}, + 'C': {KeyCode: 67, Key: "C", Code: "KeyC"}, + 'D': {KeyCode: 68, Key: "D", Code: "KeyD"}, + 'E': {KeyCode: 69, Key: "E", Code: "KeyE"}, + 'F': {KeyCode: 70, Key: "F", Code: "KeyF"}, + 'G': {KeyCode: 71, Key: "G", Code: "KeyG"}, + 'H': {KeyCode: 72, Key: "H", Code: "KeyH"}, + 'I': {KeyCode: 73, Key: "I", Code: "KeyI"}, + 'J': {KeyCode: 74, Key: "J", Code: "KeyJ"}, + 'K': {KeyCode: 75, Key: "K", Code: "KeyK"}, + 'L': {KeyCode: 76, Key: "L", Code: "KeyL"}, + 'M': {KeyCode: 77, Key: "M", Code: "KeyM"}, + 'N': {KeyCode: 78, Key: "N", Code: "KeyN"}, + 'O': {KeyCode: 79, Key: "O", Code: "KeyO"}, + 'P': {KeyCode: 80, Key: "P", Code: "KeyP"}, + 'Q': {KeyCode: 81, Key: "Q", Code: "KeyQ"}, + 'R': {KeyCode: 82, Key: "R", Code: "KeyR"}, + 'S': {KeyCode: 83, Key: "S", Code: "KeyS"}, + 'T': {KeyCode: 84, Key: "T", Code: "KeyT"}, + 'U': {KeyCode: 85, Key: "U", Code: "KeyU"}, + 'V': {KeyCode: 86, Key: "V", Code: "KeyV"}, + 'W': {KeyCode: 87, Key: "W", Code: "KeyW"}, + 'X': {KeyCode: 88, Key: "X", Code: "KeyX"}, + 'Y': {KeyCode: 89, Key: "Y", Code: "KeyY"}, + 'Z': {KeyCode: 90, Key: "Z", Code: "KeyZ"}, + ':': {KeyCode: 186, Key: ":", Code: "Semicolon"}, + '<': {KeyCode: 188, Key: "<", Code: "Comma"}, + '_': {KeyCode: 189, Key: "_", Code: "Minus"}, + '>': {KeyCode: 190, Key: ">", Code: "Period"}, + '?': {KeyCode: 191, Key: "?", Code: "Slash"}, + '~': {KeyCode: 192, Key: "~", Code: "Backquote"}, + '{': {KeyCode: 219, Key: "{", Code: "BracketLeft"}, + '|': {KeyCode: 220, Key: "|", Code: "Backslash"}, + '}': {KeyCode: 221, Key: "}", Code: "BracketRight"}, + '"': {KeyCode: 222, Key: "\"", Code: "Quote"}, +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..dc2c708 --- /dev/null +++ b/main.go @@ -0,0 +1,67 @@ +package control + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/ecwid/control/cdp" + "github.com/ecwid/control/chrome" + "github.com/ecwid/control/protocol/target" +) + +func Take(args ...string) (session *Session, cancel func(), err error) { + return TakeWithContext(context.TODO(), nil, args...) +} + +func TakeWithContext(ctx context.Context, logger *slog.Logger, chromeArgs ...string) (session *Session, cancel func(), err error) { + browser, err := chrome.Launch(ctx, chromeArgs...) + if err != nil { + return nil, nil, errors.Join(err, errors.New("chrome launch failed")) + } + tab, err := browser.NewTab(http.DefaultClient, "") + if err != nil { + return nil, nil, errors.Join(err, errors.New("failed to open a new tab")) + } + transport, err := cdp.DefaultDial(ctx, browser.WebSocketUrl, logger) + if err != nil { + return nil, nil, errors.Join(err, errors.New("websocket dial failed")) + } + session, err = NewSession(transport, target.TargetID(tab.ID)) + if err != nil { + return nil, nil, errors.Join(err, errors.New("failed to create a new session")) + } + teardown := func() { + if err := transport.Close(); err != nil { + transport.Log(slog.LevelError, "can't close transport", "err", err.Error()) + } + if err = browser.WaitCloseGracefully(); err != nil { + transport.Log(slog.LevelError, "can't close browser gracefully", "err", err.Error()) + } + } + return session, teardown, nil +} + +func Subscribe[T any](s *Session, method string, filter func(T) bool) cdp.Future[T] { + var ( + channel, cancel = s.Subscribe() + ) + callback := func(resolve func(T), reject func(error)) { + for value := range channel { + if value.Method == method { + var result T + if err := json.Unmarshal(value.Params, &result); err != nil { + reject(err) + return + } + if filter(result) { + resolve(result) + return + } + } + } + } + return cdp.NewPromise(callback, cancel) +} diff --git a/mobile/main.go b/mobile/main.go deleted file mode 100644 index 9bae3c4..0000000 --- a/mobile/main.go +++ /dev/null @@ -1,127 +0,0 @@ -package mobile - -import ( - "github.com/ecwid/control/protocol/emulation" -) - -type ScreenOrientationType = string - -const ( - PortraitPrimary ScreenOrientationType = "portraitPrimary" - PortraitSecondary ScreenOrientationType = "portraitSecondary" - LandscapePrimary ScreenOrientationType = "landscapePrimary" - LandscapeSecondary ScreenOrientationType = "landscapeSecondary" -) - -// Device device description -type Device struct { - Metrics emulation.SetDeviceMetricsOverrideArgs - UserAgent string -} - -var ( - ScreenOrientationLandscape = &emulation.ScreenOrientation{Type: LandscapePrimary, Angle: 90} - ScreenOrientationPortrait = &emulation.ScreenOrientation{Type: PortraitPrimary, Angle: 0} -) - -var ( - iphoneUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1" - ipadUA = "Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1" -) - -// Predefined devices -var ( - GalaxyS5 = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 360, - Height: 640, - DeviceScaleFactor: 3, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36", - } - - Pixel2 = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 411, - Height: 731, - DeviceScaleFactor: 2.625, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36", - } - - Pixel2XL = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 411, - Height: 823, - DeviceScaleFactor: 3.5, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36", - } - - IPad = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 768, - Height: 1024, - DeviceScaleFactor: 2, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: ipadUA, - } - - IPadMini = IPad - - IPadPro = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 1024, - Height: 1366, - DeviceScaleFactor: 2, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: ipadUA, - } - - IPhone6 = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 375, - Height: 667, - DeviceScaleFactor: 2, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: iphoneUA, - } - IPhone7 = IPhone6 - IPhone8 = IPhone6 - - IPhone6Plus = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 414, - Height: 736, - DeviceScaleFactor: 3, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: iphoneUA, - } - IPhone7Plus = IPhone6Plus - IPhone8Plus = IPhone6Plus - - IPhoneX = &Device{ - Metrics: emulation.SetDeviceMetricsOverrideArgs{ - Width: 375, - Height: 812, - DeviceScaleFactor: 3, - Mobile: true, - ScreenOrientation: ScreenOrientationPortrait, - }, - UserAgent: iphoneUA, - } -) diff --git a/network.go b/network.go deleted file mode 100644 index e8df944..0000000 --- a/network.go +++ /dev/null @@ -1,149 +0,0 @@ -package control - -import ( - "encoding/base64" - "encoding/json" - "errors" - - "github.com/ecwid/control/protocol/network" - "github.com/ecwid/control/transport" -) - -func (s *Session) CaptureResponseReceived(condition func(request *network.Request) bool, rejectOnLoadingFailed bool) Future { // Future - var requestID network.RequestId - - return s.Observe("*", func(value transport.Event, resolve func(interface{}), reject func(error)) { - switch value.Method { - - case "Network.requestWillBeSent": - var sent = network.RequestWillBeSent{} - if err := json.Unmarshal(value.Params, &sent); err != nil { - reject(err) - return - } - if condition(sent.Request) { - requestID = sent.RequestId - } - - case "Network.responseReceived": - var recv = network.ResponseReceived{} - if err := json.Unmarshal(value.Params, &recv); err != nil { - reject(err) - return - } - if recv.RequestId == requestID { - resolve(recv) - return - } - - case "Network.loadingFailed": - if rejectOnLoadingFailed { - var fail = network.LoadingFailed{} - if err := json.Unmarshal(value.Params, &fail); err != nil { - reject(err) - return - } - if fail.RequestId == requestID { - reject(errors.New(fail.ErrorText)) - return - } - } - } - }) -} - -type Network struct { - s *Session -} - -// ClearBrowserCookies ... -func (n Network) ClearBrowserCookies() error { - return network.ClearBrowserCookies(n.s) -} - -// SetCookies ... -func (n Network) SetCookies(cookies ...*network.CookieParam) error { - return network.SetCookies(n.s, network.SetCookiesArgs{ - Cookies: cookies, - }) -} - -// GetCookies returns all browser cookies for the current URL -func (n Network) GetCookies(urls ...string) ([]*network.Cookie, error) { - val, err := network.GetCookies(n.s, network.GetCookiesArgs{ - Urls: urls, - }) - if err != nil { - return nil, err - } - return val.Cookies, nil -} - -// SetExtraHTTPHeaders Specifies whether to always send extra HTTP headers with the requests from this page. -func (n Network) SetExtraHTTPHeaders(v map[string]string) error { - val := network.Headers(v) - return network.SetExtraHTTPHeaders(n.s, network.SetExtraHTTPHeadersArgs{ - Headers: &val, - }) -} - -// SetOffline set offline/online mode -// SetOffline(false) - reset all network conditions to default -func (n Network) SetOffline(e bool) error { - return n.EmulateNetworkConditions(e, 0, -1, -1, ConnectionTypeNone) -} - -const ( - ConnectionTypeNone network.ConnectionType = "none" - ConnectionTypeCellular2g network.ConnectionType = "cellular2g" - ConnectionTypeCellular3g network.ConnectionType = "cellular3g" - ConnectionTypeCellular4g network.ConnectionType = "cellular4g" - ConnectionTypeBluetooth network.ConnectionType = "bluetooth" - ConnectionTypeEthernet network.ConnectionType = "ethernet" - ConnectionTypeWIFI network.ConnectionType = "wifi" - ConnectionTypeWIMAX network.ConnectionType = "wimax" - ConnectionTypeOther network.ConnectionType = "other" -) - -func (n Network) EmulateNetworkConditions(offline bool, latency, downloadThroughput, uploadThroughput float64, connectionType network.ConnectionType) error { - return network.EmulateNetworkConditions(n.s, network.EmulateNetworkConditionsArgs{ - Offline: offline, - Latency: latency, - DownloadThroughput: downloadThroughput, - UploadThroughput: uploadThroughput, - ConnectionType: connectionType, - }) -} - -// SetBlockedURLs ... -func (n Network) SetBlockedURLs(urls []string) error { - return network.SetBlockedURLs(n.s, network.SetBlockedURLsArgs{ - Urls: urls, - }) -} - -// GetRequestPostData https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getRequestPostData -func (n Network) GetRequestPostData(requestID network.RequestId) (string, error) { - val, err := network.GetRequestPostData(n.s, network.GetRequestPostDataArgs{ - RequestId: requestID, - }) - if err != nil { - return "", err - } - return val.PostData, nil -} - -// GetResponseBody https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getResponseBody -func (n Network) GetResponseBody(requestID network.RequestId) (string, error) { - val, err := network.GetResponseBody(n.s, network.GetResponseBodyArgs{ - RequestId: requestID, - }) - if err != nil { - return "", err - } - if val.Base64Encoded { - b, err1 := base64.StdEncoding.DecodeString(val.Body) - return string(b), err1 - } - return val.Body, nil -} diff --git a/node.go b/node.go new file mode 100644 index 0000000..50dc6ce --- /dev/null +++ b/node.go @@ -0,0 +1,726 @@ +package control + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "github.com/ecwid/control/key" + "github.com/ecwid/control/protocol/dom" + "github.com/ecwid/control/protocol/overlay" + "github.com/ecwid/control/protocol/runtime" +) + +type ( + NodeNonClickableError string + NodeNonFocusableError string + NodeInvisibleError string + NodeUnstableError string + NoSuchSelectorError string +) + +func (n NodeNonClickableError) Error() string { + return fmt.Sprintf("selector `%s` is not clickable", string(n)) +} + +func (n NodeInvisibleError) Error() string { + return fmt.Sprintf("selector `%s` is not visible", string(n)) +} + +func (n NodeUnstableError) Error() string { + return fmt.Sprintf("selector `%s` is not stable", string(n)) +} + +func (n NodeNonFocusableError) Error() string { + return fmt.Sprintf("selector `%s` is not focusable", string(n)) +} + +func (s NoSuchSelectorError) Error() string { + return fmt.Sprintf("no such selector found: `%s`", string(s)) +} + +func panicIfError(err error) { + if err != nil { + panic(err) + } +} + +type Node struct { + object RemoteObject + requestedSelector string + frame *Frame +} + +type NodeList []*Node + +func (nl NodeList) Foreach(predicate func(*Node) error) error { + for _, node := range nl { + if err := predicate(node); err != nil { + return err + } + } + return nil +} + +type Point struct { + X float64 + Y float64 +} + +type Quad []Point + +func (p Point) Equal(a Point) bool { + return p.X == a.X && p.Y == a.Y +} + +func convertQuads(dq []dom.Quad) []Quad { + var p = make([]Quad, len(dq)) + for n, q := range dq { + p[n] = Quad{ + Point{q[0], q[1]}, + Point{q[2], q[3]}, + Point{q[4], q[5]}, + Point{q[6], q[7]}, + } + } + return p +} + +// Middle calc middle of quad +func (q Quad) Middle() Point { + x := 0.0 + y := 0.0 + for i := 0; i < 4; i++ { + x += q[i].X + y += q[i].Y + } + return Point{X: x / 4, Y: y / 4} +} + +func (q Quad) Area() float64 { + var area float64 + var x1, x2, y1, y2 float64 + var vertices = len(q) + for i := 0; i < vertices; i++ { + x1 = q[i].X + y1 = q[i].Y + x2 = q[(i+1)%vertices].X + y2 = q[(i+1)%vertices].Y + area += (x1*y2 - x2*y1) / 2 + } + return math.Abs(area) +} + +func (e Node) Highlight() error { + return overlay.HighlightNode(e.frame, overlay.HighlightNodeArgs{ + HighlightConfig: &overlay.HighlightConfig{ + GridHighlightConfig: &overlay.GridHighlightConfig{ + RowGapColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.3}, + RowHatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + ColumnGapColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.3}, + ColumnHatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + RowLineColor: &dom.RGBA{R: 127, G: 32, B: 210}, + ColumnLineColor: &dom.RGBA{R: 127, G: 32, B: 210}, + RowLineDash: true, + ColumnLineDash: true, + }, + FlexContainerHighlightConfig: &overlay.FlexContainerHighlightConfig{ + ContainerBorder: &overlay.LineStyle{ + Color: &dom.RGBA{R: 127, G: 32, B: 210}, + Pattern: "dashed", + }, + ItemSeparator: &overlay.LineStyle{ + Color: &dom.RGBA{R: 127, G: 32, B: 210}, + Pattern: "dashed", + }, + LineSeparator: &overlay.LineStyle{ + Color: &dom.RGBA{R: 127, G: 32, B: 210}, + Pattern: "dashed", + }, + MainDistributedSpace: &overlay.BoxStyle{ + HatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + FillColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.3}, + }, + CrossDistributedSpace: &overlay.BoxStyle{ + HatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + FillColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.3}, + }, + RowGapSpace: &overlay.BoxStyle{ + HatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + FillColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.3}, + }, + ColumnGapSpace: &overlay.BoxStyle{ + HatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + FillColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.3}, + }, + }, + FlexItemHighlightConfig: &overlay.FlexItemHighlightConfig{ + BaseSizeBox: &overlay.BoxStyle{ + HatchColor: &dom.RGBA{R: 127, G: 32, B: 210, A: 0.8}, + }, + BaseSizeBorder: &overlay.LineStyle{ + Color: &dom.RGBA{R: 127, G: 32, B: 210}, + Pattern: "dotted", + }, + FlexibilityArrow: &overlay.LineStyle{ + Color: &dom.RGBA{R: 127, G: 32, B: 210}, + }, + }, + ContrastAlgorithm: overlay.ContrastAlgorithm("aa"), + ContentColor: &dom.RGBA{R: 111, G: 168, B: 220, A: 0.66}, + PaddingColor: &dom.RGBA{R: 147, G: 196, B: 125, A: 0.55}, + BorderColor: &dom.RGBA{R: 255, G: 229, B: 153, A: 0.66}, + MarginColor: &dom.RGBA{R: 246, G: 178, B: 107, A: 0.66}, + EventTargetColor: &dom.RGBA{R: 255, G: 196, B: 196, A: 0.66}, + ShapeColor: &dom.RGBA{R: 96, G: 82, B: 177, A: 0.8}, + ShapeMarginColor: &dom.RGBA{R: 96, G: 82, B: 127, A: 0.6}, + }, + ObjectId: e.GetRemoteObjectID(), + }) +} + +func (e Node) GetRemoteObjectID() runtime.RemoteObjectId { + return e.object.GetRemoteObjectID() +} + +func (e Node) OwnerFrame() *Frame { + return e.frame +} + +func (e Node) Call(method string, send, recv any) error { + return e.frame.Call(method, send, recv) +} + +func (e Node) IsConnected() bool { + value, err := e.eval(`function(){return this.isConnected}`) + if err != nil { + return false + } + return value.(bool) +} + +func (e Node) MustReleaseObject() { + panicIfError(e.ReleaseObject()) +} + +func (e Node) ReleaseObject() error { + err := runtime.ReleaseObject(e, runtime.ReleaseObjectArgs{ObjectId: e.GetRemoteObjectID()}) + if err != nil && err.Error() == `Cannot find context with specified id` { + return nil + } + return err +} + +func (e Node) eval(function string, args ...any) (any, error) { + return e.frame.CallFunctionOn(e, function, true, args...) +} + +func (e Node) asyncEval(function string, args ...any) (RemoteObject, error) { + value, err := e.frame.CallFunctionOn(e, function, false, args...) + if err != nil { + return nil, err + } + if v, ok := value.(RemoteObject); ok { + return v, nil + } + return nil, fmt.Errorf("interface conversion failed, `%+v` not JsObject", value) +} + +func (e Node) dispatchEvents(events ...any) error { + _, err := e.eval(`function(l){for(const e of l)this.dispatchEvent(new Event(e,{'bubbles':!0}))}`, events) + return err +} + +func (e Node) Log(msg string, args ...any) { + args = append(args, "self", e.requestedSelector) + e.frame.Log(msg, args...) +} + +func (e Node) HasClass(class string) Optional[bool] { + return optional[bool](e.eval(`function(c){return this.classList.contains(c)}`)) +} + +func (e Node) MustHasClass(class string) bool { + return e.HasClass(class).MustGetValue() +} + +func (e Node) CallFunctionOn(function string, args ...any) Optional[any] { + return optional[any](e.eval(function, args...)) +} + +func (e Node) MustCallFunctionOn(function string, args ...any) any { + return e.CallFunctionOn(function, args...).MustGetValue() +} + +func (e Node) AsyncCallFunctionOn(function string, args ...any) Optional[RemoteObject] { + return optional[RemoteObject](e.asyncEval(function, args...)) +} + +func (e Node) MustAsyncCallFunctionOn(function string, args ...any) RemoteObject { + return e.AsyncCallFunctionOn(function, args...).MustGetValue() +} + +func (e Node) Query(cssSelector string) Optional[*Node] { + return optional[*Node](e.query(cssSelector)) +} + +func (e Node) MustQuery(cssSelector string) *Node { + return e.Query(cssSelector).MustGetValue() +} + +func (e Node) query(cssSelector string) (*Node, error) { + value, err := e.eval(`function(s){return this.querySelector(s)}`, cssSelector) + if err != nil { + return nil, err + } + if value == nil { + return nil, NoSuchSelectorError(cssSelector) + } + node := value.(*Node) + if e.frame.session.highlightEnabled { + _ = node.Highlight() + } + node.requestedSelector = cssSelector + return node, nil +} + +func (e Node) QueryAll(cssSelector string) Optional[NodeList] { + value, err := e.eval(`function(s){return this.querySelectorAll(s)}`, cssSelector) + if err == nil && value == nil { + err = NoSuchSelectorError(cssSelector) + } + return optional[NodeList](value, err) +} + +func (e Node) MustQueryAll(cssSelector string) NodeList { + return e.QueryAll(cssSelector).MustGetValue() +} + +func (e Node) ContentFrame() Optional[*Frame] { + return optional[*Frame](e.contentFrame()) +} + +func (e Node) MustContentFrame() *Frame { + return e.ContentFrame().MustGetValue() +} + +func (e *Node) contentFrame() (*Frame, error) { + value, err := e.frame.describeNode(e) + if err != nil { + return nil, err + } + return &Frame{ + id: value.FrameId, + session: e.frame.session, + parent: e.frame, + node: e, + }, nil +} + +func (e Node) scrollIntoView() error { + return dom.ScrollIntoViewIfNeeded(e, dom.ScrollIntoViewIfNeededArgs{ObjectId: e.GetRemoteObjectID()}) +} + +func (e Node) ScrollIntoView() error { + return e.scrollIntoView() +} + +func (e Node) MustScrollIntoView() { + panicIfError(e.ScrollIntoView()) +} + +func (e Node) GetText() Optional[string] { + return optional[string](e.eval(`function(){return ('INPUT'===this.nodeName||'TEXTAREA'===this.nodeName)?this.value:this.innerText}`)) +} + +func (e Node) MustGetText() string { + return e.GetText().MustGetValue() +} + +func (e Node) Focus() error { + err := dom.Focus(e, dom.FocusArgs{ObjectId: e.GetRemoteObjectID()}) + if err != nil && err.Error() == `Element is not focusable` { + err = NodeNonFocusableError(e.requestedSelector) + } + return err +} + +func (e Node) MustFocus() { + panicIfError(e.Focus()) +} + +func (e Node) Blur() error { + _, err := e.eval(`function(){this.blur()}`) + return err +} + +func (e Node) MustBlur() { + panicIfError(e.Blur()) +} + +func (e Node) clearInput() error { + _, err := e.eval(`function(){('INPUT'===this.nodeName||'TEXTAREA'===this.nodeName)?this.select():this.innerText=''}`) + if err != nil { + return err + } + return e.frame.session.kb.Press(key.Keys[key.Backspace], time.Millisecond*85) +} + +func (e Node) InsertText(value string) error { + return e.setText(value, false) +} + +func (e Node) MustInsertText(value string) { + panicIfError(e.InsertText(value)) +} + +func (e Node) SetText(value string) error { + return e.setText(value, true) +} + +func (e Node) MustSetText(value string) { + panicIfError(e.SetText(value)) +} + +func (e Node) setText(value string, clearBefore bool) (err error) { + if err = e.Focus(); err != nil { + return err + } + if clearBefore { + if err = e.clearInput(); err != nil { + return err + } + } + if err = e.frame.session.kb.Insert(value); err != nil { + return err + } + return nil +} + +func (e Node) MustCheckVisibility() bool { + return e.CheckVisibility().MustGetValue() +} + +func (e Node) CheckVisibility() Optional[bool] { + value, err := e.eval(`function(){return this.checkVisibility({opacityProperty: false, visibilityProperty: true})}`) + return optional[bool](value, err) +} + +func (e Node) Upload(files ...string) error { + return dom.SetFileInputFiles(e, dom.SetFileInputFilesArgs{ + ObjectId: e.GetRemoteObjectID(), + Files: files, + }) +} + +func (e Node) MustUpload(files ...string) { + panicIfError(e.Upload(files...)) +} + +func (e Node) Click() (err error) { + if err = e.scrollIntoView(); err != nil { + return err + } + point, err := e.clickablePoint() + if err != nil { + return err + } + + future := e.frame.session.funcCalled(hitCheckFunc) + defer future.Cancel() + _, err = e.eval(`function(func) { + let a = window[func], + d = (b) => { + for (let d = b; d; d = d.parentNode) { + if (d === this) { + return !0 + } + } + return !1 + }, + f = (b) => { + if (b.isTrusted && d(b.target)) { + a('') + } else { + b.stopImmediatePropagation() + a('target overlapped') + } + } + this.ownerDocument.addEventListener("click", f, { capture: true, once: true }) + window.addEventListener("beforeunload", () => a('document unloaded before click')) + }`, hitCheckFunc) + if err != nil { + return err + } + if err = e.frame.session.Click(point); err != nil { + return err + } + ctx, cancel := context.WithTimeout(e.frame.session.context, e.frame.session.timeout) + defer cancel() + call, err := future.Get(ctx) + if err != nil { + return err + } + if call.Payload != "" { + return errors.New(call.Payload) + } + return nil +} + +func (e Node) MustClick() { + panicIfError(e.Click()) +} + +func (e Node) Down() (err error) { + if err = e.scrollIntoView(); err != nil { + return err + } + point, err := e.clickablePoint() + if err != nil { + return err + } + future := e.frame.session.funcCalled(hitCheckFunc) + defer future.Cancel() + + _, err = e.eval(`function(func) { + let a = window[func], + d = (b) => { + for (let d = b; d; d = d.parentNode) { + if (d === this) { + return !0 + } + } + return !1 + }, + f = (b) => { + if (b.isTrusted && d(b.target)) { + a('') + } else { + b.stopImmediatePropagation() + a('target overlapped') + } + } + this.ownerDocument.addEventListener("mousedown", f, { capture: true, once: true }) + }`, hitCheckFunc) + + if err = e.frame.session.MouseDown(point); err != nil { + return err + } + ctx, cancel := context.WithTimeout(e.frame.session.context, e.frame.session.timeout) + defer cancel() + call, err := future.Get(ctx) + if err != nil { + return err + } + if call.Payload != "" { + return errors.New(call.Payload) + } + return nil +} + +func (e Node) MustDown() { + panicIfError(e.Down()) +} + +func (e Node) GetClickablePoint() Optional[Point] { + return optional[Point](e.clickablePoint()) +} + +func (e Node) MustGetClickablePoint() Point { + return e.GetClickablePoint().MustGetValue() +} + +func (e Node) clickablePoint() (middle Point, err error) { + value, err := e.CheckVisibility().Unwrap() + if err != nil { + return middle, err + } + if !value { + return middle, NodeInvisibleError(e.requestedSelector) + } + var r0, r1 Quad + r0, err = e.getContentQuad() + if err != nil { + return middle, err + } + _, err = e.frame.evaluate(`new Promise(r => setTimeout(r,100))`, true) + if err != nil { + return middle, err + } + r1, err = e.getContentQuad() + if err != nil { + return middle, err + } + middle = r0.Middle() + if middle.Equal(r1.Middle()) { + return middle, nil + } + return middle, NodeUnstableError(e.requestedSelector) +} + +func (e Node) GetBoundingClientRect() Optional[dom.Rect] { + return optional[dom.Rect](e.getBoundingClientRect()) +} + +func (e Node) MustGetBoundingClientRect() dom.Rect { + return e.GetBoundingClientRect().MustGetValue() +} + +func (e Node) getBoundingClientRect() (dom.Rect, error) { + value, err := e.eval(`function() { + const e = this.getBoundingClientRect() + const t = this.ownerDocument.documentElement.getBoundingClientRect() + return [e.left - t.left, e.top - t.top, e.width, e.height] + }`) + if err != nil { + return dom.Rect{}, err + } + if arr, ok := value.([]any); ok { + return dom.Rect{ + X: arr[0].(float64), + Y: arr[1].(float64), + Width: arr[2].(float64), + Height: arr[3].(float64), + }, nil + } + return dom.Rect{}, errors.New("getBoundingClientRect: eval result is not array") +} + +func (e Node) getContentQuad() (Quad, error) { + val, err := dom.GetContentQuads(e, dom.GetContentQuadsArgs{ + ObjectId: e.GetRemoteObjectID(), + }) + if err != nil { + return nil, err + } + quads := convertQuads(val.Quads) + if len(quads) == 0 { + return nil, errors.New("node has no visible bounds") + } + for _, quad := range quads { + if quad.Area() > 1 { + return quad, nil + } + } + return nil, errors.New("node bounds have no size") +} + +func (e Node) Hover() error { + if err := e.scrollIntoView(); err != nil { + return err + } + p, err := e.clickablePoint() + if err != nil { + return err + } + return e.frame.session.Hover(p) +} + +func (e Node) MustHover() { + panicIfError(e.Hover()) +} + +func (e Node) GetComputedStyle(style string, pseudo string) Optional[string] { + var pseudoVar any = nil + if pseudo != "" { + pseudoVar = pseudo + } + return optional[string](e.eval(`function(p,s){return getComputedStyle(this, p)[s]}`, pseudoVar, style)) +} + +func (e Node) MustGetComputedStyle(style string, pseudo string) string { + return e.GetComputedStyle(style, pseudo).MustGetValue() +} + +func (e Node) SetAttribute(attr, value string) error { + _, err := e.eval(`function(a,v){this.setAttribute(a,v)}`, attr, value) + return err +} + +func (e Node) MustSetAttribute(attr, value string) { + panicIfError(e.SetAttribute(attr, value)) +} + +func (e Node) GetAttribute(attr string) Optional[string] { + return optional[string](e.eval(`function(a){return this.getAttribute(a)}`, attr)) +} + +func (e Node) MustGetAttribute(attr string) string { + return e.GetAttribute(attr).MustGetValue() +} + +func (e Node) GetRectangle() Optional[dom.Rect] { + return optional[dom.Rect](e.getViewportRectangle()) +} + +func (e Node) MustGetRectangle() dom.Rect { + return e.GetRectangle().MustGetValue() +} + +func (e Node) getViewportRectangle() (dom.Rect, error) { + q, err := e.getContentQuad() + if err != nil { + return dom.Rect{}, err + } + rect := dom.Rect{ + X: q[0].X, + Y: q[0].Y, + Width: q[1].X - q[0].X, + Height: q[3].Y - q[0].Y, + } + return rect, nil +} + +func (e Node) SelectByValues(values ...string) error { + _, err := e.eval(`function(a){const b=Array.from(this.options);this.value=void 0;for(const c of b)if(c.selected=a.includes(c.value),c.selected&&!this.multiple)break}`, values) + if err != nil { + return err + } + return e.dispatchEvents("click", "input", "change") +} + +func (e Node) MustSelectByValues(values ...string) { + panicIfError(e.SelectByValues(values...)) +} + +func (e Node) GetSelected(textContent bool) Optional[[]string] { + return optional[[]string](e.getSelected(textContent)) +} + +func (e Node) MustGetSelected(textContent bool) []string { + return e.GetSelected(textContent).MustGetValue() +} + +func (e Node) getSelected(textContent bool) ([]string, error) { + values, err := e.eval(`function(text){return Array.from(this.options).filter(a=>a.selected).map(a=>text?a.textContent.trim():a.value)}`, textContent) + if err != nil { + return nil, err + } + stringsValues := make([]string, len(values.([]any))) + for n, val := range values.([]any) { + stringsValues[n] = val.(string) + } + return stringsValues, nil +} + +func (e Node) SetCheckbox(check bool) error { + _, err := e.eval(`function(v){this.checked=v}`, check) + if err != nil { + return err + } + return e.dispatchEvents("click", "input", "change") +} + +func (e Node) MustSetCheckbox(check bool) { + panicIfError(e.SetCheckbox(check)) +} + +func (e Node) IsChecked() Optional[bool] { + return optional[bool](e.eval(`function(){return this.checked}`)) +} + +func (e Node) MustIsChecked() bool { + return e.IsChecked().MustGetValue() +} diff --git a/optional.go b/optional.go new file mode 100644 index 0000000..90ecc7c --- /dev/null +++ b/optional.go @@ -0,0 +1,62 @@ +package control + +import ( + "fmt" + "reflect" +) + +type Optional[T any] struct { + value T + err error +} + +func optional[T any](value any, err error) Optional[T] { + var nilValue T + if err != nil { + return Optional[T]{err: err} + } + if value == nil { + return Optional[T]{} + } + switch typed := value.(type) { + case T: + return Optional[T]{value: typed} + default: + return Optional[T]{err: fmt.Errorf("can't cast %s to %s", reflect.TypeOf(value), reflect.TypeOf(nilValue))} + } +} + +func (op Optional[T]) Unwrap() (T, error) { + return op.value, op.err +} + +func (op Optional[T]) Err() error { + return op.err +} + +func (op Optional[T]) MustGetValue() T { + if op.err != nil { + panic(op.err) + } + return op.value +} + +func (op Optional[T]) Then(f func(T) error) error { + if op.err == nil { + return f(op.value) + } + return op.err +} + +func (op Optional[T]) Catch(f func(error) error) error { + if op.err != nil { + return f(op.err) + } + return nil +} + +func (op Optional[T]) IfPresent(f func(T)) { + if op.err == nil { + f(op.value) + } +} diff --git a/page.go b/page.go index 5a73b31..6241dd8 100644 --- a/page.go +++ b/page.go @@ -1,64 +1,147 @@ package control import ( - "github.com/ecwid/control/protocol/browser" + "errors" + + "github.com/ecwid/control/protocol/common" "github.com/ecwid/control/protocol/page" ) -// CaptureScreenshot get screen of current page -func (s Session) CaptureScreenshot(format string, quality int, clip *page.Viewport, fromSurface, captureBeyondViewport bool) ([]byte, error) { - val, err := page.CaptureScreenshot(s, page.CaptureScreenshotArgs{ - Format: format, - Quality: quality, - Clip: clip, - FromSurface: fromSurface, - CaptureBeyondViewport: captureBeyondViewport, - }) - if err != nil { - return nil, err +type LifecycleEventType string + +const ( + LifecycleDOMContentLoaded LifecycleEventType = "DOMContentLoaded" + LifecycleIdleNetwork LifecycleEventType = "networkIdle" + LifecycleFirstContentfulPaint LifecycleEventType = "firstContentfulPaint" + LifecycleFirstMeaningfulPaint LifecycleEventType = "firstMeaningfulPaint" + LifecycleFirstMeaningfulPaintCandidate LifecycleEventType = "firstMeaningfulPaintCandidate" + LifecycleFirstPaint LifecycleEventType = "firstPaint" + LifecycleFirstTextPaint LifecycleEventType = "firstTextPaint" + LifecycleInit LifecycleEventType = "init" + LifecycleLoad LifecycleEventType = "load" + LifecycleNetworkAlmostIdle LifecycleEventType = "networkAlmostIdle" +) + +var ErrNavigateNoLoader = errors.New("navigation to the same address") + +type Queryable interface { + Query(string) Optional[*Node] + MustQuery(string) *Node + QueryAll(string) Optional[NodeList] + MustQueryAll(string) NodeList + OwnerFrame() *Frame +} + +type Frame struct { + node *Node + session *Session + id common.FrameId + parent *Frame +} + +func (f Frame) GetSession() *Session { + return f.session +} + +func (f Frame) GetID() common.FrameId { + return f.id +} + +func (f Frame) executionContextID() string { + if value, ok := f.session.frames.Load(f.id); ok { + return value.(string) } - return val.Data, nil + return "" +} + +func (f Frame) Call(method string, send, recv any) error { + return f.session.Call(method, send, recv) } -// AddScriptToEvaluateOnNewDocument https://chromedevtools.github.io/devtools-protocol/tot/Page#method-addScriptToEvaluateOnNewDocument -func (s Session) AddScriptToEvaluateOnNewDocument(source string) (page.ScriptIdentifier, error) { - val, err := page.AddScriptToEvaluateOnNewDocument(s, page.AddScriptToEvaluateOnNewDocumentArgs{ - Source: source, +func (f *Frame) OwnerFrame() *Frame { + return f +} + +func (f *Frame) Parent() *Frame { + return f.parent +} + +func (f Frame) Log(msg string, args ...any) { + args = append(args, "frameId", f.id) + f.session.Log(msg, args...) +} + +func (f Frame) Navigate(url string) error { + nav, err := page.Navigate(f, page.NavigateArgs{ + Url: url, + FrameId: f.id, }) if err != nil { - return "", err + return err + } + if nav.ErrorText != "" { + return errors.New(nav.ErrorText) + } + if nav.LoaderId == "" { + return ErrNavigateNoLoader } - return val.Identifier, nil + return nil } -// RemoveScriptToEvaluateOnNewDocument https://chromedevtools.github.io/devtools-protocol/tot/Page#method-removeScriptToEvaluateOnNewDocument -func (s Session) RemoveScriptToEvaluateOnNewDocument(identifier page.ScriptIdentifier) error { - return page.RemoveScriptToEvaluateOnNewDocument(s, page.RemoveScriptToEvaluateOnNewDocumentArgs{ - Identifier: identifier, - }) +func (f Frame) MustNavigate(url string) { + if err := f.Navigate(url); err != nil { + panic(err) + } } -// SetDownloadBehavior https://chromedevtools.github.io/devtools-protocol/tot/Page#method-setDownloadBehavior -func (s Session) SetDownloadBehavior(behavior string, downloadPath string, eventsEnabled bool) error { - return browser.SetDownloadBehavior(s, browser.SetDownloadBehaviorArgs{ - Behavior: behavior, - DownloadPath: downloadPath, - EventsEnabled: eventsEnabled, // default false +func (f Frame) Reload(ignoreCache bool, scriptToEvaluateOnLoad string) error { + return page.Reload(f, page.ReloadArgs{ + IgnoreCache: ignoreCache, + ScriptToEvaluateOnLoad: scriptToEvaluateOnLoad, }) } -// HandleJavaScriptDialog ... -func (s Session) HandleJavaScriptDialog(accept bool, promptText string) error { - return page.HandleJavaScriptDialog(s, page.HandleJavaScriptDialogArgs{ - Accept: accept, - PromptText: promptText, - }) +func (f Frame) MustReload(ignoreCache bool, scriptToEvaluateOnLoad string) { + if err := f.Reload(ignoreCache, scriptToEvaluateOnLoad); err != nil { + panic(err) + } +} + +func (f Frame) Evaluate(expression string, awaitPromise bool) Optional[any] { + return optional[any](f.evaluate(expression, awaitPromise)) +} + +func (f Frame) Document() Optional[*Node] { + opt := optional[*Node](f.evaluate("document", true)) + if opt.err == nil && opt.value == nil { + opt.err = NoSuchSelectorError("document") + } + if opt.value != nil { + opt.value.requestedSelector = "document" + } + return opt +} + +func (f Frame) MustQuery(cssSelector string) *Node { + return f.Query(cssSelector).MustGetValue() +} + +func (f Frame) Query(cssSelector string) Optional[*Node] { + doc, err := f.Document().Unwrap() + if err != nil { + return Optional[*Node]{err: err} + } + return doc.Query(cssSelector) +} + +func (f Frame) MustQueryAll(cssSelector string) NodeList { + return f.QueryAll(cssSelector).MustGetValue() } -func (s Session) GetLayoutMetrics() (*page.GetLayoutMetricsVal, error) { - view, err := page.GetLayoutMetrics(s) +func (f Frame) QueryAll(cssSelector string) Optional[NodeList] { + doc, err := f.Document().Unwrap() if err != nil { - return nil, err + return Optional[NodeList]{err: err} } - return view, nil + return doc.QueryAll(cssSelector) } diff --git a/promise.go b/promise.go deleted file mode 100644 index f84557c..0000000 --- a/promise.go +++ /dev/null @@ -1,87 +0,0 @@ -package control - -import ( - "context" - "sync" - "time" - - "github.com/ecwid/control/transport" -) - -const ( - pending = iota + 0 - resolved - rejected -) - -type Future struct { - promise *promise -} - -type promise struct { - mutex sync.Mutex - context context.Context - unregister func() - cancel func() - state int32 - value interface{} - error error -} - -func (u *promise) resolve(val interface{}) { - u.mutex.Lock() - defer u.mutex.Unlock() - if u.state == pending { - u.state = resolved - u.value = val - u.cancel() - } -} - -func (u *promise) reject(err error) { - u.mutex.Lock() - defer u.mutex.Unlock() - if u.state == pending { - u.state = rejected - u.error = err - u.cancel() - } -} - -func (u *promise) isPending() bool { - u.mutex.Lock() - defer u.mutex.Unlock() - return u.state == pending -} - -func (u Future) Cancel() { - u.promise.cancel() - u.promise.unregister() -} - -func (u Future) Get(timeout time.Duration) (interface{}, error) { - var ctx, cancel = context.WithTimeout(u.promise.context, timeout) - defer cancel() - <-ctx.Done() - if ctx.Err() == context.DeadlineExceeded { - return nil, FutureTimeoutError{timeout: timeout} - } - return u.promise.value, u.promise.error -} - -func (s Session) Observe(method string, condition func(transport.Event, func(interface{}), func(error))) Future { - u := &promise{ - state: pending, - mutex: sync.Mutex{}, - value: nil, - error: nil, - } - u.context, u.cancel = context.WithCancel(s.context) - u.unregister = s.Subscribe(method, func(e transport.Event) error { - if u.isPending() { - condition(e, u.resolve, u.reject) - } - return nil - }) - return Future{u} -} diff --git a/protocol/runtime/types.go b/protocol/runtime/types.go index ab45e55..ec3d6f0 100644 --- a/protocol/runtime/types.go +++ b/protocol/runtime/types.go @@ -10,10 +10,11 @@ type ScriptId string https://w3c.github.io/webdriver-bidi. */ -type WebDriverValue struct { - Type string `json:"type"` - Value interface{} `json:"value,omitempty"` - ObjectId string `json:"objectId,omitempty"` +type DeepSerializedValue struct { + Type string `json:"type"` + Value any `json:"value,omitempty"` + ObjectId string `json:"objectId,omitempty"` + WeakLocalObjectReference int `json:"weakLocalObjectReference,omitempty"` } /* @@ -32,16 +33,16 @@ type UnserializableValue string Mirror object referencing original JavaScript object. */ type RemoteObject struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` - ClassName string `json:"className,omitempty"` - Value interface{} `json:"value,omitempty"` - UnserializableValue UnserializableValue `json:"unserializableValue,omitempty"` - Description string `json:"description,omitempty"` - WebDriverValue *WebDriverValue `json:"webDriverValue,omitempty"` - ObjectId RemoteObjectId `json:"objectId,omitempty"` - Preview *ObjectPreview `json:"preview,omitempty"` - CustomPreview *CustomPreview `json:"customPreview,omitempty"` + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + ClassName string `json:"className,omitempty"` + Value any `json:"value,omitempty"` + UnserializableValue UnserializableValue `json:"unserializableValue,omitempty"` + Description string `json:"description,omitempty"` + DeepSerializedValue *DeepSerializedValue `json:"deepSerializedValue,omitempty"` + ObjectId RemoteObjectId `json:"objectId,omitempty"` + Preview *ObjectPreview `json:"preview,omitempty"` + CustomPreview *CustomPreview `json:"customPreview,omitempty"` } /* @@ -217,19 +218,19 @@ type AwaitPromiseVal struct { } type CallFunctionOnArgs struct { - FunctionDeclaration string `json:"functionDeclaration"` - ObjectId RemoteObjectId `json:"objectId,omitempty"` - Arguments []*CallArgument `json:"arguments,omitempty"` - Silent bool `json:"silent,omitempty"` - ReturnByValue bool `json:"returnByValue,omitempty"` - GeneratePreview bool `json:"generatePreview,omitempty"` - UserGesture bool `json:"userGesture,omitempty"` - AwaitPromise bool `json:"awaitPromise,omitempty"` - ExecutionContextId ExecutionContextId `json:"executionContextId,omitempty"` - ObjectGroup string `json:"objectGroup,omitempty"` - ThrowOnSideEffect bool `json:"throwOnSideEffect,omitempty"` - UniqueContextId string `json:"uniqueContextId,omitempty"` - GenerateWebDriverValue bool `json:"generateWebDriverValue,omitempty"` + FunctionDeclaration string `json:"functionDeclaration"` + ObjectId RemoteObjectId `json:"objectId,omitempty"` + Arguments []*CallArgument `json:"arguments,omitempty"` + Silent bool `json:"silent,omitempty"` + ReturnByValue bool `json:"returnByValue,omitempty"` + GeneratePreview bool `json:"generatePreview,omitempty"` + UserGesture bool `json:"userGesture,omitempty"` + AwaitPromise bool `json:"awaitPromise,omitempty"` + ExecutionContextId ExecutionContextId `json:"executionContextId,omitempty"` + ObjectGroup string `json:"objectGroup,omitempty"` + ThrowOnSideEffect bool `json:"throwOnSideEffect,omitempty"` + UniqueContextId string `json:"uniqueContextId,omitempty"` + SerializationOptions *SerializationOptions `json:"serializationOptions,omitempty"` } type CallFunctionOnVal struct { @@ -249,23 +250,28 @@ type CompileScriptVal struct { ExceptionDetails *ExceptionDetails `json:"exceptionDetails,omitempty"` } +type SerializationOptions struct { + Serialization string `json:"serialization,omitempty"` + MaxDepth int `json:"maxDepth,omitempty"` +} + type EvaluateArgs struct { - Expression string `json:"expression"` - ObjectGroup string `json:"objectGroup,omitempty"` - IncludeCommandLineAPI bool `json:"includeCommandLineAPI,omitempty"` - Silent bool `json:"silent,omitempty"` - ContextId ExecutionContextId `json:"contextId,omitempty"` - ReturnByValue bool `json:"returnByValue,omitempty"` - GeneratePreview bool `json:"generatePreview,omitempty"` - UserGesture bool `json:"userGesture,omitempty"` - AwaitPromise bool `json:"awaitPromise,omitempty"` - ThrowOnSideEffect bool `json:"throwOnSideEffect,omitempty"` - Timeout TimeDelta `json:"timeout,omitempty"` - DisableBreaks bool `json:"disableBreaks,omitempty"` - ReplMode bool `json:"replMode,omitempty"` - AllowUnsafeEvalBlockedByCSP bool `json:"allowUnsafeEvalBlockedByCSP,omitempty"` - UniqueContextId string `json:"uniqueContextId,omitempty"` - GenerateWebDriverValue bool `json:"generateWebDriverValue,omitempty"` + Expression string `json:"expression"` + ObjectGroup string `json:"objectGroup,omitempty"` + IncludeCommandLineAPI bool `json:"includeCommandLineAPI,omitempty"` + Silent bool `json:"silent,omitempty"` + ContextId ExecutionContextId `json:"contextId,omitempty"` + ReturnByValue bool `json:"returnByValue,omitempty"` + GeneratePreview bool `json:"generatePreview,omitempty"` + UserGesture bool `json:"userGesture,omitempty"` + AwaitPromise bool `json:"awaitPromise,omitempty"` + ThrowOnSideEffect bool `json:"throwOnSideEffect,omitempty"` + Timeout TimeDelta `json:"timeout,omitempty"` + DisableBreaks bool `json:"disableBreaks,omitempty"` + ReplMode bool `json:"replMode,omitempty"` + AllowUnsafeEvalBlockedByCSP bool `json:"allowUnsafeEvalBlockedByCSP,omitempty"` + UniqueContextId string `json:"uniqueContextId,omitempty"` + SerializationOptions *SerializationOptions `json:"serializationOptions,omitempty"` } type EvaluateVal struct { diff --git a/retry/main.go b/retry/main.go new file mode 100644 index 0000000..2835277 --- /dev/null +++ b/retry/main.go @@ -0,0 +1,93 @@ +package retry + +import ( + "errors" + "fmt" + "math/rand" + "time" +) + +var ( + DefaultTiming Timing = Static{ + Timeout: 10 * time.Second, + Delay: 500 * time.Millisecond, + } +) + +type Timing interface { + GetTimeout() time.Duration + Before(retry int) +} + +type Static struct { + Timeout time.Duration + Delay time.Duration +} + +func (d Static) GetTimeout() time.Duration { + return d.Timeout +} + +func (d Static) Before(retry int) { + if retry > 0 { + time.Sleep(d.Delay) + } +} + +type Backoff struct { + Timeout time.Duration +} + +func (d Backoff) GetTimeout() time.Duration { + return d.Timeout +} + +// 0 = 0s, 1 = 1s, 2 = 2s, 3 = 4s, 4 = 8s, 5 = 17s, +// 6 = 32s, 7 = 1m5s, 8 = 2m9s, 9 = 4m23s, 10 = 8m58s +func (d Backoff) Before(retry int) { + backoff := float64(uint(1) << (uint(retry) - 1)) + backoff += backoff * (0.1 * rand.Float64()) + time.Sleep(time.Second * time.Duration(backoff)) +} + +func RecoverFunc(function func()) func() error { + return func() (err error) { + defer func() { + if value := recover(); value != nil { + switch errorValue := value.(type) { + case error: + err = errorValue + default: + err = errors.New(fmt.Sprint(value)) + } + } + }() + function() + return + } +} + +func FuncPanic(t Timing, function func()) error { + return BaseRerty(t, RecoverFunc(function)) +} + +func Func(t Timing, function func() error) error { + return BaseRerty(t, function) +} + +func BaseRerty(t Timing, function func() error) error { + var ( + err error + retry = 0 + start = time.Now() + deadline = t.GetTimeout() + ) + for time.Since(start) < deadline { + t.Before(retry) + if err = function(); err == nil { + return nil + } + retry++ + } + return err +} diff --git a/runtime.go b/runtime.go index 664453b..13c139b 100644 --- a/runtime.go +++ b/runtime.go @@ -1,45 +1,255 @@ package control import ( + "encoding/json" + "errors" + "fmt" + + "github.com/ecwid/control/protocol/dom" "github.com/ecwid/control/protocol/runtime" ) -type primitiveRemoteObject runtime.RemoteObject +var ErrExecutionContextDestroyed = errors.New("execution context destroyed") + +type DOMException struct { + ExceptionDetails *runtime.ExceptionDetails +} + +func (e DOMException) Error() string { + if e.ExceptionDetails.Exception.Description != "" { + return e.ExceptionDetails.Exception.Description + } + b, _ := json.Marshal(e.ExceptionDetails) + return string(b) +} + +type nodeType float64 + +const ( + nodeTypeElement nodeType = 1 // An Element node like

or

+ nodeTypeAttribute nodeType = 2 // An Attribute of an Element + nodeTypeText nodeType = 3 // The actual Text inside an Element or Attr + nodeTypeCDataSection nodeType = 4 // A CDATASection + nodeTypeProcessingInstruction nodeType = 7 // A ProcessingInstruction of an XML document + nodeTypeComment nodeType = 8 // A Comment node + nodeTypeDocument nodeType = 9 // A Document node + nodeTypeDocumentType nodeType = 10 // A DocumentType node + nodeTypeFragment nodeType = 11 // A DocumentFragment node +) + +type RemoteObject interface { + GetRemoteObjectID() runtime.RemoteObjectId +} + +type remoteObjectValue runtime.RemoteObjectId + +func (r remoteObjectValue) GetRemoteObjectID() runtime.RemoteObjectId { + return runtime.RemoteObjectId(r) +} + +func getNodeType(deepSerializedValue any) nodeType { + return nodeType(deepSerializedValue.(map[string]any)["nodeType"].(float64)) +} + +func deepUnserialize(self string, value any) any { + switch self { + case "boolean", "string", "number": + return value + case "undefined", "null": + return nil + case "array": + if value == nil { + return value + } + arr := []any{} + for _, e := range value.([]any) { + pair := e.(map[string]any) + arr = append(arr, deepUnserialize(pair["type"].(string), pair["value"])) + } + return arr + case "object": + if value == nil { + return value + } + obj := map[string]any{} + for _, e := range value.([]any) { + var ( + val = e.([]any) + pair = val[1].(map[string]any) + ) + obj[val[0].(string)] = deepUnserialize(pair["type"].(string), pair["value"]) + } + return obj + default: + return value + } +} + +// implemented +// + undefined, null, string, number, boolean, promise, node, array, object, bigint, function, window +// unimplemented +// - regexp, date, symbol, map, set, weakmap, weakset, error, proxy, typedarray, arraybuffer +func (f *Frame) unserialize(value *runtime.RemoteObject) (any, error) { + if value == nil { + return nil, errors.New("can't unserialize nil RemoteObject") + } + if value.DeepSerializedValue == nil { + return value.Value, nil + } + + switch value.DeepSerializedValue.Type { + + case "promise", "function", "weakmap": + return remoteObjectValue(value.ObjectId), nil + + case "node": + switch getNodeType(value.DeepSerializedValue.Value) { + case nodeTypeElement, nodeTypeDocument: + return &Node{ + object: remoteObjectValue(value.ObjectId), + frame: f, + }, nil + default: + return nil, errors.New("unsupported type of node") + } + + case "nodelist": + if value.Description == "NodeList(0)" { + return nil, nil + } + return f.requestNodeList(value.ObjectId) + + default: + return deepUnserialize(value.DeepSerializedValue.Type, value.DeepSerializedValue.Value), nil + } +} + +func (f *Frame) requestNodeList(objectId runtime.RemoteObjectId) (NodeList, error) { + descriptor, err := f.getProperties(remoteObjectValue(objectId), true, false, false, false) + if err != nil { + return nil, err + } + var i = 0 + + var nodeList = make(NodeList, 0) + for _, d := range descriptor.Result { + if d.Enumerable { + i++ + n := &Node{ + object: remoteObjectValue(d.Value.ObjectId), + requestedSelector: d.Value.Description + fmt.Sprintf("(%d)", i), + frame: f, + } + nodeList = append(nodeList, n) + } + } + return nodeList, nil +} -func (p primitiveRemoteObject) String() (string, error) { - const to = "string" - if p.Type == to { - return p.Value.(string), nil +func (f Frame) toCallArgument(args ...any) (arguments []*runtime.CallArgument) { + for _, arg := range args { + callArg := runtime.CallArgument{} + switch a := arg.(type) { + case RemoteObject: + callArg.ObjectId = a.GetRemoteObjectID() + default: + callArg.Value = a + } + arguments = append(arguments, &callArg) } - return "", RemoteObjectCastError{ - object: p, - cast: to, + return +} + +func (f Frame) evaluate(expression string, awaitPromise bool) (any, error) { + var uid = f.executionContextID() + if uid == "" { + return nil, ErrExecutionContextDestroyed + } + value, err := runtime.Evaluate(f, runtime.EvaluateArgs{ + Expression: expression, + IncludeCommandLineAPI: true, + UniqueContextId: uid, + AwaitPromise: awaitPromise, + Timeout: runtime.TimeDelta(f.session.timeout.Milliseconds()), + SerializationOptions: &runtime.SerializationOptions{ + Serialization: "deep", + }, + }) + if err != nil { + return nil, err } + if err = toDOMException(value.ExceptionDetails); err != nil { + return nil, err + } + return f.unserialize(value.Result) } -// Bool RemoteObject as bool value -func (p primitiveRemoteObject) Bool() (bool, error) { - const to = "boolean" - if p.Type == to { - return p.Value.(bool), nil +func (f Frame) AwaitPromise(promise RemoteObject) (any, error) { + value, err := runtime.AwaitPromise(f, runtime.AwaitPromiseArgs{ + PromiseObjectId: promise.GetRemoteObjectID(), + ReturnByValue: true, + GeneratePreview: false, + }) + if err != nil { + return nil, err } - return false, RemoteObjectCastError{ - object: p, - cast: to, + if err = toDOMException(value.ExceptionDetails); err != nil { + return nil, err } + return f.unserialize(value.Result) } -func (f Frame) getProperties(objectID runtime.RemoteObjectId, ownProperties, accessorPropertiesOnly bool) ([]*runtime.PropertyDescriptor, error) { - val, err := runtime.GetProperties(f, runtime.GetPropertiesArgs{ - ObjectId: objectID, - OwnProperties: ownProperties, - AccessorPropertiesOnly: accessorPropertiesOnly, +func (f Frame) CallFunctionOn(self RemoteObject, function string, awaitPromise bool, args ...any) (any, error) { + value, err := runtime.CallFunctionOn(f, runtime.CallFunctionOnArgs{ + FunctionDeclaration: function, + ObjectId: self.GetRemoteObjectID(), + AwaitPromise: awaitPromise, + Arguments: f.toCallArgument(args...), + SerializationOptions: &runtime.SerializationOptions{ + Serialization: "deep", + }, }) if err != nil { return nil, err } - if val.ExceptionDetails != nil { - return nil, RuntimeError(*val.ExceptionDetails) + if err = toDOMException(value.ExceptionDetails); err != nil { + return nil, err + } + return f.unserialize(value.Result) +} + +func (f Frame) getProperties(self RemoteObject, ownProperties, accessorPropertiesOnly, generatePreview, nonIndexedPropertiesOnly bool) (*runtime.GetPropertiesVal, error) { + value, err := runtime.GetProperties(f, runtime.GetPropertiesArgs{ + ObjectId: self.GetRemoteObjectID(), + OwnProperties: ownProperties, + AccessorPropertiesOnly: accessorPropertiesOnly, + GeneratePreview: generatePreview, + NonIndexedPropertiesOnly: nonIndexedPropertiesOnly, + }) + if err != nil { + return nil, err + } + if err = toDOMException(value.ExceptionDetails); err != nil { + return nil, err + } + return value, nil +} + +func (f Frame) describeNode(self RemoteObject) (*dom.Node, error) { + value, err := dom.DescribeNode(f, dom.DescribeNodeArgs{ + ObjectId: self.GetRemoteObjectID(), + }) + if err != nil { + return nil, err + } + return value.Node, nil +} + +func toDOMException(value *runtime.ExceptionDetails) error { + if value != nil && value.Exception != nil { + return DOMException{ + ExceptionDetails: value, + } } - return val.Result, nil + return nil } diff --git a/session.go b/session.go index a945fdd..184d98d 100644 --- a/session.go +++ b/session.go @@ -4,169 +4,391 @@ import ( "context" "encoding/json" "errors" + "log/slog" "sync" + "time" + "github.com/ecwid/control/cdp" + "github.com/ecwid/control/protocol/browser" "github.com/ecwid/control/protocol/common" + "github.com/ecwid/control/protocol/dom" + "github.com/ecwid/control/protocol/network" + "github.com/ecwid/control/protocol/overlay" + "github.com/ecwid/control/protocol/page" "github.com/ecwid/control/protocol/runtime" "github.com/ecwid/control/protocol/target" - "github.com/ecwid/control/transport" ) -const ( - Blank = "about:blank" - bindClick = "_on_click" +// The Longest post body size (in bytes) that would be included in requestWillBeSent notification +var ( + MaxPostDataSize = 20 * 1024 // 20KB ) -type Session struct { - browser BrowserContext - id target.SessionID - tid target.TargetID - executions *sync.Map - eventPool chan transport.Event - publisher *transport.Publisher - exitCode error - context context.Context - cancelCtx func() - detach func() - - Network Network - Input Input - Emulation Emulation -} - -func (s Session) Call(method string, send, recv interface{}) error { - select { - case <-s.context.Done(): - if s.exitCode != nil { - return s.exitCode - } - return s.context.Err() - default: - return s.browser.Client.Call(string(s.id), method, send, recv) - } -} +const Blank = "about:blank" +const hitCheckFunc = `__control_clk_backend_hit` -func (s Session) GetBrowserContext() BrowserContext { - return s.browser +var ( + ErrTargetDestroyed error = errors.New("target destroyed") + ErrTargetDetached error = errors.New("session detached from target") + ErrNetworkIdleReachedTimeout error = errors.New("session network idle reached timeout") +) + +type TargetCrashedError []byte + +func (t TargetCrashedError) Error() string { + return string(t) } -func (s Session) GetTargetID() target.TargetID { - return s.tid +func mustUnmarshal[T any](u cdp.Message) T { + var value T + err := json.Unmarshal(u.Params, &value) + if err != nil { + panic(err) + } + return value } -func (s Session) ID() string { - return string(s.id) +type Session struct { + timeout time.Duration + context context.Context + transport *cdp.Transport + targetID target.TargetID + sessionID string + frames *sync.Map + Frame *Frame + highlightEnabled bool + mouse Mouse + kb Keyboard + touch Touch } -func (s Session) Name() string { - return s.ID() +func (s *Session) Transport() *cdp.Transport { + return s.transport } -func (s Session) Page() *Frame { - return &Frame{id: common.FrameId(s.tid), session: &s} +func (s *Session) Context() context.Context { + return s.context } -func (s Session) Frame(id common.FrameId) (*Frame, error) { - if _, ok := s.executions.Load(id); ok { - return &Frame{id: id, session: &s}, nil +func (s *Session) Log(msg string, args ...any) { + level := slog.LevelInfo + args = append(args, "sessionId", s.sessionID) + for n := range args { + switch a := args[n].(type) { + case error: + if a != nil { + args[n] = a.Error() + level = slog.LevelWarn + } + } } - return nil, NoSuchFrameError{id: id} + s.transport.Log(level, msg, args...) +} + +func (s *Session) GetID() string { + return s.sessionID } -func (s Session) Activate() error { - return s.browser.ActivateTarget(s.tid) +func (s *Session) IsDone() bool { + select { + case <-s.context.Done(): + return true + default: + return false + } } -func (s Session) Update(val transport.Event) error { +func (s *Session) Call(method string, send, recv any) error { select { - case s.eventPool <- val: case <-s.context.Done(): + return context.Cause(s.context) default: - return errors.New("eventPool is full") + } + future := s.transport.Send(&cdp.Request{ + SessionID: string(s.sessionID), + Method: method, + Params: send, + }) + defer future.Cancel() + + ctxTo, cancel := context.WithTimeout(s.context, s.timeout) + defer cancel() + value, err := future.Get(ctxTo) + if err != nil { + return err + } + + if recv != nil { + return json.Unmarshal(value.Result, recv) } return nil } -func (s *Session) handle(e transport.Event) error { - switch e.Method { +func (s *Session) Subscribe() (channel chan cdp.Message, cancel func()) { + return s.transport.Subscribe(s.sessionID) +} - case "Runtime.executionContextCreated": - var v = runtime.ExecutionContextCreated{} - if err := json.Unmarshal(e.Params, &v); err != nil { - return err +func NewSession(transport *cdp.Transport, targetID target.TargetID) (*Session, error) { + var session = &Session{ + transport: transport, + targetID: targetID, + timeout: 60 * time.Second, + frames: &sync.Map{}, + } + session.mouse = NewMouse(session) + session.kb = NewKeyboard(session) + session.touch = NewTouch(session) + session.Frame = &Frame{ + session: session, + id: common.FrameId(session.targetID), + } + var cancel func(error) + session.context, cancel = context.WithCancelCause(transport.Context()) + val, err := target.AttachToTarget(session, target.AttachToTargetArgs{ + TargetId: targetID, + Flatten: true, + }) + if err != nil { + return nil, err + } + session.sessionID = string(val.SessionId) + channel, unsubscribe := session.Subscribe() + go func() { + if err := session.handle(channel); err != nil { + unsubscribe() + cancel(err) } - frameID := common.FrameId((v.Context.AuxData.(map[string]interface{}))["frameId"].(string)) - s.executions.Store(frameID, v.Context.UniqueId) + }() + if err = page.Enable(session); err != nil { + return nil, err + } + if err = page.SetLifecycleEventsEnabled(session, page.SetLifecycleEventsEnabledArgs{Enabled: true}); err != nil { + return nil, err + } + if err = runtime.Enable(session); err != nil { + return nil, err + } + if err = dom.Enable(session, dom.EnableArgs{IncludeWhitespace: "none"}); err != nil { + return nil, err + } + if err = target.SetDiscoverTargets(session, target.SetDiscoverTargetsArgs{Discover: true}); err != nil { + return nil, err + } + if err = network.Enable(session, network.EnableArgs{MaxPostDataSize: MaxPostDataSize}); err != nil { + return nil, err + } + if err = runtime.AddBinding(session, runtime.AddBindingArgs{Name: hitCheckFunc}); err != nil { + return nil, err + } + return session, nil +} - case "Target.targetCrashed": - var v = target.TargetCrashed{} - if err := json.Unmarshal(e.Params, &v); err != nil { - return err - } - return ErrTargetCrashed(v) +func (s *Session) EnableHighlight() error { + if err := overlay.Enable(s); err != nil { + return err + } + s.highlightEnabled = true + return nil +} - case "Target.targetDestroyed": - var v = target.TargetDestroyed{} - if err := json.Unmarshal(e.Params, &v); err != nil { - return err - } - if v.TargetId == s.tid { - return ErrTargetDestroyed - } +func (s *Session) handle(channel chan cdp.Message) error { + for message := range channel { + switch message.Method { + + case "Runtime.executionContextCreated": + executionContextCreated := mustUnmarshal[runtime.ExecutionContextCreated](message) + aux := executionContextCreated.Context.AuxData.(map[string]any) + frameID := aux["frameId"].(string) + s.frames.Store(common.FrameId(frameID), executionContextCreated.Context.UniqueId) + + case "Page.frameDetached": + frameDetached := mustUnmarshal[page.FrameDetached](message) + s.frames.Delete(frameDetached.FrameId) + + case "Target.detachedFromTarget": + detachedFromTarget := mustUnmarshal[target.DetachedFromTarget](message) + if s.sessionID == string(detachedFromTarget.SessionId) { + return ErrTargetDetached + } - case "Target.detachedFromTarget": - var v = target.DetachedFromTarget{} - if err := json.Unmarshal(e.Params, &v); err != nil { - return err + case "Target.targetDestroyed": + targetDestroyed := mustUnmarshal[target.TargetDestroyed](message) + if s.targetID == targetDestroyed.TargetId { + return ErrTargetDestroyed + } + + case "Target.targetCrashed": + targetCrashed := mustUnmarshal[target.TargetCrashed](message) + if s.targetID == targetCrashed.TargetId { + return TargetCrashedError(message.Params) + } } - if v.SessionId == s.id { - return ErrDetachedFromTarget + } + return nil +} + +func (s *Session) funcCalled(fn string) cdp.Future[runtime.BindingCalled] { + var channel, cancel = s.Subscribe() + callback := func(resolve func(runtime.BindingCalled), reject func(error)) { + for value := range channel { + if value.Method == "Runtime.bindingCalled" { + var result runtime.BindingCalled + if err := json.Unmarshal(value.Params, &result); err != nil { + reject(err) + return + } + if result.Name == fn { + resolve(result) + return + } + } } + } + return cdp.NewPromise(callback, cancel) +} +func (s *Session) CaptureScreenshot(format string, quality int, clip *page.Viewport, fromSurface, captureBeyondViewport, optimizeForSpeed bool) ([]byte, error) { + val, err := page.CaptureScreenshot(s, page.CaptureScreenshotArgs{ + Format: format, + Quality: quality, + Clip: clip, + FromSurface: fromSurface, + CaptureBeyondViewport: captureBeyondViewport, + OptimizeForSpeed: optimizeForSpeed, + }) + if err != nil { + return nil, err } - return s.publisher.Notify(e.Method, e) + return val.Data, nil } -func (s *Session) handleEventPool() { - defer func() { - s.detach() // detach from the transport updates - s.cancelCtx() - }() - for e := range s.eventPool { - if err := s.handle(e); err != nil { - s.exitCode = err - return - } +func (s *Session) SetDownloadBehavior(behavior string, downloadPath string, eventsEnabled bool) error { + return browser.SetDownloadBehavior(s, browser.SetDownloadBehaviorArgs{ + Behavior: behavior, + DownloadPath: downloadPath, + EventsEnabled: eventsEnabled, // default false + }) +} + +func (s *Session) MustSetDownloadBehavior(behavior string, downloadPath string, eventsEnabled bool) { + if err := s.SetDownloadBehavior(behavior, downloadPath, eventsEnabled); err != nil { + panic(err) } } -func (s Session) onBindingCalled(name string, function func(string)) (cancel func()) { - return s.Subscribe("Runtime.bindingCalled", func(value transport.Event) error { - bindingCalled := runtime.BindingCalled{} - err := json.Unmarshal(value.Params, &bindingCalled) - if err != nil { - return err - } - if bindingCalled.Name == name { - function(bindingCalled.Payload) - } - return nil +func (s *Session) GetTargetCreated() cdp.Future[target.TargetCreated] { + return Subscribe(s, "Target.targetCreated", func(t target.TargetCreated) bool { + return t.TargetInfo.Type == "page" && t.TargetInfo.OpenerId == s.targetID }) } -func (s Session) Subscribe(event string, v func(e transport.Event) error) (cancel func()) { - return s.publisher.Register(transport.NewSimpleObserver(event, v)) +func (s *Session) AttachToTarget(id target.TargetID) (*Session, error) { + return NewSession(s.transport, id) } -func (s Session) Close() error { - return s.browser.CloseTarget(s.tid) +func (s *Session) CreatePageTargetTab(url string) (*Session, error) { + if url == "" { + url = Blank // headless chrome crash when url is empty + } + r, err := target.CreateTarget(s, target.CreateTargetArgs{Url: url}) + if err != nil { + return nil, err + } + return s.AttachToTarget(r.TargetId) } -func (s Session) IsClosed() bool { - select { - case <-s.context.Done(): - return true - default: - return false +func (s *Session) Activate() error { + return target.ActivateTarget(s, target.ActivateTargetArgs{TargetId: s.targetID}) +} + +func (s *Session) Close() error { + return s.CloseTarget(s.targetID) +} + +func (s *Session) CloseTarget(id target.TargetID) (err error) { + err = target.CloseTarget(s, target.CloseTargetArgs{TargetId: id}) + /* Target.detachedFromTarget event may come before the response of CloseTarget call */ + if err == ErrTargetDetached { + return nil + } + return err +} + +func (s *Session) Click(point Point) error { + return s.mouse.Click(MouseLeft, point, time.Millisecond*85) +} + +func (s *Session) MouseDown(point Point) error { + return s.mouse.Down(MouseLeft, point) +} + +func (s *Session) MustClick(point Point) { + if err := s.Click(point); err != nil { + panic(err) } } + +func (s *Session) Swipe(from, to Point) error { + return s.touch.Swipe(from, to) +} + +func (s *Session) MustSwipe(from, to Point) { + if err := s.Swipe(from, to); err != nil { + panic(err) + } +} + +func (s *Session) Hover(point Point) error { + return s.mouse.Move(MouseNone, point) +} + +func (s *Session) MustHover(point Point) { + if err := s.Hover(point); err != nil { + panic(err) + } +} + +func (s *Session) GetLayout() Optional[page.GetLayoutMetricsVal] { + view, err := page.GetLayoutMetrics(s) + if err != nil { + return Optional[page.GetLayoutMetricsVal]{err: err} + } + return Optional[page.GetLayoutMetricsVal]{value: *view} +} + +func (s *Session) GetNavigationEntry() Optional[page.NavigationEntry] { + val, err := page.GetNavigationHistory(s) + if err != nil { + return Optional[page.NavigationEntry]{err: err} + } + if val.CurrentIndex == -1 { + return Optional[page.NavigationEntry]{value: page.NavigationEntry{Url: Blank}} + } + return Optional[page.NavigationEntry]{value: *val.Entries[val.CurrentIndex]} +} + +func (s *Session) GetCurrentURL() Optional[string] { + return optional[string](s.getCurrentURL()) +} + +func (s *Session) getCurrentURL() (string, error) { + e, err := s.GetNavigationEntry().Unwrap() + if err != nil { + return "", err + } + return e.Url, nil +} + +func (s *Session) NavigateHistory(delta int) error { + val, err := page.GetNavigationHistory(s) + if err != nil { + return err + } + move := val.CurrentIndex + delta + if move >= 0 && move < len(val.Entries) { + return page.NavigateToHistoryEntry(s, page.NavigateToHistoryEntryArgs{ + EntryId: val.Entries[move].Id, + }) + } + return nil +} diff --git a/transport/client.go b/transport/client.go deleted file mode 100644 index 16536be..0000000 --- a/transport/client.go +++ /dev/null @@ -1,156 +0,0 @@ -package transport - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "sync" - "time" - - "github.com/gorilla/websocket" -) - -type Client struct { - *Publisher - conn *websocket.Conn - seq uint64 - queue map[uint64]*Request - queueMu sync.Mutex - sendMu sync.Mutex - context context.Context - Timeout time.Duration - err error - cancel func() -} - -func Dial(ctx context.Context, url string) (*Client, error) { - var dialer = websocket.Dialer{ - ReadBufferSize: 8192, - WriteBufferSize: 8192, - HandshakeTimeout: 45 * time.Second, - Proxy: http.ProxyFromEnvironment, - } - conn, _, err := dialer.Dial(url, nil) - if err != nil { - return nil, err - } - client := &Client{ - Publisher: NewPublisher(), - conn: conn, - seq: 1, - queue: map[uint64]*Request{}, - Timeout: time.Second * 60, - } - client.context, client.cancel = context.WithCancel(ctx) - go client.reading() - return client, nil -} - -func (c *Client) Context() context.Context { - return c.context -} - -func (c *Client) Close() error { - if err := c.Call("", "Browser.close", nil, nil); err != nil { - return err - } - _ = c.conn.Close() - c.finalize(errors.New("connection is shut down")) - return nil -} - -func (c *Client) Call(sessionID, method string, args, value interface{}) error { - var request = &Request{ - SessionID: sessionID, - Method: method, - Args: args, - response: make(chan Response, 1), - } - if err := c.send(request); err != nil { - return err - } - var ctx, cancel = context.WithTimeout(c.context, c.Timeout) - defer cancel() - - var r Response - select { - case r = <-request.response: - if r.Error != nil { - return r.Error - } - case <-ctx.Done(): - return DeadlineExceededError{Request: request, Timeout: c.Timeout} - } - if value != nil { - return json.Unmarshal(r.Result, value) - } - return nil -} - -func (c *Client) send(request *Request) error { - c.sendMu.Lock() - defer c.sendMu.Unlock() - - select { - case <-c.context.Done(): - return c.err - default: - } - - c.queueMu.Lock() - seq := c.seq - c.seq++ - request.ID = seq - c.queue[seq] = request - c.queueMu.Unlock() - - if err := c.conn.WriteJSON(request); err != nil { - c.queueMu.Lock() - delete(c.queue, seq) - c.queueMu.Unlock() - return err - } - return nil -} - -func (c *Client) finalize(err error) { - c.sendMu.Lock() - c.queueMu.Lock() - defer c.queueMu.Unlock() - defer c.sendMu.Unlock() - c.err = err - c.cancel() - for _, request := range c.queue { - _ = request.received(Response{Error: &Error{Message: err.Error()}}) - } -} - -func (c *Client) read() error { - response := Response{} - if err := c.conn.ReadJSON(&response); err != nil { - return err - } - if response.ID == 0 { // event, not message's response - var e = Event{Method: response.Method, Params: response.Params} - if response.SessionID != "" { - return c.Notify(response.SessionID, e) - } - return c.Broadcast(e) - } - c.queueMu.Lock() - request := c.queue[response.ID] - delete(c.queue, response.ID) - c.queueMu.Unlock() - if request == nil { - return errors.New("no request for response") - } - return request.received(response) -} - -func (c *Client) reading() { - var err error - for ; err == nil; err = c.read() { - } - c.finalize(err) -} diff --git a/transport/observer.go b/transport/observer.go deleted file mode 100644 index 90bcca2..0000000 --- a/transport/observer.go +++ /dev/null @@ -1,85 +0,0 @@ -package transport - -import ( - "sync" - "sync/atomic" -) - -type Observer interface { - Name() string // on what event it should notify - Update(val Event) error // notification callback -} - -type Publisher struct { - mx sync.Mutex - guid *uint64 - observers map[uint64]Observer -} - -func NewPublisher() *Publisher { - var uid uint64 = 0 - return &Publisher{ - guid: &uid, - mx: sync.Mutex{}, - observers: map[uint64]Observer{}, - } -} - -// if event is empty then event broadcasting to all observers -func (o *Publisher) Broadcast(val Event) error { - o.mx.Lock() - defer o.mx.Unlock() - for _, e := range o.observers { - if err := e.Update(val); err != nil { - return err - } - } - return nil -} - -// if Observer.Event == '*' then this Observer handles any events -func (o *Publisher) Notify(name string, val Event) error { - o.mx.Lock() - defer o.mx.Unlock() - for _, e := range o.observers { - switch e.Name() { - case "*", name: - if err := e.Update(val); err != nil { - return err - } - } - } - return nil -} - -func (o *Publisher) Register(val Observer) func() { - o.mx.Lock() - defer o.mx.Unlock() - var uid = atomic.AddUint64(o.guid, 1) - o.observers[uid] = val - return func() { - o.mx.Lock() - defer o.mx.Unlock() - delete(o.observers, uid) - } -} - -func NewSimpleObserver(name string, update func(value Event) error) SimpleObserver { - return SimpleObserver{ - name: name, - update: update, - } -} - -type SimpleObserver struct { - name string - update func(val Event) error -} - -func (o SimpleObserver) Name() string { - return o.name -} - -func (o SimpleObserver) Update(val Event) error { - return o.update(val) -} diff --git a/transport/types.go b/transport/types.go deleted file mode 100644 index 8156402..0000000 --- a/transport/types.go +++ /dev/null @@ -1,59 +0,0 @@ -package transport - -import ( - "encoding/json" - "errors" - "fmt" - "time" -) - -type Error struct { - Code int `json:"code"` - Message string `json:"message"` - Data string `json:"data,omitempty"` -} - -func (e Error) Error() string { - return e.Message -} - -type Event struct { - Method string - Params []byte -} - -type Response struct { - ID uint64 `json:"id,omitempty"` - SessionID string `json:"sessionId,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *Error `json:"error,omitempty"` -} - -type Request struct { - ID uint64 `json:"id"` - SessionID string `json:"sessionId,omitempty"` - Method string `json:"method"` // The name of the service and method to call. - Args interface{} `json:"params,omitempty"` // The argument to the function (*struct). - response chan Response -} - -func (request *Request) received(r Response) error { - select { - case request.response <- r: - return nil - default: - return errors.New("the response received twice") - } -} - -type DeadlineExceededError struct { - Request *Request - Timeout time.Duration -} - -func (r DeadlineExceededError) Error() string { - return fmt.Sprintf("the reply to the request [sessionID: %s, Method: %s, Args: %v] not received in %s", - r.Request.SessionID, r.Request.Method, r.Request.Args, r.Timeout) -}