
Cross-platform WebView UI and Playwright-style browser automation with AXTree extraction, CDP-based physical clicks, CSP-safe element location, anti-detection interactions, headless operation and screenshot capture.
English | 简体中文
Work in Progress — APIs are subject to change without notice. iOS and Android platforms have not been tested.
KBrowser is a Kotlin Multiplatform library that provides:
KBWebView — A cross-platform WebView UI component for Android, iOS, Desktop (JVM), and WasmJs (Browser). Pure WebView abstraction with a unified API similar to WKWebView / Android WebView.KBPage — A Playwright-inspired browser automation wrapper around KBWebView for Desktop (JVM). Built on Chrome DevTools Protocol (CDP): AXTree extraction, CSP-safe element location, anti-detection physical clicks, screenshot capture, coroutine-based thread safety.Show a page with KBWebView. Operate on a page with KBPage.
| Your goal | Use this |
|---|---|
| Show a web page in your Compose UI (browser view, embedded page) |
KBWebView Composable + rememberKBWebView()
|
| Operate on a page — automation, scraping, screenshots, AI agents |
KBPage (KBrowser.newPage()) |
KBWebView only renders and handles user interaction; it cannot click, fill, snapshot, or screenshot for you — those live on KBPage. (Both have a loadUrl, but with different semantics — see § 6.)KBPage and mount its webView in the KBWebView Composable — that's what the Demo's browser mode does.In gradle/libs.versions.toml:
[versions]
kbrowser = "0.1.0-alpha46"
[libraries]
kbrowser = { module = "io.github.lzdev42:kbrowser", version.ref = "kbrowser" }In your module's build.gradle.kts:
implementation(libs.kbrowser)Must use JetBrains Runtime (JBR) with JCEF — standard JDK will not work. JCEF classes ship with the JBR runtime itself; they are not part of the KBrowser library nor of any Maven artifact.
Add the required JVM arguments to compose.desktop (without them, OSR mode cannot input Chinese/CJK text — English is unaffected, which makes this easy to misdiagnose as a "broken IME"):
compose.desktop {
application {
jvmArgs += listOf(
"--enable-native-access=jcef",
"--add-opens=jcef/com.jetbrains.cef.remote.browser=ALL-UNNAMED",
"--add-opens=jcef/com.jetbrains.cef.remote=ALL-UNNAMED"
)
}
}JcefChecker.isJcefAvailable == false) — :desktopApp:run and every JavaExec task run on the daemon's JVM by default, and the daemon defaults to a standard JDK (Zulu/Corretto/Temurin etc.) which contains no JCEF classes, even when the JBR is installed on your system.
Fix (either of, then ./gradlew --stop to restart daemons):
Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JVM → select your JBR+JCEF.~/.gradle/gradle.properties (machine-specific path, do not commit it to the repo):org.gradle.java.home=/Users/yourname/Library/Java/JavaVirtualMachines/jbrsdk_jcef-25.0.3/Contents/Home
compose.desktop.application.javaHomeonly affects the:runtask — it does not cover the daemon itself nor otherJavaExectasks, so it is not recommended.nativeDistributions(DMG/MSI/DEB) bundles the JBR, so distributed apps are self-contained and require none of the above.Verify:
ps -o comm= -p $(jps | grep GradleDaemon | awk '{print $1}')shows the JBR path when configured correctly.
Engine initialization must complete before creating any KBWebView / KBPage. useOsr is the rendering mode and cannot be changed after startup. Both patterns below are used in this repo (Pattern B is the Demo's approach) — pick one.
Pattern A — synchronous init in main() (simplest, for "browser-on-launch" apps):
import xyz.kbrowser.webview.KBrowser
import xyz.kbrowser.webview.initializeKBrowser
import xyz.kbrowser.getDefaultStorageDir
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() {
KBrowser.initializeConfig(
storageDir = getDefaultStorageDir(), // platform default cache dir; or a custom path
useOsr = true // default; see Rendering Modes below
)
kotlinx.coroutines.runBlocking { initializeKBrowser() } // suspend, waits for JCEF to be ready
application {
Window(onCloseRequest = ::exitApplication) { App() }
}
}Pattern B — async init inside Compose with loading indicator + mode selection (the Demo's approach, for apps that let the user choose a mode first):
var isInitialized by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (!isInitialized) {
// Show mode selection / loading screen first
ModeSelectionScreen(onModeSelected = { useOsr ->
scope.launch {
KBrowser.initializeConfig(getDefaultStorageDir(), useOsr = useOsr)
initializeKBrowser() // suspend
isInitialized = true // only now switch to the WebView-containing screen
}
})
} else {
MainScreen()
}Key constraints:
initializeConfig() exactly once, before any other KBrowser API. It is a plain setter with no guard — calling it again after the engine has started will not reconfigure anything.initializeKBrowser() must complete before any KBWebView / KBPage is created (Pattern B gates the UI on isInitialized to guarantee this).Minimal usage: create the instance with rememberKBWebView, then mount it with the KBWebView Composable. Full API (navigation, JS bridge, callbacks) in API Reference § 2.
@Composable
fun BrowserScreen() {
val webView = rememberKBWebView(initialUrl = "https://example.com")
KBWebView(webView = webView, modifier = Modifier.fillMaxSize())
}KBrowser.newPage() creates a page (pass viewportWidth/viewportHeight for background automation). All automation semantics (loadUrl/snapshot/click/screenshot) live on KBPage. Full API in § 3.
newPage,loadUrl,snapshot,screenshot, and locator actions (click/fill/type) aresuspend— call them from a coroutine (runBlocking { }inmain(),LaunchedEffectin Compose). Only locator creation (getByRole/getByLabel) andclose()are plain calls.
val page = KBrowser.newPage(viewportWidth = 1280, viewportHeight = 720) // suspend
page.loadUrl("https://example.com") // suspend, returns when loaded
val result = page.snapshot() // AXTree + YAML (for AI)
val png = page.screenshot() // screenshot
page.getByLabel("Username").fill("admin") // locate + fill (with verification)
page.getByRole("button", name = "Login").click() // locate + physical click
page.close() // not suspendInteraction methods return OperationResult for programmatic verification (occlusion detection, value read-back, scroll comparison) — AI agents detect failures without re-snapshotting. See Operation Verification.
KBWebView navigation (webView.loadUrl(...) / webView.loadHtml(...)) is fire-and-forget — it returns immediately without waiting for the content to load. KBPage.loadUrl(...) is suspend and returns only when the page has finished loading. For HTML strings on KBPage, use page.webView.loadHtml(...).
// ── 1. Remote URL ──
webView.loadUrl("https://example.com") // KBWebView
page.loadUrl("https://example.com") // KBPage (suspend)
// ── 2. Local file (file:// protocol) ──
val htmlFile = File("path/to/page.html")
webView.loadUrl(htmlFile.toURI().toString()) // → file:///path/to/page.html
page.loadUrl(htmlFile.toURI().toString())
// ── 3. HTML string (no file/server needed) ──
webView.loadHtml("<html><body><h1>Hello</h1></body></html>")
page.webView.loadHtml("<html><body><h1>Hello</h1></body></html>")You can also set the initial page at creation:
rememberKBWebView(initialUrl = "https://example.com"). On Desktop (JVM), HTML-string rendering (loadHtml) is served through the built-inkbhtml://scheme handler — no file or local server needed, in both rendering modes.
The entry point is a plain ComposeViewport with no font handling needed — Compose Multiplatform 1.12+ downloads missing glyphs on demand via automatic font fallback (CJK variant selected by browser language), so Japanese, Arabic, emoji, etc. work out of the box:
@OptIn(ExperimentalComposeUiApi::class)
fun main() = ComposeViewport {
App()
}Full API documentation: docs/KBrowser_API_Reference.md
KBrowser object — initialization, newPage(), shutdown()
KBWebView UI component — state flows, navigation, JS bridge, callbacksKBPage automation — snapshot, coordinate/JS interactions, file upload, locatorsKBLocator — coordinate/JS modes, queries, chainingOperationResult strategiesAxNode, AxTreeData, SnapshotResult, etc.KBDebug query-style CDP diagnosticsUsing KBrowser with AI agents? Don't expose dozens of APIs to the model — BrowserTools collapses the automation surface into 7 tools (navigate / snapshot / act / observe / tabs / eval) with refid handles, automatic occlusion fallback, and errors-as-data. Embed it in your host (listSpecs() + call()) — see AI_Tools.md. The demo also ships an MCP stdio mode, but it is a debug backdoor for developing KBrowser itself with an AI assistant, not a feature of the library.
| Platform | KBWebView UI | KBPage Automation | Test Status |
|---|---|---|---|
| Desktop (JVM) | ✅ | ✅ Primary target | ✅ Actively tested |
| WasmJs (Browser) | ✅ | ❌ | |
| Android | ✅ | ❌ Not tested | |
| iOS | ✅ | ❌ Not tested |
Automation features are Desktop-only. On Android/iOS,
KBLocatorfalls back to JS injection. On WasmJs,KBWebViewrenders via an HTML<iframe>overlay; automation APIs are not yet implemented.
| Other Platforms | Minimum Version |
|---|---|
| Android | API 34 (Android 14) |
| iOS | iOS 17.0+ |
The mode is fixed at startup via KBrowser.initializeConfig(useOsr = ...) and cannot be changed afterwards.
| Mode | useOsr |
Overlay Compose UI | Event Handling | Performance | Chinese Input |
|---|---|---|---|---|---|
| OSR (Off-Screen Rendering) — default | true |
✅ | Lower (pixel round-trip) | ||
| Non-OSR (Native Window) | false |
❌ | ✅ Normal | ✅ Best | ✅ Native support |
BrowserExampleScreen (compose.interop.blending=true is set by initializeKBrowser()). In-page interaction, registerJsCallback/registerJsHandler JS↔Native communication, and all CDP-based automation APIs are unaffected and work in both modes.JCEF runs in OSR mode by default (zero-copy via shared memory) and does not depend on any window, so KBrowser has a single page-creation API with no "headed/headless" distinction:
KBWebView Composable; size is determined by the Compose modifier.val page = KBrowser.newPage(viewportWidth = 1280, viewportHeight = 720) // background page
page.loadUrl("https://example.com") // navigate (suspend)
val png = page.screenshot() // readyLimitations: relies on OSR rendering (the default); on headless Linux servers, a virtual display (e.g. Xvfb) is required.
KBWebView component demo pages (basic browsing, HTML rendering, JS bidirectional communication, new window & file handling, lifecycle callbacks, cache management, screenshot).KBWebView is implemented as an <iframe> overlay; only WebView demo pages are available.Apache License 2.0 — see LICENSE.
Portions of the JVM/Desktop implementation are derived from IntelliJ IDEA (JetBrains s.r.o.), licensed under Apache 2.0. Modified files retain original copyright notices.
English | 简体中文
Work in Progress — APIs are subject to change without notice. iOS and Android platforms have not been tested.
KBrowser is a Kotlin Multiplatform library that provides:
KBWebView — A cross-platform WebView UI component for Android, iOS, Desktop (JVM), and WasmJs (Browser). Pure WebView abstraction with a unified API similar to WKWebView / Android WebView.KBPage — A Playwright-inspired browser automation wrapper around KBWebView for Desktop (JVM). Built on Chrome DevTools Protocol (CDP): AXTree extraction, CSP-safe element location, anti-detection physical clicks, screenshot capture, coroutine-based thread safety.Show a page with KBWebView. Operate on a page with KBPage.
| Your goal | Use this |
|---|---|
| Show a web page in your Compose UI (browser view, embedded page) |
KBWebView Composable + rememberKBWebView()
|
| Operate on a page — automation, scraping, screenshots, AI agents |
KBPage (KBrowser.newPage()) |
KBWebView only renders and handles user interaction; it cannot click, fill, snapshot, or screenshot for you — those live on KBPage. (Both have a loadUrl, but with different semantics — see § 6.)KBPage and mount its webView in the KBWebView Composable — that's what the Demo's browser mode does.In gradle/libs.versions.toml:
[versions]
kbrowser = "0.1.0-alpha46"
[libraries]
kbrowser = { module = "io.github.lzdev42:kbrowser", version.ref = "kbrowser" }In your module's build.gradle.kts:
implementation(libs.kbrowser)Must use JetBrains Runtime (JBR) with JCEF — standard JDK will not work. JCEF classes ship with the JBR runtime itself; they are not part of the KBrowser library nor of any Maven artifact.
Add the required JVM arguments to compose.desktop (without them, OSR mode cannot input Chinese/CJK text — English is unaffected, which makes this easy to misdiagnose as a "broken IME"):
compose.desktop {
application {
jvmArgs += listOf(
"--enable-native-access=jcef",
"--add-opens=jcef/com.jetbrains.cef.remote.browser=ALL-UNNAMED",
"--add-opens=jcef/com.jetbrains.cef.remote=ALL-UNNAMED"
)
}
}JcefChecker.isJcefAvailable == false) — :desktopApp:run and every JavaExec task run on the daemon's JVM by default, and the daemon defaults to a standard JDK (Zulu/Corretto/Temurin etc.) which contains no JCEF classes, even when the JBR is installed on your system.
Fix (either of, then ./gradlew --stop to restart daemons):
Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JVM → select your JBR+JCEF.~/.gradle/gradle.properties (machine-specific path, do not commit it to the repo):org.gradle.java.home=/Users/yourname/Library/Java/JavaVirtualMachines/jbrsdk_jcef-25.0.3/Contents/Home
compose.desktop.application.javaHomeonly affects the:runtask — it does not cover the daemon itself nor otherJavaExectasks, so it is not recommended.nativeDistributions(DMG/MSI/DEB) bundles the JBR, so distributed apps are self-contained and require none of the above.Verify:
ps -o comm= -p $(jps | grep GradleDaemon | awk '{print $1}')shows the JBR path when configured correctly.
Engine initialization must complete before creating any KBWebView / KBPage. useOsr is the rendering mode and cannot be changed after startup. Both patterns below are used in this repo (Pattern B is the Demo's approach) — pick one.
Pattern A — synchronous init in main() (simplest, for "browser-on-launch" apps):
import xyz.kbrowser.webview.KBrowser
import xyz.kbrowser.webview.initializeKBrowser
import xyz.kbrowser.getDefaultStorageDir
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() {
KBrowser.initializeConfig(
storageDir = getDefaultStorageDir(), // platform default cache dir; or a custom path
useOsr = true // default; see Rendering Modes below
)
kotlinx.coroutines.runBlocking { initializeKBrowser() } // suspend, waits for JCEF to be ready
application {
Window(onCloseRequest = ::exitApplication) { App() }
}
}Pattern B — async init inside Compose with loading indicator + mode selection (the Demo's approach, for apps that let the user choose a mode first):
var isInitialized by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (!isInitialized) {
// Show mode selection / loading screen first
ModeSelectionScreen(onModeSelected = { useOsr ->
scope.launch {
KBrowser.initializeConfig(getDefaultStorageDir(), useOsr = useOsr)
initializeKBrowser() // suspend
isInitialized = true // only now switch to the WebView-containing screen
}
})
} else {
MainScreen()
}Key constraints:
initializeConfig() exactly once, before any other KBrowser API. It is a plain setter with no guard — calling it again after the engine has started will not reconfigure anything.initializeKBrowser() must complete before any KBWebView / KBPage is created (Pattern B gates the UI on isInitialized to guarantee this).Minimal usage: create the instance with rememberKBWebView, then mount it with the KBWebView Composable. Full API (navigation, JS bridge, callbacks) in API Reference § 2.
@Composable
fun BrowserScreen() {
val webView = rememberKBWebView(initialUrl = "https://example.com")
KBWebView(webView = webView, modifier = Modifier.fillMaxSize())
}KBrowser.newPage() creates a page (pass viewportWidth/viewportHeight for background automation). All automation semantics (loadUrl/snapshot/click/screenshot) live on KBPage. Full API in § 3.
newPage,loadUrl,snapshot,screenshot, and locator actions (click/fill/type) aresuspend— call them from a coroutine (runBlocking { }inmain(),LaunchedEffectin Compose). Only locator creation (getByRole/getByLabel) andclose()are plain calls.
val page = KBrowser.newPage(viewportWidth = 1280, viewportHeight = 720) // suspend
page.loadUrl("https://example.com") // suspend, returns when loaded
val result = page.snapshot() // AXTree + YAML (for AI)
val png = page.screenshot() // screenshot
page.getByLabel("Username").fill("admin") // locate + fill (with verification)
page.getByRole("button", name = "Login").click() // locate + physical click
page.close() // not suspendInteraction methods return OperationResult for programmatic verification (occlusion detection, value read-back, scroll comparison) — AI agents detect failures without re-snapshotting. See Operation Verification.
KBWebView navigation (webView.loadUrl(...) / webView.loadHtml(...)) is fire-and-forget — it returns immediately without waiting for the content to load. KBPage.loadUrl(...) is suspend and returns only when the page has finished loading. For HTML strings on KBPage, use page.webView.loadHtml(...).
// ── 1. Remote URL ──
webView.loadUrl("https://example.com") // KBWebView
page.loadUrl("https://example.com") // KBPage (suspend)
// ── 2. Local file (file:// protocol) ──
val htmlFile = File("path/to/page.html")
webView.loadUrl(htmlFile.toURI().toString()) // → file:///path/to/page.html
page.loadUrl(htmlFile.toURI().toString())
// ── 3. HTML string (no file/server needed) ──
webView.loadHtml("<html><body><h1>Hello</h1></body></html>")
page.webView.loadHtml("<html><body><h1>Hello</h1></body></html>")You can also set the initial page at creation:
rememberKBWebView(initialUrl = "https://example.com"). On Desktop (JVM), HTML-string rendering (loadHtml) is served through the built-inkbhtml://scheme handler — no file or local server needed, in both rendering modes.
The entry point is a plain ComposeViewport with no font handling needed — Compose Multiplatform 1.12+ downloads missing glyphs on demand via automatic font fallback (CJK variant selected by browser language), so Japanese, Arabic, emoji, etc. work out of the box:
@OptIn(ExperimentalComposeUiApi::class)
fun main() = ComposeViewport {
App()
}Full API documentation: docs/KBrowser_API_Reference.md
KBrowser object — initialization, newPage(), shutdown()
KBWebView UI component — state flows, navigation, JS bridge, callbacksKBPage automation — snapshot, coordinate/JS interactions, file upload, locatorsKBLocator — coordinate/JS modes, queries, chainingOperationResult strategiesAxNode, AxTreeData, SnapshotResult, etc.KBDebug query-style CDP diagnosticsUsing KBrowser with AI agents? Don't expose dozens of APIs to the model — BrowserTools collapses the automation surface into 7 tools (navigate / snapshot / act / observe / tabs / eval) with refid handles, automatic occlusion fallback, and errors-as-data. Embed it in your host (listSpecs() + call()) — see AI_Tools.md. The demo also ships an MCP stdio mode, but it is a debug backdoor for developing KBrowser itself with an AI assistant, not a feature of the library.
| Platform | KBWebView UI | KBPage Automation | Test Status |
|---|---|---|---|
| Desktop (JVM) | ✅ | ✅ Primary target | ✅ Actively tested |
| WasmJs (Browser) | ✅ | ❌ | |
| Android | ✅ | ❌ Not tested | |
| iOS | ✅ | ❌ Not tested |
Automation features are Desktop-only. On Android/iOS,
KBLocatorfalls back to JS injection. On WasmJs,KBWebViewrenders via an HTML<iframe>overlay; automation APIs are not yet implemented.
| Other Platforms | Minimum Version |
|---|---|
| Android | API 34 (Android 14) |
| iOS | iOS 17.0+ |
The mode is fixed at startup via KBrowser.initializeConfig(useOsr = ...) and cannot be changed afterwards.
| Mode | useOsr |
Overlay Compose UI | Event Handling | Performance | Chinese Input |
|---|---|---|---|---|---|
| OSR (Off-Screen Rendering) — default | true |
✅ | Lower (pixel round-trip) | ||
| Non-OSR (Native Window) | false |
❌ | ✅ Normal | ✅ Best | ✅ Native support |
BrowserExampleScreen (compose.interop.blending=true is set by initializeKBrowser()). In-page interaction, registerJsCallback/registerJsHandler JS↔Native communication, and all CDP-based automation APIs are unaffected and work in both modes.JCEF runs in OSR mode by default (zero-copy via shared memory) and does not depend on any window, so KBrowser has a single page-creation API with no "headed/headless" distinction:
KBWebView Composable; size is determined by the Compose modifier.val page = KBrowser.newPage(viewportWidth = 1280, viewportHeight = 720) // background page
page.loadUrl("https://example.com") // navigate (suspend)
val png = page.screenshot() // readyLimitations: relies on OSR rendering (the default); on headless Linux servers, a virtual display (e.g. Xvfb) is required.
KBWebView component demo pages (basic browsing, HTML rendering, JS bidirectional communication, new window & file handling, lifecycle callbacks, cache management, screenshot).KBWebView is implemented as an <iframe> overlay; only WebView demo pages are available.Apache License 2.0 — see LICENSE.
Portions of the JVM/Desktop implementation are derived from IntelliJ IDEA (JetBrains s.r.o.), licensed under Apache 2.0. Modified files retain original copyright notices.