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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ io.github.kdroidfilter.webview.*
* **Desktop support with native engines**
* A **Rust + UniFFI (Wry)** backend instead of KCEF / embedded Chromium
* A **tiny desktop footprint** with system-provided webviews
* Handling of the **WasmJs** target via **IFrame** usage

---

## Platform backends

✅ **Android**: `android.webkit.WebView`
✅ **iOS**: `WKWebView`
✅ **WasmJs**: `org.w3c.dom.HTMLIFrameElement`
✅ **Desktop**: **Wry (Rust)** via **UniFFI**

Desktop engines:
Expand Down Expand Up @@ -66,7 +68,7 @@ dependencies {
}
```

Same artifact for **Android, iOS, Desktop**.
Same artifact for **Android, iOS, Desktop and WasmJs**.

---

Expand All @@ -90,6 +92,7 @@ Run the feature showcase first:

* **Desktop**: `./gradlew :demo:run`
* **Android**: `./gradlew :demo-android:installDebug`
* **WasmJs**: `./gradlew :demo-wasmJs:wasmJsBrowserDevelopmentRun`
* **iOS**: open `iosApp/iosApp.xcodeproj` in Xcode and Run

Responsive UI:
Expand Down Expand Up @@ -263,15 +266,25 @@ Useful for debugging or platform-specific hooks.
* `wrywebview/` → Rust core + UniFFI bindings
* `wrywebview-compose/` → Compose API
* `demo-shared/` → shared demo UI
* `demo/`, `demo-android/`, `iosApp/` → platform launchers
* `demo/`, `demo-android/`, `demo-wasmJs/`, `iosApp/` → platform launchers

---

## Limitations ⚠️

* RequestInterceptor does **not** intercept sub-resources

### Desktop

* Desktop UA change recreates the WebView

### WasmJs

* Navigation back and forward is not available in the IFrame.
* The IFrame will work only if the target website has appropriately configured its CORS.
* JS can be executed only on the same origin.
* Cookies can be set only for the parent destination (when the destination of the iframe is the same as the parent destination - cookies can be set. Otherwise, they will be ignored (there is a hack for it, but it is not a clean solution then https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#security)

---


Expand Down
14 changes: 13 additions & 1 deletion demo-shared/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
@file:OptIn(ExperimentalWasmDsl::class)

import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl

plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinMultiplatform)
Expand All @@ -10,8 +14,14 @@ kotlin {

androidTarget()
jvm()
wasmJs {
browser()
}

val isMacHost = System.getProperty("os.name")?.contains("Mac", ignoreCase = true) == true
val isMacHost = System.getProperty("os.name")?.contains(
other = "Mac",
ignoreCase = true
) == true
if (isMacHost) {
listOf(
iosX64(),
Expand Down Expand Up @@ -46,6 +56,8 @@ kotlin {
implementation(compose.desktop.common)
}

wasmJsMain.dependencies { }

if (isMacHost) {
iosMain.dependencies { }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,12 @@
package io.github.kdroidfilter.webview.demo

import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.github.kdroidfilter.webview.cookie.Cookie
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
import io.github.kdroidfilter.webview.util.KLogSeverity
import io.github.kdroidfilter.webview.web.WebView
Expand Down Expand Up @@ -85,50 +63,51 @@ fun App() {
backgroundColor = androidx.compose.ui.graphics.Color.White
}
val jsBridge = rememberWebViewJsBridge(navigator)
val webViewContent =
remember(webViewState, navigator, jsBridge) {
movableContentOf<Modifier> { webViewModifier ->
WebView(
state = webViewState,
navigator = navigator,
webViewJsBridge = jsBridge,
modifier = webViewModifier,
)
}
val webViewContent = remember(webViewState, navigator, jsBridge) {
movableContentOf<Modifier> { webViewModifier ->
WebView(
state = webViewState,
navigator = navigator,
webViewJsBridge = jsBridge,
modifier = webViewModifier,
)
}
}

var urlText by remember { mutableStateOf("https://httpbin.org/html") }

val additionalHeaders =
remember(customHeadersEnabled, headerName, headerValue) {
if (!customHeadersEnabled) return@remember emptyMap()
val key = headerName.trim()
if (key.isEmpty()) return@remember emptyMap()
mapOf(key to headerValue)
val additionalHeaders = remember(customHeadersEnabled, headerName, headerValue) {
if (!customHeadersEnabled) {
return@remember emptyMap()
}
val key = headerName.trim()
if (key.isEmpty()) {
return@remember emptyMap()
}
mapOf(key to headerValue)
}

LaunchedEffect(webViewState.lastLoadedUrl) {
webViewState.lastLoadedUrl?.let { urlText = it }
}

DisposableEffect(jsBridge, webViewState, scope) {
val handlers =
listOf<IJsMessageHandler>(
EchoHandler(onLog = ::log),
AppInfoHandler(onLog = ::log),
NavigateHandler(onLog = ::log),
SetCookieHandler(
scope = scope,
cookieManager = webViewState.cookieManager,
onLog = ::log,
),
GetCookiesHandler(
scope = scope,
cookieManager = webViewState.cookieManager,
onLog = ::log,
),
CustomHandler(onLog = ::log),
)
val handlers = listOf(
EchoHandler(onLog = ::log),
AppInfoHandler(onLog = ::log),
NavigateHandler(onLog = ::log),
SetCookieHandler(
scope = scope,
cookieManager = webViewState.cookieManager,
onLog = ::log,
),
GetCookiesHandler(
scope = scope,
cookieManager = webViewState.cookieManager,
onLog = ::log,
),
CustomHandler(onLog = ::log),
)

handlers.forEach(jsBridge::register)
onDispose { handlers.forEach(jsBridge::unregister) }
Expand All @@ -145,6 +124,7 @@ fun App() {

var jsSnippet by remember {
mutableStateOf(
//language=javascript
"""
(function () {
const id = "composewebview-demo-banner";
Expand Down Expand Up @@ -209,9 +189,9 @@ fun App() {

AnimatedVisibility(visible = toolsVisible) {
DemoToolsPanel(
modifier =
Modifier.fillMaxWidth()
.heightIn(max = constraintsMaxHeight * 0.65f),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = constraintsMaxHeight * 0.65f),
isCompact = true,
webViewState = webViewState,
navigator = navigator,
Expand Down Expand Up @@ -241,24 +221,22 @@ fun App() {
cookies = cookies,
onSetCookie = {
val url = normalizeUrl(cookieUrlText.ifBlank { urlText })
val domain =
cookieDomain
.trim()
.ifBlank { hostFromUrl(url).orEmpty() }
.trim()
.takeIf { it.isNotBlank() }
val domain = cookieDomain
.trim()
.ifBlank { hostFromUrl(url).orEmpty() }
.trim()
.takeIf { it.isNotBlank() }
val path = cookiePath.trim().ifBlank { "/" }
val cookie =
Cookie(
name = cookieName.trim().ifBlank { "demo_cookie" },
value = cookieValue,
domain = domain,
path = path,
isSessionOnly = true,
isSecure = cookieSecure,
isHttpOnly = cookieHttpOnly,
sameSite = Cookie.HTTPCookieSameSitePolicy.LAX,
)
val cookie = Cookie(
name = cookieName.trim().ifBlank { "demo_cookie" },
value = cookieValue,
domain = domain,
path = path,
isSessionOnly = true,
isSecure = cookieSecure,
isHttpOnly = cookieHttpOnly,
sameSite = Cookie.HTTPCookieSameSitePolicy.LAX,
)
scope.launch {
webViewState.cookieManager.setCookie(url, cookie)
log("setCookie url=$url ${cookie.name} domain=${cookie.domain} path=${cookie.path}")
Expand Down Expand Up @@ -298,6 +276,7 @@ fun App() {
},
onCallNativeFromJs = {
val script =
//language=javascript
"""
if (window.kmpJsBridge && window.kmpJsBridge.callNative) {
window.kmpJsBridge.callNative("echo", { text: "Hello from Kotlin (evaluateJavaScript)" }, function (data) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ private fun KeyValueRow(
}

private fun inlineHtml(): String =
//language=HTML
"""
<!doctype html>
<html lang="en">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.kdroidfilter.webview.demo

@OptIn(ExperimentalWasmJsInterop::class)
internal actual fun nowTimestamp(): String = js(
"new Date().toISOString().slice(11, 19)"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.kdroidfilter.webview.demo

import kotlinx.browser.window
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

internal actual fun platformInfoJson(): String = buildJsonObject {
put("platform", "wasmJs")
put("runtime", "browser")
put("userAgent", window.navigator.userAgent)
put("language", window.navigator.language)
}.toString()
27 changes: 27 additions & 0 deletions demo-wasmJs/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@file:OptIn(ExperimentalWasmDsl::class)

import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
}

kotlin {
wasmJs {
browser()
binaries.executable()
}

sourceSets {
wasmJsMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(project(":demo-shared"))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@file:OptIn(ExperimentalComposeUiApi::class)

package io.github.kdroidfilter.webview.demo

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
import org.w3c.dom.HTMLElement

fun main() {
val body: HTMLElement = document.body ?: return
ComposeViewport(body) {
App()
}
}
21 changes: 21 additions & 0 deletions demo-wasmJs/src/wasmJsMain/resources/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
</head>
<body>
<script src="demo-wasmJs.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugi
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlinAtomicfu = { id = "org.jetbrains.kotlin.plugin.atomicfu", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ plugins {
include(":demo")
include(":demo-shared")
include(":demo-android")
include(":demo-wasmJs")
include(":wrywebview")
include(":webview-compose")
Loading
Loading