diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 52f0a1a..d83307b 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -16,6 +16,13 @@ kotlin { implementation(libs.kotlinx.coroutinesSwing) implementation(project(":demo-shared")) } + + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(libs.compose.ui.test) + implementation(libs.compose.ui.test.junit4) + implementation(project(":webview-compose-test")) + } } } diff --git a/demo/src/jvmTest/kotlin/io/github/kdroidfilter/webview/demo/WebViewTest.kt b/demo/src/jvmTest/kotlin/io/github/kdroidfilter/webview/demo/WebViewTest.kt new file mode 100644 index 0000000..7d6fb5e --- /dev/null +++ b/demo/src/jvmTest/kotlin/io/github/kdroidfilter/webview/demo/WebViewTest.kt @@ -0,0 +1,144 @@ +@file:OptIn(ExperimentalTestApi::class) + +package io.github.kdroidfilter.webview.demo + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import io.github.kdroidfilter.webview.web.* +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +const val TEST_URL = "https://github.com/kdroidFilter/ComposeNativeWebview" + +class WebViewTest { + @Test + fun testWebViewInitialization() = runComposeUiTest { + setContent { + CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) { + val state = rememberWebViewState(TEST_URL) + val navigator = rememberWebViewNavigator() + WebView( + state = state, + navigator = navigator + ) + } + } + } + + @Test + fun testJavascriptInjection() = runComposeUiTest { + var mockWebView: PlaywrightWebView? by mutableStateOf(null) + var navigator: WebViewNavigator? = null + val factory = { param: WebViewFactoryParam -> + val webView = playwrightWebViewFactory(param) + mockWebView = webView as PlaywrightWebView + webView + } + + setContent { + val nav = rememberWebViewNavigator() + navigator = nav + CompositionLocalProvider(LocalWebViewFactory provides factory) { + val state = rememberWebViewState(TEST_URL) + WebView( + state = state, + navigator = nav + ) + } + } + + runOnIdle { + navigator?.evaluateJavaScript("alert('hello')") + } + + runOnIdle { + assertEquals( + expected = mockWebView?.evaluatedScripts?.contains("alert('hello')"), + actual = true, + message = "Script should have been evaluated" + ) + } + } + + @Test + fun testInitScriptInjection() = runComposeUiTest { + var capturedInitScript: String? = null + val factory = { param: WebViewFactoryParam -> + capturedInitScript = param.state.webSettings.desktopWebSettings.initScript + playwrightWebViewFactory(param) + } + setContent { + CompositionLocalProvider(LocalWebViewFactory provides factory) { + val state = rememberWebViewState(TEST_URL) { + desktopWebSettings.initScript = "window.test = true;" + } + WebView(state) + } + } + + runOnIdle { + assertEquals("window.test = true;", capturedInitScript) + } + } + + @Test + fun testWebViewScreenshot() = runComposeUiTest { + var state: WebViewState? = null + setContent { + CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) { + val s = rememberWebViewState(TEST_URL) + state = s + WebView( + state = s + ) + } + } + + runOnIdle { + val webView = state?.webView + assertNotNull(webView, "WebView should not be null") + val screenshot = runBlocking { webView.captureScreenshotOrNull() } + assertNotNull(screenshot, "Screenshot should not be null") + assertTrue(screenshot.isNotEmpty(), "Screenshot should not be empty") + + val awtImage = runBlocking { webView.toAwtImage() } + assertNotNull(awtImage, "AWT Image should not be null") + assertEquals(PLAYWRIGHT_PAGE_WIDTH, awtImage.width) + assertEquals(PLAYWRIGHT_PAGE_HEIGHT, awtImage.height) + } + } + + @Test + fun testWebViewPrintToString() = runComposeUiTest { + var state: WebViewState? = null + setContent { + CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) { + val s = rememberWebViewState(TEST_URL) + state = s + WebView( + state = s + ) + } + } + + runOnIdle { + val webView = state?.webView + assertNotNull(webView, "WebView should not be null") + val content = runBlocking { webView.printToStringOrNull() } + assertNotNull(content, "Content should not be null") + // GitHub page should contain some recognizable text + assertTrue( + actual = content.contains("github.com") || + content.contains("ComposeNativeWebview"), + message = "Content should contain recognizable text from the real page" + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65f19d3..b886a1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,17 +8,15 @@ composeMultiplatform = "1.10.0-rc02" gobley = "0.3.7" google-material = "1.13.0" jna = "5.18.1" -junit = "4.13.2" +playwright = "1.49.0" kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" -kotlinx-datetime = "0.7.1" kotlinx-serialization = "1.9.0" skiko = "0.9.37.3" [libraries] -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -junit = { module = "junit:junit", version.ref = "junit" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } +compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "composeMultiplatform" } google-material = { module = "com.google.android.material:material", version.ref = "google-material" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } @@ -26,10 +24,9 @@ androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } -kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } -jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } +playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" } skiko-awt = { module = "org.jetbrains.skiko:skiko-awt", version.ref = "skiko" } [plugins] diff --git a/settings.gradle.kts b/settings.gradle.kts index b99e3fb..d082e3f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,3 +38,4 @@ include(":demo-android") include(":demo-wasmJs") include(":wrywebview") include(":webview-compose") +include(":webview-compose-test") diff --git a/webview-compose-test/build.gradle.kts b/webview-compose-test/build.gradle.kts new file mode 100644 index 0000000..f5fcca6 --- /dev/null +++ b/webview-compose-test/build.gradle.kts @@ -0,0 +1,33 @@ +import com.vanniktech.maven.publish.KotlinMultiplatform + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.mavenPublish) +} + +kotlin { + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":webview-compose")) + } + + jvmMain.dependencies { + api(libs.playwright) + } + } +} + +mavenPublishing { + configure(KotlinMultiplatform(sourcesJar = true)) + publishToMavenCentral() + if (project.findProperty("signingInMemoryKey") != null) { + signAllPublications() + } + coordinates(artifactId = "composewebview-test") + pom { + name.set("ComposeWebView Testing") + description.set("Testing utilities for Compose Multiplatform WebView library") + } +} diff --git a/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt b/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt new file mode 100644 index 0000000..0dfd8bf --- /dev/null +++ b/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt @@ -0,0 +1,103 @@ +package io.github.kdroidfilter.webview.web + +import com.microsoft.playwright.Playwright +import io.github.kdroidfilter.webview.wry.Rgba +import io.github.kdroidfilter.webview.wry.WryWebViewPanel +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO + +const val PLAYWRIGHT_PAGE_WIDTH = 1024 +const val PLAYWRIGHT_PAGE_HEIGHT = 720 + +/** + * A mock implementation of [WryWebViewPanel] that uses Playwright for some operations. + * This is useful for testing without a real native WebView. + */ +class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel( + initialUrl = (param.state.content as? WebContent.Url)?.url ?: "about:blank", + backgroundColor = Rgba(0u.toUByte(), 0u.toUByte(), 0u.toUByte(), 0u.toUByte()) +) { + val evaluatedScripts = mutableListOf() + var currentContent: String = (param.state.content as? WebContent.Url)?.url ?: "about:blank" + + override fun evaluateJavaScript(script: String, callback: (String) -> Unit) { + evaluatedScripts.add(script) + if (script == "document.documentElement.outerHTML") { + if (currentContent.startsWith("http")) { + runCatching { + Playwright.create().use { playwright -> + playwright.chromium().launch().use { browser -> + browser.newPage().use { page -> + page.navigate(currentContent) + val html = page.content() + callback(html) + } + } + } + }.onFailure { + callback("Playwright failed: ${it.message}") + } + } else { + callback("Mock Content: $currentContent") + } + } else { + callback("true") + } + } + + override fun isReady(): Boolean = true + override fun isLoading(): Boolean = false + override fun getCurrentUrl(): String = currentContent + override fun getTitle(): String = "Playwright WebView" + + override fun loadUrl(url: String, additionalHttpHeaders: Map) { + currentContent = url + } + + override fun loadHtml(html: String) { + currentContent = "HTML content" + } + + override fun stopLoading() {} + override fun reload() {} + override fun goBack() {} + override fun goForward() {} + + override fun captureScreenshot(nativeBytes: ByteArray?): BufferedImage { + // Try to use Playwright for a real screenshot if it's a URL + if (currentContent.startsWith("http")) { + runCatching { + Playwright.create().use { playwright -> + playwright.chromium().launch().use { browser -> + browser.newPage().use { page -> + page.setViewportSize(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT) + page.navigate(currentContent) + val bytes = page.screenshot() + return ImageIO.read(ByteArrayInputStream(bytes)) + } + } + } + }.onFailure { + println("Playwright failed: ${it.message}. Falling back to mock.") + } + } + + val img = BufferedImage(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT, BufferedImage.TYPE_INT_ARGB) + val g = img.createGraphics() + // Fill background with a recognizable color (e.g., Light Gray) + g.color = Color.LIGHT_GRAY + g.fillRect(0, 0, PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT) + // Draw some "content" + g.color = Color.BLACK + g.drawString("Mock: $currentContent", 5, 50) + g.dispose() + return img + } +} + +/** + * A factory function that creates a [PlaywrightWebView]. + */ +fun playwrightWebViewFactory(param: WebViewFactoryParam): NativeWebView = PlaywrightWebView(param) diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt b/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt index 8e9a87a..985c7f0 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt +++ b/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt @@ -1,11 +1,15 @@ package io.github.kdroidfilter.webview.web +import android.graphics.Bitmap +import android.graphics.Bitmap.createBitmap +import android.graphics.Canvas import android.webkit.JavascriptInterface import android.webkit.WebView import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge import io.github.kdroidfilter.webview.jsbridge.parseJsMessage import io.github.kdroidfilter.webview.util.KLogger import kotlinx.coroutines.CoroutineScope +import java.io.ByteArrayOutputStream internal class AndroidWebView( override val nativeWebView: WebView, @@ -75,6 +79,21 @@ internal class AndroidWebView( override fun stopLoading() = nativeWebView.stopLoading() + override suspend fun captureScreenshotOrNull(): ByteArray? { + return runCatching { + val bitmap = createBitmap( + nativeWebView.width.coerceAtLeast(1), + nativeWebView.height.coerceAtLeast(1), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + nativeWebView.draw(canvas) + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + outputStream.toByteArray() + }.getOrNull() + } + override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { val androidScript = "javascript:$script" KLogger.d { diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt b/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt index aa0b43a..c33fe64 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt +++ b/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt @@ -3,25 +3,21 @@ package io.github.kdroidfilter.webview.web import android.content.Context import android.graphics.Bitmap import android.os.Build -import android.webkit.WebChromeClient -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient +import android.view.ViewGroup +import android.webkit.* +import android.widget.FrameLayout import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import android.view.ViewGroup -import android.widget.FrameLayout import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge import io.github.kdroidfilter.webview.request.WebRequest import io.github.kdroidfilter.webview.request.WebRequestInterceptResult import io.github.kdroidfilter.webview.setting.WebSettings -import io.github.kdroidfilter.webview.util.KLogger @Composable actual fun ActualWebView( @@ -60,57 +56,77 @@ private fun AndroidWebViewContainer( onDispose: (WebView) -> Unit, factory: (Context) -> WebView, ) { - BoxWithConstraints(modifier) { - val width = - if (constraints.hasFixedWidth) { + if (LocalWebViewFactory.current != null) { + val context = LocalContext.current + BoxWithConstraints(modifier) { + val scope = rememberCoroutineScope() + // Still need to create the native webview to satisfy onCreated and state.webView + // But don't show it. Note: WebView might need a real context. + // In tests, the context from the factory should be fine. + remember(state) { + factory(context).apply { + onCreated(this) + val androidWebView = AndroidWebView( + nativeWebView = this, + scope = scope, + webViewJsBridge = webViewJsBridge + ) + state.webView = androidWebView + webViewJsBridge?.webView = androidWebView + } + } + } + } else { + BoxWithConstraints(modifier) { + val width = if (constraints.hasFixedWidth) { ViewGroup.LayoutParams.MATCH_PARENT } else { ViewGroup.LayoutParams.WRAP_CONTENT } - val height = - if (constraints.hasFixedHeight) { + val height = if (constraints.hasFixedHeight) { ViewGroup.LayoutParams.MATCH_PARENT } else { ViewGroup.LayoutParams.WRAP_CONTENT } - val layoutParams = FrameLayout.LayoutParams(width, height) - val client = remember { AndroidWebViewClient(state, navigator) } - val chromeClient = remember { AndroidWebChromeClient(state, navigator) } - val scope = rememberCoroutineScope() + val layoutParams = FrameLayout.LayoutParams(width, height) + val client = remember { AndroidWebViewClient(state, navigator) } + val chromeClient = remember { AndroidWebChromeClient(state, navigator) } + val scope = rememberCoroutineScope() - AndroidView( - factory = { context -> - factory(context).apply { - onCreated(this) + AndroidView( + factory = { context -> + factory(context).apply { + onCreated(this) - this.layoutParams = layoutParams - this.webViewClient = client - this.webChromeClient = chromeClient + this.layoutParams = layoutParams + this.webViewClient = client + this.webChromeClient = chromeClient - configureSettings(this, state.webSettings) - setBackgroundColor(state.webSettings.backgroundColor.toArgb()) + configureSettings(this, state.webSettings) + setBackgroundColor(state.webSettings.backgroundColor.toArgb()) - val androidWebView = AndroidWebView(this, scope, webViewJsBridge) - state.webView = androidWebView - webViewJsBridge?.webView = androidWebView - } - }, - modifier = Modifier, - update = { webView -> - webView.layoutParams = layoutParams - configureSettings(webView, state.webSettings) - webView.setBackgroundColor(state.webSettings.backgroundColor.toArgb()) - }, - onRelease = { webView -> - state.webView = null - webViewJsBridge?.webView = null - webView.stopLoading() - webView.webChromeClient = null - webView.destroy() - onDispose(webView) - }, - ) + val androidWebView = AndroidWebView(this, scope, webViewJsBridge) + state.webView = androidWebView + webViewJsBridge?.webView = androidWebView + } + }, + modifier = Modifier, + update = { webView -> + webView.layoutParams = layoutParams + configureSettings(webView, state.webSettings) + webView.setBackgroundColor(state.webSettings.backgroundColor.toArgb()) + }, + onRelease = { webView -> + state.webView = null + webViewJsBridge?.webView = null + webView.stopLoading() + webView.webChromeClient = null + webView.destroy() + onDispose(webView) + }, + ) + } } } diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt index efb4ecb..3aaf756 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt @@ -2,6 +2,8 @@ package io.github.kdroidfilter.webview.web import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume expect class NativeWebView @@ -50,6 +52,23 @@ interface IWebView { callback: ((String) -> Unit)? = null ) + /** + * Captures a screenshot of the WebView. + * Returns a [ByteArray] containing the image data (typically PNG), or null if failed. + */ + suspend fun captureScreenshotOrNull(): ByteArray? + + /** + * Returns the HTML content of the WebView as a string. + */ + suspend fun printToStringOrNull(): String? { + return suspendCancellableCoroutine { continuation -> + evaluateJavaScript("document.documentElement.outerHTML") { result -> + continuation.resume(result) + } + } + } + suspend fun loadContent(content: WebContent) { when (content) { is WebContent.Url -> loadUrl(content.url, content.additionalHttpHeaders) diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt index 44cb0c9..7168092 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt @@ -1,32 +1,13 @@ package io.github.kdroidfilter.webview.web -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.merge -@Composable -fun WebView( - state: WebViewState, - modifier: Modifier = Modifier, - navigator: WebViewNavigator = rememberWebViewNavigator(), - webViewJsBridge: WebViewJsBridge? = null, - onCreated: () -> Unit = {}, - onDispose: () -> Unit = {}, -) { - WebView( - state = state, - modifier = modifier, - navigator = navigator, - webViewJsBridge = webViewJsBridge, - onCreated = { _ -> onCreated() }, - onDispose = { _ -> onDispose() }, - ) -} +val LocalWebViewFactory = staticCompositionLocalOf<((WebViewFactoryParam) -> NativeWebView)?> { null } @Composable fun WebView( @@ -35,9 +16,10 @@ fun WebView( navigator: WebViewNavigator = rememberWebViewNavigator(), webViewJsBridge: WebViewJsBridge? = null, onCreated: (NativeWebView) -> Unit = {}, - onDispose: (NativeWebView) -> Unit = {}, - factory: ((WebViewFactoryParam) -> NativeWebView)? = null, + onDispose: (NativeWebView) -> Unit = {} ) { + val factory = LocalWebViewFactory.current ?: ::defaultWebViewFactory + val webView = state.webView webView?.let { wv -> @@ -56,7 +38,7 @@ fun WebView( if (webViewJsBridge != null) { LaunchedEffect(wv, state) { val loadingStateFlow = - snapshotFlow { state.loadingState }.filter { it is LoadingState.Finished } + snapshotFlow { state.loadingState }.filterIsInstance() val lastLoadedUrlFlow = snapshotFlow { state.lastLoadedUrl }.filter { !it.isNullOrEmpty() } @@ -76,7 +58,7 @@ fun WebView( webViewJsBridge = webViewJsBridge, onCreated = onCreated, onDispose = onDispose, - factory = factory ?: ::defaultWebViewFactory, + factory = factory ) DisposableEffect(Unit) { @@ -98,4 +80,3 @@ expect fun ActualWebView( onDispose: (NativeWebView) -> Unit = {}, factory: (WebViewFactoryParam) -> NativeWebView = ::defaultWebViewFactory, ) - diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt index d613e1a..dbfbc5c 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt +++ b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt @@ -41,18 +41,9 @@ fun rememberWebViewState( extraSettings: WebSettings.() -> Unit = {}, ): WebViewState = remember { - WebViewState( - WebContent.Url( - url = url, - additionalHttpHeaders = additionalHttpHeaders, - ), - ) + WebViewState(WebContent.Url(url, additionalHttpHeaders)) }.apply { - this.content = - WebContent.Url( - url = url, - additionalHttpHeaders = additionalHttpHeaders, - ) + this.content = WebContent.Url(url, additionalHttpHeaders) extraSettings(this.webSettings) } diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt b/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt index bb61977..509e5ca 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt +++ b/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt @@ -3,10 +3,18 @@ package io.github.kdroidfilter.webview.web import io.github.kdroidfilter.webview.jsbridge.WKJsMessageHandler import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge import io.github.kdroidfilter.webview.util.KLogger +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine import platform.Foundation.* +import platform.UIKit.UIImagePNGRepresentation +import platform.WebKit.WKSnapshotConfiguration import platform.WebKit.WKWebView +import platform.posix.memcpy +import kotlin.coroutines.resume internal const val IOS_JS_BRIDGE_HANDLER_NAME: String = "iosJsBridge" @@ -177,6 +185,34 @@ internal class IOSWebView( nativeWebView.stopLoading() } + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + override suspend fun captureScreenshotOrNull(): ByteArray? { + return suspendCancellableCoroutine { continuation -> + val configuration = WKSnapshotConfiguration() + nativeWebView.takeSnapshotWithConfiguration(configuration) { image, error -> + if (error != null) { + KLogger.e { "captureScreenshot error: $error" } + continuation.resume(null) + return@takeSnapshotWithConfiguration + } + if (image != null) { + val nsData = UIImagePNGRepresentation(image) + if (nsData == null) { + continuation.resume(null) + return@takeSnapshotWithConfiguration + } + val bytes = ByteArray(nsData.length.toInt()) + bytes.usePinned { pinned -> + memcpy(pinned.addressOf(0), nsData.bytes, nsData.length) + } + continuation.resume(bytes) + } else { + continuation.resume(null) + } + } + } + } + @OptIn(ExperimentalForeignApi::class) override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { nativeWebView.evaluateJavaScript(script) { result, error -> diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt b/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt index f2f893c..708e783 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt +++ b/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt @@ -1,5 +1,6 @@ package io.github.kdroidfilter.webview.web +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -40,74 +41,90 @@ actual fun ActualWebView( val navigationDelegate = remember { WKNavigationDelegate(state, navigator) } val scope = rememberCoroutineScope() - UIKitView( - factory = { - val config = - WKWebViewConfiguration().apply { - defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled - preferences.apply { + if (LocalWebViewFactory.current != null) { + Box(modifier) { + val scope = rememberCoroutineScope() + remember(state) { + val config = WKWebViewConfiguration() + factory(WebViewFactoryParam(config)).apply { + onCreated(this) + val iosWebView = IOSWebView(this, scope, webViewJsBridge) + state.webView = iosWebView + webViewJsBridge?.webView = iosWebView + } + } + } + } else { + UIKitView( + factory = { + val config = + WKWebViewConfiguration().apply { + defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled + preferences.apply { + setValue( + state.webSettings.allowFileAccessFromFileURLs, + forKey = "allowFileAccessFromFileURLs", + ) + javaScriptEnabled = state.webSettings.isJavaScriptEnabled + } setValue( - state.webSettings.allowFileAccessFromFileURLs, - forKey = "allowFileAccessFromFileURLs", + value = state.webSettings.allowUniversalAccessFromFileURLs, + forKey = "allowUniversalAccessFromFileURLs", ) - javaScriptEnabled = state.webSettings.isJavaScriptEnabled } + + factory(WebViewFactoryParam(config)).apply { + onCreated(this) + + customUserAgent = state.webSettings.customUserAgentString + + addProgressObservers(observer) + this.navigationDelegate = navigationDelegate + + applyIOSSettings(this, state.webSettings) + }.also { wkWebView -> + val iosWebView = IOSWebView(wkWebView, scope, webViewJsBridge) + state.webView = iosWebView + webViewJsBridge?.webView = iosWebView + } + }, + modifier = modifier, + update = { wkWebView -> + wkWebView.customUserAgent = state.webSettings.customUserAgentString + + wkWebView.configuration.defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled + wkWebView.configuration.preferences.apply { setValue( - value = state.webSettings.allowUniversalAccessFromFileURLs, - forKey = "allowUniversalAccessFromFileURLs", + state.webSettings.allowFileAccessFromFileURLs, + forKey = "allowFileAccessFromFileURLs", ) + javaScriptEnabled = state.webSettings.isJavaScriptEnabled } + wkWebView.configuration.setValue( + value = state.webSettings.allowUniversalAccessFromFileURLs, + forKey = "allowUniversalAccessFromFileURLs", + ) - factory(WebViewFactoryParam(config)).apply { - onCreated(this) - - customUserAgent = state.webSettings.customUserAgentString - - addProgressObservers(observer) - this.navigationDelegate = navigationDelegate + applyIOSSettings(wkWebView, state.webSettings) + }, + onRelease = { wkWebView -> + state.webView = null + webViewJsBridge?.webView = null - applyIOSSettings(this, state.webSettings) - }.also { wkWebView -> - val iosWebView = IOSWebView(wkWebView, scope, webViewJsBridge) - state.webView = iosWebView - webViewJsBridge?.webView = iosWebView - } - }, - modifier = modifier, - update = { wkWebView -> - wkWebView.customUserAgent = state.webSettings.customUserAgentString - - wkWebView.configuration.defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled - wkWebView.configuration.preferences.apply { - setValue( - state.webSettings.allowFileAccessFromFileURLs, - forKey = "allowFileAccessFromFileURLs", + wkWebView.removeProgressObservers(observer) + wkWebView.configuration.userContentController.removeScriptMessageHandlerForName( + IOS_JS_BRIDGE_HANDLER_NAME ) - javaScriptEnabled = state.webSettings.isJavaScriptEnabled - } - wkWebView.configuration.setValue( - value = state.webSettings.allowUniversalAccessFromFileURLs, - forKey = "allowUniversalAccessFromFileURLs", - ) - - applyIOSSettings(wkWebView, state.webSettings) - }, - onRelease = { wkWebView -> - state.webView = null - webViewJsBridge?.webView = null - - wkWebView.removeProgressObservers(observer) - wkWebView.configuration.userContentController.removeScriptMessageHandlerForName(IOS_JS_BRIDGE_HANDLER_NAME) - wkWebView.navigationDelegate = null - - onDispose(wkWebView) - }, - properties = - UIKitInteropProperties( + wkWebView.navigationDelegate = null + + onDispose(wkWebView) + }, + properties = UIKitInteropProperties( interactionMode = UIKitInteropInteractionMode.NonCooperative, isNativeAccessibilityEnabled = true, ), - ) + ) + } } actual data class WebViewFactoryParam( 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 26b070c..294eb56 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 @@ -3,7 +3,9 @@ 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 java.io.ByteArrayOutputStream import java.net.URL +import javax.imageio.ImageIO internal class DesktopWebView( override val nativeWebView: NativeWebView, @@ -105,6 +107,18 @@ internal class DesktopWebView( } } + override suspend fun captureScreenshotOrNull(): ByteArray? { + val nativeBytes = nativeWebView.captureScreenshotNative() + if (nativeBytes != null) return nativeBytes + + return runCatching { + val image = nativeWebView.captureScreenshot(null) + val outputStream = ByteArrayOutputStream() + ImageIO.write(image, "png", outputStream) + outputStream.toByteArray() + }.getOrNull() + } + override fun injectJsBridge() { val bridge = webViewJsBridge ?: return super.injectJsBridge() 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 32fe6de..e7f23b4 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 @@ -1,5 +1,6 @@ package io.github.kdroidfilter.webview.web +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel @@ -11,7 +12,13 @@ import io.github.kdroidfilter.webview.jsbridge.parseJsMessage import io.github.kdroidfilter.webview.request.WebRequest import io.github.kdroidfilter.webview.request.WebRequestInterceptResult import io.github.kdroidfilter.webview.wry.Rgba +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import kotlin.time.Duration.Companion.milliseconds actual class WebViewFactoryParam( val state: WebViewState, @@ -79,7 +86,7 @@ actual fun ActualWebView( LaunchedEffect(desiredSettingsKey) { if (desiredSettingsKey != effectiveSettingsKey) { - delay(400) + delay(400.milliseconds) effectiveSettingsKey = desiredSettingsKey } } @@ -103,21 +110,21 @@ actual fun ActualWebView( (state.cookieManager as? WryCookieManager)?.attach(nativeWebView) } - // Poll native state (URL/loading/title/nav) and drain IPC messages for JS bridge. - listOf(nativeWebView, state, navigator, webViewJsBridge).let { - LaunchedEffect(it) { - while (true) { - if (!nativeWebView.isReady()) { - if (state.loadingState !is LoadingState.Initializing) { - state.loadingState = LoadingState.Initializing - } - delay(50) - continue + // Poll native state (URL/loading/title/nav) and drain IPC messages for JS bridge. + listOf(nativeWebView, state, navigator, webViewJsBridge).let { + LaunchedEffect(it) { + while (true) { + if (!nativeWebView.isReady()) { + if (state.loadingState !is LoadingState.Initializing) { + state.loadingState = LoadingState.Initializing } + delay(50.milliseconds) + continue + } - val isLoading = nativeWebView.isLoading() - state.loadingState = - if (isLoading) { + val isLoading = nativeWebView.isLoading() + state.loadingState = + if (isLoading) { val next = when (val current = state.loadingState) { is LoadingState.Loading -> (current.progress + 0.02f).coerceAtMost(0.9f) @@ -143,7 +150,7 @@ actual fun ActualWebView( navigator.canGoBack = nativeWebView.canGoBack() navigator.canGoForward = nativeWebView.canGoForward() - delay(250) + delay(250.milliseconds) } } @@ -154,7 +161,7 @@ actual fun ActualWebView( parseJsMessage(raw)?.let { webViewJsBridge.dispatch(it) } } } - delay(50) + delay(50.milliseconds) } } } @@ -193,13 +200,21 @@ actual fun ActualWebView( } } - SwingPanel( - modifier = modifier, - factory = { - onCreated(nativeWebView) - nativeWebView - }, - ) + if (LocalWebViewFactory.current != null) { + Box(modifier) { + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + } + } else { + SwingPanel( + modifier = modifier, + factory = { + onCreated(nativeWebView) + nativeWebView + } + ) + } DisposableEffect(nativeWebView) { onDispose { @@ -219,3 +234,13 @@ private fun Color.toRgba(): Rgba { val b: UByte = (argb and 0xFF).toUByte() return Rgba(r = r, g = g, b = b, a = a) } + +/** + * Captures a screenshot of the WebView and returns it as a [BufferedImage]. + */ +suspend fun IWebView.toAwtImage(): BufferedImage? { + val bytes = captureScreenshotOrNull() ?: return null + return withContext(Dispatchers.IO) { + ImageIO.read(ByteArrayInputStream(bytes)) + } +} 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 index bfe1648..5ea67cb 100644 --- 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 @@ -2,7 +2,7 @@ package io.github.kdroidfilter.webview.web import org.w3c.dom.Element -import kotlin.js.JsAny +import kotlin.js.Promise /** * Evaluate JavaScript in the iframe context @@ -62,3 +62,36 @@ fun registerDomListener(target: JsAny?, type: String, callback: () -> Unit) { }""" ) } + +/** + * Capture screenshot of the iframe using html2canvas if available + */ +fun captureScreenshotJs( + element: Element +): Promise = js( + //language=javascript + """ + (async () => { + try { + if (!window.html2canvas) { + console.warn("html2canvas not found. captureScreenshot requires html2canvas library."); + return null; + } + const canvas = await window.html2canvas(element); + return new Promise(resolve => { + canvas.toBlob(async (blob) => { + if (!blob) { + resolve(null); + return; + } + const buffer = await blob.arrayBuffer(); + resolve(new Uint8Array(buffer)); + }, 'image/png'); + }); + } catch (e) { + console.error("Screenshot capture failed:", e); + return null; + } + })() + """ +) 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 index c9bd375..e914e0f 100644 --- 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 @@ -4,9 +4,14 @@ 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.await import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.get import org.w3c.dom.HTMLIFrameElement +import kotlin.js.Promise +import kotlin.time.Duration.Companion.milliseconds /** * The native web view implementation for WasmJs platform. @@ -43,7 +48,7 @@ class WasmJsWebView( element.src = url if (webViewJsBridge != null) { scope.launch { - delay(500) + delay(500.milliseconds) injectJsBridge() } } @@ -178,6 +183,28 @@ class WasmJsWebView( } } + /** + * Captures a screenshot of the current WebView content. + * Note: On WasmJs, this requires the 'html2canvas' library to be available globally. + */ + override suspend fun captureScreenshotOrNull(): ByteArray? { + return try { + val promise: Promise = captureScreenshotJs(element) + val uint8Array = promise.await() as? Uint8Array + uint8Array?.let { array -> + ByteArray(array.length) { array[it] } + } + } catch (e: Exception) { + KLogger.e( + t = e, + tag = "WasmJsWebView" + ) { + "Failed to capture screenshot" + } + null + } + } + override fun evaluateJavaScript( script: String, callback: ((String) -> Unit)?, 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 index f834fde..0a2e633 100644 --- 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 @@ -1,6 +1,7 @@ @file:OptIn(ExperimentalWasmJsInterop::class) package io.github.kdroidfilter.webview.web +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.* import androidx.compose.ui.Modifier import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge @@ -206,66 +207,92 @@ actual fun ActualWebView( } } - 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( + if (LocalWebViewFactory.current != null) { + Box(modifier) { + val scope = rememberCoroutineScope() + remember(state) { + // Create a dummy iframe for testing + val element = document.createElement("iframe") as HTMLIFrameElement + val nativeWebView = factory( WebViewFactoryParam().apply { existingElement = element } ) + + val webViewWrapper = WasmJsWebView( + element = element, + nativeWebView = nativeWebView, + scope = scope, + webViewJsBridge = webViewJsBridge, + onLoadStarted = { htmlViewState.loadingState = HtmlLoadingState.Loading }, + ) + + state.webView = webViewWrapper + onCreated(nativeWebView) } + } + } else { + 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 }, - ) + val webViewWrapper = WasmJsWebView( + element = element, + nativeWebView = nativeWebView, + scope = scope, + webViewJsBridge = webViewJsBridge, + onLoadStarted = { htmlViewState.loadingState = HtmlLoadingState.Loading }, + ) - state.webView = webViewWrapper + state.webView = webViewWrapper - if (webViewJsBridge != null) { - bridgeCleanup.value = setupJsBridgeForWasm(element, webViewJsBridge, 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) + 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 + onCreated(nativeWebView) + }, + onDispose = { element -> + bridgeCleanup.value?.invoke() + bridgeCleanup.value = null + state.webView?.let { + onDispose(NativeWebView(element)) + state.webView = null + } } - } - ) + ) + } } /** diff --git a/wrywebview/src/main/java/io/github/kdroidfilter/webview/wry/SkikoInterop.java b/wrywebview/src/main/java/io/github/kdroidfilter/webview/wry/SkikoInterop.java index 6b478e9..0828d40 100644 --- a/wrywebview/src/main/java/io/github/kdroidfilter/webview/wry/SkikoInterop.java +++ b/wrywebview/src/main/java/io/github/kdroidfilter/webview/wry/SkikoInterop.java @@ -1,17 +1,21 @@ package io.github.kdroidfilter.webview.wry; -import java.awt.Canvas; -import java.awt.Component; import org.jetbrains.skiko.HardwareLayer; +import java.awt.*; + final class SkikoInterop { private SkikoInterop() {} static Canvas createHost() { - if (isWindows()) { + try { + if (isWindows()) { + return new Canvas(); + } + return new HardwareLayer(); + } catch (Throwable e) { return new Canvas(); } - return new HardwareLayer(); } private static boolean isWindows() { diff --git a/wrywebview/src/main/kotlin/io/github/kdroidfilter/webview/wry/WryWebViewPanel.kt b/wrywebview/src/main/kotlin/io/github/kdroidfilter/webview/wry/WryWebViewPanel.kt index b4006d5..792ed3a 100644 --- a/wrywebview/src/main/kotlin/io/github/kdroidfilter/webview/wry/WryWebViewPanel.kt +++ b/wrywebview/src/main/kotlin/io/github/kdroidfilter/webview/wry/WryWebViewPanel.kt @@ -5,13 +5,15 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.imageio.ImageIO import javax.swing.JPanel import javax.swing.SwingUtilities import javax.swing.Timer import kotlin.concurrent.thread -class WryWebViewPanel( +open class WryWebViewPanel( initialUrl: String, customUserAgent: String? = null, dataDirectory: String? = null, @@ -91,19 +93,19 @@ class WryWebViewPanel( scheduleCreateIfNeeded() } - fun addNavigateListener(data: (String) -> Boolean) { + open fun addNavigateListener(data: (String) -> Boolean) { handlers.add(data) } - fun removeNavigateListener(data: (String) -> Boolean) { + open fun removeNavigateListener(data: (String) -> Boolean) { handlers.remove(data) } - fun loadUrl(url: String) { + open fun loadUrl(url: String) { loadUrl(url, emptyMap()) } - fun loadUrl(url: String, additionalHttpHeaders: Map) { + open fun loadUrl(url: String, additionalHttpHeaders: Map) { pendingUrl = url pendingHtml = null pendingHeaders = additionalHttpHeaders @@ -134,7 +136,7 @@ class WryWebViewPanel( log("loadUrl url=$url headers=${additionalHttpHeaders.size} webviewId=$webviewId") } - fun loadHtml(html: String) { + open fun loadHtml(html: String) { pendingHtml = html pendingUrl = "about:blank" pendingHeaders = emptyMap() @@ -150,7 +152,7 @@ class WryWebViewPanel( log("loadHtml bytes=${html.length} webviewId=$webviewId") } - fun goBack() { + open fun goBack() { val action = { webviewId?.let { NativeBindings.goBack(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -160,7 +162,7 @@ class WryWebViewPanel( log("goBack webviewId=$webviewId") } - fun goForward() { + open fun goForward() { val action = { webviewId?.let { NativeBindings.goForward(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -170,7 +172,7 @@ class WryWebViewPanel( log("goForward webviewId=$webviewId") } - fun reload() { + open fun reload() { val action = { webviewId?.let { NativeBindings.reload(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -180,7 +182,7 @@ class WryWebViewPanel( log("reload webviewId=$webviewId") } - fun stopLoading() { + open fun stopLoading() { val action = { webviewId?.let { NativeBindings.stopLoading(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -190,7 +192,7 @@ class WryWebViewPanel( log("stopLoading webviewId=$webviewId") } - fun evaluateJavaScript(script: String, callback: (String) -> Unit) { + open fun evaluateJavaScript(script: String, callback: (String) -> Unit) { val id = webviewId ?: run { callback("") return @@ -208,7 +210,7 @@ class WryWebViewPanel( } } - fun getCurrentUrl(): String? { + open fun getCurrentUrl(): String? { return webviewId?.let { try { NativeBindings.getUrl(it) @@ -219,7 +221,7 @@ class WryWebViewPanel( } } - fun isLoading(): Boolean { + open fun isLoading(): Boolean { return webviewId?.let { try { NativeBindings.isLoading(it) @@ -230,7 +232,7 @@ class WryWebViewPanel( } ?: true } - fun getTitle(): String? { + open fun getTitle(): String? { return webviewId?.let { try { NativeBindings.getTitle(it) @@ -241,7 +243,7 @@ class WryWebViewPanel( } } - fun canGoBack(): Boolean { + open fun canGoBack(): Boolean { return webviewId?.let { try { NativeBindings.canGoBack(it) @@ -252,7 +254,7 @@ class WryWebViewPanel( } ?: false } - fun canGoForward(): Boolean { + open fun canGoForward(): Boolean { return webviewId?.let { try { NativeBindings.canGoForward(it) @@ -263,7 +265,7 @@ class WryWebViewPanel( } ?: false } - fun drainIpcMessages(): List { + open fun drainIpcMessages(): List { return webviewId?.let { try { NativeBindings.drainIpcMessages(it) @@ -274,7 +276,7 @@ class WryWebViewPanel( } ?: emptyList() } - fun getCookiesForUrl(url: String): List { + open fun getCookiesForUrl(url: String): List { var result: List = emptyList() val id = webviewId ?: run { log("getCookiesForUrl webviewId is null") @@ -293,7 +295,7 @@ class WryWebViewPanel( return result } - fun getCookies(): List { + open fun getCookies(): List { var result: List = emptyList() val id = webviewId ?: run { log("getCookies webviewId is null") @@ -312,7 +314,7 @@ class WryWebViewPanel( return result } - fun clearCookiesForUrl(url: String) { + open fun clearCookiesForUrl(url: String) { val action = { webviewId?.let { NativeBindings.clearCookiesForUrl(it, url) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -321,7 +323,7 @@ class WryWebViewPanel( } } - fun clearAllCookies() { + open fun clearAllCookies() { val action = { webviewId?.let { NativeBindings.clearAllCookies(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -330,7 +332,7 @@ class WryWebViewPanel( } } - fun setCookie(cookie: WebViewCookie) { + open fun setCookie(cookie: WebViewCookie) { val action = { webviewId?.let { NativeBindings.setCookie(it, cookie) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -339,10 +341,43 @@ class WryWebViewPanel( } } - fun isReady(): Boolean = webviewId != null + open fun isReady(): Boolean = webviewId != null - fun requestWebViewFocus() { - val action = { webviewId?.let { NativeBindings.focus(it) } } + open fun captureScreenshot(): BufferedImage = captureScreenshot(captureScreenshotNative()) + + open fun captureScreenshot(nativeBytes: ByteArray?): BufferedImage { + nativeBytes?.let { bytes -> + try { + return ImageIO.read(java.io.ByteArrayInputStream(bytes)) + } catch (e: Exception) { + log("Failed to parse native screenshot: ${e.message}") + } + } + val img = BufferedImage( + width.coerceAtLeast(1), + height.coerceAtLeast(1), + BufferedImage.TYPE_INT_ARGB + ) + val g = img.createGraphics() + paint(g) + g.dispose() + return img + } + + open fun captureScreenshotNative(): ByteArray? { + val id = webviewId ?: return null + return try { + NativeBindings.captureScreenshot(id) + } catch (e: Exception) { + log("captureScreenshotNative failed: ${e.message}") + null + } + } + + open fun requestWebViewFocus() { + val action = { + webviewId?.let { NativeBindings.focus(it) } + } if (SwingUtilities.isEventDispatchThread()) { action() } else { @@ -351,7 +386,7 @@ class WryWebViewPanel( log("requestWebViewFocus webviewId=$webviewId") } - fun openDevTools() { + open fun openDevTools() { val action = { webviewId?.let { NativeBindings.openDevTools(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -361,7 +396,7 @@ class WryWebViewPanel( log("openDevTools webviewId=$webviewId") } - fun closeDevTools() { + open fun closeDevTools() { val action = { webviewId?.let { NativeBindings.closeDevTools(it) } } if (SwingUtilities.isEventDispatchThread()) { action() @@ -935,4 +970,8 @@ private object NativeBindings { fun closeDevTools(id: ULong) { io.github.kdroidfilter.webview.wry.closeDevTools(id) } + + fun captureScreenshot(id: ULong): ByteArray { + return io.github.kdroidfilter.webview.wry.captureScreenshot(id) + } } diff --git a/wrywebview/src/main/rust/lib.rs b/wrywebview/src/main/rust/lib.rs index 62a74f5..ee2d573 100644 --- a/wrywebview/src/main/rust/lib.rs +++ b/wrywebview/src/main/rust/lib.rs @@ -355,51 +355,20 @@ fn create_webview_inner( // On Linux, set up focus handling for the GTK widget #[cfg(target_os = "linux")] { - use gdkx11::glib::translate::ToGlibPtr; - use gdkx11::glib::Cast; - use gdkx11::X11Display; use gtk::prelude::WidgetExt; let gtk_widget = webview.webview(); gtk_widget.set_can_focus(true); - // Connect to button-press-event to grab focus when clicked using X11 + // Connect to button-press-event to grab focus when clicked. + // Avoid forcing raw X11 focus here because the host hierarchy may be + // managed by AWT/Swing and direct XSetInputFocus can desynchronize focus. gtk_widget.connect_button_press_event(|widget, _event| { wry_log!("[wrywebview] button_press_event -> grab_focus"); - - // Use X11 focus directly for proper keyboard input - if let Some(gdk_window) = widget.window() { - if let Some(display) = gdk::Display::default() { - if let Ok(x11_display) = display.downcast::() { - unsafe { - let gdk_window_ptr: *mut gdk::ffi::GdkWindow = gdk_window.to_glib_none().0; - let xid = gdkx11::ffi::gdk_x11_window_get_xid( - gdk_window_ptr as *mut gdkx11::ffi::GdkX11Window, - ); - - if xid != 0 { - let x11_display_ptr: *mut gdkx11::ffi::GdkX11Display = x11_display.to_glib_none().0; - let x_display = gdkx11::ffi::gdk_x11_display_get_xdisplay(x11_display_ptr); - - if !x_display.is_null() { - x11::xlib::XSetInputFocus( - x_display as *mut x11::xlib::Display, - xid, - x11::xlib::RevertToParent, - x11::xlib::CurrentTime, - ); - wry_log!("[wrywebview] button_press XSetInputFocus xid=0x{:x}", xid); - } - } - } - } - } - } - widget.grab_focus(); gtk::glib::Propagation::Proceed }); - wry_log!("[wrywebview] gtk focus handling configured with X11 support"); + wry_log!("[wrywebview] gtk focus handling configured"); } let id = register(webview, state, web_context)?; @@ -723,13 +692,10 @@ pub fn reload(id: u64) -> Result<(), WebViewError> { fn focus_inner(id: u64) -> Result<(), WebViewError> { wry_log!("[wrywebview] focus id={}", id); with_webview(id, |webview| { - // On Linux, we need to use X11 focus directly since the GTK widget - // is embedded in a foreign (AWT/Swing) window hierarchy + // On Linux, keep focus changes within GTK/Wry to avoid desynchronizing + // focus with the host AWT/Swing hierarchy. #[cfg(target_os = "linux")] { - use gdkx11::glib::translate::ToGlibPtr; - use gdkx11::glib::Cast; - use gdkx11::X11Display; use gtk::prelude::WidgetExt; let gtk_widget = webview.webview(); @@ -740,39 +706,6 @@ fn focus_inner(id: u64) -> Result<(), WebViewError> { gtk_widget.realize(); } - // Get the GDK window and use X11 to set focus - if let Some(gdk_window) = gtk_widget.window() { - // Get the X11 display from GDK - if let Some(display) = gdk::Display::default() { - if let Ok(x11_display) = display.downcast::() { - unsafe { - // Get the X11 window ID (XID) from the GDK window - let gdk_window_ptr: *mut gdk::ffi::GdkWindow = gdk_window.to_glib_none().0; - let xid = gdkx11::ffi::gdk_x11_window_get_xid( - gdk_window_ptr as *mut gdkx11::ffi::GdkX11Window, - ); - - if xid != 0 { - // Get the raw X11 display pointer - let x11_display_ptr: *mut gdkx11::ffi::GdkX11Display = x11_display.to_glib_none().0; - let x_display = gdkx11::ffi::gdk_x11_display_get_xdisplay(x11_display_ptr); - - if !x_display.is_null() { - // XSetInputFocus: RevertToParent = 2, CurrentTime = 0 - x11::xlib::XSetInputFocus( - x_display as *mut x11::xlib::Display, - xid, - x11::xlib::RevertToParent, - x11::xlib::CurrentTime, - ); - wry_log!("[wrywebview] XSetInputFocus xid=0x{:x}", xid); - } - } - } - } - } - } - // Also call GTK grab_focus as a fallback gtk_widget.grab_focus(); wry_log!("[wrywebview] gtk grab_focus called"); @@ -841,6 +774,27 @@ pub fn drain_ipc_messages(id: u64) -> Result, WebViewError> { state.drain_ipc_messages() } +fn capture_screenshot_inner(id: u64) -> Result, WebViewError> { + wry_log!("[wrywebview] capture_screenshot id={}", id); + with_webview(id, |_webview| { + // Fallback to JVM paint() in WryWebViewPanel.kt if this returns error + Err(WebViewError::Internal( + "Native screenshot not implemented for this platform in Rust yet".to_string(), + )) + }) +} + +#[uniffi::export] +pub fn capture_screenshot(id: u64) -> Result, WebViewError> { + #[cfg(target_os = "linux")] + { + return run_on_gtk_thread(move || capture_screenshot_inner(id)); + } + + #[cfg(not(target_os = "linux"))] + run_on_main_thread(move || capture_screenshot_inner(id)) +} + // ============================================================================ // Cookies // ============================================================================