Skip to content

FieldUiBrowserTexture.kt

src/jvmMain/kotlin/afield/media/browser/FieldUiBrowserTexture.kt · 262 lines

package afield.media.browser

import afield.document.graph.Box
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.texture.ImageView
import afield.graphics.vulkan.util.OS
import afield.graphics.vulkan.util.OSType
import afield.app.server.globalServerPort
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.PrintWriter
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference

A live web page as a Field texture: a hidden Electron window renders offscreen and its paints become an ExternalBrowserSurface a shader can sample.

FieldUiBrowserTexture is the full PuppetBrowser, so the same object takes navigation, input and JavaScript. On macOS the frames move GPU-side as IOSurfaces, with no bitmap readback.

class FieldUiBrowserTexture

A live offscreen Chromium view exposed as an ordinary stable Field texture.

The producer is a second, hidden Electron window. On macOS its paint callback transfers the Chromium-owned IOSurface through a Mach message; MacExternalBrowserSurface imports it and copies it on-GPU before this class acknowledges Electron's lease. There is no bitmap readback in this path.

declaration — 53 lines, from line 40
class FieldUiBrowserTexture(
    private val renderer: Renderer,
    val url: String,
    width: Int = 1280,
    height: Int = 720,
    slots: Int = 3,
    readyBoxUid: String? = null,
    /**
     * Read frames back to the CPU and upload them, instead of importing Chromium's shared GPU
     * allocation. Costs about 110 MB/s at 1280x720/30 and exists because Chromium's Linux
     * shared-texture export hands over a buffer it never fills (electron#53271). Off by default;
     * see [BitmapBrowserSurface].
     */
    private val cpuBitmapFallback: Boolean = false,
) : PuppetBrowser {
    private val closed = AtomicBoolean(false)
    private val writer = AtomicReference<PrintWriter?>()
    private val loadedAfterFrame = AtomicLong(Long.MAX_VALUE)
    private val browserState = AtomicReference(PuppetBrowserState(url = url, loading = true))
    private val eventListeners = CopyOnWriteArrayList<(PuppetBrowserEvent) -> Unit>()
    private val pageRequestHandlers = ConcurrentHashMap<String, (JsonElement) -> CompletableFuture<JsonElement>>()
    private val projectRoot = locateFieldProjectRoot()
    private val specValue = ExternalBrowserSurfaceSpec(
        width = width,
        height = height,
        pixelFormat = BrowserSurfacePixelFormat.BGRA8_SRGB,
        slotCount = slots,
    )
    private val delegate: ExternalBrowserSurface = createDelegate(renderer, specValue, cpuBitmapFallback) { release ->
        writer.get()?.let { out ->
            synchronized(out) {
                out.println(
                    "{\"type\":\"release\",\"token\":\"${release.releaseToken}\"," +
                        "\"reason\":\"${release.reason}\"}"
                )
                out.flush()
            }
        }
    }
    private val session = ElectronBrowserTextureSession(
        projectRoot = projectRoot,
        surface = delegate,
        url = url,
        spec = specValue,
        writer = writer,
        loadedAfterFrame = loadedAfterFrame,
        readyBoxUid = readyBoxUid,
        transport = if (cpuBitmapFallback) CpuBitmapTransport(delegate, specValue)
        else BrowserHandleTransport.forThisPlatform(projectRoot),
        stateListener = browserState::set,
        eventListener = { event -> eventListeners.forEach { listener -> runCatching { listener(event) } } },
        pageRequest = ::handlePageRequest,
    )

override val spec: ExternalBrowserSurfaceSpec get() = delegate.spec

override val presentedFrame: ExternalBrowserFrame? get() = delegate.presentedFrame

override val state: PuppetBrowserState get() = browserState.get()

val pageReady: Boolean

    val pageReady: Boolean
        get() = delegate.presentedFrame?.frameId?.let { it > loadedAfterFrame.get() } == true

override val imageView: ImageView get() = delegate.imageView

override val imageViewArray get() = delegate.imageViewArray

override fun offerFrame(frame: ExternalBrowserFrame): BrowserFrameOfferResult

    override fun offerFrame(frame: ExternalBrowserFrame): BrowserFrameOfferResult = delegate.offerFrame(frame)

override fun prepareSampledImagesFor(command: CommandBuffer)

    override fun prepareSampledImagesFor(command: CommandBuffer) = delegate.prepareSampledImagesFor(command)

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

    override fun addEventListener(listener: (PuppetBrowserEvent) -> Unit): () -> Unit {
        eventListeners += listener
        return { eventListeners -= listener }
    }

override fun navigate(url: String)

    override fun navigate(url: String) = stateCommand("navigate", buildJsonObject { put("url", url) })

override fun goBack() = stateCommand("back")

override fun goForward() = stateCommand("forward")

override fun reload(ignoreCache: Boolean)

    override fun reload(ignoreCache: Boolean) =
        stateCommand("reload", buildJsonObject { put("ignoreCache", ignoreCache) })

override fun stop() = stateCommand("stop")

override fun focus() = stateCommand("focus")

override fun blur() = stateCommand("blur")

override fun pointer(event: BrowserPointerEvent) = session.post("pointer", event.toCdpJson())

override fun key(event: BrowserKeyEvent) = session.post("key", event.toCdpJson())

override fun insertText(text: String)

    override fun insertText(text: String) = session.post("insert-text", buildJsonObject { put("text", text) })

override fun setComposition

    override fun setComposition(
        text: String,
        selectionStart: Int,
        selectionEnd: Int,
        replacementStart: Int?,
        replacementEnd: Int?,
    ) = session.post("composition", buildJsonObject {
        put("text", text)
        put("selectionStart", selectionStart)
        put("selectionEnd", selectionEnd)
        replacementStart?.let { put("replacementStart", it) }
        replacementEnd?.let { put("replacementEnd", it) }
    })

override fun touch

    override fun touch(
        type: BrowserTouchEventType,
        points: List<BrowserTouchPoint>,
        modifiers: Set<BrowserModifier>,
    ) = session.post("touch", touchToCdpJson(type, points, modifiers))

override fun flushInput(): CompletableFuture<Unit>

    override fun flushInput(): CompletableFuture<Unit> = session.command("flush-input").discardResult()

override fun evaluate

    override fun evaluate(
        javascript: String,
        world: BrowserJavascriptWorld,
        returnMode: BrowserJavascriptReturnMode,
        userGesture: Boolean,
        timeoutMillis: Long,
    ): CompletableFuture<BrowserJavascriptResult> = session.command(
        "evaluate",
        buildJsonObject {
            put("javascript", javascript)
            put("world", world.name)
            put("returnMode", returnMode.name)
            put("userGesture", userGesture)
            put("timeoutMillis", timeoutMillis)
        },
        timeoutMillis + 2_000,
    ).thenApply(::browserJavascriptResult)

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

    override fun releaseJavascriptObject(objectId: String): CompletableFuture<Unit> =
        session.command("release-object", buildJsonObject { put("objectId", objectId) }).discardResult()

override fun cdp(method: String, params: JsonObject, sessionId: String?): CompletableFuture<JsonObject>

    override fun cdp(method: String, params: JsonObject, sessionId: String?): CompletableFuture<JsonObject> =
        session.command("cdp", buildJsonObject {
            put("method", method)
            put("params", params)
            sessionId?.let { put("sessionId", it) }
        })

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

    override fun forwardCdpEvents(enabled: Boolean): CompletableFuture<Unit> =
        session.command("forward-cdp-events", buildJsonObject { put("enabled", enabled) }).discardResult()

override fun onPageRequest

    override fun onPageRequest(
        name: String,
        handler: (JsonElement) -> CompletableFuture<JsonElement>,
    ): () -> Unit {
        require(name.isNotBlank() && name.length <= 128) { "page request names must contain 1..128 characters" }
        pageRequestHandlers[name] = handler
        return { pageRequestHandlers.remove(name, handler) }
    }

    private fun stateCommand(
        name: String,
        params: JsonObject = JsonObject(emptyMap()),
    ): CompletableFuture<PuppetBrowserState> = session.command(name, params).thenApply {
        puppetBrowserState(it).also(browserState::set)
    }

    private fun handlePageRequest(id: String, name: String, value: JsonElement) {
        val handler = pageRequestHandlers[name]
        if (handler == null) {
            session.pageResponse(id, null, PuppetBrowserCommandException("No Field handler is registered for '$name'"))
            return
        }
        runCatching { handler(value) }
            .fold(
                onSuccess = { future ->
                    future.whenComplete { result, error -> session.pageResponse(id, result, error) }
                },
                onFailure = { error -> session.pageResponse(id, null, error) },
            )
    }

override fun close()

    override fun close() {
        if (!closed.compareAndSet(false, true)) return
        session.close()
        delegate.close()
    }

companion object

    companion object {

val cpuBitmapFallbackRequired: Boolean get() = OS == OSType.LINUX

Whether this platform needs cpuBitmapFallback to show anything at all.

On Linux Chromium's shared-texture export hands over a well-formed dma-buf that it never writes into (electron#53271), so the accelerated path produces a correct-looking, permanently black texture. Until that is fixed upstream, a caller that has to show something on every platform passes this as the readback flag and so takes the readback path on Linux alone, keeping the zero-copy one everywhere it works. The flag's own default stays false, so every call site that turns readback on says so where it is written.

fun fieldDocumentUrl(box: Box): String

URL for another browser view of the document containing box.

        fun fieldDocumentUrl(box: Box): String {
            val session = box.inside
            val document = URLEncoder.encode(session.documentName, StandardCharsets.UTF_8)
            val scratchSession = if (session.ephemeral) {
                "&scratch=1&sessionKey=${URLEncoder.encode(session.filename, StandardCharsets.UTF_8)}"
            } else {
                ""
            }
            return "https://localhost:$globalServerPort/index.html?file=$document$scratchSession"
        }
    }
}

Picks the receiver that matches the transport: an importer, or the CPU-upload fallback.

private fun createDelegate(
    renderer: Renderer,
    spec: ExternalBrowserSurfaceSpec,
    cpuBitmapFallback: Boolean,
    releaseListener: BrowserFrameReleaseListener,
): ExternalBrowserSurface {
    if (cpuBitmapFallback) return BitmapBrowserSurface(renderer, spec, releaseListener)
    // Only the macOS handle transfer has been validated live; the Windows (D3D11 NT handle) and Linux
    // (dma-buf) receivers compile but their Electron bridges are unfinished, so they are refused
    // here, with the route that does work, rather than left to show a blank texture.
    if (OS != OSType.MACOS) {
        throw UnsupportedOperationException(
            "FieldUiBrowserTexture's GPU handle transfer is only working on macOS so far; on $OS pass " +
                "cpuBitmapFallback = true, which reads frames back and uploads them",
        )
    }
    return ExternalBrowserSurfaces.create(renderer, spec, releaseListener)
}