Skip to content

Commit 58314a9

Browse files
authored
Merge pull request #33 from DrUlysses/main
Add web target [#21]
2 parents 3cebc43 + 198db70 commit 58314a9

31 files changed

Lines changed: 1888 additions & 250 deletions

File tree

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ io.github.kdroidfilter.webview.*
2525
* **Desktop support with native engines**
2626
* A **Rust + UniFFI (Wry)** backend instead of KCEF / embedded Chromium
2727
* A **tiny desktop footprint** with system-provided webviews
28+
* Handling of the **WasmJs** target via **IFrame** usage
2829

2930
---
3031

3132
## Platform backends
3233

3334
**Android**: `android.webkit.WebView`
3435
**iOS**: `WKWebView`
36+
**WasmJs**: `org.w3c.dom.HTMLIFrameElement`
3537
**Desktop**: **Wry (Rust)** via **UniFFI**
3638

3739
Desktop engines:
@@ -66,7 +68,7 @@ dependencies {
6668
}
6769
```
6870

69-
Same artifact for **Android, iOS, Desktop**.
71+
Same artifact for **Android, iOS, Desktop and WasmJs**.
7072

7173
---
7274

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

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

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

268271
---
269272

270273
## Limitations ⚠️
271274

272275
* RequestInterceptor does **not** intercept sub-resources
276+
277+
### Desktop
278+
273279
* Desktop UA change recreates the WebView
274280

281+
### WasmJs
282+
283+
* Navigation back and forward is not available in the IFrame.
284+
* The IFrame will work only if the target website has appropriately configured its CORS.
285+
* JS can be executed only on the same origin.
286+
* 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)
287+
275288
---
276289

277290

demo-shared/build.gradle.kts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
@file:OptIn(ExperimentalWasmDsl::class)
2+
3+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4+
15
plugins {
26
alias(libs.plugins.androidLibrary)
37
alias(libs.plugins.kotlinMultiplatform)
@@ -10,8 +14,14 @@ kotlin {
1014

1115
androidTarget()
1216
jvm()
17+
wasmJs {
18+
browser()
19+
}
1320

14-
val isMacHost = System.getProperty("os.name")?.contains("Mac", ignoreCase = true) == true
21+
val isMacHost = System.getProperty("os.name")?.contains(
22+
other = "Mac",
23+
ignoreCase = true
24+
) == true
1525
if (isMacHost) {
1626
listOf(
1727
iosX64(),
@@ -46,6 +56,8 @@ kotlin {
4656
implementation(compose.desktop.common)
4757
}
4858

59+
wasmJsMain.dependencies { }
60+
4961
if (isMacHost) {
5062
iosMain.dependencies { }
5163
}

demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt

Lines changed: 57 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,12 @@
11
package io.github.kdroidfilter.webview.demo
22

3-
import androidx.compose.foundation.layout.BoxWithConstraints
4-
import androidx.compose.foundation.layout.Column
5-
import androidx.compose.foundation.layout.Row
6-
import androidx.compose.foundation.layout.fillMaxHeight
7-
import androidx.compose.foundation.layout.fillMaxSize
8-
import androidx.compose.foundation.layout.fillMaxWidth
9-
import androidx.compose.foundation.layout.heightIn
10-
import androidx.compose.foundation.layout.width
113
import androidx.compose.animation.AnimatedVisibility
12-
import androidx.compose.material3.ExperimentalMaterial3Api
13-
import androidx.compose.material3.MaterialTheme
14-
import androidx.compose.material3.Surface
15-
import androidx.compose.material3.VerticalDivider
16-
import androidx.compose.material3.darkColorScheme
17-
import androidx.compose.runtime.Composable
18-
import androidx.compose.runtime.DisposableEffect
19-
import androidx.compose.runtime.ExperimentalComposeApi
20-
import androidx.compose.runtime.LaunchedEffect
21-
import androidx.compose.runtime.getValue
22-
import androidx.compose.runtime.movableContentOf
23-
import androidx.compose.runtime.mutableStateListOf
24-
import androidx.compose.runtime.mutableStateOf
25-
import androidx.compose.runtime.remember
26-
import androidx.compose.runtime.rememberCoroutineScope
27-
import androidx.compose.runtime.setValue
4+
import androidx.compose.foundation.layout.*
5+
import androidx.compose.material3.*
6+
import androidx.compose.runtime.*
287
import androidx.compose.ui.Modifier
298
import androidx.compose.ui.unit.dp
309
import io.github.kdroidfilter.webview.cookie.Cookie
31-
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
3210
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
3311
import io.github.kdroidfilter.webview.util.KLogSeverity
3412
import io.github.kdroidfilter.webview.web.WebView
@@ -85,50 +63,51 @@ fun App() {
8563
backgroundColor = androidx.compose.ui.graphics.Color.White
8664
}
8765
val jsBridge = rememberWebViewJsBridge(navigator)
88-
val webViewContent =
89-
remember(webViewState, navigator, jsBridge) {
90-
movableContentOf<Modifier> { webViewModifier ->
91-
WebView(
92-
state = webViewState,
93-
navigator = navigator,
94-
webViewJsBridge = jsBridge,
95-
modifier = webViewModifier,
96-
)
97-
}
66+
val webViewContent = remember(webViewState, navigator, jsBridge) {
67+
movableContentOf<Modifier> { webViewModifier ->
68+
WebView(
69+
state = webViewState,
70+
navigator = navigator,
71+
webViewJsBridge = jsBridge,
72+
modifier = webViewModifier,
73+
)
9874
}
75+
}
9976

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

102-
val additionalHeaders =
103-
remember(customHeadersEnabled, headerName, headerValue) {
104-
if (!customHeadersEnabled) return@remember emptyMap()
105-
val key = headerName.trim()
106-
if (key.isEmpty()) return@remember emptyMap()
107-
mapOf(key to headerValue)
79+
val additionalHeaders = remember(customHeadersEnabled, headerName, headerValue) {
80+
if (!customHeadersEnabled) {
81+
return@remember emptyMap()
10882
}
83+
val key = headerName.trim()
84+
if (key.isEmpty()) {
85+
return@remember emptyMap()
86+
}
87+
mapOf(key to headerValue)
88+
}
10989

11090
LaunchedEffect(webViewState.lastLoadedUrl) {
11191
webViewState.lastLoadedUrl?.let { urlText = it }
11292
}
11393

11494
DisposableEffect(jsBridge, webViewState, scope) {
115-
val handlers =
116-
listOf<IJsMessageHandler>(
117-
EchoHandler(onLog = ::log),
118-
AppInfoHandler(onLog = ::log),
119-
NavigateHandler(onLog = ::log),
120-
SetCookieHandler(
121-
scope = scope,
122-
cookieManager = webViewState.cookieManager,
123-
onLog = ::log,
124-
),
125-
GetCookiesHandler(
126-
scope = scope,
127-
cookieManager = webViewState.cookieManager,
128-
onLog = ::log,
129-
),
130-
CustomHandler(onLog = ::log),
131-
)
95+
val handlers = listOf(
96+
EchoHandler(onLog = ::log),
97+
AppInfoHandler(onLog = ::log),
98+
NavigateHandler(onLog = ::log),
99+
SetCookieHandler(
100+
scope = scope,
101+
cookieManager = webViewState.cookieManager,
102+
onLog = ::log,
103+
),
104+
GetCookiesHandler(
105+
scope = scope,
106+
cookieManager = webViewState.cookieManager,
107+
onLog = ::log,
108+
),
109+
CustomHandler(onLog = ::log),
110+
)
132111

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

146125
var jsSnippet by remember {
147126
mutableStateOf(
127+
//language=javascript
148128
"""
149129
(function () {
150130
const id = "composewebview-demo-banner";
@@ -209,9 +189,9 @@ fun App() {
209189

210190
AnimatedVisibility(visible = toolsVisible) {
211191
DemoToolsPanel(
212-
modifier =
213-
Modifier.fillMaxWidth()
214-
.heightIn(max = constraintsMaxHeight * 0.65f),
192+
modifier = Modifier
193+
.fillMaxWidth()
194+
.heightIn(max = constraintsMaxHeight * 0.65f),
215195
isCompact = true,
216196
webViewState = webViewState,
217197
navigator = navigator,
@@ -241,24 +221,22 @@ fun App() {
241221
cookies = cookies,
242222
onSetCookie = {
243223
val url = normalizeUrl(cookieUrlText.ifBlank { urlText })
244-
val domain =
245-
cookieDomain
246-
.trim()
247-
.ifBlank { hostFromUrl(url).orEmpty() }
248-
.trim()
249-
.takeIf { it.isNotBlank() }
224+
val domain = cookieDomain
225+
.trim()
226+
.ifBlank { hostFromUrl(url).orEmpty() }
227+
.trim()
228+
.takeIf { it.isNotBlank() }
250229
val path = cookiePath.trim().ifBlank { "/" }
251-
val cookie =
252-
Cookie(
253-
name = cookieName.trim().ifBlank { "demo_cookie" },
254-
value = cookieValue,
255-
domain = domain,
256-
path = path,
257-
isSessionOnly = true,
258-
isSecure = cookieSecure,
259-
isHttpOnly = cookieHttpOnly,
260-
sameSite = Cookie.HTTPCookieSameSitePolicy.LAX,
261-
)
230+
val cookie = Cookie(
231+
name = cookieName.trim().ifBlank { "demo_cookie" },
232+
value = cookieValue,
233+
domain = domain,
234+
path = path,
235+
isSessionOnly = true,
236+
isSecure = cookieSecure,
237+
isHttpOnly = cookieHttpOnly,
238+
sameSite = Cookie.HTTPCookieSameSitePolicy.LAX,
239+
)
262240
scope.launch {
263241
webViewState.cookieManager.setCookie(url, cookie)
264242
log("setCookie url=$url ${cookie.name} domain=${cookie.domain} path=${cookie.path}")
@@ -298,6 +276,7 @@ fun App() {
298276
},
299277
onCallNativeFromJs = {
300278
val script =
279+
//language=javascript
301280
"""
302281
if (window.kmpJsBridge && window.kmpJsBridge.callNative) {
303282
window.kmpJsBridge.callNative("echo", { text: "Hello from Kotlin (evaluateJavaScript)" }, function (data) {

demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ private fun KeyValueRow(
425425
}
426426

427427
private fun inlineHtml(): String =
428+
//language=HTML
428429
"""
429430
<!doctype html>
430431
<html lang="en">
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.github.kdroidfilter.webview.demo
2+
3+
@OptIn(ExperimentalWasmJsInterop::class)
4+
internal actual fun nowTimestamp(): String = js(
5+
"new Date().toISOString().slice(11, 19)"
6+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.github.kdroidfilter.webview.demo
2+
3+
import kotlinx.browser.window
4+
import kotlinx.serialization.json.buildJsonObject
5+
import kotlinx.serialization.json.put
6+
7+
internal actual fun platformInfoJson(): String = buildJsonObject {
8+
put("platform", "wasmJs")
9+
put("runtime", "browser")
10+
put("userAgent", window.navigator.userAgent)
11+
put("language", window.navigator.language)
12+
}.toString()

demo-wasmJs/build.gradle.kts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
@file:OptIn(ExperimentalWasmDsl::class)
2+
3+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4+
5+
plugins {
6+
alias(libs.plugins.kotlinMultiplatform)
7+
alias(libs.plugins.composeMultiplatform)
8+
alias(libs.plugins.composeCompiler)
9+
alias(libs.plugins.composeHotReload)
10+
}
11+
12+
kotlin {
13+
wasmJs {
14+
browser()
15+
binaries.executable()
16+
}
17+
18+
sourceSets {
19+
wasmJsMain.dependencies {
20+
implementation(compose.runtime)
21+
implementation(compose.foundation)
22+
implementation(compose.material3)
23+
implementation(compose.ui)
24+
implementation(project(":demo-shared"))
25+
}
26+
}
27+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@file:OptIn(ExperimentalComposeUiApi::class)
2+
3+
package io.github.kdroidfilter.webview.demo
4+
5+
import androidx.compose.ui.ExperimentalComposeUiApi
6+
import androidx.compose.ui.window.ComposeViewport
7+
import kotlinx.browser.document
8+
import org.w3c.dom.HTMLElement
9+
10+
fun main() {
11+
val body: HTMLElement = document.body ?: return
12+
ComposeViewport(body) {
13+
App()
14+
}
15+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Demo</title>
7+
<style>
8+
html,
9+
body {
10+
width: 100%;
11+
height: 100%;
12+
margin: 0;
13+
padding: 0;
14+
overflow: hidden;
15+
}
16+
</style>
17+
</head>
18+
<body>
19+
<script src="demo-wasmJs.js"></script>
20+
</body>
21+
</html>

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugi
3838
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
3939
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
4040
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
41+
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
4142
kotlinAtomicfu = { id = "org.jetbrains.kotlin.plugin.atomicfu", version.ref = "kotlin" }
4243
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
4344
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

0 commit comments

Comments
 (0)