Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions server/lib/devtoolsproxy/cdpcommand.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package devtoolsproxy

import (
"bytes"
"encoding/json"
"time"
"unicode/utf8"

"github.com/kernel/kernel-images/server/lib/events"
oapi "github.com/kernel/kernel-images/server/lib/oapi"
)

// controlMethods are the CDP methods that drive the browser the way an agent
// does: input gestures, navigation, dialog handling, file selection and
// screenshots. Everything else a client sends over the proxy is either
// configuration (Emulation, Network.enable) or DOM/Runtime bookkeeping a
// library issues on the caller's behalf, so it stays out of the stream.
var controlMethods = map[string]struct{}{
"Input.dispatchMouseEvent": {},
"Input.dispatchKeyEvent": {},
"Input.dispatchTouchEvent": {},
"Input.dispatchDragEvent": {},
"Input.insertText": {},
"Input.synthesizeScrollGesture": {},
"Input.synthesizeTapGesture": {},
"Page.navigate": {},
"Page.navigateToHistoryEntry": {},
"Page.reload": {},
"Page.captureScreenshot": {},
"Page.handleJavaScriptDialog": {},
"DOM.setFileInputFiles": {},
}

// skipEventTypes drops the phases that duplicate a gesture already reported.
// A click sends mousePressed and mouseReleased around a mouseMoved, and a
// keystroke sends rawKeyDown/keyDown then char then keyUp; reporting every
// phase multiplies the stream without saying anything the kept phase doesn't.
// mouseMoved also arrives in bulk from humanized cursor paths.
var skipEventTypes = map[string]struct{}{
"mouseMoved": {},
"keyUp": {},
"char": {},
}

var methodKey = []byte(`"method"`)

type cdpCommand struct {
Method string `json:"method"`
SessionId string `json:"sessionId"`
Params struct {
Type string `json:"type"`
X *float32 `json:"x"`
Y *float32 `json:"y"`
Button string `json:"button"`
Text string `json:"text"`
Key string `json:"key"`
} `json:"params"`
}

// cdpCommandEvent builds the cdp_command event for a client-to-upstream frame,
// or reports false when the frame is not a browser-control command. Params are
// reported as shape only — coordinates, button, event type and the length of
// typed text — never the text or the key itself, which on a login page is the
// password.
func cdpCommandEvent(msg []byte) (events.Event, bool) {
if !mayBeControlCommand(msg) {
return events.Event{}, false
}

var cmd cdpCommand
if err := json.Unmarshal(msg, &cmd); err != nil {
return events.Event{}, false
}
// The parsed top-level method decides, not the scanned one: a client is free
// to nest a "method" key inside params ahead of its own.
if _, ok := controlMethods[cmd.Method]; !ok {
return events.Event{}, false
}
if _, skip := skipEventTypes[cmd.Params.Type]; skip {
return events.Event{}, false
}

data := oapi.BrowserCdpCommandEventData{
Method: cmd.Method,
X: cmd.Params.X,
Y: cmd.Params.Y,
}
if cmd.SessionId != "" {
data.SessionId = &cmd.SessionId
}
if cmd.Params.Type != "" {
data.EventType = &cmd.Params.Type
}
if cmd.Params.Button != "" {
data.Button = &cmd.Params.Button
}
if text := cmd.Params.Text; text != "" {
length := utf8.RuneCountInString(text)
data.TextLength = &length
}
// A key of more than one rune is a named key — Enter, Tab, ArrowDown — which
// is worth reading back and cannot be a character someone typed. A
// single-rune key is typed input, and stays out for the same reason the text
// does.
if key := cmd.Params.Key; utf8.RuneCountInString(key) > 1 {
data.Key = &key
}

payload, err := json.Marshal(data)
if err != nil {
return events.Event{}, false
}
return events.Event{
Ts: time.Now().UnixMicro(),
Type: "cdp_command",
Category: events.Control,
Source: oapi.BrowserEventSource{Kind: oapi.KernelApi},
Data: payload,
}, true
}

// mayBeControlCommand rejects frames that cannot be a browser-control command
// without unmarshalling them, so the hot path — a large Runtime.callFunctionOn
// payload — costs one scan instead of a full parse. It reads the first "method"
// in the frame, which for every real client is the top-level one; a frame that
// nests its own ahead of it is decided by the parse in cdpCommandEvent.
func mayBeControlCommand(msg []byte) bool {
i := bytes.Index(msg, methodKey)
if i < 0 {
return false
}
rest := msg[i+len(methodKey):]
open := bytes.IndexByte(rest, '"')
if open < 0 || !bytes.Contains(rest[:open], []byte(":")) {
return false
}
rest = rest[open+1:]
end := bytes.IndexByte(rest, '"')
if end < 0 {
return false
}
_, ok := controlMethods[string(rest[:end])]
return ok
}

func publishCdpCommand(publish EventPublisher, msg []byte) {
if publish == nil {
return
}
ev, ok := cdpCommandEvent(msg)
if !ok {
return
}
publish(ev)
}
Loading
Loading