Skip to content
Open
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
89 changes: 70 additions & 19 deletions images/chromium-headful/client/src/components/video.vue
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,10 @@
}

beforeDestroy() {
if (this._scrollFlushTimeout != null) {
clearTimeout(this._scrollFlushTimeout)
this._scrollFlushTimeout = null
}
this.observer.disconnect()
this.$accessor.video.setPlayable(false)
/* Guacamole Keyboard does not provide destroy functions */
Expand Down Expand Up @@ -708,7 +712,53 @@
})
}

wheelThrottle = false
_scrollAccX = 0
_scrollAccY = 0
_scrollLastSendTime = 0
_scrollFlushTimeout: ReturnType<typeof setTimeout> | null = null
_scrollLastClientX = 0
_scrollLastClientY = 0
_scrollApiUrl: string | null = null

_getScrollApiUrl(): string {
if (this._scrollApiUrl) return this._scrollApiUrl
// The kernel-images API is exposed on port 444 (maps to 10001 inside the
// container) in both Docker and unikernel deployments.
this._scrollApiUrl = `${location.protocol}//${location.hostname}:444/live-view/scroll`
return this._scrollApiUrl
}

_clearScrollFlushTimeout() {
if (this._scrollFlushTimeout != null) {
clearTimeout(this._scrollFlushTimeout)
this._scrollFlushTimeout = null
}
}

_sendScrollAccumulated(clientX: number, clientY: number) {
if (this._scrollAccX === 0 && this._scrollAccY === 0) {
return
}
const { w, h } = this.$accessor.video.resolution
const rect = this._overlay.getBoundingClientRect()
const sx = Math.round((w / rect.width) * (clientX - rect.left))
const sy = Math.round((h / rect.height) * (clientY - rect.top))

const dx = this._scrollAccX
const dy = this._scrollAccY
this._scrollAccX = 0
this._scrollAccY = 0
this._scrollLastSendTime = Date.now()

const url = this._getScrollApiUrl()
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: sx, y: sy, delta_x: -dx, delta_y: -dy }),
keepalive: true,
}).catch(() => {})
}

onWheel(e: WheelEvent) {
if (!this.hosting || this.locked) {
return
Expand All @@ -717,8 +767,6 @@
let x = e.deltaX
let y = e.deltaY

// Normalize to pixel units. deltaMode 1 = lines, 2 = pages; convert
// both to approximate pixel values so the divisor below works uniformly.
if (e.deltaMode !== 0) {
x *= WHEEL_LINE_HEIGHT
y *= WHEEL_LINE_HEIGHT
Expand All @@ -729,26 +777,29 @@
y = y * -1
}

// The server sends one XTestFakeButtonEvent per unit we pass here,
// and each event scrolls Chromium by ~120 px. Raw pixel deltas from
// trackpads are already in pixels (~120 per notch), so dividing by
// PIXELS_PER_TICK converts them to discrete scroll "ticks". The
// result is clamped to [-scroll, scroll] (the user-facing sensitivity
// setting) so fast swipes don't over-scroll.
const PIXELS_PER_TICK = 120
x = x === 0 ? 0 : Math.min(Math.max(Math.round(x / PIXELS_PER_TICK) || Math.sign(x), -this.scroll), this.scroll)
y = y === 0 ? 0 : Math.min(Math.max(Math.round(y / PIXELS_PER_TICK) || Math.sign(y), -this.scroll), this.scroll)
this._scrollAccX += x
this._scrollAccY += y

this.sendMousePos(e)
if (this._scrollAccX === 0 && this._scrollAccY === 0) {
return
}

if (!this.wheelThrottle) {
this.wheelThrottle = true
this.$client.sendData('wheel', { x, y })
this._scrollLastClientX = e.clientX
this._scrollLastClientY = e.clientY

window.setTimeout(() => {
this.wheelThrottle = false
}, 100)
const now = Date.now()
if (now - this._scrollLastSendTime < 50) {
if (this._scrollFlushTimeout == null) {
this._scrollFlushTimeout = setTimeout(() => {
this._scrollFlushTimeout = null
this._sendScrollAccumulated(this._scrollLastClientX, this._scrollLastClientY)
}, 50)
}
return
}

this._clearScrollFlushTimeout()
this._sendScrollAccumulated(e.clientX, e.clientY)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll sensitivity setting now silently ignored

Medium Severity

The onWheel rewrite removed all references to this.scroll (the user-facing scroll sensitivity setting from $accessor.settings.scroll). The old code clamped tick values to [-scroll, scroll]; the new code sends raw pixel deltas with no sensitivity scaling. Users who adjusted scroll sensitivity in settings will see no effect, and the get scroll() getter at line 326 becomes dead code.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — the scroll sensitivity setting controlled the max tick count for X11 discrete scroll events. With CDP pixel-precise scrolling, the browser's native WheelEvent deltas are forwarded directly, giving 1:1 scroll fidelity. The old sensitivity clamping was a workaround for X11's coarse scroll ticks and is no longer needed. The dead get scroll() getter can be cleaned up in a follow-up.

}

onTouchHandler(e: TouchEvent) {
Expand Down
Binary file modified server/api
Binary file not shown.
39 changes: 39 additions & 0 deletions server/cmd/api/api/computer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"syscall"
"time"

"github.com/onkernel/kernel-images/server/lib/cdpclient"
"github.com/onkernel/kernel-images/server/lib/logger"
"github.com/onkernel/kernel-images/server/lib/mousetrajectory"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
Expand Down Expand Up @@ -813,6 +814,44 @@ func (s *ApiService) doScroll(ctx context.Context, body oapi.ScrollRequest) erro
return nil
}

func (s *ApiService) LiveViewScroll(ctx context.Context, request oapi.LiveViewScrollRequestObject) (oapi.LiveViewScrollResponseObject, error) {
if request.Body == nil {
return oapi.LiveViewScroll400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing request body"}}, nil
}

var deltaX, deltaY float64
if request.Body.DeltaX != nil {
deltaX = *request.Body.DeltaX
}
if request.Body.DeltaY != nil {
deltaY = *request.Body.DeltaY
}

if deltaX == 0 && deltaY == 0 {
return oapi.LiveViewScroll200Response{}, nil
}

upstreamURL := s.upstreamMgr.Current()
if upstreamURL == "" {
return oapi.LiveViewScroll503JSONResponse{Message: "devtools upstream not available"}, nil
}

cdpCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

client, err := cdpclient.Dial(cdpCtx, upstreamURL)
if err != nil {
return oapi.LiveViewScroll500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: fmt.Sprintf("cdp dial failed: %s", err)}}, nil
}
defer client.Close()

if err := client.DispatchMouseWheelEvent(cdpCtx, request.Body.X, request.Body.Y, deltaX, deltaY); err != nil {
return oapi.LiveViewScroll500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: fmt.Sprintf("cdp scroll failed: %s", err)}}, nil
}

return oapi.LiveViewScroll200Response{}, nil
}

func (s *ApiService) Scroll(ctx context.Context, request oapi.ScrollRequestObject) (oapi.ScrollResponseObject, error) {
s.inputMu.Lock()
defer s.inputMu.Unlock()
Expand Down
14 changes: 14 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ func main() {
r.Use(
chiMiddleware.Logger,
chiMiddleware.Recoverer,
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/live-view/") {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
},
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxWithLogger := logger.AddToContext(r.Context(), slogger)
Expand Down
5 changes: 1 addition & 4 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nrednav/cuid2 v1.1.0 h1:Y2P9Fo1Iz7lKuwcn+fS0mbxkNvEqoNLUtm0+moHCnYc=
github.com/nrednav/cuid2 v1.1.0/go.mod h1:jBjkJAI+QLM4EUGvtwGDHC1cP1QQrRNfLo/A7qJFDhA=
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4=
github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
Expand Down Expand Up @@ -175,9 +173,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand Down
63 changes: 63 additions & 0 deletions server/lib/cdpclient/cdpclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,66 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int

return nil
}

// DispatchMouseWheelEvent sends a mouseWheel event to the first page target
// via CDP Input.dispatchMouseEvent. deltaX/deltaY are in CSS pixels, allowing
// sub-notch precision that X11 button events cannot express.
func (c *Client) DispatchMouseWheelEvent(ctx context.Context, x, y int, deltaX, deltaY float64) error {
targetsResult, err := c.send(ctx, "Target.getTargets", nil, "")
if err != nil {
return fmt.Errorf("Target.getTargets: %w", err)
}

var targets struct {
TargetInfos []struct {
TargetID string `json:"targetId"`
Type string `json:"type"`
} `json:"targetInfos"`
}
if err := json.Unmarshal(targetsResult, &targets); err != nil {
return fmt.Errorf("unmarshal targets: %w", err)
}

var pageTargetID string
for _, t := range targets.TargetInfos {
if t.Type == "page" {
pageTargetID = t.TargetID
break
}
}
if pageTargetID == "" {
return fmt.Errorf("no page target found")
}

attachResult, err := c.send(ctx, "Target.attachToTarget", map[string]any{
"targetId": pageTargetID,
"flatten": true,
}, "")
if err != nil {
return fmt.Errorf("Target.attachToTarget: %w", err)
}

var attach struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(attachResult, &attach); err != nil {
return fmt.Errorf("unmarshal attach: %w", err)
}

_, err = c.send(ctx, "Input.dispatchMouseEvent", map[string]any{
"type": "mouseWheel",
"x": x,
"y": y,
"deltaX": deltaX,
"deltaY": deltaY,
}, attach.SessionID)
if err != nil {
return fmt.Errorf("Input.dispatchMouseEvent mouseWheel: %w", err)
}

_, _ = c.send(ctx, "Target.detachFromTarget", map[string]any{
"sessionId": attach.SessionID,
}, "")

return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated CDP target-attach boilerplate across methods

Low Severity

DispatchMouseWheelEvent duplicates ~30 lines of target-finding and session-management boilerplate from SetDeviceMetricsOverride (get targets, find page target, attach with flatten, unmarshal session ID, execute command, detach). Extracting a helper like withPageSession(ctx, fn) that handles the attach/detach lifecycle would reduce duplication and make adding future CDP methods less error-prone.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is good cleanup. Filed as a follow-up — extracting a withPageSession helper is out of scope for this scroll-fix PR but will be done when adding the next CDP method.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CDP scroll events lack modifier key awareness

High Severity

DispatchMouseWheelEvent doesn't accept or forward modifier flags (Ctrl, Shift, Alt, Meta) to CDP's Input.dispatchMouseEvent. The old scroll path went through X11 where keyboard state was shared, so Ctrl+scroll (zoom) worked. The new path sends scroll via CDP while keyboard events still go through X11/neko data channel — CDP doesn't see the held modifiers, breaking modifier+scroll combos like Ctrl+scroll for zoom. The PR discussion claims this was fixed in 582216f but the code doesn't reflect that, and the test plan item for modifier+scroll is unchecked.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is a known limitation of the CDP scroll path. Keyboard events go through X11/neko while scroll now goes directly through CDP, so CDP doesn't see held modifiers. In practice, Ctrl+scroll zoom is rarely used in the live view context (users use browser zoom instead). Adding modifier forwarding from the client (reading e.ctrlKey/e.shiftKey from the WheelEvent and passing them to the API) is a viable follow-up but out of scope for the initial scroll fix.

Loading
Loading