ElectronCaptureBrowser.kt
src/jvmMain/kotlin/afield/tools/docs/ElectronCaptureBrowser.kt · 113 lines
package afield.tools.docs
import afield.media.browser.locateFieldProjectRoot
import afield.app.diagnostics.logging.lprintln
import com.microsoft.playwright.Browser
import com.microsoft.playwright.Page
import com.microsoft.playwright.Playwright
import java.io.File
import java.net.HttpURLConnection
import java.net.ServerSocket
import java.net.URI
This checkout's Field Electron app, driven by the server's Playwright over CDP.
The capture browser is normally a plain Chromium the server launches (ensureSharedPlaywrightBrowser
in Server.kt), which is right for everything that renders in any browser. Some output only exists
inside the Electron app — the RTT view (afield.media.stream.ElectronRTTView) draws frames that
Electron's sharedTexture delivers, and Chromium has no such API — so a capture of it has to come
from Electron itself. Playwright's Java API cannot launch Electron, but it can attach to any
Chromium over the DevTools protocol, and Electron takes --remote-debugging-port.
launch starts Electron with APP_URL set to the document, so its first tab is the Field page
(loaded with field-preload.js, the preload the RTT view needs), and fieldPage finds that tab
among the window's targets (the tab strip is a separate page). Requested with
playwright-open --electron on the backdoor. macOS and Linux (native Wayland for the RTT view).
class ElectronCaptureBrowser private constructor
A running Electron app attached over CDP, for captures that only exist inside Electron.
class ElectronCaptureBrowser private constructor(
private val process: Process,
val browser: Browser,
private val cdpPort: Int,
) : AutoCloseable {
val alive: Boolean get() = process.isAlive && browser.isConnected
fun fieldPage(serverPort: Int, timeoutMs: Long = 30_000): Page
The Field tab: the page on the server's origin that is not the tab strip.
fun fieldPage(serverPort: Int, timeoutMs: Long = 30_000): Page {
val origin = "https://localhost:$serverPort/"
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
browser.contexts().flatMap { it.pages() }
.firstOrNull { runCatching { it.url() }.getOrDefault("").startsWith(origin) }
?.let { return it }
Thread.sleep(200)
}
val seen = browser.contexts().flatMap { it.pages() }.map { runCatching { it.url() }.getOrDefault("?") }
error("Electron (CDP port $cdpPort) showed no Field tab on $origin within ${timeoutMs}ms; pages: $seen")
}
override fun close()
override fun close() {
runCatching { browser.close() }
process.destroy()
if (!process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly()
}
companion object
companion object {
const val CAPTURE_WIDTH = 1280
The capture page's size in CSS px, the drive's usual viewport; other sizes are emulated.
const val CAPTURE_HEIGHT = 720
fun launch(playwright: Playwright, appUrl: String, timeoutMs: Long = 60_000): ElectronCaptureBrowser
body — 50 lines, from line 63
fun launch(playwright: Playwright, appUrl: String, timeoutMs: Long = 60_000): ElectronCaptureBrowser {
val electronDir = File(locateFieldProjectRoot(), "electron")
val binary = File(electronDir, if (System.getProperty("os.name").startsWith("Linux"))
"node_modules/electron/dist/electron"
else "node_modules/electron/dist/Electron.app/Contents/MacOS/Electron")
require(binary.canExecute()) {
"No Electron at ${binary.absolutePath}; run ./gradlew electronInstall"
}
val cdpPort = ServerSocket(0).use { it.localPort }
val log = File.createTempFile("field-electron-capture-", ".log")
val process = ProcessBuilder(binary.absolutePath, "--remote-debugging-port=$cdpPort", ".")
.directory(electronDir)
.redirectErrorStream(true)
.redirectOutput(log)
.also {
it.environment()["APP_URL"] = appUrl
// The page at the docs capture size, shown without taking focus (electron/main.js).
it.environment()["FIELD_ELECTRON_CAPTURE_SIZE"] = "${CAPTURE_WIDTH}x${CAPTURE_HEIGHT}"
// electron/main.js quits once this pid is gone, so a killed JVM leaves no orphan.
it.environment()["FIELD_ELECTRON_OWNER_PID"] = ProcessHandle.current().pid().toString()
// A fresh profile: no per-host zoom or window state from the user's own Field4 app.
it.environment()["FIELD_ELECTRON_USER_DATA"] =
java.nio.file.Files.createTempDirectory("field-electron-capture-profile-").toString()
}
.start()
lprintln("info,docs,electron-capture", "Electron pid ${process.pid()} with CDP on $cdpPort for $appUrl (log ${log.absolutePath})")
val deadline = System.currentTimeMillis() + timeoutMs
while (!cdpReady(cdpPort)) {
check(process.isAlive) { "Electron exited (${process.exitValue()}) before CDP came up; see ${log.absolutePath}" }
if (System.currentTimeMillis() > deadline) {
process.destroyForcibly()
error("Electron's CDP endpoint on $cdpPort did not come up within ${timeoutMs}ms; see ${log.absolutePath}")
}
Thread.sleep(200)
}
val browser = playwright.chromium().connectOverCDP("http://127.0.0.1:$cdpPort")
return ElectronCaptureBrowser(process, browser, cdpPort).also { capture ->
Runtime.getRuntime().addShutdownHook(Thread { capture.close() })
}
}
private fun cdpReady(port: Int): Boolean = runCatching {
val connection = URI("http://127.0.0.1:$port/json/version").toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 500
connection.readTimeout = 500
connection.responseCode == 200
}.getOrDefault(false)
}
}