Skip to content

PuppetBrowser.kt

src/jvmMain/kotlin/afield/media/browser/PuppetBrowser.kt · 342 lines

package afield.media.browser

import java.util.concurrent.CompletableFuture
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put

The control vocabulary for an offscreen Chromium tab driven from Field: its state, events, and the pointer, key, touch and JavaScript requests a box sends it.

PuppetBrowserControl is the command plane (navigation, input, JavaScript in the main or isolated world, raw CDP); PuppetBrowser adds an ExternalBrowserSurface, so the same tab can be sampled as a texture. HeadlessPuppetBrowser implements only the control plane and FieldUiBrowserTexture implements both. The wire encoding is covered by PuppetBrowserProtocolTest.

data class PuppetBrowserState

Current observable state of an offscreen Chromium tab.

data class PuppetBrowserState(
    val url: String = "about:blank",
    val title: String = "",
    val loading: Boolean = false,
    val canGoBack: Boolean = false,
    val canGoForward: Boolean = false,
    val audible: Boolean = false,
    val focused: Boolean = false,
    val unresponsive: Boolean = false,
    val crashed: Boolean = false,
    val cursor: String = "default",
    val navigationGeneration: Long = 0,
)

data class PuppetBrowserEvent

One browser or protocol event; its payload carries Electron's or CDP's own fields verbatim, unrenamed.

data class PuppetBrowserEvent(
    val sequence: Long,
    val name: String,
    val payload: JsonObject,
)

enum class BrowserJavascriptWorld

enum class BrowserJavascriptWorld {
    MAIN,
    ISOLATED,
}

enum class BrowserJavascriptReturnMode

enum class BrowserJavascriptReturnMode {
    VALUE,
    HANDLE,
}

data class BrowserJavascriptResult

A JavaScript result, either copied by value or retained in Chromium as objectId.

data class BrowserJavascriptResult(
    val type: String,
    val value: JsonElement = JsonNull,
    val description: String? = null,
    val unserializableValue: String? = null,
    val objectId: String? = null,
)

class PuppetBrowserCommandException

class PuppetBrowserCommandException(
    message: String,
    val command: String? = null,
    val details: JsonObject? = null,
) : RuntimeException(message)

enum class BrowserModifier(val cdpBit: Int)

enum class BrowserModifier(val cdpBit: Int) {
    ALT(1),
    CONTROL(2),
    META(4),
    SHIFT(8),
}

enum class BrowserMouseButton(val cdpName: String, val cdpBit: Int)

enum class BrowserMouseButton(val cdpName: String, val cdpBit: Int) {
    NONE("none", 0),
    LEFT("left", 1),
    RIGHT("right", 2),
    MIDDLE("middle", 4),
    BACK("back", 8),
    FORWARD("forward", 16),
}

enum class BrowserPointerType(val cdpName: String)

enum class BrowserPointerType(val cdpName: String) {
    MOUSE("mouse"),
    PEN("pen"),
}

enum class BrowserPointerEventType

enum class BrowserPointerEventType {
    MOVE,
    DOWN,
    UP,
    WHEEL,
}

data class BrowserPointerEvent

Browser pointer coordinates are CSS pixels, origin at the viewport's top-left.

data class BrowserPointerEvent(
    val type: BrowserPointerEventType,
    val x: Double,
    val y: Double,
    val button: BrowserMouseButton = BrowserMouseButton.NONE,
    val buttons: Int = 0,
    val modifiers: Set<BrowserModifier> = emptySet(),
    val clickCount: Int = 0,
    val deltaX: Double = 0.0,
    val deltaY: Double = 0.0,
    val pointerType: BrowserPointerType = BrowserPointerType.MOUSE,
    val pressure: Double = 0.0,
    val tiltX: Double = 0.0,
    val tiltY: Double = 0.0,
    val twist: Int = 0,
)

enum class BrowserKeyEventType(val cdpName: String)

enum class BrowserKeyEventType(val cdpName: String) {
    RAW_KEY_DOWN("rawKeyDown"),
    KEY_DOWN("keyDown"),
    KEY_UP("keyUp"),
    CHAR("char"),
}

data class BrowserKeyEvent

data class BrowserKeyEvent(
    val type: BrowserKeyEventType,
    val key: String,
    val code: String = key,
    val modifiers: Set<BrowserModifier> = emptySet(),
    val windowsVirtualKeyCode: Int = 0,
    val nativeVirtualKeyCode: Int = windowsVirtualKeyCode,
    val autoRepeat: Boolean = false,
    val keypad: Boolean = false,
    val text: String? = null,
    val unmodifiedText: String? = text,
)

enum class BrowserTouchEventType(val cdpName: String)

enum class BrowserTouchEventType(val cdpName: String) {
    START("touchStart"),
    MOVE("touchMove"),
    END("touchEnd"),
    CANCEL("touchCancel"),
}

data class BrowserTouchPoint

data class BrowserTouchPoint(
    val id: Int,
    val x: Double,
    val y: Double,
    val radiusX: Double = 1.0,
    val radiusY: Double = 1.0,
    val rotationAngle: Double = 0.0,
    val force: Double = 1.0,
)

interface PuppetBrowserControl : AutoCloseable

High-level control surface for a Chromium tab: navigation, input, JavaScript, the Chrome DevTools Protocol and the page→Field request bridge. Fire-and-forget input methods retain TCP order; commands return futures completed by Electron.

It says nothing about pixels. A tab that is only being driven (by a test, by a docs build, by a box that wants the DOM rather than the picture) needs no texture, and this is the type to name for that. The texture comes with PuppetBrowser, which is this interface and ExternalBrowserSurface together, so naming that one obliges you to own a Vulkan image view and therefore a afield.graphics.vulkan.Renderer. HeadlessPuppetBrowser implements this interface and nothing else.

val state: PuppetBrowserState

fun addEventListener(listener: (PuppetBrowserEvent) -> Unit): () -> Unit

fun navigate(url: String): CompletableFuture<PuppetBrowserState>

fun goBack(): CompletableFuture<PuppetBrowserState>

fun goForward(): CompletableFuture<PuppetBrowserState>

fun reload(ignoreCache: Boolean = false): CompletableFuture<PuppetBrowserState>

fun stop(): CompletableFuture<PuppetBrowserState>

fun focus(): CompletableFuture<PuppetBrowserState>

fun blur(): CompletableFuture<PuppetBrowserState>

fun pointer(event: BrowserPointerEvent)

fun key(event: BrowserKeyEvent)

fun insertText(text: String)

fun setComposition

    fun setComposition(
        text: String,
        selectionStart: Int = text.length,
        selectionEnd: Int = selectionStart,
        replacementStart: Int? = null,
        replacementEnd: Int? = null,
    )

fun touch

    fun touch(
        type: BrowserTouchEventType,
        points: List<BrowserTouchPoint>,
        modifiers: Set<BrowserModifier> = emptySet(),
    )

fun flushInput(): CompletableFuture<Unit>

Completes after every previously posted pointer, keyboard, IME, and touch event was dispatched.

fun evaluate

    fun evaluate(
        javascript: String,
        world: BrowserJavascriptWorld = BrowserJavascriptWorld.MAIN,
        returnMode: BrowserJavascriptReturnMode = BrowserJavascriptReturnMode.VALUE,
        userGesture: Boolean = false,
        timeoutMillis: Long = 10_000,
    ): CompletableFuture<BrowserJavascriptResult>

fun releaseJavascriptObject(objectId: String): CompletableFuture<Unit>

fun cdp

    fun cdp(
        method: String,
        params: JsonObject = JsonObject(emptyMap()),
        sessionId: String? = null,
    ): CompletableFuture<JsonObject>

fun forwardCdpEvents(enabled: Boolean): CompletableFuture<Unit>

fun onPageRequest

Register an allowlisted request callable as window.fieldPuppet.request(name, value).

    fun onPageRequest(
        name: String,
        handler: (JsonElement) -> CompletableFuture<JsonElement>,
    ): () -> Unit
}

interface PuppetBrowser : PuppetBrowserControl, ExternalBrowserSurface

A PuppetBrowserControl whose pixels are also a stable Field texture — the same Chromium tab, seen from the render loop as well as from the keyboard.

internal fun Set<BrowserModifier>.cdpBits(): Int

internal fun Set<BrowserModifier>.cdpBits(): Int = fold(0) { bits, modifier -> bits or modifier.cdpBit }

internal fun BrowserPointerEvent.toCdpJson(): JsonObject

internal fun BrowserPointerEvent.toCdpJson(): JsonObject = buildJsonObject {
    put(
        "type",
        when (type) {
            BrowserPointerEventType.MOVE -> "mouseMoved"
            BrowserPointerEventType.DOWN -> "mousePressed"
            BrowserPointerEventType.UP -> "mouseReleased"
            BrowserPointerEventType.WHEEL -> "mouseWheel"
        },
    )
    put("x", x)
    put("y", y)
    put("button", button.cdpName)
    put("buttons", buttons)
    put("modifiers", modifiers.cdpBits())
    put("clickCount", clickCount)
    put("pointerType", pointerType.cdpName)
    put("force", pressure)
    put("tiltX", tiltX)
    put("tiltY", tiltY)
    put("twist", twist)
    if (type == BrowserPointerEventType.WHEEL) {
        put("deltaX", deltaX)
        put("deltaY", deltaY)
    }
}

internal fun BrowserKeyEvent.toCdpJson(): JsonObject

internal fun BrowserKeyEvent.toCdpJson(): JsonObject = buildJsonObject {
    put("type", type.cdpName)
    put("key", key)
    put("code", code)
    put("modifiers", modifiers.cdpBits())
    put("windowsVirtualKeyCode", windowsVirtualKeyCode)
    put("nativeVirtualKeyCode", nativeVirtualKeyCode)
    put("autoRepeat", autoRepeat)
    put("isKeypad", keypad)
    text?.let { put("text", it) }
    unmodifiedText?.let { put("unmodifiedText", it) }
}

internal fun touchToCdpJson

internal fun touchToCdpJson(
    type: BrowserTouchEventType,
    points: List<BrowserTouchPoint>,
    modifiers: Set<BrowserModifier>,
): JsonObject = buildJsonObject {
    put("type", type.cdpName)
    put("modifiers", modifiers.cdpBits())
    put(
        "touchPoints",
        buildJsonArray {
            points.forEach { point ->
                add(
                    buildJsonObject {
                        put("id", point.id)
                        put("x", point.x)
                        put("y", point.y)
                        put("radiusX", point.radiusX)
                        put("radiusY", point.radiusY)
                        put("rotationAngle", point.rotationAngle)
                        put("force", point.force)
                    },
                )
            }
        },
    )
}

internal fun puppetBrowserState(json: JsonObject): PuppetBrowserState

internal fun puppetBrowserState(json: JsonObject): PuppetBrowserState = PuppetBrowserState(
    url = json.stringOrNull("url") ?: "about:blank",
    title = json.stringOrNull("title") ?: "",
    loading = json.boolean("loading"),
    canGoBack = json.boolean("canGoBack"),
    canGoForward = json.boolean("canGoForward"),
    audible = json.boolean("audible"),
    focused = json.boolean("focused"),
    unresponsive = json.boolean("unresponsive"),
    crashed = json.boolean("crashed"),
    cursor = json.stringOrNull("cursor") ?: "default",
    navigationGeneration = json["navigationGeneration"]?.jsonPrimitive?.longOrNull ?: 0,
)

internal fun browserJavascriptResult(json: JsonObject): BrowserJavascriptResult

internal fun browserJavascriptResult(json: JsonObject): BrowserJavascriptResult = BrowserJavascriptResult(
    type = json.stringOrNull("type") ?: "undefined",
    value = json["value"] ?: JsonNull,
    description = json.stringOrNull("description"),
    unserializableValue = json.stringOrNull("unserializableValue"),
    objectId = json.stringOrNull("objectId"),
)

internal fun JsonObject.objectOrNull(name: String): JsonObject?

internal fun JsonObject.objectOrNull(name: String): JsonObject? = this[name]?.let {
    if (it is JsonObject) it else null
}

internal fun JsonObject.stringOrNull(name: String): String?

internal fun JsonObject.stringOrNull(name: String): String? = this[name]?.jsonPrimitive?.contentOrNull

internal fun JsonObject.boolean(name: String): Boolean

internal fun JsonObject.boolean(name: String): Boolean = this[name]?.jsonPrimitive?.booleanOrNull ?: false

fun PuppetBrowserControl.addEventListener(name: String, listener: (PuppetBrowserEvent) -> Unit): () -> Unit =

Replace the event listener under name on this browser. Stop or rerun of the registering box removes it; callbacks retain that box's context. The returned function removes this listener only.

fun PuppetBrowserControl.addEventListener(name: String, listener: (PuppetBrowserEvent) -> Unit): () -> Unit =
    afield.runtime.lifecycle.runOwnedListener(this, "puppet:$name", { addEventListener(it) }, listener)