diff --git a/README.md b/README.md index 99bba6f..42a2e67 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ 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 --- @@ -32,6 +33,7 @@ io.github.kdroidfilter.webview.* ✅ **Android**: `android.webkit.WebView` ✅ **iOS**: `WKWebView` +✅ **WasmJs**: `org.w3c.dom.HTMLIFrameElement` ✅ **Desktop**: **Wry (Rust)** via **UniFFI** Desktop engines: @@ -66,7 +68,7 @@ dependencies { } ``` -Same artifact for **Android, iOS, Desktop**. +Same artifact for **Android, iOS, Desktop and WasmJs**. --- @@ -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: @@ -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) + --- diff --git a/demo-shared/build.gradle.kts b/demo-shared/build.gradle.kts index 9c924af..c632c44 100644 --- a/demo-shared/build.gradle.kts +++ b/demo-shared/build.gradle.kts @@ -1,3 +1,7 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinMultiplatform) @@ -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(), @@ -46,6 +56,8 @@ kotlin { implementation(compose.desktop.common) } + wasmJsMain.dependencies { } + if (isMacHost) { iosMain.dependencies { } } diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt b/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt index 32b65be..12904ca 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt +++ b/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt @@ -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 @@ -85,50 +63,51 @@ fun App() { backgroundColor = androidx.compose.ui.graphics.Color.White } val jsBridge = rememberWebViewJsBridge(navigator) - val webViewContent = - remember(webViewState, navigator, jsBridge) { - movableContentOf { webViewModifier -> - WebView( - state = webViewState, - navigator = navigator, - webViewJsBridge = jsBridge, - modifier = webViewModifier, - ) - } + val webViewContent = remember(webViewState, navigator, jsBridge) { + movableContentOf { 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( - 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) } @@ -145,6 +124,7 @@ fun App() { var jsSnippet by remember { mutableStateOf( + //language=javascript """ (function () { const id = "composewebview-demo-banner"; @@ -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, @@ -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}") @@ -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) { diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt b/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt index a11ec9b..af7eb3b 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt +++ b/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt @@ -425,6 +425,7 @@ private fun KeyValueRow( } private fun inlineHtml(): String = + //language=HTML """ diff --git a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.wasmJs.kt b/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.wasmJs.kt new file mode 100644 index 0000000..30448f4 --- /dev/null +++ b/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.wasmJs.kt @@ -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)" +) diff --git a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt b/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt new file mode 100644 index 0000000..099cc99 --- /dev/null +++ b/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt @@ -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() diff --git a/demo-wasmJs/build.gradle.kts b/demo-wasmJs/build.gradle.kts new file mode 100644 index 0000000..b0fe8cb --- /dev/null +++ b/demo-wasmJs/build.gradle.kts @@ -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")) + } + } +} diff --git a/demo-wasmJs/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt b/demo-wasmJs/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt new file mode 100644 index 0000000..a32a0ae --- /dev/null +++ b/demo-wasmJs/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt @@ -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() + } +} diff --git a/demo-wasmJs/src/wasmJsMain/resources/index.html b/demo-wasmJs/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..1b1998c --- /dev/null +++ b/demo-wasmJs/src/wasmJsMain/resources/index.html @@ -0,0 +1,21 @@ + + + + + + Demo + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4251e0..65f19d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 333b54e..b99e3fb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,5 +35,6 @@ plugins { include(":demo") include(":demo-shared") include(":demo-android") +include(":demo-wasmJs") include(":wrywebview") include(":webview-compose") diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts index e42d969..0730223 100644 --- a/webview-compose/build.gradle.kts +++ b/webview-compose/build.gradle.kts @@ -1,8 +1,12 @@ +@file:OptIn(ExperimentalWasmDsl::class) + import com.vanniktech.maven.publish.KotlinMultiplatform +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.mavenPublish) @@ -13,6 +17,9 @@ kotlin { androidTarget() jvm() + wasmJs { + browser() + } listOf( iosX64(), @@ -26,6 +33,10 @@ kotlin { iosTarget.setUpiOSObserver() } + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + sourceSets { commonMain.dependencies { implementation(compose.runtime) @@ -45,6 +56,8 @@ kotlin { } iosMain.dependencies { } + + wasmJsMain.dependencies { } } } diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt index eb81348..419cc1d 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt @@ -21,18 +21,18 @@ data class Cookie( STRICT, } - override fun toString(): String { - var cookieValue = "$name=$value" - - if (path != null) cookieValue += "; Path=$path" - if (domain != null) cookieValue += "; Domain=$domain" - if (expiresDate != null) cookieValue += "; Expires=" + getCookieExpirationDate(expiresDate) - if (maxAge != null) cookieValue += "; Max-Age=$maxAge" - if (isSecure == true) cookieValue += "; Secure" - if (isHttpOnly == true) cookieValue += "; HttpOnly" - if (sameSite != null) cookieValue += "; SameSite=$sameSite" - - return "$cookieValue;" + // Without buildString is empty in wasmJs + override fun toString(): String = buildString { + append("$name=$value") + if (path != null) append("; Path=$path") + // The domain must match the domain of the JavaScript origin. Setting cookies to foreign domains will be silently ignored. + if (domain != null) append("; Domain=$domain") + if (expiresDate != null) append("; Expires=" + getCookieExpirationDate(expiresDate)) + if (maxAge != null) append("; Max-Age=$maxAge") + if (isSecure == true) append("; Secure") + if (isHttpOnly == true) append("; HttpOnly") + if (sameSite != null) append("; SameSite=$sameSite") + append(';') } } diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt index f437b35..7418ad4 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt @@ -1,13 +1,16 @@ package io.github.kdroidfilter.webview.jsbridge +import kotlinx.serialization.Serializable + /** * Message dispatched from JS to native. * * `params` is expected to be a JSON string (API compatibility with compose-webview-multiplatform). */ +@Serializable data class JsMessage( val callbackId: Int, val methodName: String, - val params: String, + val params: String ) diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt index ba587af..56a1a40 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt @@ -1,20 +1,45 @@ package io.github.kdroidfilter.webview.jsbridge +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive private val jsBridgeJson = Json { ignoreUnknownKeys = true } -internal fun parseJsMessage(raw: String): JsMessage? = - runCatching { - val obj = jsBridgeJson.parseToJsonElement(raw).jsonObject - val callbackId = obj["callbackId"]?.jsonPrimitive?.content?.toIntOrNull() ?: -1 - val methodName = obj["methodName"]?.jsonPrimitive?.content ?: return null - val params = - obj["params"]?.jsonPrimitive?.content - ?: obj["params"]?.toString() - ?: "" - JsMessage(callbackId = callbackId, methodName = methodName, params = params) - }.getOrNull() +@Serializable +private data class ParsedJsBridgeMessage( + val callbackId: Int? = null, + val methodName: String? = null, + val action: String? = null, + val params: JsonElement? = null, + val type: String? = null, +) +internal fun parseJsMessage( + raw: String, + expectedType: String? = null, +): JsMessage? = runCatching { + val message = jsBridgeJson.decodeFromString(raw) + if (expectedType != null && message.type != expectedType) return null + + val methodName = message.methodName ?: message.action ?: return null + val isWasmBridgeMessage = message.action != null + val params = message.params?.toJsMessageParams() ?: if (isWasmBridgeMessage) "{}" else "" + val callbackId = message.callbackId ?: if (isWasmBridgeMessage) 0 else -1 + + JsMessage( + callbackId = callbackId, + methodName = methodName, + params = params + ) +}.getOrNull() + +private fun JsonElement.toJsMessageParams(): String = if ( + this is JsonPrimitive && + isString +) { + content +} else { + toString() +} diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt index 6718398..e122804 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt @@ -33,5 +33,10 @@ sealed class PlatformWebSettings { data class WasmJSWebSettings( var backgroundColor: Color? = null, var showBorder: Boolean = false, + var enableSandbox: Boolean = false, + var customContainerStyle: String? = null, + var allowFullscreen: Boolean = true, + var borderStyle: String = "1px solid #ccc", + var sandboxPermissions: String = "allow-scripts allow-same-origin allow-forms", ) : PlatformWebSettings() } diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt index 1f21733..55e3220 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt @@ -21,9 +21,3 @@ sealed class WebContent { data object NavigatorOnly : WebContent() } - -internal fun WebContent.withUrl(url: String) = - when (this) { - is WebContent.Url -> copy(url = url) - else -> WebContent.Url(url) - } diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt index d827592..5f02416 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt @@ -60,13 +60,14 @@ class WebViewNavigator( NavigationEvent.Reload -> reload() NavigationEvent.StopLoading -> stopLoading() is NavigationEvent.LoadUrl -> { + val normalizedUrl = normalizeHttpUrl(event.url) val interceptor = requestInterceptor if (interceptor == null) { - loadUrl(event.url, event.additionalHttpHeaders) + loadUrl(normalizedUrl, event.additionalHttpHeaders) } else { val request = io.github.kdroidfilter.webview.request.WebRequest( - url = event.url, + url = normalizedUrl, headers = event.additionalHttpHeaders.toMutableMap(), isForMainFrame = true, method = "GET", @@ -157,6 +158,22 @@ class WebViewNavigator( } } +/** + * Normalize bare-domain HTTP(S) URLs by appending a trailing slash, + * matching browser behavior (e.g. https://example.com → https://example.com/). + */ +private fun normalizeHttpUrl(url: String): String { + if (!url.startsWith("http://") && !url.startsWith("https://")) return url + val schemeEnd = url.indexOf("://") + 3 + if (url.indexOf('/', schemeEnd) == -1 && + url.indexOf('?', schemeEnd) == -1 && + url.indexOf('#', schemeEnd) == -1 + ) { + return "$url/" + } + return url +} + @Composable fun rememberWebViewNavigator( coroutineScope: CoroutineScope = rememberCoroutineScope(), diff --git a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt index 0e461bc..a50224c 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt +++ b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt @@ -6,10 +6,12 @@ import java.util.Locale import java.util.TimeZone actual fun getCookieExpirationDate(expiresDate: Long): String { - val sdf = - SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { - timeZone = TimeZone.getTimeZone("GMT") - } + val sdf = SimpleDateFormat( + /* pattern = */ "EEE, dd MMM yyyy HH:mm:ss z", + /* locale = */ Locale.US + ).apply { + timeZone = TimeZone.getTimeZone("GMT") + } return sdf.format(Date(expiresDate)) } diff --git a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/WryCookieManager.kt b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/WryCookieManager.kt index 96f4fba..87d46ce 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/WryCookieManager.kt +++ b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/WryCookieManager.kt @@ -53,43 +53,39 @@ internal class WryCookieManager : CookieManager { } } -private fun Cookie.toNativeCookie(): WebViewCookie = - WebViewCookie( - name = name, - value = value, - domain = domain, - path = path, - expiresDateMs = expiresDate, - isSessionOnly = isSessionOnly, - maxAgeSec = maxAge, - sameSite = - when (sameSite) { - null -> null - Cookie.HTTPCookieSameSitePolicy.NONE -> CookieSameSite.NONE - Cookie.HTTPCookieSameSitePolicy.LAX -> CookieSameSite.LAX - Cookie.HTTPCookieSameSitePolicy.STRICT -> CookieSameSite.STRICT - }, - isSecure = isSecure, - isHttpOnly = isHttpOnly, - ) +private fun Cookie.toNativeCookie(): WebViewCookie = WebViewCookie( + name = name, + value = value, + domain = domain, + path = path, + expiresDateMs = expiresDate, + isSessionOnly = isSessionOnly, + maxAgeSec = maxAge, + sameSite = when (sameSite) { + null -> null + Cookie.HTTPCookieSameSitePolicy.NONE -> CookieSameSite.NONE + Cookie.HTTPCookieSameSitePolicy.LAX -> CookieSameSite.LAX + Cookie.HTTPCookieSameSitePolicy.STRICT -> CookieSameSite.STRICT + }, + isSecure = isSecure, + isHttpOnly = isHttpOnly, +) -private fun WebViewCookie.toCompatCookie(): Cookie = - Cookie( - name = name, - value = value, - domain = domain, - path = path, - expiresDate = expiresDateMs, - isSessionOnly = isSessionOnly, - maxAge = maxAgeSec, - sameSite = - when (sameSite) { - null -> null - CookieSameSite.NONE -> Cookie.HTTPCookieSameSitePolicy.NONE - CookieSameSite.LAX -> Cookie.HTTPCookieSameSitePolicy.LAX - CookieSameSite.STRICT -> Cookie.HTTPCookieSameSitePolicy.STRICT - }, - isSecure = isSecure, - isHttpOnly = isHttpOnly, - ) +private fun WebViewCookie.toCompatCookie(): Cookie = Cookie( + name = name, + value = value, + domain = domain, + path = path, + expiresDate = expiresDateMs, + isSessionOnly = isSessionOnly, + maxAge = maxAgeSec, + sameSite = when (sameSite) { + null -> null + CookieSameSite.NONE -> Cookie.HTTPCookieSameSitePolicy.NONE + CookieSameSite.LAX -> Cookie.HTTPCookieSameSitePolicy.LAX + CookieSameSite.STRICT -> Cookie.HTTPCookieSameSitePolicy.STRICT + }, + isSecure = isSecure, + isHttpOnly = isHttpOnly, +) diff --git a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt index d838800..26b070c 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt +++ b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt @@ -40,56 +40,51 @@ internal class DesktopWebView( fileName: String, readType: WebViewFileReadType, ) { - val html = - runCatching { - when (readType) { - WebViewFileReadType.ASSET_RESOURCES -> { - val normalized = fileName.removePrefix("/") - val candidates = linkedSetOf() - if ( - normalized.startsWith("assets/") || - normalized.startsWith("compose-resources/") || - normalized.startsWith("composeResources/") - ) { - candidates.add(normalized) - } - candidates.add("assets/$normalized") - candidates.add("compose-resources/files/$normalized") - candidates.add("compose-resources/assets/$normalized") - candidates.add("composeResources/files/$normalized") - candidates.add("composeResources/assets/$normalized") - val loaders = - listOfNotNull(Thread.currentThread().contextClassLoader, this::class.java.classLoader) - candidates.asSequence() - .mapNotNull { path -> - loaders.asSequence() - .mapNotNull { loader -> loader.getResourceAsStream(path) } - .firstOrNull() - ?.use { it.readBytes().toString(Charsets.UTF_8) } - } - .firstOrNull() - ?: error("Resource not found: ${candidates.joinToString()}") + val html = runCatching { + when (readType) { + WebViewFileReadType.ASSET_RESOURCES -> { + val normalized = fileName.removePrefix("/") + val candidates = linkedSetOf() + if ( + normalized.startsWith("assets/") || + normalized.startsWith("compose-resources/") || + normalized.startsWith("composeResources/") + ) { + candidates.add(normalized) } - - WebViewFileReadType.COMPOSE_RESOURCE_FILES -> - URL(fileName).openStream().use { it.readBytes().toString(Charsets.UTF_8) } + candidates.add("assets/$normalized") + candidates.add("compose-resources/files/$normalized") + candidates.add("compose-resources/assets/$normalized") + candidates.add("composeResources/files/$normalized") + candidates.add("composeResources/assets/$normalized") + val loaders = + listOfNotNull(Thread.currentThread().contextClassLoader, this::class.java.classLoader) + candidates.firstNotNullOfOrNull { path -> + loaders.firstNotNullOfOrNull { loader -> + loader.getResourceAsStream(path) + }?.use { it.readBytes().toString(Charsets.UTF_8) } + } ?: error("Resource not found: ${candidates.joinToString()}") } - }.getOrElse { e -> - val errorHtml = - """ - - - Error Loading File - -

Error Loading File

-

File: $fileName (ReadType: $readType)

-
${e.stackTraceToString()}
- - - """.trimIndent() - KLogger.e(e, tag = "DesktopWebView") { "loadHtmlFile failed" } - errorHtml + + WebViewFileReadType.COMPOSE_RESOURCE_FILES -> + URL(fileName).openStream().use { it.readBytes().toString(Charsets.UTF_8) } } + }.getOrElse { e -> + // language=HTML + val errorHtml = """ + + + Error Loading File + +

Error Loading File

+

File: $fileName (ReadType: $readType)

+
${e.stackTraceToString()}
+ + + """.trimIndent() + KLogger.e(e, tag = "DesktopWebView") { "loadHtmlFile failed" } + errorHtml + } nativeWebView.loadHtml(html) } @@ -114,14 +109,14 @@ internal class DesktopWebView( val bridge = webViewJsBridge ?: return super.injectJsBridge() - val js = - """ + //language=JavaScript + val js = """ if (window.${bridge.jsBridgeName} && window.ipc && window.ipc.postMessage) { window.${bridge.jsBridgeName}.postMessage = function (message) { window.ipc.postMessage(message); }; } - """.trimIndent() + """.trimIndent() evaluateJavaScript(js) } diff --git a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.desktop.kt index 1c35c89..187f5d4 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.desktop.kt +++ b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.desktop.kt @@ -1,4 +1,5 @@ package io.github.kdroidfilter.webview.web -actual typealias NativeWebView = io.github.kdroidfilter.webview.wry.WryWebViewPanel +import io.github.kdroidfilter.webview.wry.WryWebViewPanel +actual typealias NativeWebView = WryWebViewPanel diff --git a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/WebViewDesktop.kt index cc741b8..32fe6de 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/WebViewDesktop.kt +++ b/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/WebViewDesktop.kt @@ -18,40 +18,41 @@ actual class WebViewFactoryParam( val fileContent: String = "", ) -actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView = - when (val content = param.state.content) { - is WebContent.Url -> NativeWebView( - initialUrl = content.url, - customUserAgent = param.state.webSettings.customUserAgentString, - dataDirectory = param.state.webSettings.desktopWebSettings.dataDirectory, - supportZoom = param.state.webSettings.supportZoom, - backgroundColor = param.state.webSettings.backgroundColor.toRgba(), - transparent = param.state.webSettings.desktopWebSettings.transparent, - initScript = param.state.webSettings.desktopWebSettings.initScript, - enableClipboard = param.state.webSettings.desktopWebSettings.enableClipboard, - enableDevtools = param.state.webSettings.desktopWebSettings.enableDevtools, - enableNavigationGestures = param.state.webSettings.desktopWebSettings.enableNavigationGestures, - incognito = param.state.webSettings.desktopWebSettings.incognito, - autoplayWithoutUserInteraction = param.state.webSettings.desktopWebSettings.autoplayWithoutUserInteraction, - focused = param.state.webSettings.desktopWebSettings.focused - ) - - else -> NativeWebView( - initialUrl = "about:blank", - customUserAgent = param.state.webSettings.customUserAgentString, - dataDirectory = param.state.webSettings.desktopWebSettings.dataDirectory, - supportZoom = param.state.webSettings.supportZoom, - backgroundColor = param.state.webSettings.backgroundColor.toRgba(), - transparent = param.state.webSettings.desktopWebSettings.transparent, - initScript = param.state.webSettings.desktopWebSettings.initScript, - enableClipboard = param.state.webSettings.desktopWebSettings.enableClipboard, - enableDevtools = param.state.webSettings.desktopWebSettings.enableDevtools, - enableNavigationGestures = param.state.webSettings.desktopWebSettings.enableNavigationGestures, - incognito = param.state.webSettings.desktopWebSettings.incognito, - autoplayWithoutUserInteraction = param.state.webSettings.desktopWebSettings.autoplayWithoutUserInteraction, - focused = param.state.webSettings.desktopWebSettings.focused - ) - } +actual fun defaultWebViewFactory( + param: WebViewFactoryParam +): NativeWebView = when (val content = param.state.content) { + is WebContent.Url -> NativeWebView( + initialUrl = content.url, + customUserAgent = param.state.webSettings.customUserAgentString, + dataDirectory = param.state.webSettings.desktopWebSettings.dataDirectory, + supportZoom = param.state.webSettings.supportZoom, + backgroundColor = param.state.webSettings.backgroundColor.toRgba(), + transparent = param.state.webSettings.desktopWebSettings.transparent, + initScript = param.state.webSettings.desktopWebSettings.initScript, + enableClipboard = param.state.webSettings.desktopWebSettings.enableClipboard, + enableDevtools = param.state.webSettings.desktopWebSettings.enableDevtools, + enableNavigationGestures = param.state.webSettings.desktopWebSettings.enableNavigationGestures, + incognito = param.state.webSettings.desktopWebSettings.incognito, + autoplayWithoutUserInteraction = param.state.webSettings.desktopWebSettings.autoplayWithoutUserInteraction, + focused = param.state.webSettings.desktopWebSettings.focused + ) + + else -> NativeWebView( + initialUrl = "about:blank", + customUserAgent = param.state.webSettings.customUserAgentString, + dataDirectory = param.state.webSettings.desktopWebSettings.dataDirectory, + supportZoom = param.state.webSettings.supportZoom, + backgroundColor = param.state.webSettings.backgroundColor.toRgba(), + transparent = param.state.webSettings.desktopWebSettings.transparent, + initScript = param.state.webSettings.desktopWebSettings.initScript, + enableClipboard = param.state.webSettings.desktopWebSettings.enableClipboard, + enableDevtools = param.state.webSettings.desktopWebSettings.enableDevtools, + enableNavigationGestures = param.state.webSettings.desktopWebSettings.enableNavigationGestures, + incognito = param.state.webSettings.desktopWebSettings.incognito, + autoplayWithoutUserInteraction = param.state.webSettings.desktopWebSettings.autoplayWithoutUserInteraction, + focused = param.state.webSettings.desktopWebSettings.focused + ) +} @Composable actual fun ActualWebView( @@ -164,14 +165,12 @@ actual fun ActualWebView( return@a true } - val webRequest = - WebRequest( - url = it, - headers = mutableMapOf(), - isForMainFrame = true, - isRedirect = true, - method = "GET", - ) + val webRequest = WebRequest( + url = it, + headers = mutableMapOf(), + isForMainFrame = true, + isRedirect = true + ) return@a when (val interceptResult = navigator.requestInterceptor.onInterceptUrlRequest(webRequest, navigator)) { diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.wasmJs.kt b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.wasmJs.kt new file mode 100644 index 0000000..43fa5c7 --- /dev/null +++ b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.wasmJs.kt @@ -0,0 +1,80 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) +package io.github.kdroidfilter.webview.cookie + +import io.github.kdroidfilter.webview.util.KLogger +import kotlinx.browser.document + +/** + * Converts a timestamp to a cookie expiration date string in the browser's expected format. + */ +actual fun getCookieExpirationDate( + expiresDate: Long +): String = js( + //language=javascript + "(new Date(expiresDate).toUTCString())" +) + +@Suppress("FunctionName") +actual fun WebViewCookieManager(): CookieManager = WasmJsCookieManager + +object WasmJsCookieManager : CookieManager { + override suspend fun setCookie( + url: String, + cookie: Cookie + ) { + // Set the cookie using the document.cookie API + KLogger.w(tag = "WasmJsCookieManager") { + "removeCookies(url=$url): URL-specific cookie set is not supported in browser context. Cookies for different domain will be ignored." + } + document.cookie = cookie.toString() + } + + override suspend fun getCookies( + url: String + ): List { + val cookiesStr = document.cookie + if (cookiesStr.isEmpty()) { + return emptyList() + } + KLogger.w(tag = "WasmJsCookieManager") { + "getCookies(url=$url): URL-specific cookies get is not supported in browser context. Returning only cookies for the current domain." + } + + return cookiesStr.split(";").map { cookieStr -> + val parts = cookieStr.trim().split("=", limit = 2) + val name = parts[0] + val value = if (parts.size > 1) { + parts[1] + } else { + "" + } + + Cookie( + name = name, + value = value + ) + } + } + + override suspend fun removeAllCookies() { + val cookies = getCookies("") + for (cookie in cookies) { + // To delete a cookie, set it with an expired date + document.cookie = buildString { + append("${cookie.name}=") + append("; path=/") + append("; expires=Thu, 01 Jan 1970 00:00:00 GMT") + } + } + } + + override suspend fun removeCookies(url: String) { + // Browser document.cookie API does not support removing cookies for a specific URL/domain. + // Falling back to removing all cookies. Consider using CookieStore API for finer control: + // https://developer.mozilla.org/en-US/docs/Web/API/CookieStore + KLogger.w(tag = "WasmJsCookieManager") { + "removeCookies(url=$url): URL-specific cookie removal is not supported in browser context, removing all cookies instead" + } + removeAllCookies() + } +} diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/HtmlView.kt b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/HtmlView.kt new file mode 100644 index 0000000..2f3f523 --- /dev/null +++ b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/HtmlView.kt @@ -0,0 +1,425 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalWasmJsInterop::class) +package io.github.kdroidfilter.webview.web + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateObserver +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.round +import io.github.kdroidfilter.webview.util.KLogger +import kotlinx.browser.document +import kotlinx.coroutines.launch +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import org.w3c.dom.Element +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLIFrameElement +import org.w3c.dom.Node +import org.w3c.dom.events.Event +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A Composable that renders HTML content using an iframe + * + * @param state The state of the HTML view + * @param modifier The modifier for this composable + * @param navigator The navigator for HTML navigation events + * @param onCreated Callback invoked when the view is created + * @param onDispose Callback invoked when the view is disposed + */ +@Composable +fun HtmlView( + state: HtmlViewState, + modifier: Modifier = Modifier, + navigator: HtmlViewNavigator = rememberHtmlViewNavigator(), + onCreated: (HTMLIFrameElement) -> Unit = {}, + onDispose: (HTMLIFrameElement) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val element = remember { mutableStateOf(null) } + val root: Node = document.body?.shadowRoot ?: document.body!! + val density = LocalDensity.current.density + val focusManager = LocalFocusManager.current + val htmlViewFocusRequester = remember { FocusRequester() } + + val componentInfo = remember { ComponentInfo() } + val focusSwitcher = remember { FocusSwitcher(componentInfo, focusManager) } + val eventsInitialized = remember { mutableStateOf(false) } + val componentReady = remember { mutableStateOf(false) } + + Box( + modifier = modifier + .focusRequester(htmlViewFocusRequester) + .onFocusChanged { + if (it.isFocused) { + element.value?.let(::requestFocus) + } + } + .focusTarget() + .onGloballyPositioned { coordinates -> + val location = coordinates.positionInWindow().round() + val size = coordinates.size + if (componentReady.value) { + val container = componentInfo.container as HTMLDivElement + container.style.width = "${size.width / density}px" + container.style.height = "${size.height / density}px" + container.style.left = "${location.x / density}px" + container.style.top = "${location.y / density}px" + } + } + ) { + focusSwitcher.Content() + } + + DisposableEffect(Unit) { + componentInfo.container = document.createElement("div") as HTMLDivElement + componentInfo.component = document.createElement("iframe") as HTMLIFrameElement + componentInfo.component.tabIndex = 0 + componentReady.value = true + val container = componentInfo.container as HTMLDivElement + + root.appendChild(container) + container.append(componentInfo.component) + + container.style.position = "absolute" + container.style.margin = "0px" + container.style.padding = "0px" + container.style.overflowX = "hidden" + container.style.overflowY = "hidden" + container.style.zIndex = "1000" + + componentInfo.component.style.position = "absolute" + componentInfo.component.style.left = "0px" + componentInfo.component.style.top = "0px" + componentInfo.component.style.margin = "0px" + element.value = componentInfo.component + state.htmlElement = componentInfo.component + onCreated(componentInfo.component) + + var lastAppliedContent: HtmlContent? = null + + componentInfo.updater = Updater(componentInfo.component) { iframe: HTMLIFrameElement -> + if (!eventsInitialized.value) { + eventsInitialized.value = true + + val syncFocusToIframe = { + try { + htmlViewFocusRequester.requestFocus() + requestFocus(iframe) + iframe.contentWindow?.focus() + } catch (_: Throwable) { + } + } + + val loadCallback: (Event) -> Unit = { + state.loadingState = HtmlLoadingState.Finished() + + try { + registerDomListener(iframe.contentWindow, "focus") { + htmlViewFocusRequester.requestFocus() + } + + registerDomListener(iframe.contentDocument, "focusin") { + htmlViewFocusRequester.requestFocus() + } + + registerDomListener(iframe.contentDocument, "pointerdown") { + syncFocusToIframe() + } + + registerDomListener(iframe.contentDocument, "mousedown") { + syncFocusToIframe() + } + } catch (_: Throwable) { + // Cross-origin iframe: cannot access contentDocument/contentWindow listeners + } + + when (val content = state.content) { + is HtmlContent.Url -> { + // Safe: use the URL requested, do not inspect iframe internals + if (content.url != "about:blank") { + state.lastLoadedUrl = content.url + } + state.pageTitle = null + } + + is HtmlContent.Data -> { + // srcdoc / inline HTML is usually same-origin unless sandboxing removes it + try { + iframe.contentDocument?.title?.let { state.pageTitle = it } + val href = iframe.contentWindow?.location?.href ?: iframe.src + if (href != "about:blank") { + state.lastLoadedUrl = href + } + } catch (t: Throwable) { + KLogger.e(t = t, tag = "HtmlView") { + "Failed to get URL or title" + } + } + } + + else -> Unit + } + } + + val errorCallback: (Event) -> Unit = { + state.loadingState = HtmlLoadingState.Finished( + isError = true, + errorMessage = "Failed to load content", + ) + } + + registerDomListener(iframe, "focus") { + htmlViewFocusRequester.requestFocus() + } + + registerDomListener(iframe, "pointerdown") { + syncFocusToIframe() + } + iframe.addEventListener("load", loadCallback) + iframe.addEventListener("error", errorCallback) + + scope.launch { + navigator.handleNavigationEvents(iframe) + } + } + + val content = state.content + if (content != lastAppliedContent) { + lastAppliedContent = content + when (content) { + is HtmlContent.Url -> { + iframe.src = content.url + state.loadingState = HtmlLoadingState.Loading + } + + is HtmlContent.Data -> { + iframe.srcdoc = content.data + state.loadingState = HtmlLoadingState.Loading + addContentIdentifierJs(iframe) + } + + is HtmlContent.Post -> { + // POST requests not directly supported in iframe + } + + HtmlContent.NavigatorOnly -> { + // No content update needed + } + } + } + + iframe.style.border = "none" + iframe.style.width = "100%" + iframe.style.height = "100%" + iframe.style.overflowX = "auto" + iframe.style.overflowY = "auto" + iframe.style.backgroundColor = "white" + } + + onDispose { + root.removeChild(componentInfo.container) + componentInfo.updater.dispose() + element.value?.let { onDispose(it) } + state.htmlElement = null + state.loadingState = HtmlLoadingState.Initializing + } + } + + SideEffect { + if (element.value != null) { + componentInfo.updater.update(componentInfo.component) + } + } +} + +/** + * Helper class to manage component information + */ +class ComponentInfo { + lateinit var container: Element + lateinit var component: T + lateinit var updater: Updater +} + +/** + * Helper class to manage focus switching + */ +class FocusSwitcher( + private val info: ComponentInfo, + private val focusManager: FocusManager, +) { + private val backwardRequester = FocusRequester() + private val forwardRequester = FocusRequester() + private var isRequesting = false + + private fun moveBackward() { + try { + isRequesting = true + backwardRequester.requestFocus() + } finally { + isRequesting = false + } + focusManager.moveFocus(FocusDirection.Previous) + } + + private fun moveForward() { + try { + isRequesting = true + forwardRequester.requestFocus() + } finally { + isRequesting = false + } + focusManager.moveFocus(FocusDirection.Next) + } + + @Composable + fun Content() { + Box( + Modifier + .focusRequester(backwardRequester) + .onFocusChanged { + if (it.isFocused && !isRequesting) { + focusManager.clearFocus(force = true) + val component = info.container.firstElementChild + if (component != null) { + requestFocus(component) + } else { + moveForward() + } + } + }.focusTarget() + ) + Box( + Modifier + .focusRequester(forwardRequester) + .onFocusChanged { + if (it.isFocused && !isRequesting) { + focusManager.clearFocus(force = true) + + val component = info.container.lastElementChild + if (component != null) { + requestFocus(component) + } else { + moveBackward() + } + } + }.focusTarget() + ) + } +} + +/** + * A utility class for updating a component's view in response to state changes + */ +class Updater( + private val component: T, + update: (T) -> Unit +) { + private var isDisposed = false + + private val snapshotObserver = + SnapshotStateObserver { command -> + command() + } + + private val scheduleUpdate = { _: T -> + if (isDisposed.not()) { + performUpdate() + } + } + + var update: (T) -> Unit = update + set(value) { + if (field != value) { + field = value + performUpdate() + } + } + + private fun performUpdate() { + snapshotObserver.observeReads( + scope = component, + onValueChangedForScope = scheduleUpdate + ) { + update(component) + } + } + + init { + snapshotObserver.start() + performUpdate() + } + + fun dispose() { + snapshotObserver.stop() + snapshotObserver.clear() + isDisposed = true + } +} + +/** + * Composable for displaying a URL in an HtmlView + */ +@Composable +fun HtmlViewUrl( + url: String, + modifier: Modifier = Modifier, + headers: Map = emptyMap(), + navigator: HtmlViewNavigator = rememberHtmlViewNavigator(), +) { + val state = rememberHtmlViewState() + + LaunchedEffect(url, headers) { + state.content = HtmlContent.Url(url, headers) + } + + HtmlView( + state = state, + modifier = modifier, + navigator = navigator + ) +} + +/** + * Composable for displaying HTML content in an HtmlView + */ +@Composable +fun HtmlViewContent( + htmlContent: String, + modifier: Modifier = Modifier, + baseUrl: String? = null, + navigator: HtmlViewNavigator = rememberHtmlViewNavigator(), +) { + val state = rememberHtmlViewState() + + LaunchedEffect(htmlContent, baseUrl) { + state.content = HtmlContent.Data(htmlContent, baseUrl) + } + + HtmlView( + state = state, + modifier = modifier, + navigator = navigator, + onCreated = {}, + onDispose = {} + ) +} + +/** + * Create and remember an HtmlViewState instance + */ +@Composable +fun rememberHtmlViewState(): HtmlViewState = remember { HtmlViewState() } + +// Container for HTML elements +val LocalLayerContainer = staticCompositionLocalOf { + document.body!! +} diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/JsInterop.kt b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/JsInterop.kt new file mode 100644 index 0000000..bfe1648 --- /dev/null +++ b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/JsInterop.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) +package io.github.kdroidfilter.webview.web + +import org.w3c.dom.Element +import kotlin.js.JsAny + +/** + * Evaluate JavaScript in the iframe context + */ +fun evaluateScriptJs( + element: Element, + script: String, +): String = js( + //language=javascript + """{ + return element.contentWindow && element.contentWindow.eval ? String(element.contentWindow.eval(script)) : ''; + }""" +) + +/** + * Add a content identifier to an iframe for history tracking + */ +fun addContentIdentifierJs(iframe: Element) { + js( + //language=javascript + """{ + try { + if (iframe.contentWindow) { + const uniqueId = Math.random().toString(36).substring(2, 15); + iframe.contentWindow.history.replaceState( + { id: uniqueId }, + '', + iframe.contentWindow.location.href + ); + } + } catch (e) { + console.error("Error adding content identifier:", e); + } + }""" + ) +} + +/** + * Request focus on an element + */ +fun requestFocus(element: Element) { + js("element.focus()") +} + +/** + * Register a DOM listener without relying on Kotlin event casting. + */ +fun registerDomListener(target: JsAny?, type: String, callback: () -> Unit) { + js( + //language=javascript + """{ + if (target && typeof target.addEventListener === 'function') { + target.addEventListener(type, function () { + callback(); + }); + } + }""" + ) +} diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt new file mode 100644 index 0000000..c9bd375 --- /dev/null +++ b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt @@ -0,0 +1,241 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) +package io.github.kdroidfilter.webview.web + +import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge +import io.github.kdroidfilter.webview.util.KLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.w3c.dom.HTMLIFrameElement + +/** + * The native web view implementation for WasmJs platform. + * Uses an HTML iframe element as the underlying implementation. + */ +actual class NativeWebView( + val element: HTMLIFrameElement +) + +/** + * WebView adapter for WasmJs that implements the IWebView interface + */ +class WasmJsWebView( + private val element: HTMLIFrameElement, + override val nativeWebView: NativeWebView, + override val scope: CoroutineScope, + override val webViewJsBridge: WebViewJsBridge?, + var onLoadStarted: (() -> Unit)? = null, +) : IWebView { + override fun canGoBack(): Boolean = element.contentWindow?.history?.length?.let { + it > 1 + } ?: false + + // Browser iframe history API does not expose whether forward navigation is available. + // history.length only gives total entries, not the current position within the stack. + override fun canGoForward(): Boolean = false + + override fun loadUrl( + url: String, + additionalHttpHeaders: Map + ) { + try { + onLoadStarted?.invoke() + element.src = url + if (webViewJsBridge != null) { + scope.launch { + delay(500) + injectJsBridge() + } + } + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "WasmJsWebView" + ) { + "Error setting URL: $url" + } + } + } + + override suspend fun loadHtml( + html: String?, + baseUrl: String?, + mimeType: String?, + encoding: String?, + historyUrl: String? + ) { + try { + if (html != null) { + onLoadStarted?.invoke() + val htmlWithBridge = if (webViewJsBridge != null) { + injectBridgeIntoHtml( + htmlContent = html, + jsBridgeName = webViewJsBridge.jsBridgeName + ) + } else { + html + } + element.srcdoc = htmlWithBridge + } + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "WasmJsWebView" + ) { + "Error setting HTML: $html" + } + } + } + + override suspend fun loadHtmlFile( + fileName: String, + readType: WebViewFileReadType, + ) { + try { + val url = when (readType) { + WebViewFileReadType.ASSET_RESOURCES -> "assets/$fileName" + WebViewFileReadType.COMPOSE_RESOURCE_FILES -> fileName + } + onLoadStarted?.invoke() + element.src = url + + if (webViewJsBridge != null) { + scope.launch { + delay(1000) + injectJsBridge() + } + } + } catch (e: Exception) { + val fallbackHtml = + //language=HTML + """ + + + +

Failed to load file: $fileName

+

Error: ${e.message}

+ + + """.trimIndent() + loadHtml( + html = fallbackHtml, + mimeType = null, + encoding = null + ) + } + } + + override fun goBack() { + try { + element.contentWindow?.history?.back() + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "HtmlView" + ) { + "Failed to go back in history" + } + } + } + + override fun goForward() { + try { + element.contentWindow?.history?.forward() + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "HtmlView" + ) { + "Failed to go forward in history" + } + } + } + + override fun reload() { + try { + onLoadStarted?.invoke() + element.contentWindow?.location?.reload() + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "HtmlView" + ) { + "Failed to reload page" + } + } + } + + override fun stopLoading() { + try { + element.contentWindow?.stop() + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "HtmlView" + ) { + "Failed to stop loading" + } + } + } + + override fun evaluateJavaScript( + script: String, + callback: ((String) -> Unit)?, + ) { + scope.launch { + try { + val result = evaluateScriptJs(element, script) + callback?.invoke(result) + } catch (t: Throwable) { + callback?.invoke("Error: ${t.message}") + } + } + } + + override fun injectJsBridge() { + if (webViewJsBridge == null) return + super.injectJsBridge() + + val bridgeScript = createJsBridgeScript(webViewJsBridge.jsBridgeName, true) + evaluateJavaScript(bridgeScript) + // Message handling is done by the single listener registered in setupJsBridgeForWasm() + } + + override fun initJsBridge(webViewJsBridge: WebViewJsBridge) { + // Bridge initialization is handled externally + } + + /** + * Inject JS bridge script into an HTML content + */ + private fun injectBridgeIntoHtml( + htmlContent: String, + jsBridgeName: String, + ): String { + val bridgeScriptContent = createJsBridgeScript(jsBridgeName) + val bridgeScript = + """ + + """.trimIndent() + + if (htmlContent.contains("")) { + return htmlContent.replace("", "$bridgeScript") + } + + val headPattern = "]*>".toRegex() + val headMatch = headPattern.find(htmlContent) + if (headMatch != null) { + return htmlContent.replace(headMatch.value, "${headMatch.value}$bridgeScript") + } + + if (htmlContent.contains("") || htmlContent.contains("() + + var canGoBack by mutableStateOf(false) + internal set + + var canGoForward by mutableStateOf(false) + internal set + + /** + * Handle navigation events for the given HTML element + */ + internal suspend fun handleNavigationEvents(element: HTMLIFrameElement) { + navigationEvents.collect { event -> + when (event) { + is NavigationEvent.Back -> element.contentWindow?.history?.back() + is NavigationEvent.Forward -> element.contentWindow?.history?.forward() + is NavigationEvent.Reload -> element.contentWindow?.location?.reload() + is NavigationEvent.LoadUrl -> element.src = event.url + is NavigationEvent.LoadHtml -> element.srcdoc = event.data + is NavigationEvent.EvaluateJavaScript -> { + try { + val result = evaluateScriptJs(element, event.script) + event.callback?.invoke(result) + } catch (t: Throwable) { + event.callback?.invoke("Error: ${t.message}") + } + } + is NavigationEvent.StopLoading -> element.contentWindow?.stop() + } + updateNavigationState(element) + } + } + + fun navigateBack() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) } + } + + fun navigateForward() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) } + } + + fun reload() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) } + } + + fun stopLoading() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) } + } + + fun loadUrl( + url: String, + additionalHttpHeaders: Map = emptyMap(), + ) { + coroutineScope.launch { + navigationEvents.emit(NavigationEvent.LoadUrl(url, additionalHttpHeaders)) + } + } + + fun loadHtml( + data: String, + baseUrl: String? = null, + mimeType: String? = null, + encoding: String? = "utf-8", + historyUrl: String? = null, + ) { + coroutineScope.launch { + navigationEvents.emit( + NavigationEvent.LoadHtml( + data = data, + baseUrl = baseUrl, + mimeType = mimeType, + encoding = encoding, + historyUrl = historyUrl + ) + ) + } + } + + fun evaluateJavaScript( + script: String, + callback: ((String) -> Unit)? = null, + ) { + coroutineScope.launch { + navigationEvents.emit(NavigationEvent.EvaluateJavaScript(script, callback)) + } + } + + private fun updateNavigationState(element: HTMLIFrameElement) { + try { + canGoBack = element.contentWindow?.history?.length?.let { + it > 1 + } ?: false + // Browser iframe history API does not expose forward availability + canGoForward = false + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "HtmlViewNavigator" + ) { + "Error updating navigation state" + } + } + } + + private sealed class NavigationEvent { + data object Back : NavigationEvent() + + data object Forward : NavigationEvent() + + data object Reload : NavigationEvent() + + data object StopLoading : NavigationEvent() + + data class LoadUrl( + val url: String, + val additionalHttpHeaders: Map, + ) : NavigationEvent() + + data class LoadHtml( + val data: String, + val baseUrl: String?, + val mimeType: String?, + val encoding: String?, + val historyUrl: String?, + ) : NavigationEvent() + + data class EvaluateJavaScript( + val script: String, + val callback: ((String) -> Unit)?, + ) : NavigationEvent() + } +} + +/** + * Create and remember an HtmlViewNavigator instance + */ +@Composable +fun rememberHtmlViewNavigator(): HtmlViewNavigator { + val scope = rememberCoroutineScope() + return remember { HtmlViewNavigator(scope) } +} diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt new file mode 100644 index 0000000..f834fde --- /dev/null +++ b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt @@ -0,0 +1,307 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) +package io.github.kdroidfilter.webview.web + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge +import io.github.kdroidfilter.webview.jsbridge.parseJsMessage +import io.github.kdroidfilter.webview.setting.WebSettings +import io.github.kdroidfilter.webview.util.KLogger +import kotlinx.browser.document +import kotlinx.coroutines.launch +import org.w3c.dom.HTMLIFrameElement +import org.w3c.dom.MessageEvent +import org.w3c.dom.Node +import org.w3c.dom.events.Event + +/** + * Platform-specific parameters for the WebView factory in WebAssembly/JavaScript. + */ +actual class WebViewFactoryParam { + var container: Node = document.body?.shadowRoot ?: document.body!! + var existingElement: HTMLIFrameElement? = null +} + +/** + * Default factory function for creating a WebView on the WebAssembly/JavaScript platform. + */ +actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView { + val iframe = param.existingElement ?: document.createElement("iframe") as HTMLIFrameElement + + iframe.style.apply { + border = "none" + width = "100%" + height = "100%" + } + + return NativeWebView(iframe) +} + +/** + * Factory function for creating a WebView with WebSettings applied. + */ +fun createWebViewWithSettings( + param: WebViewFactoryParam, + settings: WebSettings, +): NativeWebView { + val iframe = param.existingElement ?: document.createElement("iframe") as HTMLIFrameElement + val wasmSettings = settings.wasmJSWebSettings + + iframe.style.apply { + width = "100%" + height = "100%" + + border = if (wasmSettings.showBorder) { + wasmSettings.borderStyle + } else { + "none" + } + + val bgColor = (wasmSettings.backgroundColor ?: settings.backgroundColor) + if (bgColor != androidx.compose.ui.graphics.Color.Transparent) { + backgroundColor = "#${bgColor.value.toString(16).padStart(8, '0').substring(2)}" + } + + wasmSettings.customContainerStyle?.let { customStyle -> + cssText += "; $customStyle" + } + } + + if (wasmSettings.enableSandbox) { + iframe.setAttribute( + qualifiedName = "sandbox", + value = wasmSettings.sandboxPermissions + ) + } + + if (wasmSettings.allowFullscreen) { + iframe.setAttribute( + qualifiedName = "allowfullscreen", + value = "true" + ) + } + + return NativeWebView(iframe) +} + +/** + * Implementation of the WebView composable for the WebAssembly/JavaScript platform. + */ +@Composable +actual fun ActualWebView( + state: WebViewState, + modifier: Modifier, + navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, + onCreated: (NativeWebView) -> Unit, + onDispose: (NativeWebView) -> Unit, + factory: (WebViewFactoryParam) -> NativeWebView +) { + val scope = rememberCoroutineScope() + val htmlNavigator = rememberHtmlViewNavigator() + val htmlViewState = remember { HtmlViewState() } + val bridgeCleanup = remember { mutableStateOf<(() -> Unit)?>(null) } + + // Reactively sync navigation state from htmlNavigator to navigator + LaunchedEffect(navigator, htmlNavigator) { + snapshotFlow { htmlNavigator.canGoBack to htmlNavigator.canGoForward } + .collect { (canGoBack, canGoForward) -> + navigator.canGoBack = canGoBack + navigator.canGoForward = canGoForward + } + } + + LaunchedEffect(state.content) { + when (state.content) { + is WebContent.Url -> { + htmlViewState.content = HtmlContent.Url( + (state.content as WebContent.Url).url, + (state.content as WebContent.Url).additionalHttpHeaders, + ) + } + + is WebContent.Data -> { + val data = (state.content as WebContent.Data).data + val htmlWithBridge = if (webViewJsBridge != null) { + injectJsBridgeToHtml(data, webViewJsBridge.jsBridgeName) + } else { + data + } + + htmlViewState.content = HtmlContent.Data( + data = htmlWithBridge, + baseUrl = (state.content as WebContent.Data).baseUrl + ) + } + + is WebContent.File -> { + val fileName = (state.content as WebContent.File).fileName + val fileReadType = (state.content as WebContent.File).readType + + val webView = state.webView + if (webView != null) { + webView.loadHtmlFile(fileName, fileReadType) + } else { + htmlViewState.loadingState = HtmlLoadingState.Loading + } + } + + is WebContent.NavigatorOnly -> { + // No action needed + } + } + } + + // Sync HtmlViewState → WebViewState using snapshotFlow to avoid missing intermediate states + LaunchedEffect(htmlViewState, state) { + snapshotFlow { + Triple(htmlViewState.lastLoadedUrl, htmlViewState.pageTitle, htmlViewState.loadingState) + }.collect { (lastLoadedUrl, pageTitle, loadingState) -> + lastLoadedUrl?.let { state.lastLoadedUrl = it } + pageTitle?.let { state.pageTitle = it } + + when (loadingState) { + is HtmlLoadingState.Loading -> { + // Simulate progress like desktop: iframe doesn't provide real progress, + // so we animate from 0.1 to 0.9 while loading + state.loadingState = LoadingState.Loading(0.1f) + scope.launch { + while (htmlViewState.loadingState is HtmlLoadingState.Loading) { + kotlinx.coroutines.delay(100) + val current = state.loadingState + if (current is LoadingState.Loading) { + state.loadingState = LoadingState.Loading( + (current.progress + 0.02f).coerceAtMost(0.9f) + ) + } + } + } + } + + is HtmlLoadingState.Finished -> { + state.loadingState = LoadingState.Finished + state.webView?.nativeWebView?.element?.let { element -> + try { + state.pageTitle = evaluateScriptJs( + element, + "document.title" + ) + state.lastLoadedUrl = evaluateScriptJs( + element, + "document.location" + ) + } catch (t: Throwable) { + KLogger.e( + t = t, + tag = "ActualWebView" + ) { + "Error getting document from iframe: ${t.message}" + } + } + } + } + + is HtmlLoadingState.Initializing -> state.loadingState = LoadingState.Initializing + } + } + } + + HtmlView( + state = htmlViewState, + modifier = modifier, + navigator = htmlNavigator, + onCreated = { element -> + val nativeWebView = if ( + state.webSettings.wasmJSWebSettings.let { + it.backgroundColor != null || + it.showBorder || + it.enableSandbox || + it.customContainerStyle != null + } + ) { + createWebViewWithSettings( + WebViewFactoryParam().apply { + existingElement = element + }, + state.webSettings + ) + } else { + factory( + WebViewFactoryParam().apply { + existingElement = element + } + ) + } + + val webViewWrapper = WasmJsWebView( + element = element, + nativeWebView = nativeWebView, + scope = scope, + webViewJsBridge = webViewJsBridge, + onLoadStarted = { htmlViewState.loadingState = HtmlLoadingState.Loading }, + ) + + state.webView = webViewWrapper + + if (webViewJsBridge != null) { + bridgeCleanup.value = setupJsBridgeForWasm(element, webViewJsBridge, webViewWrapper) + } + + if (state.content is WebContent.File) { + val fileName = (state.content as WebContent.File).fileName + val readType = (state.content as WebContent.File).readType + scope.launch { + webViewWrapper.loadHtmlFile(fileName, readType) + } + } + + onCreated(nativeWebView) + }, + onDispose = { element -> + bridgeCleanup.value?.invoke() + bridgeCleanup.value = null + state.webView?.let { + onDispose(NativeWebView(element)) + state.webView = null + } + } + ) +} + +/** + * Set up the JavaScript bridge for WasmJS platform. + * Returns a cleanup function that removes the message listener. + */ +private fun setupJsBridgeForWasm( + element: HTMLIFrameElement, + webViewJsBridge: WebViewJsBridge, + webViewWrapper: WasmJsWebView +): () -> Unit { + val messageHandler: (Event) -> Unit = { event -> + val messageEvent = event as MessageEvent + + if ( + messageEvent.source == element.contentWindow && + messageEvent.data != null + ) { + try { + parseJsMessage( + raw = messageEvent.data.toString(), + expectedType = webViewJsBridge.jsBridgeName, + )?.let(webViewJsBridge::dispatch) + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "WasmJsWebView" + ) { + "Error processing message: ${e.message}" + } + } + } + } + + kotlinx.browser.window.addEventListener("message", messageHandler) + webViewJsBridge.webView = webViewWrapper + + return { kotlinx.browser.window.removeEventListener("message", messageHandler) } +} diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewJsBridge.kt b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewJsBridge.kt new file mode 100644 index 0000000..04635a2 --- /dev/null +++ b/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewJsBridge.kt @@ -0,0 +1,124 @@ +package io.github.kdroidfilter.webview.web + +/** + * Creates JavaScript bridge code that can be used for communication between Kotlin and JavaScript + * @param jsBridgeName Name of the JavaScript bridge object in a browser window + * @param isIife Whether to wrap the code in an Immediately Invoked Function Expression + * @return JavaScript code for bridge implementation + */ +internal fun createJsBridgeScript( + jsBridgeName: String, + isIife: Boolean = false, +): String { + //language=JavaScript + val bridgeObjectCode = + """ + window.$jsBridgeName = { + _callbacks: {}, + _callbackId: 0, + + postMessage: function(methodName, params, callbackId) { + // Send as JSON string instead of object to ensure proper parsing + let messageData = JSON.stringify({ + type: '$jsBridgeName', + action: methodName, + params: params, + callbackId: callbackId || 0 + }); + parent.postMessage(messageData, '*'); + }, + + onCallback: function(callbackId, message) { + let callback = this._callbacks[callbackId]; + if (callback) { + callback(message); + delete this._callbacks[callbackId]; + } + }, + + call: function(action, params, callback) { + let callbackId = 0; + if (callback) { + callbackId = ++this._callbackId; + this._callbacks[callbackId] = callback; + } + this.postMessage(action, params, callbackId); + return callbackId; + }, + + // Standard API as per documentation + callNative: function(methodName, params, callback) { + return this.call(methodName, params, callback); + } + }; + + // Listen for callback messages from parent + window.addEventListener('message', function(event) { + try { + let data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + if (data && data.type === '$jsBridgeName') { + window.$jsBridgeName.onCallback(data.callbackId, data.message); + } + } catch (e) { + console.error('Error processing callback message:', e); + } + }); + """.trimIndent() + + return if (isIife) { + //language=JavaScript + """ + (function() { + $bridgeObjectCode + })(); + """.trimIndent() + } else { + bridgeObjectCode + } +} + +/** + * Helper function to inject JS bridge into HTML content + * + * @param htmlContent HTML content to inject the bridge into + * @param jsBridgeName Name of the bridge object in JavaScript + * @return HTML content with bridge injected + */ +fun injectJsBridgeToHtml( + htmlContent: String, + jsBridgeName: String, +): String { + // Only inject if it has a proper bridge implementation + // We look for specific bridge functions like callNative in the content + if (htmlContent.contains("window.$jsBridgeName") && + htmlContent.contains("$jsBridgeName.callNative") && + htmlContent.contains("$jsBridgeName._callbacks") + ) { + return htmlContent + } + + // Create a bridge initialization script wrapped in script tags + val bridgeScriptContent = createJsBridgeScript(jsBridgeName) + val bridgeScript = + //language=HTML + """ + + """.trimIndent() + + // Insert script before end of head tag + if (htmlContent.contains("")) { + return htmlContent.replace("", "$bridgeScript") + } + + // If no head tag, insert after opening body tag + if (htmlContent.contains("") || htmlContent.contains(" = emptyMap(), + ) : HtmlContent() + + /** HTML data content with optional parameters */ + data class Data( + val data: String, + val baseUrl: String? = null, + val mimeType: String? = null, + val encoding: String? = "utf-8", + val historyUrl: String? = null, + ) : HtmlContent() + + /** POST request content */ + data class Post( + private val url: String, + private val postData: ByteArray, + ) : HtmlContent() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Post + + if (url != other.url) return false + if (!postData.contentEquals(other.postData)) return false + + return true + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + postData.contentHashCode() + return result + } + } + + /** Navigation-only content (no rendering) */ + data object NavigatorOnly : HtmlContent() +} + +/** + * Loading states for HTML view + */ +sealed class HtmlLoadingState { + /** Initial state before any loading has started */ + data object Initializing : HtmlLoadingState() + + /** Content is currently being loaded */ + data object Loading : HtmlLoadingState() + + /** Loading has finished, with a success or error flag */ + data class Finished( + val isError: Boolean = false, + val errorMessage: String? = null, + ) : HtmlLoadingState() +} + +/** + * State class for HtmlView component + */ +class HtmlViewState { + /** Native HTML element (iframe) */ + var htmlElement: Any? by mutableStateOf(null) + internal set + + /** Content to be displayed */ + var content: HtmlContent by mutableStateOf(HtmlContent.NavigatorOnly) + internal set + + /** Current loading state */ + var loadingState: HtmlLoadingState by mutableStateOf(HtmlLoadingState.Initializing) + internal set + + /** Last URL that was successfully loaded */ + var lastLoadedUrl: String? by mutableStateOf(null) + internal set + + /** Title of the current page */ + var pageTitle: String? by mutableStateOf(null) + internal set + + /** Error that occurred during loading */ + var error: Throwable? by mutableStateOf(null) + internal set +}