Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions demo-desktop/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import org.apache.tools.ant.taskdefs.condition.Os

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

kotlin {
jvm()

sourceSets {
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(libs.kotlinx.coroutinesSwing)
implementation(project(":webview-compose"))
// Tao backend required for desktop WebView (NativeView / WebKit2GTK).
implementation(libs.nucleus.application)
implementation(libs.nucleus.decorated.window.tao)
implementation(libs.nucleus.core.runtime)
}
}
}

nucleus.application {
mainClass = "dev.nucleusframework.webview.demo.MainKt"
}

// The host-OS native WebView backend is gitignored — build it before run,
// jvmRun and Compose Hot Reload's hotRunJvm so the dev classpath has the JNI library.
tasks.matching { it.name == "run" || it.name == "jvmRun" || it.name == "hotRunJvm" }.configureEach {
when {
Os.isFamily(Os.FAMILY_WINDOWS) ->
dependsOn(":webview-compose:buildNativeWindows")
Os.isFamily(Os.FAMILY_MAC) ->
dependsOn(":webview-compose:buildNativeMacos")
Os.isFamily(Os.FAMILY_UNIX) ->
dependsOn(":webview-compose:buildNativeLinux")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package dev.nucleusframework.webview.demo

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.rememberWindowState
import dev.nucleusframework.application.DecoratedWindow
import dev.nucleusframework.application.NucleusBackend
import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.webview.web.WebView
import dev.nucleusframework.webview.web.rememberWebViewNavigator
import dev.nucleusframework.webview.web.rememberWebViewState
import dev.nucleusframework.window.TitleBar
import kotlin.system.exitProcess

private const val DEFAULT_URL = "https://accounts.google.com"

fun main() {
nucleusApplication(backend = NucleusBackend.Tao) {
val windowState = rememberWindowState(size = DpSize(1080.dp, 720.dp))
DecoratedWindow(
onCloseRequest = {
exitApplication()
exitProcess(0)
},
state = windowState,
title = "ComposeNativeWebView Demo",
) {
TitleBar()
WebViewDemo()
}
}
}

@Composable
private fun WebViewDemo() {
val navigator = rememberWebViewNavigator()
val state = rememberWebViewState(DEFAULT_URL)
var urlInput by remember { mutableStateOf(DEFAULT_URL) }

Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(8.dp)) {
OutlinedTextField(
value = urlInput,
onValueChange = { urlInput = it },
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("URL") },
)
Button(
onClick = { navigator.loadUrl(urlInput) },
modifier = Modifier.padding(start = 8.dp),
) {
Text("Go")
}
}
WebView(
state = state,
navigator = navigator,
modifier = Modifier.fillMaxSize(),
)
}
}
4 changes: 2 additions & 2 deletions e2e-desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ nucleus.application {
}

// Visual e2e needs the host-OS native WebView backend on the runtime classpath.
// Natives are gitignored — build them before run if missing.
tasks.matching { it.name == "run" || it.name == "jvmRun" }.configureEach {
// Natives are gitignored — build them before run, jvmRun and Compose Hot Reload's hotRunJvm.
tasks.matching { it.name == "run" || it.name == "jvmRun" || it.name == "hotRunJvm" }.configureEach {
when {
Os.isFamily(Os.FAMILY_WINDOWS) ->
dependsOn(":webview-compose:buildNativeWindows")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ internal fun suiteCatalog(): List<SuiteCase> =
SuiteCase("C09", "Content", "state.content mutation loads new document"),
SuiteCase("C10", "Content", "loadUrl normalizes trailing slash domain"),
SuiteCase("C11", "Content", "loadHtml then loadUrl(data:) switches document"),
SuiteCase("C12", "Content", "opaque WebView preserves page dark background"),
// Navigation (Wry: go_back/forward/reload/stop/can_*)
SuiteCase("N01", "Navigation", "second navigation enables canGoBack"),
SuiteCase("N02", "Navigation", "canGoForward false at tip of history"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ internal suspend fun runFullSuite(
loadUrlAwaitMarker(ctx.navigator, "switch-b", dataHtmlUrl(pageWithMarker("switch-b")))
assertThat(markerOf(ctx.navigator) == "switch-b", "marker=${markerOf(ctx.navigator)}")
}
case("C12") {
val darkPage = """<!doctype html><html><body style="margin:0;background:#101112;color:white"><div id="marker">dark-bg</div></body></html>"""
loadHtmlAwaitMarker(ctx.navigator, "dark-bg", darkPage)
val background = evalJsUnquoted(ctx.navigator, "getComputedStyle(document.body).backgroundColor")
assertThat(background == "rgb(16, 17, 18)", "page background overridden: $background")
}

// ── Navigation (use data: URLs so WebKit builds real history) ─────
case("N01", required = setOf(SuiteCapability.HistoryNavigation)) {
Expand Down
12 changes: 12 additions & 0 deletions gradle/gradle-daemon-jvm.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ google-material = "1.14.0"
kotlin = "2.4.10"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.11.0"
nucleus = "2.5.5"
nucleus = "2.5.18"

[libraries]
compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}

include(":demo-desktop")
include(":e2e-desktop")
include(":e2e-shared")
include(":e2e-android")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package dev.nucleusframework.webview.web

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import dev.nucleusframework.core.runtime.Platform
import dev.nucleusframework.webview.cookie.DesktopCookieManager
import dev.nucleusframework.webview.jsbridge.WebViewJsBridge
Expand All @@ -27,9 +34,11 @@ import dev.nucleusframework.window.tao.NativeView
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import kotlin.time.Duration.Companion.milliseconds

actual class WebViewFactoryParam(
Expand Down Expand Up @@ -329,7 +338,12 @@ actual fun ActualWebView(
factory = { linuxWebView.asPlatformView() },
modifier = modifier,
update = { },
content = content,
content = {
if (System.getProperty("compose.reload.isActive") == "true") {
WebKitHotReloadPreview(linuxWebView)
}
content()
},
)
LaunchedEffect(nativeWebView) {
onCreated(nativeWebView)
Expand Down Expand Up @@ -379,6 +393,38 @@ actual fun ActualWebView(
}
}

/**
* During Compose Hot Reload, the EGL scene can cover GTK's Cairo surface even
* while WebKit continues painting. Mirror WebKit's native snapshot in the
* Compose overlay in that development mode; GTK remains the input target.
*/
@Composable
private fun WebKitHotReloadPreview(view: LinuxWebKitNativeWebView) {
var frame by remember(view) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(view) {
while (view.isReady()) {
try {
view.captureScreenshotAsync()?.let { png ->
frame = SkiaImage.makeFromEncoded(png).toComposeImageBitmap()
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// A resize or navigation can temporarily leave no snapshot.
}
delay(200.milliseconds)
}
}
frame?.let { image ->
Image(
bitmap = image,
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize(),
)
}
}

/**
* Captures a screenshot of the WebView and returns it as a [BufferedImage].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ typedef struct {
WebKitWebContext *context;
gulong decide_policy_handler;
gulong ipc_handler;
gulong context_menu_handler;
gulong hierarchy_handler;
GtkWidget *host_window;
gulong host_event_handler;
GtkGesture *host_pointer_gesture;
GtkWidget *context_popover;
gboolean event_filter_installed;
} ComposeWebViewState;

/* jni_bridge.c */
Expand All @@ -49,5 +56,13 @@ void compose_webview_on_script_message(
WebKitUserContentManager *manager,
WebKitJavascriptResult *js_result,
gpointer user_data);
void compose_webview_connect_input_routing(ComposeWebViewState *state);
void compose_webview_disconnect_input_routing(ComposeWebViewState *state);
gboolean compose_webview_on_context_menu(
WebKitWebView *web_view,
WebKitContextMenu *context_menu,
GdkEvent *event,
WebKitHitTestResult *hit_test_result,
gpointer user_data);

#endif /* COMPOSE_WEBVIEW_INTERNAL_H */
34 changes: 17 additions & 17 deletions webview-compose/src/jvmMain/native/linux/view_lifecycle.c
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,6 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate(
}
}

/*
* Opaque mode: force a solid page background. Many pages (and about:blank)
* leave html/body transparent; without this the Compose clear-through
* NativeView hole shows UI underneath the WebView.
*/
if (!transparent) {
WebKitUserStyleSheet *sheet = webkit_user_style_sheet_new(
"html, body { background-color: #ffffff !important; }",
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
WEBKIT_USER_STYLE_LEVEL_USER,
NULL,
NULL);
webkit_user_content_manager_add_style_sheet(state->ucm, sheet);
webkit_user_style_sheet_unref(sheet);
}

if (init_script != NULL) {
const char *src = (*env)->GetStringUTFChars(env, init_script, NULL);
if (src != NULL && src[0] != '\0') {
Expand Down Expand Up @@ -189,11 +173,17 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate(

g_object_set_data(G_OBJECT(state->web_view), "compose-webview-state", state);

// Use a non-modal in-window menu. WebKit has not bound its stock GActions
// to the page yet at this signal point, so the handler dispatches those
// commands through the WebKitWebView API instead of activating proxies.
state->decide_policy_handler = g_signal_connect(
state->web_view,
"decide-policy",
G_CALLBACK(compose_webview_on_decide_policy),
state);
state->context_menu_handler = g_signal_connect(
state->web_view, "context-menu", G_CALLBACK(compose_webview_on_context_menu), state);
compose_webview_connect_input_routing(state);

return (jlong) (uintptr_t) state;
}
Expand All @@ -217,6 +207,17 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeRelease(
if (state == NULL) return;

if (state->web_view != NULL) {
compose_webview_disconnect_input_routing(state);
if (state->context_menu_handler != 0) {
g_signal_handler_disconnect(state->web_view, state->context_menu_handler);
state->context_menu_handler = 0;
}
if (state->context_popover != NULL) {
GtkWidget *popover = state->context_popover;
state->context_popover = NULL;
gtk_widget_destroy(popover);
g_object_unref(popover);
}
if (state->decide_policy_handler != 0) {
g_signal_handler_disconnect(state->web_view, state->decide_policy_handler);
state->decide_policy_handler = 0;
Expand All @@ -239,4 +240,3 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeRelease(
}
g_free(state);
}

Loading
Loading