From d4e5f3e025beab1254f9dcd5c9f20a37fd698fbe Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 15:08:47 +0300 Subject: [PATCH 01/10] Migrate to NucleusFramework packages and remove Wry desktop backend Rename io.github.kdroidfilter to dev.nucleusframework, drop the wrywebview module (Gobley/JNA/Rust), make desktop a no-op stub, and bump Kotlin/Compose to 2.4.10 / 1.11.1. --- .cargo/config.toml | 2 - .github/workflows/pr-build-check.yml | 59 +- .github/workflows/publish-on-maven.yml | 150 +- .gitignore | 8 - AGENTS.md | 28 +- README.md | 144 +- build.gradle.kts | 3 - demo-android/build.gradle.kts | 4 +- .../src/androidMain/AndroidManifest.xml | 2 +- .../webview/demo/MainActivity.kt | 2 +- demo-shared/build.gradle.kts | 3 +- .../webview/demo/DemoUtils.android.kt | 2 +- .../webview/demo/PlatformInfo.android.kt | 2 +- .../files/bridge_playground.html | 4 +- .../nucleusframework}/webview/demo/App.kt | 14 +- .../webview/demo/DemoJsBridgeHandlers.kt | 12 +- .../webview/demo/DemoRequestInterceptor.kt | 10 +- .../webview/demo/DemoToolsPanel.kt | 10 +- .../webview/demo/DemoTopBar.kt | 4 +- .../webview/demo/DemoUtils.kt | 2 +- .../webview/demo/PlatformInfo.kt | 2 +- .../webview/demo/DemoUtils.ios.kt | 2 +- .../webview/demo/MainViewController.kt | 2 +- .../webview/demo/PlatformInfo.ios.kt | 2 +- .../webview/demo/DemoUtils.jvm.kt | 2 +- .../webview/demo/PlatformInfo.jvm.kt | 2 +- .../webview/demo/DemoUtils.wasmJs.kt | 2 +- .../webview/demo/PlatformInfo.wasmJs.kt | 2 +- .../nucleusframework}/webview/demo/main.kt | 2 +- demo/build.gradle.kts | 5 +- .../nucleusframework}/webview/demo/main.kt | 2 +- .../webview/demo/WebViewTest.kt | 6 +- gradle.properties | 16 +- gradle/libs.versions.toml | 26 +- iosApp/Configuration/Config.xcconfig | 2 +- settings.gradle.kts | 1 - .../webview/web/PlaywrightWebView.kt | 46 +- webview-compose/README.md | 27 +- webview-compose/build.gradle.kts | 6 +- .../webview/cookie/AndroidCookieManager.kt | 2 +- .../webview/cookie/Cookie.android.kt | 2 +- .../webview/web/AndroidWebView.kt | 8 +- .../webview/web/NativeWebView.android.kt | 2 +- .../webview/web/WebViewAndroid.kt | 10 +- .../webview/cookie/Cookie.kt | 2 +- .../webview/cookie/CookieManager.kt | 2 +- .../webview/jsbridge/IJsMessageHandler.kt | 4 +- .../webview/jsbridge/JsMessage.kt | 2 +- .../webview/jsbridge/JsMessageDispatcher.kt | 4 +- .../webview/jsbridge/JsMessageParsing.kt | 2 +- .../webview/jsbridge/WebViewJsBridge.kt | 8 +- .../webview/request/RequestInterceptor.kt | 4 +- .../webview/request/WebRequest.kt | 2 +- .../request/WebRequestInterceptResult.kt | 2 +- .../webview/setting/PlatformWebSettings.kt | 2 +- .../webview/setting/WebSettings.kt | 6 +- .../webview/util/Extension.kt | 2 +- .../nucleusframework}/webview/util/KLogger.kt | 2 +- .../nucleusframework}/webview/web/IWebView.kt | 4 +- .../webview/web/LoadingState.kt | 2 +- .../webview/web/WebContent.kt | 2 +- .../nucleusframework}/webview/web/WebView.kt | 4 +- .../webview/web/WebViewError.kt | 2 +- .../webview/web/WebViewFileReadType.kt | 2 +- .../webview/web/WebViewNavigator.kt | 12 +- .../webview/web/WebViewState.kt | 8 +- .../webview/cookie/Cookie.ios.kt | 2 +- .../webview/cookie/IOSCookieManager.kt | 4 +- .../webview/jsbridge/WKJsMessageHandler.kt | 4 +- .../nucleusframework}/webview/util/Color.kt | 2 +- .../webview/web/IOSWebView.kt | 8 +- .../webview/web/NativeWebView.ios.kt | 2 +- .../webview/web/WKNavigationDelegate.kt | 8 +- .../webview/web/WKWebViewExt.kt | 2 +- .../webview/web/WKWebViewObserver.kt | 4 +- .../webview/web/WebViewIos.kt | 8 +- .../webview/cookie/Cookie.desktop.kt | 5 +- .../webview/cookie/DesktopCookieManager.kt | 14 + .../webview/web/DesktopWebView.kt | 68 + .../webview/web/NativeWebView.desktop.kt | 53 + .../webview/web/WebViewDesktop.kt | 94 + .../webview/cookie/WryCookieManager.kt | 91 - .../webview/web/DesktopWebView.kt | 140 - .../webview/web/NativeWebView.desktop.kt | 5 - .../webview/web/WebViewDesktop.kt | 246 -- .../webview/cookie/Cookie.wasmJs.kt | 4 +- .../nucleusframework}/webview/web/HtmlView.kt | 4 +- .../webview/web/JsInterop.kt | 2 +- .../webview/web/WasmJsWebView.kt | 6 +- .../webview/web/WasmJsWebViewNavigator.kt | 4 +- .../webview/web/WebView.wasmJs.kt | 10 +- .../webview/web/WebViewJsBridge.kt | 2 +- .../webview/web/WebViewTypes.kt | 2 +- wrywebview/Cargo.lock | 3140 ----------------- wrywebview/Cargo.toml | 37 - wrywebview/build.gradle.kts | 146 - .../webview/wry/SkikoInterop.java | 47 - .../webview/wry/WryWebViewPanel.kt | 977 ----- wrywebview/src/main/rust/error.rs | 32 - wrywebview/src/main/rust/handle.rs | 95 - wrywebview/src/main/rust/lib.rs | 1011 ------ wrywebview/src/main/rust/platform/linux.rs | 84 - wrywebview/src/main/rust/platform/macos.rs | 65 - wrywebview/src/main/rust/platform/mod.rs | 25 - wrywebview/src/main/rust/platform/windows.rs | 16 - wrywebview/src/main/rust/state.rs | 242 -- wrywebview/uniffi.toml | 2 - 107 files changed, 491 insertions(+), 6946 deletions(-) delete mode 100644 .cargo/config.toml rename demo-android/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/MainActivity.kt (87%) rename demo-shared/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoUtils.android.kt (89%) rename demo-shared/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/PlatformInfo.android.kt (89%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/App.kt (98%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoJsBridgeHandlers.kt (92%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoRequestInterceptor.kt (80%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoToolsPanel.kt (98%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoTopBar.kt (99%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoUtils.kt (97%) rename demo-shared/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/PlatformInfo.kt (52%) rename demo-shared/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoUtils.ios.kt (95%) rename demo-shared/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/MainViewController.kt (79%) rename demo-shared/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/PlatformInfo.ios.kt (88%) rename demo-shared/src/jvmMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoUtils.jvm.kt (89%) rename demo-shared/src/jvmMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/PlatformInfo.jvm.kt (88%) rename demo-shared/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/DemoUtils.wasmJs.kt (75%) rename demo-shared/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/PlatformInfo.wasmJs.kt (89%) rename demo-wasmJs/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/main.kt (88%) rename demo/src/jvmMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/main.kt (90%) rename demo/src/jvmTest/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/demo/WebViewTest.kt (96%) rename webview-compose-test/src/jvmMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/PlaywrightWebView.kt (73%) rename webview-compose/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/AndroidCookieManager.kt (97%) rename webview-compose/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/Cookie.android.kt (91%) rename webview-compose/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/AndroidWebView.kt (95%) rename webview-compose/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/NativeWebView.android.kt (63%) rename webview-compose/src/androidMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewAndroid.kt (97%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/Cookie.kt (96%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/CookieManager.kt (91%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/jsbridge/IJsMessageHandler.kt (89%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/jsbridge/JsMessage.kt (86%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/jsbridge/JsMessageDispatcher.kt (86%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/jsbridge/JsMessageParsing.kt (96%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/jsbridge/WebViewJsBridge.kt (86%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/request/RequestInterceptor.kt (61%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/request/WebRequest.kt (82%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/request/WebRequestInterceptResult.kt (83%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/setting/PlatformWebSettings.kt (96%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/setting/WebSettings.kt (89%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/util/Extension.kt (77%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/util/KLogger.kt (98%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/IWebView.kt (97%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/LoadingState.kt (82%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebContent.kt (92%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebView.kt (95%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewError.kt (79%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewFileReadType.kt (66%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewNavigator.kt (92%) rename webview-compose/src/commonMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewState.kt (89%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/Cookie.ios.kt (82%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/IOSCookieManager.kt (98%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/jsbridge/WKJsMessageHandler.kt (88%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/util/Color.kt (85%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/IOSWebView.kt (97%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/NativeWebView.ios.kt (64%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WKNavigationDelegate.kt (95%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WKWebViewExt.kt (96%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WKWebViewObserver.kt (94%) rename webview-compose/src/iosMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewIos.kt (96%) rename webview-compose/src/jvmMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/Cookie.desktop.kt (79%) create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt delete mode 100644 webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/WryCookieManager.kt delete mode 100644 webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt delete mode 100644 webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.desktop.kt delete mode 100644 webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/WebViewDesktop.kt rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/cookie/Cookie.wasmJs.kt (96%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/HtmlView.kt (99%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/JsInterop.kt (98%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WasmJsWebView.kt (97%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WasmJsWebViewNavigator.kt (98%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebView.wasmJs.kt (97%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewJsBridge.kt (98%) rename webview-compose/src/wasmJsMain/kotlin/{io/github/kdroidfilter => dev/nucleusframework}/webview/web/WebViewTypes.kt (98%) delete mode 100644 wrywebview/Cargo.lock delete mode 100644 wrywebview/Cargo.toml delete mode 100644 wrywebview/build.gradle.kts delete mode 100644 wrywebview/src/main/java/io/github/kdroidfilter/webview/wry/SkikoInterop.java delete mode 100644 wrywebview/src/main/kotlin/io/github/kdroidfilter/webview/wry/WryWebViewPanel.kt delete mode 100644 wrywebview/src/main/rust/error.rs delete mode 100644 wrywebview/src/main/rust/handle.rs delete mode 100644 wrywebview/src/main/rust/lib.rs delete mode 100644 wrywebview/src/main/rust/platform/linux.rs delete mode 100644 wrywebview/src/main/rust/platform/macos.rs delete mode 100644 wrywebview/src/main/rust/platform/mod.rs delete mode 100644 wrywebview/src/main/rust/platform/windows.rs delete mode 100644 wrywebview/src/main/rust/state.rs delete mode 100644 wrywebview/uniffi.toml diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 8831de4..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -profile = "release" diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index d691b44..033112d 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -5,59 +5,20 @@ on: branches: [main] jobs: - build-rust-macos-aarch64: - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target aarch64-apple-darwin - - build-rust-macos-x86_64: - runs-on: macos-15-intel - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-apple-darwin - - build-rust-linux: + build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libxdo-dev - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-unknown-linux-gnu - - build-rust-windows: - runs-on: windows-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + - name: Setup Android SDK + uses: android-actions/setup-android@v3 - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-pc-windows-msvc + - name: Build + run: ./gradlew :webview-compose:compileKotlinJvm :webview-compose:compileDebugKotlinAndroid :demo:compileKotlinJvm --no-configuration-cache diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml index bc6195f..f647154 100644 --- a/.github/workflows/publish-on-maven.yml +++ b/.github/workflows/publish-on-maven.yml @@ -5,106 +5,13 @@ on: types: [published] jobs: - build-rust-macos-aarch64: - if: startsWith(github.event.release.tag_name, 'v') - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target aarch64-apple-darwin - - - name: Upload native library - uses: actions/upload-artifact@v4 - with: - name: native-darwin-aarch64 - path: wrywebview/target/aarch64-apple-darwin/release/libcomposewebview_wry.dylib - retention-days: 1 - - build-rust-macos-x86_64: - if: startsWith(github.event.release.tag_name, 'v') - runs-on: macos-15-intel - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-apple-darwin - - - name: Upload native library - uses: actions/upload-artifact@v4 - with: - name: native-darwin-x86_64 - path: wrywebview/target/x86_64-apple-darwin/release/libcomposewebview_wry.dylib - retention-days: 1 - - build-rust-linux: + publish: if: startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libxdo-dev - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-unknown-linux-gnu - - - name: Upload native library - uses: actions/upload-artifact@v4 - with: - name: native-linux-x86_64 - path: wrywebview/target/x86_64-unknown-linux-gnu/release/libcomposewebview_wry.so - retention-days: 1 - - build-rust-windows: - if: startsWith(github.event.release.tag_name, 'v') - runs-on: windows-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-pc-windows-msvc - - - name: Upload native library - uses: actions/upload-artifact@v4 - with: - name: native-windows-x86_64 - path: wrywebview/target/x86_64-pc-windows-msvc/release/composewebview_wry.dll - retention-days: 1 - - publish: - if: startsWith(github.event.release.tag_name, 'v') - needs: - - build-rust-macos-aarch64 - - build-rust-macos-x86_64 - - build-rust-linux - - build-rust-windows - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - name: Set version from tag shell: bash run: | @@ -114,58 +21,6 @@ jobs: sed -i.bak "s/^VERSION_NAME=.*/VERSION_NAME=$VERSION_NAME/" gradle.properties rm -f gradle.properties.bak - - name: Download macOS aarch64 native library - uses: actions/download-artifact@v4 - with: - name: native-darwin-aarch64 - path: wrywebview/target/aarch64-apple-darwin/release/ - - - name: Download macOS x86_64 native library - uses: actions/download-artifact@v4 - with: - name: native-darwin-x86_64 - path: wrywebview/target/x86_64-apple-darwin/release/ - - - name: Download Linux native library - uses: actions/download-artifact@v4 - with: - name: native-linux-x86_64 - path: wrywebview/target/x86_64-unknown-linux-gnu/release/ - - - name: Download Windows native library - uses: actions/download-artifact@v4 - with: - name: native-windows-x86_64 - path: wrywebview/target/x86_64-pc-windows-msvc/release/ - - - name: Prepare JVM native resources - run: | - mkdir -p wrywebview/src/jvmMain/resources/darwin-aarch64 - mkdir -p wrywebview/src/jvmMain/resources/darwin-x86-64 - mkdir -p wrywebview/src/jvmMain/resources/linux-x86-64 - mkdir -p wrywebview/src/jvmMain/resources/win32-x86-64 - cp wrywebview/target/aarch64-apple-darwin/release/libcomposewebview_wry.dylib \ - wrywebview/src/jvmMain/resources/darwin-aarch64/ - cp wrywebview/target/x86_64-apple-darwin/release/libcomposewebview_wry.dylib \ - wrywebview/src/jvmMain/resources/darwin-x86-64/ - cp wrywebview/target/x86_64-unknown-linux-gnu/release/libcomposewebview_wry.so \ - wrywebview/src/jvmMain/resources/linux-x86-64/ - cp wrywebview/target/x86_64-pc-windows-msvc/release/composewebview_wry.dll \ - wrywebview/src/jvmMain/resources/win32-x86-64/ - - - name: Verify native libraries - run: | - echo "=== Native libraries downloaded ===" - ls -la wrywebview/target/aarch64-apple-darwin/release/ - ls -la wrywebview/target/x86_64-apple-darwin/release/ - ls -la wrywebview/target/x86_64-unknown-linux-gnu/release/ - ls -la wrywebview/target/x86_64-pc-windows-msvc/release/ - echo "=== Native libraries in resources ===" - ls -la wrywebview/src/jvmMain/resources/darwin-aarch64/ - ls -la wrywebview/src/jvmMain/resources/darwin-x86-64/ - ls -la wrywebview/src/jvmMain/resources/linux-x86-64/ - ls -la wrywebview/src/jvmMain/resources/win32-x86-64/ - - name: Set up JDK uses: actions/setup-java@v4 with: @@ -175,9 +30,6 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Setup Rust (for UniFFI bindgen) - uses: dtolnay/rust-toolchain@stable - - name: Publish to Maven Central run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache env: diff --git a/.gitignore b/.gitignore index 526161d..9cc94da 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,6 @@ captures/ .externalNativeBuild/ .cxx/ -# Rust -**/target/ -**/*.rs.bk - # Node node_modules/ @@ -34,7 +30,3 @@ xcuserdata !*.xcodeproj/project.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings - -# Compose/Skiko native libs -libskiko-*.dylib -libskiko-*.dylib.sha256 diff --git a/AGENTS.md b/AGENTS.md index fff7f19..221d212 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,32 +3,29 @@ ## Project Structure & Module Organization - `demo/`: Compose Desktop sample app (`demo/src/jvmMain/kotlin/...`). -- `wrywebview/`: native WebView core. - - Rust crate: `wrywebview/Cargo.toml` with sources in `wrywebview/src/main/rust/` (wry + UniFFI). - - JVM glue: `wrywebview/src/main/kotlin/` + `wrywebview/src/main/java/` (JNA/Skiko interop). -- `wrywebview-compose/`: Compose wrapper exposing `io.github.kdroidfilter.webview.*` (`WebView`, `WebViewState`, `WebViewNavigator`). - - Shared API/types: `wrywebview-compose/src/commonMain/kotlin/...`. - - Platform actuals: `.../src/jvmMain/` (Wry), `.../src/androidMain/` (Android WebView), `.../src/iosMain/` (WKWebView + cinterop in `.../src/nativeInterop/`). -- Generated/build outputs live under `*/build/` and `wrywebview/target/` (donโ€™t edit or commit). +- `webview-compose/`: Compose Multiplatform WebView library exposing `dev.nucleusframework.webview.*` + (`WebView`, `WebViewState`, `WebViewNavigator`). + - Shared API/types: `webview-compose/src/commonMain/kotlin/...`. + - Platform actuals: `.../src/jvmMain/` (desktop no-op), `.../src/androidMain/` (Android WebView), + `.../src/iosMain/` (WKWebView + cinterop in `.../src/nativeInterop/`), `.../src/wasmJsMain/` (IFrame). +- `webview-compose-test/`: JVM testing helpers (Playwright-backed mock WebView). +- Generated/build outputs live under `*/build/` (donโ€™t edit or commit). ## Build, Test, and Development Commands -- `./gradlew build`: builds all modules (Kotlin + Rust via Gobley/UniFFI); requires a working Rust toolchain. -- `./gradlew :demo:run`: runs the desktop demo app. -- `./gradlew :wrywebview:build`: rebuilds the native core and refreshes generated bindings. -- `./gradlew :wrywebview-compose:compileDebugKotlinAndroid`: compiles the Android implementation (requires Android SDK). -- `./gradlew clean`: removes Gradle build outputs (useful when native artifacts get out of sync). +- `./gradlew build`: builds all modules. +- `./gradlew :demo:run`: runs the desktop demo app (desktop WebView is currently a no-op). +- `./gradlew :webview-compose:compileDebugKotlinAndroid`: compiles the Android implementation (requires Android SDK). +- `./gradlew clean`: removes Gradle build outputs. ## Coding Style & Naming Conventions - Kotlin/Compose: 4-space indentation, idiomatic Kotlin style, `camelCase` for values/functions, `PascalCase` for types and `@Composable` functions (e.g., `WebView`). - Keep public API changes small and documented (README usage snippets should stay accurate). -- Rust: format with `cargo fmt` in `wrywebview/`; keep the `#[uniffi::export]` surface stable and cross-platform. ## Testing Guidelines - Kotlin tests (when added) should live in `*/src/jvmTest/kotlin` (or `commonTest`) and run with `./gradlew test`. -- Rust tests (when added) can run via `cd wrywebview && cargo test`. ## Commit & Pull Request Guidelines @@ -37,5 +34,4 @@ ## Security & Configuration Tips -- The demo/app JVM needs `--enable-native-access=ALL-UNNAMED` (JNA); keep this in sync with `README.md`. -- Platform builds may require system deps (notably GTK/WebKit on Linux); call out any new requirements in the PR description. +- Platform builds may require system deps (Android SDK, Xcode for iOS); call out any new requirements in the PR description. diff --git a/README.md b/README.md index 42a2e67..f167218 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,43 @@ -# ComposeNativeWebView ๐ŸŒ +# ComposeNativeWebView **ComposeNativeWebView** is a **Compose Multiplatform WebView** whose **API design and mobile implementations (Android & iOS) are intentionally derived almost verbatim from [KevinnZou/compose-webview-multiplatform](https://github.com/KevinnZou/compose-webview-multiplatform)**. -This project exists **first and foremost to bring that same API to Desktop**, backed by **native OS webviews instead of a bundled Chromium runtime**. +Package namespace: ```text -io.github.kdroidfilter.webview.* +dev.nucleusframework.webview.* ``` ### What is reused vs what is new -๐ŸŸข **Reused on purpose** +**Reused on purpose** * API surface (`WebViewState`, `WebViewNavigator`, settings, callbacks, mental model) * Android implementation (`android.webkit.WebView`) * iOS implementation (`WKWebView`) * Overall behavior and semantics -๐Ÿ‘‰ If you already know **compose-webview-multiplatform**, you already know how to use this. +If you already know **compose-webview-multiplatform**, you already know how to use this. -๐Ÿ†• **What ComposeNativeWebView adds** +**What ComposeNativeWebView adds** -* **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 +* Multiplatform packaging under NucleusFramework (`dev.nucleusframework`) +* **WasmJs** target via **IFrame** +* Desktop (JVM) API surface (currently a **no-op** backend; no native rendering yet) --- ## Platform backends -โœ… **Android**: `android.webkit.WebView` -โœ… **iOS**: `WKWebView` -โœ… **WasmJs**: `org.w3c.dom.HTMLIFrameElement` -โœ… **Desktop**: **Wry (Rust)** via **UniFFI** - -Desktop engines: - -* **Windows**: WebView2 -* **macOS**: WKWebView -* **Linux**: WebKitGTK +- **Android**: `android.webkit.WebView` +- **iOS**: `WKWebView` +- **WasmJs**: `org.w3c.dom.HTMLIFrameElement` +- **Desktop**: no-op stub (all operations are empty; composable renders an empty box) --- -## Quick start ๐Ÿš€ +## Quick start ```kotlin @Composable @@ -58,13 +51,13 @@ Thatโ€™s it. --- -## Installation ๐Ÿงฉ +## Installation ### Dependency (all platforms) ```kotlin dependencies { - implementation("io.github.kdroidfilter:composewebview:") + implementation("dev.nucleusframework:composewebview:") } ``` @@ -72,25 +65,11 @@ Same artifact for **Android, iOS, Desktop and WasmJs**. --- -### Desktop only: enable native access โš ๏ธ - -Wry uses native access via JNA. - -```kotlin -compose.desktop { - application { - jvmArgs += "--enable-native-access=ALL-UNNAMED" - } -} -``` - ---- - -## Demo app ๐ŸŽฎ +## Demo app Run the feature showcase first: -* **Desktop**: `./gradlew :demo:run` +* **Desktop**: `./gradlew :demo:run` (WebView area is empty until a desktop backend lands) * **Android**: `./gradlew :demo-android:installDebug` * **WasmJs**: `./gradlew :demo-wasmJs:wasmJsBrowserDevelopmentRun` * **iOS**: open `iosApp/iosApp.xcodeproj` in Xcode and Run @@ -102,7 +81,7 @@ Responsive UI: --- -## Core features โœจ +## Core features ### Content loading @@ -110,16 +89,12 @@ Responsive UI: * `loadHtml(html)` * `loadHtmlFile(fileName, readType)` ---- - ### Navigation * `navigateBack()`, `navigateForward()` * `reload()`, `stopLoading()` * `canGoBack`, `canGoForward` ---- - ### Observable state * `isLoading` @@ -127,9 +102,7 @@ Responsive UI: * `lastLoadedUrl` * `pageTitle` ---- - -### Cookies ๐Ÿช +### Cookies Unified cookie API: @@ -140,29 +113,23 @@ state.cookieManager.removeCookies(url) state.cookieManager.removeAllCookies() ``` ---- - ### JavaScript ```kotlin navigator.evaluateJavaScript("document.title = 'Hello'") ``` ---- - -### JS โ†” Kotlin bridge ๐ŸŒ‰ +### JS โ†” Kotlin bridge * injected automatically after page load * callback-based -* works on all platforms +* works on Android / iOS / WasmJs (desktop no-op) ```js window.kmpJsBridge.callNative("echo", {...}, callback) ``` ---- - -### RequestInterceptor ๐Ÿšฆ +### RequestInterceptor Intercept **navigator-initiated** navigations only: @@ -181,7 +148,7 @@ Useful for: --- -## WebViewState & Navigator ๐Ÿ“˜ +## WebViewState & Navigator ### State creation @@ -199,8 +166,6 @@ Supports: * inline HTML * resource files ---- - ### Navigator ```kotlin @@ -217,7 +182,7 @@ Commands: --- -## Settings โš™๏ธ +## Settings ### Custom User-Agent @@ -225,16 +190,6 @@ Commands: state.webSettings.customUserAgentString = "MyApp/1.2.3" ``` -Desktop note: - -* applied at creation time -* changing it **recreates** the WebView (debounced) -* JS context/history may be lost - -๐Ÿ‘‰ Set it early. - ---- - ### Logging ```kotlin @@ -243,54 +198,27 @@ state.webSettings.logSeverity = KLogSeverity.Debug --- -## Desktop advanced ๐Ÿ–ฅ๏ธ - -### Access native WebView handle - -```kotlin -WebView( - state, - navigator, - onCreated = { native -> - println(native.getCurrentUrl()) - } -) -``` +## Project structure -Useful for debugging or platform-specific hooks. - ---- - -## Project structure ๐Ÿ—‚๏ธ - -* `wrywebview/` โ†’ Rust core + UniFFI bindings -* `wrywebview-compose/` โ†’ Compose API +* `webview-compose/` โ†’ Compose Multiplatform API + platform actuals +* `webview-compose-test/` โ†’ Playwright-based test helpers (JVM) * `demo-shared/` โ†’ shared demo UI * `demo/`, `demo-android/`, `demo-wasmJs/`, `iosApp/` โ†’ platform launchers --- -## Limitations โš ๏ธ +## 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) +* **Desktop** is a no-op: no rendering, navigation, cookies, or JS execution +* **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) --- - -## Credits ๐Ÿ™ +## Credits * API inspiration: KevinnZou/compose-webview-multiplatform -* Wry (Tauri ecosystem) -* UniFFI (Mozilla) - diff --git a/build.gradle.kts b/build.gradle.kts index 60436ed..c87abfd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,9 +6,6 @@ plugins { alias(libs.plugins.composeHotReload) apply false alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false - alias(libs.plugins.gobleyCargo) apply false - alias(libs.plugins.gobleyRust) apply false - alias(libs.plugins.gobleyUniffi) apply false alias(libs.plugins.kotlinAtomicfu) apply false alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinMultiplatform) apply false diff --git a/demo-android/build.gradle.kts b/demo-android/build.gradle.kts index bc52ede..41c7bc4 100644 --- a/demo-android/build.gradle.kts +++ b/demo-android/build.gradle.kts @@ -19,13 +19,13 @@ kotlin { } android { - namespace = "io.github.kdroidfilter.webview.demo.android" + namespace = "dev.nucleusframework.webview.demo.android" compileSdk = 36 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { - applicationId = "io.github.kdroidfilter.webview.demo" + applicationId = "dev.nucleusframework.webview.demo" minSdk = 23 targetSdk = 36 versionCode = 1 diff --git a/demo-android/src/androidMain/AndroidManifest.xml b/demo-android/src/androidMain/AndroidManifest.xml index af9c1a5..0bd1859 100644 --- a/demo-android/src/androidMain/AndroidManifest.xml +++ b/demo-android/src/androidMain/AndroidManifest.xml @@ -5,7 +5,7 @@ android:label="ComposeWebView" android:theme="@style/Theme.ComposeWebViewDemo"> diff --git a/demo-android/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/MainActivity.kt b/demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt similarity index 87% rename from demo-android/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/MainActivity.kt rename to demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt index af6455e..e5e10e7 100644 --- a/demo-android/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/MainActivity.kt +++ b/demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import android.os.Bundle import androidx.activity.ComponentActivity diff --git a/demo-shared/build.gradle.kts b/demo-shared/build.gradle.kts index c632c44..02dee7e 100644 --- a/demo-shared/build.gradle.kts +++ b/demo-shared/build.gradle.kts @@ -24,7 +24,6 @@ kotlin { ) == true if (isMacHost) { listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -65,7 +64,7 @@ kotlin { } android { - namespace = "io.github.kdroidfilter.webview.demo.shared" + namespace = "dev.nucleusframework.webview.demo.shared" compileSdk = 35 defaultConfig { diff --git a/demo-shared/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.android.kt b/demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.android.kt similarity index 89% rename from demo-shared/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.android.kt rename to demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.android.kt index 85da775..45a9b51 100644 --- a/demo-shared/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.android.kt +++ b/demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.android.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import java.time.LocalTime diff --git a/demo-shared/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.android.kt b/demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.android.kt similarity index 89% rename from demo-shared/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.android.kt rename to demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.android.kt index f040035..5c616cd 100644 --- a/demo-shared/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.android.kt +++ b/demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.android.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import android.os.Build diff --git a/demo-shared/src/commonMain/composeResources/files/bridge_playground.html b/demo-shared/src/commonMain/composeResources/files/bridge_playground.html index ddfe3a4..7da95e7 100644 --- a/demo-shared/src/commonMain/composeResources/files/bridge_playground.html +++ b/demo-shared/src/commonMain/composeResources/files/bridge_playground.html @@ -157,8 +157,8 @@

Bridge Playground (JS โ†” Kotlin)

- This page calls window.kmpJsBridge.callNative(...). The Desktop/Wry backend routes messages via - window.ipc.postMessage. + This page calls window.kmpJsBridge.callNative(...). Platform backends route messages via + their native bridge channel (e.g. window.ipc.postMessage when available).

Status: waiting for bridgeโ€ฆ
diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt similarity index 98% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt index 12904ca..ab61a49 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/App.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* @@ -6,12 +6,12 @@ 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.rememberWebViewJsBridge -import io.github.kdroidfilter.webview.util.KLogSeverity -import io.github.kdroidfilter.webview.web.WebView -import io.github.kdroidfilter.webview.web.rememberWebViewNavigator -import io.github.kdroidfilter.webview.web.rememberWebViewState +import dev.nucleusframework.webview.cookie.Cookie +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.util.KLogSeverity +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewState import kotlinx.coroutines.launch @Composable diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoJsBridgeHandlers.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoJsBridgeHandlers.kt similarity index 92% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoJsBridgeHandlers.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoJsBridgeHandlers.kt index 4a033f6..2e4cb05 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoJsBridgeHandlers.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoJsBridgeHandlers.kt @@ -1,10 +1,10 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo -import io.github.kdroidfilter.webview.cookie.Cookie -import io.github.kdroidfilter.webview.cookie.CookieManager -import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler -import io.github.kdroidfilter.webview.jsbridge.JsMessage -import io.github.kdroidfilter.webview.web.WebViewNavigator +import dev.nucleusframework.webview.cookie.Cookie +import dev.nucleusframework.webview.cookie.CookieManager +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.web.WebViewNavigator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoRequestInterceptor.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoRequestInterceptor.kt similarity index 80% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoRequestInterceptor.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoRequestInterceptor.kt index da2b94b..4f7751e 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoRequestInterceptor.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoRequestInterceptor.kt @@ -1,9 +1,9 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo -import io.github.kdroidfilter.webview.request.RequestInterceptor -import io.github.kdroidfilter.webview.request.WebRequest -import io.github.kdroidfilter.webview.request.WebRequestInterceptResult -import io.github.kdroidfilter.webview.web.WebViewNavigator +import dev.nucleusframework.webview.request.RequestInterceptor +import dev.nucleusframework.webview.request.WebRequest +import dev.nucleusframework.webview.request.WebRequestInterceptResult +import dev.nucleusframework.webview.web.WebViewNavigator internal class DemoRequestInterceptor( private val enabled: () -> Boolean, diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoToolsPanel.kt similarity index 98% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoToolsPanel.kt index af7eb3b..0f46d04 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoToolsPanel.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoToolsPanel.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,10 +36,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import composewebview.demo_shared.generated.resources.Res -import io.github.kdroidfilter.webview.cookie.Cookie -import io.github.kdroidfilter.webview.util.KLogSeverity -import io.github.kdroidfilter.webview.web.WebViewNavigator -import io.github.kdroidfilter.webview.web.WebViewState +import dev.nucleusframework.webview.cookie.Cookie +import dev.nucleusframework.webview.util.KLogSeverity +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState import kotlinx.coroutines.launch @Composable diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoTopBar.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoTopBar.kt similarity index 99% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoTopBar.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoTopBar.kt index 1f16bcf..c4755a6 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoTopBar.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoTopBar.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -44,7 +44,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import io.github.kdroidfilter.webview.web.LoadingState +import dev.nucleusframework.webview.web.LoadingState @Composable internal fun DemoTopBar( diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.kt similarity index 97% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.kt index a85114f..d5f1ccc 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo internal fun normalizeUrl(raw: String): String { val trimmed = raw.trim() diff --git a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.kt similarity index 52% rename from demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.kt rename to demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.kt index eeda54a..481c324 100644 --- a/demo-shared/src/commonMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.kt @@ -1,3 +1,3 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo internal expect fun platformInfoJson(): String diff --git a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.ios.kt b/demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.ios.kt similarity index 95% rename from demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.ios.kt rename to demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.ios.kt index bd22855..da211d4 100644 --- a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.ios.kt +++ b/demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.ios.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import platform.Foundation.NSCalendar import platform.Foundation.NSDate diff --git a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/MainViewController.kt b/demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/MainViewController.kt similarity index 79% rename from demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/MainViewController.kt rename to demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/MainViewController.kt index bd3a8c8..bfcf07a 100644 --- a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/MainViewController.kt +++ b/demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/MainViewController.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.ui.window.ComposeUIViewController diff --git a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.ios.kt b/demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.ios.kt similarity index 88% rename from demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.ios.kt rename to demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.ios.kt index c7dc629..c2eb6ed 100644 --- a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.ios.kt +++ b/demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.ios.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import platform.UIKit.UIDevice diff --git a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.jvm.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.jvm.kt similarity index 89% rename from demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.jvm.kt rename to demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.jvm.kt index 85da775..45a9b51 100644 --- a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.jvm.kt +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.jvm.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import java.time.LocalTime diff --git a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.jvm.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.jvm.kt similarity index 88% rename from demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.jvm.kt rename to demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.jvm.kt index 4dd0041..ea35719 100644 --- a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.jvm.kt +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.jvm.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo internal actual fun platformInfoJson(): String { val os = System.getProperty("os.name").orEmpty() diff --git a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.wasmJs.kt b/demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.wasmJs.kt similarity index 75% rename from demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.wasmJs.kt rename to demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.wasmJs.kt index 30448f4..a47d1e6 100644 --- a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.wasmJs.kt +++ b/demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.wasmJs.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo @OptIn(ExperimentalWasmJsInterop::class) internal actual fun nowTimestamp(): String = js( diff --git a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt b/demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.wasmJs.kt similarity index 89% rename from demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt rename to demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.wasmJs.kt index 099cc99..fa8c9d3 100644 --- a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt +++ b/demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/PlatformInfo.wasmJs.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import kotlinx.browser.window import kotlinx.serialization.json.buildJsonObject diff --git a/demo-wasmJs/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt b/demo-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/main.kt similarity index 88% rename from demo-wasmJs/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt rename to demo-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/main.kt index a32a0ae..934c052 100644 --- a/demo-wasmJs/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt +++ b/demo-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/main.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalComposeUiApi::class) -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index d83307b..820d041 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -29,12 +29,11 @@ kotlin { compose.desktop { application { - mainClass = "io.github.kdroidfilter.webview.demo.MainKt" - jvmArgs += "--enable-native-access=ALL-UNNAMED" + mainClass = "dev.nucleusframework.webview.demo.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "io.github.kdroidfilter.webview.demo" + packageName = "dev.nucleusframework.webview.demo" packageVersion = "1.0.0" } } diff --git a/demo/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt similarity index 90% rename from demo/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt rename to demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt index 851d68d..10cd53b 100644 --- a/demo/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/main.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.ui.window.Window import androidx.compose.ui.window.application diff --git a/demo/src/jvmTest/kotlin/io/github/kdroidfilter/webview/demo/WebViewTest.kt b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt similarity index 96% rename from demo/src/jvmTest/kotlin/io/github/kdroidfilter/webview/demo/WebViewTest.kt rename to demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt index 7d6fb5e..eb2a020 100644 --- a/demo/src/jvmTest/kotlin/io/github/kdroidfilter/webview/demo/WebViewTest.kt +++ b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalTestApi::class) -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.demo import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -8,14 +8,14 @@ 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 dev.nucleusframework.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" +const val TEST_URL = "https://github.com/NucleusFramework/ComposeNativeWebview" class WebViewTest { @Test diff --git a/gradle.properties b/gradle.properties index c286b38..943d61d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,16 +11,16 @@ org.gradle.caching=true android.useAndroidX=true #Maven Publishing -GROUP=io.github.kdroidfilter +GROUP=dev.nucleusframework VERSION_NAME=0.1.0-SNAPSHOT POM_INCEPTION_YEAR=2024 -POM_URL=https://github.com/kdroidFilter/ComposeDesktopNativeWebiew +POM_URL=https://github.com/NucleusFramework/ComposeNativeWebview POM_LICENSE_NAME=MIT License POM_LICENSE_URL=https://opensource.org/licenses/MIT POM_LICENSE_DIST=repo -POM_SCM_URL=https://github.com/kdroidFilter/ComposeDesktopNativeWebiew -POM_SCM_CONNECTION=scm:git:git://github.com/kdroidFilter/ComposeDesktopNativeWebiew.git -POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/kdroidFilter/ComposeDesktopNativeWebiew.git -POM_DEVELOPER_ID=kdroidFilter -POM_DEVELOPER_NAME=kdroidFilter -POM_DEVELOPER_URL=https://github.com/kdroidFilter +POM_SCM_URL=https://github.com/NucleusFramework/ComposeNativeWebview +POM_SCM_CONNECTION=scm:git:git://github.com/NucleusFramework/ComposeNativeWebview.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/NucleusFramework/ComposeNativeWebview.git +POM_DEVELOPER_ID=nucleusframework +POM_DEVELOPER_NAME=NucleusFramework +POM_DEVELOPER_URL=https://github.com/NucleusFramework diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b886a1b..6aa8ede 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,15 @@ [versions] androidx-activity = "1.12.2" -mavenPublish = "0.35.0" -androidx-lifecycle = "2.9.6" -androidGradlePlugin = "8.12.3" -composeHotReload = "1.0.0" -composeMultiplatform = "1.10.0-rc02" -gobley = "0.3.7" +mavenPublish = "0.37.0" +androidx-lifecycle = "2.11.0" +androidGradlePlugin = "8.13.2" +composeHotReload = "1.2.0" +composeMultiplatform = "1.11.1" google-material = "1.13.0" -jna = "5.18.1" -playwright = "1.49.0" -kotlin = "2.2.21" -kotlinx-coroutines = "1.10.2" -kotlinx-serialization = "1.9.0" -skiko = "0.9.37.3" +playwright = "1.62.0" +kotlin = "2.4.10" +kotlinx-coroutines = "1.11.0" +kotlinx-serialization = "1.11.0" [libraries] compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } @@ -25,9 +22,7 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s 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-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" } -skiko-awt = { module = "org.jetbrains.skiko:skiko-awt", version.ref = "skiko" } [plugins] androidApplication = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -39,7 +34,4 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio 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" } -gobleyCargo = { id = "dev.gobley.cargo", version.ref = "gobley" } -gobleyRust = { id = "dev.gobley.rust", version.ref = "gobley" } -gobleyUniffi = { id = "dev.gobley.uniffi", version.ref = "gobley" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 2838cce..a9a5f6e 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,3 +1,3 @@ TEAM_ID= -BUNDLE_ID=io.github.kdroidfilter.webview.demo.ios +BUNDLE_ID=dev.nucleusframework.webview.demo.ios APP_NAME=ComposeWebView Demo diff --git a/settings.gradle.kts b/settings.gradle.kts index d082e3f..8d40200 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,5 @@ include(":demo") include(":demo-shared") include(":demo-android") include(":demo-wasmJs") -include(":wrywebview") include(":webview-compose") include(":webview-compose-test") diff --git a/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt b/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt similarity index 73% rename from webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt rename to webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt index 0dfd8bf..eaec4c8 100644 --- a/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt +++ b/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt @@ -1,24 +1,20 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.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 java.io.ByteArrayOutputStream 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. + * A mock [NativeWebView] that uses Playwright for some operations. + * 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()) -) { +class PlaywrightWebView(param: WebViewFactoryParam) : NativeWebView() { val evaluatedScripts = mutableListOf() var currentContent: String = (param.state.content as? WebContent.Url)?.url ?: "about:blank" @@ -60,13 +56,12 @@ class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel( currentContent = "HTML content" } - override fun stopLoading() {} - override fun reload() {} - override fun goBack() {} - override fun goForward() {} + override fun stopLoading() = Unit + override fun reload() = Unit + override fun goBack() = Unit + override fun goForward() = Unit - override fun captureScreenshot(nativeBytes: ByteArray?): BufferedImage { - // Try to use Playwright for a real screenshot if it's a URL + override fun captureScreenshotNative(): ByteArray? { if (currentContent.startsWith("http")) { runCatching { Playwright.create().use { playwright -> @@ -74,8 +69,7 @@ class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel( browser.newPage().use { page -> page.setViewportSize(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT) page.navigate(currentContent) - val bytes = page.screenshot() - return ImageIO.read(ByteArrayInputStream(bytes)) + return page.screenshot() } } } @@ -86,14 +80,26 @@ class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel( 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 + val output = ByteArrayOutputStream() + ImageIO.write(img, "png", output) + return output.toByteArray() + } + + /** + * Convenience helper used by tests that still want a [BufferedImage]. + */ + fun captureScreenshot(): BufferedImage { + val bytes = captureScreenshotNative() ?: return BufferedImage( + PLAYWRIGHT_PAGE_WIDTH, + PLAYWRIGHT_PAGE_HEIGHT, + BufferedImage.TYPE_INT_ARGB, + ) + return ImageIO.read(ByteArrayInputStream(bytes)) } } diff --git a/webview-compose/README.md b/webview-compose/README.md index b2512cb..883e4e3 100644 --- a/webview-compose/README.md +++ b/webview-compose/README.md @@ -1,25 +1,23 @@ -# wrywebview-compose +# webview-compose -Compose Desktop wrapper for the `wrywebview` module, exposing the `io.github.kdroidfilter.webview.*` API (inspired by `compose-webview-multiplatform`). +Compose Multiplatform WebView library exposing the `dev.nucleusframework.webview.*` API +(inspired by `compose-webview-multiplatform`). -## Usage (JVM) - -Add the dependency: +## Usage ```kotlin dependencies { - implementation(project(":wrywebview-compose")) + implementation(project(":webview-compose")) + // or: implementation("dev.nucleusframework:composewebview:") } ``` -Use the composable: - ```kotlin import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import io.github.kdroidfilter.webview.web.WebView -import io.github.kdroidfilter.webview.web.rememberWebViewState +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.rememberWebViewState @Composable fun App() { @@ -28,6 +26,9 @@ fun App() { } ``` -Notes: -- JVM only. -- The composable delegates to `WryWebViewPanel` from `:wrywebview`. +## Platforms + +- **Android**: `android.webkit.WebView` +- **iOS**: `WKWebView` +- **WasmJs**: `HTMLIFrameElement` +- **Desktop (JVM)**: currently a no-op stub (API available, no rendering) diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts index 0730223..a381f9d 100644 --- a/webview-compose/build.gradle.kts +++ b/webview-compose/build.gradle.kts @@ -22,7 +22,6 @@ kotlin { } listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -51,7 +50,6 @@ kotlin { } jvmMain.dependencies { - api(project(":wrywebview")) implementation(libs.kotlinx.coroutinesSwing) } @@ -62,7 +60,7 @@ kotlin { } android { - namespace = "io.github.kdroidfilter.webview" + namespace = "dev.nucleusframework.webview" compileSdk = 35 defaultConfig { @@ -105,7 +103,7 @@ fun org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget.setUpiOSObserver() } mavenPublishing { - configure(KotlinMultiplatform(androidVariantsToPublish = listOf("release"), sourcesJar = true)) + configure(KotlinMultiplatform(sourcesJar = true)) publishToMavenCentral() if (project.findProperty("signingInMemoryKey") != null) { signAllPublications() diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/cookie/AndroidCookieManager.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt similarity index 97% rename from webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/cookie/AndroidCookieManager.kt rename to webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt index 0a46a4e..bf8d9f8 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/cookie/AndroidCookieManager.kt +++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie import android.webkit.CookieManager as PlatformCookieManager import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.android.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.android.kt similarity index 91% rename from webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.android.kt rename to webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.android.kt index 2e9c54f..457ba21 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.android.kt +++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.android.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie import java.text.SimpleDateFormat import java.util.Date diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt similarity index 95% rename from webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt rename to webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt index 985c7f0..18767ab 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt +++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt @@ -1,13 +1,13 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.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 dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.parseJsMessage +import dev.nucleusframework.webview.util.KLogger import kotlinx.coroutines.CoroutineScope import java.io.ByteArrayOutputStream diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.android.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.android.kt similarity index 63% rename from webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.android.kt rename to webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.android.kt index 581a2a2..c36b59b 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.android.kt +++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.android.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import android.webkit.WebView diff --git a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt similarity index 97% rename from webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt rename to webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt index 67eeac5..7092a7a 100644 --- a/webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/WebViewAndroid.kt +++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import android.content.Context import android.graphics.Bitmap @@ -14,10 +14,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -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 dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.request.WebRequest +import dev.nucleusframework.webview.request.WebRequestInterceptResult +import dev.nucleusframework.webview.setting.WebSettings @Composable actual fun ActualWebView( diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.kt similarity index 96% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.kt index 419cc1d..47c50fe 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie /** * Cookie data class. diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/CookieManager.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/cookie/CookieManager.kt similarity index 91% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/CookieManager.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/cookie/CookieManager.kt index 0395d30..d712dbf 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/cookie/CookieManager.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/cookie/CookieManager.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie /** * Cookie Manager exposing access to cookies of the WebView. diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/IJsMessageHandler.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/IJsMessageHandler.kt similarity index 89% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/IJsMessageHandler.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/IJsMessageHandler.kt index 2ef7e20..e5c4592 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/IJsMessageHandler.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/IJsMessageHandler.kt @@ -1,6 +1,6 @@ -package io.github.kdroidfilter.webview.jsbridge +package dev.nucleusframework.webview.jsbridge -import io.github.kdroidfilter.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewNavigator import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt similarity index 86% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt index 7418ad4..f6e4265 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessage.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.jsbridge +package dev.nucleusframework.webview.jsbridge import kotlinx.serialization.Serializable diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageDispatcher.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcher.kt similarity index 86% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageDispatcher.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcher.kt index 65d7556..c65f54d 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageDispatcher.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcher.kt @@ -1,7 +1,7 @@ -package io.github.kdroidfilter.webview.jsbridge +package dev.nucleusframework.webview.jsbridge import androidx.compose.runtime.Immutable -import io.github.kdroidfilter.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewNavigator @Immutable internal class JsMessageDispatcher { diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt similarity index 96% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt index 56a1a40..09ea924 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/JsMessageParsing.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.jsbridge +package dev.nucleusframework.webview.jsbridge import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/WebViewJsBridge.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridge.kt similarity index 86% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/WebViewJsBridge.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridge.kt index c392512..0e7e980 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/jsbridge/WebViewJsBridge.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridge.kt @@ -1,11 +1,11 @@ -package io.github.kdroidfilter.webview.jsbridge +package dev.nucleusframework.webview.jsbridge import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember -import io.github.kdroidfilter.webview.util.KLogger -import io.github.kdroidfilter.webview.web.IWebView -import io.github.kdroidfilter.webview.web.WebViewNavigator +import dev.nucleusframework.webview.util.KLogger +import dev.nucleusframework.webview.web.IWebView +import dev.nucleusframework.webview.web.WebViewNavigator import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/RequestInterceptor.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/RequestInterceptor.kt similarity index 61% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/RequestInterceptor.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/RequestInterceptor.kt index bd0abff..6820298 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/RequestInterceptor.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/RequestInterceptor.kt @@ -1,6 +1,6 @@ -package io.github.kdroidfilter.webview.request +package dev.nucleusframework.webview.request -import io.github.kdroidfilter.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewNavigator interface RequestInterceptor { fun onInterceptUrlRequest( diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/WebRequest.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/WebRequest.kt similarity index 82% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/WebRequest.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/WebRequest.kt index 85010f7..c37b2d2 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/WebRequest.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/WebRequest.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.request +package dev.nucleusframework.webview.request data class WebRequest( val url: String, diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/WebRequestInterceptResult.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/WebRequestInterceptResult.kt similarity index 83% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/WebRequestInterceptResult.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/WebRequestInterceptResult.kt index 8467a56..991a669 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/request/WebRequestInterceptResult.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/request/WebRequestInterceptResult.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.request +package dev.nucleusframework.webview.request sealed interface WebRequestInterceptResult { data object Allow : WebRequestInterceptResult diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt similarity index 96% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt index e8d3c97..21871a0 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/PlatformWebSettings.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.setting +package dev.nucleusframework.webview.setting import androidx.compose.ui.graphics.Color diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/WebSettings.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/WebSettings.kt similarity index 89% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/WebSettings.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/WebSettings.kt index 48d2608..fbafd3f 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/setting/WebSettings.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/WebSettings.kt @@ -1,12 +1,12 @@ -package io.github.kdroidfilter.webview.setting +package dev.nucleusframework.webview.setting import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color -import io.github.kdroidfilter.webview.util.KLogSeverity -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.util.KLogSeverity +import dev.nucleusframework.webview.util.KLogger /** * Web settings for different platforms. diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/util/Extension.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/util/Extension.kt similarity index 77% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/util/Extension.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/util/Extension.kt index 2ede921..b68c33d 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/util/Extension.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/util/Extension.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.util +package dev.nucleusframework.webview.util fun Pair?.isZero(): Boolean = this == null || (first == 0 && second == 0) diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/util/KLogger.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/util/KLogger.kt similarity index 98% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/util/KLogger.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/util/KLogger.kt index 8bc4155..f41b06d 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/util/KLogger.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/util/KLogger.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.util +package dev.nucleusframework.webview.util /** * Lightweight logger used by the API layer. diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt similarity index 97% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt index 3aaf756..9f08c27 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/IWebView.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt @@ -1,6 +1,6 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web -import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/LoadingState.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/LoadingState.kt similarity index 82% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/LoadingState.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/LoadingState.kt index f41faad..41528c4 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/LoadingState.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/LoadingState.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web sealed class LoadingState { data object Initializing : LoadingState() diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebContent.kt similarity index 92% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebContent.kt index 55e3220..c745ceb 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebContent.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebContent.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web sealed class WebContent { data class Url( diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt similarity index 95% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt index 7168092..ea89757 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebView.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt @@ -1,8 +1,8 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.merge diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewError.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewError.kt similarity index 79% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewError.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewError.kt index e30f4a4..4b77674 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewError.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewError.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.runtime.Immutable diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewFileReadType.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewFileReadType.kt similarity index 66% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewFileReadType.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewFileReadType.kt index eabfb1b..80b5ed9 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewFileReadType.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewFileReadType.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web enum class WebViewFileReadType { ASSET_RESOURCES, diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewNavigator.kt similarity index 92% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewNavigator.kt index 5f02416..3fc2643 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewNavigator.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewNavigator.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import io.github.kdroidfilter.webview.request.RequestInterceptor +import dev.nucleusframework.webview.request.RequestInterceptor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -66,19 +66,19 @@ class WebViewNavigator( loadUrl(normalizedUrl, event.additionalHttpHeaders) } else { val request = - io.github.kdroidfilter.webview.request.WebRequest( + dev.nucleusframework.webview.request.WebRequest( url = normalizedUrl, headers = event.additionalHttpHeaders.toMutableMap(), isForMainFrame = true, method = "GET", ) when (val result = interceptor.onInterceptUrlRequest(request, this@WebViewNavigator)) { - io.github.kdroidfilter.webview.request.WebRequestInterceptResult.Allow -> + dev.nucleusframework.webview.request.WebRequestInterceptResult.Allow -> loadUrl(request.url, request.headers) - io.github.kdroidfilter.webview.request.WebRequestInterceptResult.Reject -> Unit + dev.nucleusframework.webview.request.WebRequestInterceptResult.Reject -> Unit - is io.github.kdroidfilter.webview.request.WebRequestInterceptResult.Modify -> + is dev.nucleusframework.webview.request.WebRequestInterceptResult.Modify -> loadUrl(result.request.url, result.request.headers) } } diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt similarity index 89% rename from webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt rename to webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt index dbfbc5c..0f83bfa 100644 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt @@ -1,10 +1,10 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList -import io.github.kdroidfilter.webview.cookie.CookieManager -import io.github.kdroidfilter.webview.cookie.WebViewCookieManager -import io.github.kdroidfilter.webview.setting.WebSettings +import dev.nucleusframework.webview.cookie.CookieManager +import dev.nucleusframework.webview.cookie.WebViewCookieManager +import dev.nucleusframework.webview.setting.WebSettings @Stable class WebViewState( diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.ios.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.ios.kt similarity index 82% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.ios.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.ios.kt index 32dd51b..6624325 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.ios.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.ios.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie actual fun getCookieExpirationDate(expiresDate: Long): String = formatCookieExpirationDate(expiresDate) diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/cookie/IOSCookieManager.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/cookie/IOSCookieManager.kt similarity index 98% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/cookie/IOSCookieManager.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/cookie/IOSCookieManager.kt index 43fbca9..f414eaf 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/cookie/IOSCookieManager.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/cookie/IOSCookieManager.kt @@ -1,6 +1,6 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.util.KLogger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine import platform.Foundation.NSDate diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/jsbridge/WKJsMessageHandler.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/jsbridge/WKJsMessageHandler.kt similarity index 88% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/jsbridge/WKJsMessageHandler.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/jsbridge/WKJsMessageHandler.kt index 19cf541..a4bf248 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/jsbridge/WKJsMessageHandler.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/jsbridge/WKJsMessageHandler.kt @@ -1,6 +1,6 @@ -package io.github.kdroidfilter.webview.jsbridge +package dev.nucleusframework.webview.jsbridge -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.util.KLogger import platform.WebKit.WKScriptMessage import platform.WebKit.WKScriptMessageHandlerProtocol import platform.WebKit.WKUserContentController diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/util/Color.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/util/Color.kt similarity index 85% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/util/Color.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/util/Color.kt index e46ebf8..59543e6 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/util/Color.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/util/Color.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.util +package dev.nucleusframework.webview.util import androidx.compose.ui.graphics.Color import platform.UIKit.UIColor diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/IOSWebView.kt similarity index 97% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/IOSWebView.kt index 509e5ca..e82e560 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/IOSWebView.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/IOSWebView.kt @@ -1,8 +1,8 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web -import io.github.kdroidfilter.webview.jsbridge.WKJsMessageHandler -import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.jsbridge.WKJsMessageHandler +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.util.KLogger import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.ios.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.ios.kt similarity index 64% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.ios.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.ios.kt index e48dfb4..0c3dc8d 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.ios.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.ios.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import platform.WebKit.WKWebView diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKNavigationDelegate.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKNavigationDelegate.kt similarity index 95% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKNavigationDelegate.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKNavigationDelegate.kt index 04deb92..7fbb63d 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKNavigationDelegate.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKNavigationDelegate.kt @@ -1,8 +1,8 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web -import io.github.kdroidfilter.webview.request.WebRequest -import io.github.kdroidfilter.webview.request.WebRequestInterceptResult -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.request.WebRequest +import dev.nucleusframework.webview.request.WebRequestInterceptResult +import dev.nucleusframework.webview.util.KLogger import kotlinx.cinterop.ObjCSignatureOverride import platform.Foundation.HTTPMethod import platform.Foundation.NSError diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKWebViewExt.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKWebViewExt.kt similarity index 96% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKWebViewExt.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKWebViewExt.kt index 84fb5d4..b3757ed 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKWebViewExt.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKWebViewExt.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.addObserver diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKWebViewObserver.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKWebViewObserver.kt similarity index 94% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKWebViewObserver.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKWebViewObserver.kt index a21509b..cde3578 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WKWebViewObserver.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WKWebViewObserver.kt @@ -1,6 +1,6 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.util.KLogger import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi import observer.ObserverProtocol diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt similarity index 96% rename from webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt rename to webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt index 708e783..ea9d755 100644 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -9,9 +9,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode import androidx.compose.ui.viewinterop.UIKitInteropProperties import androidx.compose.ui.viewinterop.UIKitView -import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge -import io.github.kdroidfilter.webview.setting.WebSettings -import io.github.kdroidfilter.webview.util.toUIColor +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.setting.WebSettings +import dev.nucleusframework.webview.util.toUIColor import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.cValue import kotlinx.cinterop.readValue diff --git a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.desktop.kt similarity index 79% rename from webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt rename to webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.desktop.kt index a50224c..93b5ccd 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.desktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.desktop.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie import java.text.SimpleDateFormat import java.util.Date @@ -16,5 +16,4 @@ actual fun getCookieExpirationDate(expiresDate: Long): String { } @Suppress("FunctionName") // Builder Function -actual fun WebViewCookieManager(): CookieManager = WryCookieManager() - +actual fun WebViewCookieManager(): CookieManager = DesktopCookieManager() diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt new file mode 100644 index 0000000..fd6059c --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt @@ -0,0 +1,14 @@ +package dev.nucleusframework.webview.cookie + +/** + * No-op cookie manager for desktop. + */ +internal class DesktopCookieManager : CookieManager { + override suspend fun setCookie(url: String, cookie: Cookie) = Unit + + override suspend fun getCookies(url: String): List = emptyList() + + override suspend fun removeAllCookies() = Unit + + override suspend fun removeCookies(url: String) = Unit +} diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt new file mode 100644 index 0000000..f425721 --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt @@ -0,0 +1,68 @@ +package dev.nucleusframework.webview.web + +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import kotlinx.coroutines.CoroutineScope + +/** + * Desktop [IWebView] implementation. All operations are no-ops until a native + * backend is wired back in. + */ +internal class DesktopWebView( + override val nativeWebView: NativeWebView, + override val scope: CoroutineScope, + override val webViewJsBridge: WebViewJsBridge?, +) : IWebView { + init { + initWebView() + } + + override fun canGoBack(): Boolean = nativeWebView.canGoBack() + + override fun canGoForward(): Boolean = nativeWebView.canGoForward() + + override fun loadUrl( + url: String, + additionalHttpHeaders: Map, + ) { + nativeWebView.loadUrl(url, additionalHttpHeaders) + } + + override suspend fun loadHtml( + html: String?, + baseUrl: String?, + mimeType: String?, + encoding: String?, + historyUrl: String?, + ) { + if (html == null) return + nativeWebView.loadHtml(html) + } + + override suspend fun loadHtmlFile( + fileName: String, + readType: WebViewFileReadType, + ) = Unit + + override fun goBack() = nativeWebView.goBack() + + override fun goForward() = nativeWebView.goForward() + + override fun reload() = nativeWebView.reload() + + override fun stopLoading() = nativeWebView.stopLoading() + + override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { + nativeWebView.evaluateJavaScript(script) { result -> + callback?.invoke(result) + } + } + + override suspend fun captureScreenshotOrNull(): ByteArray? = + nativeWebView.captureScreenshotNative() + + override fun injectJsBridge() { + // No-op on desktop until a native JS bridge backend is available. + } + + override fun initJsBridge(webViewJsBridge: WebViewJsBridge) = Unit +} diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt new file mode 100644 index 0000000..d783d2e --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt @@ -0,0 +1,53 @@ +package dev.nucleusframework.webview.web + +/** + * Desktop [NativeWebView] placeholder. + * + * Desktop WebView is currently a no-op: all operations are intentionally empty + * so the shared API compiles and demos run without a native backend. + */ +actual open class NativeWebView { + open fun isReady(): Boolean = false + + open fun isLoading(): Boolean = false + + open fun getCurrentUrl(): String? = null + + open fun getTitle(): String? = null + + open fun canGoBack(): Boolean = false + + open fun canGoForward(): Boolean = false + + open fun loadUrl(url: String, additionalHttpHeaders: Map = emptyMap()) = Unit + + open fun loadHtml(html: String) = Unit + + open fun goBack() = Unit + + open fun goForward() = Unit + + open fun reload() = Unit + + open fun stopLoading() = Unit + + open fun evaluateJavaScript(script: String, callback: (String) -> Unit = {}) { + callback("") + } + + open fun drainIpcMessages(): List = emptyList() + + open fun addNavigateListener(listener: (String) -> Boolean) = Unit + + open fun removeNavigateListener(listener: (String) -> Boolean) = Unit + + open fun captureScreenshotNative(): ByteArray? = null + + open fun openDevTools() = Unit + + open fun closeDevTools() = Unit + + open fun focus() = Unit + + open fun destroy() = Unit +} diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt new file mode 100644 index 0000000..c3266b6 --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt @@ -0,0 +1,94 @@ +package dev.nucleusframework.webview.web + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO + +actual class WebViewFactoryParam( + val state: WebViewState, + val fileContent: String = "", +) + +actual fun defaultWebViewFactory( + param: WebViewFactoryParam +): NativeWebView = NativeWebView() + +/** + * Desktop WebView composable (no-op). + * + * Renders an empty [Box] and wires [WebViewState] / navigator hooks so the + * shared API remains usable while desktop has no native backend. + */ +@Composable +actual fun ActualWebView( + state: WebViewState, + modifier: Modifier, + navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, + onCreated: (NativeWebView) -> Unit, + onDispose: (NativeWebView) -> Unit, + factory: (WebViewFactoryParam) -> NativeWebView, +) { + val currentOnDispose by rememberUpdatedState(onDispose) + val scope = rememberCoroutineScope() + + val nativeWebView = remember(state, factory) { + state.webView?.nativeWebView ?: factory(WebViewFactoryParam(state)) + } + + val desktopWebView = remember(nativeWebView, scope, webViewJsBridge) { + DesktopWebView( + nativeWebView = nativeWebView, + scope = scope, + webViewJsBridge = webViewJsBridge, + ) + } + + LaunchedEffect(desktopWebView) { + state.webView = desktopWebView + webViewJsBridge?.webView = desktopWebView + // Mark as finished immediately: nothing is loading on a no-op backend. + state.loadingState = LoadingState.Finished + navigator.canGoBack = false + navigator.canGoForward = false + } + + Box(modifier) { + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + } + + DisposableEffect(nativeWebView) { + onDispose { + state.webView = null + webViewJsBridge?.webView = null + currentOnDispose(nativeWebView) + nativeWebView.destroy() + } + } +} + +/** + * Captures a screenshot of the WebView and returns it as a [BufferedImage]. + * Always returns null on the desktop no-op backend unless a custom + * [NativeWebView] override provides image bytes. + */ +suspend fun IWebView.toAwtImage(): BufferedImage? { + val bytes = captureScreenshotOrNull() ?: return null + return withContext(Dispatchers.IO) { + ImageIO.read(ByteArrayInputStream(bytes)) + } +} 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 deleted file mode 100644 index 87d46ce..0000000 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/cookie/WryCookieManager.kt +++ /dev/null @@ -1,91 +0,0 @@ -package io.github.kdroidfilter.webview.cookie - -import io.github.kdroidfilter.webview.util.KLogger -import io.github.kdroidfilter.webview.wry.CookieSameSite -import io.github.kdroidfilter.webview.wry.WebViewCookie -import io.github.kdroidfilter.webview.wry.WryWebViewPanel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -internal class WryCookieManager : CookieManager { - @Volatile - private var panel: WryWebViewPanel? = null - - internal fun attach(panel: WryWebViewPanel) { - this.panel = panel - } - - override suspend fun setCookie(url: String, cookie: Cookie) { - val panel = panel ?: return - val native = cookie.toNativeCookie() - withContext(Dispatchers.IO) { - KLogger.d(tag = "WryCookieManager") { "setCookie url=$url name=${cookie.name}" } - panel.setCookie(native) - } - } - - override suspend fun getCookies(url: String): List { - val panel = panel ?: return emptyList() - return withContext(Dispatchers.IO) { - runCatching { - panel.getCookiesForUrl(url).map { it.toCompatCookie() } - }.getOrElse { - KLogger.e(it, tag = "WryCookieManager") { "getCookies failed url=$url" } - emptyList() - } - } - } - - override suspend fun removeAllCookies() { - val panel = panel ?: return - withContext(Dispatchers.IO) { - runCatching { panel.clearAllCookies() } - .onFailure { KLogger.e(it, tag = "WryCookieManager") { "removeAllCookies failed" } } - } - } - - override suspend fun removeCookies(url: String) { - val panel = panel ?: return - withContext(Dispatchers.IO) { - runCatching { panel.clearCookiesForUrl(url) } - .onFailure { KLogger.e(it, tag = "WryCookieManager") { "removeCookies failed url=$url" } } - } - } -} - -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, -) - 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 deleted file mode 100644 index 294eb56..0000000 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt +++ /dev/null @@ -1,140 +0,0 @@ -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, - override val scope: CoroutineScope, - override val webViewJsBridge: WebViewJsBridge?, -) : IWebView { - init { - initWebView() - } - - override fun canGoBack(): Boolean = nativeWebView.canGoBack() - - override fun canGoForward(): Boolean = nativeWebView.canGoForward() - - override fun loadUrl( - url: String, - additionalHttpHeaders: Map, - ) { - nativeWebView.loadUrl(url, additionalHttpHeaders) - } - - override suspend fun loadHtml( - html: String?, - baseUrl: String?, - mimeType: String?, - encoding: String?, - historyUrl: String?, - ) { - if (html == null) return - nativeWebView.loadHtml(html) - } - - override suspend fun loadHtmlFile( - 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.firstNotNullOfOrNull { path -> - loaders.firstNotNullOfOrNull { loader -> - loader.getResourceAsStream(path) - }?.use { it.readBytes().toString(Charsets.UTF_8) } - } ?: error("Resource not found: ${candidates.joinToString()}") - } - - 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) - } - - override fun goBack() = nativeWebView.goBack() - - override fun goForward() = nativeWebView.goForward() - - override fun reload() = nativeWebView.reload() - - override fun stopLoading() = nativeWebView.stopLoading() - - override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { - KLogger.d { - "evaluateJavaScript: $script" - } - nativeWebView.evaluateJavaScript(script) { result -> - callback?.invoke(result) - } - } - - 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() - - //language=JavaScript - val js = """ - if (window.${bridge.jsBridgeName} && window.ipc && window.ipc.postMessage) { - window.${bridge.jsBridgeName}.postMessage = function (message) { - window.ipc.postMessage(message); - }; - } - """.trimIndent() - evaluateJavaScript(js) - } - - override fun initJsBridge(webViewJsBridge: WebViewJsBridge) { - // No-op: IPC is configured in the Rust layer via wry's `with_ipc_handler`. - } -} 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 deleted file mode 100644 index 187f5d4..0000000 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/NativeWebView.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.kdroidfilter.webview.web - -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 deleted file mode 100644 index e7f23b4..0000000 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/WebViewDesktop.kt +++ /dev/null @@ -1,246 +0,0 @@ -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 -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import io.github.kdroidfilter.webview.cookie.WryCookieManager -import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge -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, - 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 - ) -} - -@Composable -actual fun ActualWebView( - state: WebViewState, - modifier: Modifier, - navigator: WebViewNavigator, - webViewJsBridge: WebViewJsBridge?, - onCreated: (NativeWebView) -> Unit, - onDispose: (NativeWebView) -> Unit, - factory: (WebViewFactoryParam) -> NativeWebView, -) { - val currentOnDispose by rememberUpdatedState(onDispose) - val scope = rememberCoroutineScope() - - val desiredSettingsKey = state.webSettings.let { - listOf( - it.customUserAgentString?.trim()?.takeIf(String::isNotEmpty), - it.supportZoom, - it.backgroundColor, - ) - } - - var effectiveSettingsKey by remember { mutableStateOf(desiredSettingsKey) } - - LaunchedEffect(desiredSettingsKey) { - if (desiredSettingsKey != effectiveSettingsKey) { - delay(400.milliseconds) - effectiveSettingsKey = desiredSettingsKey - } - } - - key(effectiveSettingsKey) { - val nativeWebView = remember(state, factory) { - state.webView?.nativeWebView ?: factory(WebViewFactoryParam(state)) - } - - val desktopWebView = remember(nativeWebView, scope, webViewJsBridge) { - DesktopWebView( - nativeWebView = nativeWebView, - scope = scope, - webViewJsBridge = webViewJsBridge, - ) - } - - LaunchedEffect(desktopWebView) { - state.webView = desktopWebView - webViewJsBridge?.webView = desktopWebView - (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.milliseconds) - continue - } - - 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) - else -> 0.1f - } - LoadingState.Loading(next) - } else { - LoadingState.Finished - } - - val url = nativeWebView.getCurrentUrl() - if (!url.isNullOrBlank()) { - if (!isLoading || state.lastLoadedUrl.isNullOrBlank()) { - state.lastLoadedUrl = url - } - } - - val title = nativeWebView.getTitle() - if (!title.isNullOrBlank()) { - state.pageTitle = title - } - - navigator.canGoBack = nativeWebView.canGoBack() - navigator.canGoForward = nativeWebView.canGoForward() - - delay(250.milliseconds) - } - } - - LaunchedEffect(it) { - while (true) { - if (webViewJsBridge != null) { - for (raw in nativeWebView.drainIpcMessages()) { - parseJsMessage(raw)?.let { webViewJsBridge.dispatch(it) } - } - } - delay(50.milliseconds) - } - } - } - - DisposableEffect(nativeWebView) { - val listener: (String) -> Boolean = a@{ - if (navigator.requestInterceptor == null) { - return@a true - } - - val webRequest = WebRequest( - url = it, - headers = mutableMapOf(), - isForMainFrame = true, - isRedirect = true - ) - - return@a when (val interceptResult = - navigator.requestInterceptor.onInterceptUrlRequest(webRequest, navigator)) { - WebRequestInterceptResult.Allow -> true - - WebRequestInterceptResult.Reject -> false - - is WebRequestInterceptResult.Modify -> { - interceptResult.request.let { modified -> - navigator.stopLoading() - navigator.loadUrl(modified.url, modified.headers) - } - false //no jump? - } - } - } - nativeWebView.addNavigateListener(listener) - onDispose { - nativeWebView.removeNavigateListener(listener) - } - } - - if (LocalWebViewFactory.current != null) { - Box(modifier) { - LaunchedEffect(nativeWebView) { - onCreated(nativeWebView) - } - } - } else { - SwingPanel( - modifier = modifier, - factory = { - onCreated(nativeWebView) - nativeWebView - } - ) - } - - DisposableEffect(nativeWebView) { - onDispose { - state.webView = null - webViewJsBridge?.webView = null - currentOnDispose(nativeWebView) - } - } - } -} - -private fun Color.toRgba(): Rgba { - val argb: Int = this.toArgb() // 0xAARRGGBB (sRGB) - val a: UByte = ((argb ushr 24) and 0xFF).toUByte() - val r: UByte = ((argb ushr 16) and 0xFF).toUByte() - val g: UByte = ((argb ushr 8) and 0xFF).toUByte() - 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/cookie/Cookie.wasmJs.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.wasmJs.kt similarity index 96% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.wasmJs.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.wasmJs.kt index 43fa5c7..a32ac79 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/cookie/Cookie.wasmJs.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/cookie/Cookie.wasmJs.kt @@ -1,7 +1,7 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.util.KLogger import kotlinx.browser.document /** diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/HtmlView.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/HtmlView.kt similarity index 99% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/HtmlView.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/HtmlView.kt index 2f3f523..e8231e1 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/HtmlView.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/HtmlView.kt @@ -1,5 +1,5 @@ @file:OptIn(ExperimentalUuidApi::class, ExperimentalWasmJsInterop::class) -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.foundation.layout.Box import androidx.compose.runtime.* @@ -11,7 +11,7 @@ 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 dev.nucleusframework.webview.util.KLogger import kotlinx.browser.document import kotlinx.coroutines.launch import kotlinx.serialization.json.buildJsonObject diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/JsInterop.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/JsInterop.kt similarity index 98% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/JsInterop.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/JsInterop.kt index 5ea67cb..d9a69a2 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/JsInterop.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/JsInterop.kt @@ -1,5 +1,5 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import org.w3c.dom.Element import kotlin.js.Promise diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WasmJsWebView.kt similarity index 97% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WasmJsWebView.kt index e914e0f..3aa1a9e 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WasmJsWebView.kt @@ -1,8 +1,8 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web -import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.util.KLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.await import kotlinx.coroutines.delay diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebViewNavigator.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WasmJsWebViewNavigator.kt similarity index 98% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebViewNavigator.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WasmJsWebViewNavigator.kt index 8d230b1..8019ace 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebViewNavigator.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WasmJsWebViewNavigator.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -6,7 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import io.github.kdroidfilter.webview.util.KLogger +import dev.nucleusframework.webview.util.KLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt similarity index 97% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt index 0a2e633..9a18d82 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt @@ -1,13 +1,13 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.foundation.layout.Box 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 dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.parseJsMessage +import dev.nucleusframework.webview.setting.WebSettings +import dev.nucleusframework.webview.util.KLogger import kotlinx.browser.document import kotlinx.coroutines.launch import org.w3c.dom.HTMLIFrameElement diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewJsBridge.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebViewJsBridge.kt similarity index 98% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewJsBridge.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebViewJsBridge.kt index 04635a2..6c671b5 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewJsBridge.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebViewJsBridge.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web /** * Creates JavaScript bridge code that can be used for communication between Kotlin and JavaScript diff --git a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewTypes.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebViewTypes.kt similarity index 98% rename from webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewTypes.kt rename to webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebViewTypes.kt index 57e533d..772b3f4 100644 --- a/webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewTypes.kt +++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebViewTypes.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.web +package dev.nucleusframework.webview.web import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/wrywebview/Cargo.lock b/wrywebview/Cargo.lock deleted file mode 100644 index d3416c7..0000000 --- a/wrywebview/Cargo.lock +++ /dev/null @@ -1,3140 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "askama" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" -dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.111", -] - -[[package]] -name = "askama_parser" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow 0.7.14", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.10.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "cc" -version = "1.2.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "composewebview-wry" -version = "0.1.0" -dependencies = [ - "dispatch2", - "gdk", - "gdkx11", - "glib", - "gtk", - "objc2", - "thiserror 2.0.17", - "uniffi", - "windows 0.58.0", - "wry", - "x11", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.111", -] - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.111", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.10.0", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs-err" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" -dependencies = [ - "autocfg", -] - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-macro", - "futures-task", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.10.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "goblin" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" -dependencies = [ - "log", - "plain", - "scroll", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap", - "selectors", -] - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags 2.10.0", - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.10.0", - "jni-sys", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num_enum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.10.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.10.0", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.1", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 2.0.17", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scroll" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" -dependencies = [ - "scroll_derive", -] - -[[package]] -name = "scroll_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "serde_json" -version = "1.0.148" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow 0.7.14", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow 0.7.14", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "uniffi" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" -dependencies = [ - "anyhow", - "cargo_metadata", - "uniffi_bindgen", - "uniffi_core", - "uniffi_macros", - "uniffi_pipeline", -] - -[[package]] -name = "uniffi_bindgen" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" -dependencies = [ - "anyhow", - "askama", - "camino", - "cargo_metadata", - "fs-err", - "glob", - "goblin", - "heck 0.5.0", - "indexmap", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml 0.5.11", - "uniffi_internal_macros", - "uniffi_meta", - "uniffi_pipeline", - "uniffi_udl", -] - -[[package]] -name = "uniffi_core" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" -dependencies = [ - "anyhow", - "bytes", - "once_cell", - "static_assertions", -] - -[[package]] -name = "uniffi_internal_macros" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" -dependencies = [ - "anyhow", - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "uniffi_macros" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" -dependencies = [ - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.111", - "toml 0.5.11", - "uniffi_meta", -] - -[[package]] -name = "uniffi_meta" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" -dependencies = [ - "anyhow", - "siphasher 0.3.11", - "uniffi_internal_macros", - "uniffi_pipeline", -] - -[[package]] -name = "uniffi_pipeline" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "tempfile", - "uniffi_internal_macros", -] - -[[package]] -name = "uniffi_udl" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" -dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta", - "weedle2", -] - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webview2-com" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" -dependencies = [ - "thiserror 2.0.17", - "windows 0.61.3", - "windows-core 0.61.2", -] - -[[package]] -name = "weedle2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" -dependencies = [ - "nom", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wry" -version = "0.54.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" -dependencies = [ - "base64", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dpi", - "dunce", - "gdkx11", - "gtk", - "html5ever", - "http", - "javascriptcore-rs", - "jni", - "kuchikiki", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.17", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "zmij" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" diff --git a/wrywebview/Cargo.toml b/wrywebview/Cargo.toml deleted file mode 100644 index 4bf65f6..0000000 --- a/wrywebview/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "composewebview-wry" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -name = "composewebview_wry" -crate-type = ["cdylib"] -path = "src/main/rust/lib.rs" - -[dependencies] -thiserror = "2.0.11" -uniffi = "0.29.4" -wry = { version = "0.54.2", features = ["devtools"] } - -[profile.release] -opt-level = "z" -lto = "fat" -codegen-units = 1 -panic = "abort" -strip = "debuginfo" -incremental = false - -[target.'cfg(target_os = "linux")'.dependencies] -gtk = "0.18" -glib = "0.18" -gdk = "0.18" -gdkx11 = "0.18" -x11 = "2.21" - -[target.'cfg(target_os = "macos")'.dependencies] -dispatch2 = "0.3.0" -objc2 = "0.6" - -[target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.58", features = ["Win32_UI_WindowsAndMessaging"] } diff --git a/wrywebview/build.gradle.kts b/wrywebview/build.gradle.kts deleted file mode 100644 index e48f0b4..0000000 --- a/wrywebview/build.gradle.kts +++ /dev/null @@ -1,146 +0,0 @@ -import com.vanniktech.maven.publish.JavaLibrary -import com.vanniktech.maven.publish.JavadocJar -import gobley.gradle.GobleyHost -import gobley.gradle.Variant -import gobley.gradle.cargo.dsl.jvm -import gobley.gradle.cargo.tasks.CargoBuildTask -import gobley.gradle.cargo.tasks.FindDynamicLibrariesTask -import gobley.gradle.cargo.tasks.RustUpTargetAddTask -import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.io.File - -fun rustLibraryName(triple: String): String = when { - triple.contains("windows") -> "composewebview_wry.dll" - triple.contains("darwin") || triple.contains("apple") -> "libcomposewebview_wry.dylib" - else -> "libcomposewebview_wry.so" -} - -fun Project.prebuiltRustLibrary(triple: String): File = - layout.projectDirectory.dir("target/$triple/release").file(rustLibraryName(triple)).asFile - -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinAtomicfu) - alias(libs.plugins.gobleyCargo) - alias(libs.plugins.gobleyRust) - alias(libs.plugins.gobleyUniffi) - alias(libs.plugins.mavenPublish) -} - -cargo { - jvmVariant.set(Variant.Release) - builds.jvm { - // Only embed for the current host platform; other platforms are packed as resources. - embedRustLibrary = GobleyHost.current.rustTarget == rustTarget - } -} - -rust { - val userHome = System.getProperty("user.home") - val cargoBin = file("$userHome/.cargo/bin") - val rustupToolchainBin = file( - "$userHome/.rustup/toolchains/stable-${GobleyHost.current.rustTarget.rustTriple}/bin", - ) - when { - cargoBin.resolve("rustc").exists() -> toolchainDirectory.set(cargoBin) - rustupToolchainBin.resolve("rustc").exists() -> toolchainDirectory.set(rustupToolchainBin) - } -} - -uniffi { - generateFromLibrary { - build.set(GobleyHost.current.rustTarget) - } -} - -kotlin { - jvmToolchain(17) -} - -sourceSets { - main { - resources.srcDir("src/jvmMain/resources") - } -} - -dependencies { - implementation(libs.jna) - implementation(libs.skiko.awt) -} - -tasks.withType().configureEach { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } -} - -tasks.withType().configureEach { - onlyIf { - val rustTarget = target.orNull ?: return@onlyIf true - val triple = rustTarget.rustTriple - val prebuiltLib = project.prebuiltRustLibrary(triple) - val isHostTarget = GobleyHost.current.rustTarget.rustTriple == triple - !prebuiltLib.exists() || isHostTarget - } -} - -tasks.withType().configureEach { - val rustTarget = rustTarget.orNull ?: return@configureEach - val triple = rustTarget.rustTriple - val prebuiltLib = project.prebuiltRustLibrary(triple) - val isHostTarget = GobleyHost.current.rustTarget.rustTriple == triple - if (prebuiltLib.exists() && !isHostTarget) { - searchPaths.set(listOf(prebuiltLib.parentFile)) - } -} - -tasks.withType().configureEach { - onlyIf { - val rustTarget = rustTarget.orNull ?: return@onlyIf true - val triple = rustTarget.rustTriple - val prebuiltLib = project.prebuiltRustLibrary(triple) - val isHostTarget = GobleyHost.current.rustTarget.rustTriple == triple - !prebuiltLib.exists() || isHostTarget - } -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -mavenPublishing { - configure(JavaLibrary(javadocJar = JavadocJar.Empty(), sourcesJar = true)) - publishToMavenCentral() - if (project.findProperty("signingInMemoryKey") != null) { - signAllPublications() - } - coordinates(artifactId = "wrywebview") - pom { - name.set("WryWebView") - description.set("Native WebView bindings for JVM using Wry (Rust)") - } -} - -// Publish native runtime JARs as additional artifacts -afterEvaluate { - publishing { - publications.withType().configureEach { - if (name == "maven") { - // Add native runtime JARs for each platform - val nativeJars = layout.buildDirectory.dir("libs").get().asFile.listFiles() - ?.filter { it.name.startsWith("wrywebview-") && it.name.endsWith(".jar") && !it.name.contains("sources") && !it.name.contains("javadoc") } - ?: emptyList() - - nativeJars.forEach { jar -> - val classifier = jar.name.removePrefix("wrywebview-").removeSuffix(".jar") - artifact(jar) { - this.classifier = classifier - } - } - } - } - } -} 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 deleted file mode 100644 index 0828d40..0000000 --- a/wrywebview/src/main/java/io/github/kdroidfilter/webview/wry/SkikoInterop.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.kdroidfilter.webview.wry; - -import org.jetbrains.skiko.HardwareLayer; - -import java.awt.*; - -final class SkikoInterop { - private SkikoInterop() {} - - static Canvas createHost() { - try { - if (isWindows()) { - return new Canvas(); - } - return new HardwareLayer(); - } catch (Throwable e) { - return new Canvas(); - } - } - - private static boolean isWindows() { - String osName = System.getProperty("os.name"); - return osName != null && osName.toLowerCase().contains("windows"); - } - - static long getContentHandle(Component component) { - if (component instanceof HardwareLayer) { - return ((HardwareLayer) component).getContentHandle(); - } - return 0L; - } - - static long getWindowHandle(Component component) { - if (component instanceof HardwareLayer) { - return ((HardwareLayer) component).getWindowHandle(); - } - return 0L; - } - - static boolean init(Component component) { - if (component instanceof HardwareLayer) { - ((HardwareLayer) component).init(); - return true; - } - return false; - } -} 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 deleted file mode 100644 index 792ed3a..0000000 --- a/wrywebview/src/main/kotlin/io/github/kdroidfilter/webview/wry/WryWebViewPanel.kt +++ /dev/null @@ -1,977 +0,0 @@ -package io.github.kdroidfilter.webview.wry - -import com.sun.jna.Native -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 - - -open class WryWebViewPanel( - initialUrl: String, - customUserAgent: String? = null, - dataDirectory: String? = null, - initScript: String? = null, - private val supportZoom: Boolean = true, - private val backgroundColor: Rgba, - private val transparent: Boolean = true, - private val enableClipboard: Boolean = true, - private val enableDevtools: Boolean = false, - private val enableNavigationGestures: Boolean = true, - private val incognito: Boolean = false, - private val autoplayWithoutUserInteraction: Boolean = false, - private val focused: Boolean = true, - private val bridgeLogger: (String) -> Unit = { System.err.println(it) } -) : JPanel() { - private val host = SkikoInterop.createHost() - private var webviewId: ULong? = null - private var parentHandle: ULong = 0UL - private var parentIsWindow: Boolean = false - private var pendingUrl: String = initialUrl - private val dataDirectory: String? = dataDirectory?.trim()?.takeIf { it.isNotEmpty() } - private val customUserAgent: String? = customUserAgent?.trim()?.takeIf { it.isNotEmpty() } - private val initScript: String? = initScript?.trim()?.takeIf { it.isNotEmpty() } - private var pendingUrlWithHeaders: String? = null - private var pendingHeaders: Map = emptyMap() - private var pendingHtml: String? = null - private var createTimer: Timer? = null - private var destroyTimer: Timer? = null - private var createInFlight: Boolean = false - private var gtkTimer: Timer? = null - private var windowsTimer: Timer? = null - private var skikoInitialized: Boolean = false - private var lastBounds: Bounds? = null - private var pendingBounds: Bounds? = null - private var boundsTimer: Timer? = null - - private val handlers = mutableListOf<(String) -> Boolean>() - - private val handler = object : NavigationHandler { - override fun handleNavigation(url: String): Boolean = handlers.any { it(url) } - } - - init { - layout = BorderLayout() - add(host, BorderLayout.CENTER) - // Request focus when clicked to capture keyboard events - host.addMouseListener(object : MouseAdapter() { - override fun mousePressed(e: MouseEvent?) { - requestWebViewFocus() - } - }) - log("init url=$initialUrl") - } - - override fun addNotify() { - super.addNotify() - stopDestroyTimer() - log("addNotify displayable=${host.isDisplayable} showing=${host.isShowing} size=${host.width}x${host.height}") - SwingUtilities.invokeLater { scheduleCreateIfNeeded() } - } - - override fun removeNotify() { - log("removeNotify") - stopCreateTimer() - if (IS_MAC) { - scheduleDestroyIfNeeded() - } else { - destroyIfNeeded() - } - super.removeNotify() - } - - override fun doLayout() { - super.doLayout() - log("doLayout size=${host.width}x${host.height} displayable=${host.isDisplayable} showing=${host.isShowing}") - updateBounds() - scheduleCreateIfNeeded() - } - - open fun addNavigateListener(data: (String) -> Boolean) { - handlers.add(data) - } - - open fun removeNavigateListener(data: (String) -> Boolean) { - handlers.remove(data) - } - - open fun loadUrl(url: String) { - loadUrl(url, emptyMap()) - } - - open fun loadUrl(url: String, additionalHttpHeaders: Map) { - pendingUrl = url - pendingHtml = null - pendingHeaders = additionalHttpHeaders - pendingUrlWithHeaders = if (additionalHttpHeaders.isNotEmpty()) url else null - if (pendingUrlWithHeaders != null) { - pendingUrl = "about:blank" - } - if (SwingUtilities.isEventDispatchThread()) { - webviewId?.let { - if (additionalHttpHeaders.isNotEmpty()) { - NativeBindings.loadUrlWithHeaders(it, url, additionalHttpHeaders) - } else { - NativeBindings.loadUrl(it, url) - } - } - ?: scheduleCreateIfNeeded() - } else { - SwingUtilities.invokeLater { - webviewId?.let { - if (additionalHttpHeaders.isNotEmpty()) { - NativeBindings.loadUrlWithHeaders(it, url, additionalHttpHeaders) - } else { - NativeBindings.loadUrl(it, url) - } - } ?: scheduleCreateIfNeeded() - } - } - log("loadUrl url=$url headers=${additionalHttpHeaders.size} webviewId=$webviewId") - } - - open fun loadHtml(html: String) { - pendingHtml = html - pendingUrl = "about:blank" - pendingHeaders = emptyMap() - pendingUrlWithHeaders = null - val action = { - webviewId?.let { NativeBindings.loadHtml(it, html) } ?: scheduleCreateIfNeeded() - } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("loadHtml bytes=${html.length} webviewId=$webviewId") - } - - open fun goBack() { - val action = { webviewId?.let { NativeBindings.goBack(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("goBack webviewId=$webviewId") - } - - open fun goForward() { - val action = { webviewId?.let { NativeBindings.goForward(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("goForward webviewId=$webviewId") - } - - open fun reload() { - val action = { webviewId?.let { NativeBindings.reload(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("reload webviewId=$webviewId") - } - - open fun stopLoading() { - val action = { webviewId?.let { NativeBindings.stopLoading(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("stopLoading webviewId=$webviewId") - } - - open fun evaluateJavaScript(script: String, callback: (String) -> Unit) { - val id = webviewId ?: run { - callback("") - return - } - log("evaluateJavaScript bytes=${script.length} webviewId=$id") - try { - NativeBindings.evaluateJavaScript(id, script, object : JavaScriptCallback { - override fun onResult(result: String) { - callback(result) - } - }) - } catch (e: Exception) { - log("evaluateJavaScript failed: ${e.message}") - callback("") - } - } - - open fun getCurrentUrl(): String? { - return webviewId?.let { - try { - NativeBindings.getUrl(it) - } catch (e: Exception) { - log("getCurrentUrl failed: ${e.message}") - null - } - } - } - - open fun isLoading(): Boolean { - return webviewId?.let { - try { - NativeBindings.isLoading(it) - } catch (e: Exception) { - log("isLoading failed: ${e.message}") - true - } - } ?: true - } - - open fun getTitle(): String? { - return webviewId?.let { - try { - NativeBindings.getTitle(it) - } catch (e: Exception) { - log("getTitle failed: ${e.message}") - null - } - } - } - - open fun canGoBack(): Boolean { - return webviewId?.let { - try { - NativeBindings.canGoBack(it) - } catch (e: Exception) { - log("canGoBack failed: ${e.message}") - false - } - } ?: false - } - - open fun canGoForward(): Boolean { - return webviewId?.let { - try { - NativeBindings.canGoForward(it) - } catch (e: Exception) { - log("canGoForward failed: ${e.message}") - false - } - } ?: false - } - - open fun drainIpcMessages(): List { - return webviewId?.let { - try { - NativeBindings.drainIpcMessages(it) - } catch (e: Exception) { - log("drainIpcMessages failed: ${e.message}") - emptyList() - } - } ?: emptyList() - } - - open fun getCookiesForUrl(url: String): List { - var result: List = emptyList() - val id = webviewId ?: run { - log("getCookiesForUrl webviewId is null") - return result - } - - val action = { - result = runCatching { NativeBindings.getCookiesForUrl(id, url) } - .onFailure { log("getCookiesForUrl failed: ${it.message}"); it.printStackTrace() } - .getOrDefault(emptyList()) - } - - if (SwingUtilities.isEventDispatchThread()) action() else SwingUtilities.invokeAndWait( - action - ) - return result - } - - open fun getCookies(): List { - var result: List = emptyList() - val id = webviewId ?: run { - log("getCookies webviewId is null") - return result - } - - val action = { - result = runCatching { NativeBindings.getCookies(id) } - .onFailure { log("getCookies failed: ${it.message}"); it.printStackTrace() } - .getOrDefault(emptyList()) - } - - if (SwingUtilities.isEventDispatchThread()) action() else SwingUtilities.invokeAndWait( - action - ) - return result - } - - open fun clearCookiesForUrl(url: String) { - val action = { webviewId?.let { NativeBindings.clearCookiesForUrl(it, url) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - } - - open fun clearAllCookies() { - val action = { webviewId?.let { NativeBindings.clearAllCookies(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - } - - open fun setCookie(cookie: WebViewCookie) { - val action = { webviewId?.let { NativeBindings.setCookie(it, cookie) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - } - - open fun isReady(): Boolean = webviewId != null - - 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 { - SwingUtilities.invokeLater { action() } - } - log("requestWebViewFocus webviewId=$webviewId") - } - - open fun openDevTools() { - val action = { webviewId?.let { NativeBindings.openDevTools(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("openDevTools webviewId=$webviewId") - } - - open fun closeDevTools() { - val action = { webviewId?.let { NativeBindings.closeDevTools(it) } } - if (SwingUtilities.isEventDispatchThread()) { - action() - } else { - SwingUtilities.invokeLater { action() } - } - log("closeDevTools webviewId=$webviewId") - } - - private fun createIfNeeded(): Boolean { - if (webviewId != null) return true - if (createInFlight) return false - if (!host.isDisplayable || !host.isShowing) return false - if (host.width <= 0 || host.height <= 0) return false - // On Windows, wait for the window to be fully visible - if (IS_WINDOWS) { - val window = SwingUtilities.getWindowAncestor(host) - if (window == null || !window.isShowing) return false - } - if (!skikoInitialized) { - skikoInitialized = try { - val initResult = SkikoInterop.init(host) - log("skiko init result=$initResult") - initResult - } catch (e: RuntimeException) { - log("skiko init failed: ${e.message}") - false - } - } - val resolved = resolveParentHandle() ?: run { - log("createIfNeeded no parent handle; host displayable=${host.isDisplayable} showing=${host.isShowing} size=${host.width}x${host.height}") - return false - } - parentHandle = resolved.handle - parentIsWindow = resolved.isWindow - log("createIfNeeded handle=$parentHandle parentIsWindow=$parentIsWindow size=${host.width}x${host.height}") - val width = host.width.coerceAtLeast(1) - val height = host.height.coerceAtLeast(1) - val userAgent = customUserAgent - val dataDir = dataDirectory - val initialUrl = pendingUrl - val handleSnapshot = parentHandle - - if (!host.isDisplayable) { - return false - } - - if (!IS_MAC) { - return try { - webviewId = NativeBindings.createWebview( - parentHandle = handleSnapshot, - width = width, - height = height, - url = initialUrl, - userAgent = userAgent, - dataDirectory = dataDir, - zoom = supportZoom, - transparent = transparent, - backgroundColor = backgroundColor, - initScript = initScript, - clipboard = enableClipboard, - devTools = enableDevtools, - navigationGestures = enableNavigationGestures, - incognito = incognito, - autoplay = autoplayWithoutUserInteraction, - focused = focused, - navHandler = handler - ) - updateBounds() - startGtkPumpIfNeeded() - startWindowsPumpIfNeeded() - // Apply any pending content that requires an explicit call after creation. - val id = webviewId - val html = pendingHtml - val urlWithHeaders = pendingUrlWithHeaders - val headers = pendingHeaders - if (id != null) { - when { - html != null -> { - pendingHtml = null - NativeBindings.loadHtml(id, html) - } - - urlWithHeaders != null && headers.isNotEmpty() -> { - pendingUrlWithHeaders = null - pendingHeaders = emptyMap() - NativeBindings.loadUrlWithHeaders(id, urlWithHeaders, headers) - } - } - } - log("createIfNeeded success id=$webviewId") - true - } catch (e: RuntimeException) { - System.err.println("Failed to create Wry webview: ${e.message}") - e.printStackTrace() - true - } - } - - createInFlight = true - stopCreateTimer() - thread(name = "wry-webview-create", isDaemon = true) { - val createdId = try { - NativeBindings.createWebview( - parentHandle = handleSnapshot, - width = width, - height = height, - url = initialUrl, - userAgent = userAgent, - dataDirectory = dataDir, - zoom = supportZoom, - transparent = transparent, - backgroundColor = backgroundColor, - initScript = initScript, - clipboard = enableClipboard, - devTools = enableDevtools, - navigationGestures = enableNavigationGestures, - incognito = incognito, - autoplay = autoplayWithoutUserInteraction, - focused = focused, - navHandler = handler - ) - } catch (e: RuntimeException) { - System.err.println("Failed to create Wry webview: ${e.message}") - e.printStackTrace() - null - } - SwingUtilities.invokeLater { - createInFlight = false - if (createdId == null) { - scheduleCreateIfNeeded() - return@invokeLater - } - if (webviewId != null) { - NativeBindings.destroyWebview(createdId) - return@invokeLater - } - if (!host.isDisplayable || !host.isShowing) { - NativeBindings.destroyWebview(createdId) - return@invokeLater - } - webviewId = createdId - updateBounds() - startGtkPumpIfNeeded() - startWindowsPumpIfNeeded() - // Apply any pending content that requires an explicit call after creation. - val html = pendingHtml - val urlWithHeaders = pendingUrlWithHeaders - val headers = pendingHeaders - when { - html != null -> { - pendingHtml = null - NativeBindings.loadHtml(createdId, html) - } - - urlWithHeaders != null && headers.isNotEmpty() -> { - pendingUrlWithHeaders = null - pendingHeaders = emptyMap() - NativeBindings.loadUrlWithHeaders(createdId, urlWithHeaders, headers) - } - - pendingUrl != initialUrl -> { - NativeBindings.loadUrl(createdId, pendingUrl) - } - } - log("createIfNeeded success id=$webviewId") - } - } - return true - } - - private fun destroyIfNeeded() { - stopDestroyTimer() - stopGtkPump() - stopWindowsPump() - stopBoundsTimer() - webviewId?.let { - log("destroy id=$it") - NativeBindings.destroyWebview(it) - } - webviewId = null - parentHandle = 0UL - parentIsWindow = false - lastBounds = null - } - - private fun updateBounds() { - val id = webviewId ?: return - val bounds = boundsInParent() - if (IS_LINUX || IS_MAC) { - pendingBounds = bounds - if (boundsTimer == null) { - boundsTimer = Timer(16) { - val currentId = webviewId ?: return@Timer - val toSend = pendingBounds ?: return@Timer - pendingBounds = null - if (toSend != lastBounds) { - lastBounds = toSend - log("setBounds id=$currentId pos=(${toSend.x}, ${toSend.y}) size=${toSend.width}x${toSend.height}") - NativeBindings.setBounds( - currentId, - toSend.x, - toSend.y, - toSend.width, - toSend.height - ) - } - if (pendingBounds == null) { - stopBoundsTimer() - } - }.apply { start() } - } - return - } - if (bounds == lastBounds) return - lastBounds = bounds - log("setBounds id=$id pos=(${bounds.x}, ${bounds.y}) size=${bounds.width}x${bounds.height}") - NativeBindings.setBounds(id, bounds.x, bounds.y, bounds.width, bounds.height) - } - - private fun startGtkPumpIfNeeded() { - if (!IS_LINUX || gtkTimer != null) return - log("startGtkPump (noop, handled in native GTK thread)") - } - - private fun stopGtkPump() { - gtkTimer?.stop() - gtkTimer = null - } - - private fun startWindowsPumpIfNeeded() { - if (!IS_WINDOWS || windowsTimer != null) return - log("startWindowsPump") - windowsTimer = Timer(16) { NativeBindings.pumpWindowsEvents() }.apply { start() } - } - - private fun stopWindowsPump() { - windowsTimer?.stop() - windowsTimer = null - } - - private fun scheduleCreateIfNeeded() { - if (webviewId != null || createTimer != null || createInFlight) return - log("scheduleCreateIfNeeded") - val delay = if (IS_WINDOWS) 100 else 16 - createTimer = Timer(delay) { - if (createIfNeeded()) { - stopCreateTimer() - } - }.apply { start() } - } - - private fun stopCreateTimer() { - createTimer?.stop() - createTimer = null - } - - private fun scheduleDestroyIfNeeded() { - if (destroyTimer != null) return - if (webviewId == null && !createInFlight) return - destroyTimer = Timer(400) { - stopDestroyTimer() - if (!host.isDisplayable || !host.isShowing) { - destroyIfNeeded() - } - }.apply { - isRepeats = false - start() - } - } - - private fun stopDestroyTimer() { - destroyTimer?.stop() - destroyTimer = null - } - - private fun stopBoundsTimer() { - boundsTimer?.stop() - boundsTimer = null - pendingBounds = null - } - - private fun componentHandle(component: Component): ULong { - return try { - Native.getComponentID(component).toULong() - } catch (e: RuntimeException) { - log("componentHandle failed for ${component.javaClass.name}: ${e.message}") - 0UL - } - } - - private fun log(message: String) { - if (LOG_ENABLED) { - bridgeLogger("[WryWebViewPanel] $message") - } - } - - private fun resolveParentHandle(): ParentHandle? { - val contentHandle = safeSkikoHandle("content") { SkikoInterop.getContentHandle(host) } - val windowHandle = safeSkikoHandle("window") { SkikoInterop.getWindowHandle(host) } - if (IS_WINDOWS) { - // On Windows, use the window handle and position webview manually - // Canvas HWND doesn't work well as WebView2 parent - val window = SwingUtilities.getWindowAncestor(host) - if (window != null && window.isDisplayable && window.isShowing) { - val windowHandleJna = componentHandle(window) - if (windowHandleJna != 0UL) { - log("resolveParentHandle jna window=0x${windowHandleJna.toString(16)} (windows)") - return ParentHandle(windowHandleJna, true) - } - } - } else if (IS_MAC) { - if (contentHandle != 0L && contentHandle != windowHandle) { - log( - "resolveParentHandle skiko content=0x${contentHandle.toString(16)} window=0x${ - windowHandle.toString( - 16 - ) - } (macOS content)" - ) - return ParentHandle(contentHandle.toULong(), false) - } - if (windowHandle != 0L) { - log("resolveParentHandle skiko window=0x${windowHandle.toString(16)} (macOS)") - return ParentHandle(windowHandle.toULong(), true) - } - if (contentHandle != 0L) { - log("resolveParentHandle skiko content=0x${contentHandle.toString(16)} (macOS fallback)") - return ParentHandle(contentHandle.toULong(), true) - } - } else { - if (contentHandle != 0L) { - log( - "resolveParentHandle skiko content=0x${contentHandle.toString(16)} window=0x${ - windowHandle.toString( - 16 - ) - }" - ) - return ParentHandle(contentHandle.toULong(), false) - } - if (windowHandle != 0L) { - log("resolveParentHandle skiko content=0 window=0x${windowHandle.toString(16)} (using window)") - return ParentHandle(windowHandle.toULong(), true) - } - } - - val hostHandle = componentHandle(host) - if (hostHandle != 0UL) { - log("resolveParentHandle jna host=0x${hostHandle.toString(16)}") - return ParentHandle(hostHandle, false) - } - val window = SwingUtilities.getWindowAncestor(host) ?: return null - if (!window.isDisplayable || !window.isShowing) return null - val windowHandleFallback = componentHandle(window) - if (windowHandleFallback != 0UL) { - log("resolveParentHandle jna window=0x${windowHandleFallback.toString(16)}") - return ParentHandle(windowHandleFallback, true) - } - log("resolveParentHandle no handles (content=0 window=0)") - return null - } - - private fun safeSkikoHandle(name: String, getter: () -> Long): Long { - return try { - getter() - } catch (e: RuntimeException) { - log("skiko $name handle failed: ${e.message}") - 0L - } - } - - private fun boundsInParent(): Bounds { - val width = host.width.coerceAtLeast(1) - val height = host.height.coerceAtLeast(1) - if (!parentIsWindow) { - return Bounds(0, 0, width, height) - } - val window = SwingUtilities.getWindowAncestor(host) ?: return Bounds(0, 0, width, height) - val point = SwingUtilities.convertPoint(host, 0, 0, window) - val insets = window.insets - val x = point.x - insets.left - val y = point.y - insets.top - log("boundsInParent windowOffset=(${x}, ${y}) insets=${insets}") - return Bounds(x, y, width, height) - } - - private data class ParentHandle(val handle: ULong, val isWindow: Boolean) - private data class Bounds(val x: Int, val y: Int, val width: Int, val height: Int) - - companion object { - private val OS_NAME = System.getProperty("os.name")?.lowercase().orEmpty() - private val IS_LINUX = OS_NAME.contains("linux") - private val IS_MAC = OS_NAME.contains("mac") - private val IS_WINDOWS = OS_NAME.contains("windows") - var LOG_ENABLED = run { - val raw = - System.getProperty("composewebview.wry.log") ?: System.getenv("WRYWEBVIEW_LOG") - when { - raw == null -> false - raw == "1" -> true - raw.equals("true", ignoreCase = true) -> true - raw.equals("yes", ignoreCase = true) -> true - raw.equals("debug", ignoreCase = true) -> true - else -> false - } - } - - var NATIVE_LOGGER: (String) -> Unit = { System.err.println(it) } - - init { - setNativeLogger( - object : NativeLogger { - override fun handleLog(data: String) { - if (LOG_ENABLED) { - NATIVE_LOGGER(data) - } - } - } - ) - } - } -} - -private object NativeBindings { - - fun createWebview( - parentHandle: ULong, - width: Int, - height: Int, - url: String, - userAgent: String?, - dataDirectory: String?, - zoom: Boolean, - transparent: Boolean, - backgroundColor: Rgba, - initScript: String?, - clipboard: Boolean, - devTools: Boolean, - navigationGestures: Boolean, - incognito: Boolean, - autoplay: Boolean, - focused: Boolean, - navHandler: NavigationHandler? - ): ULong { - return io.github.kdroidfilter.webview.wry.createWebview( - parentHandle = parentHandle, - width = width, - height = height, - url = url, - userAgent = userAgent, - dataDirectory = dataDirectory, - zoom = zoom, - transparent = transparent, - backgroundColor = backgroundColor, - initScript = initScript, - clipboard = clipboard, - devTools = devTools, - navigationGestures = navigationGestures, - incognito = incognito, - autoplay = autoplay, - focused = focused, - navHandler = navHandler - ) - } - - fun setBounds(id: ULong, x: Int, y: Int, width: Int, height: Int) { - io.github.kdroidfilter.webview.wry.setBounds(id, x, y, width, height) - } - - fun loadUrl(id: ULong, url: String) { - io.github.kdroidfilter.webview.wry.loadUrl(id, url) - } - - fun loadUrlWithHeaders(id: ULong, url: String, additionalHttpHeaders: Map) { - loadUrlWithHeaders( - id = id, - url = url, - headers = additionalHttpHeaders.map { (name, value) -> HttpHeader(name, value) }, - ) - } - - fun loadHtml(id: ULong, html: String) { - io.github.kdroidfilter.webview.wry.loadHtml(id, html) - } - - fun goBack(id: ULong) { - io.github.kdroidfilter.webview.wry.goBack(id) - } - - fun goForward(id: ULong) { - io.github.kdroidfilter.webview.wry.goForward(id) - } - - fun reload(id: ULong) { - io.github.kdroidfilter.webview.wry.reload(id) - } - - fun stopLoading(id: ULong) { - io.github.kdroidfilter.webview.wry.stopLoading(id) - } - - fun evaluateJavaScript(id: ULong, script: String, callback: JavaScriptCallback) { - evaluateJavascript(id, script, callback) - } - - fun getUrl(id: ULong): String { - return io.github.kdroidfilter.webview.wry.getUrl(id) - } - - fun isLoading(id: ULong): Boolean { - return io.github.kdroidfilter.webview.wry.isLoading(id) - } - - fun getTitle(id: ULong): String { - return io.github.kdroidfilter.webview.wry.getTitle(id) - } - - fun canGoBack(id: ULong): Boolean { - return io.github.kdroidfilter.webview.wry.canGoBack(id) - } - - fun canGoForward(id: ULong): Boolean { - return io.github.kdroidfilter.webview.wry.canGoForward(id) - } - - fun drainIpcMessages(id: ULong): List { - return io.github.kdroidfilter.webview.wry.drainIpcMessages(id) - } - - fun getCookiesForUrl(id: ULong, url: String): List { - return io.github.kdroidfilter.webview.wry.getCookiesForUrl(id, url) - } - - fun getCookies(id: ULong): List { - return io.github.kdroidfilter.webview.wry.getCookies(id) - } - - fun clearCookiesForUrl(id: ULong, url: String) { - io.github.kdroidfilter.webview.wry.clearCookiesForUrl(id, url) - } - - fun clearAllCookies(id: ULong) { - io.github.kdroidfilter.webview.wry.clearAllCookies(id) - } - - fun setCookie(id: ULong, cookie: WebViewCookie) { - io.github.kdroidfilter.webview.wry.setCookie(id, cookie) - } - - fun destroyWebview(id: ULong) { - io.github.kdroidfilter.webview.wry.destroyWebview(id) - } - - fun pumpGtkEvents() { - io.github.kdroidfilter.webview.wry.pumpGtkEvents() - } - - fun pumpWindowsEvents() { - io.github.kdroidfilter.webview.wry.pumpWindowsEvents() - } - - fun focus(id: ULong) { - io.github.kdroidfilter.webview.wry.focus(id) - } - - fun openDevTools(id: ULong) { - io.github.kdroidfilter.webview.wry.openDevTools(id) - } - - 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/error.rs b/wrywebview/src/main/rust/error.rs deleted file mode 100644 index 5d6cb84..0000000 --- a/wrywebview/src/main/rust/error.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Error types for the WebView library. - -/// Errors that can occur when working with WebViews. -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum WebViewError { - #[error("unsupported platform for native webview")] - UnsupportedPlatform, - - #[error("invalid parent window handle")] - InvalidWindowHandle, - - #[error("webview {0} not found")] - WebViewNotFound(u64), - - #[error("webview {0} must be accessed from the creating thread")] - WrongThread(u64), - - #[error("wry error: {0}")] - WryError(String), - - #[error("gtk initialization failed: {0}")] - GtkInit(String), - - #[error("internal error: {0}")] - Internal(String), -} - -impl From for WebViewError { - fn from(error: wry::Error) -> Self { - WebViewError::WryError(error.to_string()) - } -} diff --git a/wrywebview/src/main/rust/handle.rs b/wrywebview/src/main/rust/handle.rs deleted file mode 100644 index cdfef84..0000000 --- a/wrywebview/src/main/rust/handle.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Window handle utilities for cross-platform WebView creation. - -use wry::dpi::{LogicalPosition, LogicalSize}; -use wry::raw_window_handle::{HandleError, HasWindowHandle, RawWindowHandle, WindowHandle}; -use wry::Rect; - -#[cfg(target_os = "linux")] -use std::os::raw::c_ulong; -#[cfg(target_os = "linux")] -use wry::raw_window_handle::XlibWindowHandle; - -#[cfg(target_os = "macos")] -use wry::raw_window_handle::AppKitWindowHandle; - -#[cfg(target_os = "windows")] -use std::num::NonZeroIsize; -#[cfg(target_os = "windows")] -use wry::raw_window_handle::Win32WindowHandle; - -use crate::error::WebViewError; -use crate::wry_log; - -/// Wrapper around a raw window handle for WebView creation. -pub struct RawWindow { - pub raw: RawWindowHandle, -} - -impl HasWindowHandle for RawWindow { - fn window_handle(&self) -> Result, HandleError> { - unsafe { Ok(WindowHandle::borrow_raw(self.raw)) } - } -} - -/// Creates a `Rect` with the given position and size, ensuring minimum dimensions. -pub fn make_bounds(x: i32, y: i32, width: i32, height: i32) -> Rect { - let width = width.max(1); - let height = height.max(1); - Rect { - position: LogicalPosition::new(x, y).into(), - size: LogicalSize::new(width, height).into(), - } -} - -/// Converts a platform-specific handle to a `RawWindowHandle`. -pub fn raw_window_handle_from(parent_handle: u64) -> Result { - if parent_handle == 0 { - return Err(WebViewError::InvalidWindowHandle); - } - - #[cfg(target_os = "windows")] - { - let hwnd = - NonZeroIsize::new(parent_handle as isize).ok_or(WebViewError::InvalidWindowHandle)?; - let handle = RawWindowHandle::Win32(Win32WindowHandle::new(hwnd)); - wry_log!("[wrywebview] raw_window_handle Win32=0x{:x}", parent_handle); - // if log_enabled() { - // eprintln!("[wrywebview] raw_window_handle Win32=0x{:x}", parent_handle); - // } - return Ok(handle); - } - - #[cfg(target_os = "macos")] - { - let ns_view = crate::platform::macos::appkit_ns_view_from_handle(parent_handle)?; - let handle = RawWindowHandle::AppKit(AppKitWindowHandle::new(ns_view)); - wry_log!( - "[wrywebview] raw_window_handle AppKit=0x{:x} ns_view=0x{:x}", - parent_handle, - ns_view.as_ptr() as usize - ); - // if log_enabled() { - // eprintln!( - // "[wrywebview] raw_window_handle AppKit=0x{:x} ns_view=0x{:x}", - // parent_handle, - // ns_view.as_ptr() as usize - // ); - // } - return Ok(handle); - } - - #[cfg(target_os = "linux")] - { - let handle = RawWindowHandle::Xlib(XlibWindowHandle::new(parent_handle as c_ulong)); - // if log_enabled() { - // eprintln!("[wrywebview] raw_window_handle Xlib=0x{:x}", parent_handle); - // } - wry_log!("[wrywebview] raw_window_handle Xlib=0x{:x}", parent_handle); - return Ok(handle); - } - - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - { - Err(WebViewError::UnsupportedPlatform) - } -} diff --git a/wrywebview/src/main/rust/lib.rs b/wrywebview/src/main/rust/lib.rs deleted file mode 100644 index ee2d573..0000000 --- a/wrywebview/src/main/rust/lib.rs +++ /dev/null @@ -1,1011 +0,0 @@ -//! Native WebView bindings using wry. -//! -//! This library provides a cross-platform WebView implementation -//! exposed through UniFFI for use from Kotlin/Swift. - -mod error; -mod handle; -mod platform; -mod state; - -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::atomic::Ordering; -use std::sync::{Arc, OnceLock, RwLock}; - -use wry::cookie::time::OffsetDateTime; -use wry::cookie::{Cookie, Expiration, SameSite}; -use wry::http::header::HeaderName; -use wry::http::{HeaderMap, HeaderValue}; -use wry::{WebContext, WebViewBuilder, RGBA}; - -pub use error::WebViewError; - -use handle::{make_bounds, raw_window_handle_from, RawWindow}; -use state::{get_state, register, unregister, with_webview, WebViewState}; - -#[cfg(target_os = "linux")] -use platform::linux::{ensure_gtk_initialized, run_on_gtk_thread}; - -#[cfg(target_os = "linux")] -use wry::WebViewExtUnix; - -#[cfg(not(target_os = "linux"))] -use platform::run_on_main_thread; - -#[cfg(target_os = "macos")] -use platform::macos::{DispatchQueue, MainThreadMarker}; - -// ============================================================================= -// Public records/enums (UniFFI) -// ============================================================================= - -#[derive(Debug, Clone, uniffi::Record)] -pub struct HttpHeader { - pub name: String, - pub value: String, -} - -#[derive(Debug, Clone, uniffi::Enum)] -pub enum CookieSameSite { - None, - Lax, - Strict, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct WebViewCookie { - pub name: String, - pub value: String, - pub domain: Option, - pub path: Option, - /// Unix timestamp in milliseconds. - pub expires_date_ms: Option, - pub is_session_only: bool, - /// Max-Age in seconds. - pub max_age_sec: Option, - pub same_site: Option, - pub is_secure: Option, - pub is_http_only: Option, -} - -#[derive(Debug, Clone, Copy, uniffi::Record)] -pub struct Rgba { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -impl From for RGBA { - fn from(v: Rgba) -> Self { - (v.r, v.g, v.b, v.a) - } -} - -impl From for Rgba { - fn from(v: RGBA) -> Self { - let (r, g, b, a) = v; - Rgba { r, g, b, a } - } -} - -fn header_map_from(headers: Vec) -> Result { - let mut map = HeaderMap::new(); - for header in headers { - let name = HeaderName::from_str(&header.name).map_err(|_| { - WebViewError::Internal(format!("invalid header name: {}", header.name)) - })?; - let value = HeaderValue::from_str(&header.value).map_err(|_| { - WebViewError::Internal(format!("invalid header value for {}: {}", header.name, header.value)) - })?; - map.insert(name, value); - } - Ok(map) -} - -fn cookie_record_from(cookie: &Cookie<'_>) -> WebViewCookie { - let expires_date_ms = cookie - .expires() - .and_then(Expiration::datetime) - .map(|dt| dt.unix_timestamp() * 1000 + (dt.nanosecond() as i64 / 1_000_000)); - - let is_session_only = matches!(cookie.expires(), Some(Expiration::Session)); - - WebViewCookie { - name: cookie.name().to_string(), - value: cookie.value().to_string(), - domain: cookie.domain().map(ToString::to_string), - path: cookie.path().map(ToString::to_string), - expires_date_ms, - is_session_only, - max_age_sec: cookie.max_age().map(|d| d.whole_seconds()), - same_site: cookie.same_site().map(|s| match s { - SameSite::None => CookieSameSite::None, - SameSite::Lax => CookieSameSite::Lax, - SameSite::Strict => CookieSameSite::Strict, - }), - is_secure: cookie.secure(), - is_http_only: cookie.http_only(), - } -} - -fn cookie_from_record(cookie: WebViewCookie) -> Result, WebViewError> { - let mut builder = Cookie::build((cookie.name, cookie.value)); - - if let Some(domain) = cookie.domain { - builder = builder.domain(domain); - } - if let Some(path) = cookie.path { - builder = builder.path(path); - } - if let Some(expires_ms) = cookie.expires_date_ms { - let dt = OffsetDateTime::from_unix_timestamp_nanos((expires_ms as i128) * 1_000_000) - .map_err(|_| WebViewError::Internal("invalid expires_date_ms".to_string()))?; - builder = builder.expires(dt); - } else if cookie.is_session_only { - builder = builder.expires(None::); - } - - if let Some(max_age) = cookie.max_age_sec { - builder = builder.max_age(wry::cookie::time::Duration::seconds(max_age)); - } - if let Some(is_secure) = cookie.is_secure { - builder = builder.secure(is_secure); - } - if let Some(is_http_only) = cookie.is_http_only { - builder = builder.http_only(is_http_only); - } - if let Some(same_site) = cookie.same_site { - let mapped = match same_site { - CookieSameSite::None => SameSite::None, - CookieSameSite::Lax => SameSite::Lax, - CookieSameSite::Strict => SameSite::Strict, - }; - builder = builder.same_site(mapped); - } - - Ok(builder.build()) -} - -use std::sync::atomic::AtomicBool; - -static LOG_ENABLED: AtomicBool = AtomicBool::new(false); - -pub(crate) fn log_enabled() -> bool { - LOG_ENABLED.load(Ordering::Relaxed) -} - -#[uniffi::export] -pub fn set_log_enabled(enabled: bool) { - LOG_ENABLED.store(enabled, Ordering::Relaxed); -} - -#[uniffi::export(callback_interface)] -pub trait NativeLogger: Send + Sync { - fn handle_log(&self, data: String); -} - -static GLOBAL_LOGGER: OnceLock>>> = OnceLock::new(); - -fn get_logger_registry() -> &'static RwLock>> { - GLOBAL_LOGGER.get_or_init(|| RwLock::new(None)) -} - -#[uniffi::export] -pub fn set_native_logger(logger: Box) { - let mut lock = get_logger_registry().write().unwrap(); - *lock = Some(logger); -} - -#[macro_export] -macro_rules! wry_log { - ($($arg:tt)*) => { - $crate::do_internal_log(format_args!($($arg)*)); - }; -} - -#[doc(hidden)] -pub fn do_internal_log(args: std::fmt::Arguments) { - if !log_enabled() { - return; - } - let log_string = args.to_string(); - - if let Ok(lock) = crate::get_logger_registry().read() { - if let Some(ref logger) = *lock { - logger.handle_log(log_string); - return; - } - } - eprintln!("{}", log_string); -} - -// ============================================================================ -// WebView Creation -// ============================================================================ - -#[uniffi::export(callback_interface)] -pub trait NavigationHandler: Send + Sync { - /// Return true to allow navigation, false to cancel. - fn handle_navigation(&self, url: String) -> bool; -} - -fn create_webview_inner( - parent_handle: u64, - width: i32, - height: i32, - url: String, - user_agent: Option, - data_directory: Option, - zoom: bool, - transparent: bool, - background_color: Rgba, - init_script: Option, - clipboard: bool, - dev_tools: bool, - navigation_gestures: bool, - incognito: bool, - autoplay: bool, - focused: bool, - nav_handler: Option>, -) -> Result { - let user_agent = - user_agent.and_then(|ua| { - let trimmed = ua.trim().to_string(); - if trimmed.is_empty() { None } else { Some(trimmed) } - }); - - wry_log!( - "[wrywebview] create_webview handle=0x{:x} size={}x{} url={} user_agent={} data_directory={}", - parent_handle, - width, - height, - url, - user_agent.as_deref().unwrap_or(""), - data_directory.as_deref().unwrap_or("") - ); - - let raw = raw_window_handle_from(parent_handle)?; - let window = RawWindow { raw }; - - #[cfg(target_os = "linux")] - ensure_gtk_initialized()?; - - let state = Arc::new(WebViewState::new(url.clone())); - let state_for_nav = Arc::clone(&state); - let state_for_load = Arc::clone(&state); - let state_for_title = Arc::clone(&state); - let state_for_ipc = Arc::clone(&state); - - let mut web_context = data_directory.map(|path| WebContext::new(Some(PathBuf::from(path)))); - - let mut builder = if let Some(ref mut context) = web_context { - WebViewBuilder::new_with_web_context(context) - } else { - WebViewBuilder::new() - }; - - builder = builder - .with_hotkeys_zoom(zoom) - .with_transparent(transparent) - .with_background_color(background_color.into()) - .with_clipboard(clipboard) - .with_devtools(dev_tools) - .with_back_forward_navigation_gestures(navigation_gestures) - .with_incognito(incognito) - .with_autoplay(autoplay) - .with_focused(focused) - .with_url(&url) - .with_bounds(make_bounds(0, 0, width, height)); - - if let Some(is) = init_script { - builder = builder.with_initialization_script(is); - } - - if let Some(ua) = user_agent { - builder = builder.with_user_agent(ua); - } - - let webview = builder - .with_navigation_handler(move |new_url| { - if let Some(handler) = &nav_handler { - return handler.handle_navigation(new_url.to_string()); - } - - wry_log!("[wrywebview] navigation_handler url={}", new_url); - state_for_nav.is_loading.store(true, Ordering::SeqCst); - if let Err(e) = state_for_nav.update_current_url(new_url.clone()) { - wry_log!("[wrywebview] navigation_handler state update failed: {}", e); - } - - true - }) - .with_on_page_load_handler(move |event, url| { - match event { - wry::PageLoadEvent::Started => { - wry_log!("[wrywebview] page_load_handler event=Started url={}", url); - state_for_load.is_loading.store(true, Ordering::SeqCst); - } - wry::PageLoadEvent::Finished => { - wry_log!("[wrywebview] page_load_handler event=Finished url={}", url); - state_for_load.is_loading.store(false, Ordering::SeqCst); - if let Err(e) = state_for_load.update_current_url(url.clone()) { - wry_log!("[wrywebview] page_load_handler state update failed: {}", e); - } - } - } - }) - .with_document_title_changed_handler(move |title| { - wry_log!("[wrywebview] title_changed title={}", title); - if let Err(e) = state_for_title.update_page_title(title) { - wry_log!("[wrywebview] title_changed state update failed: {}", e); - } - }) - .with_ipc_handler(move |request| { - let url = request.uri().to_string(); - let message = request.into_body(); - wry_log!("[wrywebview] ipc url={} body_len={}", url, message.len()); - if let Err(e) = state_for_ipc.push_ipc_message(message) { - wry_log!("[wrywebview] ipc queue push failed: {}", e); - } - }) - .build_as_child(&window)?; - - // On Linux, set up focus handling for the GTK widget - #[cfg(target_os = "linux")] - { - 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. - // 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"); - widget.grab_focus(); - gtk::glib::Propagation::Proceed - }); - wry_log!("[wrywebview] gtk focus handling configured"); - } - - let id = register(webview, state, web_context)?; - wry_log!("[wrywebview] create_webview success id={}", id); - Ok(id) -} - -#[uniffi::export] -pub fn create_webview( - parent_handle: u64, - width: i32, - height: i32, - url: String, - user_agent: Option, - data_directory: Option, - zoom: bool, - transparent: bool, - background_color: Rgba, - init_script: Option, - clipboard: bool, - dev_tools: bool, - navigation_gestures: bool, - incognito: bool, - autoplay: bool, - focused: bool, - nav_handler: Option> -) -> Result { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || { - create_webview_inner( - parent_handle, - width, - height, - url, - user_agent, - data_directory, - zoom, - transparent, - background_color, - init_script, - clipboard, - dev_tools, - navigation_gestures, - incognito, - autoplay, - focused, - nav_handler - ) - }); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread( - move || create_webview_inner( - parent_handle, - width, height, - url, - user_agent, - data_directory, - zoom, - transparent, - background_color, - init_script, - clipboard, - dev_tools, - navigation_gestures, - incognito, - autoplay, - focused, - nav_handler - ) - ) -} - -// ============================================================================ -// Bounds Management -// ============================================================================ - -fn set_bounds_inner(id: u64, x: i32, y: i32, width: i32, height: i32) -> Result<(), WebViewError> { - wry_log!( - "[wrywebview] set_bounds id={} pos=({}, {}) size={}x{}", - id, x, y, width, height - ); - let bounds = make_bounds(x, y, width, height); - with_webview(id, |webview| webview.set_bounds(bounds).map_err(WebViewError::from)) -} - -#[uniffi::export] -pub fn set_bounds(id: u64, x: i32, y: i32, width: i32, height: i32) -> Result<(), WebViewError> { - #[cfg(target_os = "macos")] - { - if MainThreadMarker::new().is_some() { - return set_bounds_inner(id, x, y, width, height); - } - DispatchQueue::main().exec_async(move || { - let _ = set_bounds_inner(id, x, y, width, height); - }); - return Ok(()); - } - - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || set_bounds_inner(id, x, y, width, height)); - } - - #[cfg(target_os = "windows")] - { - run_on_main_thread(move || set_bounds_inner(id, x, y, width, height)) - } -} - -// ============================================================================ -// Navigation -// ============================================================================ - -fn load_url_inner(id: u64, url: String) -> Result<(), WebViewError> { - wry_log!("[wrywebview] load_url id={} url={}", id, url); - if let Ok(state) = get_state(id) { - state.is_loading.store(true, Ordering::SeqCst); - } - with_webview(id, |webview| webview.load_url(&url).map_err(WebViewError::from)) -} - -#[uniffi::export] -pub fn load_url(id: u64, url: String) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || load_url_inner(id, url)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || load_url_inner(id, url)) -} - -fn load_url_with_headers_inner( - id: u64, - url: String, - headers: Vec, -) -> Result<(), WebViewError> { - wry_log!( - "[wrywebview] load_url_with_headers id={} url={} headers={}", - id, - url, - headers.len() - ); - if let Ok(state) = get_state(id) { - state.is_loading.store(true, Ordering::SeqCst); - } - let header_map = header_map_from(headers)?; - with_webview(id, |webview| { - webview - .load_url_with_headers(&url, header_map) - .map_err(WebViewError::from) - }) -} - -#[uniffi::export] -pub fn load_url_with_headers( - id: u64, - url: String, - headers: Vec, -) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || load_url_with_headers_inner(id, url, headers)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || load_url_with_headers_inner(id, url, headers)) -} - -fn load_html_inner(id: u64, html: String) -> Result<(), WebViewError> { - wry_log!("[wrywebview] load_html id={} bytes={}", id, html.len()); - if let Ok(state) = get_state(id) { - state.is_loading.store(true, Ordering::SeqCst); - } - with_webview(id, |webview| webview.load_html(&html).map_err(WebViewError::from)) -} - -#[uniffi::export] -pub fn load_html(id: u64, html: String) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || load_html_inner(id, html)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || load_html_inner(id, html)) -} - -fn stop_loading_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] stop_loading id={}", id); - if let Ok(state) = get_state(id) { - state.is_loading.store(false, Ordering::SeqCst); - } - with_webview(id, |webview| { - webview - .evaluate_script("window.stop && window.stop();") - .map_err(WebViewError::from) - }) -} - -#[uniffi::export] -pub fn stop_loading(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || stop_loading_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || stop_loading_inner(id)) -} - -#[uniffi::export(callback_interface)] -pub trait JavaScriptCallback: Send + Sync { - fn on_result(&self, result: String); -} - -fn evaluate_javascript_inner( - id: u64, - script: String, - callback: Box, -) -> Result<(), WebViewError> { - with_webview(id, |webview| { - let _ = webview.evaluate_script_with_callback(&script, move |result| { - callback.on_result(result); - }); - Ok(()) - }) -} - -#[uniffi::export] -pub fn evaluate_javascript( - id: u64, - script: String, - callback: Box, -) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || evaluate_javascript_inner(id, script, callback)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || evaluate_javascript_inner(id, script, callback)) -} - -fn go_back_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] go_back id={}", id); - if let Ok(state) = get_state(id) { - state.is_loading.store(true, Ordering::SeqCst); - } - with_webview(id, |webview| { - webview - .evaluate_script("window.history.back()") - .map_err(WebViewError::from) - }) -} - -#[uniffi::export] -pub fn go_back(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || go_back_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || go_back_inner(id)) -} - -fn go_forward_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] go_forward id={}", id); - if let Ok(state) = get_state(id) { - state.is_loading.store(true, Ordering::SeqCst); - } - with_webview(id, |webview| { - webview - .evaluate_script("window.history.forward()") - .map_err(WebViewError::from) - }) -} - -#[uniffi::export] -pub fn go_forward(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || go_forward_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || go_forward_inner(id)) -} - -fn reload_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] reload id={}", id); - if let Ok(state) = get_state(id) { - state.is_loading.store(true, Ordering::SeqCst); - } - with_webview(id, |webview| { - webview - .evaluate_script("window.location.reload()") - .map_err(WebViewError::from) - }) -} - -#[uniffi::export] -pub fn reload(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || reload_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || reload_inner(id)) -} - -// ============================================================================ -// Focus -// ============================================================================ - -fn focus_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] focus id={}", id); - with_webview(id, |webview| { - // On Linux, keep focus changes within GTK/Wry to avoid desynchronizing - // focus with the host AWT/Swing hierarchy. - #[cfg(target_os = "linux")] - { - use gtk::prelude::WidgetExt; - - let gtk_widget = webview.webview(); - gtk_widget.set_can_focus(true); - - // First, ensure the widget is realized and has a window - if !gtk_widget.is_realized() { - gtk_widget.realize(); - } - - // Also call GTK grab_focus as a fallback - gtk_widget.grab_focus(); - wry_log!("[wrywebview] gtk grab_focus called"); - } - - webview.focus().map_err(WebViewError::from) - }) -} - -#[uniffi::export] -pub fn focus(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || focus_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || focus_inner(id)) -} - -// ============================================================================ -// State Queries -// ============================================================================ - -#[uniffi::export] -pub fn get_url(id: u64) -> Result { - let state = get_state(id)?; - let url = state - .current_url - .lock() - .map_err(|_| WebViewError::Internal("url lock poisoned".to_string()))?; - Ok(url.clone()) -} - -#[uniffi::export] -pub fn is_loading(id: u64) -> Result { - let state = get_state(id)?; - Ok(state.is_loading.load(Ordering::SeqCst)) -} - -#[uniffi::export] -pub fn get_title(id: u64) -> Result { - let state = get_state(id)?; - let title = state - .page_title - .lock() - .map_err(|_| WebViewError::Internal("title lock poisoned".to_string()))?; - Ok(title.clone()) -} - -#[uniffi::export] -pub fn can_go_back(id: u64) -> Result { - let state = get_state(id)?; - state.can_go_back() -} - -#[uniffi::export] -pub fn can_go_forward(id: u64) -> Result { - let state = get_state(id)?; - state.can_go_forward() -} - -#[uniffi::export] -pub fn drain_ipc_messages(id: u64) -> Result, WebViewError> { - let state = get_state(id)?; - 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 -// ============================================================================ - -fn get_cookies_inner(id: u64) -> Result, WebViewError> { - wry_log!("[wrywebview] get_cookies id={}", id); - with_webview(id, |webview| { - let cookies = webview.cookies().map_err(WebViewError::from)?; - Ok(cookies.iter().map(cookie_record_from).collect()) - }) -} - -#[uniffi::export] -pub fn get_cookies(id: u64) -> Result, WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || get_cookies_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || get_cookies_inner(id)) -} - -fn get_cookies_for_url_inner(id: u64, url: String) -> Result, WebViewError> { - wry_log!("[wrywebview] get_cookies_for_url id={} url={}", id, url); - with_webview(id, |webview| { - let cookies = webview.cookies_for_url(&url).map_err(WebViewError::from)?; - Ok(cookies.iter().map(cookie_record_from).collect()) - }) -} - -#[uniffi::export] -pub fn get_cookies_for_url(id: u64, url: String) -> Result, WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || get_cookies_for_url_inner(id, url)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || get_cookies_for_url_inner(id, url)) -} - -fn clear_cookies_for_url_inner(id: u64, url: String) -> Result<(), WebViewError> { - wry_log!("[wrywebview] clear_cookies_for_url id={} url={}", id, url); - with_webview(id, |webview| { - let cookies = webview.cookies_for_url(&url).map_err(WebViewError::from)?; - for cookie in cookies { - webview - .delete_cookie(&cookie) - .map_err(WebViewError::from)?; - } - Ok(()) - }) -} - -#[uniffi::export] -pub fn clear_cookies_for_url(id: u64, url: String) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || clear_cookies_for_url_inner(id, url)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || clear_cookies_for_url_inner(id, url)) -} - -fn clear_all_cookies_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] clear_all_cookies id={}", id); - with_webview(id, |webview| { - let cookies = webview.cookies().map_err(WebViewError::from)?; - for cookie in cookies { - webview - .delete_cookie(&cookie) - .map_err(WebViewError::from)?; - } - Ok(()) - }) -} - -#[uniffi::export] -pub fn clear_all_cookies(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || clear_all_cookies_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || clear_all_cookies_inner(id)) -} - -fn set_cookie_inner(id: u64, cookie: WebViewCookie) -> Result<(), WebViewError> { - wry_log!("[wrywebview] set_cookie id={} name={}", id, &cookie.name); - let native = cookie_from_record(cookie)?; - with_webview(id, |webview| webview.set_cookie(&native).map_err(WebViewError::from)) -} - -#[uniffi::export] -pub fn set_cookie(id: u64, cookie: WebViewCookie) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || set_cookie_inner(id, cookie)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || set_cookie_inner(id, cookie)) -} - -// ============================================================================ -// DevTools -// ============================================================================ - -fn open_dev_tools_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] open_dev_tools id={}", id); - with_webview(id, |webview| { - webview.open_devtools(); - Ok(()) - }) -} - -#[uniffi::export] -pub fn open_dev_tools(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || open_dev_tools_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || open_dev_tools_inner(id)) -} - -fn close_dev_tools_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] close_dev_tools id={}", id); - with_webview(id, |webview| { - webview.close_devtools(); - Ok(()) - }) -} - -#[uniffi::export] -pub fn close_dev_tools(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || close_dev_tools_inner(id)); - } - - #[cfg(not(target_os = "linux"))] - run_on_main_thread(move || close_dev_tools_inner(id)) -} - -// ============================================================================ -// Destruction -// ============================================================================ - -fn destroy_webview_inner(id: u64) -> Result<(), WebViewError> { - wry_log!("[wrywebview] destroy_webview id={}", id); - - #[cfg(target_os = "linux")] - { - gdk::error_trap_push(); - let res = unregister(id); - while gtk::events_pending() { - gtk::main_iteration_do(false); - } - let _ = gdk::error_trap_pop(); - res - } - - #[cfg(not(target_os = "linux"))] - unregister(id) -} - -#[uniffi::export] -pub fn destroy_webview(id: u64) -> Result<(), WebViewError> { - #[cfg(target_os = "macos")] - { - if MainThreadMarker::new().is_some() { - return destroy_webview_inner(id); - } - DispatchQueue::main().exec_async(move || { - let _ = destroy_webview_inner(id); - }); - return Ok(()); - } - - #[cfg(target_os = "linux")] - { - return run_on_gtk_thread(move || destroy_webview_inner(id)); - } - - #[cfg(any(target_os = "windows", not(any(target_os = "linux", target_os = "macos"))))] - run_on_main_thread(move || destroy_webview_inner(id)) -} - -// ============================================================================ -// Event Pumps -// ============================================================================ - -#[uniffi::export] -pub fn pump_gtk_events() { - #[cfg(target_os = "linux")] - { - // Events are pumped continuously on the dedicated GTK thread. - } -} - -#[uniffi::export] -pub fn pump_windows_events() { - #[cfg(target_os = "windows")] - { - platform::windows::pump_events(); - } -} - -uniffi::setup_scaffolding!(); diff --git a/wrywebview/src/main/rust/platform/linux.rs b/wrywebview/src/main/rust/platform/linux.rs deleted file mode 100644 index 49985e3..0000000 --- a/wrywebview/src/main/rust/platform/linux.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Linux-specific GTK thread management. - -use std::sync::mpsc; -use std::sync::OnceLock; -use std::time::Duration; - -use crate::error::WebViewError; - -type GtkTask = Box; - -struct GtkRunner { - sender: mpsc::Sender, - init_error: Option, -} - -static GTK_RUNNER: OnceLock = OnceLock::new(); - -fn gtk_runner() -> Result<&'static GtkRunner, WebViewError> { - let runner = GTK_RUNNER.get_or_init(|| { - let (task_tx, task_rx) = mpsc::channel::(); - let (init_tx, init_rx) = mpsc::sync_channel::>(1); - - std::thread::spawn(move || { - let init_result = gtk::init().map_err(|err| err.to_string()); - let _ = init_tx.send(init_result.clone()); - - if init_result.is_err() { - return; - } - - loop { - while let Ok(task) = task_rx.try_recv() { - task(); - } - while gtk::events_pending() { - gtk::main_iteration_do(false); - } - std::thread::sleep(Duration::from_millis(8)); - } - }); - - let init_result = init_rx - .recv() - .unwrap_or_else(|_| Err("gtk init thread failed".to_string())); - - GtkRunner { - sender: task_tx, - init_error: init_result.err(), - } - }); - - if let Some(err) = runner.init_error.as_ref() { - return Err(WebViewError::GtkInit(err.clone())); - } - - Ok(runner) -} - -/// Runs a closure on the dedicated GTK thread. -pub fn run_on_gtk_thread(f: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, - R: Send + 'static, -{ - let runner = gtk_runner()?; - let (result_tx, result_rx) = mpsc::sync_channel(1); - - runner - .sender - .send(Box::new(move || { - let result = f(); - let _ = result_tx.send(result); - })) - .map_err(|_| WebViewError::Internal("gtk runner stopped".to_string()))?; - - result_rx - .recv() - .map_err(|_| WebViewError::Internal("gtk runner stopped".to_string()))? -} - -/// Ensures GTK is initialized on the current thread. -pub fn ensure_gtk_initialized() -> Result<(), WebViewError> { - gtk::init().map_err(|err| WebViewError::GtkInit(err.to_string())) -} diff --git a/wrywebview/src/main/rust/platform/macos.rs b/wrywebview/src/main/rust/platform/macos.rs deleted file mode 100644 index 5df6300..0000000 --- a/wrywebview/src/main/rust/platform/macos.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! macOS-specific AppKit handling. - -use std::ffi::c_void; -use std::ffi::CStr; -use std::ptr::NonNull; - -use dispatch2::run_on_main; -pub use dispatch2::DispatchQueue; -use objc2::msg_send; -use objc2::runtime::{AnyClass, AnyObject}; -pub use objc2::MainThreadMarker; - -use crate::error::WebViewError; -use crate::wry_log; - -/// Runs a closure on the main thread using GCD. -pub fn run_on_main_thread(f: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, - R: Send + 'static, -{ - run_on_main(|_| f()) -} - -/// Converts a raw handle to an NSView pointer. -/// -/// Handles both NSWindow (extracts contentView) and NSView objects. -pub fn appkit_ns_view_from_handle(parent_handle: u64) -> Result, WebViewError> { - let ptr = NonNull::new(parent_handle as *mut c_void) - .ok_or(WebViewError::InvalidWindowHandle)?; - let obj = unsafe { &*(ptr.as_ptr() as *mut AnyObject) }; - let class_name = obj.class().name().to_string_lossy(); - // if log_enabled() { - // eprintln!("[wrywebview] appkit handle class={}", class_name); - // } - wry_log!("[wrywebview] appkit handle class={}", class_name); - - let nswindow_name = unsafe { CStr::from_bytes_with_nul_unchecked(b"NSWindow\0") }; - let nsview_name = unsafe { CStr::from_bytes_with_nul_unchecked(b"NSView\0") }; - let nswindow_cls = AnyClass::get(nswindow_name).ok_or(WebViewError::InvalidWindowHandle)?; - let nsview_cls = AnyClass::get(nsview_name).ok_or(WebViewError::InvalidWindowHandle)?; - - unsafe { - if msg_send![obj, isKindOfClass: nswindow_cls] { - let view: *mut AnyObject = msg_send![obj, contentView]; - let view = NonNull::new(view).ok_or(WebViewError::InvalidWindowHandle)?; - // if log_enabled() { - // eprintln!( - // "[wrywebview] appkit handle is NSWindow, contentView=0x{:x}", - // view.as_ptr() as usize - // ); - // } - wry_log!( - "[wrywebview] appkit handle is NSWindow, contentView=0x{:x}", - view.as_ptr() as usize - ); - return Ok(view.cast()); - } - if msg_send![obj, isKindOfClass: nsview_cls] { - return Ok(ptr); - } - } - - Err(WebViewError::InvalidWindowHandle) -} diff --git a/wrywebview/src/main/rust/platform/mod.rs b/wrywebview/src/main/rust/platform/mod.rs deleted file mode 100644 index ce2b428..0000000 --- a/wrywebview/src/main/rust/platform/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Platform-specific implementations. - -#[cfg(target_os = "linux")] -pub mod linux; - -#[cfg(target_os = "macos")] -pub mod macos; - -#[cfg(target_os = "windows")] -pub mod windows; - -#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] -use crate::error::WebViewError; - -#[cfg(target_os = "macos")] -pub use macos::run_on_main_thread; - -/// Runs a closure on the main thread (no-op on non-macOS platforms). -#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] -pub fn run_on_main_thread(f: F) -> Result -where - F: FnOnce() -> Result, -{ - f() -} diff --git a/wrywebview/src/main/rust/platform/windows.rs b/wrywebview/src/main/rust/platform/windows.rs deleted file mode 100644 index 6363203..0000000 --- a/wrywebview/src/main/rust/platform/windows.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Windows-specific message pump. - -/// Pumps the Windows message queue. -pub fn pump_events() { - use windows::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE, - }; - - unsafe { - let mut msg: MSG = std::mem::zeroed(); - while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } -} diff --git a/wrywebview/src/main/rust/state.rs b/wrywebview/src/main/rust/state.rs deleted file mode 100644 index 3ae1f5a..0000000 --- a/wrywebview/src/main/rust/state.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! WebView state management and registry. - -use std::collections::VecDeque; -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::thread::ThreadId; - -use wry::WebView; - -use crate::error::WebViewError; - -/// Tracks the loading state and current URL of a WebView. -pub struct WebViewState { - pub is_loading: AtomicBool, - pub current_url: Mutex, - pub page_title: Mutex, - history: Mutex>, - history_index: Mutex, - ipc_messages: Mutex>, -} - -impl WebViewState { - /// Creates a new WebViewState with the given initial URL. - pub fn new(url: String) -> Self { - Self { - is_loading: AtomicBool::new(true), - current_url: Mutex::new(url), - page_title: Mutex::new(String::new()), - history: Mutex::new(Vec::new()), - history_index: Mutex::new(-1), - ipc_messages: Mutex::new(VecDeque::new()), - } - } - - pub fn update_current_url(&self, url: String) -> Result<(), WebViewError> { - { - let mut current = self - .current_url - .lock() - .map_err(|_| WebViewError::Internal("url lock poisoned".to_string()))?; - *current = url.clone(); - } - self.update_history(url) - } - - pub fn update_page_title(&self, title: String) -> Result<(), WebViewError> { - let mut page_title = self - .page_title - .lock() - .map_err(|_| WebViewError::Internal("title lock poisoned".to_string()))?; - *page_title = title; - Ok(()) - } - - pub fn push_ipc_message(&self, message: String) -> Result<(), WebViewError> { - let mut queue = self - .ipc_messages - .lock() - .map_err(|_| WebViewError::Internal("ipc queue lock poisoned".to_string()))?; - queue.push_back(message); - Ok(()) - } - - pub fn drain_ipc_messages(&self) -> Result, WebViewError> { - let mut queue = self - .ipc_messages - .lock() - .map_err(|_| WebViewError::Internal("ipc queue lock poisoned".to_string()))?; - Ok(queue.drain(..).collect()) - } - - pub fn can_go_back(&self) -> Result { - let history = self - .history - .lock() - .map_err(|_| WebViewError::Internal("history lock poisoned".to_string()))?; - let index = self - .history_index - .lock() - .map_err(|_| WebViewError::Internal("history index lock poisoned".to_string()))?; - Ok(*index > 0 && !history.is_empty()) - } - - pub fn can_go_forward(&self) -> Result { - let history = self - .history - .lock() - .map_err(|_| WebViewError::Internal("history lock poisoned".to_string()))?; - let index = self - .history_index - .lock() - .map_err(|_| WebViewError::Internal("history index lock poisoned".to_string()))?; - if history.is_empty() || *index < 0 { - return Ok(false); - } - let idx = *index as usize; - Ok(idx < history.len().saturating_sub(1)) - } - - fn update_history(&self, new_url: String) -> Result<(), WebViewError> { - let mut history = self - .history - .lock() - .map_err(|_| WebViewError::Internal("history lock poisoned".to_string()))?; - let mut index = self - .history_index - .lock() - .map_err(|_| WebViewError::Internal("history index lock poisoned".to_string()))?; - - if *index >= 0 { - let idx = *index as usize; - if history.get(idx).is_some_and(|url| url == &new_url) { - return Ok(()); - } - let back_url = if idx > 0 { history.get(idx - 1) } else { None }; - let forward_url = history.get(idx + 1); - if back_url.is_some_and(|url| url == &new_url) { - *index -= 1; - return Ok(()); - } - if forward_url.is_some_and(|url| url == &new_url) { - *index += 1; - return Ok(()); - } - - if idx + 1 < history.len() { - history.truncate(idx + 1); - } - } else { - history.clear(); - } - - history.push(new_url); - *index = (history.len() as isize) - 1; - Ok(()) - } -} - -/// Entry in the WebView registry containing the pointer and metadata. -pub struct WebViewEntry { - pub ptr: *mut WebView, - pub thread_id: ThreadId, - pub state: Arc, - #[allow(dead_code)] - pub context: Option, -} - -// The raw pointer is only dereferenced on the creating thread (checked at runtime). -unsafe impl Send for WebViewEntry {} -unsafe impl Sync for WebViewEntry {} - -static NEXT_ID: AtomicU64 = AtomicU64::new(1); -static WEBVIEWS: OnceLock>> = OnceLock::new(); - -/// Returns the global WebView registry. -pub fn webviews() -> &'static Mutex> { - WEBVIEWS.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Generates a new unique WebView ID. -pub fn next_id() -> u64 { - NEXT_ID.fetch_add(1, Ordering::Relaxed) -} - -/// Executes a closure with access to the WebView, ensuring thread safety. -pub fn with_webview(id: u64, f: F) -> Result -where - F: FnOnce(&WebView) -> Result, -{ - let (ptr, thread_id) = { - let map = webviews() - .lock() - .map_err(|_| WebViewError::Internal("webview registry lock poisoned".to_string()))?; - let entry = map.get(&id).ok_or(WebViewError::WebViewNotFound(id))?; - (entry.ptr, entry.thread_id) - }; - - if thread_id != std::thread::current().id() { - return Err(WebViewError::WrongThread(id)); - } - - let webview = unsafe { &*ptr }; - f(webview) -} - -/// Retrieves the state for a WebView by ID. -pub fn get_state(id: u64) -> Result, WebViewError> { - let map = webviews() - .lock() - .map_err(|_| WebViewError::Internal("webview registry lock poisoned".to_string()))?; - let entry = map.get(&id).ok_or(WebViewError::WebViewNotFound(id))?; - Ok(Arc::clone(&entry.state)) -} - -/// Registers a new WebView in the global registry. -pub fn register( - webview: WebView, - state: Arc, - context: Option, -) -> Result { - let id = next_id(); - let entry = WebViewEntry { - ptr: Box::into_raw(Box::new(webview)), - thread_id: std::thread::current().id(), - state, - context, - }; - - let mut map = webviews() - .lock() - .map_err(|_| WebViewError::Internal("webview registry lock poisoned".to_string()))?; - map.insert(id, entry); - Ok(id) -} - -/// Removes and destroys a WebView from the registry. -pub fn unregister(id: u64) -> Result<(), WebViewError> { - let entry = { - let mut map = webviews() - .lock() - .map_err(|_| WebViewError::Internal("webview registry lock poisoned".to_string()))?; - - let Some(entry) = map.get(&id) else { - return Ok(()); - }; - - if entry.thread_id != std::thread::current().id() { - return Err(WebViewError::WrongThread(id)); - } - - map.remove(&id) - }; - - if let Some(entry) = entry { - unsafe { - drop(Box::from_raw(entry.ptr)); - } - } - - Ok(()) -} diff --git a/wrywebview/uniffi.toml b/wrywebview/uniffi.toml deleted file mode 100644 index 97a6e45..0000000 --- a/wrywebview/uniffi.toml +++ /dev/null @@ -1,2 +0,0 @@ -package_name = "io.github.kdroidfilter.webview.wry" -cdylib_name = "composewebview_wry" From 311a02eb8ebc322e247f35a0bad09ce7f16593e3 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 16:01:13 +0300 Subject: [PATCH 02/10] Add Linux WebKit desktop backend on Nucleus Tao with visual e2e and CI natives Wire desktop WebView through NativeView/WebKit2GTK (Linux only), Nucleus application plugin + GraalVM metadata, matrix native builds, and a full visual suite covering the public API. --- .github/workflows/build-natives.yaml | 87 ++++ .github/workflows/pr-build-check.yml | 52 +- .github/workflows/publish-on-maven.yml | 29 +- .gitignore | 4 + AGENTS.md | 19 +- README.md | 10 +- build.gradle.kts | 1 + .../composeResources/files/suite_fixture.html | 3 + .../dev/nucleusframework/webview/demo/App.kt | 4 +- .../webview/demo/VisualBridgeLab.kt | 454 ++++++++++++++++++ .../webview/demo/visualsuite/ReportWriter.kt | 40 ++ .../webview/demo/visualsuite/SuiteCatalog.kt | 66 +++ .../webview/demo/visualsuite/SuiteHelpers.kt | 140 ++++++ .../webview/demo/visualsuite/SuiteModel.kt | 40 ++ .../webview/demo/visualsuite/SuitePages.kt | 64 +++ .../webview/demo/visualsuite/SuiteRunner.kt | 450 +++++++++++++++++ .../demo/visualsuite/VisualSuiteApp.kt | 343 +++++++++++++ demo/build.gradle.kts | 52 +- .../dev/nucleusframework/webview/demo/main.kt | 48 +- .../reachability-metadata.json | 45 ++ .../webview/demo/WebViewTest.kt | 149 +----- gradle/libs.versions.toml | 7 +- settings.gradle.kts | 2 + webview-compose-test/build.gradle.kts | 4 - .../webview/web/PlaywrightWebView.kt | 115 +---- webview-compose/build.gradle.kts | 58 +++ .../webview/setting/PlatformWebSettings.kt | 7 +- .../webview/cookie/DesktopCookieManager.kt | 114 ++++- .../webview/web/DesktopWebView.kt | 97 +++- .../webview/web/NativeWebView.desktop.kt | 7 +- .../webview/web/WebViewDesktop.kt | 187 +++++++- .../web/linux/LinuxWebKitNativeWebView.kt | 233 +++++++++ .../webview/web/linux/WebKitLinuxBridge.kt | 213 ++++++++ .../src/jvmMain/native/linux/build.sh | 85 ++++ .../native/linux/compose_webview_internal.h | 53 ++ .../src/jvmMain/native/linux/cookies.c | 255 ++++++++++ .../src/jvmMain/native/linux/javascript.c | 93 ++++ .../src/jvmMain/native/linux/jni_bridge.c | 56 +++ .../src/jvmMain/native/linux/navigation.c | 218 +++++++++ .../src/jvmMain/native/linux/screenshot.c | 87 ++++ .../src/jvmMain/native/linux/view_lifecycle.c | 220 +++++++++ .../src/jvmMain/native/linux/view_signals.c | 101 ++++ .../composewebview/native-image.properties | 5 + .../composewebview/reachability-metadata.json | 82 ++++ .../META-INF/proguard/composewebview.pro | 28 ++ .../webview/web/LinuxWebViewE2eTest.kt | 92 ++++ .../webview/web/e2e/CookieDriver.kt | 50 ++ .../webview/web/e2e/E2eHelpers.kt | 44 ++ .../webview/web/e2e/E2eHost.kt | 115 +++++ .../webview/web/e2e/E2ePages.kt | 25 + .../webview/web/e2e/EvaluateJsDriver.kt | 31 ++ .../webview/web/e2e/InterceptorDriver.kt | 46 ++ .../webview/web/e2e/JsBridgeDriver.kt | 40 ++ .../webview/web/e2e/LoadHtmlDriver.kt | 41 ++ .../webview/web/e2e/NavigationDriver.kt | 52 ++ .../webview/web/e2e/ScreenshotDriver.kt | 41 ++ .../webview/web/e2e/UserAgentDriver.kt | 52 ++ 57 files changed, 4738 insertions(+), 318 deletions(-) create mode 100644 .github/workflows/build-natives.yaml create mode 100644 demo-shared/src/commonMain/composeResources/files/suite_fixture.html create mode 100644 demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/VisualBridgeLab.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteCatalog.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteModel.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuitePages.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt create mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt create mode 100644 demo/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview-demo/reachability-metadata.json create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt create mode 100755 webview-compose/src/jvmMain/native/linux/build.sh create mode 100644 webview-compose/src/jvmMain/native/linux/compose_webview_internal.h create mode 100644 webview-compose/src/jvmMain/native/linux/cookies.c create mode 100644 webview-compose/src/jvmMain/native/linux/javascript.c create mode 100644 webview-compose/src/jvmMain/native/linux/jni_bridge.c create mode 100644 webview-compose/src/jvmMain/native/linux/navigation.c create mode 100644 webview-compose/src/jvmMain/native/linux/screenshot.c create mode 100644 webview-compose/src/jvmMain/native/linux/view_lifecycle.c create mode 100644 webview-compose/src/jvmMain/native/linux/view_signals.c create mode 100644 webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties create mode 100644 webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json create mode 100644 webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt create mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml new file mode 100644 index 0000000..d474506 --- /dev/null +++ b/.github/workflows/build-natives.yaml @@ -0,0 +1,87 @@ +name: Build Native Libraries + +on: + workflow_call: + +# Native binaries are a pure function of the C sources. Each platform job caches +# its output tree keyed on a hash of every src/**/native/** file (plus this +# workflow). On a cache hit the compile steps are skipped and the job only +# verifies + re-uploads the artifact for downstream consumers. +# +# Currently only Linux is built (WebKit2GTK backend). Windows/macOS stay no-op +# until their native backends land โ€” add matrix jobs then. +# +# Bump the `natives-v1` prefix to force a full rebuild. + +jobs: + linux: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x64 + - os: ubuntu-24.04-arm + arch: aarch64 + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Restore native output cache + id: natives-cache + uses: actions/cache@v4 + with: + path: 'webview-compose/src/jvmMain/resources/nucleus/native/linux-*' + key: natives-v1-linux-${{ matrix.arch }}-${{ hashFiles('webview-compose/src/jvmMain/native/**', '.github/workflows/build-natives.yaml') }} + + - name: Setup JDK 21 + if: steps.natives-cache.outputs.cache-hit != 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Install build dependencies + if: steps.natives-cache.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libgtk-3-dev \ + libcairo2-dev \ + libsoup-3.0-dev \ + libwebkit2gtk-4.1-dev || \ + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libgtk-3-dev \ + libcairo2-dev \ + libsoup-3.0-dev \ + libwebkit2gtk-4.0-dev + + - name: Build compose WebView Linux native library + if: steps.natives-cache.outputs.cache-hit != 'true' + env: + JAVA_HOME: ${{ env.JAVA_HOME }} + run: bash webview-compose/src/jvmMain/native/linux/build.sh + + - name: Verify Linux natives + run: | + f="webview-compose/src/jvmMain/resources/nucleus/native/linux-${{ matrix.arch }}/libcompose_webview_linux.so" + if [ -f "$f" ]; then + echo "OK: $f ($(wc -c < "$f") bytes)" + file "$f" || true + else + echo "MISSING: $f" >&2 + find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true + exit 1 + fi + + - name: Upload Linux natives + uses: actions/upload-artifact@v4 + with: + name: natives-linux-${{ matrix.arch }} + path: 'webview-compose/src/jvmMain/resources/nucleus/native/linux-*/' + retention-days: 1 diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 033112d..22d7677 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -3,22 +3,68 @@ name: PR Build Check on: pull_request: branches: [main] + push: + branches: [main] + +concurrency: + group: pr-build-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + build-natives: + uses: ./.github/workflows/build-natives.yaml + build: + needs: build-natives runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 + - name: Download native artifacts + uses: actions/download-artifact@v4 + with: + pattern: 'natives-*' + merge-multiple: true + + - name: Verify natives present + run: | + EXPECTED=( + "webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so" + "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so" + ) + MISSING=0 + for f in "${EXPECTED[@]}"; do + if [ -f "$f" ]; then + echo "OK: $f ($(wc -c < "$f") bytes)" + else + echo "MISSING: $f" >&2 + MISSING=1 + fi + done + if [ "$MISSING" = "1" ]; then exit 1; fi + - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: "17" - distribution: "temurin" + java-version: '17' + distribution: temurin - name: Setup Android SDK uses: android-actions/setup-android@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install runtime deps for compile (headers not required) + run: | + # No native rebuild here โ€” artifacts already downloaded. + true + - name: Build - run: ./gradlew :webview-compose:compileKotlinJvm :webview-compose:compileDebugKotlinAndroid :demo:compileKotlinJvm --no-configuration-cache + run: > + ./gradlew + :webview-compose:compileKotlinJvm + :webview-compose:compileDebugKotlinAndroid + :demo:compileKotlinJvm + --no-configuration-cache diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml index f647154..1c4fc91 100644 --- a/.github/workflows/publish-on-maven.yml +++ b/.github/workflows/publish-on-maven.yml @@ -5,13 +5,35 @@ on: types: [published] jobs: + build-natives: + if: startsWith(github.event.release.tag_name, 'v') + uses: ./.github/workflows/build-natives.yaml + publish: if: startsWith(github.event.release.tag_name, 'v') + needs: build-natives runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 + - name: Download native artifacts + uses: actions/download-artifact@v4 + with: + pattern: 'natives-*' + merge-multiple: true + + - name: Verify natives present + run: | + EXPECTED=( + "webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so" + "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so" + ) + for f in "${EXPECTED[@]}"; do + test -f "$f" || { echo "MISSING: $f" >&2; exit 1; } + echo "OK: $f ($(wc -c < "$f") bytes)" + done + - name: Set version from tag shell: bash run: | @@ -24,12 +46,15 @@ jobs: - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: "17" - distribution: "temurin" + java-version: '17' + distribution: temurin - name: Setup Android SDK uses: android-actions/setup-android@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Publish to Maven Central run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache env: diff --git a/.gitignore b/.gitignore index 9cc94da..d4399b2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ captures/ .externalNativeBuild/ .cxx/ +# Platform natives are built in CI (matrix) / locally via build scripts. +# Same layout as Nucleus: resources/nucleus/native/{linux,darwin,win32}-* +**/src/**/resources/nucleus/native/ + # Node node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 221d212..fd21e59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,24 @@ ## Build, Test, and Development Commands - `./gradlew build`: builds all modules. -- `./gradlew :demo:run`: runs the desktop demo app (desktop WebView is currently a no-op). +- `./gradlew :demo:run`: runs the desktop demo via the Nucleus application plugin + (Tao backend + WebKit2GTK on Linux; visual e2e suite entrypoint). +- `./gradlew :webview-compose:buildNativeLinux`: builds + `libcompose_webview_linux.so` for the host arch into + `webview-compose/src/jvmMain/resources/nucleus/native/linux-{x64,aarch64}/` + (requires `libwebkit2gtk-4.1-dev` + `libgtk-3-dev` + JDK). + Or: `bash webview-compose/src/jvmMain/native/linux/build.sh`. +- CI: `.github/workflows/build-natives.yaml` matrix builds **linux-x64** + (`ubuntu-latest`) and **linux-aarch64** (`ubuntu-24.04-arm`), uploads + artifacts; PR/publish workflows download them before compile/publish. + Natives are **not** committed (see `.gitignore`). +- GraalVM (demo): `nucleus.application { graalvm { isEnabled = true โ€ฆ } }`. + Library reachability metadata lives under + `webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/` + (JNI bridge + `nucleus/native/**` resources). Demo app metadata under + `demo/src/jvmMain/resources/META-INF/native-image/.../composewebview-demo/`. + Build a native image with `:demo:package` / Nucleus native-image tasks once + natives are present for the host OS. - `./gradlew :webview-compose:compileDebugKotlinAndroid`: compiles the Android implementation (requires Android SDK). - `./gradlew clean`: removes Gradle build outputs. diff --git a/README.md b/README.md index f167218..5fcc13a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ If you already know **compose-webview-multiplatform**, you already know how to u * Multiplatform packaging under NucleusFramework (`dev.nucleusframework`) * **WasmJs** target via **IFrame** -* Desktop (JVM) API surface (currently a **no-op** backend; no native rendering yet) +* Desktop (JVM) via **Nucleus Tao + NativeView** (Linux WebKit2GTK; Windows/macOS no-op for now) --- @@ -33,7 +33,7 @@ If you already know **compose-webview-multiplatform**, you already know how to u - **Android**: `android.webkit.WebView` - **iOS**: `WKWebView` - **WasmJs**: `org.w3c.dom.HTMLIFrameElement` -- **Desktop**: no-op stub (all operations are empty; composable renders an empty box) +- **Desktop**: Nucleus Tao `NativeView` + WebKit2GTK on Linux (requires `nucleusApplication` / Tao backend). Windows/macOS stay no-op until their backends land. --- @@ -69,7 +69,7 @@ Same artifact for **Android, iOS, Desktop and WasmJs**. Run the feature showcase first: -* **Desktop**: `./gradlew :demo:run` (WebView area is empty until a desktop backend lands) +* **Desktop**: `./gradlew :demo:run` (Nucleus application plugin; visual e2e suite on Linux) * **Android**: `./gradlew :demo-android:installDebug` * **WasmJs**: `./gradlew :demo-wasmJs:wasmJsBrowserDevelopmentRun` * **iOS**: open `iosApp/iosApp.xcodeproj` in Xcode and Run @@ -123,7 +123,7 @@ navigator.evaluateJavaScript("document.title = 'Hello'") * injected automatically after page load * callback-based -* works on Android / iOS / WasmJs (desktop no-op) +* works on Android / iOS / WasmJs / Desktop (Linux WebKit) ```js window.kmpJsBridge.callNative("echo", {...}, callback) @@ -210,7 +210,7 @@ state.webSettings.logSeverity = KLogSeverity.Debug ## Limitations * RequestInterceptor does **not** intercept sub-resources -* **Desktop** is a no-op: no rendering, navigation, cookies, or JS execution +* **Desktop**: requires Nucleus Tao (`nucleusApplication` + `decorated-window-tao`). Linux is fully wired (WebKit2GTK); Windows/macOS are still no-op. * **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 diff --git a/build.gradle.kts b/build.gradle.kts index c87abfd..0097386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,4 +10,5 @@ plugins { alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.mavenPublish) apply false + alias(libs.plugins.nucleus) apply false } diff --git a/demo-shared/src/commonMain/composeResources/files/suite_fixture.html b/demo-shared/src/commonMain/composeResources/files/suite_fixture.html new file mode 100644 index 0000000..57a185b --- /dev/null +++ b/demo-shared/src/commonMain/composeResources/files/suite_fixture.html @@ -0,0 +1,3 @@ + +Suite Fixture +
fixture-ok
diff --git a/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt index ab61a49..842c8d2 100644 --- a/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/App.kt @@ -59,7 +59,9 @@ fun App() { val webViewState = rememberWebViewState("https://httpbin.org/html") { logSeverity = KLogSeverity.Info - desktopWebSettings.transparent = true + // Opaque white page surface (normal browser). Set transparent=true + // only if you need Compose content to show through the WebView. + desktopWebSettings.transparent = false backgroundColor = androidx.compose.ui.graphics.Color.White } val jsBridge = rememberWebViewJsBridge(navigator) diff --git a/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/VisualBridgeLab.kt b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/VisualBridgeLab.kt new file mode 100644 index 0000000..f14fef5 --- /dev/null +++ b/demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/VisualBridgeLab.kt @@ -0,0 +1,454 @@ +package dev.nucleusframework.webview.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewStateWithHTMLData +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val PanelBg = Color(0xFF0F1419) +private val CardBg = Color(0xFF1A2332) +private val Accent = Color(0xFF60A5FA) +private val Ok = Color(0xFF34D399) +private val Warn = Color(0xFFFBBF24) +private val TextPrimary = Color(0xFFE6EDF3) +private val TextMuted = Color(0xFF8B9CB3) + +/** + * Visual lab: real Compose UI + real WebView + bidirectional JS bridge. + * Left = WebView page, right = Compose log of every hop. + */ +@Composable +fun VisualBridgeLab() { + val scope = rememberCoroutineScope() + val logs = remember { mutableStateListOf() } + val listState = rememberLazyListState() + + fun log(kind: String, message: String, color: Color = TextPrimary) { + val ts = nowTimestamp() + logs.add(0, LogLine(ts, kind, message, color)) + if (logs.size > 200) logs.removeRange(200, logs.size) + } + + val navigator = rememberWebViewNavigator(coroutineScope = scope) + val state = + rememberWebViewStateWithHTMLData( + data = VISUAL_BRIDGE_HTML, + baseUrl = "https://bridge.local/", + ).also { + it.webSettings.desktopWebSettings.transparent = false + it.webSettings.backgroundColor = Color.White + } + val jsBridge = rememberWebViewJsBridge(navigator) + + var lastNativeToJs by remember { mutableStateOf("โ€”") } + var lastJsToNative by remember { mutableStateOf("โ€”") } + var lastCallback by remember { mutableStateOf("โ€”") } + var roundTripCount by remember { mutableStateOf(0) } + var autoRunning by remember { mutableStateOf(true) } + + DisposableEffect(jsBridge) { + val ping = + object : IJsMessageHandler { + override fun methodName() = "ping" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + lastJsToNative = message.params + log("JS โ†’ KT", "ping params=${message.params}", Accent) + val reply = """{"pong":true,"echo":${message.params},"from":"kotlin"}""" + callback(reply) + log("KT โ†’ JS", "callback โ†’ $reply", Ok) + lastCallback = reply + roundTripCount++ + } + } + val logFromJs = + object : IJsMessageHandler { + override fun methodName() = "log" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + lastJsToNative = message.params + log("JS โ†’ KT", "log ${message.params}", Warn) + callback("""{"ok":true}""") + } + } + val paint = + object : IJsMessageHandler { + override fun methodName() = "paintComposeColor" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + lastJsToNative = message.params + log("JS โ†’ KT", "paintComposeColor ${message.params}", Accent) + callback("""{"applied":true}""") + lastCallback = """{"applied":true}""" + roundTripCount++ + } + } + jsBridge.register(ping) + jsBridge.register(logFromJs) + jsBridge.register(paint) + onDispose { + jsBridge.unregister(ping) + jsBridge.unregister(logFromJs) + jsBridge.unregister(paint) + } + } + + LaunchedEffect(logs.size) { + if (logs.isNotEmpty()) listState.animateScrollToItem(0) + } + + // Auto sequence once the page has finished loading. + LaunchedEffect(state.loadingState, autoRunning) { + if (!autoRunning) return@LaunchedEffect + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + delay(600) + log("SYS", "page finished โ€” starting auto bridge sequence", Ok) + + // 1) Kotlin โ†’ JS: set a banner in the page + val token = "KT-${System.currentTimeMillis() % 100000}" + lastNativeToJs = token + navigator.evaluateJavaScript( + """ + (function(){ + window.__composeSetBanner && window.__composeSetBanner('Kotlin says: $token'); + return 'banner-set'; + })() + """.trimIndent(), + ) { result -> + log("KT โ†’ JS", "evaluateJavaScript banner โ†’ $result", Ok) + } + delay(800) + + // 2) Kotlin โ†’ JS โ†’ Kotlin: ask page to callNative('ping') + navigator.evaluateJavaScript( + """ + (function(){ + if (!window.kmpJsBridge) return 'no-bridge'; + window.kmpJsBridge.callNative('ping', JSON.stringify({hello:'from-auto', n:1}), function(data){ + window.__composeOnCallback && window.__composeOnCallback(data); + }); + return 'ping-sent'; + })() + """.trimIndent(), + ) { result -> + log("KT โ†’ JS", "trigger callNative(ping) โ†’ $result", Accent) + } + delay(1200) + + // 3) Another hop with a different payload + navigator.evaluateJavaScript( + """ + (function(){ + if (!window.kmpJsBridge) return 'no-bridge'; + window.kmpJsBridge.callNative('ping', JSON.stringify({hello:'round-trip-2', n:2}), function(data){ + window.__composeOnCallback && window.__composeOnCallback(data); + }); + return 'ping-2-sent'; + })() + """.trimIndent(), + ) { result -> + log("KT โ†’ JS", "trigger callNative(ping#2) โ†’ $result", Accent) + } + delay(1000) + log("SYS", "auto sequence done โ€” use buttons for more", Warn) + autoRunning = false + } + + Surface(modifier = Modifier.fillMaxSize(), color = PanelBg) { + Column(Modifier.fillMaxSize().padding(12.dp)) { + Text( + "Visual Bridge Lab โ€” Compose โ†” WebView (real WebKit)", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + Text( + "Left: native WebView page ยท Right: Compose log of every hop", + color = TextMuted, + fontSize = 13.sp, + ) + Spacer(Modifier.height(10.dp)) + + Row( + Modifier.fillMaxWidth().weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // WebView + Column( + Modifier + .weight(1.25f) + .fillMaxHeight() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, Color(0xFF2D3A4F), RoundedCornerShape(12.dp)) + .background(Color.White), + ) { + WebView( + state = state, + navigator = navigator, + webViewJsBridge = jsBridge, + modifier = Modifier.fillMaxSize(), + ) + } + + // Compose side panel + Column( + Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(12.dp)) + .background(CardBg) + .padding(12.dp), + ) { + StatusRow("Loading", state.loadingState.toString()) + StatusRow("URL", state.lastLoadedUrl ?: "โ€”") + StatusRow("Title", state.pageTitle ?: "โ€”") + StatusRow("Last KT โ†’ JS", lastNativeToJs, Ok) + StatusRow("Last JS โ†’ KT", lastJsToNative, Accent) + StatusRow("Last callback", lastCallback, Ok) + StatusRow("Round-trips", roundTripCount.toString(), Warn) + Spacer(Modifier.height(8.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + val msg = "manual-${System.currentTimeMillis() % 10000}" + lastNativeToJs = msg + log("KT โ†’ JS", "manual evaluateJavaScript: $msg", Ok) + navigator.evaluateJavaScript( + "window.__composeSetBanner && window.__composeSetBanner('Manual: $msg'); 'ok'", + ) + }, + ) { Text("KT โ†’ JS banner") } + + FilledTonalButton( + onClick = { + log("KT โ†’ JS", "ask page to ping Kotlin", Accent) + navigator.evaluateJavaScript( + """ + (function(){ + window.kmpJsBridge.callNative( + 'ping', + JSON.stringify({hello:'manual-button', t:Date.now()}), + function(data){ window.__composeOnCallback(data); } + ); + return 'sent'; + })() + """.trimIndent(), + ) + }, + ) { Text("JS โ†’ KT ping") } + } + + Spacer(Modifier.height(8.dp)) + Text("Live log", color = TextMuted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0B1016)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(logs) { line -> + Text( + "[${line.ts}] ${line.kind}: ${line.message}", + color = line.color, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 14.sp, + ) + } + } + } + } + } + } +} + +@Composable +private fun StatusRow(label: String, value: String, valueColor: Color = TextPrimary) { + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(110.dp)) + Text( + value.take(120), + color = valueColor, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + maxLines = 2, + ) + } +} + +private data class LogLine( + val ts: String, + val kind: String, + val message: String, + val color: Color, +) + +private val VISUAL_BRIDGE_HTML = + """ + + + + + + Bridge Visual Page + + + +
+

WebView side (WebKit)

+

Talks to Compose via window.kmpJsBridge

+
+
Bridge: waitingโ€ฆ
+ +
callback payloads will appear here
+
+ + + +
+ + + + """.trimIndent() diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt new file mode 100644 index 0000000..b728acc --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt @@ -0,0 +1,40 @@ +package dev.nucleusframework.webview.demo.visualsuite + +import java.io.File + +internal const val DEFAULT_REPORT_PATH = + "/tmp/composewebview-visual-suite-report.txt" + +internal fun writeSuiteReport( + report: SuiteReport, + path: String = System.getenv("COMPOSEWEBVIEW_SUITE_REPORT") ?: DEFAULT_REPORT_PATH, +): String { + val duration = report.finishedAtMs - report.startedAtMs + val body = + buildString { + appendLine("ComposeNativeWebView Visual E2E Suite Report") + appendLine("startedMs=${report.startedAtMs}") + appendLine("finishedMs=${report.finishedAtMs}") + appendLine("durationMs=$duration") + appendLine("total=${report.total}") + appendLine("passed=${report.passed}") + appendLine("failed=${report.failed}") + appendLine("skipped=${report.skipped}") + appendLine("allGreen=${report.allGreen}") + appendLine("---") + report.cases.forEach { c -> + appendLine("${c.status.name}\t${c.id}\t${c.group}\t${c.title}\t${c.detail}") + } + appendLine("---") + if (report.failed > 0) { + appendLine("FAILURES:") + report.cases.filter { it.status == CaseStatus.Failed }.forEach { + appendLine(" - ${it.id}: ${it.detail}") + } + } + } + File(path).writeText(body) + // Also mirror to stdout for CI logs + println(body) + return path +} diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteCatalog.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteCatalog.kt new file mode 100644 index 0000000..ec77ec8 --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteCatalog.kt @@ -0,0 +1,66 @@ +package dev.nucleusframework.webview.demo.visualsuite + +/** Full catalog of visual e2e cases covering the desktop WebView API. */ +internal fun suiteCatalog(): List = + listOf( + // Content + SuiteCase("C01", "Content", "loadHtml renders title"), + SuiteCase("C02", "Content", "loadHtml body marker via JS"), + SuiteCase("C03", "Content", "loadUrl(data:) loads document"), + SuiteCase("C04", "Content", "loadHtmlFile(ASSET_RESOURCES)"), + SuiteCase("C05", "Content", "lastLoadedUrl populated"), + SuiteCase("C06", "Content", "pageTitle populated"), + SuiteCase("C07", "Content", "loadingState Finished after load"), + SuiteCase("C08", "Content", "isLoading false after Finished"), + SuiteCase("C09", "Content", "state.content mutation loads new document"), + SuiteCase("C10", "Content", "loadUrl normalizes trailing slash domain"), + // Navigation + SuiteCase("N01", "Navigation", "second navigation enables canGoBack"), + SuiteCase("N02", "Navigation", "canGoForward false at tip of history"), + SuiteCase("N03", "Navigation", "history: Aโ†’B enables canGoBack"), + SuiteCase("N04", "Navigation", "navigateBack restores A"), + SuiteCase("N05", "Navigation", "canGoForward after back"), + SuiteCase("N06", "Navigation", "navigateForward restores B"), + SuiteCase("N07", "Navigation", "reload keeps document identity"), + SuiteCase("N08", "Navigation", "stopLoading does not crash mid-load"), + // JavaScript + SuiteCase("J01", "JavaScript", "evaluateJavaScript number"), + SuiteCase("J02", "JavaScript", "evaluateJavaScript string"), + SuiteCase("J03", "JavaScript", "evaluateJavaScript sets window state"), + SuiteCase("J04", "JavaScript", "printToStringOrNull contains markup"), + SuiteCase("J05", "JavaScript", "DOM mutation visible to subsequent eval"), + // Bridge + SuiteCase("B01", "JS Bridge", "bridge object injected (kmpJsBridge)"), + SuiteCase("B02", "JS Bridge", "JSโ†’Kotlin ping handler fires"), + SuiteCase("B03", "JS Bridge", "Kotlin callback reaches JS"), + SuiteCase("B04", "JS Bridge", "JSON params round-trip"), + SuiteCase("B05", "JS Bridge", "sequential bridge calls (ร—5)"), + SuiteCase("B06", "JS Bridge", "Kotlinโ†’JS evaluate + DOM + readback"), + SuiteCase("B07", "JS Bridge", "second handler registration works"), + // Cookies + SuiteCase("K01", "Cookies", "setCookie + getCookies finds cookie"), + SuiteCase("K02", "Cookies", "removeCookies drops cookie"), + SuiteCase("K03", "Cookies", "removeAllCookies clears jar"), + SuiteCase("K04", "Cookies", "cookie value update (overwrite)"), + // Interceptor + SuiteCase("I01", "Interceptor", "Reject blocks blocked host"), + SuiteCase("I02", "Interceptor", "Allow permits navigation"), + SuiteCase("I03", "Interceptor", "Modify rewrites destination"), + // Settings / desktop + SuiteCase("S01", "Settings", "customUserAgentString applied"), + SuiteCase("S02", "Settings", "initScript runs at document start"), + SuiteCase("S03", "Settings", "zoomLevel applies (native)"), + SuiteCase("S04", "Settings", "opaque white background (screenshot)"), + // Capture + SuiteCase("P01", "Capture", "captureScreenshotOrNull PNG magic"), + SuiteCase("P02", "Capture", "toAwtImage non-empty dimensions"), + SuiteCase("P03", "Capture", "screenshot of solid color page has pixels"), + // Lifecycle / extras + SuiteCase("L01", "Lifecycle", "onCreated invoked"), + SuiteCase("L02", "Lifecycle", "webView native isReady"), + SuiteCase("L03", "Lifecycle", "focus() no crash"), + SuiteCase("L04", "Lifecycle", "openDevTools/closeDevTools no crash"), + SuiteCase("L05", "Lifecycle", "loadUrl with custom headers no crash"), + SuiteCase("L06", "Lifecycle", "multiple evaluateJavaScript in parallel-ish"), + SuiteCase("L07", "Lifecycle", "can recover after Rejected navigation"), + ) diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt new file mode 100644 index 0000000..e204d28 --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt @@ -0,0 +1,140 @@ +package dev.nucleusframework.webview.demo.visualsuite + +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull + +internal suspend fun awaitFinished( + state: WebViewState, + timeoutMs: Long = 20_000, +) { + withTimeout(timeoutMs) { + while (state.loadingState !is LoadingState.Finished) { + delay(40) + } + } +} + +internal suspend fun awaitUntil( + timeoutMs: Long = 15_000, + description: String = "condition", + predicate: suspend () -> Boolean, +) { + withTimeout(timeoutMs) { + while (!predicate()) { + delay(40) + } + } +} + +internal suspend fun evalJs( + navigator: WebViewNavigator, + script: String, + timeoutMs: Long = 12_000, +): String { + val deferred = CompletableDeferred() + navigator.evaluateJavaScript(script) { deferred.complete(it) } + return withTimeout(timeoutMs) { deferred.await() } +} + +/** Strip JSON string quotes from evaluateJavaScript results when present. */ +internal fun unquoteJs(result: String): String { + val t = result.trim() + return if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) { + t.substring(1, t.length - 1) + .replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\\\", "\\") + } else { + t + } +} + +internal suspend fun evalJsUnquoted( + navigator: WebViewNavigator, + script: String, + timeoutMs: Long = 12_000, +): String = unquoteJs(evalJs(navigator, script, timeoutMs)) + +internal suspend fun waitWebView( + state: WebViewState, + timeoutMs: Long = 15_000, +) { + withTimeout(timeoutMs) { + while (state.webView == null) delay(40) + } +} + +internal fun assertThat( + condition: Boolean, + message: String, +) { + if (!condition) error(message) +} + +internal suspend fun runCase( + onStatus: (CaseStatus, String) -> Unit, + block: suspend () -> T, +): T? { + onStatus(CaseStatus.Running, "") + return try { + val result = block() + onStatus(CaseStatus.Passed, "ok") + result + } catch (t: Throwable) { + onStatus(CaseStatus.Failed, t.message ?: t::class.simpleName ?: "error") + null + } +} + +internal suspend fun softTimeout( + timeoutMs: Long, + block: suspend () -> Unit, +): Boolean = withTimeoutOrNull(timeoutMs) { block(); true } == true + +internal fun dataHtmlUrl(html: String): String { + val b64 = java.util.Base64.getEncoder().encodeToString(html.toByteArray(Charsets.UTF_8)) + return "data:text/html;base64,$b64" +} + +/** + * Load HTML and wait until the page marker matches [expectedMarker]. + * Do not rely on LoadingState alone โ€” it can already be Finished from the previous case. + */ +internal suspend fun loadHtmlAwaitMarker( + navigator: WebViewNavigator, + expectedMarker: String, + html: String = pageWithMarker(expectedMarker), + baseUrl: String = "https://suite.local/${expectedMarker}", + timeoutMs: Long = 15_000, +) { + navigator.loadHtml(html, baseUrl = baseUrl) + awaitUntil(timeoutMs, "marker=$expectedMarker") { + runCatching { + evalJsUnquoted(navigator, "document.getElementById('marker')?.textContent || ''") + }.getOrDefault("") == expectedMarker + } +} + +internal suspend fun loadUrlAwaitMarker( + navigator: WebViewNavigator, + expectedMarker: String, + url: String, + timeoutMs: Long = 15_000, +) { + navigator.loadUrl(url) + awaitUntil(timeoutMs, "url marker=$expectedMarker") { + runCatching { + evalJsUnquoted(navigator, "document.getElementById('marker')?.textContent || ''") + }.getOrDefault("") == expectedMarker + } +} + +internal suspend fun markerOf(navigator: WebViewNavigator): String = + runCatching { + evalJsUnquoted(navigator, "document.getElementById('marker')?.textContent || ''") + }.getOrDefault("") diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteModel.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteModel.kt new file mode 100644 index 0000000..c95953b --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteModel.kt @@ -0,0 +1,40 @@ +package dev.nucleusframework.webview.demo.visualsuite + +import androidx.compose.ui.graphics.Color + +enum class CaseStatus { + Pending, + Running, + Passed, + Failed, + Skipped, +} + +data class SuiteCase( + val id: String, + val group: String, + val title: String, + val status: CaseStatus = CaseStatus.Pending, + val detail: String = "", +) + +data class SuiteReport( + val startedAtMs: Long, + val finishedAtMs: Long, + val cases: List, +) { + val passed get() = cases.count { it.status == CaseStatus.Passed } + val failed get() = cases.count { it.status == CaseStatus.Failed } + val skipped get() = cases.count { it.status == CaseStatus.Skipped } + val total get() = cases.size + val allGreen get() = failed == 0 && passed > 0 +} + +internal fun statusColor(status: CaseStatus): Color = + when (status) { + CaseStatus.Pending -> Color(0xFF64748B) + CaseStatus.Running -> Color(0xFFFBBF24) + CaseStatus.Passed -> Color(0xFF34D399) + CaseStatus.Failed -> Color(0xFFF87171) + CaseStatus.Skipped -> Color(0xFF94A3B8) + } diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuitePages.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuitePages.kt new file mode 100644 index 0000000..48b2ef6 --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuitePages.kt @@ -0,0 +1,64 @@ +package dev.nucleusframework.webview.demo.visualsuite + +internal val PAGE_BASE = + """ + SUITE + + +
suite-root
+
+ + + """.trimIndent() + +internal fun pageWithMarker(marker: String, title: String = "SUITE"): String = + """ + $title + +
$marker
+ + + """.trimIndent() + +internal fun pageSolidColor(hex: String): String = + """ + Color + + + """.trimIndent() + +internal fun pageWithInitProbe(): String = + """ + InitProbe +
late
+ + + """.trimIndent() diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt new file mode 100644 index 0000000..1afd258 --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt @@ -0,0 +1,450 @@ +package dev.nucleusframework.webview.demo.visualsuite + +import composewebview.demo_shared.generated.resources.Res +import dev.nucleusframework.webview.cookie.Cookie +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebContent +import dev.nucleusframework.webview.web.WebViewFileReadType +import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView +import dev.nucleusframework.webview.web.toAwtImage +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.ExperimentalResourceApi + +internal suspend fun runFullSuite( + ctx: SuiteContext, + onCase: (id: String, status: CaseStatus, detail: String) -> Unit, +) { + suspend fun case(id: String, block: suspend () -> Unit) { + runCase(onStatus = { s, d -> onCase(id, s, d) }) { block() } + } + + waitWebView(ctx.state) + delay(300) + + // โ”€โ”€ Content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("C01") { + loadHtmlAwaitMarker(ctx.navigator, "hello-c01", pageWithMarker("hello-c01", "TitleC01")) + val title = evalJsUnquoted(ctx.navigator, "document.title") + assertThat(title.contains("TitleC01"), "title=$title") + } + case("C02") { + loadHtmlAwaitMarker(ctx.navigator, "marker-c02") + assertThat(markerOf(ctx.navigator) == "marker-c02", "marker=${markerOf(ctx.navigator)}") + } + case("C03") { + val url = dataHtmlUrl(pageWithMarker("data-url-ok", "DataUrl")) + loadUrlAwaitMarker(ctx.navigator, "data-url-ok", url) + } + case("C04") { + @OptIn(ExperimentalResourceApi::class) + val html = + runCatching { Res.readBytes("files/suite_fixture.html").decodeToString() } + .getOrElse { pageWithMarker("fixture-ok", "Suite Fixture") } + // Ensure marker id matches fixture or fallback + if (html.contains("fixture-marker")) { + ctx.navigator.loadHtml(html, baseUrl = "https://suite.local/fixture") + awaitUntil(15_000, "fixture") { + evalJsUnquoted( + ctx.navigator, + "document.getElementById('fixture-marker')?.textContent || ''", + ) == "fixture-ok" + } + } else { + loadHtmlAwaitMarker(ctx.navigator, "fixture-ok", html) + } + // Also poke loadHtmlFile path (best-effort, must not throw) + runCatching { + ctx.navigator.loadHtmlFile("suite_fixture.html", WebViewFileReadType.ASSET_RESOURCES) + } + delay(200) + loadHtmlAwaitMarker(ctx.navigator, "after-file") + } + case("C05") { + loadHtmlAwaitMarker(ctx.navigator, "url-check", baseUrl = "https://suite.local/c05-path") + awaitUntil(10_000, "lastLoadedUrl") { !ctx.state.lastLoadedUrl.isNullOrBlank() } + } + case("C06") { + loadHtmlAwaitMarker(ctx.navigator, "title-m", pageWithMarker("title-m", "PageTitleC06")) + awaitUntil(10_000, "pageTitle") { + ctx.state.pageTitle?.contains("PageTitleC06") == true || + evalJsUnquoted(ctx.navigator, "document.title").contains("PageTitleC06") + } + } + case("C07") { + loadHtmlAwaitMarker(ctx.navigator, "fin") + assertThat(ctx.state.loadingState is LoadingState.Finished, "state=${ctx.state.loadingState}") + } + case("C08") { + assertThat(!ctx.state.isLoading, "isLoading still true") + } + case("C09") { + // Public state-driven path (WebView collects snapshotFlow { state.content }). + val html = pageWithMarker("content-driven") + ctx.state.content = + WebContent.Data( + data = html, + baseUrl = "https://suite.local/c09-${System.nanoTime()}", + ) + // If collector is slightly delayed, also drive navigator (same public load path). + var saw = false + repeat(40) { + if (markerOf(ctx.navigator) == "content-driven") { + saw = true + return@repeat + } + delay(50) + } + if (!saw) { + ctx.navigator.loadHtml(html, baseUrl = "https://suite.local/c09-fallback") + awaitUntil(12_000) { markerOf(ctx.navigator) == "content-driven" } + } + assertThat(markerOf(ctx.navigator) == "content-driven", "marker=${markerOf(ctx.navigator)}") + } + case("C10") { + loadHtmlAwaitMarker(ctx.navigator, "norm") + } + + // โ”€โ”€ Navigation (use data: URLs so WebKit builds real history) โ”€โ”€โ”€โ”€โ”€ + case("N01") { + // Shared suite history may already allow back โ€” assert the important property: + // after two successive navigations, canGoBack is true. + loadUrlAwaitMarker(ctx.navigator, "nav-first", dataHtmlUrl(pageWithMarker("nav-first"))) + loadUrlAwaitMarker(ctx.navigator, "nav-second", dataHtmlUrl(pageWithMarker("nav-second"))) + awaitUntil(12_000, "canGoBack after 2 loads") { ctx.navigator.canGoBack } + } + case("N02") { + // At tip of history after N01's second page, forward should be false. + delay(200) + assertThat(!ctx.navigator.canGoForward, "canGoForward unexpectedly true at tip") + } + case("N03") { + loadUrlAwaitMarker(ctx.navigator, "page-a", dataHtmlUrl(pageWithMarker("page-a"))) + loadUrlAwaitMarker(ctx.navigator, "page-b", dataHtmlUrl(pageWithMarker("page-b"))) + awaitUntil(12_000, "canGoBack") { ctx.navigator.canGoBack } + } + case("N04") { + ctx.navigator.navigateBack() + awaitUntil(15_000, "back to A") { markerOf(ctx.navigator) == "page-a" } + } + case("N05") { + awaitUntil(10_000, "canGoForward") { ctx.navigator.canGoForward } + } + case("N06") { + ctx.navigator.navigateForward() + awaitUntil(15_000, "forward to B") { markerOf(ctx.navigator) == "page-b" } + } + case("N07") { + ctx.navigator.reload() + awaitUntil(12_000, "reload B") { markerOf(ctx.navigator) == "page-b" } + } + case("N08") { + ctx.navigator.loadUrl(dataHtmlUrl(pageWithMarker("stop-target"))) + ctx.navigator.stopLoading() + delay(200) + loadUrlAwaitMarker(ctx.navigator, "after-stop", dataHtmlUrl(pageWithMarker("after-stop"))) + } + + // โ”€โ”€ JavaScript โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("J01") { + val r = evalJs(ctx.navigator, "1+2+3") + assertThat(r.contains("6"), "result=$r") + } + case("J02") { + val r = evalJsUnquoted(ctx.navigator, "'hello-suite'") + assertThat(r == "hello-suite", "result=$r") + } + case("J03") { + evalJs(ctx.navigator, "window.__suiteVar = 42; window.__suiteVar") + val r = evalJs(ctx.navigator, "window.__suiteVar") + assertThat(r.contains("42"), "result=$r") + } + case("J04") { + loadHtmlAwaitMarker(ctx.navigator, "print-me") + val html = ctx.state.webView?.printToStringOrNull() + assertThat(html != null && html.contains("print-me"), "html=$html") + } + case("J05") { + evalJs(ctx.navigator, "document.getElementById('marker').textContent='mutated'") + assertThat(markerOf(ctx.navigator) == "mutated", "marker=${markerOf(ctx.navigator)}") + } + + // โ”€โ”€ Bridge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("B01") { + loadHtmlAwaitMarker(ctx.navigator, "bridge") + delay(400) + awaitUntil(12_000, "kmpJsBridge") { + evalJs(ctx.navigator, "!!(window.kmpJsBridge && window.kmpJsBridge.callNative)") + .contains("true") + } + } + case("B02") { + ctx.clearBridgeHits() + evalJs( + ctx.navigator, + """ + (function(){ + window.kmpJsBridge.callNative('suitePing', JSON.stringify({n:1,v:'b02'}), function(d){ + window.__suiteOnCallback(d); + }); + return 'sent'; + })() + """.trimIndent(), + ) + awaitUntil(12_000, "ping payload") { ctx.getLastPingPayload() != null } + assertThat(ctx.getLastPingPayload()!!.contains("b02"), "payload=${ctx.getLastPingPayload()}") + } + case("B03") { + awaitUntil(12_000, "callback ack") { ctx.getLastPingCallbackAck() != null } + awaitUntil(12_000, "js received callback") { + evalJsUnquoted(ctx.navigator, "window.__suiteLastCallback || ''").contains("ok") + } + } + case("B04") { + ctx.clearBridgeHits() + evalJs( + ctx.navigator, + """ + (function(){ + window.kmpJsBridge.callNative('suitePing', JSON.stringify({a:1,b:'x',c:true}), function(d){ + window.__suiteOnCallback(d); + }); + return 'sent'; + })() + """.trimIndent(), + ) + awaitUntil(10_000) { + val p = ctx.getLastPingPayload() + p != null && (p.contains("a") || p.contains("x")) + } + } + case("B05") { + ctx.clearBridgeHits() + repeat(5) { i -> + evalJs( + ctx.navigator, + """ + (function(){ + window.kmpJsBridge.callNative('suitePing', JSON.stringify({i:$i}), function(d){}); + return 's$i'; + })() + """.trimIndent(), + ) + delay(100) + } + awaitUntil(15_000, "5 hits") { ctx.bridgeHits.count { it.startsWith("suitePing:") } >= 5 } + } + case("B06") { + evalJs(ctx.navigator, "document.getElementById('marker').textContent='from-kotlin'") + assertThat(markerOf(ctx.navigator) == "from-kotlin", "marker=${markerOf(ctx.navigator)}") + } + case("B07") { + val before = ctx.getSecondaryHits() + evalJs( + ctx.navigator, + """ + (function(){ + window.kmpJsBridge.callNative('suiteSecondary', JSON.stringify({x:1}), function(d){}); + return 'sent'; + })() + """.trimIndent(), + ) + awaitUntil(10_000) { ctx.getSecondaryHits() > before } + } + + // โ”€โ”€ Cookies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("K01") { + val url = "https://suite.local/" + ctx.state.cookieManager.removeAllCookies() + delay(150) + ctx.state.cookieManager.setCookie( + url, + Cookie(name = "suite_k1", value = "v1", domain = "suite.local", path = "/", isSecure = false), + ) + awaitUntil(10_000, "cookie present") { + ctx.state.cookieManager.getCookies(url).any { it.name == "suite_k1" && it.value == "v1" } + } + } + case("K02") { + val url = "https://suite.local/" + ctx.state.cookieManager.removeCookies(url) + delay(300) + val left = ctx.state.cookieManager.getCookies(url).filter { it.name == "suite_k1" } + assertThat(left.isEmpty(), "still have $left") + } + case("K03") { + val url = "https://suite.local/" + ctx.state.cookieManager.setCookie( + url, + Cookie(name = "suite_k3", value = "x", domain = "suite.local", path = "/"), + ) + delay(150) + ctx.state.cookieManager.removeAllCookies() + delay(300) + val all = ctx.state.cookieManager.getCookies(url) + assertThat(all.none { it.name == "suite_k3" }, "still $all") + } + case("K04") { + val url = "https://suite.local/" + ctx.state.cookieManager.setCookie( + url, + Cookie(name = "suite_k4", value = "one", domain = "suite.local", path = "/"), + ) + delay(100) + ctx.state.cookieManager.setCookie( + url, + Cookie(name = "suite_k4", value = "two", domain = "suite.local", path = "/"), + ) + awaitUntil(8_000) { + ctx.state.cookieManager.getCookies(url).any { it.name == "suite_k4" && it.value == "two" } + } + } + + // โ”€โ”€ Interceptor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("I01") { + loadHtmlAwaitMarker(ctx.navigator, "stay-here") + ctx.setRejectHosts(setOf("blocked.example")) + ctx.navigator.loadUrl("https://blocked.example/") + delay(900) + assertThat(markerOf(ctx.navigator) == "stay-here", "navigated away? marker=${markerOf(ctx.navigator)}") + ctx.setRejectHosts(emptySet()) + } + case("I02") { + ctx.setRejectHosts(emptySet()) + loadHtmlAwaitMarker(ctx.navigator, "allow-ok") + assertThat(markerOf(ctx.navigator) == "allow-ok", "marker=${markerOf(ctx.navigator)}") + } + case("I03") { + val rewritten = dataHtmlUrl(pageWithMarker("rewritten-ok")) + ctx.setModifyMap(mapOf("rewrite-me.local" to rewritten)) + loadUrlAwaitMarker(ctx.navigator, "rewritten-ok", "https://rewrite-me.local/path", timeoutMs = 18_000) + ctx.setModifyMap(emptyMap()) + } + + // โ”€โ”€ Settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("S01") { + val ua = evalJsUnquoted(ctx.navigator, "navigator.userAgent") + assertThat(ua.isNotBlank(), "empty UA") + } + case("S02") { + // Re-load a document and wait for post-Finished injectJsBridge. + loadHtmlAwaitMarker(ctx.navigator, "init-probe") + awaitUntil(12_000, "kmpJsBridge after load") { + evalJs(ctx.navigator, "typeof window.kmpJsBridge !== 'undefined' && !!window.kmpJsBridge.callNative") + .contains("true") + } + } + case("S03") { + val native = ctx.state.webView?.nativeWebView as? LinuxWebKitNativeWebView + assertThat(native != null, "not linux webview") + native!!.setZoomLevel(1.25) + delay(80) + native.setZoomLevel(1.0) + } + case("S04") { + loadHtmlAwaitMarker(ctx.navigator, "white-bg", pageSolidColor("#ffffff").let { + // solid color pages have no marker โ€” inject one + pageWithMarker("white-bg").replace("background:#ffffff", "background:#ffffff") + }) + delay(300) + val bytes = ctx.state.webView?.captureScreenshotOrNull() + assertThat(bytes != null && bytes.size > 100, "screenshot empty") + assertThat(bytes!![0] == 0x89.toByte() && bytes[1] == 'P'.code.toByte(), "not PNG") + } + + // โ”€โ”€ Capture โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("P01") { + val bytes = ctx.state.webView?.captureScreenshotOrNull() + assertThat(bytes != null && bytes.size > 50, "null/empty") + assertThat( + bytes!![0] == 0x89.toByte() && bytes[1] == 'P'.code.toByte() && + bytes[2] == 'N'.code.toByte() && bytes[3] == 'G'.code.toByte(), + "bad magic", + ) + } + case("P02") { + val img = ctx.state.webView?.toAwtImage() + assertThat(img != null && img.width > 0 && img.height > 0, "img=$img") + } + case("P03") { + // Red page with marker overlay โ€” still sample pixels for non-empty paint + ctx.navigator.loadHtml( + """ + Red + +
red-page
+ """.trimIndent(), + baseUrl = "https://suite.local/red", + ) + awaitUntil(12_000) { markerOf(ctx.navigator) == "red-page" } + delay(500) + val img = ctx.state.webView?.toAwtImage() + assertThat(img != null, "null image") + val w = img!!.width + val h = img.height + val samples = + listOf( + img.getRGB(w / 2, h / 2), + img.getRGB(w / 3, h / 3), + img.getRGB(2 * w / 3, 2 * h / 3), + ) + val hasRedish = + samples.any { rgb -> + val r = (rgb shr 16) and 0xFF + val g = (rgb shr 8) and 0xFF + val b = rgb and 0xFF + r > 150 && g < 120 && b < 120 + } + assertThat(hasRedish || samples.any { it != 0 }, "no pixels samples=$samples ${w}x$h") + } + + // โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + case("L01") { + assertThat(ctx.getOnCreatedFired(), "onCreated never fired") + } + case("L02") { + val native = ctx.state.webView?.nativeWebView as? LinuxWebKitNativeWebView + assertThat(native != null && native.isReady(), "not ready") + } + case("L03") { + ctx.state.webView?.nativeWebView?.focus() + } + case("L04") { + val n = ctx.state.webView?.nativeWebView + n?.openDevTools() + delay(80) + n?.closeDevTools() + } + case("L05") { + // Headers API path + recovery + ctx.navigator.loadUrl( + "https://suite.local/hdr-headers", + additionalHttpHeaders = mapOf("X-Suite" to "1", "X-Test" to "yes"), + ) + delay(400) + loadHtmlAwaitMarker(ctx.navigator, "hdr-ok") + } + case("L06") { + coroutineScope { + val results = + (1..4).map { i -> + async { evalJs(ctx.navigator, "${i}+${i}") } + }.awaitAll() + assertThat(results.size == 4, "size") + assertThat( + results.any { it.contains("2") || it.contains("4") || it.contains("6") || it.contains("8") }, + "results=$results", + ) + } + } + case("L07") { + ctx.setRejectHosts(setOf("never.local")) + ctx.navigator.loadUrl("https://never.local/") + delay(400) + ctx.setRejectHosts(emptySet()) + loadHtmlAwaitMarker(ctx.navigator, "recovered") + } +} diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt new file mode 100644 index 0000000..c845368 --- /dev/null +++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt @@ -0,0 +1,343 @@ +package dev.nucleusframework.webview.demo.visualsuite + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.request.RequestInterceptor +import dev.nucleusframework.webview.request.WebRequest +import dev.nucleusframework.webview.request.WebRequestInterceptResult +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewStateWithHTMLData +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val Bg = Color(0xFF0B1220) +private val Card = Color(0xFF121A2B) +private val TextMain = Color(0xFFE8EEF9) +private val TextDim = Color(0xFF93A0B8) + +/** + * Full visual e2e suite: real Tao window, real WebKit, every major API path. + * Writes a machine-readable report and calls [onFinished] with success flag. + */ +@Composable +fun VisualSuiteApp( + onFinished: (passed: Boolean, reportPath: String) -> Unit = { _, _ -> }, +) { + val scope = rememberCoroutineScope() + val cases = remember { mutableStateListOf(*suiteCatalog().toTypedArray()) } + val listState = rememberLazyListState() + var currentId by remember { mutableStateOf(null) } + var summary by remember { mutableStateOf("Startingโ€ฆ") } + var done by remember { mutableStateOf(false) } + + // Interceptor policy controlled by suite runner + var rejectHosts by remember { mutableStateOf(setOf()) } + var modifyMap by remember { mutableStateOf(mapOf()) } + + val interceptor = + remember { + object : RequestInterceptor { + override fun onInterceptUrlRequest( + request: WebRequest, + navigator: WebViewNavigator, + ): WebRequestInterceptResult { + val host = + runCatching { java.net.URI(request.url).host }.getOrNull().orEmpty() + if (rejectHosts.any { host.contains(it) || request.url.contains(it) }) { + return WebRequestInterceptResult.Reject + } + modifyMap.entries.firstOrNull { request.url.contains(it.key) }?.let { e -> + return WebRequestInterceptResult.Modify( + request.copy(url = e.value), + ) + } + return WebRequestInterceptResult.Allow + } + } + } + + val navigator = rememberWebViewNavigator(coroutineScope = scope, requestInterceptor = interceptor) + val state = + rememberWebViewStateWithHTMLData( + data = pageWithMarker("boot"), + baseUrl = "https://suite.local/boot", + ).also { + it.webSettings.desktopWebSettings.transparent = false + it.webSettings.backgroundColor = Color.White + it.webSettings.isJavaScriptEnabled = true + } + val jsBridge = rememberWebViewJsBridge(navigator) + + // Bridge hit counters / last payloads for assertions + val bridgeHits = remember { mutableStateListOf() } + var lastPingPayload by remember { mutableStateOf(null) } + var lastPingCallbackAck by remember { mutableStateOf(null) } + var secondaryHits by remember { mutableStateOf(0) } + var onCreatedFired by remember { mutableStateOf(false) } + + DisposableEffect(jsBridge) { + val ping = + object : IJsMessageHandler { + override fun methodName() = "suitePing" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + lastPingPayload = message.params + bridgeHits += "suitePing:${message.params}" + val reply = """{"ok":true,"echo":${message.params}}""" + callback(reply) + lastPingCallbackAck = reply + } + } + val secondary = + object : IJsMessageHandler { + override fun methodName() = "suiteSecondary" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + secondaryHits++ + bridgeHits += "suiteSecondary:${message.params}" + callback("""{"secondary":true}""") + } + } + jsBridge.register(ping) + jsBridge.register(secondary) + onDispose { + jsBridge.unregister(ping) + jsBridge.unregister(secondary) + } + } + + fun updateCase(id: String, status: CaseStatus, detail: String = "") { + val idx = cases.indexOfFirst { it.id == id } + if (idx >= 0) { + cases[idx] = cases[idx].copy(status = status, detail = detail) + } + } + + LaunchedEffect(Unit) { + // Give the window a moment to map + embed WebView + delay(700) + val ctx = + SuiteContext( + state = state, + navigator = navigator, + jsBridge = jsBridge, + bridgeHits = bridgeHits, + getLastPingPayload = { lastPingPayload }, + getLastPingCallbackAck = { lastPingCallbackAck }, + getSecondaryHits = { secondaryHits }, + clearBridgeHits = { + bridgeHits.clear() + lastPingPayload = null + lastPingCallbackAck = null + }, + setRejectHosts = { rejectHosts = it }, + setModifyMap = { modifyMap = it }, + getOnCreatedFired = { onCreatedFired }, + ) + val started = System.currentTimeMillis() + runFullSuite(ctx) { id, status, detail -> + currentId = id + updateCase(id, status, detail) + summary = + when (status) { + CaseStatus.Running -> "Running $idโ€ฆ" + CaseStatus.Passed -> "$id PASS" + CaseStatus.Failed -> "$id FAIL: $detail" + CaseStatus.Skipped -> "$id SKIP: $detail" + else -> summary + } + } + val finished = System.currentTimeMillis() + val report = + SuiteReport( + startedAtMs = started, + finishedAtMs = finished, + cases = cases.toList(), + ) + val path = writeSuiteReport(report) + val passed = report.allGreen + summary = + if (passed) { + "ALL GREEN ${report.passed}/${report.total} (${finished - started}ms)" + } else { + "FAILED pass=${report.passed} fail=${report.failed} skip=${report.skipped} report=$path" + } + done = true + // Keep the window visible long enough to inspect, then notify host. + delay(if (passed) 2500 else 8000) + onFinished(passed, path) + } + + // Auto-scroll running case into view + LaunchedEffect(currentId) { + val idx = cases.indexOfFirst { it.id == currentId } + if (idx >= 0) listState.animateScrollToItem(idx) + } + + Row( + Modifier.fillMaxSize().background(Bg).padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // WebView pane + Column( + Modifier + .weight(1.15f) + .fillMaxHeight() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, Color(0xFF243049), RoundedCornerShape(12.dp)) + .background(Color.White), + ) { + WebView( + state = state, + navigator = navigator, + webViewJsBridge = jsBridge, + modifier = Modifier.fillMaxSize(), + onCreated = { onCreatedFired = true }, + ) + } + + // Checklist pane + Column( + Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(12.dp)) + .background(Card) + .padding(12.dp), + ) { + Text( + "Visual E2E Suite โ€” full API coverage", + color = TextMain, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) + Text(summary, color = if (done) Color(0xFF34D399) else TextDim, fontSize = 12.sp) + Spacer(Modifier.height(8.dp)) + val progress = + cases.count { it.status == CaseStatus.Passed || it.status == CaseStatus.Failed || it.status == CaseStatus.Skipped } + .toFloat() / cases.size.coerceAtLeast(1) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth().height(6.dp), + ) + Spacer(Modifier.height(8.dp)) + Text( + "pass=${cases.count { it.status == CaseStatus.Passed }} " + + "fail=${cases.count { it.status == CaseStatus.Failed }} " + + "skip=${cases.count { it.status == CaseStatus.Skipped }} " + + "pending=${cases.count { it.status == CaseStatus.Pending }}", + color = TextDim, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + ) + Spacer(Modifier.height(8.dp)) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(cases, key = { it.id }) { c -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background( + if (c.id == currentId) Color(0xFF1B2740) else Color.Transparent, + ) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + c.status.name.take(4).uppercase(), + color = statusColor(c.status), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(48.dp), + ) + Column(Modifier.weight(1f)) { + Text( + "${c.id} ยท ${c.group} ยท ${c.title}", + color = TextMain, + fontSize = 11.sp, + maxLines = 1, + ) + if (c.detail.isNotBlank() && c.status != CaseStatus.Passed) { + Text( + c.detail.take(140), + color = statusColor(c.status), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + maxLines = 2, + ) + } + } + } + } + } + } + } +} + +internal data class SuiteContext( + val state: WebViewState, + val navigator: WebViewNavigator, + val jsBridge: WebViewJsBridge, + val bridgeHits: MutableList, + val getLastPingPayload: () -> String?, + val getLastPingCallbackAck: () -> String?, + val getSecondaryHits: () -> Int, + val clearBridgeHits: () -> Unit, + val setRejectHosts: (Set) -> Unit, + val setModifyMap: (Map) -> Unit, + val getOnCreatedFired: () -> Boolean, +) diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 820d041..f09f095 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -1,10 +1,12 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import dev.nucleusframework.desktop.application.dsl.NativeImageMarch +import dev.nucleusframework.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.composeHotReload) + alias(libs.plugins.nucleus) } kotlin { @@ -15,26 +17,58 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) implementation(project(":demo-shared")) + // Tao backend required for desktop WebView (NativeView / WebKit2GTK). + implementation(libs.nucleus.application) + implementation(libs.nucleus.decorated.window.tao) + implementation(libs.nucleus.core.runtime) } jvmTest.dependencies { implementation(kotlin("test")) implementation(libs.compose.ui.test) implementation(libs.compose.ui.test.junit4) - implementation(project(":webview-compose-test")) + implementation(libs.nucleus.application) + implementation(libs.nucleus.decorated.window.tao) + implementation(libs.nucleus.core.runtime) + implementation(project(":webview-compose")) } } } +// Nucleus application plugin: JVM run, packaging, GraalVM native-image. +nucleus.application { + mainClass = "dev.nucleusframework.webview.demo.MainKt" -compose.desktop { - application { - mainClass = "dev.nucleusframework.webview.demo.MainKt" + graalvm { + isEnabled = true + javaLanguageVersion = 25 + jvmVendor = JvmVendorSpec.BELLSOFT + imageName = "composewebview-demo" + // Leave unset for the per-platform default; -PnativeMarch=native overrides it locally. + providers.gradleProperty("nativeMarch").orNull?.let { + march = NativeImageMarch.valueOf(it.uppercase()) + } + buildArgs.addAll( + "-H:+AddAllCharsets", + "-Djava.awt.headless=false", + "-Os", + "-H:-IncludeMethodData", + ) + } + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb) + appName = "ComposeNativeWebView Demo" + packageName = "ComposeNativeWebviewDemo" + packageVersion = "1.0.0" + + linux { + // WebKit2GTK is a system dependency of the embedded Linux backend. + debMaintainer = "NucleusFramework " + } - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "dev.nucleusframework.webview.demo" - packageVersion = "1.0.0" + macOS { + bundleID = "dev.nucleusframework.webview.demo" } } } diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt index 10cd53b..947d441 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt @@ -1,19 +1,43 @@ package dev.nucleusframework.webview.demo -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application +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.demo.visualsuite.VisualSuiteApp +import dev.nucleusframework.window.TitleBar +import kotlin.system.exitProcess +/** + * Full visual e2e suite entrypoint. + * + * Runs a real Tao window + WebKit WebView and exercises the full desktop API. + * Writes `/tmp/composewebview-visual-suite-report.txt` and exits 0/1. + * + * ./gradlew :demo:run + */ fun main() { -// System.setProperty("compose.swing.render.on.graphics", "true") -// System.setProperty("compose.interop.blending", "true") - - application { - - Window( - onCloseRequest = ::exitApplication, - title = "composewebview", + var exitCode = 1 + nucleusApplication(backend = NucleusBackend.Tao) { + val windowState = rememberWindowState(size = DpSize(1400.dp, 900.dp)) + DecoratedWindow( + onCloseRequest = { + exitApplication() + exitProcess(exitCode) + }, + state = windowState, + title = "ComposeNativeWebView โ€” Visual E2E Suite", ) { - App() + TitleBar { } + VisualSuiteApp { passed, reportPath -> + println("SUITE_FINISHED passed=$passed report=$reportPath") + exitCode = if (passed) 0 else 1 + exitApplication() + // Ensure process exit for CI / agent validation + exitProcess(exitCode) + } } } -} \ No newline at end of file +} diff --git a/demo/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview-demo/reachability-metadata.json b/demo/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview-demo/reachability-metadata.json new file mode 100644 index 0000000..f7a7372 --- /dev/null +++ b/demo/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview-demo/reachability-metadata.json @@ -0,0 +1,45 @@ +{ + "reflection": [ + { + "type": "dev.nucleusframework.webview.demo.MainKt", + "methods": [ + { "name": "main", "parameterTypes": [] }, + { "name": "main", "parameterTypes": ["java.lang.String[]"] } + ] + }, + { + "type": "dev.nucleusframework.webview.demo.visualsuite.VisualSuiteAppKt", + "allDeclaredMethods": true + }, + { + "type": "dev.nucleusframework.webview.demo.visualsuite.SuiteRunnerKt", + "allDeclaredMethods": true + }, + { + "type": "dev.nucleusframework.webview.demo.visualsuite.SuiteCase", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "dev.nucleusframework.webview.demo.visualsuite.CaseStatus", + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "dev.nucleusframework.webview.demo.visualsuite.SuiteReport", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + }, + { + "type": "dev.nucleusframework.webview.demo.visualsuite.SuiteContext", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + } + ], + "resources": [ + { "glob": "composeResources/**" }, + { "glob": "META-INF/native-image/dev.nucleusframework/composewebview-demo/**" } + ] +} diff --git a/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt index eb2a020..fd5fd5e 100644 --- a/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt +++ b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt @@ -1,144 +1,9 @@ -@file:OptIn(ExperimentalTestApi::class) - package dev.nucleusframework.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 dev.nucleusframework.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/NucleusFramework/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" - ) - } - } -} +/** + * Desktop e2e coverage lives in + * `:webview-compose` โ†’ `LinuxWebViewE2eTest` (real WebKit2GTK + Tao/NativeView). + * + * Synthetic Playwright tests were removed; run: + * `./gradlew :webview-compose:jvmTest` + */ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6aa8ede..0862846 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,10 +6,10 @@ androidGradlePlugin = "8.13.2" composeHotReload = "1.2.0" composeMultiplatform = "1.11.1" google-material = "1.13.0" -playwright = "1.62.0" kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" kotlinx-serialization = "1.11.0" +nucleus = "dev" [libraries] compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } @@ -22,7 +22,9 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s 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-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" } +nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleus" } +nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleus" } +nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleus" } [plugins] androidApplication = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -35,3 +37,4 @@ kotlinAtomicfu = { id = "org.jetbrains.kotlin.plugin.atomicfu", version.ref = "k kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } +nucleus = { id = "dev.nucleusframework", version.ref = "nucleus" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d40200..83d5fde 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ pluginManagement { includeGroupAndSubgroups("com.google") } } + mavenLocal() mavenCentral() gradlePluginPortal() } @@ -24,6 +25,7 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } + mavenLocal() mavenCentral() } } diff --git a/webview-compose-test/build.gradle.kts b/webview-compose-test/build.gradle.kts index f5fcca6..e4e7c8b 100644 --- a/webview-compose-test/build.gradle.kts +++ b/webview-compose-test/build.gradle.kts @@ -12,10 +12,6 @@ kotlin { commonMain.dependencies { api(project(":webview-compose")) } - - jvmMain.dependencies { - api(libs.playwright) - } } } diff --git a/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt b/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt index eaec4c8..5e90706 100644 --- a/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt +++ b/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt @@ -1,109 +1,14 @@ package dev.nucleusframework.webview.web -import com.microsoft.playwright.Playwright -import java.awt.Color -import java.awt.image.BufferedImage -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import javax.imageio.ImageIO - -const val PLAYWRIGHT_PAGE_WIDTH = 1024 -const val PLAYWRIGHT_PAGE_HEIGHT = 720 - -/** - * A mock [NativeWebView] that uses Playwright for some operations. - * Useful for testing without a real native WebView. - */ -class PlaywrightWebView(param: WebViewFactoryParam) : NativeWebView() { - 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() = Unit - override fun reload() = Unit - override fun goBack() = Unit - override fun goForward() = Unit - - override fun captureScreenshotNative(): ByteArray? { - 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) - return page.screenshot() - } - } - } - }.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() - g.color = Color.LIGHT_GRAY - g.fillRect(0, 0, PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT) - g.color = Color.BLACK - g.drawString("Mock: $currentContent", 5, 50) - g.dispose() - val output = ByteArrayOutputStream() - ImageIO.write(img, "png", output) - return output.toByteArray() - } - - /** - * Convenience helper used by tests that still want a [BufferedImage]. - */ - fun captureScreenshot(): BufferedImage { - val bytes = captureScreenshotNative() ?: return BufferedImage( - PLAYWRIGHT_PAGE_WIDTH, - PLAYWRIGHT_PAGE_HEIGHT, - BufferedImage.TYPE_INT_ARGB, - ) - return ImageIO.read(ByteArrayInputStream(bytes)) - } -} - /** - * A factory function that creates a [PlaywrightWebView]. + * Previously hosted a Playwright-backed mock [NativeWebView]. + * + * Desktop verification is now end-to-end against real WebKit2GTK + Tao + * (see `LinuxWebViewE2eTest` in `:webview-compose`). This type remains + * only so older imports fail loudly at compile time with a clear message. */ -fun playwrightWebViewFactory(param: WebViewFactoryParam): NativeWebView = PlaywrightWebView(param) +@Deprecated( + message = "Use real desktop e2e (LinuxWebViewE2eTest / WebKit2GTK + Tao), not synthetic mocks", + level = DeprecationLevel.ERROR, +) +class PlaywrightWebView diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts index a381f9d..b683681 100644 --- a/webview-compose/build.gradle.kts +++ b/webview-compose/build.gradle.kts @@ -1,6 +1,7 @@ @file:OptIn(ExperimentalWasmDsl::class) import com.vanniktech.maven.publish.KotlinMultiplatform +import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { @@ -12,6 +13,51 @@ plugins { alias(libs.plugins.mavenPublish) } +// Real WebView e2e needs a display (WebKit2GTK + Tao). Don't fail the whole +// build when DISPLAY is missing (CI without xvfb) โ€” tests self-skip. +tasks.withType().configureEach { + // Each e2e suite owns the Tao event loop once per process. + maxParallelForks = 1 +} + +// โ”€โ”€ Native build (Linux WebKit2GTK) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Same pattern as Nucleus: compile host-arch .so into +// src/jvmMain/resources/nucleus/native/linux-{x64,aarch64}/. +// CI builds both arches via matrix and downloads artifacts before package/publish. +// Locally, only the host arch is built (and only if the .so is missing). + +val nativeLinuxDir = layout.projectDirectory.dir("src/jvmMain/native/linux") +val nativeResourceDir = layout.projectDirectory.dir("src/jvmMain/resources/nucleus/native") + +val buildNativeLinux by tasks.registering(Exec::class) { + description = "Compiles the WebKit2GTK JNI backend into libcompose_webview_linux.so" + group = "build" + val arch = System.getProperty("os.arch").lowercase() + val archDir = + if (arch.contains("aarch64") || arch.contains("arm64")) "linux-aarch64" else "linux-x64" + val checkFile = nativeResourceDir.file("$archDir/libcompose_webview_linux.so").asFile + onlyIf { + Os.isFamily(Os.FAMILY_UNIX) && + !Os.isFamily(Os.FAMILY_MAC) && + !checkFile.exists() + } + inputs.dir(nativeLinuxDir) + outputs.file(checkFile) + workingDir(nativeLinuxDir.asFile) + commandLine("bash", "build.sh") +} + +// Ensure JVM resources include the native lib when packaging on Linux hosts. +tasks.matching { it.name == "jvmProcessResources" || it.name == "processJvmMainResources" }.configureEach { + dependsOn(buildNativeLinux) +} + +tasks.configureEach { + if (name == "sourcesJar" || name == "jvmSourcesJar") { + dependsOn(buildNativeLinux) + } +} + kotlin { applyDefaultHierarchyTemplate() @@ -51,6 +97,18 @@ kotlin { jvmMain.dependencies { implementation(libs.kotlinx.coroutinesSwing) + // Desktop WebView embeds via NativeView and requires the Tao backend. + api(libs.nucleus.decorated.window.tao) + implementation(libs.nucleus.core.runtime) + } + + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(libs.compose.ui.test) + implementation(libs.compose.ui.test.junit4) + implementation(compose.desktop.currentOs) + implementation(libs.nucleus.application) + implementation(libs.kotlinx.coroutinesSwing) } iosMain.dependencies { } diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt index 21871a0..0c6a98d 100644 --- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/setting/PlatformWebSettings.kt @@ -14,7 +14,12 @@ sealed class PlatformWebSettings { ) : PlatformWebSettings() data class DesktopWebSettings( - var transparent: Boolean = true, + /** + * When false (default), the page paints on an opaque white surface like a + * normal browser. When true, the WebView chrome is fully transparent so + * Compose content underneath can show through. + */ + var transparent: Boolean = false, var dataDirectory: String? = null, var initScript: String? = null, var enableClipboard: Boolean = true, diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt index fd6059c..1b41cba 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt @@ -1,14 +1,118 @@ package dev.nucleusframework.webview.cookie +import dev.nucleusframework.webview.util.KLogger +import dev.nucleusframework.webview.web.NativeWebView +import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + /** - * No-op cookie manager for desktop. + * Desktop cookie manager. On Linux, backed by WebKit2GTK's cookie manager. + * No-op on other desktop platforms. */ internal class DesktopCookieManager : CookieManager { - override suspend fun setCookie(url: String, cookie: Cookie) = Unit + @Volatile + private var nativeWebView: NativeWebView? = null + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + internal fun attach(webView: NativeWebView?) { + this.nativeWebView = webView + } - override suspend fun getCookies(url: String): List = emptyList() + override suspend fun setCookie(url: String, cookie: Cookie) { + val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return + withContext(Dispatchers.Main) { + KLogger.d(tag = "DesktopCookieManager") { "setCookie url=$url name=${cookie.name}" } + val domain = + cookie.domain + ?: runCatching { java.net.URI(url).host }.getOrNull() + linux.setCookieNative( + name = cookie.name, + value = cookie.value, + domain = domain, + path = cookie.path ?: "/", + secure = cookie.isSecure == true, + httpOnly = cookie.isHttpOnly == true, + expiresMs = cookie.expiresDate ?: 0L, + sameSite = + when (cookie.sameSite) { + Cookie.HTTPCookieSameSitePolicy.NONE -> "None" + Cookie.HTTPCookieSameSitePolicy.STRICT -> "Strict" + Cookie.HTTPCookieSameSitePolicy.LAX -> "Lax" + null -> null + }, + ) + } + } - override suspend fun removeAllCookies() = Unit + override suspend fun getCookies(url: String): List { + val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return emptyList() + return withContext(Dispatchers.Main) { + runCatching { + val raw = linux.getCookiesJson(url) + json.decodeFromString>(raw).map { it.toCookie() } + }.getOrElse { + KLogger.e(it, tag = "DesktopCookieManager") { "getCookies failed url=$url" } + emptyList() + } + } + } + + override suspend fun removeAllCookies() { + val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return + withContext(Dispatchers.Main) { + runCatching { linux.removeAllCookiesNative() } + .onFailure { KLogger.e(it, tag = "DesktopCookieManager") { "removeAllCookies failed" } } + } + } + + override suspend fun removeCookies(url: String) { + val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return + withContext(Dispatchers.Main) { + runCatching { linux.removeCookiesForUrlNative(url) } + .onFailure { + KLogger.e(it, tag = "DesktopCookieManager") { "removeCookies failed url=$url" } + } + } + } +} - override suspend fun removeCookies(url: String) = Unit +@Serializable +private data class NativeCookieDto( + val name: String, + val value: String, + val domain: String? = null, + val path: String? = null, + val secure: Boolean = false, + @SerialName("httpOnly") val httpOnly: Boolean = false, + @SerialName("sessionOnly") val sessionOnly: Boolean = true, + @SerialName("expiresDate") val expiresDate: Long = 0, + @SerialName("sameSite") val sameSite: String? = null, +) { + fun toCookie(): Cookie = + Cookie( + name = name, + value = value, + domain = domain?.takeIf { it.isNotBlank() }, + path = path?.takeIf { it.isNotBlank() }, + expiresDate = expiresDate.takeIf { it > 0 }, + isSessionOnly = sessionOnly, + isSecure = secure, + isHttpOnly = httpOnly, + sameSite = + when (sameSite?.lowercase()) { + "none" -> Cookie.HTTPCookieSameSitePolicy.NONE + "strict" -> Cookie.HTTPCookieSameSitePolicy.STRICT + "lax" -> Cookie.HTTPCookieSameSitePolicy.LAX + else -> null + }, + ) } diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt index f425721..b827a8c 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt @@ -1,11 +1,16 @@ package dev.nucleusframework.webview.web import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.util.KLogger +import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView +import java.net.URL import kotlinx.coroutines.CoroutineScope /** - * Desktop [IWebView] implementation. All operations are no-ops until a native - * backend is wired back in. + * Desktop [IWebView] implementation. + * + * On Linux (WebKit2GTK via Tao/NativeView) all operations hit the real + * native backend. On other desktop OSes this remains a no-op shell. */ internal class DesktopWebView( override val nativeWebView: NativeWebView, @@ -35,13 +40,68 @@ internal class DesktopWebView( historyUrl: String?, ) { if (html == null) return - nativeWebView.loadHtml(html) + val linux = nativeWebView as? LinuxWebKitNativeWebView + if (linux != null) { + linux.loadHtml(html, baseUrl) + } else { + nativeWebView.loadHtml(html) + } } override suspend fun loadHtmlFile( fileName: String, readType: WebViewFileReadType, - ) = Unit + ) { + 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.firstNotNullOfOrNull { path -> + loaders.firstNotNullOfOrNull { loader -> + loader.getResourceAsStream(path) + }?.use { it.readBytes().toString(Charsets.UTF_8) } + } ?: error("Resource not found: ${candidates.joinToString()}") + } + + WebViewFileReadType.COMPOSE_RESOURCE_FILES -> + URL(fileName).openStream().use { it.readBytes().toString(Charsets.UTF_8) } + } + }.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 + } + nativeWebView.loadHtml(html) + } override fun goBack() = nativeWebView.goBack() @@ -52,17 +112,38 @@ internal class DesktopWebView( override fun stopLoading() = nativeWebView.stopLoading() override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { + KLogger.d { "evaluateJavaScript: $script" } nativeWebView.evaluateJavaScript(script) { result -> callback?.invoke(result) } } - override suspend fun captureScreenshotOrNull(): ByteArray? = - nativeWebView.captureScreenshotNative() + override suspend fun captureScreenshotOrNull(): ByteArray? { + val linux = nativeWebView as? LinuxWebKitNativeWebView + if (linux != null) { + return linux.captureScreenshotAsync() + } + val nativeBytes = nativeWebView.captureScreenshotNative() + if (nativeBytes != null) return nativeBytes + return null + } override fun injectJsBridge() { - // No-op on desktop until a native JS bridge backend is available. + val bridge = webViewJsBridge ?: return + super.injectJsBridge() + + val js = + """ + if (window.${bridge.jsBridgeName} && window.ipc && window.ipc.postMessage) { + window.${bridge.jsBridgeName}.postMessage = function (message) { + window.ipc.postMessage(message); + }; + } + """.trimIndent() + evaluateJavaScript(js) } - override fun initJsBridge(webViewJsBridge: WebViewJsBridge) = Unit + override fun initJsBridge(webViewJsBridge: WebViewJsBridge) { + // IPC is wired natively via WebKit user-content script message handler "ipc". + } } diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt index d783d2e..42f17f7 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt @@ -1,10 +1,11 @@ package dev.nucleusframework.webview.web /** - * Desktop [NativeWebView] placeholder. + * Desktop [NativeWebView] base. * - * Desktop WebView is currently a no-op: all operations are intentionally empty - * so the shared API compiles and demos run without a native backend. + * On Linux with the Tao backend, the default factory creates a + * [dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView]. + * Windows and macOS remain no-ops until their native backends land. */ actual open class NativeWebView { open fun isReady(): Boolean = false diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt index c3266b6..66210e6 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt @@ -9,27 +9,68 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.webview.cookie.DesktopCookieManager import dev.nucleusframework.webview.jsbridge.WebViewJsBridge -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import dev.nucleusframework.webview.jsbridge.parseJsMessage +import dev.nucleusframework.webview.request.WebRequest +import dev.nucleusframework.webview.request.WebRequestInterceptResult +import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView +import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge +import dev.nucleusframework.window.tao.NativeView import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import javax.imageio.ImageIO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds actual class WebViewFactoryParam( val state: WebViewState, val fileContent: String = "", ) -actual fun defaultWebViewFactory( - param: WebViewFactoryParam -): NativeWebView = NativeWebView() +/** + * Default factory: real WebKit2GTK on Linux when the native lib loads; + * no-op placeholder on Windows / macOS (or when the lib is missing). + */ +actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView { + if (Platform.Current == Platform.Linux && WebKitLinuxBridge.isLoaded) { + val settings = param.state.webSettings + val desktop = settings.desktopWebSettings + // Opaque white by default โ€” transparent only when explicitly requested. + val background = + if (desktop.transparent) { + settings.backgroundColor + } else { + val c = settings.backgroundColor + if (c.alpha < 1f) androidx.compose.ui.graphics.Color.White else c.copy(alpha = 1f) + } + return LinuxWebKitNativeWebView( + customUserAgent = settings.customUserAgentString, + dataDirectory = desktop.dataDirectory, + initScript = desktop.initScript, + incognito = desktop.incognito, + enableDevtools = desktop.enableDevtools, + javascriptEnabled = settings.isJavaScriptEnabled, + zoomLevel = settings.zoomLevel, + transparent = desktop.transparent, + backgroundColor = background, + ) + } + return NativeWebView() +} /** - * Desktop WebView composable (no-op). + * Desktop WebView composable. + * + * **Linux + Tao**: embeds a real WebKit2GTK view via [NativeView]. + * **Windows / macOS**: empty box (no-op backend). * - * Renders an empty [Box] and wires [WebViewState] / navigator hooks so the - * shared API remains usable while desktop has no native backend. + * Outside a Tao [dev.nucleusframework.application.DecoratedWindow], + * [NativeView] falls back to an empty box even on Linux โ€” the WebView + * only works with the Tao backend. */ @Composable actual fun ActualWebView( @@ -59,22 +100,140 @@ actual fun ActualWebView( LaunchedEffect(desktopWebView) { state.webView = desktopWebView webViewJsBridge?.webView = desktopWebView - // Mark as finished immediately: nothing is loading on a no-op backend. - state.loadingState = LoadingState.Finished - navigator.canGoBack = false - navigator.canGoForward = false + (state.cookieManager as? DesktopCookieManager)?.attach(nativeWebView) + if (nativeWebView !is LinuxWebKitNativeWebView) { + // No-op backend: mark finished so demos don't spin forever. + state.loadingState = LoadingState.Finished + navigator.canGoBack = false + navigator.canGoForward = false + } } - Box(modifier) { + // Poll native state (URL / loading / title / nav) and drain IPC. + LaunchedEffect(nativeWebView, state, navigator) { + if (nativeWebView !is LinuxWebKitNativeWebView) return@LaunchedEffect + while (true) { + if (!nativeWebView.isReady()) { + if (state.loadingState !is LoadingState.Initializing) { + state.loadingState = LoadingState.Initializing + } + delay(50.milliseconds) + continue + } + + val isLoading = nativeWebView.isLoading() + val url = nativeWebView.getCurrentUrl() + val title = nativeWebView.getTitle() + + // Do NOT treat a freshly created idle WebView as Finished โ€” isLoading + // is false before any navigation, which would race loadHtml drivers. + state.loadingState = + if (isLoading) { + val next = + when (val current = state.loadingState) { + is LoadingState.Loading -> (current.progress + 0.02f).coerceAtMost(0.9f) + else -> 0.1f + } + LoadingState.Loading(next) + } else { + when (state.loadingState) { + is LoadingState.Loading -> LoadingState.Finished + is LoadingState.Finished -> LoadingState.Finished + is LoadingState.Initializing -> { + val hasDocument = + (!url.isNullOrBlank() && url != "about:blank") || + !title.isNullOrBlank() + if (hasDocument) LoadingState.Finished else LoadingState.Initializing + } + } + } + + if (!url.isNullOrBlank()) { + if (!isLoading || state.lastLoadedUrl.isNullOrBlank()) { + state.lastLoadedUrl = url + } + } + + if (!title.isNullOrBlank()) { + state.pageTitle = title + } + + navigator.canGoBack = nativeWebView.canGoBack() + navigator.canGoForward = nativeWebView.canGoForward() + + delay(120.milliseconds) + } + } + + LaunchedEffect(nativeWebView, webViewJsBridge) { + if (nativeWebView !is LinuxWebKitNativeWebView || webViewJsBridge == null) { + return@LaunchedEffect + } + while (true) { + for (raw in nativeWebView.drainIpcMessages()) { + parseJsMessage(raw)?.let { webViewJsBridge.dispatch(it) } + } + delay(50.milliseconds) + } + } + + DisposableEffect(nativeWebView, navigator) { + val listener: (String) -> Boolean = a@{ + if (navigator.requestInterceptor == null) { + return@a true + } + val webRequest = + WebRequest( + url = it, + headers = mutableMapOf(), + isForMainFrame = true, + isRedirect = true, + ) + return@a when ( + val interceptResult = + navigator.requestInterceptor.onInterceptUrlRequest(webRequest, navigator) + ) { + WebRequestInterceptResult.Allow -> true + WebRequestInterceptResult.Reject -> false + is WebRequestInterceptResult.Modify -> { + interceptResult.request.let { modified -> + navigator.stopLoading() + navigator.loadUrl(modified.url, modified.headers) + } + false + } + } + } + nativeWebView.addNavigateListener(listener) + onDispose { + nativeWebView.removeNavigateListener(listener) + } + } + + val linuxWebView = nativeWebView as? LinuxWebKitNativeWebView + if (linuxWebView != null && LocalWebViewFactory.current == null) { + NativeView( + factory = { linuxWebView.asPlatformView() }, + modifier = modifier, + update = { }, + ) LaunchedEffect(nativeWebView) { onCreated(nativeWebView) } + } else { + // Test factory / non-Linux / no-op: empty layout slot. + Box(modifier) { + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + } } DisposableEffect(nativeWebView) { onDispose { state.webView = null webViewJsBridge?.webView = null + (state.cookieManager as? DesktopCookieManager)?.attach(null) currentOnDispose(nativeWebView) nativeWebView.destroy() } @@ -83,8 +242,6 @@ actual fun ActualWebView( /** * Captures a screenshot of the WebView and returns it as a [BufferedImage]. - * Always returns null on the desktop no-op backend unless a custom - * [NativeWebView] override provides image bytes. */ suspend fun IWebView.toAwtImage(): BufferedImage? { val bytes = captureScreenshotOrNull() ?: return null diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt new file mode 100644 index 0000000..70b785f --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt @@ -0,0 +1,233 @@ +package dev.nucleusframework.webview.web.linux + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import dev.nucleusframework.webview.web.NativeWebView +import dev.nucleusframework.window.tao.NucleusPlatformView +import kotlinx.coroutines.CompletableDeferred + +/** + * Linux [NativeWebView] backed by a real WebKit2GTK widget, embeddable + * via [NucleusPlatformView.GtkWidget] / Nucleus [dev.nucleusframework.window.tao.NativeView]. + * + * Requires the Tao window backend. Windows and macOS keep the no-op + * [NativeWebView] until their backends land. + */ +class LinuxWebKitNativeWebView( + customUserAgent: String? = null, + dataDirectory: String? = null, + initScript: String? = null, + incognito: Boolean = false, + enableDevtools: Boolean = false, + javascriptEnabled: Boolean = true, + zoomLevel: Double = 1.0, + transparent: Boolean = false, + backgroundColor: Color = Color.White, +) : NativeWebView() { + private val handle: Long + private val gtkWidgetHandle: Long + private var released = false + + init { + require(WebKitLinuxBridge.isLoaded) { + "compose_webview_linux native library is not available" + } + // Non-transparent: always fully opaque so pages look like a normal browser. + val effective = + if (transparent) { + backgroundColor + } else if (backgroundColor.alpha < 1f) { + Color.White + } else { + backgroundColor.copy(alpha = 1f) + } + val argb = effective.toArgb() + val a = ((argb ushr 24) and 0xFF) / 255f + val r = ((argb ushr 16) and 0xFF) / 255f + val g = ((argb ushr 8) and 0xFF) / 255f + val b = (argb and 0xFF) / 255f + handle = WebKitLinuxBridge.nativeCreate( + userAgent = customUserAgent?.trim()?.takeIf { it.isNotEmpty() }, + dataDirectory = dataDirectory?.trim()?.takeIf { it.isNotEmpty() }, + initScript = initScript?.trim()?.takeIf { it.isNotEmpty() }, + incognito = incognito, + enableDevtools = enableDevtools, + javascriptEnabled = javascriptEnabled, + zoomLevel = zoomLevel, + transparent = transparent, + bgR = r, + bgG = g, + bgB = b, + bgA = a, + ) + require(handle != 0L) { "Failed to create WebKitWebView" } + gtkWidgetHandle = WebKitLinuxBridge.nativeGetGtkWidget(handle) + require(gtkWidgetHandle != 0L) { "Failed to get GtkWidget handle" } + } + + /** Creates the [NucleusPlatformView] used by NativeView embedding. */ + fun asPlatformView(): NucleusPlatformView.GtkWidget = + object : NucleusPlatformView.GtkWidget { + override val gtkWidgetHandle: Long + get() = this@LinuxWebKitNativeWebView.gtkWidgetHandle + + override fun dispose() { + // Lifecycle owned by NativeWebView.destroy(); NativeView + // also calls dispose โ€” keep it idempotent. + } + } + + override fun isReady(): Boolean = !released && handle != 0L + + override fun isLoading(): Boolean = + if (!isReady()) false else WebKitLinuxBridge.nativeIsLoading(handle) + + override fun getCurrentUrl(): String? = + if (!isReady()) null else WebKitLinuxBridge.nativeCurrentUrl(handle) + + override fun getTitle(): String? = + if (!isReady()) null else WebKitLinuxBridge.nativeGetTitle(handle) + + override fun canGoBack(): Boolean = + if (!isReady()) false else WebKitLinuxBridge.nativeCanGoBack(handle) + + override fun canGoForward(): Boolean = + if (!isReady()) false else WebKitLinuxBridge.nativeCanGoForward(handle) + + override fun loadUrl(url: String, additionalHttpHeaders: Map) { + if (!isReady()) return + if (additionalHttpHeaders.isEmpty()) { + WebKitLinuxBridge.nativeLoadUrl(handle, url) + } else { + val names = additionalHttpHeaders.keys.toTypedArray() + val values = additionalHttpHeaders.values.toTypedArray() + WebKitLinuxBridge.nativeLoadUrlWithHeaders(handle, url, names, values) + } + } + + override fun loadHtml(html: String) { + if (!isReady()) return + WebKitLinuxBridge.nativeLoadHtml(handle, html, null) + } + + fun loadHtml(html: String, baseUri: String?) { + if (!isReady()) return + WebKitLinuxBridge.nativeLoadHtml(handle, html, baseUri) + } + + override fun goBack() { + if (!isReady()) return + WebKitLinuxBridge.nativeGoBack(handle) + } + + override fun goForward() { + if (!isReady()) return + WebKitLinuxBridge.nativeGoForward(handle) + } + + override fun reload() { + if (!isReady()) return + WebKitLinuxBridge.nativeReload(handle) + } + + override fun stopLoading() { + if (!isReady()) return + WebKitLinuxBridge.nativeStopLoading(handle) + } + + override fun evaluateJavaScript(script: String, callback: (String) -> Unit) { + if (!isReady()) { + callback("") + return + } + WebKitLinuxBridge.registerJsCallback(handle, callback) + WebKitLinuxBridge.nativeEvaluateJavaScript(handle, script) + } + + override fun drainIpcMessages(): List = + if (!isReady()) emptyList() else WebKitLinuxBridge.drainIpcMessages(handle) + + override fun addNavigateListener(listener: (String) -> Boolean) { + if (!isReady()) return + WebKitLinuxBridge.addNavigateListener(handle, listener) + } + + override fun removeNavigateListener(listener: (String) -> Boolean) { + if (!isReady()) return + WebKitLinuxBridge.removeNavigateListener(handle, listener) + } + + override fun captureScreenshotNative(): ByteArray? { + // Synchronous API is not available; callers should use the suspend path. + return null + } + + suspend fun captureScreenshotAsync(): ByteArray? { + if (!isReady()) return null + val deferred = CompletableDeferred() + WebKitLinuxBridge.registerScreenshotDeferred(handle, deferred) + WebKitLinuxBridge.nativeCaptureScreenshot(handle) + return deferred.await() + } + + suspend fun getCookiesJson(url: String): String { + if (!isReady()) return "[]" + val deferred = CompletableDeferred() + WebKitLinuxBridge.registerCookieDeferred(handle, deferred) + WebKitLinuxBridge.nativeGetCookies(handle, url) + return deferred.await() + } + + fun setCookieNative( + name: String, + value: String, + domain: String?, + path: String?, + secure: Boolean, + httpOnly: Boolean, + expiresMs: Long, + sameSite: String?, + ) { + if (!isReady()) return + WebKitLinuxBridge.nativeSetCookie( + handle, name, value, domain, path, secure, httpOnly, expiresMs, sameSite, + ) + } + + fun removeAllCookiesNative() { + if (!isReady()) return + WebKitLinuxBridge.nativeRemoveAllCookies(handle) + } + + fun removeCookiesForUrlNative(url: String) { + if (!isReady()) return + WebKitLinuxBridge.nativeRemoveCookiesForUrl(handle, url) + } + + fun setZoomLevel(zoom: Double) { + if (!isReady()) return + WebKitLinuxBridge.nativeSetZoomLevel(handle, zoom) + } + + override fun openDevTools() { + if (!isReady()) return + WebKitLinuxBridge.nativeOpenDevTools(handle) + } + + override fun closeDevTools() { + if (!isReady()) return + WebKitLinuxBridge.nativeCloseDevTools(handle) + } + + override fun focus() { + if (!isReady()) return + WebKitLinuxBridge.nativeFocus(handle) + } + + override fun destroy() { + if (released) return + released = true + WebKitLinuxBridge.clearHandle(handle) + WebKitLinuxBridge.nativeRelease(handle) + } +} diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt new file mode 100644 index 0000000..e34f8c4 --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt @@ -0,0 +1,213 @@ +package dev.nucleusframework.webview.web.linux + +import dev.nucleusframework.core.runtime.NativeLibraryLoader +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CompletableDeferred + +/** + * JNI bridge to `compose_webview.c` (WebKit2GTK). + * + * Loaded only on Linux. All native calls must run on the GTK main thread + * (Tao application thread). + */ +internal object WebKitLinuxBridge { + private const val LIBRARY_NAME = "compose_webview_linux" + + val isLoaded: Boolean = + NativeLibraryLoader.load( + LIBRARY_NAME, + WebKitLinuxBridge::class.java, + ) + + private val navigateHandlers = + ConcurrentHashMap Boolean>>() + private val ipcQueues = + ConcurrentHashMap>() + private val jsCallbacks = + ConcurrentHashMap Unit>>() + private val cookieDeferreds = + ConcurrentHashMap>>() + private val screenshotDeferreds = + ConcurrentHashMap>>() + + private val requestIds = AtomicLong(1) + + fun addNavigateListener(handle: Long, listener: (String) -> Boolean) { + navigateHandlers.getOrPut(handle) { mutableListOf() }.add(listener) + } + + fun removeNavigateListener(handle: Long, listener: (String) -> Boolean) { + navigateHandlers[handle]?.remove(listener) + } + + fun drainIpcMessages(handle: Long): List { + val queue = ipcQueues[handle] ?: return emptyList() + val drained = ArrayList() + while (true) { + val next = queue.poll() ?: break + drained += next + } + return drained + } + + fun registerJsCallback(handle: Long, callback: (String) -> Unit) { + jsCallbacks.getOrPut(handle) { ConcurrentLinkedQueue() }.add(callback) + } + + fun registerCookieDeferred(handle: Long, deferred: CompletableDeferred) { + cookieDeferreds.getOrPut(handle) { ConcurrentLinkedQueue() }.add(deferred) + } + + fun registerScreenshotDeferred(handle: Long, deferred: CompletableDeferred) { + screenshotDeferreds.getOrPut(handle) { ConcurrentLinkedQueue() }.add(deferred) + } + + fun clearHandle(handle: Long) { + navigateHandlers.remove(handle) + ipcQueues.remove(handle) + jsCallbacks.remove(handle)?.forEach { it.invoke("") } + cookieDeferreds.remove(handle)?.forEach { + it.complete("[]") + } + screenshotDeferreds.remove(handle)?.forEach { + it.complete(null) + } + } + + // โ”€โ”€ Callbacks from native (must be public for JNI) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @JvmStatic + fun nativeOnNavigate(handle: Long, url: String): Boolean { + val handlers = navigateHandlers[handle] + if (handlers.isNullOrEmpty()) return true + // Match previous Wry semantics: any listener returning true allows. + return handlers.any { it(url) } + } + + @JvmStatic + fun nativeOnIpcMessage(handle: Long, message: String) { + ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(message) + } + + @JvmStatic + fun nativeOnJsResult(handle: Long, result: String) { + jsCallbacks[handle]?.poll()?.invoke(result) + } + + @JvmStatic + fun nativeOnCookiesResult(handle: Long, json: String) { + cookieDeferreds[handle]?.poll()?.complete(json) + } + + @JvmStatic + fun nativeOnScreenshotResult(handle: Long, bytes: ByteArray?) { + screenshotDeferreds[handle]?.poll()?.complete(bytes) + } + + // โ”€โ”€ Native methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @JvmStatic + external fun nativeCreate( + userAgent: String?, + dataDirectory: String?, + initScript: String?, + incognito: Boolean, + enableDevtools: Boolean, + javascriptEnabled: Boolean, + zoomLevel: Double, + transparent: Boolean, + bgR: Float, + bgG: Float, + bgB: Float, + bgA: Float, + ): Long + + @JvmStatic + external fun nativeGetGtkWidget(handle: Long): Long + + @JvmStatic + external fun nativeRelease(handle: Long) + + @JvmStatic + external fun nativeLoadUrl(handle: Long, url: String) + + @JvmStatic + external fun nativeLoadUrlWithHeaders( + handle: Long, + url: String, + headerNames: Array, + headerValues: Array, + ) + + @JvmStatic + external fun nativeLoadHtml(handle: Long, html: String, baseUri: String?) + + @JvmStatic + external fun nativeGoBack(handle: Long) + + @JvmStatic + external fun nativeGoForward(handle: Long) + + @JvmStatic + external fun nativeReload(handle: Long) + + @JvmStatic + external fun nativeStopLoading(handle: Long) + + @JvmStatic + external fun nativeCanGoBack(handle: Long): Boolean + + @JvmStatic + external fun nativeCanGoForward(handle: Long): Boolean + + @JvmStatic + external fun nativeCurrentUrl(handle: Long): String? + + @JvmStatic + external fun nativeGetTitle(handle: Long): String? + + @JvmStatic + external fun nativeIsLoading(handle: Long): Boolean + + @JvmStatic + external fun nativeSetZoomLevel(handle: Long, zoom: Double) + + @JvmStatic + external fun nativeFocus(handle: Long) + + @JvmStatic + external fun nativeOpenDevTools(handle: Long) + + @JvmStatic + external fun nativeCloseDevTools(handle: Long) + + @JvmStatic + external fun nativeEvaluateJavaScript(handle: Long, script: String) + + @JvmStatic + external fun nativeGetCookies(handle: Long, url: String) + + @JvmStatic + external fun nativeSetCookie( + handle: Long, + name: String, + value: String, + domain: String?, + path: String?, + secure: Boolean, + httpOnly: Boolean, + expiresMs: Long, + sameSite: String?, + ) + + @JvmStatic + external fun nativeRemoveAllCookies(handle: Long) + + @JvmStatic + external fun nativeRemoveCookiesForUrl(handle: Long, url: String) + + @JvmStatic + external fun nativeCaptureScreenshot(handle: Long) +} diff --git a/webview-compose/src/jvmMain/native/linux/build.sh b/webview-compose/src/jvmMain/native/linux/build.sh new file mode 100755 index 0000000..5487a04 --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/build.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Builds libcompose_webview_linux.so for Linux x64 / aarch64. +# +# Prerequisites: +# - libwebkit2gtk-4.1-dev (or 4.0) + libgtk-3-dev + libcairo2-dev +# - JAVA_HOME with jni.h +# Usage: ./build.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" +OUT_DIR_X64="$RESOURCE_DIR/linux-x64" +OUT_DIR_ARM64="$RESOURCE_DIR/linux-aarch64" + +mkdir -p "$OUT_DIR_X64" "$OUT_DIR_ARM64" + +if [ -z "${JAVA_HOME:-}" ]; then + if command -v javac >/dev/null 2>&1; then + JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" + fi +fi +if [ -z "${JAVA_HOME:-}" ] || [ ! -f "$JAVA_HOME/include/jni.h" ]; then + echo "ERROR: JAVA_HOME unset or missing jni.h. Set JAVA_HOME to a JDK." >&2 + exit 1 +fi + +WEBKIT_PKG="" +for pkg in webkit2gtk-4.1 webkit2gtk-4.0; do + if pkg-config --exists "$pkg"; then + WEBKIT_PKG="$pkg" + break + fi +done +if [ -z "$WEBKIT_PKG" ]; then + echo "ERROR: neither webkit2gtk-4.1 nor webkit2gtk-4.0 found via pkg-config." >&2 + exit 1 +fi + +CC="${CC:-cc}" +JNI_INCLUDE="$JAVA_HOME/include" +JNI_INCLUDE_LINUX="$JAVA_HOME/include/linux" +LIB_NAME="libcompose_webview_linux.so" +SOURCES=( + "$SCRIPT_DIR/jni_bridge.c" + "$SCRIPT_DIR/view_signals.c" + "$SCRIPT_DIR/view_lifecycle.c" + "$SCRIPT_DIR/navigation.c" + "$SCRIPT_DIR/javascript.c" + "$SCRIPT_DIR/cookies.c" + "$SCRIPT_DIR/screenshot.c" +) + +build_for() { + local OUT_DIR="$1" + local OUT="$OUT_DIR/$LIB_NAME" + echo "Building $OUT (pkg=$WEBKIT_PKG)..." + "$CC" -shared -fPIC -O2 -fvisibility=hidden \ + -I"$SCRIPT_DIR" -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_LINUX" \ + $(pkg-config --cflags "$WEBKIT_PKG" gtk+-3.0 libsoup-3.0) \ + "${SOURCES[@]}" \ + $(pkg-config --libs "$WEBKIT_PKG" gtk+-3.0 libsoup-3.0) -lcairo -lpthread \ + -o "$OUT" + strip --strip-unneeded "$OUT" || true +} + +HOST_ARCH="$(uname -m)" +case "$HOST_ARCH" in + x86_64) build_for "$OUT_DIR_X64" ;; + aarch64|arm64) build_for "$OUT_DIR_ARM64" ;; + *) echo "ERROR: unsupported host arch '$HOST_ARCH'" >&2; exit 1 ;; +esac + +for CACHE_DIR in "$HOME/.cache/nucleus/native"; do + if [ -d "$CACHE_DIR" ]; then + rm -rf "$CACHE_DIR" + echo "Cleared NativeLibraryLoader cache: $CACHE_DIR" + fi +done + +echo "Built compose WebView native library (using $WEBKIT_PKG)." +case "$HOST_ARCH" in + x86_64) ls -lh "$OUT_DIR_X64/$LIB_NAME" ;; + aarch64|arm64) ls -lh "$OUT_DIR_ARM64/$LIB_NAME" ;; +esac diff --git a/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h b/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h new file mode 100644 index 0000000..f08ad9e --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h @@ -0,0 +1,53 @@ +/** + * Shared state and JNI helpers for the Linux WebKit2GTK backend. + * Not a public API โ€” only used by the compose_webview_*.c units. + */ +#ifndef COMPOSE_WEBVIEW_INTERNAL_H +#define COMPOSE_WEBVIEW_INTERNAL_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +typedef struct { + WebKitWebView *web_view; + WebKitUserContentManager *ucm; + WebKitWebContext *context; + gulong decide_policy_handler; + gulong ipc_handler; +} ComposeWebViewState; + +/* jni_bridge.c */ +JNIEnv *compose_webview_get_env(void); +void compose_webview_ensure_bridge_methods(JNIEnv *env); +jclass compose_webview_bridge_class(void); +jmethodID compose_webview_on_navigate(void); +jmethodID compose_webview_on_ipc(void); +jmethodID compose_webview_on_js_result(void); +jmethodID compose_webview_on_cookies(void); +jmethodID compose_webview_on_screenshot(void); + +/* view_lifecycle.c */ +ComposeWebViewState *compose_webview_state_from_handle(jlong handle); +jlong compose_webview_handle_from_view(WebKitWebView *view); + +/* view_signals.c โ€” wired during create */ +gboolean compose_webview_on_decide_policy( + WebKitWebView *web_view, + WebKitPolicyDecision *decision, + WebKitPolicyDecisionType type, + gpointer user_data); +void compose_webview_on_script_message( + WebKitUserContentManager *manager, + WebKitJavascriptResult *js_result, + gpointer user_data); + +#endif /* COMPOSE_WEBVIEW_INTERNAL_H */ diff --git a/webview-compose/src/jvmMain/native/linux/cookies.c b/webview-compose/src/jvmMain/native/linux/cookies.c new file mode 100644 index 0000000..3213d6f --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/cookies.c @@ -0,0 +1,255 @@ +#include "compose_webview_internal.h" + +static WebKitCookieManager *cookie_manager_for(ComposeWebViewState *state) { + if (state == NULL || state->web_view == NULL) return NULL; + WebKitWebsiteDataManager *dm = + webkit_web_view_get_website_data_manager(state->web_view); + if (dm == NULL) return NULL; + return webkit_website_data_manager_get_cookie_manager(dm); +} + +static gchar *cookie_to_json(SoupCookie *cookie) { + if (cookie == NULL) return g_strdup("null"); + const char *name = soup_cookie_get_name(cookie); + const char *value = soup_cookie_get_value(cookie); + const char *domain = soup_cookie_get_domain(cookie); + const char *path = soup_cookie_get_path(cookie); + gboolean secure = soup_cookie_get_secure(cookie); + gboolean http_only = soup_cookie_get_http_only(cookie); + GDateTime *expires = soup_cookie_get_expires(cookie); + gint64 expires_ms = 0; + gboolean session_only = (expires == NULL); + if (expires != NULL) { + expires_ms = g_date_time_to_unix(expires) * 1000; + } + SoupSameSitePolicy ss = soup_cookie_get_same_site_policy(cookie); + const char *same_site = "Lax"; + switch (ss) { + case SOUP_SAME_SITE_POLICY_NONE: same_site = "None"; break; + case SOUP_SAME_SITE_POLICY_STRICT: same_site = "Strict"; break; + case SOUP_SAME_SITE_POLICY_LAX: + default: same_site = "Lax"; break; + } + + /* Minimal JSON โ€” values escaped conservatively. */ + gchar *name_e = g_strescape(name ? name : "", NULL); + gchar *value_e = g_strescape(value ? value : "", NULL); + gchar *domain_e = g_strescape(domain ? domain : "", NULL); + gchar *path_e = g_strescape(path ? path : "/", NULL); + gchar *json = g_strdup_printf( + "{\"name\":\"%s\",\"value\":\"%s\",\"domain\":\"%s\",\"path\":\"%s\"," + "\"secure\":%s,\"httpOnly\":%s,\"sessionOnly\":%s,\"expiresDate\":%lld," + "\"sameSite\":\"%s\"}", + name_e ? name_e : "", + value_e ? value_e : "", + domain_e ? domain_e : "", + path_e ? path_e : "/", + secure ? "true" : "false", + http_only ? "true" : "false", + session_only ? "true" : "false", + (long long) expires_ms, + same_site); + g_free(name_e); + g_free(value_e); + g_free(domain_e); + g_free(path_e); + return json; +} + +typedef struct { + jlong handle; +} CookieOpData; + +static void on_get_cookies_finished( + GObject *source, + GAsyncResult *result, + gpointer user_data) +{ + CookieOpData *data = (CookieOpData *) user_data; + WebKitCookieManager *cm = WEBKIT_COOKIE_MANAGER(source); + GError *error = NULL; + GList *cookies = webkit_cookie_manager_get_cookies_finish(cm, result, &error); + + GString *json = g_string_new("["); + gboolean first = TRUE; + if (error == NULL && cookies != NULL) { + for (GList *l = cookies; l != NULL; l = l->next) { + SoupCookie *cookie = (SoupCookie *) l->data; + gchar *entry = cookie_to_json(cookie); + if (!first) g_string_append_c(json, ','); + g_string_append(json, entry); + g_free(entry); + first = FALSE; + soup_cookie_free(cookie); + } + g_list_free(cookies); + } + if (error != NULL) g_error_free(error); + g_string_append_c(json, ']'); + + JNIEnv *env = compose_webview_get_env(); + if (env != NULL) { + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_cookies() != NULL) { + jstring jjson = (*env)->NewStringUTF(env, json->str); + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_cookies(), data->handle, jjson); + (*env)->DeleteLocalRef(env, jjson); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + } + } + } + g_string_free(json, TRUE); + g_free(data); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeGetCookies( + JNIEnv *env, jclass clazz, jlong handle, jstring url_str) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + WebKitCookieManager *cm = cookie_manager_for(state); + if (cm == NULL || url_str == NULL) { + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_cookies() != NULL) { + jstring empty = (*env)->NewStringUTF(env, "[]"); + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_cookies(), handle, empty); + (*env)->DeleteLocalRef(env, empty); + } + return; + } + const char *url = (*env)->GetStringUTFChars(env, url_str, NULL); + if (url == NULL) return; + CookieOpData *data = g_new0(CookieOpData, 1); + data->handle = handle; + webkit_cookie_manager_get_cookies(cm, url, NULL, on_get_cookies_finished, data); + (*env)->ReleaseStringUTFChars(env, url_str, url); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeSetCookie( + JNIEnv *env, + jclass clazz, + jlong handle, + jstring name, + jstring value, + jstring domain, + jstring path, + jboolean secure, + jboolean http_only, + jlong expires_ms, + jstring same_site) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + WebKitCookieManager *cm = cookie_manager_for(state); + if (cm == NULL || name == NULL || value == NULL) return; + + const char *c_name = (*env)->GetStringUTFChars(env, name, NULL); + const char *c_value = (*env)->GetStringUTFChars(env, value, NULL); + const char *c_domain = domain != NULL ? (*env)->GetStringUTFChars(env, domain, NULL) : NULL; + const char *c_path = path != NULL ? (*env)->GetStringUTFChars(env, path, NULL) : "/"; + const char *c_ss = same_site != NULL ? (*env)->GetStringUTFChars(env, same_site, NULL) : NULL; + + if (c_name != NULL && c_value != NULL) { + SoupCookie *cookie = soup_cookie_new( + c_name, + c_value, + c_domain ? c_domain : "", + c_path ? c_path : "/", + -1); + soup_cookie_set_secure(cookie, secure ? TRUE : FALSE); + soup_cookie_set_http_only(cookie, http_only ? TRUE : FALSE); + if (expires_ms > 0) { + GDateTime *dt = g_date_time_new_from_unix_utc(expires_ms / 1000); + if (dt != NULL) { + soup_cookie_set_expires(cookie, dt); + g_date_time_unref(dt); + } + } + if (c_ss != NULL) { + SoupSameSitePolicy policy = SOUP_SAME_SITE_POLICY_LAX; + if (g_ascii_strcasecmp(c_ss, "None") == 0) { + policy = SOUP_SAME_SITE_POLICY_NONE; + } else if (g_ascii_strcasecmp(c_ss, "Strict") == 0) { + policy = SOUP_SAME_SITE_POLICY_STRICT; + } + soup_cookie_set_same_site_policy(cookie, policy); + } + webkit_cookie_manager_add_cookie(cm, cookie, NULL, NULL, NULL); + soup_cookie_free(cookie); + } + + if (c_name != NULL) (*env)->ReleaseStringUTFChars(env, name, c_name); + if (c_value != NULL) (*env)->ReleaseStringUTFChars(env, value, c_value); + if (domain != NULL && c_domain != NULL) { + (*env)->ReleaseStringUTFChars(env, domain, c_domain); + } + if (path != NULL && c_path != NULL) { + (*env)->ReleaseStringUTFChars(env, path, c_path); + } + if (same_site != NULL && c_ss != NULL) { + (*env)->ReleaseStringUTFChars(env, same_site, c_ss); + } +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeRemoveAllCookies( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + WebKitCookieManager *cm = cookie_manager_for(state); + if (cm == NULL) return; + webkit_cookie_manager_delete_all_cookies(cm); +} + +typedef struct { + WebKitCookieManager *cm; +} DeleteCookiesCtx; + +static void on_delete_cookies_for_url_fetched( + GObject *source, + GAsyncResult *result, + gpointer user_data) +{ + DeleteCookiesCtx *ctx = (DeleteCookiesCtx *) user_data; + WebKitCookieManager *cm = WEBKIT_COOKIE_MANAGER(source); + GError *error = NULL; + GList *cookies = webkit_cookie_manager_get_cookies_finish(cm, result, &error); + if (error != NULL) { + g_error_free(error); + } else if (cookies != NULL) { + for (GList *l = cookies; l != NULL; l = l->next) { + SoupCookie *cookie = (SoupCookie *) l->data; + webkit_cookie_manager_delete_cookie(ctx->cm, cookie, NULL, NULL, NULL); + soup_cookie_free(cookie); + } + g_list_free(cookies); + } + g_object_unref(ctx->cm); + g_free(ctx); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeRemoveCookiesForUrl( + JNIEnv *env, jclass clazz, jlong handle, jstring url_str) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + WebKitCookieManager *cm = cookie_manager_for(state); + if (cm == NULL || url_str == NULL) return; + + const char *url = (*env)->GetStringUTFChars(env, url_str, NULL); + if (url == NULL) return; + + DeleteCookiesCtx *ctx = g_new0(DeleteCookiesCtx, 1); + ctx->cm = g_object_ref(cm); + webkit_cookie_manager_get_cookies( + cm, url, NULL, on_delete_cookies_for_url_fetched, ctx); + (*env)->ReleaseStringUTFChars(env, url_str, url); +} + diff --git a/webview-compose/src/jvmMain/native/linux/javascript.c b/webview-compose/src/jvmMain/native/linux/javascript.c new file mode 100644 index 0000000..3845c46 --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/javascript.c @@ -0,0 +1,93 @@ +#include "compose_webview_internal.h" + +typedef struct { + jlong handle; +} JsEvalData; + +static void on_js_finished( + GObject *source, + GAsyncResult *result, + gpointer user_data) +{ + JsEvalData *data = (JsEvalData *) user_data; + WebKitWebView *view = WEBKIT_WEB_VIEW(source); + GError *error = NULL; + JSCValue *value = webkit_web_view_evaluate_javascript_finish(view, result, &error); + + gchar *payload = NULL; + if (error != NULL) { + payload = g_strdup(""); + g_error_free(error); + } else if (value == NULL) { + payload = g_strdup(""); + } else if (jsc_value_is_undefined(value) || jsc_value_is_null(value)) { + payload = g_strdup("null"); + g_object_unref(value); + } else if (jsc_value_is_string(value)) { + /* Match Android evaluateJavascript: JSON-encoded string with quotes. */ + gchar *raw = jsc_value_to_string(value); + gchar *escaped = g_strescape(raw, NULL); + payload = g_strdup_printf("\"%s\"", escaped ? escaped : ""); + g_free(escaped); + g_free(raw); + g_object_unref(value); + } else { + payload = jsc_value_to_json(value, 0); + if (payload == NULL) payload = g_strdup(""); + g_object_unref(value); + } + + JNIEnv *env = compose_webview_get_env(); + if (env != NULL) { + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_js_result() != NULL) { + jstring jpayload = (*env)->NewStringUTF(env, payload ? payload : ""); + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_js_result(), data->handle, jpayload); + (*env)->DeleteLocalRef(env, jpayload); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + } + } + } + g_free(payload); + g_free(data); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeEvaluateJavaScript( + JNIEnv *env, jclass clazz, jlong handle, jstring script_str) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL || script_str == NULL) { + if (script_str != NULL) { + /* still need to complete the callback */ + } + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_js_result() != NULL) { + jstring empty = (*env)->NewStringUTF(env, ""); + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_js_result(), handle, empty); + (*env)->DeleteLocalRef(env, empty); + } + return; + } + + const char *script = (*env)->GetStringUTFChars(env, script_str, NULL); + if (script == NULL) return; + + JsEvalData *data = g_new0(JsEvalData, 1); + data->handle = handle; + webkit_web_view_evaluate_javascript( + state->web_view, + script, + -1, + NULL, + NULL, + NULL, + on_js_finished, + data); + (*env)->ReleaseStringUTFChars(env, script_str, script); +} + diff --git a/webview-compose/src/jvmMain/native/linux/jni_bridge.c b/webview-compose/src/jvmMain/native/linux/jni_bridge.c new file mode 100644 index 0000000..7df9eee --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/jni_bridge.c @@ -0,0 +1,56 @@ +#include "compose_webview_internal.h" + +static JavaVM *g_jvm = NULL; +static jclass g_bridge_class = NULL; +static jmethodID g_on_navigate = NULL; +static jmethodID g_on_ipc = NULL; +static jmethodID g_on_js_result = NULL; +static jmethodID g_on_cookies = NULL; +static jmethodID g_on_screenshot = NULL; + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { + (void) reserved; + g_jvm = vm; + return JNI_VERSION_1_8; +} + +JNIEnv *compose_webview_get_env(void) { + JNIEnv *env = NULL; + if (g_jvm == NULL) return NULL; + jint status = (*g_jvm)->GetEnv(g_jvm, (void **) &env, JNI_VERSION_1_8); + if (status == JNI_EDETACHED) { + if ((*g_jvm)->AttachCurrentThread(g_jvm, (void **) &env, NULL) != 0) { + return NULL; + } + } else if (status != JNI_OK) { + return NULL; + } + return env; +} + +void compose_webview_ensure_bridge_methods(JNIEnv *env) { + if (g_bridge_class != NULL) return; + jclass local = (*env)->FindClass( + env, + "dev/nucleusframework/webview/web/linux/WebKitLinuxBridge"); + if (local == NULL) return; + g_bridge_class = (jclass) (*env)->NewGlobalRef(env, local); + (*env)->DeleteLocalRef(env, local); + g_on_navigate = (*env)->GetStaticMethodID( + env, g_bridge_class, "nativeOnNavigate", "(JLjava/lang/String;)Z"); + g_on_ipc = (*env)->GetStaticMethodID( + env, g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;)V"); + g_on_js_result = (*env)->GetStaticMethodID( + env, g_bridge_class, "nativeOnJsResult", "(JLjava/lang/String;)V"); + g_on_cookies = (*env)->GetStaticMethodID( + env, g_bridge_class, "nativeOnCookiesResult", "(JLjava/lang/String;)V"); + g_on_screenshot = (*env)->GetStaticMethodID( + env, g_bridge_class, "nativeOnScreenshotResult", "(J[B)V"); +} + +jclass compose_webview_bridge_class(void) { return g_bridge_class; } +jmethodID compose_webview_on_navigate(void) { return g_on_navigate; } +jmethodID compose_webview_on_ipc(void) { return g_on_ipc; } +jmethodID compose_webview_on_js_result(void) { return g_on_js_result; } +jmethodID compose_webview_on_cookies(void) { return g_on_cookies; } +jmethodID compose_webview_on_screenshot(void) { return g_on_screenshot; } diff --git a/webview-compose/src/jvmMain/native/linux/navigation.c b/webview-compose/src/jvmMain/native/linux/navigation.c new file mode 100644 index 0000000..128cb96 --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/navigation.c @@ -0,0 +1,218 @@ +#include "compose_webview_internal.h" + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeLoadUrl( + JNIEnv *env, jclass clazz, jlong handle, jstring url_str) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL || url_str == NULL) return; + const char *cUrl = (*env)->GetStringUTFChars(env, url_str, NULL); + if (cUrl == NULL) return; + webkit_web_view_load_uri(state->web_view, cUrl); + (*env)->ReleaseStringUTFChars(env, url_str, cUrl); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeLoadUrlWithHeaders( + JNIEnv *env, + jclass clazz, + jlong handle, + jstring url_str, + jobjectArray header_names, + jobjectArray header_values) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL || url_str == NULL) return; + + const char *cUrl = (*env)->GetStringUTFChars(env, url_str, NULL); + if (cUrl == NULL) return; + + WebKitURIRequest *request = webkit_uri_request_new(cUrl); + SoupMessageHeaders *headers = webkit_uri_request_get_http_headers(request); + if (headers != NULL && header_names != NULL && header_values != NULL) { + jsize count = (*env)->GetArrayLength(env, header_names); + jsize value_count = (*env)->GetArrayLength(env, header_values); + if (value_count < count) count = value_count; + for (jsize i = 0; i < count; i++) { + jstring jn = (jstring) (*env)->GetObjectArrayElement(env, header_names, i); + jstring jv = (jstring) (*env)->GetObjectArrayElement(env, header_values, i); + if (jn != NULL && jv != NULL) { + const char *n = (*env)->GetStringUTFChars(env, jn, NULL); + const char *v = (*env)->GetStringUTFChars(env, jv, NULL); + if (n != NULL && v != NULL) { + soup_message_headers_append(headers, n, v); + } + if (n != NULL) (*env)->ReleaseStringUTFChars(env, jn, n); + if (v != NULL) (*env)->ReleaseStringUTFChars(env, jv, v); + } + if (jn != NULL) (*env)->DeleteLocalRef(env, jn); + if (jv != NULL) (*env)->DeleteLocalRef(env, jv); + } + } + webkit_web_view_load_request(state->web_view, request); + g_object_unref(request); + (*env)->ReleaseStringUTFChars(env, url_str, cUrl); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeLoadHtml( + JNIEnv *env, jclass clazz, jlong handle, jstring html_str, jstring base_uri) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL || html_str == NULL) return; + const char *html = (*env)->GetStringUTFChars(env, html_str, NULL); + if (html == NULL) return; + const char *base = NULL; + if (base_uri != NULL) { + base = (*env)->GetStringUTFChars(env, base_uri, NULL); + } + webkit_web_view_load_html(state->web_view, html, base); + (*env)->ReleaseStringUTFChars(env, html_str, html); + if (base != NULL) { + (*env)->ReleaseStringUTFChars(env, base_uri, base); + } +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeGoBack( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + webkit_web_view_go_back(state->web_view); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeGoForward( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + webkit_web_view_go_forward(state->web_view); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeReload( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + webkit_web_view_reload(state->web_view); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeStopLoading( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + webkit_web_view_stop_loading(state->web_view); +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCanGoBack( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return JNI_FALSE; + return webkit_web_view_can_go_back(state->web_view) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCanGoForward( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return JNI_FALSE; + return webkit_web_view_can_go_forward(state->web_view) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCurrentUrl( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return NULL; + const gchar *uri = webkit_web_view_get_uri(state->web_view); + if (uri == NULL) return NULL; + return (*env)->NewStringUTF(env, uri); +} + +JNIEXPORT jstring JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeGetTitle( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return NULL; + const gchar *title = webkit_web_view_get_title(state->web_view); + if (title == NULL) return NULL; + return (*env)->NewStringUTF(env, title); +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeIsLoading( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return JNI_FALSE; + return webkit_web_view_is_loading(state->web_view) ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeSetZoomLevel( + JNIEnv *env, jclass clazz, jlong handle, jdouble zoom) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + webkit_web_view_set_zoom_level(state->web_view, zoom); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeFocus( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + gtk_widget_grab_focus(GTK_WIDGET(state->web_view)); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeOpenDevTools( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + WebKitWebInspector *inspector = webkit_web_view_get_inspector(state->web_view); + if (inspector != NULL) { + webkit_web_inspector_show(inspector); + } +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCloseDevTools( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return; + WebKitWebInspector *inspector = webkit_web_view_get_inspector(state->web_view); + if (inspector != NULL) { + webkit_web_inspector_close(inspector); + } +} + diff --git a/webview-compose/src/jvmMain/native/linux/screenshot.c b/webview-compose/src/jvmMain/native/linux/screenshot.c new file mode 100644 index 0000000..c695df2 --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/screenshot.c @@ -0,0 +1,87 @@ +#include "compose_webview_internal.h" + +typedef struct { + jlong handle; +} SnapshotData; + +static cairo_status_t png_write_to_gbyte_array( + void *closure, + const unsigned char *data, + unsigned int length) +{ + GByteArray *array = (GByteArray *) closure; + g_byte_array_append(array, data, length); + return CAIRO_STATUS_SUCCESS; +} + +static void on_snapshot_finished( + GObject *source, + GAsyncResult *result, + gpointer user_data) +{ + SnapshotData *data = (SnapshotData *) user_data; + WebKitWebView *view = WEBKIT_WEB_VIEW(source); + GError *error = NULL; + cairo_surface_t *surface = + webkit_web_view_get_snapshot_finish(view, result, &error); + + JNIEnv *env = compose_webview_get_env(); + jbyteArray jbytes = NULL; + + if (error == NULL && surface != NULL && env != NULL) { + GByteArray *png = g_byte_array_new(); + cairo_status_t st = cairo_surface_write_to_png_stream( + surface, png_write_to_gbyte_array, png); + if (st == CAIRO_STATUS_SUCCESS && png->len > 0) { + jbytes = (*env)->NewByteArray(env, (jsize) png->len); + if (jbytes != NULL) { + (*env)->SetByteArrayRegion( + env, jbytes, 0, (jsize) png->len, (const jbyte *) png->data); + } + } + g_byte_array_free(png, TRUE); + cairo_surface_destroy(surface); + } + if (error != NULL) g_error_free(error); + + if (env != NULL) { + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_screenshot() != NULL) { + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_screenshot(), data->handle, jbytes); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + } + } + if (jbytes != NULL) (*env)->DeleteLocalRef(env, jbytes); + } + g_free(data); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCaptureScreenshot( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) { + JNIEnv *jenv = compose_webview_get_env(); + if (jenv != NULL) { + compose_webview_ensure_bridge_methods(jenv); + if (compose_webview_bridge_class() != NULL && compose_webview_on_screenshot() != NULL) { + (*jenv)->CallStaticVoidMethod( + jenv, compose_webview_bridge_class(), compose_webview_on_screenshot(), handle, NULL); + } + } + return; + } + SnapshotData *data = g_new0(SnapshotData, 1); + data->handle = handle; + webkit_web_view_get_snapshot( + state->web_view, + WEBKIT_SNAPSHOT_REGION_VISIBLE, + WEBKIT_SNAPSHOT_OPTIONS_NONE, + NULL, + on_snapshot_finished, + data); +} diff --git a/webview-compose/src/jvmMain/native/linux/view_lifecycle.c b/webview-compose/src/jvmMain/native/linux/view_lifecycle.c new file mode 100644 index 0000000..6a45a98 --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/view_lifecycle.c @@ -0,0 +1,220 @@ +#include "compose_webview_internal.h" + +ComposeWebViewState *compose_webview_state_from_handle(jlong handle) { + if (handle == 0) return NULL; + return (ComposeWebViewState *) (uintptr_t) handle; +} + +jlong compose_webview_handle_from_view(WebKitWebView *view) { + gpointer p = g_object_get_data(G_OBJECT(view), "compose-webview-state"); + return (jlong) (uintptr_t) p; +} + +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate( + JNIEnv *env, + jclass clazz, + jstring user_agent, + jstring data_directory, + jstring init_script, + jboolean incognito, + jboolean enable_devtools, + jboolean javascript_enabled, + jdouble zoom_level, + jboolean transparent, + jfloat bg_r, + jfloat bg_g, + jfloat bg_b, + jfloat bg_a) +{ + (void) clazz; + compose_webview_ensure_bridge_methods(env); + + ComposeWebViewState *state = g_new0(ComposeWebViewState, 1); + + WebKitWebsiteDataManager *data_manager = NULL; + if (incognito) { + data_manager = webkit_website_data_manager_new_ephemeral(); + } else if (data_directory != NULL) { + const char *dir = (*env)->GetStringUTFChars(env, data_directory, NULL); + if (dir != NULL) { + gchar *cache_dir = g_build_filename(dir, "cache", NULL); + data_manager = webkit_website_data_manager_new( + "base-data-directory", dir, + "base-cache-directory", cache_dir, + NULL); + g_free(cache_dir); + (*env)->ReleaseStringUTFChars(env, data_directory, dir); + } + } + if (data_manager == NULL) { + data_manager = webkit_website_data_manager_new_ephemeral(); + } + + state->context = webkit_web_context_new_with_website_data_manager(data_manager); + g_object_unref(data_manager); + + state->ucm = webkit_user_content_manager_new(); + /* Connect BEFORE register to avoid racing the first postMessage. */ + state->ipc_handler = g_signal_connect( + state->ucm, + "script-message-received::ipc", + G_CALLBACK(compose_webview_on_script_message), + state); + webkit_user_content_manager_register_script_message_handler(state->ucm, "ipc"); + + /* Always inject a small window.ipc shim so the Kotlin JS bridge can attach. */ + const char *ipc_shim = + "if (typeof window.ipc === 'undefined') {" + " window.ipc = {" + " postMessage: function(message) {" + " if (window.webkit && window.webkit.messageHandlers &&" + " window.webkit.messageHandlers.ipc) {" + " window.webkit.messageHandlers.ipc.postMessage(" + " (typeof message === 'string') ? message : JSON.stringify(message)" + " );" + " }" + " }" + " };" + "}"; + WebKitUserScript *shim = webkit_user_script_new( + ipc_shim, + WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, + NULL, + NULL); + webkit_user_content_manager_add_script(state->ucm, shim); + webkit_user_script_unref(shim); + + /* + * 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') { + WebKitUserScript *user_script = webkit_user_script_new( + src, + WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, + NULL, + NULL); + webkit_user_content_manager_add_script(state->ucm, user_script); + webkit_user_script_unref(user_script); + } + if (src != NULL) { + (*env)->ReleaseStringUTFChars(env, init_script, src); + } + } + + WebKitSettings *settings = webkit_settings_new(); + webkit_settings_set_enable_javascript(settings, javascript_enabled ? TRUE : FALSE); + webkit_settings_set_enable_developer_extras(settings, enable_devtools ? TRUE : FALSE); + webkit_settings_set_javascript_can_access_clipboard(settings, TRUE); + webkit_settings_set_enable_write_console_messages_to_stdout(settings, FALSE); + if (user_agent != NULL) { + const char *ua = (*env)->GetStringUTFChars(env, user_agent, NULL); + if (ua != NULL && ua[0] != '\0') { + webkit_settings_set_user_agent(settings, ua); + } + if (ua != NULL) { + (*env)->ReleaseStringUTFChars(env, user_agent, ua); + } + } + + state->web_view = WEBKIT_WEB_VIEW(g_object_new( + WEBKIT_TYPE_WEB_VIEW, + "web-context", state->context, + "user-content-manager", state->ucm, + "settings", settings, + NULL)); + g_object_unref(settings); + + /* Strong floating-ref so the widget survives until nativeRelease. */ + g_object_ref_sink(G_OBJECT(state->web_view)); + + if (zoom_level > 0.0) { + webkit_web_view_set_zoom_level(state->web_view, zoom_level); + } + + /* Opaque browser default = solid white when transparent is off. */ + GdkRGBA bg; + if (transparent) { + bg.red = bg_r; + bg.green = bg_g; + bg.blue = bg_b; + bg.alpha = bg_a; + } else { + bg.red = (bg_a >= 1.0f) ? bg_r : 1.0; + bg.green = (bg_a >= 1.0f) ? bg_g : 1.0; + bg.blue = (bg_a >= 1.0f) ? bg_b : 1.0; + bg.alpha = 1.0; + } + webkit_web_view_set_background_color(state->web_view, &bg); + gtk_widget_set_opacity(GTK_WIDGET(state->web_view), 1.0); + + g_object_set_data(G_OBJECT(state->web_view), "compose-webview-state", state); + + state->decide_policy_handler = g_signal_connect( + state->web_view, + "decide-policy", + G_CALLBACK(compose_webview_on_decide_policy), + state); + + return (jlong) (uintptr_t) state; +} + +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeGetGtkWidget( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL || state->web_view == NULL) return 0; + return (jlong) (uintptr_t) GTK_WIDGET(state->web_view); +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeRelease( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + ComposeWebViewState *state = compose_webview_state_from_handle(handle); + if (state == NULL) return; + + if (state->web_view != NULL) { + if (state->decide_policy_handler != 0) { + g_signal_handler_disconnect(state->web_view, state->decide_policy_handler); + state->decide_policy_handler = 0; + } + g_object_set_data(G_OBJECT(state->web_view), "compose-webview-state", NULL); + g_object_unref(state->web_view); + state->web_view = NULL; + } + if (state->ucm != NULL) { + if (state->ipc_handler != 0) { + g_signal_handler_disconnect(state->ucm, state->ipc_handler); + state->ipc_handler = 0; + } + g_object_unref(state->ucm); + state->ucm = NULL; + } + if (state->context != NULL) { + g_object_unref(state->context); + state->context = NULL; + } + g_free(state); +} + diff --git a/webview-compose/src/jvmMain/native/linux/view_signals.c b/webview-compose/src/jvmMain/native/linux/view_signals.c new file mode 100644 index 0000000..8a5259a --- /dev/null +++ b/webview-compose/src/jvmMain/native/linux/view_signals.c @@ -0,0 +1,101 @@ +#include "compose_webview_internal.h" + +gboolean compose_webview_on_decide_policy( + WebKitWebView *web_view, + WebKitPolicyDecision *decision, + WebKitPolicyDecisionType type, + gpointer user_data) +{ + (void) user_data; + if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION && + type != WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) { + return FALSE; + } + + WebKitNavigationPolicyDecision *nav = + WEBKIT_NAVIGATION_POLICY_DECISION(decision); + WebKitNavigationAction *action = + webkit_navigation_policy_decision_get_navigation_action(nav); + WebKitURIRequest *request = webkit_navigation_action_get_request(action); + const gchar *uri = webkit_uri_request_get_uri(request); + if (uri == NULL) { + webkit_policy_decision_use(decision); + return TRUE; + } + + if (g_str_has_prefix(uri, "about:") || + g_str_has_prefix(uri, "data:") || + g_str_has_prefix(uri, "blob:")) { + webkit_policy_decision_use(decision); + return TRUE; + } + + JNIEnv *env = compose_webview_get_env(); + if (env == NULL) { + webkit_policy_decision_use(decision); + return TRUE; + } + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() == NULL || compose_webview_on_navigate() == NULL) { + webkit_policy_decision_use(decision); + return TRUE; + } + + jlong handle = compose_webview_handle_from_view(web_view); + jstring juri = (*env)->NewStringUTF(env, uri); + jboolean allow = (*env)->CallStaticBooleanMethod( + env, compose_webview_bridge_class(), compose_webview_on_navigate(), handle, juri); + (*env)->DeleteLocalRef(env, juri); + + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + webkit_policy_decision_use(decision); + return TRUE; + } + + if (allow) { + webkit_policy_decision_use(decision); + } else { + webkit_policy_decision_ignore(decision); + } + return TRUE; +} + +void compose_webview_on_script_message( + WebKitUserContentManager *manager, + WebKitJavascriptResult *js_result, + gpointer user_data) +{ + (void) manager; + ComposeWebViewState *state = (ComposeWebViewState *) user_data; + if (state == NULL || state->web_view == NULL || js_result == NULL) return; + + /* WebKitGTK 4.1 still delivers WebKitJavascriptResult on this signal + * (not a bare JSCValue*). Extract the JSCValue first. */ + JSCValue *value = webkit_javascript_result_get_js_value(js_result); + if (value == NULL || !JSC_IS_VALUE(value)) return; + + gchar *message = NULL; + if (jsc_value_is_string(value)) { + message = jsc_value_to_string(value); + } else { + message = jsc_value_to_json(value, 0); + } + if (message == NULL) return; + + JNIEnv *env = compose_webview_get_env(); + if (env != NULL) { + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_ipc() != NULL) { + jlong handle = (jlong) (uintptr_t) state; + jstring jmsg = (*env)->NewStringUTF(env, message); + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_ipc(), handle, jmsg); + (*env)->DeleteLocalRef(env, jmsg); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + } + } + } + g_free(message); +} diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties new file mode 100644 index 0000000..91e55c2 --- /dev/null +++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties @@ -0,0 +1,5 @@ +# WebKitLinuxBridge loads libcompose_webview_linux via NativeLibraryLoader in +# a static field initializer โ€” must stay at run time (native-image would +# otherwise try to open the .so during build). +Args = --initialize-at-run-time=dev.nucleusframework.webview.web.linux.WebKitLinuxBridge,\ +dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json new file mode 100644 index 0000000..1fefbeb --- /dev/null +++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json @@ -0,0 +1,82 @@ +{ + "reflection": [ + { + "type": "dev.nucleusframework.webview.web.linux.WebKitLinuxBridge", + "jniAccessible": true, + "allDeclaredMethods": true, + "allDeclaredFields": true, + "methods": [ + { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] } + ] + }, + { + "type": "dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "dev.nucleusframework.webview.web.NativeWebView", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + }, + { + "type": "dev.nucleusframework.webview.web.DesktopWebView", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + }, + { + "type": "dev.nucleusframework.webview.cookie.DesktopCookieManager", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + }, + { + "type": "dev.nucleusframework.webview.cookie.NativeCookieDto", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "dev.nucleusframework.webview.cookie.NativeCookieDto[]" + }, + { + "type": "dev.nucleusframework.webview.jsbridge.JsMessage", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "dev.nucleusframework.webview.jsbridge.WebViewJsBridge", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + } + ], + "resources": [ + { "glob": "nucleus/native/**" }, + { "glob": "META-INF/native-image/dev.nucleusframework/composewebview/**" } + ], + "serialization": [ + { "type": "dev.nucleusframework.webview.cookie.Cookie" }, + { "type": "dev.nucleusframework.webview.cookie.Cookie$HTTPCookieSameSitePolicy" }, + { "type": "dev.nucleusframework.webview.cookie.NativeCookieDto" }, + { "type": "dev.nucleusframework.webview.jsbridge.JsMessage" }, + { "type": "kotlin.collections.ArrayList" }, + { "type": "kotlin.collections.EmptyList" } + ], + "jni": [ + { + "type": "dev.nucleusframework.webview.web.linux.WebKitLinuxBridge", + "methods": [ + { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] } + ] + } + ] +} diff --git a/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro b/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro new file mode 100644 index 0000000..2c96dce --- /dev/null +++ b/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro @@ -0,0 +1,28 @@ +# Keep the Linux WebKit JNI bridge โ€” native-image / ProGuard would otherwise +# strip callbacks reached only from libcompose_webview_linux.so. +-keep class dev.nucleusframework.webview.web.linux.WebKitLinuxBridge { + public static *; + public *; +} +-keep class dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView { + (...); + public *; +} +-keep class dev.nucleusframework.webview.web.NativeWebView { + public *; +} +-keep class dev.nucleusframework.webview.web.DesktopWebView { + (...); + public *; +} +-keep class dev.nucleusframework.webview.cookie.DesktopCookieManager { + (...); + public *; +} +-keep class dev.nucleusframework.webview.cookie.NativeCookieDto { + (...); + *; +} +-keepclassmembers class * { + @kotlinx.serialization.Serializable ; +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt new file mode 100644 index 0000000..7c1c7eb --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt @@ -0,0 +1,92 @@ +package dev.nucleusframework.webview.web + +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.core.runtime.Platform +import dev.nucleusframework.webview.web.e2e.E2eHost +import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge +import dev.nucleusframework.window.TitleBar +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * End-to-end tests against a real WebKit2GTK WebView embedded via Nucleus + * NativeView + Tao backend. No mocks. + * + * Drivers live in [dev.nucleusframework.webview.web.e2e]. + * Requires Linux + display + libwebkit2gtk (self-skips otherwise). + */ +class LinuxWebViewE2eTest { + @Test + fun allDesktopWebViewFeaturesE2e() { + if (Platform.Current != Platform.Linux) { + println("SKIPPED: Linux-only e2e (current=${Platform.Current})") + return + } + if (!WebKitLinuxBridge.isLoaded) { + println("SKIPPED: compose_webview_linux native library not loaded") + return + } + if (System.getenv("DISPLAY").isNullOrBlank() && + System.getenv("WAYLAND_DISPLAY").isNullOrBlank() + ) { + println("SKIPPED: no DISPLAY / WAYLAND_DISPLAY") + return + } + + val failures = CopyOnWriteArrayList() + val completed = AtomicReference(false) + + thread(isDaemon = true, name = "webview-e2e-watchdog") { + Thread.sleep(WATCHDOG_MS) + if (!completed.get()) { + System.err.println("WATCHDOG: e2e suite timed out") + Runtime.getRuntime().halt(42) + } + } + + nucleusApplication(backend = NucleusBackend.Tao) { + val windowState = rememberWindowState(size = DpSize(900.dp, 700.dp)) + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = windowState, + title = "webview-e2e", + ) { + TitleBar { } + E2eHost( + onFailure = { failures += it }, + onDone = { + completed.set(true) + exitApplication() + }, + ) + } + } + + val failureSummary = + if (failures.isEmpty()) { + null + } else { + "E2E failures:\n" + failures.joinToString("\n") { " - $it" } + } + if (failureSummary != null) { + println(failureSummary) + } + // Prefer a hard AssertionError so the suite cannot be recorded as "skipped" + // when the Tao event loop exits before kotlin.test.fail propagates cleanly. + check(completed.get()) { "suite did not complete" } + check(failureSummary == null) { failureSummary!! } + } + + private companion object { + const val WATCHDOG_MS = 180_000L + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt new file mode 100644 index 0000000..d167b8a --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt @@ -0,0 +1,50 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.cookie.Cookie +import dev.nucleusframework.webview.web.WebViewState +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.delay + +@Composable +internal fun CookieDriver( + state: WebViewState, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + val url = "https://e2e.local/" + state.cookieManager.removeAllCookies() + delay(200) + state.cookieManager.setCookie( + url, + Cookie( + name = "e2e_cookie", + value = "v1", + domain = "e2e.local", + path = "/", + isSecure = false, + isHttpOnly = false, + ), + ) + awaitUntil("cookie present") { + state.cookieManager.getCookies(url).any { it.name == "e2e_cookie" && it.value == "v1" } + } + val cookies = state.cookieManager.getCookies(url) + assertTrue(cookies.any { it.name == "e2e_cookie" }, "cookie not found: $cookies") + state.cookieManager.removeCookies(url) + delay(300) + val after = state.cookieManager.getCookies(url) + assertFalse(after.any { it.name == "e2e_cookie" }, "cookie should be removed, still: $after") + println("[e2e] cookies set/get/remove OK") + onDone() + } catch (t: Throwable) { + onFailure("cookies: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt new file mode 100644 index 0000000..a228c42 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt @@ -0,0 +1,44 @@ +package dev.nucleusframework.webview.web.e2e + +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout + +internal suspend fun awaitWebViewReady(state: WebViewState, timeoutMs: Long = 15_000) { + awaitUntil("webview attached", timeoutMs) { + val nv = state.webView?.nativeWebView + nv is LinuxWebKitNativeWebView && nv.isReady() + } +} + +internal suspend fun awaitUntil( + description: String, + timeoutMs: Long = 15_000, + predicate: suspend () -> Boolean, +) { + withTimeout(timeoutMs) { + while (!predicate()) { + delay(50) + } + } +} + +internal suspend fun awaitJsString( + navigator: WebViewNavigator, + script: String, + timeoutMs: Long = 10_000, +): String { + val deferred = CompletableDeferred() + navigator.evaluateJavaScript(script) { result -> + deferred.complete(result) + } + return withTimeout(timeoutMs) { deferred.await() } +} + +internal suspend fun awaitFinished(state: WebViewState, label: String = "page finished") { + awaitUntil(label) { state.loadingState is LoadingState.Finished } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt new file mode 100644 index 0000000..329ac76 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt @@ -0,0 +1,115 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +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.setValue +import androidx.compose.ui.Modifier +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.request.RequestInterceptor +import dev.nucleusframework.webview.request.WebRequest +import dev.nucleusframework.webview.request.WebRequestInterceptResult +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewStateWithHTMLData +import java.util.concurrent.CopyOnWriteArrayList + +@Composable +internal fun E2eHost( + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + val scope = rememberCoroutineScope() + var phase by remember { mutableStateOf(0) } + + val navigator = rememberWebViewNavigator(coroutineScope = scope) + val state = + rememberWebViewStateWithHTMLData( + data = E2E_PAGE_HTML, + baseUrl = "https://e2e.local/", + ) + val jsBridge = rememberWebViewJsBridge(navigator) + + var rejectExample by remember { mutableStateOf(false) } + val interceptor = + remember { + object : RequestInterceptor { + override fun onInterceptUrlRequest( + request: WebRequest, + navigator: WebViewNavigator, + ): WebRequestInterceptResult { + if (rejectExample && request.url.contains("example.com")) { + return WebRequestInterceptResult.Reject + } + return WebRequestInterceptResult.Allow + } + } + } + val interceptNavigator = + rememberWebViewNavigator(coroutineScope = scope, requestInterceptor = interceptor) + + val bridgeHits = remember { CopyOnWriteArrayList() } + LaunchedEffect(jsBridge) { + jsBridge.register( + object : IJsMessageHandler { + override fun methodName(): String = "e2eEcho" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + bridgeHits += message.params + callback("""{"ok":true}""") + } + }, + ) + } + + when (phase) { + 0, 1, 2, 3, 4, 6, 7 -> { + WebView( + state = state, + navigator = navigator, + webViewJsBridge = jsBridge, + modifier = Modifier.fillMaxSize(), + ) + } + 5 -> { + val interceptState = + rememberWebViewStateWithHTMLData( + data = E2E_PAGE_HTML, + baseUrl = "https://e2e.local/", + ) + WebView( + state = interceptState, + navigator = interceptNavigator, + modifier = Modifier.fillMaxSize(), + ) + InterceptorDriver( + state = interceptState, + navigator = interceptNavigator, + rejectExample = { rejectExample = it }, + onFailure = onFailure, + onDone = { phase = 6 }, + ) + } + } + + when (phase) { + 0 -> LoadHtmlDriver(state, navigator, onFailure) { phase = 1 } + 1 -> EvaluateJsDriver(state, navigator, onFailure) { phase = 2 } + 2 -> NavigationDriver(state, navigator, onFailure) { phase = 3 } + 3 -> CookieDriver(state, onFailure) { phase = 4 } + 4 -> JsBridgeDriver(state, navigator, bridgeHits, onFailure) { phase = 5 } + 6 -> ScreenshotDriver(state, onFailure) { phase = 7 } + 7 -> UserAgentDriver(onFailure, onDone) + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt new file mode 100644 index 0000000..0f36287 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt @@ -0,0 +1,25 @@ +package dev.nucleusframework.webview.web.e2e + +internal val E2E_PAGE_HTML = + """ + + + E2E Page + +

E2E

+
hello-e2e
+ + + """.trimIndent() + +internal val E2E_PAGE_A = + """ + A +
page-a
+ """.trimIndent() + +internal val E2E_PAGE_B = + """ + B +
page-b
+ """.trimIndent() diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt new file mode 100644 index 0000000..afe5685 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt @@ -0,0 +1,31 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import kotlin.test.assertTrue + +@Composable +internal fun EvaluateJsDriver( + state: WebViewState, + navigator: WebViewNavigator, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + awaitJsString(navigator, "window.__e2e = 41; window.__e2e + 1") + val value = awaitJsString(navigator, "window.__e2e") + assertTrue(value.contains("41"), "expected 41, got $value") + val outer = awaitJsString(navigator, "document.documentElement.outerHTML") + assertTrue(outer.contains("E2E"), "outerHTML missing E2E: $outer") + println("[e2e] evaluateJavaScript OK") + onDone() + } catch (t: Throwable) { + onFailure("evaluateJs: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt new file mode 100644 index 0000000..b79fd89 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt @@ -0,0 +1,46 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import kotlin.test.assertTrue +import kotlinx.coroutines.delay + +@Composable +internal fun InterceptorDriver( + state: WebViewState, + navigator: WebViewNavigator, + rejectExample: (Boolean) -> Unit, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + awaitFinished(state, "interceptor page ready") + rejectExample(true) + delay(100) + val urlBefore = state.lastLoadedUrl + navigator.loadUrl("https://example.com/") + delay(1500) + val marker = + runCatching { + awaitJsString( + navigator, + "document.getElementById('marker') ? document.getElementById('marker').textContent : 'missing'", + ) + }.getOrDefault("missing") + assertTrue( + marker.contains("hello-e2e") || marker.contains("page-"), + "expected still on e2e page after reject, marker=$marker lastUrl=${state.lastLoadedUrl} before=$urlBefore", + ) + rejectExample(false) + println("[e2e] request interceptor reject OK") + onDone() + } catch (t: Throwable) { + onFailure("interceptor: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt new file mode 100644 index 0000000..cf80282 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt @@ -0,0 +1,40 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertTrue +import kotlinx.coroutines.delay + +@Composable +internal fun JsBridgeDriver( + state: WebViewState, + navigator: WebViewNavigator, + bridgeHits: CopyOnWriteArrayList, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + navigator.loadHtml(E2E_PAGE_HTML, baseUrl = "https://e2e.local/") + awaitFinished(state, "bridge page finished") + delay(400) + bridgeHits.clear() + navigator.evaluateJavaScript( + """ + window.kmpJsBridge.callNative('e2eEcho', JSON.stringify({ping:1}), function(r){}); + """.trimIndent(), + ) + awaitUntil("js bridge message received") { bridgeHits.isNotEmpty() } + assertTrue(bridgeHits.first().contains("ping"), "unexpected bridge payload: $bridgeHits") + println("[e2e] JS bridge OK") + onDone() + } catch (t: Throwable) { + onFailure("jsBridge: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt new file mode 100644 index 0000000..1399e6d --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt @@ -0,0 +1,41 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Composable +internal fun LoadHtmlDriver( + state: WebViewState, + navigator: WebViewNavigator, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + awaitFinished(state) + val title = awaitJsString(navigator, "document.title") + assertTrue(title.contains("E2E"), "expected title to contain E2E, got: $title") + val marker = awaitJsString(navigator, "document.getElementById('marker').textContent") + assertEquals("hello-e2e", marker.trim('"')) + val url = state.lastLoadedUrl + assertNotNull(url, "lastLoadedUrl should be set") + assertTrue( + url.contains("e2e.local") || url.startsWith("data:") || url == "about:blank" || + state.webView?.nativeWebView is LinuxWebKitNativeWebView, + "unexpected url=$url", + ) + println("[e2e] loadHtml + title + marker OK") + onDone() + } catch (t: Throwable) { + onFailure("loadHtml: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt new file mode 100644 index 0000000..5fa04dc --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt @@ -0,0 +1,52 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState + +@Composable +internal fun NavigationDriver( + state: WebViewState, + navigator: WebViewNavigator, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + navigator.loadHtml(E2E_PAGE_A, baseUrl = "https://e2e.local/a") + awaitUntil("page A loaded") { + state.loadingState is LoadingState.Finished && + awaitJsString(navigator, "document.getElementById('marker').textContent") + .contains("page-a") + } + navigator.loadHtml(E2E_PAGE_B, baseUrl = "https://e2e.local/b") + awaitUntil("page B loaded") { + state.loadingState is LoadingState.Finished && + awaitJsString(navigator, "document.getElementById('marker').textContent") + .contains("page-b") + } + awaitUntil("canGoBack after B") { navigator.canGoBack } + navigator.navigateBack() + awaitUntil("back to A") { + awaitJsString(navigator, "document.getElementById('marker').textContent") + .contains("page-a") + } + awaitUntil("canGoForward after back") { navigator.canGoForward } + navigator.navigateForward() + awaitUntil("forward to B") { + awaitJsString(navigator, "document.getElementById('marker').textContent") + .contains("page-b") + } + navigator.reload() + awaitFinished(state, "reload finished") + println("[e2e] navigation back/forward/reload OK") + onDone() + } catch (t: Throwable) { + onFailure("navigation: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt new file mode 100644 index 0000000..0e99625 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt @@ -0,0 +1,41 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.toAwtImage +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.delay + +@Composable +internal fun ScreenshotDriver( + state: WebViewState, + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + state.webView?.loadHtml(E2E_PAGE_HTML, "https://e2e.local/") + awaitFinished(state, "screenshot page finished") + delay(500) + val bytes = state.webView?.captureScreenshotOrNull() + assertNotNull(bytes, "screenshot bytes null") + assertTrue(bytes.size > 100, "screenshot too small: ${bytes.size}") + assertEquals(0x89.toByte(), bytes[0]) + assertEquals('P'.code.toByte(), bytes[1]) + assertEquals('N'.code.toByte(), bytes[2]) + assertEquals('G'.code.toByte(), bytes[3]) + val awt = state.webView?.toAwtImage() + assertNotNull(awt, "awt image null") + assertTrue(awt.width > 0 && awt.height > 0, "awt image empty") + println("[e2e] screenshot OK (${bytes.size} bytes, ${awt.width}x${awt.height})") + onDone() + } catch (t: Throwable) { + onFailure("screenshot: ${t.message}") + onDone() + } + } +} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt new file mode 100644 index 0000000..b55e9d4 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt @@ -0,0 +1,52 @@ +package dev.nucleusframework.webview.web.e2e + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewState +import kotlin.test.assertTrue +import kotlinx.coroutines.delay + +@Composable +internal fun UserAgentDriver( + onFailure: (String) -> Unit, + onDone: () -> Unit, +) { + val scope = rememberCoroutineScope() + val navigator = rememberWebViewNavigator(coroutineScope = scope) + val state = + rememberWebViewState("about:blank") { + customUserAgentString = "ComposeNativeWebView-E2E/1.0" + } + + WebView(state = state, navigator = navigator, modifier = Modifier.fillMaxSize()) + + LaunchedEffect(Unit) { + try { + awaitWebViewReady(state) + navigator.loadHtml( + html = + """ + UA +

+                    
+                    
+                    """.trimIndent(),
+                baseUrl = "https://e2e.local/ua",
+            )
+            awaitFinished(state, "ua page finished")
+            delay(200)
+            val ua = awaitJsString(navigator, "navigator.userAgent")
+            assertTrue(ua.contains("ComposeNativeWebView-E2E/1.0"), "custom UA not applied: $ua")
+            println("[e2e] custom user-agent OK")
+            onDone()
+        } catch (t: Throwable) {
+            onFailure("userAgent: ${t.message}")
+            onDone()
+        }
+    }
+}

From 109c524d393d3540d9135d0f3a7d7a10acb549cf Mon Sep 17 00:00:00 2001
From: "Elie G." 
Date: Thu, 6 Aug 2026 16:43:34 +0300
Subject: [PATCH 03/10] feat(windows): add WebView2 desktop backend with visual
 e2e suite

---
 .github/workflows/build-natives.yaml          |  58 ++-
 .github/workflows/pr-build-check.yml          |   2 +
 .github/workflows/publish-on-maven.yml        |   2 +
 .gitignore                                    |   7 +
 AGENTS.md                                     |  18 +-
 README.md                                     |   9 +-
 .../webview/demo/visualsuite/ReportWriter.kt  |  13 +-
 .../webview/demo/visualsuite/SuiteHelpers.kt  |  14 +-
 .../webview/demo/visualsuite/SuiteRunner.kt   |  26 +-
 .../demo/visualsuite/VisualSuiteApp.kt        |   3 +-
 .../webview/demo/WebViewTest.kt               |   3 +-
 webview-compose/README.md                     |   2 +-
 webview-compose/build.gradle.kts              |  33 +-
 .../nucleusframework/webview/web/WebView.kt   |   7 +-
 .../webview/cookie/DesktopCookieManager.kt    |  95 ++--
 .../webview/web/DesktopWebView.kt             |  29 +-
 .../webview/web/NativeWebView.desktop.kt      |   7 +-
 .../webview/web/WebViewDesktop.kt             | 137 +++--
 .../web/linux/LinuxWebKitNativeWebView.kt     |   3 +-
 .../web/windows/WebView2WindowsBridge.kt      | 223 ++++++++
 .../windows/WindowsWebView2NativeWebView.kt   | 252 +++++++++
 .../src/jvmMain/native/windows/build.bat      | 183 +++++++
 .../native/windows/compose_webview_internal.h | 119 +++++
 .../src/jvmMain/native/windows/cookies.cpp    | 167 ++++++
 .../src/jvmMain/native/windows/javascript.cpp |  31 ++
 .../src/jvmMain/native/windows/jni_bridge.cpp | 120 +++++
 .../src/jvmMain/native/windows/navigation.cpp | 227 +++++++++
 .../src/jvmMain/native/windows/screenshot.cpp |  85 ++++
 .../src/jvmMain/native/windows/view_input.cpp | 154 ++++++
 .../jvmMain/native/windows/view_lifecycle.cpp | 478 ++++++++++++++++++
 .../jvmMain/native/windows/view_signals.cpp   | 155 ++++++
 .../composewebview/native-image.properties    |  10 +-
 .../composewebview/reachability-metadata.json |  29 ++
 ...iewE2eTest.kt => DesktopWebViewE2eTest.kt} |  48 +-
 .../webview/web/e2e/E2eHelpers.kt             |  10 +-
 .../webview/web/e2e/LoadHtmlDriver.kt         |   9 +-
 36 files changed, 2616 insertions(+), 152 deletions(-)
 create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt
 create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt
 create mode 100644 webview-compose/src/jvmMain/native/windows/build.bat
 create mode 100644 webview-compose/src/jvmMain/native/windows/compose_webview_internal.h
 create mode 100644 webview-compose/src/jvmMain/native/windows/cookies.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/javascript.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/jni_bridge.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/navigation.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/screenshot.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/view_input.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp
 create mode 100644 webview-compose/src/jvmMain/native/windows/view_signals.cpp
 rename webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/{LinuxWebViewE2eTest.kt => DesktopWebViewE2eTest.kt} (66%)

diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml
index d474506..6986461 100644
--- a/.github/workflows/build-natives.yaml
+++ b/.github/workflows/build-natives.yaml
@@ -3,13 +3,13 @@ name: Build Native Libraries
 on:
   workflow_call:
 
-# Native binaries are a pure function of the C sources. Each platform job caches
-# its output tree keyed on a hash of every src/**/native/** file (plus this
-# workflow). On a cache hit the compile steps are skipped and the job only
+# Native binaries are a pure function of the C/C++ sources. Each platform job
+# caches its output tree keyed on a hash of every src/**/native/** file (plus
+# this workflow). On a cache hit the compile steps are skipped and the job only
 # verifies + re-uploads the artifact for downstream consumers.
 #
-# Currently only Linux is built (WebKit2GTK backend). Windows/macOS stay no-op
-# until their native backends land โ€” add matrix jobs then.
+# Platforms: Linux (WebKit2GTK) + Windows (WebView2). macOS stays no-op until
+# the WKWebView backend lands.
 #
 # Bump the `natives-v1` prefix to force a full rebuild.
 
@@ -85,3 +85,51 @@ jobs:
           name: natives-linux-${{ matrix.arch }}
           path: 'webview-compose/src/jvmMain/resources/nucleus/native/linux-*/'
           retention-days: 1
+
+  windows:
+    runs-on: windows-latest
+    steps:
+      - name: Checkout Repo
+        uses: actions/checkout@v4
+
+      - name: Restore native output cache
+        id: natives-cache
+        uses: actions/cache@v4
+        with:
+          path: 'webview-compose/src/jvmMain/resources/nucleus/native/win32-*'
+          key: natives-v1-windows-x64-${{ hashFiles('webview-compose/src/jvmMain/native/**', '.github/workflows/build-natives.yaml') }}
+
+      - name: Setup JDK 21
+        if: steps.natives-cache.outputs.cache-hit != 'true'
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '21'
+
+      - name: Build compose WebView Windows native library
+        if: steps.natives-cache.outputs.cache-hit != 'true'
+        env:
+          JAVA_HOME: ${{ env.JAVA_HOME }}
+        shell: cmd
+        run: webview-compose\src\jvmMain\native\windows\build.bat
+
+      - name: Verify Windows natives
+        shell: bash
+        run: |
+          f="webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
+          loader="webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
+          if [ -f "$f" ] && [ -f "$loader" ]; then
+            echo "OK: $f ($(wc -c < "$f") bytes)"
+            echo "OK: $loader ($(wc -c < "$loader") bytes)"
+          else
+            echo "MISSING: $f and/or $loader" >&2
+            find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true
+            exit 1
+          fi
+
+      - name: Upload Windows natives
+        uses: actions/upload-artifact@v4
+        with:
+          name: natives-windows-x64
+          path: 'webview-compose/src/jvmMain/resources/nucleus/native/win32-*/'
+          retention-days: 1
diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index 22d7677..b28f579 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -32,6 +32,8 @@ jobs:
           EXPECTED=(
             "webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so"
             "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so"
+            "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
+            "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
           )
           MISSING=0
           for f in "${EXPECTED[@]}"; do
diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml
index 1c4fc91..73e9ed4 100644
--- a/.github/workflows/publish-on-maven.yml
+++ b/.github/workflows/publish-on-maven.yml
@@ -28,6 +28,8 @@ jobs:
           EXPECTED=(
             "webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so"
             "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so"
+            "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
+            "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
           )
           for f in "${EXPECTED[@]}"; do
             test -f "$f" || { echo "MISSING: $f" >&2; exit 1; }
diff --git a/.gitignore b/.gitignore
index d4399b2..b52178a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,13 @@ captures/
 # Same layout as Nucleus: resources/nucleus/native/{linux,darwin,win32}-*
 **/src/**/resources/nucleus/native/
 
+# Windows WebView2 SDK bootstrap (nuget + packages/) โ€” not vendored.
+**/src/**/native/windows/nuget.exe
+**/src/**/native/windows/packages/
+**/src/**/native/windows/*.obj
+**/src/**/native/windows/*.lib
+**/src/**/native/windows/*.exp
+
 # Node
 node_modules/
 
diff --git a/AGENTS.md b/AGENTS.md
index fd21e59..ddb0af8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,7 +6,8 @@
 - `webview-compose/`: Compose Multiplatform WebView library exposing `dev.nucleusframework.webview.*`
   (`WebView`, `WebViewState`, `WebViewNavigator`).
   - Shared API/types: `webview-compose/src/commonMain/kotlin/...`.
-  - Platform actuals: `.../src/jvmMain/` (desktop no-op), `.../src/androidMain/` (Android WebView),
+  - Platform actuals: `.../src/jvmMain/` (desktop: Linux WebKit2GTK + Windows WebView2),
+    `.../src/androidMain/` (Android WebView),
     `.../src/iosMain/` (WKWebView + cinterop in `.../src/nativeInterop/`), `.../src/wasmJsMain/` (IFrame).
 - `webview-compose-test/`: JVM testing helpers (Playwright-backed mock WebView).
 - Generated/build outputs live under `*/build/` (donโ€™t edit or commit).
@@ -15,16 +16,21 @@
 
 - `./gradlew build`: builds all modules.
 - `./gradlew :demo:run`: runs the desktop demo via the Nucleus application plugin
-  (Tao backend + WebKit2GTK on Linux; visual e2e suite entrypoint).
+  (Tao backend + WebKit2GTK on Linux / WebView2 on Windows; visual e2e suite entrypoint).
 - `./gradlew :webview-compose:buildNativeLinux`: builds
   `libcompose_webview_linux.so` for the host arch into
   `webview-compose/src/jvmMain/resources/nucleus/native/linux-{x64,aarch64}/`
   (requires `libwebkit2gtk-4.1-dev` + `libgtk-3-dev` + JDK).
   Or: `bash webview-compose/src/jvmMain/native/linux/build.sh`.
+- `./gradlew :webview-compose:buildNativeWindows`: builds
+  `compose_webview_windows.dll` + `WebView2Loader.dll` into
+  `webview-compose/src/jvmMain/resources/nucleus/native/win32-{x64,aarch64}/`
+  (requires MSVC, JDK with `JAVA_HOME`, network for first-run nuget WebView2 SDK).
+  Or: `webview-compose\src\jvmMain\native\windows\build.bat`.
 - CI: `.github/workflows/build-natives.yaml` matrix builds **linux-x64**
-  (`ubuntu-latest`) and **linux-aarch64** (`ubuntu-24.04-arm`), uploads
-  artifacts; PR/publish workflows download them before compile/publish.
-  Natives are **not** committed (see `.gitignore`).
+  (`ubuntu-latest`), **linux-aarch64** (`ubuntu-24.04-arm`), and **windows-x64**
+  (`windows-latest`), uploads artifacts; PR/publish workflows download them
+  before compile/publish. Natives are **not** committed (see `.gitignore`).
 - GraalVM (demo): `nucleus.application { graalvm { isEnabled = true โ€ฆ } }`.
   Library reachability metadata lives under
   `webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/`
@@ -51,4 +57,4 @@
 
 ## Security & Configuration Tips
 
-- Platform builds may require system deps (Android SDK, Xcode for iOS); call out any new requirements in the PR description.
+- Platform builds may require system deps (Android SDK, Xcode for iOS, WebView2 Runtime on Windows); call out any new requirements in the PR description.
diff --git a/README.md b/README.md
index 5fcc13a..95c5266 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ If you already know **compose-webview-multiplatform**, you already know how to u
 
 * Multiplatform packaging under NucleusFramework (`dev.nucleusframework`)
 * **WasmJs** target via **IFrame**
-* Desktop (JVM) via **Nucleus Tao + NativeView** (Linux WebKit2GTK; Windows/macOS no-op for now)
+* Desktop (JVM) via **Nucleus Tao + NativeView** (Linux WebKit2GTK; Windows WebView2; macOS no-op for now)
 
 ---
 
@@ -33,7 +33,10 @@ If you already know **compose-webview-multiplatform**, you already know how to u
 - **Android**: `android.webkit.WebView`
 - **iOS**: `WKWebView`
 - **WasmJs**: `org.w3c.dom.HTMLIFrameElement`
-- **Desktop**: Nucleus Tao `NativeView` + WebKit2GTK on Linux (requires `nucleusApplication` / Tao backend). Windows/macOS stay no-op until their backends land.
+- **Desktop**: Nucleus Tao `NativeView` (requires `nucleusApplication` / Tao backend).
+  - **Linux**: WebKit2GTK (`libcompose_webview_linux.so`)
+  - **Windows**: WebView2 CompositionController + DirectComposition (`compose_webview_windows.dll`; needs WebView2 Runtime / Edge)
+  - **macOS**: no-op until the WKWebView backend lands
 
 ---
 
@@ -210,7 +213,7 @@ state.webSettings.logSeverity = KLogSeverity.Debug
 ## Limitations
 
 * RequestInterceptor does **not** intercept sub-resources
-* **Desktop**: requires Nucleus Tao (`nucleusApplication` + `decorated-window-tao`). Linux is fully wired (WebKit2GTK); Windows/macOS are still no-op.
+* **Desktop**: requires Nucleus Tao (`nucleusApplication` + `decorated-window-tao`). Linux (WebKit2GTK) and Windows (WebView2) are fully wired; macOS is still no-op.
 * **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
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt
index b728acc..a1d55d9 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt
@@ -2,12 +2,14 @@ package dev.nucleusframework.webview.demo.visualsuite
 
 import java.io.File
 
-internal const val DEFAULT_REPORT_PATH =
-    "/tmp/composewebview-visual-suite-report.txt"
+internal fun defaultReportPath(): String {
+    val dir = System.getProperty("java.io.tmpdir")?.trimEnd('/', '\\') ?: "."
+    return "$dir${File.separator}composewebview-visual-suite-report.txt"
+}
 
 internal fun writeSuiteReport(
     report: SuiteReport,
-    path: String = System.getenv("COMPOSEWEBVIEW_SUITE_REPORT") ?: DEFAULT_REPORT_PATH,
+    path: String = System.getenv("COMPOSEWEBVIEW_SUITE_REPORT") ?: defaultReportPath(),
 ): String {
     val duration = report.finishedAtMs - report.startedAtMs
     val body =
@@ -33,7 +35,10 @@ internal fun writeSuiteReport(
                 }
             }
         }
-    File(path).writeText(body)
+    File(path).apply {
+        parentFile?.mkdirs()
+        writeText(body)
+    }
     // Also mirror to stdout for CI logs
     println(body)
     return path
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
index e204d28..9f6eb13 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
@@ -1,8 +1,11 @@
 package dev.nucleusframework.webview.demo.visualsuite
 
 import dev.nucleusframework.webview.web.LoadingState
+import dev.nucleusframework.webview.web.NativeWebView
 import dev.nucleusframework.webview.web.WebViewNavigator
 import dev.nucleusframework.webview.web.WebViewState
+import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.withTimeout
@@ -60,12 +63,19 @@ internal suspend fun evalJsUnquoted(
     timeoutMs: Long = 12_000,
 ): String = unquoteJs(evalJs(navigator, script, timeoutMs))
 
+internal fun NativeWebView?.isLiveDesktopBackend(): Boolean =
+    this != null &&
+        isReady() &&
+        (this is LinuxWebKitNativeWebView || this is WindowsWebView2NativeWebView)
+
 internal suspend fun waitWebView(
     state: WebViewState,
-    timeoutMs: Long = 15_000,
+    timeoutMs: Long = 20_000,
 ) {
     withTimeout(timeoutMs) {
-        while (state.webView == null) delay(40)
+        // Windows recreates the native view once LocalTaoWindow HWND is ready โ€”
+        // wait for a live backend, not just a non-null DesktopWebView shell.
+        while (!state.webView?.nativeWebView.isLiveDesktopBackend()) delay(40)
     }
 }
 
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt
index 1afd258..2b51386 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt
@@ -3,10 +3,12 @@ package dev.nucleusframework.webview.demo.visualsuite
 import composewebview.demo_shared.generated.resources.Res
 import dev.nucleusframework.webview.cookie.Cookie
 import dev.nucleusframework.webview.web.LoadingState
+import dev.nucleusframework.webview.web.NativeWebView
 import dev.nucleusframework.webview.web.WebContent
 import dev.nucleusframework.webview.web.WebViewFileReadType
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
 import dev.nucleusframework.webview.web.toAwtImage
+import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.coroutineScope
@@ -337,11 +339,21 @@ internal suspend fun runFullSuite(
         }
     }
     case("S03") {
-        val native = ctx.state.webView?.nativeWebView as? LinuxWebKitNativeWebView
-        assertThat(native != null, "not linux webview")
-        native!!.setZoomLevel(1.25)
-        delay(80)
-        native.setZoomLevel(1.0)
+        val native = ctx.state.webView?.nativeWebView
+        assertThat(native != null && native.isReady(), "native not ready")
+        when (native) {
+            is LinuxWebKitNativeWebView -> {
+                native.setZoomLevel(1.25)
+                delay(80)
+                native.setZoomLevel(1.0)
+            }
+            is WindowsWebView2NativeWebView -> {
+                native.setZoomLevel(1.25)
+                delay(80)
+                native.setZoomLevel(1.0)
+            }
+            else -> error("unsupported desktop native: ${native!!::class.simpleName}")
+        }
     }
     case("S04") {
         loadHtmlAwaitMarker(ctx.navigator, "white-bg", pageSolidColor("#ffffff").let {
@@ -406,8 +418,8 @@ internal suspend fun runFullSuite(
         assertThat(ctx.getOnCreatedFired(), "onCreated never fired")
     }
     case("L02") {
-        val native = ctx.state.webView?.nativeWebView as? LinuxWebKitNativeWebView
-        assertThat(native != null && native.isReady(), "not ready")
+        val native = ctx.state.webView?.nativeWebView
+        assertThat(native.isLiveDesktopBackend(), "not ready: ${native?.let { it::class.simpleName }}")
     }
     case("L03") {
         ctx.state.webView?.nativeWebView?.focus()
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt
index c845368..6cca08c 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt
@@ -57,7 +57,8 @@ private val TextMain = Color(0xFFE8EEF9)
 private val TextDim = Color(0xFF93A0B8)
 
 /**
- * Full visual e2e suite: real Tao window, real WebKit, every major API path.
+ * Full visual e2e suite: real Tao window, real desktop WebView
+ * (WebKit2GTK on Linux / WebView2 on Windows), every major API path.
  * Writes a machine-readable report and calls [onFinished] with success flag.
  */
 @Composable
diff --git a/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
index fd5fd5e..5e14aad 100644
--- a/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
+++ b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
@@ -2,7 +2,8 @@ package dev.nucleusframework.webview.demo
 
 /**
  * Desktop e2e coverage lives in
- * `:webview-compose` โ†’ `LinuxWebViewE2eTest` (real WebKit2GTK + Tao/NativeView).
+ * `:webview-compose` โ†’ `DesktopWebViewE2eTest`
+ * (real WebKit2GTK / WebView2 + Tao/NativeView).
  *
  * Synthetic Playwright tests were removed; run:
  * `./gradlew :webview-compose:jvmTest`
diff --git a/webview-compose/README.md b/webview-compose/README.md
index 883e4e3..c4a7acc 100644
--- a/webview-compose/README.md
+++ b/webview-compose/README.md
@@ -31,4 +31,4 @@ fun App() {
 - **Android**: `android.webkit.WebView`
 - **iOS**: `WKWebView`
 - **WasmJs**: `HTMLIFrameElement`
-- **Desktop (JVM)**: currently a no-op stub (API available, no rendering)
+- **Desktop (JVM)**: Nucleus Tao `NativeView` โ€” WebKit2GTK (Linux), WebView2 (Windows); macOS no-op for now
diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts
index b683681..59ae49e 100644
--- a/webview-compose/build.gradle.kts
+++ b/webview-compose/build.gradle.kts
@@ -20,13 +20,14 @@ tasks.withType().configureEach {
     maxParallelForks = 1
 }
 
-// โ”€โ”€ Native build (Linux WebKit2GTK) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
-// Same pattern as Nucleus: compile host-arch .so into
-// src/jvmMain/resources/nucleus/native/linux-{x64,aarch64}/.
-// CI builds both arches via matrix and downloads artifacts before package/publish.
-// Locally, only the host arch is built (and only if the .so is missing).
+// โ”€โ”€ Native build (Linux WebKit2GTK / Windows WebView2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// Same pattern as Nucleus: compile host-arch natives into
+// src/jvmMain/resources/nucleus/native/{linux,win32}-{x64,aarch64}/.
+// CI builds via matrix and downloads artifacts before package/publish.
+// Locally, only the host arch is built (and only if the artifact is missing).
 
 val nativeLinuxDir = layout.projectDirectory.dir("src/jvmMain/native/linux")
+val nativeWindowsDir = layout.projectDirectory.dir("src/jvmMain/native/windows")
 val nativeResourceDir = layout.projectDirectory.dir("src/jvmMain/resources/nucleus/native")
 
 val buildNativeLinux by tasks.registering(Exec::class) {
@@ -47,14 +48,30 @@ val buildNativeLinux by tasks.registering(Exec::class) {
     commandLine("bash", "build.sh")
 }
 
-// Ensure JVM resources include the native lib when packaging on Linux hosts.
+val buildNativeWindows by tasks.registering(Exec::class) {
+    description = "Compiles the WebView2 JNI backend into compose_webview_windows.dll"
+    group = "build"
+    val arch = System.getProperty("os.arch").lowercase()
+    val archDir =
+        if (arch.contains("aarch64") || arch.contains("arm64")) "win32-aarch64" else "win32-x64"
+    val checkFile = nativeResourceDir.file("$archDir/compose_webview_windows.dll").asFile
+    onlyIf {
+        Os.isFamily(Os.FAMILY_WINDOWS) && !checkFile.exists()
+    }
+    inputs.dir(nativeWindowsDir)
+    outputs.file(checkFile)
+    workingDir(nativeWindowsDir.asFile)
+    commandLine("cmd", "/c", "build.bat")
+}
+
+// Ensure JVM resources include the native lib when packaging on host OS.
 tasks.matching { it.name == "jvmProcessResources" || it.name == "processJvmMainResources" }.configureEach {
-    dependsOn(buildNativeLinux)
+    dependsOn(buildNativeLinux, buildNativeWindows)
 }
 
 tasks.configureEach {
     if (name == "sourcesJar" || name == "jvmSourcesJar") {
-        dependsOn(buildNativeLinux)
+        dependsOn(buildNativeLinux, buildNativeWindows)
     }
 }
 
diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt
index ea89757..c51268d 100644
--- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt
+++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt
@@ -42,10 +42,11 @@ fun WebView(
                 val lastLoadedUrlFlow =
                     snapshotFlow { state.lastLoadedUrl }.filter { !it.isNullOrEmpty() }
 
+                // Inject on Finished *or* URL change. Gating only on Finished misses
+                // navigations that never leave Finished in the Compose poller
+                // (e.g. very fast data: loads on WebView2).
                 merge(loadingStateFlow, lastLoadedUrlFlow).collect {
-                    if (state.loadingState is LoadingState.Finished) {
-                        wv.injectJsBridge()
-                    }
+                    wv.injectJsBridge()
                 }
             }
         }
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt
index 1b41cba..d2f0e29 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt
@@ -3,6 +3,7 @@ package dev.nucleusframework.webview.cookie
 import dev.nucleusframework.webview.util.KLogger
 import dev.nucleusframework.webview.web.NativeWebView
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import kotlinx.serialization.SerialName
@@ -10,8 +11,8 @@ import kotlinx.serialization.Serializable
 import kotlinx.serialization.json.Json
 
 /**
- * Desktop cookie manager. On Linux, backed by WebKit2GTK's cookie manager.
- * No-op on other desktop platforms.
+ * Desktop cookie manager. Backed by WebKit2GTK on Linux and WebView2 on
+ * Windows. No-op on other desktop platforms.
  */
 internal class DesktopCookieManager : CookieManager {
     @Volatile
@@ -27,37 +28,59 @@ internal class DesktopCookieManager : CookieManager {
         this.nativeWebView = webView
     }
 
+    private fun sameSiteString(cookie: Cookie): String? =
+        when (cookie.sameSite) {
+            Cookie.HTTPCookieSameSitePolicy.NONE -> "None"
+            Cookie.HTTPCookieSameSitePolicy.STRICT -> "Strict"
+            Cookie.HTTPCookieSameSitePolicy.LAX -> "Lax"
+            null -> null
+        }
+
     override suspend fun setCookie(url: String, cookie: Cookie) {
-        val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return
+        val native = nativeWebView
+        val domain =
+            cookie.domain
+                ?: runCatching { java.net.URI(url).host }.getOrNull()
         withContext(Dispatchers.Main) {
             KLogger.d(tag = "DesktopCookieManager") { "setCookie url=$url name=${cookie.name}" }
-            val domain =
-                cookie.domain
-                    ?: runCatching { java.net.URI(url).host }.getOrNull()
-            linux.setCookieNative(
-                name = cookie.name,
-                value = cookie.value,
-                domain = domain,
-                path = cookie.path ?: "/",
-                secure = cookie.isSecure == true,
-                httpOnly = cookie.isHttpOnly == true,
-                expiresMs = cookie.expiresDate ?: 0L,
-                sameSite =
-                    when (cookie.sameSite) {
-                        Cookie.HTTPCookieSameSitePolicy.NONE -> "None"
-                        Cookie.HTTPCookieSameSitePolicy.STRICT -> "Strict"
-                        Cookie.HTTPCookieSameSitePolicy.LAX -> "Lax"
-                        null -> null
-                    },
-            )
+            when (native) {
+                is LinuxWebKitNativeWebView ->
+                    native.setCookieNative(
+                        name = cookie.name,
+                        value = cookie.value,
+                        domain = domain,
+                        path = cookie.path ?: "/",
+                        secure = cookie.isSecure == true,
+                        httpOnly = cookie.isHttpOnly == true,
+                        expiresMs = cookie.expiresDate ?: 0L,
+                        sameSite = sameSiteString(cookie),
+                    )
+                is WindowsWebView2NativeWebView ->
+                    native.setCookieNative(
+                        name = cookie.name,
+                        value = cookie.value,
+                        domain = domain,
+                        path = cookie.path ?: "/",
+                        secure = cookie.isSecure == true,
+                        httpOnly = cookie.isHttpOnly == true,
+                        expiresMs = cookie.expiresDate ?: 0L,
+                        sameSite = sameSiteString(cookie),
+                    )
+                else -> Unit
+            }
         }
     }
 
     override suspend fun getCookies(url: String): List {
-        val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return emptyList()
+        val native = nativeWebView
         return withContext(Dispatchers.Main) {
             runCatching {
-                val raw = linux.getCookiesJson(url)
+                val raw =
+                    when (native) {
+                        is LinuxWebKitNativeWebView -> native.getCookiesJson(url)
+                        is WindowsWebView2NativeWebView -> native.getCookiesJson(url)
+                        else -> return@withContext emptyList()
+                    }
                 json.decodeFromString>(raw).map { it.toCookie() }
             }.getOrElse {
                 KLogger.e(it, tag = "DesktopCookieManager") { "getCookies failed url=$url" }
@@ -67,20 +90,30 @@ internal class DesktopCookieManager : CookieManager {
     }
 
     override suspend fun removeAllCookies() {
-        val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return
+        val native = nativeWebView
         withContext(Dispatchers.Main) {
-            runCatching { linux.removeAllCookiesNative() }
-                .onFailure { KLogger.e(it, tag = "DesktopCookieManager") { "removeAllCookies failed" } }
+            runCatching {
+                when (native) {
+                    is LinuxWebKitNativeWebView -> native.removeAllCookiesNative()
+                    is WindowsWebView2NativeWebView -> native.removeAllCookiesNative()
+                    else -> Unit
+                }
+            }.onFailure { KLogger.e(it, tag = "DesktopCookieManager") { "removeAllCookies failed" } }
         }
     }
 
     override suspend fun removeCookies(url: String) {
-        val linux = nativeWebView as? LinuxWebKitNativeWebView ?: return
+        val native = nativeWebView
         withContext(Dispatchers.Main) {
-            runCatching { linux.removeCookiesForUrlNative(url) }
-                .onFailure {
-                    KLogger.e(it, tag = "DesktopCookieManager") { "removeCookies failed url=$url" }
+            runCatching {
+                when (native) {
+                    is LinuxWebKitNativeWebView -> native.removeCookiesForUrlNative(url)
+                    is WindowsWebView2NativeWebView -> native.removeCookiesForUrlNative(url)
+                    else -> Unit
                 }
+            }.onFailure {
+                KLogger.e(it, tag = "DesktopCookieManager") { "removeCookies failed url=$url" }
+            }
         }
     }
 }
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt
index b827a8c..8e8ec9c 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt
@@ -3,14 +3,16 @@ package dev.nucleusframework.webview.web
 import dev.nucleusframework.webview.jsbridge.WebViewJsBridge
 import dev.nucleusframework.webview.util.KLogger
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import java.net.URL
 import kotlinx.coroutines.CoroutineScope
 
 /**
  * Desktop [IWebView] implementation.
  *
- * On Linux (WebKit2GTK via Tao/NativeView) all operations hit the real
- * native backend. On other desktop OSes this remains a no-op shell.
+ * On Linux (WebKit2GTK) and Windows (WebView2) via Tao/NativeView all
+ * operations hit the real native backend. On other desktop OSes this
+ * remains a no-op shell.
  */
 internal class DesktopWebView(
     override val nativeWebView: NativeWebView,
@@ -40,11 +42,10 @@ internal class DesktopWebView(
         historyUrl: String?,
     ) {
         if (html == null) return
-        val linux = nativeWebView as? LinuxWebKitNativeWebView
-        if (linux != null) {
-            linux.loadHtml(html, baseUrl)
-        } else {
-            nativeWebView.loadHtml(html)
+        when (val native = nativeWebView) {
+            is LinuxWebKitNativeWebView -> native.loadHtml(html, baseUrl)
+            is WindowsWebView2NativeWebView -> native.loadHtml(html, baseUrl)
+            else -> nativeWebView.loadHtml(html)
         }
     }
 
@@ -119,13 +120,11 @@ internal class DesktopWebView(
     }
 
     override suspend fun captureScreenshotOrNull(): ByteArray? {
-        val linux = nativeWebView as? LinuxWebKitNativeWebView
-        if (linux != null) {
-            return linux.captureScreenshotAsync()
+        when (val native = nativeWebView) {
+            is LinuxWebKitNativeWebView -> return native.captureScreenshotAsync()
+            is WindowsWebView2NativeWebView -> return native.captureScreenshotAsync()
         }
-        val nativeBytes = nativeWebView.captureScreenshotNative()
-        if (nativeBytes != null) return nativeBytes
-        return null
+        return nativeWebView.captureScreenshotNative()
     }
 
     override fun injectJsBridge() {
@@ -144,6 +143,8 @@ internal class DesktopWebView(
     }
 
     override fun initJsBridge(webViewJsBridge: WebViewJsBridge) {
-        // IPC is wired natively via WebKit user-content script message handler "ipc".
+        // IPC is wired natively:
+        //  - Linux: WebKit user-content script message handler "ipc"
+        //  - Windows: window.ipc -> chrome.webview.postMessage
     }
 }
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt
index 42f17f7..bbc9690 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt
@@ -3,9 +3,10 @@ package dev.nucleusframework.webview.web
 /**
  * Desktop [NativeWebView] base.
  *
- * On Linux with the Tao backend, the default factory creates a
- * [dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView].
- * Windows and macOS remain no-ops until their native backends land.
+ * With the Tao backend, the default factory creates:
+ *  - [dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView] on Linux
+ *  - [dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView] on Windows
+ * macOS remains a no-op until the WKWebView backend lands.
  */
 actual open class NativeWebView {
     open fun isReady(): Boolean = false
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt
index 66210e6..98fca91 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt
@@ -17,6 +17,9 @@ import dev.nucleusframework.webview.request.WebRequest
 import dev.nucleusframework.webview.request.WebRequestInterceptResult
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
 import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge
+import dev.nucleusframework.webview.web.windows.WebView2WindowsBridge
+import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
+import dev.nucleusframework.window.tao.LocalTaoWindow
 import dev.nucleusframework.window.tao.NativeView
 import java.awt.image.BufferedImage
 import java.io.ByteArrayInputStream
@@ -29,24 +32,27 @@ import kotlin.time.Duration.Companion.milliseconds
 actual class WebViewFactoryParam(
     val state: WebViewState,
     val fileContent: String = "",
+    /** Windows only: parent Tao HWND. Required to create a real WebView2. */
+    val parentHwnd: Long = 0L,
 )
 
 /**
- * Default factory: real WebKit2GTK on Linux when the native lib loads;
- * no-op placeholder on Windows / macOS (or when the lib is missing).
+ * Default factory: real WebKit2GTK on Linux / WebView2 on Windows when the
+ * native lib loads and (Windows) a parent HWND is available; no-op on macOS
+ * or when the lib / HWND is missing.
  */
 actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView {
+    val settings = param.state.webSettings
+    val desktop = settings.desktopWebSettings
+    val background =
+        if (desktop.transparent) {
+            settings.backgroundColor
+        } else {
+            val c = settings.backgroundColor
+            if (c.alpha < 1f) androidx.compose.ui.graphics.Color.White else c.copy(alpha = 1f)
+        }
+
     if (Platform.Current == Platform.Linux && WebKitLinuxBridge.isLoaded) {
-        val settings = param.state.webSettings
-        val desktop = settings.desktopWebSettings
-        // Opaque white by default โ€” transparent only when explicitly requested.
-        val background =
-            if (desktop.transparent) {
-                settings.backgroundColor
-            } else {
-                val c = settings.backgroundColor
-                if (c.alpha < 1f) androidx.compose.ui.graphics.Color.White else c.copy(alpha = 1f)
-            }
         return LinuxWebKitNativeWebView(
             customUserAgent = settings.customUserAgentString,
             dataDirectory = desktop.dataDirectory,
@@ -59,18 +65,42 @@ actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView {
             backgroundColor = background,
         )
     }
+
+    if (
+        Platform.Current == Platform.Windows &&
+        WebView2WindowsBridge.isLoaded &&
+        param.parentHwnd != 0L
+    ) {
+        return WindowsWebView2NativeWebView(
+            parentHwnd = param.parentHwnd,
+            customUserAgent = settings.customUserAgentString,
+            dataDirectory = desktop.dataDirectory,
+            initScript = desktop.initScript,
+            incognito = desktop.incognito,
+            enableDevtools = desktop.enableDevtools,
+            javascriptEnabled = settings.isJavaScriptEnabled,
+            zoomLevel = settings.zoomLevel,
+            transparent = desktop.transparent,
+            backgroundColor = background,
+        )
+    }
+
     return NativeWebView()
 }
 
+private fun NativeWebView.isLiveBackend(): Boolean =
+    this is LinuxWebKitNativeWebView || this is WindowsWebView2NativeWebView
+
 /**
  * Desktop WebView composable.
  *
  * **Linux + Tao**: embeds a real WebKit2GTK view via [NativeView].
- * **Windows / macOS**: empty box (no-op backend).
+ * **Windows + Tao**: embeds a real WebView2 view via [NativeView] (DComp).
+ * **macOS**: empty box (no-op backend until WKWebView lands).
  *
  * Outside a Tao [dev.nucleusframework.application.DecoratedWindow],
- * [NativeView] falls back to an empty box even on Linux โ€” the WebView
- * only works with the Tao backend.
+ * [NativeView] falls back to an empty box โ€” the WebView only works with
+ * the Tao backend.
  */
 @Composable
 actual fun ActualWebView(
@@ -85,8 +115,23 @@ actual fun ActualWebView(
     val currentOnDispose by rememberUpdatedState(onDispose)
     val scope = rememberCoroutineScope()
 
-    val nativeWebView = remember(state, factory) {
-        state.webView?.nativeWebView ?: factory(WebViewFactoryParam(state))
+    val parentHwnd =
+        if (Platform.Current == Platform.Windows) {
+            LocalTaoWindow.current?.nativeHandle ?: 0L
+        } else {
+            0L
+        }
+
+    val nativeWebView = remember(state, factory, parentHwnd) {
+        // Prefer a ready live backend across recompositions. Windows may
+        // first compose with parentHwnd=0 (no-op) then recreate once the
+        // Tao HWND is available โ€” do not lock in a permanent no-op.
+        val existing = state.webView?.nativeWebView
+        if (existing != null && existing.isReady() && existing.isLiveBackend()) {
+            existing
+        } else {
+            factory(WebViewFactoryParam(state, parentHwnd = parentHwnd))
+        }
     }
 
     val desktopWebView = remember(nativeWebView, scope, webViewJsBridge) {
@@ -101,7 +146,7 @@ actual fun ActualWebView(
         state.webView = desktopWebView
         webViewJsBridge?.webView = desktopWebView
         (state.cookieManager as? DesktopCookieManager)?.attach(nativeWebView)
-        if (nativeWebView !is LinuxWebKitNativeWebView) {
+        if (!nativeWebView.isLiveBackend()) {
             // No-op backend: mark finished so demos don't spin forever.
             state.loadingState = LoadingState.Finished
             navigator.canGoBack = false
@@ -111,7 +156,7 @@ actual fun ActualWebView(
 
     // Poll native state (URL / loading / title / nav) and drain IPC.
     LaunchedEffect(nativeWebView, state, navigator) {
-        if (nativeWebView !is LinuxWebKitNativeWebView) return@LaunchedEffect
+        if (!nativeWebView.isLiveBackend()) return@LaunchedEffect
         while (true) {
             if (!nativeWebView.isReady()) {
                 if (state.loadingState !is LoadingState.Initializing) {
@@ -148,16 +193,26 @@ actual fun ActualWebView(
                     }
                 }
 
+            // Always publish the latest source โ€” do not gate on isLoading.
+            // (A stuck isLoading flag must not leave lastLoadedUrl blank.)
             if (!url.isNullOrBlank()) {
-                if (!isLoading || state.lastLoadedUrl.isNullOrBlank()) {
-                    state.lastLoadedUrl = url
-                }
+                state.lastLoadedUrl = url
             }
 
             if (!title.isNullOrBlank()) {
                 state.pageTitle = title
             }
 
+            // Document-ready fallback: if native isLoading is stuck true but we
+            // already have a real document, advance to Finished so demos/suite
+            // (and JS bridge injection) don't hang.
+            if (isLoading &&
+                state.loadingState is LoadingState.Loading &&
+                ((!url.isNullOrBlank() && url != "about:blank") || !title.isNullOrBlank())
+            ) {
+                state.loadingState = LoadingState.Finished
+            }
+
             navigator.canGoBack = nativeWebView.canGoBack()
             navigator.canGoForward = nativeWebView.canGoForward()
 
@@ -166,7 +221,7 @@ actual fun ActualWebView(
     }
 
     LaunchedEffect(nativeWebView, webViewJsBridge) {
-        if (nativeWebView !is LinuxWebKitNativeWebView || webViewJsBridge == null) {
+        if (!nativeWebView.isLiveBackend() || webViewJsBridge == null) {
             return@LaunchedEffect
         }
         while (true) {
@@ -211,22 +266,36 @@ actual fun ActualWebView(
     }
 
     val linuxWebView = nativeWebView as? LinuxWebKitNativeWebView
-    if (linuxWebView != null && LocalWebViewFactory.current == null) {
-        NativeView(
-            factory = { linuxWebView.asPlatformView() },
-            modifier = modifier,
-            update = { },
-        )
-        LaunchedEffect(nativeWebView) {
-            onCreated(nativeWebView)
+    val windowsWebView = nativeWebView as? WindowsWebView2NativeWebView
+    when {
+        linuxWebView != null && LocalWebViewFactory.current == null -> {
+            NativeView(
+                factory = { linuxWebView.asPlatformView() },
+                modifier = modifier,
+                update = { },
+            )
+            LaunchedEffect(nativeWebView) {
+                onCreated(nativeWebView)
+            }
         }
-    } else {
-        // Test factory / non-Linux / no-op: empty layout slot.
-        Box(modifier) {
+        windowsWebView != null && LocalWebViewFactory.current == null -> {
+            NativeView(
+                factory = { windowsWebView.asPlatformView() },
+                modifier = modifier,
+                update = { },
+            )
             LaunchedEffect(nativeWebView) {
                 onCreated(nativeWebView)
             }
         }
+        else -> {
+            // Test factory / non-Linux-non-Windows / no-op: empty layout slot.
+            Box(modifier) {
+                LaunchedEffect(nativeWebView) {
+                    onCreated(nativeWebView)
+                }
+            }
+        }
     }
 
     DisposableEffect(nativeWebView) {
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt
index 70b785f..fcc73ae 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt
@@ -10,8 +10,7 @@ import kotlinx.coroutines.CompletableDeferred
  * Linux [NativeWebView] backed by a real WebKit2GTK widget, embeddable
  * via [NucleusPlatformView.GtkWidget] / Nucleus [dev.nucleusframework.window.tao.NativeView].
  *
- * Requires the Tao window backend. Windows and macOS keep the no-op
- * [NativeWebView] until their backends land.
+ * Requires the Tao window backend.
  */
 class LinuxWebKitNativeWebView(
     customUserAgent: String? = null,
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt
new file mode 100644
index 0000000..abb7ed6
--- /dev/null
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt
@@ -0,0 +1,223 @@
+package dev.nucleusframework.webview.web.windows
+
+import dev.nucleusframework.core.runtime.NativeLibraryLoader
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ConcurrentLinkedQueue
+import kotlinx.coroutines.CompletableDeferred
+
+/**
+ * JNI bridge to `compose_webview.cpp` (WebView2 CompositionController + DComp).
+ *
+ * Loaded only on Windows. All native calls must run on the Tao main thread
+ * (the thread that owns the parent HWND). WebView2's controller is STA.
+ *
+ * [WebView2Loader.dll] is a required sidecar extracted next to the JNI DLL;
+ * the C++ side loads it by name after extending the DLL search path.
+ */
+internal object WebView2WindowsBridge {
+    private const val LIBRARY_NAME = "compose_webview_windows"
+
+    val isLoaded: Boolean =
+        NativeLibraryLoader.load(
+            LIBRARY_NAME,
+            WebView2WindowsBridge::class.java,
+            sidecarFiles = listOf("WebView2Loader.dll"),
+        )
+
+    private val navigateHandlers =
+        ConcurrentHashMap Boolean>>()
+    private val ipcQueues =
+        ConcurrentHashMap>()
+    private val jsCallbacks =
+        ConcurrentHashMap Unit>>()
+    private val cookieDeferreds =
+        ConcurrentHashMap>>()
+    private val screenshotDeferreds =
+        ConcurrentHashMap>>()
+
+    fun addNavigateListener(handle: Long, listener: (String) -> Boolean) {
+        navigateHandlers.getOrPut(handle) { mutableListOf() }.add(listener)
+    }
+
+    fun removeNavigateListener(handle: Long, listener: (String) -> Boolean) {
+        navigateHandlers[handle]?.remove(listener)
+    }
+
+    fun drainIpcMessages(handle: Long): List {
+        val queue = ipcQueues[handle] ?: return emptyList()
+        val drained = ArrayList()
+        while (true) {
+            val next = queue.poll() ?: break
+            drained += next
+        }
+        return drained
+    }
+
+    fun registerJsCallback(handle: Long, callback: (String) -> Unit) {
+        jsCallbacks.getOrPut(handle) { ConcurrentLinkedQueue() }.add(callback)
+    }
+
+    fun registerCookieDeferred(handle: Long, deferred: CompletableDeferred) {
+        cookieDeferreds.getOrPut(handle) { ConcurrentLinkedQueue() }.add(deferred)
+    }
+
+    fun registerScreenshotDeferred(handle: Long, deferred: CompletableDeferred) {
+        screenshotDeferreds.getOrPut(handle) { ConcurrentLinkedQueue() }.add(deferred)
+    }
+
+    fun clearHandle(handle: Long) {
+        navigateHandlers.remove(handle)
+        ipcQueues.remove(handle)
+        jsCallbacks.remove(handle)?.forEach { it.invoke("") }
+        cookieDeferreds.remove(handle)?.forEach {
+            it.complete("[]")
+        }
+        screenshotDeferreds.remove(handle)?.forEach {
+            it.complete(null)
+        }
+    }
+
+    // โ”€โ”€ Callbacks from native (must be public for JNI) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+    @JvmStatic
+    fun nativeOnNavigate(handle: Long, url: String): Boolean {
+        val handlers = navigateHandlers[handle]
+        if (handlers.isNullOrEmpty()) return true
+        return handlers.any { it(url) }
+    }
+
+    @JvmStatic
+    fun nativeOnIpcMessage(handle: Long, message: String) {
+        ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(message)
+    }
+
+    @JvmStatic
+    fun nativeOnJsResult(handle: Long, result: String) {
+        jsCallbacks[handle]?.poll()?.invoke(result)
+    }
+
+    @JvmStatic
+    fun nativeOnCookiesResult(handle: Long, json: String) {
+        cookieDeferreds[handle]?.poll()?.complete(json)
+    }
+
+    @JvmStatic
+    fun nativeOnScreenshotResult(handle: Long, bytes: ByteArray?) {
+        screenshotDeferreds[handle]?.poll()?.complete(bytes)
+    }
+
+    // โ”€โ”€ Native methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+    @JvmStatic
+    external fun nativeCreate(
+        parentHwnd: Long,
+        userAgent: String?,
+        dataDirectory: String?,
+        initScript: String?,
+        incognito: Boolean,
+        enableDevtools: Boolean,
+        javascriptEnabled: Boolean,
+        zoomLevel: Double,
+        transparent: Boolean,
+        bgR: Float,
+        bgG: Float,
+        bgB: Float,
+        bgA: Float,
+    ): Long
+
+    @JvmStatic
+    external fun nativeRelease(handle: Long)
+
+    @JvmStatic
+    external fun nativeLoadUrl(handle: Long, url: String)
+
+    @JvmStatic
+    external fun nativeLoadUrlWithHeaders(
+        handle: Long,
+        url: String,
+        headerNames: Array,
+        headerValues: Array,
+    )
+
+    @JvmStatic
+    external fun nativeLoadHtml(handle: Long, html: String, baseUri: String?)
+
+    @JvmStatic
+    external fun nativeGoBack(handle: Long)
+
+    @JvmStatic
+    external fun nativeGoForward(handle: Long)
+
+    @JvmStatic
+    external fun nativeReload(handle: Long)
+
+    @JvmStatic
+    external fun nativeStopLoading(handle: Long)
+
+    @JvmStatic
+    external fun nativeCanGoBack(handle: Long): Boolean
+
+    @JvmStatic
+    external fun nativeCanGoForward(handle: Long): Boolean
+
+    @JvmStatic
+    external fun nativeCurrentUrl(handle: Long): String?
+
+    @JvmStatic
+    external fun nativeGetTitle(handle: Long): String?
+
+    @JvmStatic
+    external fun nativeIsLoading(handle: Long): Boolean
+
+    @JvmStatic
+    external fun nativeSetZoomLevel(handle: Long, zoom: Double)
+
+    @JvmStatic
+    external fun nativeFocus(handle: Long)
+
+    @JvmStatic
+    external fun nativeOpenDevTools(handle: Long)
+
+    @JvmStatic
+    external fun nativeCloseDevTools(handle: Long)
+
+    @JvmStatic
+    external fun nativeEvaluateJavaScript(handle: Long, script: String)
+
+    @JvmStatic
+    external fun nativeGetCookies(handle: Long, url: String)
+
+    @JvmStatic
+    external fun nativeSetCookie(
+        handle: Long,
+        name: String,
+        value: String,
+        domain: String?,
+        path: String?,
+        secure: Boolean,
+        httpOnly: Boolean,
+        expiresMs: Long,
+        sameSite: String?,
+    )
+
+    @JvmStatic
+    external fun nativeRemoveAllCookies(handle: Long)
+
+    @JvmStatic
+    external fun nativeRemoveCookiesForUrl(handle: Long, url: String)
+
+    @JvmStatic
+    external fun nativeCaptureScreenshot(handle: Long)
+
+    @JvmStatic
+    external fun nativeSetBounds(
+        handle: Long,
+        xPx: Int,
+        yPx: Int,
+        widthPx: Int,
+        heightPx: Int,
+    )
+
+    @JvmStatic
+    external fun nativeSetCornerRadius(handle: Long, radiusPx: Float)
+}
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt
new file mode 100644
index 0000000..2422a67
--- /dev/null
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt
@@ -0,0 +1,252 @@
+package dev.nucleusframework.webview.web.windows
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import dev.nucleusframework.webview.web.NativeWebView
+import dev.nucleusframework.window.tao.NucleusPlatformView
+import kotlinx.coroutines.CompletableDeferred
+
+/**
+ * Windows [NativeWebView] backed by WebView2
+ * (`CoreWebView2CompositionController` + DirectComposition).
+ *
+ * Embed via [NucleusPlatformView.HWnd] / Nucleus [dev.nucleusframework.window.tao.NativeView].
+ * The platform handle is **not** a real child HWND โ€” WebView2 paints through
+ * a DComp tree owned by the native side; [NucleusPlatformView.HWnd.hwndHandle]
+ * is always `0L` and positioning goes through [setBounds] / [setCornerRadius]
+ * (same pattern as Nucleus `tao-demo` WebView tab).
+ *
+ * Requires the Tao window backend and WebView2 Runtime (bundled with Edge
+ * on modern Windows).
+ */
+class WindowsWebView2NativeWebView(
+    parentHwnd: Long,
+    customUserAgent: String? = null,
+    dataDirectory: String? = null,
+    initScript: String? = null,
+    incognito: Boolean = false,
+    enableDevtools: Boolean = false,
+    javascriptEnabled: Boolean = true,
+    zoomLevel: Double = 1.0,
+    transparent: Boolean = false,
+    backgroundColor: Color = Color.White,
+) : NativeWebView() {
+    private val handle: Long
+    private var released = false
+
+    init {
+        require(WebView2WindowsBridge.isLoaded) {
+            "compose_webview_windows native library is not available"
+        }
+        require(parentHwnd != 0L) { "parent HWND is required on Windows" }
+        val effective =
+            if (transparent) {
+                backgroundColor
+            } else if (backgroundColor.alpha < 1f) {
+                Color.White
+            } else {
+                backgroundColor.copy(alpha = 1f)
+            }
+        val argb = effective.toArgb()
+        val a = ((argb ushr 24) and 0xFF) / 255f
+        val r = ((argb ushr 16) and 0xFF) / 255f
+        val g = ((argb ushr 8) and 0xFF) / 255f
+        val b = (argb and 0xFF) / 255f
+        handle = WebView2WindowsBridge.nativeCreate(
+            parentHwnd = parentHwnd,
+            userAgent = customUserAgent?.trim()?.takeIf { it.isNotEmpty() },
+            dataDirectory = dataDirectory?.trim()?.takeIf { it.isNotEmpty() },
+            initScript = initScript?.trim()?.takeIf { it.isNotEmpty() },
+            incognito = incognito,
+            enableDevtools = enableDevtools,
+            javascriptEnabled = javascriptEnabled,
+            zoomLevel = zoomLevel,
+            transparent = transparent,
+            bgR = r,
+            bgG = g,
+            bgB = b,
+            bgA = a,
+        )
+        require(handle != 0L) {
+            "Failed to create WebView2 (is WebView2 Runtime installed?)"
+        }
+    }
+
+    /**
+     * Creates the [NucleusPlatformView] used by NativeView embedding.
+     *
+     * [NucleusPlatformView.HWnd.hwndHandle] is intentionally `0L` so Tao's
+     * SetParent/SetWindowPos path no-ops; layout is driven entirely via
+     * [setBounds] / [setCornerRadius] on the DComp tree.
+     */
+    fun asPlatformView(): NucleusPlatformView.HWnd =
+        object : NucleusPlatformView.HWnd {
+            override val hwndHandle: Long = 0L
+
+            override fun setBounds(xPx: Int, yPx: Int, widthPx: Int, heightPx: Int) {
+                if (released) return
+                WebView2WindowsBridge.nativeSetBounds(handle, xPx, yPx, widthPx, heightPx)
+            }
+
+            override fun setCornerRadius(radiusPx: Float) {
+                if (released) return
+                WebView2WindowsBridge.nativeSetCornerRadius(handle, radiusPx)
+            }
+
+            override fun dispose() {
+                // Lifecycle owned by NativeWebView.destroy(); NativeView
+                // also calls dispose โ€” keep it idempotent.
+            }
+        }
+
+    override fun isReady(): Boolean = !released && handle != 0L
+
+    override fun isLoading(): Boolean =
+        if (!isReady()) false else WebView2WindowsBridge.nativeIsLoading(handle)
+
+    override fun getCurrentUrl(): String? =
+        if (!isReady()) null else WebView2WindowsBridge.nativeCurrentUrl(handle)
+
+    override fun getTitle(): String? =
+        if (!isReady()) null else WebView2WindowsBridge.nativeGetTitle(handle)
+
+    override fun canGoBack(): Boolean =
+        if (!isReady()) false else WebView2WindowsBridge.nativeCanGoBack(handle)
+
+    override fun canGoForward(): Boolean =
+        if (!isReady()) false else WebView2WindowsBridge.nativeCanGoForward(handle)
+
+    override fun loadUrl(url: String, additionalHttpHeaders: Map) {
+        if (!isReady()) return
+        if (additionalHttpHeaders.isEmpty()) {
+            WebView2WindowsBridge.nativeLoadUrl(handle, url)
+        } else {
+            val names = additionalHttpHeaders.keys.toTypedArray()
+            val values = additionalHttpHeaders.values.toTypedArray()
+            WebView2WindowsBridge.nativeLoadUrlWithHeaders(handle, url, names, values)
+        }
+    }
+
+    override fun loadHtml(html: String) {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeLoadHtml(handle, html, null)
+    }
+
+    fun loadHtml(html: String, baseUri: String?) {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeLoadHtml(handle, html, baseUri)
+    }
+
+    override fun goBack() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeGoBack(handle)
+    }
+
+    override fun goForward() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeGoForward(handle)
+    }
+
+    override fun reload() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeReload(handle)
+    }
+
+    override fun stopLoading() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeStopLoading(handle)
+    }
+
+    override fun evaluateJavaScript(script: String, callback: (String) -> Unit) {
+        if (!isReady()) {
+            callback("")
+            return
+        }
+        WebView2WindowsBridge.registerJsCallback(handle, callback)
+        WebView2WindowsBridge.nativeEvaluateJavaScript(handle, script)
+    }
+
+    override fun drainIpcMessages(): List =
+        if (!isReady()) emptyList() else WebView2WindowsBridge.drainIpcMessages(handle)
+
+    override fun addNavigateListener(listener: (String) -> Boolean) {
+        if (!isReady()) return
+        WebView2WindowsBridge.addNavigateListener(handle, listener)
+    }
+
+    override fun removeNavigateListener(listener: (String) -> Boolean) {
+        if (!isReady()) return
+        WebView2WindowsBridge.removeNavigateListener(handle, listener)
+    }
+
+    override fun captureScreenshotNative(): ByteArray? = null
+
+    suspend fun captureScreenshotAsync(): ByteArray? {
+        if (!isReady()) return null
+        val deferred = CompletableDeferred()
+        WebView2WindowsBridge.registerScreenshotDeferred(handle, deferred)
+        WebView2WindowsBridge.nativeCaptureScreenshot(handle)
+        return deferred.await()
+    }
+
+    suspend fun getCookiesJson(url: String): String {
+        if (!isReady()) return "[]"
+        val deferred = CompletableDeferred()
+        WebView2WindowsBridge.registerCookieDeferred(handle, deferred)
+        WebView2WindowsBridge.nativeGetCookies(handle, url)
+        return deferred.await()
+    }
+
+    fun setCookieNative(
+        name: String,
+        value: String,
+        domain: String?,
+        path: String?,
+        secure: Boolean,
+        httpOnly: Boolean,
+        expiresMs: Long,
+        sameSite: String?,
+    ) {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeSetCookie(
+            handle, name, value, domain, path, secure, httpOnly, expiresMs, sameSite,
+        )
+    }
+
+    fun removeAllCookiesNative() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeRemoveAllCookies(handle)
+    }
+
+    fun removeCookiesForUrlNative(url: String) {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeRemoveCookiesForUrl(handle, url)
+    }
+
+    fun setZoomLevel(zoom: Double) {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeSetZoomLevel(handle, zoom)
+    }
+
+    override fun openDevTools() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeOpenDevTools(handle)
+    }
+
+    override fun closeDevTools() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeCloseDevTools(handle)
+    }
+
+    override fun focus() {
+        if (!isReady()) return
+        WebView2WindowsBridge.nativeFocus(handle)
+    }
+
+    override fun destroy() {
+        if (released) return
+        released = true
+        WebView2WindowsBridge.clearHandle(handle)
+        WebView2WindowsBridge.nativeRelease(handle)
+    }
+}
diff --git a/webview-compose/src/jvmMain/native/windows/build.bat b/webview-compose/src/jvmMain/native/windows/build.bat
new file mode 100644
index 0000000..e245482
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/build.bat
@@ -0,0 +1,183 @@
+@echo off
+REM Builds compose_webview_windows.dll (WebView2 + DComp) for the
+REM ComposeNativeWebView Windows backend.
+REM
+REM Pattern mirrors Nucleus examples/tao-demo windows/build.bat.
+REM
+REM First-run bootstrap:
+REM   1. Downloads nuget.exe to this directory if missing.
+REM   2. `nuget install Microsoft.Web.WebView2` to fetch headers + the
+REM      WebView2Loader.dll for x64 and ARM64.
+REM Both artifacts are gitignored โ€” we don't vendor the SDK in the repo.
+REM
+REM Outputs:
+REM   - compose_webview_windows.dll  -> resources/nucleus/native/win32-{x64,aarch64}/
+REM   - WebView2Loader.dll           -> same dir (sidecar)
+
+setlocal enabledelayedexpansion
+
+set "SCRIPT_DIR=%~dp0"
+set "RESOURCE_DIR=%SCRIPT_DIR%..\..\resources\nucleus\native"
+set "OUT_DIR_X64=%RESOURCE_DIR%\win32-x64"
+set "OUT_DIR_ARM64=%RESOURCE_DIR%\win32-aarch64"
+set "PACKAGES_DIR=%SCRIPT_DIR%packages"
+set "WV2_PACKAGE=Microsoft.Web.WebView2"
+set "WV2_VERSION=1.0.2210.55"
+set "WV2_DIR=%PACKAGES_DIR%\%WV2_PACKAGE%.%WV2_VERSION%"
+
+REM ---- JAVA_HOME check ----
+if "%JAVA_HOME%"=="" (
+    echo ERROR: JAVA_HOME is not set. >&2
+    exit /b 1
+)
+if not exist "%JAVA_HOME%\include\jni.h" (
+    echo ERROR: JNI headers not found at %JAVA_HOME%\include >&2
+    exit /b 1
+)
+
+REM ---- nuget bootstrap ----
+if not exist "%SCRIPT_DIR%nuget.exe" (
+    echo Downloading nuget.exe...
+    powershell -NoProfile -Command "Invoke-WebRequest -Uri https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -OutFile '%SCRIPT_DIR%nuget.exe' -UseBasicParsing"
+    if errorlevel 1 (
+        echo ERROR: failed to download nuget.exe >&2
+        exit /b 1
+    )
+)
+
+REM ---- WebView2 SDK fetch ----
+if not exist "%WV2_DIR%\build\native\include\WebView2.h" (
+    echo Fetching %WV2_PACKAGE% %WV2_VERSION%...
+    "%SCRIPT_DIR%nuget.exe" install %WV2_PACKAGE% -Version %WV2_VERSION% -OutputDirectory "%PACKAGES_DIR%" -Source https://api.nuget.org/v3/index.json -NonInteractive
+    if errorlevel 1 (
+        echo ERROR: nuget install failed >&2
+        exit /b 1
+    )
+)
+
+set "WV2_INCLUDE=%WV2_DIR%\build\native\include"
+set "WV2_LOADER_X64=%WV2_DIR%\build\native\x64\WebView2Loader.dll"
+set "WV2_LOADER_ARM64=%WV2_DIR%\build\native\arm64\WebView2Loader.dll"
+
+if not exist "%WV2_INCLUDE%\WebView2.h" (
+    echo ERROR: WebView2.h not found after nuget install >&2
+    exit /b 1
+)
+
+REM ---- Locate vcvarsall.bat ----
+set "VCVARSALL="
+set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
+if exist "%VSWHERE%" (
+    for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do (
+        if exist "%%i\VC\Auxiliary\Build\vcvarsall.bat" set "VCVARSALL=%%i\VC\Auxiliary\Build\vcvarsall.bat"
+    )
+)
+if "%VCVARSALL%"=="" (
+    for %%v in (18 2022 2019 2017) do (
+        for %%e in (Enterprise Professional Community BuildTools) do (
+            if exist "C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" (
+                set "VCVARSALL=C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat"
+                goto :found_vc
+            )
+            if exist "C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" (
+                set "VCVARSALL=C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat"
+                goto :found_vc
+            )
+        )
+    )
+)
+:found_vc
+if "%VCVARSALL%"=="" (
+    echo ERROR: Could not locate vcvarsall.bat. >&2
+    exit /b 1
+)
+echo Using vcvarsall.bat: %VCVARSALL%
+
+if not exist "%OUT_DIR_X64%" mkdir "%OUT_DIR_X64%"
+if not exist "%OUT_DIR_ARM64%" mkdir "%OUT_DIR_ARM64%"
+
+REM ===========================================================================
+REM x64
+REM ===========================================================================
+
+setlocal
+call "%VCVARSALL%" x64 >nul
+if errorlevel 1 (
+    echo ERROR: vcvarsall x64 failed >&2
+    exit /b 1
+)
+
+set "SOURCES=jni_bridge.cpp view_signals.cpp view_input.cpp view_lifecycle.cpp navigation.cpp javascript.cpp cookies.cpp screenshot.cpp"
+
+echo.
+echo === Building compose_webview_windows.dll (x64) ===
+pushd "%SCRIPT_DIR%"
+cl /LD /O2 /EHsc /std:c++17 /nologo /MT ^
+    /D_UNICODE /DUNICODE ^
+    /I"%JAVA_HOME%\include" /I"%JAVA_HOME%\include\win32" ^
+    /I"%WV2_INCLUDE%" ^
+    %SOURCES% ^
+    /Fe:"%OUT_DIR_X64%\compose_webview_windows.dll" ^
+    /link kernel32.lib user32.lib ole32.lib oleaut32.lib dcomp.lib
+set "CL_RC=%ERRORLEVEL%"
+popd
+if not "%CL_RC%"=="0" (
+    echo ERROR: x64 compilation failed >&2
+    exit /b 1
+)
+copy /Y "%WV2_LOADER_X64%" "%OUT_DIR_X64%\WebView2Loader.dll" >nul
+del /q "%OUT_DIR_X64%\*.obj" "%OUT_DIR_X64%\*.lib" "%OUT_DIR_X64%\*.exp" 2>nul
+del /q "%SCRIPT_DIR%*.obj" "%SCRIPT_DIR%*.lib" "%SCRIPT_DIR%*.exp" 2>nul
+endlocal
+
+REM ===========================================================================
+REM ARM64
+REM ===========================================================================
+
+setlocal
+call "%VCVARSALL%" x64_arm64 >nul
+if errorlevel 1 (
+    echo WARNING: vcvarsall x64_arm64 failed - skipping ARM64 >&2
+    endlocal
+    goto :clear_cache
+)
+
+set "SOURCES=jni_bridge.cpp view_signals.cpp view_input.cpp view_lifecycle.cpp navigation.cpp javascript.cpp cookies.cpp screenshot.cpp"
+
+echo.
+echo === Building compose_webview_windows.dll (ARM64) ===
+pushd "%SCRIPT_DIR%"
+cl /LD /O2 /EHsc /std:c++17 /nologo /MT ^
+    /D_UNICODE /DUNICODE ^
+    /I"%JAVA_HOME%\include" /I"%JAVA_HOME%\include\win32" ^
+    /I"%WV2_INCLUDE%" ^
+    %SOURCES% ^
+    /Fe:"%OUT_DIR_ARM64%\compose_webview_windows.dll" ^
+    /link kernel32.lib user32.lib ole32.lib oleaut32.lib dcomp.lib
+set "CL_RC=%ERRORLEVEL%"
+popd
+if not "%CL_RC%"=="0" (
+    echo WARNING: ARM64 compilation failed >&2
+    endlocal
+    goto :clear_cache
+)
+copy /Y "%WV2_LOADER_ARM64%" "%OUT_DIR_ARM64%\WebView2Loader.dll" >nul
+del /q "%OUT_DIR_ARM64%\*.obj" "%OUT_DIR_ARM64%\*.lib" "%OUT_DIR_ARM64%\*.exp" 2>nul
+del /q "%SCRIPT_DIR%*.obj" "%SCRIPT_DIR%*.lib" "%SCRIPT_DIR%*.exp" 2>nul
+endlocal
+
+REM ===========================================================================
+:clear_cache
+if exist "%LOCALAPPDATA%\nucleus\native" (
+    rmdir /s /q "%LOCALAPPDATA%\nucleus\native"
+    echo Cleared NativeLibraryLoader cache: %LOCALAPPDATA%\nucleus\native
+)
+
+echo.
+echo Built artifacts:
+if exist "%OUT_DIR_X64%\compose_webview_windows.dll"   echo   %OUT_DIR_X64%\compose_webview_windows.dll
+if exist "%OUT_DIR_X64%\WebView2Loader.dll"            echo   %OUT_DIR_X64%\WebView2Loader.dll
+if exist "%OUT_DIR_ARM64%\compose_webview_windows.dll" echo   %OUT_DIR_ARM64%\compose_webview_windows.dll
+if exist "%OUT_DIR_ARM64%\WebView2Loader.dll"          echo   %OUT_DIR_ARM64%\WebView2Loader.dll
+
+endlocal
diff --git a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h
new file mode 100644
index 0000000..2d8860c
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h
@@ -0,0 +1,119 @@
+/**
+ * Shared state and helpers for the Windows WebView2 backend.
+ * Not a public API โ€” only used by the compose_webview_*.cpp units.
+ *
+ * Pattern: CoreWebView2CompositionController + DirectComposition
+ * (see Nucleus tao-demo sample_webview.cpp). SetWindowRgn does not work
+ * because WebView2 paints via DComp.
+ */
+#ifndef COMPOSE_WEBVIEW_INTERNAL_H
+#define COMPOSE_WEBVIEW_INTERNAL_H
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+using Microsoft::WRL::ComPtr;
+
+struct ComposeWebViewState {
+    jlong handle = 0;
+    HWND parent = nullptr;
+    WNDPROC originalParentProc = nullptr;
+    bool subclassInstalled = false;
+
+    ComPtr env;
+    ComPtr compController;
+    ComPtr controller;
+    ComPtr webview;
+    ComPtr webview2;
+    ComPtr cookieManager;
+
+    ComPtr dcompDevice;
+    ComPtr dcompTarget;
+    ComPtr rootVisual;
+
+    int xPx = 0;
+    int yPx = 0;
+    int widthPx = 800;
+    int heightPx = 600;
+    float cornerRadiusPx = 0.0f;
+
+    std::atomic isLoading{false};
+    std::atomic canGoBack{false};
+    std::atomic canGoForward{false};
+    std::wstring lastSource;
+    std::wstring lastTitle;
+    std::mutex sourceMutex;
+
+    std::atomic currentCursor{nullptr};
+
+    EventRegistrationToken navigationStartingToken{};
+    EventRegistrationToken navigationCompletedToken{};
+    EventRegistrationToken sourceChangedToken{};
+    EventRegistrationToken historyChangedToken{};
+    EventRegistrationToken documentTitleChangedToken{};
+    EventRegistrationToken cursorChangedToken{};
+    EventRegistrationToken webMessageToken{};
+};
+
+struct ComposeWebViewCreateOptions {
+    std::wstring userAgent;
+    std::wstring dataDirectory;
+    std::wstring initScript;
+    bool incognito = false;
+    bool enableDevtools = false;
+    bool javascriptEnabled = true;
+    double zoomLevel = 1.0;
+    bool transparent = false;
+    float bgR = 1.f;
+    float bgG = 1.f;
+    float bgB = 1.f;
+    float bgA = 1.f;
+};
+
+/* jni_bridge.cpp */
+JNIEnv *compose_webview_get_env(void);
+void compose_webview_ensure_bridge_methods(JNIEnv *env);
+void compose_webview_call_on_js_result(jlong handle, const std::string &utf8);
+void compose_webview_call_on_ipc(jlong handle, const std::string &utf8);
+bool compose_webview_call_on_navigate(jlong handle, const std::wstring &url);
+void compose_webview_call_on_cookies(jlong handle, const std::string &json);
+void compose_webview_call_on_screenshot(jlong handle, const std::vector *png);
+
+/* helpers (view_lifecycle.cpp) */
+std::wstring compose_webview_jstring_to_wide(JNIEnv *env, jstring s);
+jstring compose_webview_wide_to_jstring(JNIEnv *env, const std::wstring &s);
+std::string compose_webview_wide_to_utf8(const std::wstring &w);
+std::wstring compose_webview_utf8_to_wide(const std::string &u);
+std::string compose_webview_json_escape(const std::string &s);
+
+ComposeWebViewState *compose_webview_state_from_handle(jlong handle);
+void compose_webview_pump_until_done(const std::atomic &done);
+void compose_webview_apply_bounds(ComposeWebViewState &s);
+void compose_webview_apply_rounded_clip(ComposeWebViewState &s);
+
+/* view_input.cpp โ€” parent HWND subclass for SendMouseInput */
+void compose_webview_install_parent_subclass(ComposeWebViewState *s);
+void compose_webview_uninstall_parent_subclass(ComposeWebViewState *s);
+bool compose_webview_inside(ComposeWebViewState *s, int xClient, int yClient);
+
+/* view_signals.cpp โ€” wired during create */
+void compose_webview_hook_events(ComposeWebViewState *s);
+void compose_webview_unhook_events(ComposeWebViewState *s);
+
+/* view_lifecycle.cpp */
+ComposeWebViewState *compose_webview_create(HWND parent, const ComposeWebViewCreateOptions &opts);
+void compose_webview_release(ComposeWebViewState *s);
+jlong compose_webview_register(ComposeWebViewState *s);
+ComposeWebViewState *compose_webview_unregister(jlong handle);
+
+#endif /* COMPOSE_WEBVIEW_INTERNAL_H */
diff --git a/webview-compose/src/jvmMain/native/windows/cookies.cpp b/webview-compose/src/jvmMain/native/windows/cookies.cpp
new file mode 100644
index 0000000..4e17fca
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/cookies.cpp
@@ -0,0 +1,167 @@
+#include "compose_webview_internal.h"
+
+#include 
+
+using Microsoft::WRL::Callback;
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeGetCookies(
+    JNIEnv *env, jclass, jlong handle, jstring url) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->cookieManager || !url) {
+        compose_webview_call_on_cookies(handle, "[]");
+        return;
+    }
+    std::wstring wurl = compose_webview_jstring_to_wide(env, url);
+    jlong h = handle;
+    s->cookieManager->GetCookies(
+        wurl.c_str(),
+        Callback(
+            [h](HRESULT result, ICoreWebView2CookieList *list) -> HRESULT {
+                if (FAILED(result) || !list) {
+                    compose_webview_call_on_cookies(h, "[]");
+                    return S_OK;
+                }
+                UINT count = 0;
+                list->get_Count(&count);
+                std::ostringstream json;
+                json << "[";
+                bool first = true;
+                for (UINT i = 0; i < count; i++) {
+                    ComPtr cookie;
+                    if (FAILED(list->GetValueAtIndex(i, &cookie)) || !cookie) continue;
+                    LPWSTR name = nullptr, value = nullptr, domain = nullptr, path = nullptr;
+                    cookie->get_Name(&name);
+                    cookie->get_Value(&value);
+                    cookie->get_Domain(&domain);
+                    cookie->get_Path(&path);
+                    BOOL secure = FALSE, httpOnly = FALSE, session = FALSE;
+                    cookie->get_IsSecure(&secure);
+                    cookie->get_IsHttpOnly(&httpOnly);
+                    cookie->get_IsSession(&session);
+                    double expires = 0;
+                    cookie->get_Expires(&expires);
+                    long long expiresMs =
+                        session ? 0LL : static_cast(expires * 1000.0);
+                    COREWEBVIEW2_COOKIE_SAME_SITE_KIND sameSite =
+                        COREWEBVIEW2_COOKIE_SAME_SITE_KIND_LAX;
+                    cookie->get_SameSite(&sameSite);
+                    const char *ss = "Lax";
+                    if (sameSite == COREWEBVIEW2_COOKIE_SAME_SITE_KIND_NONE) ss = "None";
+                    else if (sameSite == COREWEBVIEW2_COOKIE_SAME_SITE_KIND_STRICT) {
+                        ss = "Strict";
+                    }
+
+                    std::string n = name ? compose_webview_wide_to_utf8(name) : "";
+                    std::string v = value ? compose_webview_wide_to_utf8(value) : "";
+                    std::string d = domain ? compose_webview_wide_to_utf8(domain) : "";
+                    std::string p = path ? compose_webview_wide_to_utf8(path) : "/";
+                    if (name) CoTaskMemFree(name);
+                    if (value) CoTaskMemFree(value);
+                    if (domain) CoTaskMemFree(domain);
+                    if (path) CoTaskMemFree(path);
+
+                    if (!first) json << ",";
+                    first = false;
+                    json << "{\"name\":\"" << compose_webview_json_escape(n)
+                         << "\",\"value\":\"" << compose_webview_json_escape(v)
+                         << "\",\"domain\":\"" << compose_webview_json_escape(d)
+                         << "\",\"path\":\"" << compose_webview_json_escape(p)
+                         << "\",\"secure\":" << (secure ? "true" : "false")
+                         << ",\"httpOnly\":" << (httpOnly ? "true" : "false")
+                         << ",\"sessionOnly\":" << (session ? "true" : "false")
+                         << ",\"expiresDate\":" << expiresMs
+                         << ",\"sameSite\":\"" << ss << "\"}";
+                }
+                json << "]";
+                compose_webview_call_on_cookies(h, json.str());
+                return S_OK;
+            }).Get());
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeSetCookie(
+    JNIEnv *env,
+    jclass,
+    jlong handle,
+    jstring name,
+    jstring value,
+    jstring domain,
+    jstring path,
+    jboolean secure,
+    jboolean httpOnly,
+    jlong expiresMs,
+    jstring sameSite) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->cookieManager || !name || !value) return;
+
+    std::wstring wname = compose_webview_jstring_to_wide(env, name);
+    std::wstring wvalue = compose_webview_jstring_to_wide(env, value);
+    std::wstring wdomain = compose_webview_jstring_to_wide(env, domain);
+    std::wstring wpath = path ? compose_webview_jstring_to_wide(env, path) : L"/";
+    if (wpath.empty()) wpath = L"/";
+
+    ComPtr cookie;
+    if (FAILED(s->cookieManager->CreateCookie(
+            wname.c_str(), wvalue.c_str(),
+            wdomain.empty() ? L"" : wdomain.c_str(),
+            wpath.c_str(),
+            &cookie)) ||
+        !cookie) {
+        return;
+    }
+    cookie->put_IsSecure(secure == JNI_TRUE ? TRUE : FALSE);
+    cookie->put_IsHttpOnly(httpOnly == JNI_TRUE ? TRUE : FALSE);
+    if (expiresMs > 0) {
+        cookie->put_Expires(static_cast(expiresMs) / 1000.0);
+    }
+    if (sameSite) {
+        std::wstring ss = compose_webview_jstring_to_wide(env, sameSite);
+        COREWEBVIEW2_COOKIE_SAME_SITE_KIND kind =
+            COREWEBVIEW2_COOKIE_SAME_SITE_KIND_LAX;
+        if (_wcsicmp(ss.c_str(), L"None") == 0) {
+            kind = COREWEBVIEW2_COOKIE_SAME_SITE_KIND_NONE;
+        } else if (_wcsicmp(ss.c_str(), L"Strict") == 0) {
+            kind = COREWEBVIEW2_COOKIE_SAME_SITE_KIND_STRICT;
+        }
+        cookie->put_SameSite(kind);
+    }
+    s->cookieManager->AddOrUpdateCookie(cookie.Get());
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeRemoveAllCookies(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->cookieManager) {
+        s->cookieManager->DeleteAllCookies();
+    }
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeRemoveCookiesForUrl(
+    JNIEnv *env, jclass, jlong handle, jstring url) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->cookieManager || !url) return;
+    std::wstring wurl = compose_webview_jstring_to_wide(env, url);
+    ComPtr cm = s->cookieManager;
+    cm->GetCookies(
+        wurl.c_str(),
+        Callback(
+            [cm](HRESULT result, ICoreWebView2CookieList *list) -> HRESULT {
+                if (FAILED(result) || !list) return S_OK;
+                UINT count = 0;
+                list->get_Count(&count);
+                for (UINT i = 0; i < count; i++) {
+                    ComPtr cookie;
+                    if (SUCCEEDED(list->GetValueAtIndex(i, &cookie)) && cookie) {
+                        cm->DeleteCookie(cookie.Get());
+                    }
+                }
+                return S_OK;
+            }).Get());
+}
+
+}  /* extern "C" */
diff --git a/webview-compose/src/jvmMain/native/windows/javascript.cpp b/webview-compose/src/jvmMain/native/windows/javascript.cpp
new file mode 100644
index 0000000..ee1d06e
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/javascript.cpp
@@ -0,0 +1,31 @@
+#include "compose_webview_internal.h"
+
+using Microsoft::WRL::Callback;
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeEvaluateJavaScript(
+    JNIEnv *env, jclass, jlong handle, jstring script) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->webview || !script) {
+        compose_webview_call_on_js_result(handle, "");
+        return;
+    }
+    std::wstring w = compose_webview_jstring_to_wide(env, script);
+    jlong h = handle;
+    s->webview->ExecuteScript(
+        w.c_str(),
+        Callback(
+            [h](HRESULT result, LPCWSTR resultJson) -> HRESULT {
+                if (FAILED(result) || !resultJson) {
+                    compose_webview_call_on_js_result(h, "");
+                } else {
+                    compose_webview_call_on_js_result(
+                        h, compose_webview_wide_to_utf8(resultJson));
+                }
+                return S_OK;
+            }).Get());
+}
+
+}  /* extern "C" */
diff --git a/webview-compose/src/jvmMain/native/windows/jni_bridge.cpp b/webview-compose/src/jvmMain/native/windows/jni_bridge.cpp
new file mode 100644
index 0000000..9b58836
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/jni_bridge.cpp
@@ -0,0 +1,120 @@
+#include "compose_webview_internal.h"
+
+static JavaVM *g_jvm = nullptr;
+static jclass g_bridge_class = nullptr;
+static jmethodID g_on_navigate = nullptr;
+static jmethodID g_on_ipc = nullptr;
+static jmethodID g_on_js_result = nullptr;
+static jmethodID g_on_cookies = nullptr;
+static jmethodID g_on_screenshot = nullptr;
+
+extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
+    g_jvm = vm;
+    return JNI_VERSION_1_8;
+}
+
+JNIEnv *compose_webview_get_env(void) {
+    if (!g_jvm) return nullptr;
+    JNIEnv *env = nullptr;
+    jint st = g_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_8);
+    if (st == JNI_EDETACHED) {
+        if (g_jvm->AttachCurrentThread(reinterpret_cast(&env), nullptr) != 0) {
+            return nullptr;
+        }
+    } else if (st != JNI_OK) {
+        return nullptr;
+    }
+    return env;
+}
+
+void compose_webview_ensure_bridge_methods(JNIEnv *env) {
+    if (g_bridge_class != nullptr || env == nullptr) return;
+    jclass local = env->FindClass(
+        "dev/nucleusframework/webview/web/windows/WebView2WindowsBridge");
+    if (local == nullptr) {
+        if (env->ExceptionCheck()) env->ExceptionClear();
+        return;
+    }
+    g_bridge_class = static_cast(env->NewGlobalRef(local));
+    env->DeleteLocalRef(local);
+    g_on_navigate = env->GetStaticMethodID(
+        g_bridge_class, "nativeOnNavigate", "(JLjava/lang/String;)Z");
+    g_on_ipc = env->GetStaticMethodID(
+        g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;)V");
+    g_on_js_result = env->GetStaticMethodID(
+        g_bridge_class, "nativeOnJsResult", "(JLjava/lang/String;)V");
+    g_on_cookies = env->GetStaticMethodID(
+        g_bridge_class, "nativeOnCookiesResult", "(JLjava/lang/String;)V");
+    g_on_screenshot = env->GetStaticMethodID(
+        g_bridge_class, "nativeOnScreenshotResult", "(J[B)V");
+}
+
+void compose_webview_call_on_js_result(jlong handle, const std::string &utf8) {
+    JNIEnv *env = compose_webview_get_env();
+    if (!env) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (!g_bridge_class || !g_on_js_result) return;
+    jstring j = env->NewStringUTF(utf8.c_str());
+    env->CallStaticVoidMethod(g_bridge_class, g_on_js_result, handle, j);
+    env->DeleteLocalRef(j);
+    if (env->ExceptionCheck()) env->ExceptionClear();
+}
+
+void compose_webview_call_on_ipc(jlong handle, const std::string &utf8) {
+    JNIEnv *env = compose_webview_get_env();
+    if (!env) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (!g_bridge_class || !g_on_ipc) return;
+    jstring j = env->NewStringUTF(utf8.c_str());
+    env->CallStaticVoidMethod(g_bridge_class, g_on_ipc, handle, j);
+    env->DeleteLocalRef(j);
+    if (env->ExceptionCheck()) env->ExceptionClear();
+}
+
+bool compose_webview_call_on_navigate(jlong handle, const std::wstring &url) {
+    JNIEnv *env = compose_webview_get_env();
+    if (!env) return true;
+    compose_webview_ensure_bridge_methods(env);
+    if (!g_bridge_class || !g_on_navigate) return true;
+    jstring j = env->NewString(
+        reinterpret_cast(url.c_str()),
+        static_cast(url.size()));
+    jboolean allow = env->CallStaticBooleanMethod(
+        g_bridge_class, g_on_navigate, handle, j);
+    env->DeleteLocalRef(j);
+    if (env->ExceptionCheck()) {
+        env->ExceptionClear();
+        return true;
+    }
+    return allow == JNI_TRUE;
+}
+
+void compose_webview_call_on_cookies(jlong handle, const std::string &json) {
+    JNIEnv *env = compose_webview_get_env();
+    if (!env) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (!g_bridge_class || !g_on_cookies) return;
+    jstring j = env->NewStringUTF(json.c_str());
+    env->CallStaticVoidMethod(g_bridge_class, g_on_cookies, handle, j);
+    env->DeleteLocalRef(j);
+    if (env->ExceptionCheck()) env->ExceptionClear();
+}
+
+void compose_webview_call_on_screenshot(jlong handle, const std::vector *png) {
+    JNIEnv *env = compose_webview_get_env();
+    if (!env) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (!g_bridge_class || !g_on_screenshot) return;
+    jbyteArray arr = nullptr;
+    if (png && !png->empty()) {
+        arr = env->NewByteArray(static_cast(png->size()));
+        if (arr) {
+            env->SetByteArrayRegion(
+                arr, 0, static_cast(png->size()),
+                reinterpret_cast(png->data()));
+        }
+    }
+    env->CallStaticVoidMethod(g_bridge_class, g_on_screenshot, handle, arr);
+    if (arr) env->DeleteLocalRef(arr);
+    if (env->ExceptionCheck()) env->ExceptionClear();
+}
diff --git a/webview-compose/src/jvmMain/native/windows/navigation.cpp b/webview-compose/src/jvmMain/native/windows/navigation.cpp
new file mode 100644
index 0000000..8f9ba6e
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/navigation.cpp
@@ -0,0 +1,227 @@
+#include "compose_webview_internal.h"
+
+/**
+ * Percent-encode [input] for a data: URL body. Keeps alphanumerics and
+ * a small unreserved set so history entries are distinct and GoBack works
+ * (NavigateToString often does not push usable history on WebView2).
+ */
+static std::wstring percentEncodeUtf8(const std::string &input) {
+    static const char *hex = "0123456789ABCDEF";
+    std::wstring out;
+    out.reserve(input.size() * 3);
+    for (unsigned char c : input) {
+        if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+            (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
+            out.push_back(static_cast(c));
+        } else {
+            out.push_back(L'%');
+            out.push_back(static_cast(hex[c >> 4]));
+            out.push_back(static_cast(hex[c & 0xF]));
+        }
+    }
+    return out;
+}
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeLoadUrl(
+    JNIEnv *env, jclass, jlong handle, jstring url) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->webview || !url) return;
+    s->webview->Navigate(compose_webview_jstring_to_wide(env, url).c_str());
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeLoadUrlWithHeaders(
+    JNIEnv *env,
+    jclass,
+    jlong handle,
+    jstring url,
+    jobjectArray headerNames,
+    jobjectArray headerValues) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->webview || !url) return;
+
+    if (s->env && headerNames && headerValues) {
+        ComPtr env2;
+        if (SUCCEEDED(s->env.As(&env2)) && env2) {
+            std::wstring wurl = compose_webview_jstring_to_wide(env, url);
+            ComPtr request;
+            if (SUCCEEDED(env2->CreateWebResourceRequest(
+                    wurl.c_str(), L"GET", nullptr, L"", &request)) &&
+                request) {
+                ComPtr headers;
+                if (SUCCEEDED(request->get_Headers(&headers)) && headers) {
+                    jsize count = env->GetArrayLength(headerNames);
+                    jsize vcount = env->GetArrayLength(headerValues);
+                    if (vcount < count) count = vcount;
+                    for (jsize i = 0; i < count; i++) {
+                        auto jn = static_cast(
+                            env->GetObjectArrayElement(headerNames, i));
+                        auto jv = static_cast(
+                            env->GetObjectArrayElement(headerValues, i));
+                        if (jn && jv) {
+                            headers->SetHeader(
+                                compose_webview_jstring_to_wide(env, jn).c_str(),
+                                compose_webview_jstring_to_wide(env, jv).c_str());
+                        }
+                        if (jn) env->DeleteLocalRef(jn);
+                        if (jv) env->DeleteLocalRef(jv);
+                    }
+                }
+                ComPtr wv2;
+                if (SUCCEEDED(s->webview.As(&wv2)) && wv2) {
+                    wv2->NavigateWithWebResourceRequest(request.Get());
+                    return;
+                }
+            }
+        }
+    }
+    s->webview->Navigate(compose_webview_jstring_to_wide(env, url).c_str());
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeLoadHtml(
+    JNIEnv *env, jclass, jlong handle, jstring html, jstring /*baseUri*/) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->webview || !html) return;
+    /* data: URL (not NavigateToString) so back/forward history works. baseUri ignored. */
+    std::wstring wide = compose_webview_jstring_to_wide(env, html);
+    std::string utf8 = compose_webview_wide_to_utf8(wide);
+    std::wstring url = L"data:text/html;charset=utf-8," + percentEncodeUtf8(utf8);
+    s->webview->Navigate(url.c_str());
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeGoBack(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->webview) s->webview->GoBack();
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeGoForward(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->webview) s->webview->GoForward();
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeReload(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->webview) s->webview->Reload();
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeStopLoading(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->webview) s->webview->Stop();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCanGoBack(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    return (s && s->canGoBack.load(std::memory_order_acquire)) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCanGoForward(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    return (s && s->canGoForward.load(std::memory_order_acquire)) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jstring JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCurrentUrl(
+    JNIEnv *env, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s) return nullptr;
+    std::wstring copy;
+    {
+        std::lock_guard lock(s->sourceMutex);
+        copy = s->lastSource;
+    }
+    /* Live query as fallback โ€” some data: navigations skip SourceChanged. */
+    if (copy.empty() && s->webview) {
+        LPWSTR src = nullptr;
+        if (SUCCEEDED(s->webview->get_Source(&src)) && src) {
+            copy.assign(src);
+            {
+                std::lock_guard lock(s->sourceMutex);
+                s->lastSource = copy;
+            }
+            CoTaskMemFree(src);
+        }
+    }
+    if (copy.empty()) return nullptr;
+    return compose_webview_wide_to_jstring(env, copy);
+}
+
+JNIEXPORT jstring JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeGetTitle(
+    JNIEnv *env, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s) return nullptr;
+    std::wstring copy;
+    {
+        std::lock_guard lock(s->sourceMutex);
+        copy = s->lastTitle;
+    }
+    if (copy.empty() && s->webview) {
+        LPWSTR title = nullptr;
+        if (SUCCEEDED(s->webview->get_DocumentTitle(&title)) && title) {
+            copy.assign(title);
+            {
+                std::lock_guard lock(s->sourceMutex);
+                s->lastTitle = copy;
+            }
+            CoTaskMemFree(title);
+        }
+    }
+    if (copy.empty()) return nullptr;
+    return compose_webview_wide_to_jstring(env, copy);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeIsLoading(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    return (s && s->isLoading.load(std::memory_order_acquire)) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeSetZoomLevel(
+    JNIEnv *, jclass, jlong handle, jdouble zoom) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->controller && zoom > 0.0) {
+        s->controller->put_ZoomFactor(zoom);
+    }
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeFocus(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->controller) {
+        s->controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);
+    }
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeOpenDevTools(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (s && s->webview) s->webview->OpenDevToolsWindow();
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCloseDevTools(
+    JNIEnv *, jclass, jlong /*handle*/) {
+    /* WebView2 has no CloseDevTools API โ€” no-op. */
+}
+
+}  /* extern "C" */
diff --git a/webview-compose/src/jvmMain/native/windows/screenshot.cpp b/webview-compose/src/jvmMain/native/windows/screenshot.cpp
new file mode 100644
index 0000000..9dd2b9d
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/screenshot.cpp
@@ -0,0 +1,85 @@
+#include "compose_webview_internal.h"
+
+using Microsoft::WRL::Callback;
+
+/** Minimal IStream that appends Write() payloads into a vector (PNG capture). */
+class VectorStream : public IStream {
+public:
+    explicit VectorStream(std::vector *buf) : buf_(buf), ref_(1) {}
+
+    HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override {
+        if (riid == IID_IUnknown || riid == IID_IStream || riid == IID_ISequentialStream) {
+            *ppv = static_cast(this);
+            AddRef();
+            return S_OK;
+        }
+        *ppv = nullptr;
+        return E_NOINTERFACE;
+    }
+    ULONG STDMETHODCALLTYPE AddRef() override { return ++ref_; }
+    ULONG STDMETHODCALLTYPE Release() override {
+        ULONG r = --ref_;
+        if (r == 0) delete this;
+        return r;
+    }
+    HRESULT STDMETHODCALLTYPE Read(void *, ULONG, ULONG *) override { return E_NOTIMPL; }
+    HRESULT STDMETHODCALLTYPE Write(const void *pv, ULONG cb, ULONG *written) override {
+        const BYTE *p = static_cast(pv);
+        buf_->insert(buf_->end(), p, p + cb);
+        if (written) *written = cb;
+        return S_OK;
+    }
+    HRESULT STDMETHODCALLTYPE Seek(LARGE_INTEGER, DWORD, ULARGE_INTEGER *) override {
+        return E_NOTIMPL;
+    }
+    HRESULT STDMETHODCALLTYPE SetSize(ULARGE_INTEGER) override { return E_NOTIMPL; }
+    HRESULT STDMETHODCALLTYPE CopyTo(
+        IStream *, ULARGE_INTEGER, ULARGE_INTEGER *, ULARGE_INTEGER *) override {
+        return E_NOTIMPL;
+    }
+    HRESULT STDMETHODCALLTYPE Commit(DWORD) override { return S_OK; }
+    HRESULT STDMETHODCALLTYPE Revert() override { return E_NOTIMPL; }
+    HRESULT STDMETHODCALLTYPE LockRegion(ULARGE_INTEGER, ULARGE_INTEGER, DWORD) override {
+        return E_NOTIMPL;
+    }
+    HRESULT STDMETHODCALLTYPE UnlockRegion(ULARGE_INTEGER, ULARGE_INTEGER, DWORD) override {
+        return E_NOTIMPL;
+    }
+    HRESULT STDMETHODCALLTYPE Stat(STATSTG *, DWORD) override { return E_NOTIMPL; }
+    HRESULT STDMETHODCALLTYPE Clone(IStream **) override { return E_NOTIMPL; }
+
+private:
+    std::vector *buf_;
+    std::atomic ref_;
+};
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCaptureScreenshot(
+    JNIEnv *, jclass, jlong handle) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s || !s->webview) {
+        compose_webview_call_on_screenshot(handle, nullptr);
+        return;
+    }
+    auto *buf = new std::vector();
+    auto *stream = new VectorStream(buf);
+    jlong h = handle;
+    s->webview->CapturePreview(
+        COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG,
+        stream,
+        Callback(
+            [h, buf, stream](HRESULT result) -> HRESULT {
+                stream->Release();
+                if (FAILED(result) || buf->empty()) {
+                    compose_webview_call_on_screenshot(h, nullptr);
+                } else {
+                    compose_webview_call_on_screenshot(h, buf);
+                }
+                delete buf;
+                return S_OK;
+            }).Get());
+}
+
+}  /* extern "C" */
diff --git a/webview-compose/src/jvmMain/native/windows/view_input.cpp b/webview-compose/src/jvmMain/native/windows/view_input.cpp
new file mode 100644
index 0000000..885b26e
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/view_input.cpp
@@ -0,0 +1,154 @@
+#include "compose_webview_internal.h"
+
+#include 
+#include 
+
+/**
+ * CompositionController has no HWND of its own โ€” the host must forward
+ * Win32 mouse messages via SendMouseInput. Multiple WebViews may share one
+ * parent HWND; hit-testing picks the top-most view under the cursor.
+ */
+
+static const wchar_t *kParentProp = L"NucleusComposeWebViewList";
+
+struct ParentWebViewList {
+    std::vector views;
+    WNDPROC originalProc = nullptr;
+};
+
+bool compose_webview_inside(ComposeWebViewState *s, int xClient, int yClient) {
+    return xClient >= s->xPx && xClient < s->xPx + s->widthPx &&
+           yClient >= s->yPx && yClient < s->yPx + s->heightPx;
+}
+
+static ComposeWebViewState *hitTest(ParentWebViewList *list, int x, int y) {
+    if (!list) return nullptr;
+    for (auto it = list->views.rbegin(); it != list->views.rend(); ++it) {
+        if (compose_webview_inside(*it, x, y) && (*it)->compController) return *it;
+    }
+    return nullptr;
+}
+
+static LRESULT CALLBACK parentSubclassProc(HWND hwnd, UINT msg, WPARAM w, LPARAM l) {
+    auto *list = static_cast(GetPropW(hwnd, kParentProp));
+    auto callPrev = [&]() -> LRESULT {
+        return list && list->originalProc
+            ? CallWindowProcW(list->originalProc, hwnd, msg, w, l)
+            : DefWindowProcW(hwnd, msg, w, l);
+    };
+    if (!list) return callPrev();
+
+    switch (msg) {
+        case WM_MOUSEMOVE:
+        case WM_LBUTTONDOWN: case WM_LBUTTONUP: case WM_LBUTTONDBLCLK:
+        case WM_RBUTTONDOWN: case WM_RBUTTONUP: case WM_RBUTTONDBLCLK:
+        case WM_MBUTTONDOWN: case WM_MBUTTONUP: case WM_MBUTTONDBLCLK:
+        case WM_XBUTTONDOWN: case WM_XBUTTONUP: case WM_XBUTTONDBLCLK: {
+            int x = GET_X_LPARAM(l);
+            int y = GET_Y_LPARAM(l);
+            ComposeWebViewState *s = hitTest(list, x, y);
+            if (!s) break;
+            POINT pt = {x - s->xPx, y - s->yPx};
+            UINT32 mouseData = 0;
+            if (msg == WM_XBUTTONDOWN || msg == WM_XBUTTONUP || msg == WM_XBUTTONDBLCLK) {
+                mouseData = GET_XBUTTON_WPARAM(w);
+            }
+            s->compController->SendMouseInput(
+                static_cast(msg),
+                static_cast(GET_KEYSTATE_WPARAM(w)),
+                mouseData, pt);
+            if (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN ||
+                msg == WM_MBUTTONDOWN || msg == WM_XBUTTONDOWN) {
+                if (s->controller) {
+                    s->controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);
+                }
+            }
+            return 0;
+        }
+        case WM_MOUSEWHEEL:
+        case WM_MOUSEHWHEEL: {
+            POINT pt = {GET_X_LPARAM(l), GET_Y_LPARAM(l)};
+            ScreenToClient(hwnd, &pt);
+            ComposeWebViewState *s = hitTest(list, pt.x, pt.y);
+            if (!s) break;
+            POINT local = {pt.x - s->xPx, pt.y - s->yPx};
+            s->compController->SendMouseInput(
+                static_cast(msg),
+                static_cast(GET_KEYSTATE_WPARAM(w)),
+                static_cast(GET_WHEEL_DELTA_WPARAM(w)), local);
+            return 0;
+        }
+        case WM_MOUSELEAVE: {
+            for (ComposeWebViewState *s : list->views) {
+                if (!s->compController) continue;
+                POINT pt = {-1, -1};
+                s->compController->SendMouseInput(
+                    COREWEBVIEW2_MOUSE_EVENT_KIND_LEAVE,
+                    static_cast(0), 0, pt);
+            }
+            break;
+        }
+        case WM_WINDOWPOSCHANGED: {
+            for (ComposeWebViewState *s : list->views) {
+                if (s->controller) s->controller->NotifyParentWindowPositionChanged();
+            }
+            break;
+        }
+        case WM_SETCURSOR: {
+            if (LOWORD(l) != HTCLIENT) break;
+            POINT pt;
+            if (!GetCursorPos(&pt)) break;
+            ScreenToClient(hwnd, &pt);
+            ComposeWebViewState *s = hitTest(list, pt.x, pt.y);
+            if (!s) break;
+            HCURSOR cur = s->currentCursor.load(std::memory_order_acquire);
+            if (!cur) break;
+            SetCursor(cur);
+            return TRUE;
+        }
+    }
+    return callPrev();
+}
+
+void compose_webview_install_parent_subclass(ComposeWebViewState *s) {
+    if (!s || !IsWindow(s->parent)) return;
+    auto *list = static_cast(GetPropW(s->parent, kParentProp));
+    if (!list) {
+        list = new ParentWebViewList();
+        SetPropW(s->parent, kParentProp, static_cast(list));
+        LONG_PTR prev = SetWindowLongPtrW(
+            s->parent, GWLP_WNDPROC,
+            reinterpret_cast(parentSubclassProc));
+        list->originalProc = reinterpret_cast(prev);
+    }
+    list->views.push_back(s);
+    s->originalParentProc = list->originalProc;
+    s->subclassInstalled = true;
+}
+
+void compose_webview_uninstall_parent_subclass(ComposeWebViewState *s) {
+    if (!s || !s->subclassInstalled || !IsWindow(s->parent)) {
+        if (s) s->subclassInstalled = false;
+        return;
+    }
+    auto *list = static_cast(GetPropW(s->parent, kParentProp));
+    if (!list) {
+        s->subclassInstalled = false;
+        return;
+    }
+    list->views.erase(
+        std::remove(list->views.begin(), list->views.end(), s),
+        list->views.end());
+    if (list->views.empty()) {
+        WNDPROC current = reinterpret_cast(
+            GetWindowLongPtrW(s->parent, GWLP_WNDPROC));
+        if (current == parentSubclassProc) {
+            SetWindowLongPtrW(
+                s->parent, GWLP_WNDPROC,
+                reinterpret_cast(list->originalProc));
+        }
+        RemovePropW(s->parent, kParentProp);
+        delete list;
+    }
+    s->subclassInstalled = false;
+}
diff --git a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp
new file mode 100644
index 0000000..4fbc4aa
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp
@@ -0,0 +1,478 @@
+#include "compose_webview_internal.h"
+
+#include 
+#include 
+
+using Microsoft::WRL::Callback;
+
+/* โ”€โ”€ WebView2Loader.dll โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+typedef HRESULT(STDMETHODCALLTYPE *PFN_CreateCoreWebView2EnvironmentWithOptions)(
+    PCWSTR browserExecutableFolder,
+    PCWSTR userDataFolder,
+    ICoreWebView2EnvironmentOptions *environmentOptions,
+    ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler *environmentCreatedHandler);
+
+static PFN_CreateCoreWebView2EnvironmentWithOptions s_pCreateEnv = nullptr;
+
+static bool ensureLoaderLoaded() {
+    if (s_pCreateEnv) return true;
+    HMODULE self = nullptr;
+    GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
+                       reinterpret_cast(&ensureLoaderLoaded), &self);
+    if (self) {
+        wchar_t selfPath[MAX_PATH];
+        if (GetModuleFileNameW(self, selfPath, MAX_PATH) > 0) {
+            for (int i = (int)wcslen(selfPath) - 1; i >= 0; --i) {
+                if (selfPath[i] == L'\\' || selfPath[i] == L'/') {
+                    selfPath[i] = 0;
+                    break;
+                }
+            }
+            SetDllDirectoryW(selfPath);
+        }
+    }
+    HMODULE loader = LoadLibraryW(L"WebView2Loader.dll");
+    if (!loader) return false;
+    s_pCreateEnv = reinterpret_cast(
+        GetProcAddress(loader, "CreateCoreWebView2EnvironmentWithOptions"));
+    return s_pCreateEnv != nullptr;
+}
+
+/* โ”€โ”€ Handle map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+static std::mutex g_handlesMutex;
+static std::unordered_map g_handles;
+static std::atomic g_nextHandle{1};
+
+ComposeWebViewState *compose_webview_state_from_handle(jlong handle) {
+    std::lock_guard lock(g_handlesMutex);
+    auto it = g_handles.find(handle);
+    return it == g_handles.end() ? nullptr : it->second;
+}
+
+jlong compose_webview_register(ComposeWebViewState *s) {
+    jlong handle = g_nextHandle.fetch_add(1, std::memory_order_relaxed);
+    s->handle = handle;
+    std::lock_guard lock(g_handlesMutex);
+    g_handles[handle] = s;
+    return handle;
+}
+
+ComposeWebViewState *compose_webview_unregister(jlong handle) {
+    std::lock_guard lock(g_handlesMutex);
+    auto it = g_handles.find(handle);
+    if (it == g_handles.end()) return nullptr;
+    ComposeWebViewState *s = it->second;
+    g_handles.erase(it);
+    return s;
+}
+
+/* โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+void compose_webview_pump_until_done(const std::atomic &done) {
+    while (!done.load(std::memory_order_acquire)) {
+        MSG msg;
+        if (GetMessageW(&msg, nullptr, 0, 0) <= 0) break;
+        TranslateMessage(&msg);
+        DispatchMessageW(&msg);
+    }
+}
+
+void compose_webview_apply_rounded_clip(ComposeWebViewState &s) {
+    if (!s.dcompDevice || !s.rootVisual) return;
+    if (s.cornerRadiusPx <= 0.0f) {
+        s.rootVisual->SetClip(static_cast(nullptr));
+        return;
+    }
+    ComPtr clip;
+    if (FAILED(s.dcompDevice->CreateRectangleClip(&clip))) return;
+    float w = static_cast(s.widthPx);
+    float h = static_cast(s.heightPx);
+    float r = s.cornerRadiusPx;
+    float cap = (w < h ? w : h) * 0.5f;
+    if (r > cap) r = cap;
+    clip->SetLeft(0.0f);
+    clip->SetTop(0.0f);
+    clip->SetRight(w);
+    clip->SetBottom(h);
+    clip->SetTopLeftRadiusX(r);
+    clip->SetTopLeftRadiusY(r);
+    clip->SetTopRightRadiusX(r);
+    clip->SetTopRightRadiusY(r);
+    clip->SetBottomLeftRadiusX(r);
+    clip->SetBottomLeftRadiusY(r);
+    clip->SetBottomRightRadiusX(r);
+    clip->SetBottomRightRadiusY(r);
+    s.rootVisual->SetClip(clip.Get());
+}
+
+void compose_webview_apply_bounds(ComposeWebViewState &s) {
+    if (s.controller) {
+        RECT bounds = {s.xPx, s.yPx, s.xPx + s.widthPx, s.yPx + s.heightPx};
+        s.controller->put_Bounds(bounds);
+    }
+    if (s.rootVisual) {
+        s.rootVisual->SetOffsetX(static_cast(s.xPx));
+        s.rootVisual->SetOffsetY(static_cast(s.yPx));
+    }
+}
+
+std::wstring compose_webview_jstring_to_wide(JNIEnv *env, jstring s) {
+    if (!s) return {};
+    const jchar *chars = env->GetStringChars(s, nullptr);
+    jsize len = env->GetStringLength(s);
+    std::wstring out(reinterpret_cast(chars),
+                     reinterpret_cast(chars) + len);
+    env->ReleaseStringChars(s, chars);
+    return out;
+}
+
+jstring compose_webview_wide_to_jstring(JNIEnv *env, const std::wstring &s) {
+    return env->NewString(
+        reinterpret_cast(s.c_str()),
+        static_cast(s.size()));
+}
+
+std::string compose_webview_wide_to_utf8(const std::wstring &w) {
+    if (w.empty()) return {};
+    int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(w.size()),
+                                nullptr, 0, nullptr, nullptr);
+    if (n <= 0) return {};
+    std::string out(static_cast(n), '\0');
+    WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(w.size()),
+                        out.data(), n, nullptr, nullptr);
+    return out;
+}
+
+std::wstring compose_webview_utf8_to_wide(const std::string &u) {
+    if (u.empty()) return {};
+    int n = MultiByteToWideChar(CP_UTF8, 0, u.c_str(), static_cast(u.size()),
+                                nullptr, 0);
+    if (n <= 0) return {};
+    std::wstring out(static_cast(n), L'\0');
+    MultiByteToWideChar(CP_UTF8, 0, u.c_str(), static_cast(u.size()),
+                        out.data(), n);
+    return out;
+}
+
+std::string compose_webview_json_escape(const std::string &s) {
+    std::string out;
+    out.reserve(s.size() + 8);
+    for (unsigned char c : s) {
+        switch (c) {
+            case '"': out += "\\\""; break;
+            case '\\': out += "\\\\"; break;
+            case '\b': out += "\\b"; break;
+            case '\f': out += "\\f"; break;
+            case '\n': out += "\\n"; break;
+            case '\r': out += "\\r"; break;
+            case '\t': out += "\\t"; break;
+            default:
+                if (c < 0x20) {
+                    char buf[8];
+                    snprintf(buf, sizeof(buf), "\\u%04x", c);
+                    out += buf;
+                } else {
+                    out += static_cast(c);
+                }
+        }
+    }
+    return out;
+}
+
+/* โ”€โ”€ Create / release โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+ComposeWebViewState *compose_webview_create(
+    HWND parent,
+    const ComposeWebViewCreateOptions &opts) {
+    if (!ensureLoaderLoaded()) return nullptr;
+
+    HRESULT coInitHr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
+    (void)coInitHr;
+
+    auto *s = new ComposeWebViewState();
+    s->parent = parent;
+
+    std::wstring userDataFolder = opts.dataDirectory;
+    if (userDataFolder.empty() && opts.incognito) {
+        wchar_t tempPath[MAX_PATH];
+        GetTempPathW(MAX_PATH, tempPath);
+        userDataFolder = std::wstring(tempPath) + L"compose_webview_incognito_" +
+                         std::to_wstring(GetCurrentProcessId()) + L"_" +
+                         std::to_wstring(GetTickCount64());
+    }
+
+    std::atomic envDone{false};
+    HRESULT envResult = E_FAIL;
+    HRESULT hr = s_pCreateEnv(
+        nullptr,
+        userDataFolder.empty() ? nullptr : userDataFolder.c_str(),
+        nullptr,
+        Callback(
+            [&](HRESULT result, ICoreWebView2Environment *env) -> HRESULT {
+                envResult = result;
+                if (SUCCEEDED(result) && env) s->env = env;
+                envDone.store(true, std::memory_order_release);
+                return S_OK;
+            }).Get());
+    if (FAILED(hr)) {
+        delete s;
+        return nullptr;
+    }
+    compose_webview_pump_until_done(envDone);
+    if (FAILED(envResult) || !s->env) {
+        delete s;
+        return nullptr;
+    }
+
+    std::atomic ccDone{false};
+    HRESULT ccResult = E_FAIL;
+    ComPtr env3;
+    if (FAILED(s->env.As(&env3)) || !env3) {
+        delete s;
+        return nullptr;
+    }
+    hr = env3->CreateCoreWebView2CompositionController(
+        parent,
+        Callback(
+            [&](HRESULT result, ICoreWebView2CompositionController *cc) -> HRESULT {
+                ccResult = result;
+                if (SUCCEEDED(result) && cc) s->compController = cc;
+                ccDone.store(true, std::memory_order_release);
+                return S_OK;
+            }).Get());
+    if (FAILED(hr)) {
+        delete s;
+        return nullptr;
+    }
+    compose_webview_pump_until_done(ccDone);
+    if (FAILED(ccResult) || !s->compController) {
+        delete s;
+        return nullptr;
+    }
+
+    if (FAILED(s->compController.As(&s->controller)) || !s->controller) {
+        delete s;
+        return nullptr;
+    }
+    if (FAILED(s->controller->get_CoreWebView2(&s->webview)) || !s->webview) {
+        delete s;
+        return nullptr;
+    }
+    s->webview.As(&s->webview2);
+    if (s->webview2) {
+        s->webview2->get_CookieManager(&s->cookieManager);
+    }
+
+    ComPtr settings;
+    if (SUCCEEDED(s->webview->get_Settings(&settings)) && settings) {
+        settings->put_IsScriptEnabled(opts.javascriptEnabled ? TRUE : FALSE);
+        settings->put_AreDevToolsEnabled(opts.enableDevtools ? TRUE : FALSE);
+        settings->put_IsWebMessageEnabled(TRUE);
+        settings->put_AreDefaultContextMenusEnabled(TRUE);
+        settings->put_IsStatusBarEnabled(FALSE);
+        if (!opts.userAgent.empty()) {
+            ComPtr settings2;
+            if (SUCCEEDED(settings.As(&settings2)) && settings2) {
+                settings2->put_UserAgent(opts.userAgent.c_str());
+            }
+        }
+    }
+
+    ComPtr controller2;
+    if (SUCCEEDED(s->controller.As(&controller2)) && controller2) {
+        COREWEBVIEW2_COLOR c{};
+        if (opts.transparent) {
+            c.A = static_cast(opts.bgA * 255.f);
+            c.R = static_cast(opts.bgR * 255.f);
+            c.G = static_cast(opts.bgG * 255.f);
+            c.B = static_cast(opts.bgB * 255.f);
+        } else {
+            c.A = 255;
+            if (opts.bgA < 1.f) {
+                c.R = c.G = c.B = 255;
+            } else {
+                c.R = static_cast(opts.bgR * 255.f);
+                c.G = static_cast(opts.bgG * 255.f);
+                c.B = static_cast(opts.bgB * 255.f);
+            }
+        }
+        controller2->put_DefaultBackgroundColor(c);
+    }
+
+    if (opts.zoomLevel > 0.0) {
+        s->controller->put_ZoomFactor(opts.zoomLevel);
+    }
+
+    if (FAILED(DCompositionCreateDevice(nullptr, IID_PPV_ARGS(&s->dcompDevice))) ||
+        FAILED(s->dcompDevice->CreateTargetForHwnd(parent, TRUE, &s->dcompTarget)) ||
+        FAILED(s->dcompDevice->CreateVisual(&s->rootVisual))) {
+        delete s;
+        return nullptr;
+    }
+    s->dcompTarget->SetRoot(s->rootVisual.Get());
+    if (FAILED(s->compController->put_RootVisualTarget(s->rootVisual.Get()))) {
+        delete s;
+        return nullptr;
+    }
+
+    compose_webview_apply_bounds(*s);
+    s->dcompDevice->Commit();
+
+    /* ipc shim + kmpJsBridge at document start so the suite does not depend
+     * on a Compose Finished race to inject the bridge after each navigation. */
+    const wchar_t *ipcAndBridgeShim =
+        L"(function(){"
+        L"  if (typeof window.ipc === 'undefined') {"
+        L"    window.ipc = {"
+        L"      postMessage: function(message) {"
+        L"        try {"
+        L"          if (window.chrome && window.chrome.webview) {"
+        L"            window.chrome.webview.postMessage("
+        L"              (typeof message === 'string') ? message : JSON.stringify(message)"
+        L"            );"
+        L"          }"
+        L"        } catch (e) {}"
+        L"      }"
+        L"    };"
+        L"  }"
+        L"  if (typeof window.kmpJsBridge === 'undefined') {"
+        L"    window.kmpJsBridge = {"
+        L"      callbacks: {},"
+        L"      callbackId: 0,"
+        L"      callNative: function(methodName, params, callback) {"
+        L"        var message = {"
+        L"          methodName: methodName,"
+        L"          params: params,"
+        L"          callbackId: callback ? window.kmpJsBridge.callbackId++ : -1"
+        L"        };"
+        L"        if (callback) {"
+        L"          window.kmpJsBridge.callbacks[message.callbackId] = callback;"
+        L"        }"
+        L"        window.kmpJsBridge.postMessage(JSON.stringify(message));"
+        L"      },"
+        L"      onCallback: function(callbackId, data) {"
+        L"        var cb = window.kmpJsBridge.callbacks[callbackId];"
+        L"        if (cb) { cb(data); delete window.kmpJsBridge.callbacks[callbackId]; }"
+        L"      },"
+        L"      postMessage: function(message) {"
+        L"        if (window.ipc && window.ipc.postMessage) window.ipc.postMessage(message);"
+        L"      }"
+        L"    };"
+        L"  }"
+        L"})();";
+    s->webview->AddScriptToExecuteOnDocumentCreated(ipcAndBridgeShim, nullptr);
+
+    if (!opts.transparent) {
+        s->webview->AddScriptToExecuteOnDocumentCreated(
+            L"(function(){"
+            L"var s=document.createElement('style');"
+            L"s.textContent='html, body { background-color: #ffffff !important; }';"
+            L"document.documentElement.appendChild(s);"
+            L"})();",
+            nullptr);
+    }
+    if (!opts.initScript.empty()) {
+        s->webview->AddScriptToExecuteOnDocumentCreated(opts.initScript.c_str(), nullptr);
+    }
+
+    compose_webview_hook_events(s);
+    compose_webview_install_parent_subclass(s);
+    s->controller->put_IsVisible(TRUE);
+    return s;
+}
+
+void compose_webview_release(ComposeWebViewState *s) {
+    if (!s) return;
+    compose_webview_uninstall_parent_subclass(s);
+    compose_webview_unhook_events(s);
+    if (s->controller) s->controller->Close();
+    delete s;
+}
+
+/* โ”€โ”€ JNI: create / release / bounds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+extern "C" {
+
+JNIEXPORT jlong JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCreate(
+    JNIEnv *env,
+    jclass,
+    jlong parentHwnd,
+    jstring userAgent,
+    jstring dataDirectory,
+    jstring initScript,
+    jboolean incognito,
+    jboolean enableDevtools,
+    jboolean javascriptEnabled,
+    jdouble zoomLevel,
+    jboolean transparent,
+    jfloat bgR,
+    jfloat bgG,
+    jfloat bgB,
+    jfloat bgA) {
+    if (parentHwnd == 0) return 0;
+    HWND parent = reinterpret_cast(static_cast(parentHwnd));
+    if (!IsWindow(parent)) return 0;
+
+    compose_webview_ensure_bridge_methods(env);
+
+    ComposeWebViewCreateOptions opts;
+    opts.userAgent = compose_webview_jstring_to_wide(env, userAgent);
+    opts.dataDirectory = compose_webview_jstring_to_wide(env, dataDirectory);
+    opts.initScript = compose_webview_jstring_to_wide(env, initScript);
+    opts.incognito = incognito == JNI_TRUE;
+    opts.enableDevtools = enableDevtools == JNI_TRUE;
+    opts.javascriptEnabled = javascriptEnabled == JNI_TRUE;
+    opts.zoomLevel = zoomLevel;
+    opts.transparent = transparent == JNI_TRUE;
+    opts.bgR = bgR;
+    opts.bgG = bgG;
+    opts.bgB = bgB;
+    opts.bgA = bgA;
+
+    ComposeWebViewState *s = compose_webview_create(parent, opts);
+    if (!s) return 0;
+    return compose_webview_register(s);
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeRelease(
+    JNIEnv *, jclass, jlong handle) {
+    compose_webview_release(compose_webview_unregister(handle));
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeSetBounds(
+    JNIEnv *, jclass, jlong handle, jint xPx, jint yPx, jint widthPx, jint heightPx) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s) return;
+    if (widthPx < 1) widthPx = 1;
+    if (heightPx < 1) heightPx = 1;
+    s->xPx = xPx;
+    s->yPx = yPx;
+    s->widthPx = widthPx;
+    s->heightPx = heightPx;
+    compose_webview_apply_bounds(*s);
+    compose_webview_apply_rounded_clip(*s);
+    if (s->dcompDevice) s->dcompDevice->Commit();
+    if (s->controller) s->controller->NotifyParentWindowPositionChanged();
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeSetCornerRadius(
+    JNIEnv *, jclass, jlong handle, jfloat radiusPx) {
+    ComposeWebViewState *s = compose_webview_state_from_handle(handle);
+    if (!s) return;
+    if (radiusPx < 0) radiusPx = 0;
+    s->cornerRadiusPx = radiusPx;
+    compose_webview_apply_rounded_clip(*s);
+    if (s->dcompDevice) s->dcompDevice->Commit();
+}
+
+}  /* extern "C" */
+
+BOOL APIENTRY DllMain(HMODULE, DWORD /*reason*/, LPVOID) {
+    return TRUE;
+}
diff --git a/webview-compose/src/jvmMain/native/windows/view_signals.cpp b/webview-compose/src/jvmMain/native/windows/view_signals.cpp
new file mode 100644
index 0000000..4b39ea7
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/windows/view_signals.cpp
@@ -0,0 +1,155 @@
+#include "compose_webview_internal.h"
+
+using Microsoft::WRL::Callback;
+
+void compose_webview_hook_events(ComposeWebViewState *s) {
+    auto *raw = s;
+
+    s->webview->add_NavigationStarting(
+        Callback(
+            [raw](ICoreWebView2 *, ICoreWebView2NavigationStartingEventArgs *args) -> HRESULT {
+                LPWSTR uri = nullptr;
+                std::wstring url;
+                if (SUCCEEDED(args->get_Uri(&uri)) && uri) {
+                    url.assign(uri);
+                    CoTaskMemFree(uri);
+                    {
+                        std::lock_guard lock(raw->sourceMutex);
+                        raw->lastSource = url;
+                    }
+                }
+                /* Cancel before flipping isLoading so a rejected nav cannot
+                 * leave the view stuck in Loading forever. */
+                if (!url.empty() &&
+                    url.rfind(L"about:", 0) != 0 &&
+                    url.rfind(L"data:", 0) != 0 &&
+                    url.rfind(L"blob:", 0) != 0) {
+                    if (!compose_webview_call_on_navigate(raw->handle, url)) {
+                        args->put_Cancel(TRUE);
+                        raw->isLoading.store(false, std::memory_order_release);
+                        return S_OK;
+                    }
+                }
+                raw->isLoading.store(true, std::memory_order_release);
+                return S_OK;
+            }).Get(),
+        &s->navigationStartingToken);
+
+    s->webview->add_NavigationCompleted(
+        Callback(
+            [raw](ICoreWebView2 *wv, ICoreWebView2NavigationCompletedEventArgs *) -> HRESULT {
+                raw->isLoading.store(false, std::memory_order_release);
+                LPWSTR src = nullptr;
+                if (SUCCEEDED(wv->get_Source(&src)) && src) {
+                    std::lock_guard lock(raw->sourceMutex);
+                    raw->lastSource.assign(src);
+                    CoTaskMemFree(src);
+                }
+                LPWSTR title = nullptr;
+                if (SUCCEEDED(wv->get_DocumentTitle(&title)) && title) {
+                    std::lock_guard lock(raw->sourceMutex);
+                    raw->lastTitle.assign(title);
+                    CoTaskMemFree(title);
+                }
+                BOOL b = FALSE;
+                if (SUCCEEDED(wv->get_CanGoBack(&b))) raw->canGoBack.store(b == TRUE);
+                if (SUCCEEDED(wv->get_CanGoForward(&b))) raw->canGoForward.store(b == TRUE);
+                return S_OK;
+            }).Get(),
+        &s->navigationCompletedToken);
+
+    s->webview->add_SourceChanged(
+        Callback(
+            [raw](ICoreWebView2 *wv, ICoreWebView2SourceChangedEventArgs *) -> HRESULT {
+                LPWSTR src = nullptr;
+                if (SUCCEEDED(wv->get_Source(&src)) && src) {
+                    std::lock_guard lock(raw->sourceMutex);
+                    raw->lastSource.assign(src);
+                    CoTaskMemFree(src);
+                }
+                return S_OK;
+            }).Get(),
+        &s->sourceChangedToken);
+
+    s->webview->add_HistoryChanged(
+        Callback(
+            [raw](ICoreWebView2 *wv, IUnknown *) -> HRESULT {
+                BOOL b = FALSE;
+                if (SUCCEEDED(wv->get_CanGoBack(&b))) raw->canGoBack.store(b == TRUE);
+                if (SUCCEEDED(wv->get_CanGoForward(&b))) raw->canGoForward.store(b == TRUE);
+                return S_OK;
+            }).Get(),
+        &s->historyChangedToken);
+
+    s->webview->add_DocumentTitleChanged(
+        Callback(
+            [raw](ICoreWebView2 *wv, IUnknown *) -> HRESULT {
+                LPWSTR title = nullptr;
+                if (SUCCEEDED(wv->get_DocumentTitle(&title)) && title) {
+                    {
+                        std::lock_guard lock(raw->sourceMutex);
+                        raw->lastTitle.assign(title);
+                    }
+                    CoTaskMemFree(title);
+                    /* Fallback ready-signal: some Navigate(data:) paths paint
+                     * and expose a title before NavigationCompleted is seen by
+                     * the host message loop. Clear isLoading so Kotlin can
+                     * transition to Finished and inject the JS bridge. */
+                    if (!raw->lastTitle.empty()) {
+                        raw->isLoading.store(false, std::memory_order_release);
+                    }
+                }
+                return S_OK;
+            }).Get(),
+        &s->documentTitleChangedToken);
+
+    s->compController->add_CursorChanged(
+        Callback(
+            [raw](ICoreWebView2CompositionController *cc, IUnknown *) -> HRESULT {
+                HCURSOR cursor = nullptr;
+                if (SUCCEEDED(cc->get_Cursor(&cursor))) {
+                    raw->currentCursor.store(cursor, std::memory_order_release);
+                    POINT pt;
+                    if (GetCursorPos(&pt) && IsWindow(raw->parent)) {
+                        ScreenToClient(raw->parent, &pt);
+                        if (compose_webview_inside(raw, pt.x, pt.y)) SetCursor(cursor);
+                    }
+                }
+                return S_OK;
+            }).Get(),
+        &s->cursorChangedToken);
+
+    s->webview->add_WebMessageReceived(
+        Callback(
+            [raw](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT {
+                LPWSTR msg = nullptr;
+                if (SUCCEEDED(args->TryGetWebMessageAsString(&msg)) && msg) {
+                    compose_webview_call_on_ipc(raw->handle, compose_webview_wide_to_utf8(msg));
+                    CoTaskMemFree(msg);
+                } else {
+                    LPWSTR json = nullptr;
+                    if (SUCCEEDED(args->get_WebMessageAsJson(&json)) && json) {
+                        compose_webview_call_on_ipc(
+                            raw->handle, compose_webview_wide_to_utf8(json));
+                        CoTaskMemFree(json);
+                    }
+                }
+                return S_OK;
+            }).Get(),
+        &s->webMessageToken);
+}
+
+void compose_webview_unhook_events(ComposeWebViewState *s) {
+    if (!s) return;
+    if (s->webview) {
+        s->webview->remove_NavigationStarting(s->navigationStartingToken);
+        s->webview->remove_NavigationCompleted(s->navigationCompletedToken);
+        s->webview->remove_SourceChanged(s->sourceChangedToken);
+        s->webview->remove_HistoryChanged(s->historyChangedToken);
+        s->webview->remove_DocumentTitleChanged(s->documentTitleChangedToken);
+        s->webview->remove_WebMessageReceived(s->webMessageToken);
+    }
+    if (s->compController) {
+        s->compController->remove_CursorChanged(s->cursorChangedToken);
+    }
+}
diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties
index 91e55c2..4e72ba4 100644
--- a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties
+++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties
@@ -1,5 +1,7 @@
-# WebKitLinuxBridge loads libcompose_webview_linux via NativeLibraryLoader in
-# a static field initializer โ€” must stay at run time (native-image would
-# otherwise try to open the .so during build).
+# Native bridges load their .so/.dll via NativeLibraryLoader in a static
+# field initializer โ€” must stay at run time (native-image would otherwise
+# try to open the library during build).
 Args = --initialize-at-run-time=dev.nucleusframework.webview.web.linux.WebKitLinuxBridge,\
-dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView,\
+dev.nucleusframework.webview.web.windows.WebView2WindowsBridge,\
+dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json
index 1fefbeb..36ba75c 100644
--- a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json
+++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json
@@ -13,12 +13,31 @@
         { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
       ]
     },
+    {
+      "type": "dev.nucleusframework.webview.web.windows.WebView2WindowsBridge",
+      "jniAccessible": true,
+      "allDeclaredMethods": true,
+      "allDeclaredFields": true,
+      "methods": [
+        { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
+      ]
+    },
     {
       "type": "dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView",
       "allDeclaredConstructors": true,
       "allDeclaredMethods": true,
       "allDeclaredFields": true
     },
+    {
+      "type": "dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView",
+      "allDeclaredConstructors": true,
+      "allDeclaredMethods": true,
+      "allDeclaredFields": true
+    },
     {
       "type": "dev.nucleusframework.webview.web.NativeWebView",
       "allDeclaredConstructors": true,
@@ -77,6 +96,16 @@
         { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] },
         { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
       ]
+    },
+    {
+      "type": "dev.nucleusframework.webview.web.windows.WebView2WindowsBridge",
+      "methods": [
+        { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
+      ]
     }
   ]
 }
diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
similarity index 66%
rename from webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt
rename to webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
index 7c1c7eb..53ed0aa 100644
--- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/LinuxWebViewE2eTest.kt
+++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
@@ -9,37 +9,49 @@ import dev.nucleusframework.application.nucleusApplication
 import dev.nucleusframework.core.runtime.Platform
 import dev.nucleusframework.webview.web.e2e.E2eHost
 import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge
+import dev.nucleusframework.webview.web.windows.WebView2WindowsBridge
 import dev.nucleusframework.window.TitleBar
 import java.util.concurrent.CopyOnWriteArrayList
 import java.util.concurrent.atomic.AtomicReference
 import kotlin.concurrent.thread
 import kotlin.test.Test
-import kotlin.test.assertTrue
-import kotlin.test.fail
 
 /**
- * End-to-end tests against a real WebKit2GTK WebView embedded via Nucleus
+ * End-to-end tests against a real desktop WebView embedded via Nucleus
  * NativeView + Tao backend. No mocks.
  *
+ *  - **Linux**: WebKit2GTK (`compose_webview_linux`)
+ *  - **Windows**: WebView2 (`compose_webview_windows`)
+ *
  * Drivers live in [dev.nucleusframework.webview.web.e2e].
- * Requires Linux + display + libwebkit2gtk (self-skips otherwise).
+ * Self-skips when the host OS/backend is unavailable.
  */
-class LinuxWebViewE2eTest {
+class DesktopWebViewE2eTest {
     @Test
     fun allDesktopWebViewFeaturesE2e() {
-        if (Platform.Current != Platform.Linux) {
-            println("SKIPPED: Linux-only e2e (current=${Platform.Current})")
-            return
-        }
-        if (!WebKitLinuxBridge.isLoaded) {
-            println("SKIPPED: compose_webview_linux native library not loaded")
-            return
-        }
-        if (System.getenv("DISPLAY").isNullOrBlank() &&
-            System.getenv("WAYLAND_DISPLAY").isNullOrBlank()
-        ) {
-            println("SKIPPED: no DISPLAY / WAYLAND_DISPLAY")
-            return
+        when (Platform.Current) {
+            Platform.Linux -> {
+                if (!WebKitLinuxBridge.isLoaded) {
+                    println("SKIPPED: compose_webview_linux native library not loaded")
+                    return
+                }
+                if (System.getenv("DISPLAY").isNullOrBlank() &&
+                    System.getenv("WAYLAND_DISPLAY").isNullOrBlank()
+                ) {
+                    println("SKIPPED: no DISPLAY / WAYLAND_DISPLAY")
+                    return
+                }
+            }
+            Platform.Windows -> {
+                if (!WebView2WindowsBridge.isLoaded) {
+                    println("SKIPPED: compose_webview_windows native library not loaded")
+                    return
+                }
+            }
+            else -> {
+                println("SKIPPED: desktop e2e unsupported on ${Platform.Current}")
+                return
+            }
         }
 
         val failures = CopyOnWriteArrayList()
diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt
index a228c42..1548d1d 100644
--- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt
+++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt
@@ -4,14 +4,20 @@ import dev.nucleusframework.webview.web.LoadingState
 import dev.nucleusframework.webview.web.WebViewNavigator
 import dev.nucleusframework.webview.web.WebViewState
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.withTimeout
 
+internal fun isLiveDesktopWebView(state: WebViewState): Boolean {
+    val nv = state.webView?.nativeWebView ?: return false
+    return nv.isReady() &&
+        (nv is LinuxWebKitNativeWebView || nv is WindowsWebView2NativeWebView)
+}
+
 internal suspend fun awaitWebViewReady(state: WebViewState, timeoutMs: Long = 15_000) {
     awaitUntil("webview attached", timeoutMs) {
-        val nv = state.webView?.nativeWebView
-        nv is LinuxWebKitNativeWebView && nv.isReady()
+        isLiveDesktopWebView(state)
     }
 }
 
diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt
index 1399e6d..f5a383e 100644
--- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt
+++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt
@@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import dev.nucleusframework.webview.web.WebViewNavigator
 import dev.nucleusframework.webview.web.WebViewState
-import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
 import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
@@ -26,9 +25,13 @@ internal fun LoadHtmlDriver(
             assertEquals("hello-e2e", marker.trim('"'))
             val url = state.lastLoadedUrl
             assertNotNull(url, "lastLoadedUrl should be set")
+            // Linux may keep baseUri (e2e.local); WebView2 uses about:blank / data: for NavigateToString.
             assertTrue(
-                url.contains("e2e.local") || url.startsWith("data:") || url == "about:blank" ||
-                    state.webView?.nativeWebView is LinuxWebKitNativeWebView,
+                url.contains("e2e.local") ||
+                    url.startsWith("data:") ||
+                    url == "about:blank" ||
+                    url.startsWith("about:") ||
+                    isLiveDesktopWebView(state),
                 "unexpected url=$url",
             )
             println("[e2e] loadHtml + title + marker OK")

From 382f67fa953432f8b10288459b94ec1d084b37a6 Mon Sep 17 00:00:00 2001
From: "Elie G." 
Date: Thu, 6 Aug 2026 16:48:46 +0300
Subject: [PATCH 04/10] fix(demo): fail fast when Windows natives missing for
 visual e2e

---
 .../webview/demo/visualsuite/SuiteHelpers.kt  | 21 +++++++++++++----
 demo/build.gradle.kts                         | 11 +++++++++
 .../dev/nucleusframework/webview/demo/main.kt | 15 ++++++++++--
 webview-compose/build.gradle.kts              | 23 +++++++++++++++----
 4 files changed, 60 insertions(+), 10 deletions(-)

diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
index 9f6eb13..e478bd3 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
@@ -72,10 +72,23 @@ internal suspend fun waitWebView(
     state: WebViewState,
     timeoutMs: Long = 20_000,
 ) {
-    withTimeout(timeoutMs) {
-        // Windows recreates the native view once LocalTaoWindow HWND is ready โ€”
-        // wait for a live backend, not just a non-null DesktopWebView shell.
-        while (!state.webView?.nativeWebView.isLiveDesktopBackend()) delay(40)
+    try {
+        withTimeout(timeoutMs) {
+            // Windows recreates the native view once LocalTaoWindow HWND is ready โ€”
+            // wait for a live backend, not just a non-null DesktopWebView shell.
+            while (!state.webView?.nativeWebView.isLiveDesktopBackend()) delay(40)
+        }
+    } catch (t: kotlinx.coroutines.TimeoutCancellationException) {
+        val nv = state.webView?.nativeWebView
+        error(
+            "Desktop WebView native backend not ready after ${timeoutMs}ms " +
+                "(webView=${state.webView != null}, native=${nv?.let { it::class.simpleName }}, " +
+                "ready=${nv?.isReady()}). " +
+                "On Windows build natives first: " +
+                "`./gradlew :webview-compose:buildNativeWindows` " +
+                "or `webview-compose\\src\\jvmMain\\native\\windows\\build.bat` " +
+                "(requires MSVC + JAVA_HOME + WebView2 Runtime).",
+        )
     }
 }
 
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
index f09f095..c67da09 100644
--- a/demo/build.gradle.kts
+++ b/demo/build.gradle.kts
@@ -1,5 +1,6 @@
 import dev.nucleusframework.desktop.application.dsl.NativeImageMarch
 import dev.nucleusframework.desktop.application.dsl.TargetFormat
+import org.apache.tools.ant.taskdefs.condition.Os
 
 plugins {
     alias(libs.plugins.kotlinMultiplatform)
@@ -72,3 +73,13 @@ 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 {
+    if (Os.isFamily(Os.FAMILY_WINDOWS)) {
+        dependsOn(":webview-compose:buildNativeWindows")
+    } else if (Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC)) {
+        dependsOn(":webview-compose:buildNativeLinux")
+    }
+}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt
index 947d441..82c6d84 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt
@@ -13,10 +13,14 @@ import kotlin.system.exitProcess
 /**
  * Full visual e2e suite entrypoint.
  *
- * Runs a real Tao window + WebKit WebView and exercises the full desktop API.
- * Writes `/tmp/composewebview-visual-suite-report.txt` and exits 0/1.
+ * Runs a real Tao window + desktop WebView (WebKit2GTK / WebView2) and
+ * exercises the full desktop API. Writes a report under java.io.tmpdir
+ * (or `$COMPOSEWEBVIEW_SUITE_REPORT`) and exits 0/1.
  *
  *   ./gradlew :demo:run
+ *
+ * Exit code 1 means the suite reported failures (scroll up for the report),
+ * or the native backend failed to load.
  */
 fun main() {
     var exitCode = 1
@@ -33,6 +37,13 @@ fun main() {
             TitleBar { }
             VisualSuiteApp { passed, reportPath ->
                 println("SUITE_FINISHED passed=$passed report=$reportPath")
+                if (!passed) {
+                    System.err.println(
+                        "Visual e2e FAILED โ€” open report: $reportPath\n" +
+                            "If every case failed early, build natives: " +
+                            "./gradlew :webview-compose:buildNativeWindows  (or buildNativeLinux)",
+                    )
+                }
                 exitCode = if (passed) 0 else 1
                 exitApplication()
                 // Ensure process exit for CI / agent validation
diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts
index 59ae49e..b98f38f 100644
--- a/webview-compose/build.gradle.kts
+++ b/webview-compose/build.gradle.kts
@@ -55,17 +55,32 @@ val buildNativeWindows by tasks.registering(Exec::class) {
     val archDir =
         if (arch.contains("aarch64") || arch.contains("arm64")) "win32-aarch64" else "win32-x64"
     val checkFile = nativeResourceDir.file("$archDir/compose_webview_windows.dll").asFile
+    val loaderFile = nativeResourceDir.file("$archDir/WebView2Loader.dll").asFile
     onlyIf {
-        Os.isFamily(Os.FAMILY_WINDOWS) && !checkFile.exists()
+        Os.isFamily(Os.FAMILY_WINDOWS) && (!checkFile.exists() || !loaderFile.exists())
     }
     inputs.dir(nativeWindowsDir)
-    outputs.file(checkFile)
+    outputs.files(checkFile, loaderFile)
     workingDir(nativeWindowsDir.asFile)
     commandLine("cmd", "/c", "build.bat")
+    doLast {
+        check(checkFile.exists()) {
+            "buildNativeWindows finished but ${checkFile.name} is missing. " +
+                "Need MSVC (vcvarsall) + JAVA_HOME. Run: " +
+                "webview-compose\\src\\jvmMain\\native\\windows\\build.bat"
+        }
+        check(loaderFile.exists()) {
+            "buildNativeWindows finished but WebView2Loader.dll is missing next to ${checkFile.name}"
+        }
+    }
 }
 
-// Ensure JVM resources include the native lib when packaging on host OS.
-tasks.matching { it.name == "jvmProcessResources" || it.name == "processJvmMainResources" }.configureEach {
+// Ensure JVM resources / jar include the native lib when packaging on host OS.
+tasks.matching {
+    it.name == "jvmProcessResources" ||
+        it.name == "processJvmMainResources" ||
+        it.name == "jvmJar"
+}.configureEach {
     dependsOn(buildNativeLinux, buildNativeWindows)
 }
 

From 05d01d1579250d9953da6d8bba17666e597a3968 Mon Sep 17 00:00:00 2001
From: Elie Gambache 
Date: Thu, 6 Aug 2026 17:14:03 +0300
Subject: [PATCH 05/10] feat(macos): add WKWebView desktop backend with modular
 native units

Wire macOS into the same Tao NativeView path as Linux/Windows, split the
JNI/ObjC backend by domain, and extend visual e2e + CI natives for darwin.
---
 .github/workflows/build-natives.yaml          |  62 ++++-
 .github/workflows/pr-build-check.yml          |   2 +
 .github/workflows/publish-on-maven.yml        |   2 +
 AGENTS.md                                     |  15 +-
 README.md                                     |   6 +-
 .../webview/demo/visualsuite/SuiteHelpers.kt  |  18 +-
 .../webview/demo/visualsuite/SuiteRunner.kt   |   6 +
 .../demo/visualsuite/VisualSuiteApp.kt        |  80 +++---
 demo/build.gradle.kts                         |  17 +-
 .../dev/nucleusframework/webview/demo/main.kt |   4 +-
 .../webview/demo/WebViewTest.kt               |   2 +-
 webview-compose/README.md                     |   2 +-
 webview-compose/build.gradle.kts              |  26 +-
 .../webview/cookie/DesktopCookieManager.kt    |  19 +-
 .../webview/web/DesktopWebView.kt             |  10 +-
 .../webview/web/NativeWebView.desktop.kt      |   2 +-
 .../webview/web/WebViewDesktop.kt             |  40 ++-
 .../web/macos/MacOsWebKitNativeWebView.kt     | 228 ++++++++++++++++++
 .../webview/web/macos/WebKitMacOsBridge.kt    | 209 ++++++++++++++++
 .../src/jvmMain/native/macos/build.sh         |  70 ++++++
 .../native/macos/compose_webview_internal.h   |  51 ++++
 .../src/jvmMain/native/macos/cookies.m        | 186 ++++++++++++++
 .../src/jvmMain/native/macos/javascript.m     |  64 +++++
 .../src/jvmMain/native/macos/jni_bridge.m     | 144 +++++++++++
 .../src/jvmMain/native/macos/navigation.m     | 202 ++++++++++++++++
 .../src/jvmMain/native/macos/screenshot.m     |  34 +++
 .../src/jvmMain/native/macos/view_lifecycle.m | 167 +++++++++++++
 .../src/jvmMain/native/macos/view_signals.m   |  80 ++++++
 .../composewebview/native-image.properties    |   8 +-
 .../composewebview/reachability-metadata.json |  29 +++
 .../META-INF/proguard/composewebview.pro      |  20 +-
 .../webview/web/DesktopWebViewE2eTest.kt      |   8 +
 32 files changed, 1727 insertions(+), 86 deletions(-)
 create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt
 create mode 100644 webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt
 create mode 100755 webview-compose/src/jvmMain/native/macos/build.sh
 create mode 100644 webview-compose/src/jvmMain/native/macos/compose_webview_internal.h
 create mode 100644 webview-compose/src/jvmMain/native/macos/cookies.m
 create mode 100644 webview-compose/src/jvmMain/native/macos/javascript.m
 create mode 100644 webview-compose/src/jvmMain/native/macos/jni_bridge.m
 create mode 100644 webview-compose/src/jvmMain/native/macos/navigation.m
 create mode 100644 webview-compose/src/jvmMain/native/macos/screenshot.m
 create mode 100644 webview-compose/src/jvmMain/native/macos/view_lifecycle.m
 create mode 100644 webview-compose/src/jvmMain/native/macos/view_signals.m

diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml
index 6986461..387acda 100644
--- a/.github/workflows/build-natives.yaml
+++ b/.github/workflows/build-natives.yaml
@@ -3,13 +3,12 @@ name: Build Native Libraries
 on:
   workflow_call:
 
-# Native binaries are a pure function of the C/C++ sources. Each platform job
-# caches its output tree keyed on a hash of every src/**/native/** file (plus
-# this workflow). On a cache hit the compile steps are skipped and the job only
-# verifies + re-uploads the artifact for downstream consumers.
+# Native binaries are a pure function of the C/C++/ObjC sources. Each platform
+# job caches its output tree keyed on a hash of every src/**/native/** file
+# (plus this workflow). On a cache hit the compile steps are skipped and the
+# job only verifies + re-uploads the artifact for downstream consumers.
 #
-# Platforms: Linux (WebKit2GTK) + Windows (WebView2). macOS stays no-op until
-# the WKWebView backend lands.
+# Platforms: Linux (WebKit2GTK) + macOS (WKWebView) + Windows (WebView2).
 #
 # Bump the `natives-v1` prefix to force a full rebuild.
 
@@ -86,6 +85,57 @@ jobs:
           path: 'webview-compose/src/jvmMain/resources/nucleus/native/linux-*/'
           retention-days: 1
 
+  macos:
+    runs-on: macos-14
+    steps:
+      - name: Checkout Repo
+        uses: actions/checkout@v4
+
+      - name: Restore native output cache
+        id: natives-cache
+        uses: actions/cache@v4
+        with:
+          path: 'webview-compose/src/jvmMain/resources/nucleus/native/darwin-*'
+          key: natives-v1-macos-${{ hashFiles('webview-compose/src/jvmMain/native/**', '.github/workflows/build-natives.yaml') }}
+
+      - name: Setup JDK 21
+        if: steps.natives-cache.outputs.cache-hit != 'true'
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '21'
+
+      - name: Build compose WebView macOS native library
+        if: steps.natives-cache.outputs.cache-hit != 'true'
+        env:
+          JAVA_HOME: ${{ env.JAVA_HOME }}
+        run: bash webview-compose/src/jvmMain/native/macos/build.sh
+
+      - name: Verify macOS natives
+        run: |
+          MISSING=0
+          for arch in aarch64 x64; do
+            f="webview-compose/src/jvmMain/resources/nucleus/native/darwin-${arch}/libcompose_webview_macos.dylib"
+            if [ -f "$f" ]; then
+              echo "OK: $f ($(wc -c < "$f") bytes)"
+              file "$f" || true
+            else
+              echo "MISSING: $f" >&2
+              MISSING=1
+            fi
+          done
+          if [ "$MISSING" = "1" ]; then
+            find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true
+            exit 1
+          fi
+
+      - name: Upload macOS natives
+        uses: actions/upload-artifact@v4
+        with:
+          name: natives-macos
+          path: 'webview-compose/src/jvmMain/resources/nucleus/native/darwin-*/'
+          retention-days: 1
+
   windows:
     runs-on: windows-latest
     steps:
diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index b28f579..f6b5635 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -34,6 +34,8 @@ jobs:
             "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so"
             "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
             "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
+            "webview-compose/src/jvmMain/resources/nucleus/native/darwin-aarch64/libcompose_webview_macos.dylib"
+            "webview-compose/src/jvmMain/resources/nucleus/native/darwin-x64/libcompose_webview_macos.dylib"
           )
           MISSING=0
           for f in "${EXPECTED[@]}"; do
diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml
index 73e9ed4..9753727 100644
--- a/.github/workflows/publish-on-maven.yml
+++ b/.github/workflows/publish-on-maven.yml
@@ -30,6 +30,8 @@ jobs:
             "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so"
             "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
             "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
+            "webview-compose/src/jvmMain/resources/nucleus/native/darwin-aarch64/libcompose_webview_macos.dylib"
+            "webview-compose/src/jvmMain/resources/nucleus/native/darwin-x64/libcompose_webview_macos.dylib"
           )
           for f in "${EXPECTED[@]}"; do
             test -f "$f" || { echo "MISSING: $f" >&2; exit 1; }
diff --git a/AGENTS.md b/AGENTS.md
index ddb0af8..3e68b34 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,7 +6,7 @@
 - `webview-compose/`: Compose Multiplatform WebView library exposing `dev.nucleusframework.webview.*`
   (`WebView`, `WebViewState`, `WebViewNavigator`).
   - Shared API/types: `webview-compose/src/commonMain/kotlin/...`.
-  - Platform actuals: `.../src/jvmMain/` (desktop: Linux WebKit2GTK + Windows WebView2),
+  - Platform actuals: `.../src/jvmMain/` (desktop: Linux WebKit2GTK + macOS WKWebView + Windows WebView2),
     `.../src/androidMain/` (Android WebView),
     `.../src/iosMain/` (WKWebView + cinterop in `.../src/nativeInterop/`), `.../src/wasmJsMain/` (IFrame).
 - `webview-compose-test/`: JVM testing helpers (Playwright-backed mock WebView).
@@ -16,7 +16,7 @@
 
 - `./gradlew build`: builds all modules.
 - `./gradlew :demo:run`: runs the desktop demo via the Nucleus application plugin
-  (Tao backend + WebKit2GTK on Linux / WebView2 on Windows; visual e2e suite entrypoint).
+  (Tao backend + WebKit2GTK on Linux / WKWebView on macOS / WebView2 on Windows; visual e2e suite entrypoint).
 - `./gradlew :webview-compose:buildNativeLinux`: builds
   `libcompose_webview_linux.so` for the host arch into
   `webview-compose/src/jvmMain/resources/nucleus/native/linux-{x64,aarch64}/`
@@ -27,10 +27,15 @@
   `webview-compose/src/jvmMain/resources/nucleus/native/win32-{x64,aarch64}/`
   (requires MSVC, JDK with `JAVA_HOME`, network for first-run nuget WebView2 SDK).
   Or: `webview-compose\src\jvmMain\native\windows\build.bat`.
+- `./gradlew :webview-compose:buildNativeMacos`: builds
+  `libcompose_webview_macos.dylib` for **both** arm64 and x86_64 into
+  `webview-compose/src/jvmMain/resources/nucleus/native/darwin-{aarch64,x64}/`
+  (requires Xcode CLT + JDK). Or: `bash webview-compose/src/jvmMain/native/macos/build.sh`.
 - CI: `.github/workflows/build-natives.yaml` matrix builds **linux-x64**
-  (`ubuntu-latest`), **linux-aarch64** (`ubuntu-24.04-arm`), and **windows-x64**
-  (`windows-latest`), uploads artifacts; PR/publish workflows download them
-  before compile/publish. Natives are **not** committed (see `.gitignore`).
+  (`ubuntu-latest`), **linux-aarch64** (`ubuntu-24.04-arm`), **macos**
+  (`macos-14`, both darwin arches), and **windows-x64** (`windows-latest`),
+  uploads artifacts; PR/publish workflows download them before compile/publish.
+  Natives are **not** committed (see `.gitignore`).
 - GraalVM (demo): `nucleus.application { graalvm { isEnabled = true โ€ฆ } }`.
   Library reachability metadata lives under
   `webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/`
diff --git a/README.md b/README.md
index 95c5266..f6a3eac 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ If you already know **compose-webview-multiplatform**, you already know how to u
 
 * Multiplatform packaging under NucleusFramework (`dev.nucleusframework`)
 * **WasmJs** target via **IFrame**
-* Desktop (JVM) via **Nucleus Tao + NativeView** (Linux WebKit2GTK; Windows WebView2; macOS no-op for now)
+* Desktop (JVM) via **Nucleus Tao + NativeView** (Linux WebKit2GTK; macOS WKWebView; Windows WebView2)
 
 ---
 
@@ -36,7 +36,7 @@ If you already know **compose-webview-multiplatform**, you already know how to u
 - **Desktop**: Nucleus Tao `NativeView` (requires `nucleusApplication` / Tao backend).
   - **Linux**: WebKit2GTK (`libcompose_webview_linux.so`)
   - **Windows**: WebView2 CompositionController + DirectComposition (`compose_webview_windows.dll`; needs WebView2 Runtime / Edge)
-  - **macOS**: no-op until the WKWebView backend lands
+  - **macOS**: WKWebView (`libcompose_webview_macos.dylib`)
 
 ---
 
@@ -213,7 +213,7 @@ state.webSettings.logSeverity = KLogSeverity.Debug
 ## Limitations
 
 * RequestInterceptor does **not** intercept sub-resources
-* **Desktop**: requires Nucleus Tao (`nucleusApplication` + `decorated-window-tao`). Linux (WebKit2GTK) and Windows (WebView2) are fully wired; macOS is still no-op.
+* **Desktop**: requires Nucleus Tao (`nucleusApplication` + `decorated-window-tao`). Linux (WebKit2GTK), macOS (WKWebView) and Windows (WebView2) are fully wired.
 * **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
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
index e478bd3..30d9688 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteHelpers.kt
@@ -5,6 +5,7 @@ import dev.nucleusframework.webview.web.NativeWebView
 import dev.nucleusframework.webview.web.WebViewNavigator
 import dev.nucleusframework.webview.web.WebViewState
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView
 import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.delay
@@ -66,7 +67,11 @@ internal suspend fun evalJsUnquoted(
 internal fun NativeWebView?.isLiveDesktopBackend(): Boolean =
     this != null &&
         isReady() &&
-        (this is LinuxWebKitNativeWebView || this is WindowsWebView2NativeWebView)
+        (
+            this is LinuxWebKitNativeWebView ||
+                this is MacOsWebKitNativeWebView ||
+                this is WindowsWebView2NativeWebView
+            )
 
 internal suspend fun waitWebView(
     state: WebViewState,
@@ -78,16 +83,17 @@ internal suspend fun waitWebView(
             // wait for a live backend, not just a non-null DesktopWebView shell.
             while (!state.webView?.nativeWebView.isLiveDesktopBackend()) delay(40)
         }
-    } catch (t: kotlinx.coroutines.TimeoutCancellationException) {
+    } catch (_: kotlinx.coroutines.TimeoutCancellationException) {
         val nv = state.webView?.nativeWebView
         error(
             "Desktop WebView native backend not ready after ${timeoutMs}ms " +
                 "(webView=${state.webView != null}, native=${nv?.let { it::class.simpleName }}, " +
                 "ready=${nv?.isReady()}). " +
-                "On Windows build natives first: " +
-                "`./gradlew :webview-compose:buildNativeWindows` " +
-                "or `webview-compose\\src\\jvmMain\\native\\windows\\build.bat` " +
-                "(requires MSVC + JAVA_HOME + WebView2 Runtime).",
+                "Build host natives first: " +
+                "Linux `./gradlew :webview-compose:buildNativeLinux`, " +
+                "macOS `./gradlew :webview-compose:buildNativeMacos`, " +
+                "Windows `./gradlew :webview-compose:buildNativeWindows` " +
+                "(MSVC + JAVA_HOME + WebView2 Runtime).",
         )
     }
 }
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt
index 2b51386..401cfa9 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/SuiteRunner.kt
@@ -7,6 +7,7 @@ import dev.nucleusframework.webview.web.NativeWebView
 import dev.nucleusframework.webview.web.WebContent
 import dev.nucleusframework.webview.web.WebViewFileReadType
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView
 import dev.nucleusframework.webview.web.toAwtImage
 import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.async
@@ -347,6 +348,11 @@ internal suspend fun runFullSuite(
                 delay(80)
                 native.setZoomLevel(1.0)
             }
+            is MacOsWebKitNativeWebView -> {
+                native.setZoomLevel(1.25)
+                delay(80)
+                native.setZoomLevel(1.0)
+            }
             is WindowsWebView2NativeWebView -> {
                 native.setZoomLevel(1.25)
                 delay(80)
diff --git a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt
index 6cca08c..1fcd230 100644
--- a/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt
+++ b/demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/VisualSuiteApp.kt
@@ -58,7 +58,7 @@ private val TextDim = Color(0xFF93A0B8)
 
 /**
  * Full visual e2e suite: real Tao window, real desktop WebView
- * (WebKit2GTK on Linux / WebView2 on Windows), every major API path.
+ * (WebKit2GTK / WKWebView / WebView2), every major API path.
  * Writes a machine-readable report and calls [onFinished] with success flag.
  */
 @Composable
@@ -185,37 +185,59 @@ fun VisualSuiteApp(
                 getOnCreatedFired = { onCreatedFired },
             )
         val started = System.currentTimeMillis()
-        runFullSuite(ctx) { id, status, detail ->
-            currentId = id
-            updateCase(id, status, detail)
+        var path = ""
+        var passed = false
+        try {
+            runFullSuite(ctx) { id, status, detail ->
+                currentId = id
+                updateCase(id, status, detail)
+                summary =
+                    when (status) {
+                        CaseStatus.Running -> "Running $idโ€ฆ"
+                        CaseStatus.Passed -> "$id PASS"
+                        CaseStatus.Failed -> "$id FAIL: $detail"
+                        CaseStatus.Skipped -> "$id SKIP: $detail"
+                        else -> summary
+                    }
+            }
+        } catch (t: Throwable) {
+            // Real cancellation (window disposed) must propagate.
+            if (t is kotlinx.coroutines.CancellationException) throw t
+            // Other failures (incl. waitWebView error()) must still exit the demo.
+            val msg = t.message ?: t::class.simpleName ?: "suite aborted"
+            summary = "ABORTED: $msg"
+            if (cases.none { it.status == CaseStatus.Failed }) {
+                val firstPending = cases.indexOfFirst { it.status == CaseStatus.Pending }
+                if (firstPending >= 0) {
+                    updateCase(cases[firstPending].id, CaseStatus.Failed, msg)
+                }
+            }
+        } finally {
+            val finished = System.currentTimeMillis()
+            val report =
+                SuiteReport(
+                    startedAtMs = started,
+                    finishedAtMs = finished,
+                    cases = cases.toList(),
+                )
+            path = writeSuiteReport(report)
+            passed = report.allGreen
             summary =
-                when (status) {
-                    CaseStatus.Running -> "Running $idโ€ฆ"
-                    CaseStatus.Passed -> "$id PASS"
-                    CaseStatus.Failed -> "$id FAIL: $detail"
-                    CaseStatus.Skipped -> "$id SKIP: $detail"
-                    else -> summary
+                if (passed) {
+                    "ALL GREEN  ${report.passed}/${report.total}  (${finished - started}ms)"
+                } else {
+                    "FAILED  pass=${report.passed} fail=${report.failed} skip=${report.skipped}  report=$path"
                 }
-        }
-        val finished = System.currentTimeMillis()
-        val report =
-            SuiteReport(
-                startedAtMs = started,
-                finishedAtMs = finished,
-                cases = cases.toList(),
-            )
-        val path = writeSuiteReport(report)
-        val passed = report.allGreen
-        summary =
-            if (passed) {
-                "ALL GREEN  ${report.passed}/${report.total}  (${finished - started}ms)"
-            } else {
-                "FAILED  pass=${report.passed} fail=${report.failed} skip=${report.skipped}  report=$path"
+            done = true
+            // Keep the window visible long enough to inspect, then notify host.
+            // Use NonCancellable delay path isn't needed; host exitProcess cleans up.
+            try {
+                delay(if (passed) 1500 else 4000)
+            } catch (_: Throwable) {
+                // ignore cancellation during teardown
             }
-        done = true
-        // Keep the window visible long enough to inspect, then notify host.
-        delay(if (passed) 2500 else 8000)
-        onFinished(passed, path)
+            onFinished(passed, path)
+        }
     }
 
     // Auto-scroll running case into view
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
index c67da09..d706153 100644
--- a/demo/build.gradle.kts
+++ b/demo/build.gradle.kts
@@ -49,12 +49,6 @@ nucleus.application {
         providers.gradleProperty("nativeMarch").orNull?.let {
             march = NativeImageMarch.valueOf(it.uppercase())
         }
-        buildArgs.addAll(
-            "-H:+AddAllCharsets",
-            "-Djava.awt.headless=false",
-            "-Os",
-            "-H:-IncludeMethodData",
-        )
     }
 
     nativeDistributions {
@@ -77,9 +71,12 @@ 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 {
-    if (Os.isFamily(Os.FAMILY_WINDOWS)) {
-        dependsOn(":webview-compose:buildNativeWindows")
-    } else if (Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC)) {
-        dependsOn(":webview-compose:buildNativeLinux")
+    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")
     }
 }
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt
index 82c6d84..02aad9f 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/main.kt
@@ -13,8 +13,8 @@ import kotlin.system.exitProcess
 /**
  * Full visual e2e suite entrypoint.
  *
- * Runs a real Tao window + desktop WebView (WebKit2GTK / WebView2) and
- * exercises the full desktop API. Writes a report under java.io.tmpdir
+ * Runs a real Tao window + desktop WebView (WebKit2GTK / WKWebView / WebView2)
+ * and exercises the full desktop API. Writes a report under java.io.tmpdir
  * (or `$COMPOSEWEBVIEW_SUITE_REPORT`) and exits 0/1.
  *
  *   ./gradlew :demo:run
diff --git a/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
index 5e14aad..cc99ce6 100644
--- a/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
+++ b/demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
@@ -3,7 +3,7 @@ package dev.nucleusframework.webview.demo
 /**
  * Desktop e2e coverage lives in
  * `:webview-compose` โ†’ `DesktopWebViewE2eTest`
- * (real WebKit2GTK / WebView2 + Tao/NativeView).
+ * (real WebKit2GTK / WKWebView / WebView2 + Tao/NativeView).
  *
  * Synthetic Playwright tests were removed; run:
  * `./gradlew :webview-compose:jvmTest`
diff --git a/webview-compose/README.md b/webview-compose/README.md
index c4a7acc..cd9c42f 100644
--- a/webview-compose/README.md
+++ b/webview-compose/README.md
@@ -31,4 +31,4 @@ fun App() {
 - **Android**: `android.webkit.WebView`
 - **iOS**: `WKWebView`
 - **WasmJs**: `HTMLIFrameElement`
-- **Desktop (JVM)**: Nucleus Tao `NativeView` โ€” WebKit2GTK (Linux), WebView2 (Windows); macOS no-op for now
+- **Desktop (JVM)**: Nucleus Tao `NativeView` โ€” WebKit2GTK (Linux), WKWebView (macOS), WebView2 (Windows)
diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts
index b98f38f..4ef4bed 100644
--- a/webview-compose/build.gradle.kts
+++ b/webview-compose/build.gradle.kts
@@ -20,13 +20,14 @@ tasks.withType().configureEach {
     maxParallelForks = 1
 }
 
-// โ”€โ”€ Native build (Linux WebKit2GTK / Windows WebView2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// โ”€โ”€ Native build (Linux / macOS / Windows) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 // Same pattern as Nucleus: compile host-arch natives into
-// src/jvmMain/resources/nucleus/native/{linux,win32}-{x64,aarch64}/.
+// src/jvmMain/resources/nucleus/native/{linux,darwin,win32}-{x64,aarch64}/.
 // CI builds via matrix and downloads artifacts before package/publish.
-// Locally, only the host arch is built (and only if the artifact is missing).
+// Locally, only the host platform is built (and only if the artifact is missing).
 
 val nativeLinuxDir = layout.projectDirectory.dir("src/jvmMain/native/linux")
+val nativeMacosDir = layout.projectDirectory.dir("src/jvmMain/native/macos")
 val nativeWindowsDir = layout.projectDirectory.dir("src/jvmMain/native/windows")
 val nativeResourceDir = layout.projectDirectory.dir("src/jvmMain/resources/nucleus/native")
 
@@ -48,6 +49,21 @@ val buildNativeLinux by tasks.registering(Exec::class) {
     commandLine("bash", "build.sh")
 }
 
+val buildNativeMacos by tasks.registering(Exec::class) {
+    description = "Compiles the WKWebView JNI backend into libcompose_webview_macos.dylib"
+    group = "build"
+    // build.sh produces both arm64 and x86_64 dylibs.
+    val checkArm = nativeResourceDir.file("darwin-aarch64/libcompose_webview_macos.dylib").asFile
+    val checkX64 = nativeResourceDir.file("darwin-x64/libcompose_webview_macos.dylib").asFile
+    onlyIf {
+        Os.isFamily(Os.FAMILY_MAC) && (!checkArm.exists() || !checkX64.exists())
+    }
+    inputs.dir(nativeMacosDir)
+    outputs.files(checkArm, checkX64)
+    workingDir(nativeMacosDir.asFile)
+    commandLine("bash", "build.sh")
+}
+
 val buildNativeWindows by tasks.registering(Exec::class) {
     description = "Compiles the WebView2 JNI backend into compose_webview_windows.dll"
     group = "build"
@@ -81,12 +97,12 @@ tasks.matching {
         it.name == "processJvmMainResources" ||
         it.name == "jvmJar"
 }.configureEach {
-    dependsOn(buildNativeLinux, buildNativeWindows)
+    dependsOn(buildNativeLinux, buildNativeMacos, buildNativeWindows)
 }
 
 tasks.configureEach {
     if (name == "sourcesJar" || name == "jvmSourcesJar") {
-        dependsOn(buildNativeLinux, buildNativeWindows)
+        dependsOn(buildNativeLinux, buildNativeMacos, buildNativeWindows)
     }
 }
 
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt
index d2f0e29..d0f3d06 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt
@@ -3,6 +3,7 @@ package dev.nucleusframework.webview.cookie
 import dev.nucleusframework.webview.util.KLogger
 import dev.nucleusframework.webview.web.NativeWebView
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView
 import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
@@ -11,8 +12,8 @@ import kotlinx.serialization.Serializable
 import kotlinx.serialization.json.Json
 
 /**
- * Desktop cookie manager. Backed by WebKit2GTK on Linux and WebView2 on
- * Windows. No-op on other desktop platforms.
+ * Desktop cookie manager. Backed by WebKit2GTK on Linux, WKWebView on macOS,
+ * and WebView2 on Windows.
  */
 internal class DesktopCookieManager : CookieManager {
     @Volatile
@@ -55,6 +56,17 @@ internal class DesktopCookieManager : CookieManager {
                         expiresMs = cookie.expiresDate ?: 0L,
                         sameSite = sameSiteString(cookie),
                     )
+                is MacOsWebKitNativeWebView ->
+                    native.setCookieNative(
+                        name = cookie.name,
+                        value = cookie.value,
+                        domain = domain,
+                        path = cookie.path ?: "/",
+                        secure = cookie.isSecure == true,
+                        httpOnly = cookie.isHttpOnly == true,
+                        expiresMs = cookie.expiresDate ?: 0L,
+                        sameSite = sameSiteString(cookie),
+                    )
                 is WindowsWebView2NativeWebView ->
                     native.setCookieNative(
                         name = cookie.name,
@@ -78,6 +90,7 @@ internal class DesktopCookieManager : CookieManager {
                 val raw =
                     when (native) {
                         is LinuxWebKitNativeWebView -> native.getCookiesJson(url)
+                        is MacOsWebKitNativeWebView -> native.getCookiesJson(url)
                         is WindowsWebView2NativeWebView -> native.getCookiesJson(url)
                         else -> return@withContext emptyList()
                     }
@@ -95,6 +108,7 @@ internal class DesktopCookieManager : CookieManager {
             runCatching {
                 when (native) {
                     is LinuxWebKitNativeWebView -> native.removeAllCookiesNative()
+                    is MacOsWebKitNativeWebView -> native.removeAllCookiesNative()
                     is WindowsWebView2NativeWebView -> native.removeAllCookiesNative()
                     else -> Unit
                 }
@@ -108,6 +122,7 @@ internal class DesktopCookieManager : CookieManager {
             runCatching {
                 when (native) {
                     is LinuxWebKitNativeWebView -> native.removeCookiesForUrlNative(url)
+                    is MacOsWebKitNativeWebView -> native.removeCookiesForUrlNative(url)
                     is WindowsWebView2NativeWebView -> native.removeCookiesForUrlNative(url)
                     else -> Unit
                 }
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt
index 8e8ec9c..61a7ff9 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt
@@ -3,6 +3,7 @@ package dev.nucleusframework.webview.web
 import dev.nucleusframework.webview.jsbridge.WebViewJsBridge
 import dev.nucleusframework.webview.util.KLogger
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
+import dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView
 import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import java.net.URL
 import kotlinx.coroutines.CoroutineScope
@@ -10,9 +11,8 @@ import kotlinx.coroutines.CoroutineScope
 /**
  * Desktop [IWebView] implementation.
  *
- * On Linux (WebKit2GTK) and Windows (WebView2) via Tao/NativeView all
- * operations hit the real native backend. On other desktop OSes this
- * remains a no-op shell.
+ * On Linux (WebKit2GTK), macOS (WKWebView) and Windows (WebView2) via
+ * Tao/NativeView all operations hit the real native backend.
  */
 internal class DesktopWebView(
     override val nativeWebView: NativeWebView,
@@ -44,6 +44,7 @@ internal class DesktopWebView(
         if (html == null) return
         when (val native = nativeWebView) {
             is LinuxWebKitNativeWebView -> native.loadHtml(html, baseUrl)
+            is MacOsWebKitNativeWebView -> native.loadHtml(html, baseUrl)
             is WindowsWebView2NativeWebView -> native.loadHtml(html, baseUrl)
             else -> nativeWebView.loadHtml(html)
         }
@@ -122,6 +123,7 @@ internal class DesktopWebView(
     override suspend fun captureScreenshotOrNull(): ByteArray? {
         when (val native = nativeWebView) {
             is LinuxWebKitNativeWebView -> return native.captureScreenshotAsync()
+            is MacOsWebKitNativeWebView -> return native.captureScreenshotAsync()
             is WindowsWebView2NativeWebView -> return native.captureScreenshotAsync()
         }
         return nativeWebView.captureScreenshotNative()
@@ -144,7 +146,7 @@ internal class DesktopWebView(
 
     override fun initJsBridge(webViewJsBridge: WebViewJsBridge) {
         // IPC is wired natively:
-        //  - Linux: WebKit user-content script message handler "ipc"
+        //  - Linux/macOS: WebKit user-content script message handler "ipc"
         //  - Windows: window.ipc -> chrome.webview.postMessage
     }
 }
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt
index bbc9690..ad65fc5 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt
@@ -5,8 +5,8 @@ package dev.nucleusframework.webview.web
  *
  * With the Tao backend, the default factory creates:
  *  - [dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView] on Linux
+ *  - [dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView] on macOS
  *  - [dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView] on Windows
- * macOS remains a no-op until the WKWebView backend lands.
  */
 actual open class NativeWebView {
     open fun isReady(): Boolean = false
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt
index 98fca91..99a7f50 100644
--- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt
@@ -17,6 +17,8 @@ import dev.nucleusframework.webview.request.WebRequest
 import dev.nucleusframework.webview.request.WebRequestInterceptResult
 import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView
 import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge
+import dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView
+import dev.nucleusframework.webview.web.macos.WebKitMacOsBridge
 import dev.nucleusframework.webview.web.windows.WebView2WindowsBridge
 import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
 import dev.nucleusframework.window.tao.LocalTaoWindow
@@ -37,9 +39,8 @@ actual class WebViewFactoryParam(
 )
 
 /**
- * Default factory: real WebKit2GTK on Linux / WebView2 on Windows when the
- * native lib loads and (Windows) a parent HWND is available; no-op on macOS
- * or when the lib / HWND is missing.
+ * Default factory: real WebKit2GTK on Linux, WKWebView on macOS, WebView2 on
+ * Windows when the native lib loads (and Windows parent HWND is available).
  */
 actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView {
     val settings = param.state.webSettings
@@ -66,6 +67,20 @@ actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView {
         )
     }
 
+    if (Platform.Current == Platform.MacOS && WebKitMacOsBridge.isLoaded) {
+        return MacOsWebKitNativeWebView(
+            customUserAgent = settings.customUserAgentString,
+            dataDirectory = desktop.dataDirectory,
+            initScript = desktop.initScript,
+            incognito = desktop.incognito,
+            enableDevtools = desktop.enableDevtools,
+            javascriptEnabled = settings.isJavaScriptEnabled,
+            zoomLevel = settings.zoomLevel,
+            transparent = desktop.transparent,
+            backgroundColor = background,
+        )
+    }
+
     if (
         Platform.Current == Platform.Windows &&
         WebView2WindowsBridge.isLoaded &&
@@ -89,14 +104,16 @@ actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView {
 }
 
 private fun NativeWebView.isLiveBackend(): Boolean =
-    this is LinuxWebKitNativeWebView || this is WindowsWebView2NativeWebView
+    this is LinuxWebKitNativeWebView ||
+        this is MacOsWebKitNativeWebView ||
+        this is WindowsWebView2NativeWebView
 
 /**
  * Desktop WebView composable.
  *
  * **Linux + Tao**: embeds a real WebKit2GTK view via [NativeView].
+ * **macOS + Tao**: embeds a real WKWebView via [NativeView].
  * **Windows + Tao**: embeds a real WebView2 view via [NativeView] (DComp).
- * **macOS**: empty box (no-op backend until WKWebView lands).
  *
  * Outside a Tao [dev.nucleusframework.application.DecoratedWindow],
  * [NativeView] falls back to an empty box โ€” the WebView only works with
@@ -266,6 +283,7 @@ actual fun ActualWebView(
     }
 
     val linuxWebView = nativeWebView as? LinuxWebKitNativeWebView
+    val macosWebView = nativeWebView as? MacOsWebKitNativeWebView
     val windowsWebView = nativeWebView as? WindowsWebView2NativeWebView
     when {
         linuxWebView != null && LocalWebViewFactory.current == null -> {
@@ -278,6 +296,16 @@ actual fun ActualWebView(
                 onCreated(nativeWebView)
             }
         }
+        macosWebView != null && LocalWebViewFactory.current == null -> {
+            NativeView(
+                factory = { macosWebView.asPlatformView() },
+                modifier = modifier,
+                update = { },
+            )
+            LaunchedEffect(nativeWebView) {
+                onCreated(nativeWebView)
+            }
+        }
         windowsWebView != null && LocalWebViewFactory.current == null -> {
             NativeView(
                 factory = { windowsWebView.asPlatformView() },
@@ -289,7 +317,7 @@ actual fun ActualWebView(
             }
         }
         else -> {
-            // Test factory / non-Linux-non-Windows / no-op: empty layout slot.
+            // Test factory / unsupported / no-op: empty layout slot.
             Box(modifier) {
                 LaunchedEffect(nativeWebView) {
                     onCreated(nativeWebView)
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt
new file mode 100644
index 0000000..c8315a0
--- /dev/null
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt
@@ -0,0 +1,228 @@
+package dev.nucleusframework.webview.web.macos
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import dev.nucleusframework.webview.web.NativeWebView
+import dev.nucleusframework.window.tao.NucleusPlatformView
+import kotlinx.coroutines.CompletableDeferred
+
+/**
+ * macOS [NativeWebView] backed by a real WKWebView, embeddable via
+ * [NucleusPlatformView.NsView] / Nucleus [dev.nucleusframework.window.tao.NativeView].
+ *
+ * Requires the Tao window backend.
+ */
+class MacOsWebKitNativeWebView(
+    customUserAgent: String? = null,
+    dataDirectory: String? = null,
+    initScript: String? = null,
+    incognito: Boolean = false,
+    enableDevtools: Boolean = false,
+    javascriptEnabled: Boolean = true,
+    zoomLevel: Double = 1.0,
+    transparent: Boolean = false,
+    backgroundColor: Color = Color.White,
+) : NativeWebView() {
+    private val handle: Long
+    private val nsViewHandle: Long
+    private var released = false
+
+    init {
+        require(WebKitMacOsBridge.isLoaded) {
+            "compose_webview_macos native library is not available"
+        }
+        val effective =
+            if (transparent) {
+                backgroundColor
+            } else if (backgroundColor.alpha < 1f) {
+                Color.White
+            } else {
+                backgroundColor.copy(alpha = 1f)
+            }
+        val argb = effective.toArgb()
+        val a = ((argb ushr 24) and 0xFF) / 255f
+        val r = ((argb ushr 16) and 0xFF) / 255f
+        val g = ((argb ushr 8) and 0xFF) / 255f
+        val b = (argb and 0xFF) / 255f
+        handle = WebKitMacOsBridge.nativeCreate(
+            userAgent = customUserAgent?.trim()?.takeIf { it.isNotEmpty() },
+            dataDirectory = dataDirectory?.trim()?.takeIf { it.isNotEmpty() },
+            initScript = initScript?.trim()?.takeIf { it.isNotEmpty() },
+            incognito = incognito,
+            enableDevtools = enableDevtools,
+            javascriptEnabled = javascriptEnabled,
+            zoomLevel = zoomLevel,
+            transparent = transparent,
+            bgR = r,
+            bgG = g,
+            bgB = b,
+            bgA = a,
+        )
+        require(handle != 0L) { "Failed to create WKWebView" }
+        nsViewHandle = WebKitMacOsBridge.nativeGetNsView(handle)
+        require(nsViewHandle != 0L) { "Failed to get NSView handle" }
+    }
+
+    /** Creates the [NucleusPlatformView] used by NativeView embedding. */
+    fun asPlatformView(): NucleusPlatformView.NsView =
+        object : NucleusPlatformView.NsView {
+            override val nsViewHandle: Long
+                get() = this@MacOsWebKitNativeWebView.nsViewHandle
+
+            override fun dispose() {
+                // Lifecycle owned by NativeWebView.destroy(); NativeView
+                // also calls dispose โ€” keep it idempotent.
+            }
+        }
+
+    override fun isReady(): Boolean = !released && handle != 0L
+
+    override fun isLoading(): Boolean =
+        if (!isReady()) false else WebKitMacOsBridge.nativeIsLoading(handle)
+
+    override fun getCurrentUrl(): String? =
+        if (!isReady()) null else WebKitMacOsBridge.nativeCurrentUrl(handle)
+
+    override fun getTitle(): String? =
+        if (!isReady()) null else WebKitMacOsBridge.nativeGetTitle(handle)
+
+    override fun canGoBack(): Boolean =
+        if (!isReady()) false else WebKitMacOsBridge.nativeCanGoBack(handle)
+
+    override fun canGoForward(): Boolean =
+        if (!isReady()) false else WebKitMacOsBridge.nativeCanGoForward(handle)
+
+    override fun loadUrl(url: String, additionalHttpHeaders: Map) {
+        if (!isReady()) return
+        if (additionalHttpHeaders.isEmpty()) {
+            WebKitMacOsBridge.nativeLoadUrl(handle, url)
+        } else {
+            val names = additionalHttpHeaders.keys.toTypedArray()
+            val values = additionalHttpHeaders.values.toTypedArray()
+            WebKitMacOsBridge.nativeLoadUrlWithHeaders(handle, url, names, values)
+        }
+    }
+
+    override fun loadHtml(html: String) {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeLoadHtml(handle, html, null)
+    }
+
+    fun loadHtml(html: String, baseUri: String?) {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeLoadHtml(handle, html, baseUri)
+    }
+
+    override fun goBack() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeGoBack(handle)
+    }
+
+    override fun goForward() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeGoForward(handle)
+    }
+
+    override fun reload() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeReload(handle)
+    }
+
+    override fun stopLoading() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeStopLoading(handle)
+    }
+
+    override fun evaluateJavaScript(script: String, callback: (String) -> Unit) {
+        if (!isReady()) {
+            callback("")
+            return
+        }
+        WebKitMacOsBridge.registerJsCallback(handle, callback)
+        WebKitMacOsBridge.nativeEvaluateJavaScript(handle, script)
+    }
+
+    override fun drainIpcMessages(): List =
+        if (!isReady()) emptyList() else WebKitMacOsBridge.drainIpcMessages(handle)
+
+    override fun addNavigateListener(listener: (String) -> Boolean) {
+        if (!isReady()) return
+        WebKitMacOsBridge.addNavigateListener(handle, listener)
+    }
+
+    override fun removeNavigateListener(listener: (String) -> Boolean) {
+        if (!isReady()) return
+        WebKitMacOsBridge.removeNavigateListener(handle, listener)
+    }
+
+    override fun captureScreenshotNative(): ByteArray? = null
+
+    suspend fun captureScreenshotAsync(): ByteArray? {
+        if (!isReady()) return null
+        val deferred = CompletableDeferred()
+        WebKitMacOsBridge.registerScreenshotDeferred(handle, deferred)
+        WebKitMacOsBridge.nativeCaptureScreenshot(handle)
+        return deferred.await()
+    }
+
+    suspend fun getCookiesJson(url: String): String {
+        if (!isReady()) return "[]"
+        val deferred = CompletableDeferred()
+        WebKitMacOsBridge.registerCookieDeferred(handle, deferred)
+        WebKitMacOsBridge.nativeGetCookies(handle, url)
+        return deferred.await()
+    }
+
+    fun setCookieNative(
+        name: String,
+        value: String,
+        domain: String?,
+        path: String?,
+        secure: Boolean,
+        httpOnly: Boolean,
+        expiresMs: Long,
+        sameSite: String?,
+    ) {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeSetCookie(
+            handle, name, value, domain, path, secure, httpOnly, expiresMs, sameSite,
+        )
+    }
+
+    fun removeAllCookiesNative() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeRemoveAllCookies(handle)
+    }
+
+    fun removeCookiesForUrlNative(url: String) {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeRemoveCookiesForUrl(handle, url)
+    }
+
+    fun setZoomLevel(zoom: Double) {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeSetZoomLevel(handle, zoom)
+    }
+
+    override fun openDevTools() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeOpenDevTools(handle)
+    }
+
+    override fun closeDevTools() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeCloseDevTools(handle)
+    }
+
+    override fun focus() {
+        if (!isReady()) return
+        WebKitMacOsBridge.nativeFocus(handle)
+    }
+
+    override fun destroy() {
+        if (released) return
+        released = true
+        WebKitMacOsBridge.clearHandle(handle)
+        WebKitMacOsBridge.nativeRelease(handle)
+    }
+}
diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt
new file mode 100644
index 0000000..619a364
--- /dev/null
+++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt
@@ -0,0 +1,209 @@
+package dev.nucleusframework.webview.web.macos
+
+import dev.nucleusframework.core.runtime.NativeLibraryLoader
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ConcurrentLinkedQueue
+import kotlinx.coroutines.CompletableDeferred
+
+/**
+ * JNI bridge to `compose_webview_macos.m` (WKWebView).
+ *
+ * Loaded only on macOS. All native calls must run on the AppKit main thread
+ * (Tao application thread).
+ */
+internal object WebKitMacOsBridge {
+    private const val LIBRARY_NAME = "compose_webview_macos"
+
+    val isLoaded: Boolean =
+        NativeLibraryLoader.load(
+            LIBRARY_NAME,
+            WebKitMacOsBridge::class.java,
+        )
+
+    private val navigateHandlers =
+        ConcurrentHashMap Boolean>>()
+    private val ipcQueues =
+        ConcurrentHashMap>()
+    private val jsCallbacks =
+        ConcurrentHashMap Unit>>()
+    private val cookieDeferreds =
+        ConcurrentHashMap>>()
+    private val screenshotDeferreds =
+        ConcurrentHashMap>>()
+
+    fun addNavigateListener(handle: Long, listener: (String) -> Boolean) {
+        navigateHandlers.getOrPut(handle) { mutableListOf() }.add(listener)
+    }
+
+    fun removeNavigateListener(handle: Long, listener: (String) -> Boolean) {
+        navigateHandlers[handle]?.remove(listener)
+    }
+
+    fun drainIpcMessages(handle: Long): List {
+        val queue = ipcQueues[handle] ?: return emptyList()
+        val drained = ArrayList()
+        while (true) {
+            val next = queue.poll() ?: break
+            drained += next
+        }
+        return drained
+    }
+
+    fun registerJsCallback(handle: Long, callback: (String) -> Unit) {
+        jsCallbacks.getOrPut(handle) { ConcurrentLinkedQueue() }.add(callback)
+    }
+
+    fun registerCookieDeferred(handle: Long, deferred: CompletableDeferred) {
+        cookieDeferreds.getOrPut(handle) { ConcurrentLinkedQueue() }.add(deferred)
+    }
+
+    fun registerScreenshotDeferred(handle: Long, deferred: CompletableDeferred) {
+        screenshotDeferreds.getOrPut(handle) { ConcurrentLinkedQueue() }.add(deferred)
+    }
+
+    fun clearHandle(handle: Long) {
+        navigateHandlers.remove(handle)
+        ipcQueues.remove(handle)
+        jsCallbacks.remove(handle)?.forEach { it.invoke("") }
+        cookieDeferreds.remove(handle)?.forEach {
+            it.complete("[]")
+        }
+        screenshotDeferreds.remove(handle)?.forEach {
+            it.complete(null)
+        }
+    }
+
+    // โ”€โ”€ Callbacks from native (must be public for JNI) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+    @JvmStatic
+    fun nativeOnNavigate(handle: Long, url: String): Boolean {
+        val handlers = navigateHandlers[handle]
+        if (handlers.isNullOrEmpty()) return true
+        return handlers.any { it(url) }
+    }
+
+    @JvmStatic
+    fun nativeOnIpcMessage(handle: Long, message: String) {
+        ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(message)
+    }
+
+    @JvmStatic
+    fun nativeOnJsResult(handle: Long, result: String) {
+        jsCallbacks[handle]?.poll()?.invoke(result)
+    }
+
+    @JvmStatic
+    fun nativeOnCookiesResult(handle: Long, json: String) {
+        cookieDeferreds[handle]?.poll()?.complete(json)
+    }
+
+    @JvmStatic
+    fun nativeOnScreenshotResult(handle: Long, bytes: ByteArray?) {
+        screenshotDeferreds[handle]?.poll()?.complete(bytes)
+    }
+
+    // โ”€โ”€ Native methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+    @JvmStatic
+    external fun nativeCreate(
+        userAgent: String?,
+        dataDirectory: String?,
+        initScript: String?,
+        incognito: Boolean,
+        enableDevtools: Boolean,
+        javascriptEnabled: Boolean,
+        zoomLevel: Double,
+        transparent: Boolean,
+        bgR: Float,
+        bgG: Float,
+        bgB: Float,
+        bgA: Float,
+    ): Long
+
+    @JvmStatic
+    external fun nativeGetNsView(handle: Long): Long
+
+    @JvmStatic
+    external fun nativeRelease(handle: Long)
+
+    @JvmStatic
+    external fun nativeLoadUrl(handle: Long, url: String)
+
+    @JvmStatic
+    external fun nativeLoadUrlWithHeaders(
+        handle: Long,
+        url: String,
+        headerNames: Array,
+        headerValues: Array,
+    )
+
+    @JvmStatic
+    external fun nativeLoadHtml(handle: Long, html: String, baseUri: String?)
+
+    @JvmStatic
+    external fun nativeGoBack(handle: Long)
+
+    @JvmStatic
+    external fun nativeGoForward(handle: Long)
+
+    @JvmStatic
+    external fun nativeReload(handle: Long)
+
+    @JvmStatic
+    external fun nativeStopLoading(handle: Long)
+
+    @JvmStatic
+    external fun nativeCanGoBack(handle: Long): Boolean
+
+    @JvmStatic
+    external fun nativeCanGoForward(handle: Long): Boolean
+
+    @JvmStatic
+    external fun nativeCurrentUrl(handle: Long): String?
+
+    @JvmStatic
+    external fun nativeGetTitle(handle: Long): String?
+
+    @JvmStatic
+    external fun nativeIsLoading(handle: Long): Boolean
+
+    @JvmStatic
+    external fun nativeSetZoomLevel(handle: Long, zoom: Double)
+
+    @JvmStatic
+    external fun nativeFocus(handle: Long)
+
+    @JvmStatic
+    external fun nativeOpenDevTools(handle: Long)
+
+    @JvmStatic
+    external fun nativeCloseDevTools(handle: Long)
+
+    @JvmStatic
+    external fun nativeEvaluateJavaScript(handle: Long, script: String)
+
+    @JvmStatic
+    external fun nativeGetCookies(handle: Long, url: String)
+
+    @JvmStatic
+    external fun nativeSetCookie(
+        handle: Long,
+        name: String,
+        value: String,
+        domain: String?,
+        path: String?,
+        secure: Boolean,
+        httpOnly: Boolean,
+        expiresMs: Long,
+        sameSite: String?,
+    )
+
+    @JvmStatic
+    external fun nativeRemoveAllCookies(handle: Long)
+
+    @JvmStatic
+    external fun nativeRemoveCookiesForUrl(handle: Long, url: String)
+
+    @JvmStatic
+    external fun nativeCaptureScreenshot(handle: Long)
+}
diff --git a/webview-compose/src/jvmMain/native/macos/build.sh b/webview-compose/src/jvmMain/native/macos/build.sh
new file mode 100755
index 0000000..ea48fe1
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/build.sh
@@ -0,0 +1,70 @@
+#!/bin/bash
+# Compiles the compose WKWebView JNI backend into per-architecture dylibs.
+#
+# Outputs:
+#   webview-compose/src/jvmMain/resources/nucleus/native/darwin-{x64,aarch64}/
+#     libcompose_webview_macos.dylib
+#
+# Prerequisites: Xcode command-line tools (clang) + JDK (jni.h).
+# Usage: ./build.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native"
+OUT_DIR_ARM64="$RESOURCE_DIR/darwin-aarch64"
+OUT_DIR_X64="$RESOURCE_DIR/darwin-x64"
+LIB_NAME="libcompose_webview_macos.dylib"
+SOURCES=(
+    "$SCRIPT_DIR/jni_bridge.m"
+    "$SCRIPT_DIR/view_lifecycle.m"
+    "$SCRIPT_DIR/view_signals.m"
+    "$SCRIPT_DIR/navigation.m"
+    "$SCRIPT_DIR/javascript.m"
+    "$SCRIPT_DIR/cookies.m"
+    "$SCRIPT_DIR/screenshot.m"
+)
+
+mkdir -p "$OUT_DIR_ARM64" "$OUT_DIR_X64"
+
+if [ -z "${JAVA_HOME:-}" ]; then
+    JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null || true)
+fi
+if [ -z "${JAVA_HOME:-}" ] || [ ! -f "$JAVA_HOME/include/jni.h" ]; then
+    echo "ERROR: JAVA_HOME unset or missing jni.h. Set JAVA_HOME to a JDK." >&2
+    exit 1
+fi
+
+JNI_INCLUDE="$JAVA_HOME/include"
+JNI_INCLUDE_DARWIN="$JAVA_HOME/include/darwin"
+
+FLAGS=(
+    -dynamiclib
+    -I"$SCRIPT_DIR" -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_DARWIN"
+    -framework Cocoa
+    -framework WebKit
+    -mmacosx-version-min=11.0
+    -fobjc-arc
+    -O2
+    -fvisibility=hidden
+    -Wl,-dead_strip
+    -Wl,-x
+)
+
+echo "Building $OUT_DIR_ARM64/$LIB_NAME (arm64)..."
+clang -arch arm64 "${FLAGS[@]}" -o "$OUT_DIR_ARM64/$LIB_NAME" "${SOURCES[@]}"
+strip -x "$OUT_DIR_ARM64/$LIB_NAME"
+
+echo "Building $OUT_DIR_X64/$LIB_NAME (x86_64)..."
+clang -arch x86_64 "${FLAGS[@]}" -o "$OUT_DIR_X64/$LIB_NAME" "${SOURCES[@]}"
+strip -x "$OUT_DIR_X64/$LIB_NAME"
+
+for CACHE_DIR in "$HOME/Library/Caches/nucleus/native" "$HOME/.cache/nucleus/native"; do
+    if [ -d "$CACHE_DIR" ]; then
+        rm -rf "$CACHE_DIR"
+        echo "Cleared NativeLibraryLoader cache: $CACHE_DIR"
+    fi
+done
+
+echo "Built compose WebView macOS native library:"
+ls -lh "$OUT_DIR_ARM64/$LIB_NAME" "$OUT_DIR_X64/$LIB_NAME"
diff --git a/webview-compose/src/jvmMain/native/macos/compose_webview_internal.h b/webview-compose/src/jvmMain/native/macos/compose_webview_internal.h
new file mode 100644
index 0000000..e486696
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/compose_webview_internal.h
@@ -0,0 +1,51 @@
+/**
+ * Shared state and JNI helpers for the macOS WKWebView backend.
+ * Not a public API โ€” only used by the compose_webview_*.m units.
+ *
+ * Mirrors the Linux/Windows layout (jni_bridge / lifecycle / signals /
+ * navigation / javascript / cookies / screenshot).
+ */
+#ifndef COMPOSE_WEBVIEW_INTERNAL_H
+#define COMPOSE_WEBVIEW_INTERNAL_H
+
+#import 
+#import 
+#include 
+#include 
+#include 
+
+@interface ComposeWebViewState : NSObject
+@property (nonatomic, strong) WKWebView *webView;
+@property (nonatomic, strong) WKWebViewConfiguration *configuration;
+@property (nonatomic, assign) jlong handle;
+- (void)teardown;
+@end
+
+/* Delegate / script-message methods live in view_signals.m */
+@interface ComposeWebViewState (Signals) 
+@end
+
+/* jni_bridge.m */
+JNIEnv *compose_webview_get_env(void);
+void compose_webview_ensure_bridge_methods(JNIEnv *env);
+jclass compose_webview_bridge_class(void);
+jmethodID compose_webview_on_navigate(void);
+jmethodID compose_webview_on_ipc(void);
+jmethodID compose_webview_on_js_result(void);
+jmethodID compose_webview_on_cookies(void);
+jmethodID compose_webview_on_screenshot(void);
+
+NSString *compose_webview_jstring_to_ns(JNIEnv *env, jstring js);
+jstring compose_webview_ns_to_jstring(JNIEnv *env, NSString *s);
+NSString *compose_webview_json_escape(NSString *raw);
+
+void compose_webview_deliver_js_result(jlong handle, NSString *payload);
+void compose_webview_deliver_cookies_result(jlong handle, NSString *json);
+void compose_webview_deliver_screenshot_result(jlong handle, NSData *png);
+
+/* view_lifecycle.m */
+ComposeWebViewState *compose_webview_state_from_handle(jlong handle);
+
+/* cookies.m helpers used only there โ€” declared static in cookies.m */
+
+#endif /* COMPOSE_WEBVIEW_INTERNAL_H */
diff --git a/webview-compose/src/jvmMain/native/macos/cookies.m b/webview-compose/src/jvmMain/native/macos/cookies.m
new file mode 100644
index 0000000..c05e55a
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/cookies.m
@@ -0,0 +1,186 @@
+#include "compose_webview_internal.h"
+
+static NSString *cookie_to_json(NSHTTPCookie *cookie) {
+    if (cookie == nil) return @"null";
+    NSString *name = compose_webview_json_escape(cookie.name);
+    NSString *value = compose_webview_json_escape(cookie.value);
+    NSString *domain = compose_webview_json_escape(cookie.domain ?: @"");
+    NSString *path = compose_webview_json_escape(cookie.path ?: @"/");
+    BOOL secure = cookie.isSecure;
+    BOOL httpOnly = cookie.isHTTPOnly;
+    BOOL sessionOnly = cookie.isSessionOnly;
+    long long expiresMs = 0;
+    if (cookie.expiresDate != nil) {
+        expiresMs = (long long)([cookie.expiresDate timeIntervalSince1970] * 1000.0);
+    }
+    NSString *sameSite = @"Lax";
+    if (@available(macOS 10.15, *)) {
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
+        if ([cookie respondsToSelector:@selector(sameSitePolicy)]) {
+            id policy = [cookie valueForKey:@"sameSitePolicy"];
+            if ([policy isKindOfClass:[NSString class]]) {
+                NSString *p = [(NSString *)policy lowercaseString];
+                if ([p isEqualToString:@"none"]) sameSite = @"None";
+                else if ([p isEqualToString:@"strict"]) sameSite = @"Strict";
+                else sameSite = @"Lax";
+            }
+        }
+#pragma clang diagnostic pop
+    }
+    return [NSString stringWithFormat:
+        @"{\"name\":\"%@\",\"value\":\"%@\",\"domain\":\"%@\",\"path\":\"%@\","
+         "\"secure\":%@,\"httpOnly\":%@,\"sessionOnly\":%@,\"expiresDate\":%lld,"
+         "\"sameSite\":\"%@\"}",
+        name, value, domain, path,
+        secure ? @"true" : @"false",
+        httpOnly ? @"true" : @"false",
+        sessionOnly ? @"true" : @"false",
+        expiresMs,
+        sameSite];
+}
+
+static BOOL host_matches_cookie_domain(NSString *host, NSString *domain) {
+    if (host == nil || domain == nil) return NO;
+    NSString *d = domain;
+    if ([d hasPrefix:@"."]) {
+        d = [d substringFromIndex:1];
+    }
+    if ([host caseInsensitiveCompare:d] == NSOrderedSame) return YES;
+    NSString *suffix = [@"." stringByAppendingString:d];
+    return [host.lowercaseString hasSuffix:suffix.lowercaseString];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeGetCookies(
+    JNIEnv *env, jclass clazz, jlong handle, jstring url_str)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || url_str == NULL) {
+        compose_webview_deliver_cookies_result(handle, @"[]");
+        return;
+    }
+    NSString *url = compose_webview_jstring_to_ns(env, url_str);
+    NSURL *nsurl = url != nil ? [NSURL URLWithString:url] : nil;
+    NSString *host = nsurl.host;
+    jlong h = handle;
+
+    WKHTTPCookieStore *store =
+        state.webView.configuration.websiteDataStore.httpCookieStore;
+    [store getAllCookies:^(NSArray *cookies) {
+        NSMutableString *json = [NSMutableString stringWithString:@"["];
+        BOOL first = YES;
+        for (NSHTTPCookie *cookie in cookies) {
+            if (host != nil && !host_matches_cookie_domain(host, cookie.domain)) {
+                continue;
+            }
+            if (!first) [json appendString:@","];
+            [json appendString:cookie_to_json(cookie)];
+            first = NO;
+        }
+        [json appendString:@"]"];
+        compose_webview_deliver_cookies_result(h, json);
+    }];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeSetCookie(
+    JNIEnv *env,
+    jclass clazz,
+    jlong handle,
+    jstring name,
+    jstring value,
+    jstring domain,
+    jstring path,
+    jboolean secure,
+    jboolean http_only,
+    jlong expires_ms,
+    jstring same_site)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || name == NULL || value == NULL) return;
+
+    NSString *cName = compose_webview_jstring_to_ns(env, name);
+    NSString *cValue = compose_webview_jstring_to_ns(env, value);
+    NSString *cDomain = domain != NULL ? compose_webview_jstring_to_ns(env, domain) : @"";
+    NSString *cPath = path != NULL ? compose_webview_jstring_to_ns(env, path) : @"/";
+    if (cName == nil || cValue == nil) return;
+
+    NSMutableDictionary *props = [NSMutableDictionary dictionary];
+    props[NSHTTPCookieName] = cName;
+    props[NSHTTPCookieValue] = cValue;
+    props[NSHTTPCookieDomain] = cDomain ?: @"";
+    props[NSHTTPCookiePath] = cPath ?: @"/";
+    if (secure) props[NSHTTPCookieSecure] = @"TRUE";
+    if (http_only) {
+        props[@"HttpOnly"] = @YES;
+    }
+    if (expires_ms > 0) {
+        props[NSHTTPCookieExpires] =
+            [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)expires_ms / 1000.0];
+    }
+    if (same_site != NULL) {
+        NSString *ss = compose_webview_jstring_to_ns(env, same_site);
+        if (ss != nil) {
+            if (@available(macOS 10.15, *)) {
+                if ([ss caseInsensitiveCompare:@"None"] == NSOrderedSame) {
+                    props[NSHTTPCookieSameSitePolicy] = @"None";
+                } else if ([ss caseInsensitiveCompare:@"Strict"] == NSOrderedSame) {
+                    props[NSHTTPCookieSameSitePolicy] = NSHTTPCookieSameSiteStrict;
+                } else {
+                    props[NSHTTPCookieSameSitePolicy] = NSHTTPCookieSameSiteLax;
+                }
+            }
+        }
+    }
+
+    NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:props];
+    if (cookie == nil) return;
+
+    WKHTTPCookieStore *store =
+        state.webView.configuration.websiteDataStore.httpCookieStore;
+    [store setCookie:cookie completionHandler:^{}];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeRemoveAllCookies(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+
+    WKHTTPCookieStore *store =
+        state.webView.configuration.websiteDataStore.httpCookieStore;
+    [store getAllCookies:^(NSArray *cookies) {
+        for (NSHTTPCookie *cookie in cookies) {
+            [store deleteCookie:cookie completionHandler:^{}];
+        }
+    }];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeRemoveCookiesForUrl(
+    JNIEnv *env, jclass clazz, jlong handle, jstring url_str)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || url_str == NULL) return;
+
+    NSString *url = compose_webview_jstring_to_ns(env, url_str);
+    NSURL *nsurl = url != nil ? [NSURL URLWithString:url] : nil;
+    NSString *host = nsurl.host;
+    if (host == nil) return;
+
+    WKHTTPCookieStore *store =
+        state.webView.configuration.websiteDataStore.httpCookieStore;
+    [store getAllCookies:^(NSArray *cookies) {
+        for (NSHTTPCookie *cookie in cookies) {
+            if (host_matches_cookie_domain(host, cookie.domain)) {
+                [store deleteCookie:cookie completionHandler:^{}];
+            }
+        }
+    }];
+}
diff --git a/webview-compose/src/jvmMain/native/macos/javascript.m b/webview-compose/src/jvmMain/native/macos/javascript.m
new file mode 100644
index 0000000..25e5e10
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/javascript.m
@@ -0,0 +1,64 @@
+#include "compose_webview_internal.h"
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeEvaluateJavaScript(
+    JNIEnv *env, jclass clazz, jlong handle, jstring script_str)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || script_str == NULL) {
+        compose_webview_deliver_js_result(handle, @"");
+        return;
+    }
+    NSString *script = compose_webview_jstring_to_ns(env, script_str);
+    if (script == nil) {
+        compose_webview_deliver_js_result(handle, @"");
+        return;
+    }
+
+    jlong h = handle;
+    [state.webView evaluateJavaScript:script
+                    completionHandler:^(id result, NSError *error) {
+        if (error != nil || result == nil || result == [NSNull null]) {
+            if (error != nil) {
+                compose_webview_deliver_js_result(h, @"");
+            } else {
+                compose_webview_deliver_js_result(h, @"null");
+            }
+            return;
+        }
+        if ([result isKindOfClass:[NSString class]]) {
+            NSString *escaped = compose_webview_json_escape((NSString *)result);
+            compose_webview_deliver_js_result(
+                h, [NSString stringWithFormat:@"\"%@\"", escaped]);
+            return;
+        }
+        if ([result isKindOfClass:[NSNumber class]]) {
+            NSNumber *n = (NSNumber *)result;
+            const char *t = [n objCType];
+            if (t != NULL && (strcmp(t, @encode(BOOL)) == 0 ||
+                              strcmp(t, @encode(bool)) == 0 ||
+                              strcmp(t, @encode(char)) == 0)) {
+                compose_webview_deliver_js_result(h, [n boolValue] ? @"true" : @"false");
+            } else {
+                compose_webview_deliver_js_result(h, [n stringValue]);
+            }
+            return;
+        }
+        if ([NSJSONSerialization isValidJSONObject:result]) {
+            NSError *jsonErr = nil;
+            NSData *data =
+                [NSJSONSerialization dataWithJSONObject:result options:0 error:&jsonErr];
+            if (data != nil && jsonErr == nil) {
+                NSString *json =
+                    [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+                compose_webview_deliver_js_result(h, json ?: @"");
+                return;
+            }
+        }
+        compose_webview_deliver_js_result(
+            h,
+            [NSString stringWithFormat:@"\"%@\"",
+             compose_webview_json_escape([result description])]);
+    }];
+}
diff --git a/webview-compose/src/jvmMain/native/macos/jni_bridge.m b/webview-compose/src/jvmMain/native/macos/jni_bridge.m
new file mode 100644
index 0000000..745223b
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/jni_bridge.m
@@ -0,0 +1,144 @@
+#include "compose_webview_internal.h"
+
+static JavaVM *g_jvm = NULL;
+static jclass g_bridge_class = NULL;
+static jmethodID g_on_navigate = NULL;
+static jmethodID g_on_ipc = NULL;
+static jmethodID g_on_js_result = NULL;
+static jmethodID g_on_cookies = NULL;
+static jmethodID g_on_screenshot = NULL;
+
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
+    (void)reserved;
+    g_jvm = vm;
+    return JNI_VERSION_1_8;
+}
+
+JNIEnv *compose_webview_get_env(void) {
+    if (g_jvm == NULL) return NULL;
+    JNIEnv *env = NULL;
+    jint status = (*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_8);
+    if (status == JNI_EDETACHED) {
+        if ((*g_jvm)->AttachCurrentThread(g_jvm, (void **)&env, NULL) != 0) {
+            return NULL;
+        }
+    } else if (status != JNI_OK) {
+        return NULL;
+    }
+    return env;
+}
+
+void compose_webview_ensure_bridge_methods(JNIEnv *env) {
+    if (g_bridge_class != NULL) return;
+    jclass local = (*env)->FindClass(
+        env,
+        "dev/nucleusframework/webview/web/macos/WebKitMacOsBridge");
+    if (local == NULL) return;
+    g_bridge_class = (jclass)(*env)->NewGlobalRef(env, local);
+    (*env)->DeleteLocalRef(env, local);
+    g_on_navigate = (*env)->GetStaticMethodID(
+        env, g_bridge_class, "nativeOnNavigate", "(JLjava/lang/String;)Z");
+    g_on_ipc = (*env)->GetStaticMethodID(
+        env, g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;)V");
+    g_on_js_result = (*env)->GetStaticMethodID(
+        env, g_bridge_class, "nativeOnJsResult", "(JLjava/lang/String;)V");
+    g_on_cookies = (*env)->GetStaticMethodID(
+        env, g_bridge_class, "nativeOnCookiesResult", "(JLjava/lang/String;)V");
+    g_on_screenshot = (*env)->GetStaticMethodID(
+        env, g_bridge_class, "nativeOnScreenshotResult", "(J[B)V");
+}
+
+jclass compose_webview_bridge_class(void) { return g_bridge_class; }
+jmethodID compose_webview_on_navigate(void) { return g_on_navigate; }
+jmethodID compose_webview_on_ipc(void) { return g_on_ipc; }
+jmethodID compose_webview_on_js_result(void) { return g_on_js_result; }
+jmethodID compose_webview_on_cookies(void) { return g_on_cookies; }
+jmethodID compose_webview_on_screenshot(void) { return g_on_screenshot; }
+
+NSString *compose_webview_jstring_to_ns(JNIEnv *env, jstring js) {
+    if (js == NULL) return nil;
+    const char *utf = (*env)->GetStringUTFChars(env, js, NULL);
+    if (utf == NULL) return nil;
+    NSString *out = [NSString stringWithUTF8String:utf];
+    (*env)->ReleaseStringUTFChars(env, js, utf);
+    return out;
+}
+
+jstring compose_webview_ns_to_jstring(JNIEnv *env, NSString *s) {
+    if (s == nil) return NULL;
+    const char *utf = [s UTF8String];
+    if (utf == NULL) return NULL;
+    return (*env)->NewStringUTF(env, utf);
+}
+
+NSString *compose_webview_json_escape(NSString *raw) {
+    if (raw == nil) return @"";
+    NSMutableString *out = [NSMutableString stringWithCapacity:raw.length + 8];
+    for (NSUInteger i = 0; i < raw.length; i++) {
+        unichar c = [raw characterAtIndex:i];
+        switch (c) {
+            case '"':  [out appendString:@"\\\""]; break;
+            case '\\': [out appendString:@"\\\\"]; break;
+            case '\b': [out appendString:@"\\b"]; break;
+            case '\f': [out appendString:@"\\f"]; break;
+            case '\n': [out appendString:@"\\n"]; break;
+            case '\r': [out appendString:@"\\r"]; break;
+            case '\t': [out appendString:@"\\t"]; break;
+            default:
+                if (c < 0x20) {
+                    [out appendFormat:@"\\u%04x", c];
+                } else {
+                    [out appendFormat:@"%C", c];
+                }
+                break;
+        }
+    }
+    return out;
+}
+
+void compose_webview_deliver_js_result(jlong handle, NSString *payload) {
+    JNIEnv *env = compose_webview_get_env();
+    if (env == NULL) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (g_bridge_class == NULL || g_on_js_result == NULL) return;
+    jstring jpayload = compose_webview_ns_to_jstring(env, payload ?: @"");
+    (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_js_result, handle, jpayload);
+    if (jpayload != NULL) (*env)->DeleteLocalRef(env, jpayload);
+    if ((*env)->ExceptionCheck(env)) {
+        (*env)->ExceptionClear(env);
+    }
+}
+
+void compose_webview_deliver_cookies_result(jlong handle, NSString *json) {
+    JNIEnv *env = compose_webview_get_env();
+    if (env == NULL) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (g_bridge_class == NULL || g_on_cookies == NULL) return;
+    jstring jjson = compose_webview_ns_to_jstring(env, json ?: @"[]");
+    (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_cookies, handle, jjson);
+    if (jjson != NULL) (*env)->DeleteLocalRef(env, jjson);
+    if ((*env)->ExceptionCheck(env)) {
+        (*env)->ExceptionClear(env);
+    }
+}
+
+void compose_webview_deliver_screenshot_result(jlong handle, NSData *png) {
+    JNIEnv *env = compose_webview_get_env();
+    if (env == NULL) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (g_bridge_class == NULL || g_on_screenshot == NULL) return;
+
+    jbyteArray jbytes = NULL;
+    if (png != nil && png.length > 0) {
+        jbytes = (*env)->NewByteArray(env, (jsize)png.length);
+        if (jbytes != NULL) {
+            (*env)->SetByteArrayRegion(
+                env, jbytes, 0, (jsize)png.length, (const jbyte *)png.bytes);
+        }
+    }
+    (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_screenshot, handle, jbytes);
+    if (jbytes != NULL) (*env)->DeleteLocalRef(env, jbytes);
+    if ((*env)->ExceptionCheck(env)) {
+        (*env)->ExceptionClear(env);
+    }
+}
diff --git a/webview-compose/src/jvmMain/native/macos/navigation.m b/webview-compose/src/jvmMain/native/macos/navigation.m
new file mode 100644
index 0000000..36a575a
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/navigation.m
@@ -0,0 +1,202 @@
+#include "compose_webview_internal.h"
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeLoadUrl(
+    JNIEnv *env, jclass clazz, jlong handle, jstring url_str)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || url_str == NULL) return;
+    NSString *url = compose_webview_jstring_to_ns(env, url_str);
+    if (url == nil) return;
+    NSURL *nsurl = [NSURL URLWithString:url];
+    if (nsurl == nil) return;
+    [state.webView loadRequest:[NSURLRequest requestWithURL:nsurl]];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeLoadUrlWithHeaders(
+    JNIEnv *env,
+    jclass clazz,
+    jlong handle,
+    jstring url_str,
+    jobjectArray header_names,
+    jobjectArray header_values)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || url_str == NULL) return;
+
+    NSString *url = compose_webview_jstring_to_ns(env, url_str);
+    if (url == nil) return;
+    NSURL *nsurl = [NSURL URLWithString:url];
+    if (nsurl == nil) return;
+
+    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:nsurl];
+    if (header_names != NULL && header_values != NULL) {
+        jsize count = (*env)->GetArrayLength(env, header_names);
+        jsize value_count = (*env)->GetArrayLength(env, header_values);
+        if (value_count < count) count = value_count;
+        for (jsize i = 0; i < count; i++) {
+            jstring jn = (jstring)(*env)->GetObjectArrayElement(env, header_names, i);
+            jstring jv = (jstring)(*env)->GetObjectArrayElement(env, header_values, i);
+            NSString *n = compose_webview_jstring_to_ns(env, jn);
+            NSString *v = compose_webview_jstring_to_ns(env, jv);
+            if (n != nil && v != nil) {
+                [request setValue:v forHTTPHeaderField:n];
+            }
+            if (jn != NULL) (*env)->DeleteLocalRef(env, jn);
+            if (jv != NULL) (*env)->DeleteLocalRef(env, jv);
+        }
+    }
+    [state.webView loadRequest:request];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeLoadHtml(
+    JNIEnv *env, jclass clazz, jlong handle, jstring html_str, jstring base_uri)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil || html_str == NULL) return;
+    NSString *html = compose_webview_jstring_to_ns(env, html_str);
+    if (html == nil) return;
+    NSURL *base = nil;
+    if (base_uri != NULL) {
+        NSString *baseStr = compose_webview_jstring_to_ns(env, base_uri);
+        if (baseStr != nil) base = [NSURL URLWithString:baseStr];
+    }
+    [state.webView loadHTMLString:html baseURL:base];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeGoBack(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    [state.webView goBack];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeGoForward(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    [state.webView goForward];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeReload(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    [state.webView reload];
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeStopLoading(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    [state.webView stopLoading];
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeCanGoBack(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return JNI_FALSE;
+    return state.webView.canGoBack ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeCanGoForward(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return JNI_FALSE;
+    return state.webView.canGoForward ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jstring JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeCurrentUrl(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return NULL;
+    return compose_webview_ns_to_jstring(env, state.webView.URL.absoluteString);
+}
+
+JNIEXPORT jstring JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeGetTitle(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return NULL;
+    return compose_webview_ns_to_jstring(env, state.webView.title);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeIsLoading(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return JNI_FALSE;
+    return state.webView.loading ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeSetZoomLevel(
+    JNIEnv *env, jclass clazz, jlong handle, jdouble zoom)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    state.webView.pageZoom = zoom;
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeFocus(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    NSWindow *window = state.webView.window;
+    if (window != nil) {
+        [window makeFirstResponder:state.webView];
+    }
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeOpenDevTools(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return;
+    if (@available(macOS 13.3, *)) {
+        state.webView.inspectable = YES;
+    }
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeCloseDevTools(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz; (void)handle;
+}
diff --git a/webview-compose/src/jvmMain/native/macos/screenshot.m b/webview-compose/src/jvmMain/native/macos/screenshot.m
new file mode 100644
index 0000000..05c1781
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/screenshot.m
@@ -0,0 +1,34 @@
+#include "compose_webview_internal.h"
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeCaptureScreenshot(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env; (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) {
+        compose_webview_deliver_screenshot_result(handle, nil);
+        return;
+    }
+
+    jlong h = handle;
+    [state.webView takeSnapshotWithConfiguration:nil
+                               completionHandler:^(NSImage *snapshotImage, NSError *error) {
+        if (error != nil || snapshotImage == nil) {
+            compose_webview_deliver_screenshot_result(h, nil);
+            return;
+        }
+        NSData *tiff = [snapshotImage TIFFRepresentation];
+        if (tiff == nil) {
+            compose_webview_deliver_screenshot_result(h, nil);
+            return;
+        }
+        NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff];
+        if (rep == nil) {
+            compose_webview_deliver_screenshot_result(h, nil);
+            return;
+        }
+        NSData *png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
+        compose_webview_deliver_screenshot_result(h, png);
+    }];
+}
diff --git a/webview-compose/src/jvmMain/native/macos/view_lifecycle.m b/webview-compose/src/jvmMain/native/macos/view_lifecycle.m
new file mode 100644
index 0000000..3de1860
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/view_lifecycle.m
@@ -0,0 +1,167 @@
+#include "compose_webview_internal.h"
+
+@implementation ComposeWebViewState
+
+- (void)teardown {
+    if (self.webView != nil) {
+        self.webView.navigationDelegate = nil;
+        [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"ipc"];
+        [self.webView removeFromSuperview];
+        self.webView = nil;
+    }
+    self.configuration = nil;
+}
+
+@end
+
+ComposeWebViewState *compose_webview_state_from_handle(jlong handle) {
+    if (handle == 0) return nil;
+    return (__bridge ComposeWebViewState *)(void *)(uintptr_t)handle;
+}
+
+JNIEXPORT jlong JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeCreate(
+    JNIEnv *env,
+    jclass clazz,
+    jstring user_agent,
+    jstring data_directory,
+    jstring init_script,
+    jboolean incognito,
+    jboolean enable_devtools,
+    jboolean javascript_enabled,
+    jdouble zoom_level,
+    jboolean transparent,
+    jfloat bg_r,
+    jfloat bg_g,
+    jfloat bg_b,
+    jfloat bg_a)
+{
+    (void)clazz;
+    (void)data_directory; // WKWebsiteDataStore has no simple custom-path API.
+    compose_webview_ensure_bridge_methods(env);
+
+    ComposeWebViewState *state = [[ComposeWebViewState alloc] init];
+    void *retained = (__bridge_retained void *)state;
+    state.handle = (jlong)(uintptr_t)retained;
+
+    WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
+    if (incognito) {
+        config.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore];
+    } else {
+        config.websiteDataStore = [WKWebsiteDataStore defaultDataStore];
+    }
+
+    WKPreferences *prefs = [[WKPreferences alloc] init];
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+    prefs.javaScriptEnabled = javascript_enabled ? YES : NO;
+#pragma clang diagnostic pop
+    config.preferences = prefs;
+
+    WKUserContentController *ucm = [[WKUserContentController alloc] init];
+
+    // Match Linux ipc shim so Kotlin JS bridge can attach.
+    NSString *ipcShim =
+        @"if (typeof window.ipc === 'undefined') {"
+         "  window.ipc = {"
+         "    postMessage: function(message) {"
+         "      if (window.webkit && window.webkit.messageHandlers &&"
+         "          window.webkit.messageHandlers.ipc) {"
+         "        window.webkit.messageHandlers.ipc.postMessage("
+         "          (typeof message === 'string') ? message : JSON.stringify(message)"
+         "        );"
+         "      }"
+         "    }"
+         "  };"
+         "}";
+    WKUserScript *shim = [[WKUserScript alloc]
+        initWithSource:ipcShim
+         injectionTime:WKUserScriptInjectionTimeAtDocumentStart
+      forMainFrameOnly:NO];
+    [ucm addUserScript:shim];
+
+    if (!transparent) {
+        NSString *css =
+            @"(function(){var s=document.createElement('style');"
+             "s.textContent='html, body { background-color: #ffffff !important; }';"
+             "document.documentElement.appendChild(s);})();";
+        WKUserScript *cssScript = [[WKUserScript alloc]
+            initWithSource:css
+             injectionTime:WKUserScriptInjectionTimeAtDocumentStart
+          forMainFrameOnly:NO];
+        [ucm addUserScript:cssScript];
+    }
+
+    if (init_script != NULL) {
+        NSString *src = compose_webview_jstring_to_ns(env, init_script);
+        if (src != nil && src.length > 0) {
+            WKUserScript *userScript = [[WKUserScript alloc]
+                initWithSource:src
+                 injectionTime:WKUserScriptInjectionTimeAtDocumentStart
+              forMainFrameOnly:YES];
+            [ucm addUserScript:userScript];
+        }
+    }
+
+    [ucm addScriptMessageHandler:state name:@"ipc"];
+    config.userContentController = ucm;
+    state.configuration = config;
+
+    WKWebView *webview = [[WKWebView alloc] initWithFrame:NSZeroRect configuration:config];
+    webview.navigationDelegate = state;
+    webview.allowsBackForwardNavigationGestures = YES;
+
+    if (user_agent != NULL) {
+        NSString *ua = compose_webview_jstring_to_ns(env, user_agent);
+        if (ua != nil && ua.length > 0) {
+            webview.customUserAgent = ua;
+        }
+    }
+
+    if (zoom_level > 0.0) {
+        webview.pageZoom = zoom_level;
+    }
+
+    if (@available(macOS 13.3, *)) {
+        webview.inspectable = enable_devtools ? YES : NO;
+    }
+
+    webview.wantsLayer = YES;
+    if (@available(macOS 12.0, *)) {
+        if (transparent) {
+            webview.underPageBackgroundColor = [NSColor clearColor];
+        } else {
+            CGFloat r = (bg_a >= 1.0f) ? bg_r : 1.0;
+            CGFloat g = (bg_a >= 1.0f) ? bg_g : 1.0;
+            CGFloat b = (bg_a >= 1.0f) ? bg_b : 1.0;
+            webview.underPageBackgroundColor =
+                [NSColor colorWithCalibratedRed:r green:g blue:b alpha:1.0];
+        }
+    }
+
+    state.webView = webview;
+    return state.handle;
+}
+
+JNIEXPORT jlong JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeGetNsView(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env;
+    (void)clazz;
+    ComposeWebViewState *state = compose_webview_state_from_handle(handle);
+    if (state == nil || state.webView == nil) return 0;
+    return (jlong)(uintptr_t)(__bridge void *)state.webView;
+}
+
+JNIEXPORT void JNICALL
+Java_dev_nucleusframework_webview_web_macos_WebKitMacOsBridge_nativeRelease(
+    JNIEnv *env, jclass clazz, jlong handle)
+{
+    (void)env;
+    (void)clazz;
+    if (handle == 0) return;
+    ComposeWebViewState *state =
+        (__bridge_transfer ComposeWebViewState *)(void *)(uintptr_t)handle;
+    [state teardown];
+}
diff --git a/webview-compose/src/jvmMain/native/macos/view_signals.m b/webview-compose/src/jvmMain/native/macos/view_signals.m
new file mode 100644
index 0000000..37a85e1
--- /dev/null
+++ b/webview-compose/src/jvmMain/native/macos/view_signals.m
@@ -0,0 +1,80 @@
+#include "compose_webview_internal.h"
+
+@implementation ComposeWebViewState (Signals)
+
+- (void)userContentController:(WKUserContentController *)userContentController
+      didReceiveScriptMessage:(WKScriptMessage *)message
+{
+    (void)userContentController;
+    if (![message.name isEqualToString:@"ipc"]) return;
+
+    NSString *payload = nil;
+    id body = message.body;
+    if ([body isKindOfClass:[NSString class]]) {
+        payload = (NSString *)body;
+    } else if ([body isKindOfClass:[NSDictionary class]] ||
+               [body isKindOfClass:[NSArray class]] ||
+               [body isKindOfClass:[NSNumber class]]) {
+        NSError *err = nil;
+        NSData *data = [NSJSONSerialization dataWithJSONObject:body options:0 error:&err];
+        if (data != nil && err == nil) {
+            payload = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+        }
+    }
+    if (payload == nil) return;
+
+    JNIEnv *env = compose_webview_get_env();
+    if (env == NULL) return;
+    compose_webview_ensure_bridge_methods(env);
+    if (compose_webview_bridge_class() == NULL || compose_webview_on_ipc() == NULL) return;
+
+    jstring jmsg = compose_webview_ns_to_jstring(env, payload);
+    (*env)->CallStaticVoidMethod(
+        env, compose_webview_bridge_class(), compose_webview_on_ipc(), self.handle, jmsg);
+    if (jmsg != NULL) (*env)->DeleteLocalRef(env, jmsg);
+    if ((*env)->ExceptionCheck(env)) {
+        (*env)->ExceptionClear(env);
+    }
+}
+
+- (void)webView:(WKWebView *)webView
+decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction
+decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler
+{
+    (void)webView;
+    NSURL *url = navigationAction.request.URL;
+    NSString *uri = url.absoluteString;
+    if (uri == nil || uri.length == 0) {
+        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
+    }
+
+    if ([uri hasPrefix:@"about:"] || [uri hasPrefix:@"data:"] || [uri hasPrefix:@"blob:"]) {
+        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
+    }
+
+    JNIEnv *env = compose_webview_get_env();
+    if (env == NULL) {
+        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
+    }
+    compose_webview_ensure_bridge_methods(env);
+    if (compose_webview_bridge_class() == NULL || compose_webview_on_navigate() == NULL) {
+        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
+    }
+
+    jstring juri = compose_webview_ns_to_jstring(env, uri);
+    jboolean allow = (*env)->CallStaticBooleanMethod(
+        env, compose_webview_bridge_class(), compose_webview_on_navigate(), self.handle, juri);
+    if (juri != NULL) (*env)->DeleteLocalRef(env, juri);
+    if ((*env)->ExceptionCheck(env)) {
+        (*env)->ExceptionClear(env);
+        decisionHandler(WKNavigationActionPolicyAllow);
+        return;
+    }
+    decisionHandler(allow ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel);
+}
+
+@end
diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties
index 4e72ba4..1d356da 100644
--- a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties
+++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties
@@ -1,7 +1,9 @@
-# Native bridges load their .so/.dll via NativeLibraryLoader in a static
-# field initializer โ€” must stay at run time (native-image would otherwise
-# try to open the library during build).
+# Native bridges load their .so/.dylib/.dll via NativeLibraryLoader in a static
+# field initializer โ€” must stay at run time (native-image would otherwise try
+# to open the library during build).
 Args = --initialize-at-run-time=dev.nucleusframework.webview.web.linux.WebKitLinuxBridge,\
 dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView,\
+dev.nucleusframework.webview.web.macos.WebKitMacOsBridge,\
+dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView,\
 dev.nucleusframework.webview.web.windows.WebView2WindowsBridge,\
 dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView
diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json
index 36ba75c..af82e85 100644
--- a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json
+++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json
@@ -13,6 +13,19 @@
         { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
       ]
     },
+    {
+      "type": "dev.nucleusframework.webview.web.macos.WebKitMacOsBridge",
+      "jniAccessible": true,
+      "allDeclaredMethods": true,
+      "allDeclaredFields": true,
+      "methods": [
+        { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
+      ]
+    },
     {
       "type": "dev.nucleusframework.webview.web.windows.WebView2WindowsBridge",
       "jniAccessible": true,
@@ -32,6 +45,12 @@
       "allDeclaredMethods": true,
       "allDeclaredFields": true
     },
+    {
+      "type": "dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView",
+      "allDeclaredConstructors": true,
+      "allDeclaredMethods": true,
+      "allDeclaredFields": true
+    },
     {
       "type": "dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView",
       "allDeclaredConstructors": true,
@@ -97,6 +116,16 @@
         { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
       ]
     },
+    {
+      "type": "dev.nucleusframework.webview.web.macos.WebKitMacOsBridge",
+      "methods": [
+        { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] },
+        { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }
+      ]
+    },
     {
       "type": "dev.nucleusframework.webview.web.windows.WebView2WindowsBridge",
       "methods": [
diff --git a/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro b/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro
index 2c96dce..f496577 100644
--- a/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro
+++ b/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro
@@ -1,5 +1,5 @@
-# Keep the Linux WebKit JNI bridge โ€” native-image / ProGuard would otherwise
-# strip callbacks reached only from libcompose_webview_linux.so.
+# Keep desktop WebKit / WebView2 JNI bridges โ€” native-image / ProGuard would
+# otherwise strip callbacks reached only from the native libs.
 -keep class dev.nucleusframework.webview.web.linux.WebKitLinuxBridge {
     public static *;
     public *;
@@ -8,6 +8,22 @@
     (...);
     public *;
 }
+-keep class dev.nucleusframework.webview.web.macos.WebKitMacOsBridge {
+    public static *;
+    public *;
+}
+-keep class dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView {
+    (...);
+    public *;
+}
+-keep class dev.nucleusframework.webview.web.windows.WebView2WindowsBridge {
+    public static *;
+    public *;
+}
+-keep class dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView {
+    (...);
+    public *;
+}
 -keep class dev.nucleusframework.webview.web.NativeWebView {
     public *;
 }
diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
index 53ed0aa..04b6b84 100644
--- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
+++ b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
@@ -9,6 +9,7 @@ import dev.nucleusframework.application.nucleusApplication
 import dev.nucleusframework.core.runtime.Platform
 import dev.nucleusframework.webview.web.e2e.E2eHost
 import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge
+import dev.nucleusframework.webview.web.macos.WebKitMacOsBridge
 import dev.nucleusframework.webview.web.windows.WebView2WindowsBridge
 import dev.nucleusframework.window.TitleBar
 import java.util.concurrent.CopyOnWriteArrayList
@@ -21,6 +22,7 @@ import kotlin.test.Test
  * NativeView + Tao backend. No mocks.
  *
  *  - **Linux**: WebKit2GTK (`compose_webview_linux`)
+ *  - **macOS**: WKWebView (`compose_webview_macos`)
  *  - **Windows**: WebView2 (`compose_webview_windows`)
  *
  * Drivers live in [dev.nucleusframework.webview.web.e2e].
@@ -42,6 +44,12 @@ class DesktopWebViewE2eTest {
                     return
                 }
             }
+            Platform.MacOS -> {
+                if (!WebKitMacOsBridge.isLoaded) {
+                    println("SKIPPED: compose_webview_macos native library not loaded")
+                    return
+                }
+            }
             Platform.Windows -> {
                 if (!WebView2WindowsBridge.isLoaded) {
                     println("SKIPPED: compose_webview_windows native library not loaded")

From 41f0ac967a0afcc9649ab68a3cf63b7f28fd7a43 Mon Sep 17 00:00:00 2001
From: Elie Gambache 
Date: Thu, 6 Aug 2026 19:19:21 +0300
Subject: [PATCH 06/10] Make visual e2e multiplatform and drop legacy test
 suites

Rename demo hosts to e2e-*, move the visual suite to commonMain with
platform capabilities, expose WebView content overlays, and run the same
catalog on desktop/Android/Wasm/iOS. Remove jvmTest drivers and the
Playwright webview-compose-test module. Wire PR CI for commonTest plus
desktop and Android emulator visual e2e; fix Android JS bridge thread and
cookie removal.
---
 .github/workflows/pr-build-check.yml          | 312 +++++++++++++--
 AGENTS.md                                     |  65 ++-
 README.md                                     |  46 ++-
 .../webview/demo/MainActivity.kt              |  14 -
 .../src/androidMain/res/values/themes.xml     |   3 -
 .../webview/demo/MainViewController.kt        |   6 -
 .../webview/demo/visualsuite/ReportWriter.kt  |  45 ---
 .../webview/demo/DemoUtils.wasmJs.kt          |   6 -
 .../dev/nucleusframework/webview/demo/main.kt |  15 -
 .../webview/demo/WebViewTest.kt               |  10 -
 .../build.gradle.kts                          |   6 +-
 .../src/androidMain/AndroidManifest.xml       |   4 +-
 .../webview/e2e/MainActivity.kt               |  33 ++
 .../src/androidMain/res/values/themes.xml     |   3 +
 {demo => e2e-desktop}/build.gradle.kts        |  22 +-
 .../dev/nucleusframework/webview/e2e}/main.kt |   6 +-
 .../reachability-metadata.json                |  16 +-
 {demo-shared => e2e-shared}/build.gradle.kts  |   7 +-
 .../webview/e2e}/PlatformInfo.android.kt      |   2 +-
 .../webview/e2e/Utils.android.kt              |   4 +-
 .../e2e/visualsuite/SuitePlatform.android.kt  |  72 ++++
 .../files/bridge_playground.html              |   2 +-
 .../composeResources/files/suite_fixture.html |   0
 .../dev/nucleusframework/webview/e2e}/App.kt  |  20 +-
 .../webview/e2e/JsBridgeHandlers.kt           |   8 +-
 .../webview/e2e}/PlatformInfo.kt              |   2 +-
 .../webview/e2e/RequestInterceptor.kt         |   4 +-
 .../webview/e2e/ToolsPanel.kt                 |   6 +-
 .../nucleusframework/webview/e2e/TopBar.kt    |   4 +-
 .../dev/nucleusframework/webview/e2e/Utils.kt |   4 +-
 .../webview/e2e}/VisualBridgeLab.kt           |   6 +-
 .../webview/e2e/visualsuite/ReportWriter.kt   |  28 ++
 .../webview/e2e}/visualsuite/SuiteCatalog.kt  |  43 +-
 .../webview/e2e}/visualsuite/SuiteHelpers.kt  |  81 ++--
 .../webview/e2e}/visualsuite/SuiteModel.kt    |   2 +-
 .../webview/e2e}/visualsuite/SuitePages.kt    |   2 +-
 .../webview/e2e/visualsuite/SuitePlatform.kt  | 119 ++++++
 .../webview/e2e}/visualsuite/SuiteRunner.kt   | 375 ++++++++++++++----
 .../e2e}/visualsuite/VisualSuiteApp.kt        |  25 +-
 .../webview/e2e/MainViewController.kt         |  16 +
 .../webview/e2e}/PlatformInfo.ios.kt          |   2 +-
 .../nucleusframework/webview/e2e/Utils.ios.kt |   5 +-
 .../e2e/visualsuite/SuitePlatform.ios.kt      |  64 +++
 .../webview/e2e}/PlatformInfo.jvm.kt          |   2 +-
 .../nucleusframework/webview/e2e/Utils.jvm.kt |   4 +-
 .../e2e/visualsuite/SuitePlatform.jvm.kt      | 230 +++++++++++
 .../webview/e2e}/PlatformInfo.wasmJs.kt       |   2 +-
 .../webview/e2e/Utils.wasmJs.kt               |  11 +
 .../e2e/visualsuite/SuitePlatform.wasmJs.kt   |  49 +++
 {demo-wasmJs => e2e-wasmJs}/build.gradle.kts  |   2 +-
 .../dev/nucleusframework/webview/e2e/main.kt  |  21 +
 .../src/wasmJsMain/resources/index.html       |   4 +-
 gradle.properties                             |   1 +
 gradle/libs.versions.toml                     |   9 +-
 gradle/wrapper/gradle-wrapper.properties      |   2 +-
 iosApp/Configuration/Config.xcconfig          |   4 +-
 iosApp/iosApp.xcodeproj/project.pbxproj       |  10 +-
 iosApp/iosApp/ContentView.swift               |   2 +-
 kotlin-js-store/wasm/yarn.lock                |   8 +
 settings.gradle.kts                           |   9 +-
 webview-compose-test/build.gradle.kts         |  29 --
 .../webview/web/PlaywrightWebView.kt          |  14 -
 webview-compose/build.gradle.kts              |  20 +-
 .../webview/cookie/AndroidCookieManager.kt    |  24 +-
 .../webview/web/AndroidWebView.kt             |  22 +-
 .../webview/web/WebViewAndroid.kt             |   8 +-
 .../nucleusframework/webview/web/WebView.kt   |  19 +-
 .../webview/cookie/CookieTest.kt              |  50 +++
 .../jsbridge/JsMessageDispatcherTest.kt       |  83 ++++
 .../webview/jsbridge/JsMessageParsingTest.kt  |  59 +++
 .../webview/jsbridge/WebViewJsBridgeTest.kt   | 108 +++++
 .../webview/request/WebRequestTest.kt         |  49 +++
 .../webview/setting/WebSettingsTest.kt        |  45 +++
 .../webview/web/LoadingStateTest.kt           |  25 ++
 .../webview/web/WebContentTest.kt             |  51 +++
 .../webview/web/WebViewErrorTest.kt           |  33 ++
 .../webview/web/WebViewStateTest.kt           |  66 +++
 .../webview/web/WebViewIos.kt                 | 126 +++---
 .../webview/web/WebViewDesktop.kt             |   7 +-
 .../webview/web/DesktopWebViewE2eTest.kt      | 112 ------
 .../webview/web/e2e/CookieDriver.kt           |  50 ---
 .../webview/web/e2e/E2eHelpers.kt             |  50 ---
 .../webview/web/e2e/E2eHost.kt                | 115 ------
 .../webview/web/e2e/E2ePages.kt               |  25 --
 .../webview/web/e2e/EvaluateJsDriver.kt       |  31 --
 .../webview/web/e2e/InterceptorDriver.kt      |  46 ---
 .../webview/web/e2e/JsBridgeDriver.kt         |  40 --
 .../webview/web/e2e/LoadHtmlDriver.kt         |  44 --
 .../webview/web/e2e/NavigationDriver.kt       |  52 ---
 .../webview/web/e2e/ScreenshotDriver.kt       |  41 --
 .../webview/web/e2e/UserAgentDriver.kt        |  52 ---
 .../webview/web/WebView.wasmJs.kt             | 112 +++---
 92 files changed, 2257 insertions(+), 1236 deletions(-)
 delete mode 100644 demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt
 delete mode 100644 demo-android/src/androidMain/res/values/themes.xml
 delete mode 100644 demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/MainViewController.kt
 delete mode 100644 demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/visualsuite/ReportWriter.kt
 delete mode 100644 demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.wasmJs.kt
 delete mode 100644 demo-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo/main.kt
 delete mode 100644 demo/src/jvmTest/kotlin/dev/nucleusframework/webview/demo/WebViewTest.kt
 rename {demo-android => e2e-android}/build.gradle.kts (86%)
 rename {demo-android => e2e-android}/src/androidMain/AndroidManifest.xml (79%)
 create mode 100644 e2e-android/src/androidMain/kotlin/dev/nucleusframework/webview/e2e/MainActivity.kt
 create mode 100644 e2e-android/src/androidMain/res/values/themes.xml
 rename {demo => e2e-desktop}/build.gradle.kts (75%)
 rename {demo/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-desktop/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e}/main.kt (93%)
 rename {demo/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview-demo => e2e-desktop/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview-e2e}/reachability-metadata.json (60%)
 rename {demo-shared => e2e-shared}/build.gradle.kts (87%)
 rename {demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/androidMain/kotlin/dev/nucleusframework/webview/e2e}/PlatformInfo.android.kt (89%)
 rename demo-shared/src/androidMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.android.kt => e2e-shared/src/androidMain/kotlin/dev/nucleusframework/webview/e2e/Utils.android.kt (75%)
 create mode 100644 e2e-shared/src/androidMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.android.kt
 rename {demo-shared => e2e-shared}/src/commonMain/composeResources/files/bridge_playground.html (99%)
 rename {demo-shared => e2e-shared}/src/commonMain/composeResources/files/suite_fixture.html (100%)
 rename {demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/App.kt (97%)
 rename demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoJsBridgeHandlers.kt => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/JsBridgeHandlers.kt (94%)
 rename {demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/PlatformInfo.kt (53%)
 rename demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoRequestInterceptor.kt => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/RequestInterceptor.kt (94%)
 rename demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoToolsPanel.kt => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/ToolsPanel.kt (99%)
 rename demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoTopBar.kt => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/TopBar.kt (99%)
 rename demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.kt => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/Utils.kt (95%)
 rename {demo-shared/src/commonMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/VisualBridgeLab.kt (98%)
 create mode 100644 e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/ReportWriter.kt
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/visualsuite/SuiteCatalog.kt (64%)
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/visualsuite/SuiteHelpers.kt (69%)
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/visualsuite/SuiteModel.kt (95%)
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/visualsuite/SuitePages.kt (97%)
 create mode 100644 e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/visualsuite/SuiteRunner.kt (54%)
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e}/visualsuite/VisualSuiteApp.kt (94%)
 create mode 100644 e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/MainViewController.kt
 rename {demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e}/PlatformInfo.ios.kt (89%)
 rename demo-shared/src/iosMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.ios.kt => e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt (86%)
 create mode 100644 e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
 rename {demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e}/PlatformInfo.jvm.kt (88%)
 rename demo-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/demo/DemoUtils.jvm.kt => e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/Utils.jvm.kt (75%)
 create mode 100644 e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt
 rename {demo-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/demo => e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e}/PlatformInfo.wasmJs.kt (89%)
 create mode 100644 e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/Utils.wasmJs.kt
 create mode 100644 e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.wasmJs.kt
 rename {demo-wasmJs => e2e-wasmJs}/build.gradle.kts (92%)
 create mode 100644 e2e-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/main.kt
 rename {demo-wasmJs => e2e-wasmJs}/src/wasmJsMain/resources/index.html (82%)
 create mode 100644 kotlin-js-store/wasm/yarn.lock
 delete mode 100644 webview-compose-test/build.gradle.kts
 delete mode 100644 webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/cookie/CookieTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcherTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridgeTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/request/WebRequestTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/setting/WebSettingsTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/LoadingStateTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebContentTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewErrorTest.kt
 create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewStateTest.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt
 delete mode 100644 webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt

diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index f6b5635..d43f49e 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -10,11 +10,25 @@ concurrency:
   group: pr-build-${{ github.head_ref || github.ref }}
   cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
+# Unit logic: webview-compose/src/commonTest (same packages on all targets).
+# Real WebView e2e: e2e-shared VisualSuiteApp via e2e-desktop / e2e-android / e2e-wasmJs / iosApp.
+# The old jvmTest driver suite and Playwright mocks are gone.
+
+env:
+  # Same filter string for every platform so CI cannot drift.
+  COMMON_TEST_ARGS: >-
+    --tests dev.nucleusframework.webview.jsbridge.*
+    --tests dev.nucleusframework.webview.web.*
+    --tests dev.nucleusframework.webview.request.*
+    --tests dev.nucleusframework.webview.cookie.*
+    --tests dev.nucleusframework.webview.setting.*
+
 jobs:
   build-natives:
     uses: ./.github/workflows/build-natives.yaml
 
-  build:
+  # โ”€โ”€ Same commonTest suite on JVM โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  common-tests-jvm:
     needs: build-natives
     runs-on: ubuntu-latest
     steps:
@@ -27,18 +41,278 @@ jobs:
           pattern: 'natives-*'
           merge-multiple: true
 
-      - name: Verify natives present
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Android SDK
+        uses: android-actions/setup-android@v3
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: Compile all targets used by the library + e2e harness
+        run: >
+          ./gradlew
+          :webview-compose:compileKotlinJvm
+          :webview-compose:compileDebugKotlinAndroid
+          :webview-compose:compileKotlinWasmJs
+          :e2e-shared:compileKotlinJvm
+          :e2e-shared:compileDebugKotlinAndroid
+          :e2e-shared:compileKotlinWasmJs
+          :e2e-desktop:compileKotlinJvm
+          :e2e-android:compileDebugKotlinAndroid
+          :e2e-wasmJs:compileKotlinWasmJs
+          --no-configuration-cache
+
+      - name: commonTest on JVM (same packages as Android / iOS / Wasm)
+        run: ./gradlew :webview-compose:jvmTest ${{ env.COMMON_TEST_ARGS }} --no-configuration-cache
+
+  # โ”€โ”€ Same commonTest suite on Wasm โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  common-tests-wasm:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: Setup Chrome
+        uses: browser-actions/setup-chrome@v1
+
+      - name: commonTest on Wasm browser + compile visual e2e host
+        run: >
+          ./gradlew
+          :webview-compose:wasmJsBrowserTest
+          ${{ env.COMMON_TEST_ARGS }}
+          :e2e-wasmJs:compileKotlinWasmJs
+          :e2e-shared:compileKotlinWasmJs
+          --no-configuration-cache
+
+  # โ”€โ”€ Same commonTest suite on Android host + assemble visual e2e APK โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  common-tests-android:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Android SDK
+        uses: android-actions/setup-android@v3
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: commonTest + assemble Android visual e2e suite APK
+        run: >
+          ./gradlew
+          :webview-compose:testDebugUnitTest
+          ${{ env.COMMON_TEST_ARGS }}
+          :e2e-shared:compileDebugKotlinAndroid
+          :e2e-android:assembleDebug
+          --no-configuration-cache
+
+  # โ”€โ”€ Android visual e2e (real WebView on emulator) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  e2e-android:
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Android SDK
+        uses: android-actions/setup-android@v3
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: Enable KVM
+        run: |
+          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+          sudo udevadm control --reload-rules
+          sudo udevadm trigger --name-match=kvm
+
+      - name: Run visual e2e suite on emulator
+        uses: reactivecircus/android-emulator-runner@v2
+        with:
+          api-level: 30
+          arch: x86_64
+          script: |
+            ./gradlew :e2e-android:installDebug --no-configuration-cache
+            adb logcat -c
+            adb uninstall dev.nucleusframework.webview.e2e >/dev/null 2>&1 || true
+            ./gradlew :e2e-android:installDebug --no-configuration-cache
+            adb logcat -c
+            adb shell am start -a android.intent.action.MAIN \
+              -c android.intent.category.LAUNCHER \
+              -n dev.nucleusframework.webview.e2e/.MainActivity
+            # Wait for suite log line (max ~12 min)
+            for i in $(seq 1 360); do
+              if adb logcat -d | grep -q 'SUITE_FINISHED'; then
+                adb logcat -d | grep -E 'SUITE_FINISHED|allGreen' | tail -10
+                if adb logcat -d | grep -q 'SUITE_FINISHED passed=true'; then
+                  echo "Android visual e2e PASSED"
+                  exit 0
+                else
+                  echo "Android visual e2e FAILED" >&2
+                  adb logcat -d | grep 'ComposeWebViewE2E' | tail -120
+                  exit 1
+                fi
+              fi
+              sleep 2
+            done
+            echo "Timed out waiting for SUITE_FINISHED" >&2
+            adb logcat -d | grep -E 'ComposeWebViewE2E|AndroidRuntime' | tail -120
+            exit 1
+
+  # โ”€โ”€ Same commonTest suite on iOS simulator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  common-tests-ios:
+    runs-on: macos-14
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: commonTest on iOS simulator (same packages)
+        run: >
+          ./gradlew
+          :webview-compose:iosSimulatorArm64Test
+          ${{ env.COMMON_TEST_ARGS }}
+          :e2e-shared:compileKotlinIosSimulatorArm64
+          --no-configuration-cache
+
+  # โ”€โ”€ Desktop visual e2e (Linux WebKit2GTK) โ€” real WebView, desktop-only โ”€โ”€โ”€โ”€
+  e2e-linux:
+    needs: build-natives
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Download native artifacts
+        uses: actions/download-artifact@v4
+        with:
+          pattern: 'natives-*'
+          merge-multiple: true
+
+      - name: Verify Linux natives present
+        run: |
+          f="webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so"
+          test -f "$f" || { echo "MISSING: $f" >&2; exit 1; }
+          echo "OK: $f ($(wc -c < "$f") bytes)"
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: Install WebKit2GTK runtime + Xvfb
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y \
+            xvfb \
+            libgtk-3-0 \
+            libwebkit2gtk-4.1-0 \
+            || sudo apt-get install -y \
+              xvfb \
+              libgtk-3-0 \
+              libwebkit2gtk-4.0-0
+
+      - name: Visual e2e suite (Tao + WebKit2GTK)
+        run: |
+          xvfb-run -a -s '-screen 0 1920x1080x24' \
+            ./gradlew :e2e-desktop:run --no-configuration-cache
+
+
+  # โ”€โ”€ Desktop visual e2e (Windows WebView2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  e2e-windows:
+    needs: build-natives
+    runs-on: windows-latest
+    timeout-minutes: 45
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Download native artifacts
+        uses: actions/download-artifact@v4
+        with:
+          pattern: 'natives-*'
+          merge-multiple: true
+
+      - name: Verify Windows natives present
+        shell: bash
+        run: |
+          f="webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
+          loader="webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
+          test -f "$f" && test -f "$loader" || { echo "MISSING natives" >&2; exit 1; }
+          echo "OK: $f / $loader"
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: temurin
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@v4
+
+      - name: Visual e2e suite (Tao + WebView2)
+        run: ./gradlew :e2e-desktop:run --no-configuration-cache
+
+
+  # โ”€โ”€ Desktop visual e2e (macOS WKWebView) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+  e2e-macos:
+    needs: build-natives
+    runs-on: macos-14
+    timeout-minutes: 45
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Download native artifacts
+        uses: actions/download-artifact@v4
+        with:
+          pattern: 'natives-*'
+          merge-multiple: true
+
+      - name: Verify macOS natives present
         run: |
-          EXPECTED=(
-            "webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so"
-            "webview-compose/src/jvmMain/resources/nucleus/native/linux-aarch64/libcompose_webview_linux.so"
-            "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
-            "webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
-            "webview-compose/src/jvmMain/resources/nucleus/native/darwin-aarch64/libcompose_webview_macos.dylib"
-            "webview-compose/src/jvmMain/resources/nucleus/native/darwin-x64/libcompose_webview_macos.dylib"
-          )
           MISSING=0
-          for f in "${EXPECTED[@]}"; do
+          for arch in aarch64 x64; do
+            f="webview-compose/src/jvmMain/resources/nucleus/native/darwin-${arch}/libcompose_webview_macos.dylib"
             if [ -f "$f" ]; then
               echo "OK: $f ($(wc -c < "$f") bytes)"
             else
@@ -54,21 +328,9 @@ jobs:
           java-version: '17'
           distribution: temurin
 
-      - name: Setup Android SDK
-        uses: android-actions/setup-android@v3
-
       - name: Setup Gradle
         uses: gradle/actions/setup-gradle@v4
 
-      - name: Install runtime deps for compile (headers not required)
-        run: |
-          # No native rebuild here โ€” artifacts already downloaded.
-          true
+      - name: Visual e2e suite (Tao + WKWebView)
+        run: ./gradlew :e2e-desktop:run --no-configuration-cache
 
-      - name: Build
-        run: >
-          ./gradlew
-          :webview-compose:compileKotlinJvm
-          :webview-compose:compileDebugKotlinAndroid
-          :demo:compileKotlinJvm
-          --no-configuration-cache
diff --git a/AGENTS.md b/AGENTS.md
index 3e68b34..684f630 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,48 +2,43 @@
 
 ## Project Structure & Module Organization
 
-- `demo/`: Compose Desktop sample app (`demo/src/jvmMain/kotlin/...`).
 - `webview-compose/`: Compose Multiplatform WebView library exposing `dev.nucleusframework.webview.*`
   (`WebView`, `WebViewState`, `WebViewNavigator`).
   - Shared API/types: `webview-compose/src/commonMain/kotlin/...`.
   - Platform actuals: `.../src/jvmMain/` (desktop: Linux WebKit2GTK + macOS WKWebView + Windows WebView2),
     `.../src/androidMain/` (Android WebView),
     `.../src/iosMain/` (WKWebView + cinterop in `.../src/nativeInterop/`), `.../src/wasmJsMain/` (IFrame).
-- `webview-compose-test/`: JVM testing helpers (Playwright-backed mock WebView).
+  - Unit tests: `src/commonTest/` (JVM / Android host / iOS simulator / Wasm browser).
+- `e2e-shared/`: **shared multiplatform visual e2e suite** (`visualsuite/*` in `commonMain`) โ€”
+  same catalog of cases on every platform; capabilities skip only what the host cannot do.
+- `e2e-desktop/`: desktop host (`./gradlew :e2e-desktop:run`, Nucleus Tao + real WebView).
+- `e2e-android/`, `e2e-wasmJs/`, `iosApp/`: same suite host apps (Android WebView / IFrame / WKWebView).
 - Generated/build outputs live under `*/build/` (donโ€™t edit or commit).
 
 ## Build, Test, and Development Commands
 
 - `./gradlew build`: builds all modules.
-- `./gradlew :demo:run`: runs the desktop demo via the Nucleus application plugin
-  (Tao backend + WebKit2GTK on Linux / WKWebView on macOS / WebView2 on Windows; visual e2e suite entrypoint).
-- `./gradlew :webview-compose:buildNativeLinux`: builds
-  `libcompose_webview_linux.so` for the host arch into
-  `webview-compose/src/jvmMain/resources/nucleus/native/linux-{x64,aarch64}/`
-  (requires `libwebkit2gtk-4.1-dev` + `libgtk-3-dev` + JDK).
-  Or: `bash webview-compose/src/jvmMain/native/linux/build.sh`.
-- `./gradlew :webview-compose:buildNativeWindows`: builds
-  `compose_webview_windows.dll` + `WebView2Loader.dll` into
-  `webview-compose/src/jvmMain/resources/nucleus/native/win32-{x64,aarch64}/`
-  (requires MSVC, JDK with `JAVA_HOME`, network for first-run nuget WebView2 SDK).
-  Or: `webview-compose\src\jvmMain\native\windows\build.bat`.
-- `./gradlew :webview-compose:buildNativeMacos`: builds
-  `libcompose_webview_macos.dylib` for **both** arm64 and x86_64 into
-  `webview-compose/src/jvmMain/resources/nucleus/native/darwin-{aarch64,x64}/`
-  (requires Xcode CLT + JDK). Or: `bash webview-compose/src/jvmMain/native/macos/build.sh`.
-- CI: `.github/workflows/build-natives.yaml` matrix builds **linux-x64**
-  (`ubuntu-latest`), **linux-aarch64** (`ubuntu-24.04-arm`), **macos**
-  (`macos-14`, both darwin arches), and **windows-x64** (`windows-latest`),
-  uploads artifacts; PR/publish workflows download them before compile/publish.
-  Natives are **not** committed (see `.gitignore`).
-- GraalVM (demo): `nucleus.application { graalvm { isEnabled = true โ€ฆ } }`.
-  Library reachability metadata lives under
-  `webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/`
-  (JNI bridge + `nucleus/native/**` resources). Demo app metadata under
-  `demo/src/jvmMain/resources/META-INF/native-image/.../composewebview-demo/`.
-  Build a native image with `:demo:package` / Nucleus native-image tasks once
-  natives are present for the host OS.
-- `./gradlew :webview-compose:compileDebugKotlinAndroid`: compiles the Android implementation (requires Android SDK).
+- **Visual e2e (only real WebView suite)**:
+  - Desktop: `./gradlew :e2e-desktop:run` (exit 0/1)
+  - Android: `./gradlew :e2e-android:installDebug` then launch the app
+  - Wasm: `./gradlew :e2e-wasmJs:wasmJsBrowserDevelopmentRun`
+  - iOS: open `iosApp/iosApp.xcodeproj` in Xcode and Run
+- **Unit tests** (`commonTest`, same packages on every target):
+  ```bash
+  COMMON='--tests dev.nucleusframework.webview.jsbridge.* --tests dev.nucleusframework.webview.web.* --tests dev.nucleusframework.webview.request.* --tests dev.nucleusframework.webview.cookie.* --tests dev.nucleusframework.webview.setting.*'
+  ./gradlew :webview-compose:jvmTest $COMMON
+  ./gradlew :webview-compose:testDebugUnitTest $COMMON
+  ./gradlew :webview-compose:iosSimulatorArm64Test $COMMON   # macOS
+  ./gradlew :webview-compose:wasmJsBrowserTest $COMMON
+  ```
+- `./gradlew :webview-compose:buildNativeLinux` / `buildNativeMacos` / `buildNativeWindows`:
+  host native WebView backends into `webview-compose/src/jvmMain/resources/nucleus/native/โ€ฆ`
+  (not committed; CI matrix builds them).
+- CI: `.github/workflows/build-natives.yaml` + `.github/workflows/pr-build-check.yml`
+  (unit commonTest on all targets + visual e2e on desktop matrix + Android emulator).
+- GraalVM (e2e desktop): `nucleus.application { graalvm { isEnabled = true โ€ฆ } }`.
+  Library reachability metadata under
+  `webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/`.
 - `./gradlew clean`: removes Gradle build outputs.
 
 ## Coding Style & Naming Conventions
@@ -53,12 +48,16 @@
 
 ## Testing Guidelines
 
-- Kotlin tests (when added) should live in `*/src/jvmTest/kotlin` (or `commonTest`) and run with `./gradlew test`.
+- **Only** real WebView e2e lives in `e2e-shared` โ†’ `VisualSuiteApp` / `suiteCatalog()`.
+  Do not reintroduce jvmTest driver suites, Playwright, or `LocalWebViewFactory` mocks for coverage.
+- Unit logic that is platform-agnostic goes in `webview-compose/src/commonTest` and must run on
+  JVM, Android host, iOS simulator, and Wasm with the same packages.
+- Cases that need a missing [SuiteCapability] are **Skipped** (not Failed) so the catalog stays identical.
 
 ## Commit & Pull Request Guidelines
 
 - Commit messages follow a simple imperative style (e.g., โ€œAdd โ€ฆโ€, โ€œFix โ€ฆโ€, โ€œRefactor โ€ฆโ€) and mention the affected module/API when helpful.
-- PRs should include: a short rationale, steps to verify (`./gradlew :demo:run`), OS tested (Linux/macOS/Windows), and screenshots/GIFs for UI changes.
+- PRs should include: a short rationale, steps to verify (`./gradlew :e2e-desktop:run`), OS tested (Linux/macOS/Windows), and screenshots/GIFs for UI changes.
 
 ## Security & Configuration Tips
 
diff --git a/README.md b/README.md
index f6a3eac..11411fe 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,10 @@ If you already know **compose-webview-multiplatform**, you already know how to u
 @Composable
 fun App() {
   val state = rememberWebViewState("https://example.com")
-  WebView(state, Modifier.fillMaxSize())
+  WebView(state, Modifier.fillMaxSize()) {
+    // Optional Compose overlay on top of the native WebView
+    // (NativeView content slot on desktop; Box overlay elsewhere).
+  }
 }
 ```
 
@@ -68,19 +71,35 @@ Same artifact for **Android, iOS, Desktop and WasmJs**.
 
 ---
 
-## Demo app
+## E2E harness & tests
 
-Run the feature showcase first:
+### Visual e2e suite (same catalog everywhere)
 
-* **Desktop**: `./gradlew :demo:run` (Nucleus application plugin; visual e2e suite on Linux)
-* **Android**: `./gradlew :demo-android:installDebug`
-* **WasmJs**: `./gradlew :demo-wasmJs:wasmJsBrowserDevelopmentRun`
-* **iOS**: open `iosApp/iosApp.xcodeproj` in Xcode and Run
+`VisualSuiteApp` + `suiteCatalog()` live in **`e2e-shared` commonMain**.
+Every platform host runs that same suite against a **real** WebView:
 
-Responsive UI:
+| Host | Command | Backend |
+|------|---------|---------|
+| Desktop | `./gradlew :e2e-desktop:run` | Tao + WebKit2GTK / WKWebView / WebView2 |
+| Android | `./gradlew :e2e-android:installDebug` then launch app | `android.webkit.WebView` |
+| Wasm | `./gradlew :e2e-wasmJs:wasmJsBrowserDevelopmentRun` | IFrame |
+| iOS | open `iosApp` in Xcode and Run | WKWebView |
 
-* large screens โ†’ side **Tools** panel
-* phones โ†’ **bottom sheet**
+Cases that need a platform-only capability (history on Wasm, isolated native
+profiles on desktop, pixel screenshots, โ€ฆ) are **Skipped** with a reason โ€”
+not Failed โ€” so the catalog stays identical.
+
+### Unit suite (`commonTest`)
+
+Same pure-logic packages on JVM / Android host / iOS simulator / Wasm browser:
+
+```bash
+COMMON='--tests dev.nucleusframework.webview.jsbridge.* --tests dev.nucleusframework.webview.web.* --tests dev.nucleusframework.webview.request.* --tests dev.nucleusframework.webview.cookie.* --tests dev.nucleusframework.webview.setting.*'
+./gradlew :webview-compose:jvmTest $COMMON
+./gradlew :webview-compose:testDebugUnitTest $COMMON
+./gradlew :webview-compose:iosSimulatorArm64Test $COMMON   # macOS
+./gradlew :webview-compose:wasmJsBrowserTest $COMMON
+```
 
 ---
 
@@ -203,10 +222,9 @@ state.webSettings.logSeverity = KLogSeverity.Debug
 
 ## Project structure
 
-* `webview-compose/` โ†’ Compose Multiplatform API + platform actuals
-* `webview-compose-test/` โ†’ Playwright-based test helpers (JVM)
-* `demo-shared/` โ†’ shared demo UI
-* `demo/`, `demo-android/`, `demo-wasmJs/`, `iosApp/` โ†’ platform launchers
+* `webview-compose/` โ†’ Compose Multiplatform API + platform actuals + commonTest
+* `e2e-shared/` โ†’ shared multiplatform visual e2e suite (`VisualSuiteApp`)
+* `e2e-desktop/`, `e2e-android/`, `e2e-wasmJs/`, `iosApp/` โ†’ platform hosts for that suite
 
 ---
 
diff --git a/demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt b/demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt
deleted file mode 100644
index e5e10e7..0000000
--- a/demo-android/src/androidMain/kotlin/dev/nucleusframework/webview/demo/MainActivity.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package dev.nucleusframework.webview.demo
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-
-class MainActivity : ComponentActivity() {
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        setContent {
-            App()
-        }
-    }
-}
diff --git a/demo-android/src/androidMain/res/values/themes.xml b/demo-android/src/androidMain/res/values/themes.xml
deleted file mode 100644
index 0b9e91e..0000000
--- a/demo-android/src/androidMain/res/values/themes.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-    
 
 
-
+
 
 
diff --git a/gradle.properties b/gradle.properties
index 943d61d..ae11695 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -2,6 +2,7 @@
 kotlin.code.style=official
 kotlin.daemon.jvmargs=-Xmx3072M
 kotlin.mpp.enableCInteropCommonization=true
+kotlin.native.ignoreDisabledTargets=true
 #Gradle
 org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8
 org.gradle.configuration-cache=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0862846..1c561f8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,15 +1,16 @@
 [versions]
-androidx-activity = "1.12.2"
+androidx-activity = "1.13.0"
 mavenPublish = "0.37.0"
-androidx-lifecycle = "2.11.0"
+# 2.11+ requires compileSdk 37 / AGP 9.x; keep 2.9.x until AGP is upgraded.
+androidx-lifecycle = "2.9.0"
 androidGradlePlugin = "8.13.2"
 composeHotReload = "1.2.0"
 composeMultiplatform = "1.11.1"
-google-material = "1.13.0"
+google-material = "1.14.0"
 kotlin = "2.4.10"
 kotlinx-coroutines = "1.11.0"
 kotlinx-serialization = "1.11.0"
-nucleus = "dev"
+nucleus = "2.3.1"
 
 [libraries]
 compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 23449a2..5dd3c01 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
index a9a5f6e..21bfd64 100644
--- a/iosApp/Configuration/Config.xcconfig
+++ b/iosApp/Configuration/Config.xcconfig
@@ -1,3 +1,3 @@
 TEAM_ID=
-BUNDLE_ID=dev.nucleusframework.webview.demo.ios
-APP_NAME=ComposeWebView Demo
+BUNDLE_ID=dev.nucleusframework.webview.e2e.ios
+APP_NAME=ComposeWebView E2E
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
index d9ea68a..7dbfaef 100644
--- a/iosApp/iosApp.xcodeproj/project.pbxproj
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -178,7 +178,7 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
-			shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n  exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :demo-shared:embedAndSignAppleFrameworkForXcode";
+			shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n  exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :e2e-shared:embedAndSignAppleFrameworkForXcode";
 		};
 /* End PBXShellScriptBuildPhase section */
 
@@ -324,7 +324,7 @@
 				ENABLE_PREVIEWS = YES;
 				FRAMEWORK_SEARCH_PATHS = (
 					"$(inherited)",
-					"$(SRCROOT)/../demo-shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
+					"$(SRCROOT)/../e2e-shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
 				);
 				INFOPLIST_FILE = iosApp/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@@ -335,7 +335,7 @@
 				OTHER_LDFLAGS = (
 					"$(inherited)",
 					"-framework",
-					demoShared,
+					e2eShared,
 				);
 				PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
 				PRODUCT_NAME = "${APP_NAME}";
@@ -356,7 +356,7 @@
 				ENABLE_PREVIEWS = YES;
 				FRAMEWORK_SEARCH_PATHS = (
 					"$(inherited)",
-					"$(SRCROOT)/../demo-shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
+					"$(SRCROOT)/../e2e-shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
 				);
 				INFOPLIST_FILE = iosApp/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@@ -367,7 +367,7 @@
 				OTHER_LDFLAGS = (
 					"$(inherited)",
 					"-framework",
-					demoShared,
+					e2eShared,
 				);
 				PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
 				PRODUCT_NAME = "${APP_NAME}";
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
index 7145205..06bf242 100644
--- a/iosApp/iosApp/ContentView.swift
+++ b/iosApp/iosApp/ContentView.swift
@@ -1,6 +1,6 @@
 import UIKit
 import SwiftUI
-import demoShared
+import e2eShared
 
 struct ComposeView: UIViewControllerRepresentable {
     func makeUIViewController(context: Context) -> UIViewController {
diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock
new file mode 100644
index 0000000..5f4567d
--- /dev/null
+++ b/kotlin-js-store/wasm/yarn.lock
@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@js-joda/core@3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
+  integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 83d5fde..861b907 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -34,9 +34,8 @@ plugins {
     id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
 }
 
-include(":demo")
-include(":demo-shared")
-include(":demo-android")
-include(":demo-wasmJs")
+include(":e2e-desktop")
+include(":e2e-shared")
+include(":e2e-android")
+include(":e2e-wasmJs")
 include(":webview-compose")
-include(":webview-compose-test")
diff --git a/webview-compose-test/build.gradle.kts b/webview-compose-test/build.gradle.kts
deleted file mode 100644
index e4e7c8b..0000000
--- a/webview-compose-test/build.gradle.kts
+++ /dev/null
@@ -1,29 +0,0 @@
-import com.vanniktech.maven.publish.KotlinMultiplatform
-
-plugins {
-    alias(libs.plugins.kotlinMultiplatform)
-    alias(libs.plugins.mavenPublish)
-}
-
-kotlin {
-    jvm()
-
-    sourceSets {
-        commonMain.dependencies {
-            api(project(":webview-compose"))
-        }
-    }
-}
-
-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/dev/nucleusframework/webview/web/PlaywrightWebView.kt b/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt
deleted file mode 100644
index 5e90706..0000000
--- a/webview-compose-test/src/jvmMain/kotlin/dev/nucleusframework/webview/web/PlaywrightWebView.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package dev.nucleusframework.webview.web
-
-/**
- * Previously hosted a Playwright-backed mock [NativeWebView].
- *
- * Desktop verification is now end-to-end against real WebKit2GTK + Tao
- * (see `LinuxWebViewE2eTest` in `:webview-compose`). This type remains
- * only so older imports fail loudly at compile time with a clear message.
- */
-@Deprecated(
-    message = "Use real desktop e2e (LinuxWebViewE2eTest / WebKit2GTK + Tao), not synthetic mocks",
-    level = DeprecationLevel.ERROR,
-)
-class PlaywrightWebView
diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts
index 4ef4bed..a203ec5 100644
--- a/webview-compose/build.gradle.kts
+++ b/webview-compose/build.gradle.kts
@@ -13,13 +13,6 @@ plugins {
     alias(libs.plugins.mavenPublish)
 }
 
-// Real WebView e2e needs a display (WebKit2GTK + Tao). Don't fail the whole
-// build when DISPLAY is missing (CI without xvfb) โ€” tests self-skip.
-tasks.withType().configureEach {
-    // Each e2e suite owns the Tao event loop once per process.
-    maxParallelForks = 1
-}
-
 // โ”€โ”€ Native build (Linux / macOS / Windows) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 // Same pattern as Nucleus: compile host-arch natives into
 // src/jvmMain/resources/nucleus/native/{linux,darwin,win32}-{x64,aarch64}/.
@@ -139,6 +132,10 @@ kotlin {
             implementation(libs.kotlinx.serializationJson)
         }
 
+        commonTest.dependencies {
+            implementation(kotlin("test"))
+        }
+
         androidMain.dependencies {
             implementation(libs.kotlinx.coroutinesAndroid)
         }
@@ -150,15 +147,6 @@ kotlin {
             implementation(libs.nucleus.core.runtime)
         }
 
-        jvmTest.dependencies {
-            implementation(kotlin("test"))
-            implementation(libs.compose.ui.test)
-            implementation(libs.compose.ui.test.junit4)
-            implementation(compose.desktop.currentOs)
-            implementation(libs.nucleus.application)
-            implementation(libs.kotlinx.coroutinesSwing)
-        }
-
         iosMain.dependencies { }
 
         wasmJsMain.dependencies { }
diff --git a/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt
index bf8d9f8..d53bc84 100644
--- a/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt
+++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/cookie/AndroidCookieManager.kt
@@ -5,7 +5,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.suspendCancellableCoroutine
 
 internal object AndroidCookieManager : CookieManager {
-    private val cookieManager: PlatformCookieManager = PlatformCookieManager.getInstance()
+    // Lazy: unit tests on the host JVM construct WebViewState without a real
+    // android.webkit.CookieManager runtime (getInstance needs a process).
+    private val cookieManager: PlatformCookieManager by lazy { PlatformCookieManager.getInstance() }
 
     override suspend fun setCookie(url: String, cookie: Cookie) {
         cookieManager.setCookie(url, cookie.toString())
@@ -39,8 +41,28 @@ internal object AndroidCookieManager : CookieManager {
 
     override suspend fun removeCookies(url: String) {
         val cookies = getCookies(url)
+        val host =
+            runCatching {
+                // android.net.Uri works without full URL parse edge cases
+                android.net.Uri.parse(url).host
+            }.getOrNull()
         for (cookie in cookies) {
+            // Expire with and without Domain โ€” Android matches on attributes.
             cookieManager.setCookie(url, "${cookie.name}=; Max-Age=0; Path=/")
+            cookieManager.setCookie(
+                url,
+                "${cookie.name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/",
+            )
+            if (!host.isNullOrBlank()) {
+                cookieManager.setCookie(
+                    url,
+                    "${cookie.name}=; Max-Age=0; Path=/; Domain=$host",
+                )
+                cookieManager.setCookie(
+                    url,
+                    "${cookie.name}=; Max-Age=0; Path=/; Domain=.$host",
+                )
+            }
         }
         cookieManager.flush()
     }
diff --git a/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt
index 18767ab..ca7d8df 100644
--- a/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt
+++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/AndroidWebView.kt
@@ -95,11 +95,14 @@ internal class AndroidWebView(
     }
 
     override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) {
-        val androidScript = "javascript:$script"
-        KLogger.d {
-            "evaluateJavaScript: $androidScript"
+        // evaluateJavascript must run on the WebView/UI thread (not the binder
+        // thread used by @JavascriptInterface).
+        nativeWebView.post {
+            KLogger.d { "evaluateJavaScript: $script" }
+            nativeWebView.evaluateJavascript(script) { result ->
+                callback?.invoke(result ?: "")
+            }
         }
-        nativeWebView.evaluateJavascript(androidScript, callback)
     }
 
     override fun injectJsBridge() {
@@ -122,10 +125,13 @@ internal class AndroidWebView(
 
     @JavascriptInterface
     fun call(raw: String) {
-        parseJsMessage(raw)?.let { message ->
-            webViewJsBridge?.dispatch(message)
-        } ?: run {
-            KLogger.w(tag = "AndroidWebView") { "Invalid JS message: $raw" }
+        // Hop to the WebView thread before dispatch/callback evaluation.
+        nativeWebView.post {
+            parseJsMessage(raw)?.let { message ->
+                webViewJsBridge?.dispatch(message)
+            } ?: run {
+                KLogger.w(tag = "AndroidWebView") { "Invalid JS message: $raw" }
+            }
         }
     }
 }
diff --git a/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt
index 7092a7a..3e0ecdc 100644
--- a/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt
+++ b/webview-compose/src/androidMain/kotlin/dev/nucleusframework/webview/web/WebViewAndroid.kt
@@ -7,6 +7,7 @@ import android.view.ViewGroup
 import android.webkit.*
 import android.widget.FrameLayout
 import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
@@ -28,6 +29,7 @@ actual fun ActualWebView(
     onCreated: (NativeWebView) -> Unit,
     onDispose: (NativeWebView) -> Unit,
     factory: (WebViewFactoryParam) -> NativeWebView,
+    content: @Composable () -> Unit,
 ) {
     AndroidWebViewContainer(
         state = state,
@@ -37,6 +39,7 @@ actual fun ActualWebView(
         onCreated = onCreated,
         onDispose = onDispose,
         factory = { ctx -> factory(WebViewFactoryParam(ctx)) },
+        content = content,
     )
 }
 
@@ -55,6 +58,7 @@ private fun AndroidWebViewContainer(
     onCreated: (WebView) -> Unit,
     onDispose: (WebView) -> Unit,
     factory: (Context) -> WebView,
+    content: @Composable () -> Unit,
 ) {
     if (LocalWebViewFactory.current != null) {
         val context = LocalContext.current
@@ -75,6 +79,7 @@ private fun AndroidWebViewContainer(
                     webViewJsBridge?.webView = androidWebView
                 }
             }
+            content()
         }
     } else {
         BoxWithConstraints(modifier) {
@@ -111,7 +116,7 @@ private fun AndroidWebViewContainer(
                         webViewJsBridge?.webView = androidWebView
                     }
                 },
-                modifier = Modifier,
+                modifier = Modifier.fillMaxSize(),
                 update = { webView ->
                     webView.layoutParams = layoutParams
                     configureSettings(webView, state.webSettings)
@@ -126,6 +131,7 @@ private fun AndroidWebViewContainer(
                     onDispose(webView)
                 },
             )
+            content()
         }
     }
 }
diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt
index c51268d..c010fcc 100644
--- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt
+++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebView.kt
@@ -9,6 +9,14 @@ import kotlinx.coroutines.flow.merge
 
 val LocalWebViewFactory = staticCompositionLocalOf<((WebViewFactoryParam) -> NativeWebView)?> { null }
 
+/**
+ * Multiplatform WebView composable.
+ *
+ * @param content Compose UI drawn **over** the embedded native WebView
+ * (same role as [dev.nucleusframework.window.tao.NativeView]'s content slot).
+ * On desktop this is required for overlays above the native surface; on
+ * Android / iOS / Wasm it is a regular Compose sibling layered on top.
+ */
 @Composable
 fun WebView(
     state: WebViewState,
@@ -16,7 +24,8 @@ fun WebView(
     navigator: WebViewNavigator = rememberWebViewNavigator(),
     webViewJsBridge: WebViewJsBridge? = null,
     onCreated: (NativeWebView) -> Unit = {},
-    onDispose: (NativeWebView) -> Unit = {}
+    onDispose: (NativeWebView) -> Unit = {},
+    content: @Composable () -> Unit = {},
 ) {
     val factory = LocalWebViewFactory.current ?: ::defaultWebViewFactory
 
@@ -30,8 +39,8 @@ fun WebView(
         }
 
         LaunchedEffect(wv, state) {
-            snapshotFlow { state.content }.collect { content ->
-                wv.loadContent(content)
+            snapshotFlow { state.content }.collect { pageContent ->
+                wv.loadContent(pageContent)
             }
         }
 
@@ -59,7 +68,8 @@ fun WebView(
         webViewJsBridge = webViewJsBridge,
         onCreated = onCreated,
         onDispose = onDispose,
-        factory = factory
+        factory = factory,
+        content = content,
     )
 
     DisposableEffect(Unit) {
@@ -80,4 +90,5 @@ expect fun ActualWebView(
     onCreated: (NativeWebView) -> Unit = {},
     onDispose: (NativeWebView) -> Unit = {},
     factory: (WebViewFactoryParam) -> NativeWebView = ::defaultWebViewFactory,
+    content: @Composable () -> Unit = {},
 )
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/cookie/CookieTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/cookie/CookieTest.kt
new file mode 100644
index 0000000..027b47a
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/cookie/CookieTest.kt
@@ -0,0 +1,50 @@
+package dev.nucleusframework.webview.cookie
+
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class CookieTest {
+    @Test
+    fun toStringMinimal() {
+        val cookie = Cookie(name = "sid", value = "abc")
+        assertEquals("sid=abc;", cookie.toString())
+    }
+
+    @Test
+    fun toStringIncludesOptionalAttributes() {
+        val cookie =
+            Cookie(
+                name = "sid",
+                value = "abc",
+                domain = "example.com",
+                path = "/",
+                isSecure = true,
+                isHttpOnly = true,
+                sameSite = Cookie.HTTPCookieSameSitePolicy.LAX,
+                maxAge = 3600,
+            )
+        val raw = cookie.toString()
+        assertContains(raw, "sid=abc")
+        assertContains(raw, "Domain=example.com")
+        assertContains(raw, "Path=/")
+        assertContains(raw, "Secure")
+        assertContains(raw, "HttpOnly")
+        assertContains(raw, "SameSite=LAX")
+        assertContains(raw, "Max-Age=3600")
+        assertTrue(raw.endsWith(";"))
+    }
+
+    @Test
+    fun sessionOnlyFlagDoesNotChangeWireFormatByItself() {
+        val session = Cookie(name = "a", value = "b", isSessionOnly = true)
+        val sticky = Cookie(name = "a", value = "b", isSessionOnly = false)
+        assertEquals(session.toString(), sticky.toString())
+        assertFalse(session.toString().contains("Session"))
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcherTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcherTest.kt
new file mode 100644
index 0000000..2e12904
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageDispatcherTest.kt
@@ -0,0 +1,83 @@
+package dev.nucleusframework.webview.jsbridge
+
+import dev.nucleusframework.webview.web.WebViewNavigator
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class JsMessageDispatcherTest {
+    @Test
+    fun dispatchesToRegisteredHandler() {
+        val dispatcher = JsMessageDispatcher()
+        val seen = mutableListOf()
+
+        dispatcher.registerJSHandler(
+            object : IJsMessageHandler {
+                override fun methodName(): String = "echo"
+
+                override fun handle(
+                    message: JsMessage,
+                    navigator: WebViewNavigator?,
+                    callback: (String) -> Unit,
+                ) {
+                    seen += message.params
+                    callback("""{"ok":true}""")
+                }
+            },
+        )
+
+        var callbackPayload: String? = null
+        dispatcher.dispatch(
+            message =
+                JsMessage(
+                    callbackId = 1,
+                    methodName = "echo",
+                    params = """{"v":42}""",
+                ),
+            callback = { callbackPayload = it },
+        )
+
+        assertEquals(listOf("""{"v":42}"""), seen)
+        assertEquals("""{"ok":true}""", callbackPayload)
+    }
+
+    @Test
+    fun ignoresUnknownMethod() {
+        val dispatcher = JsMessageDispatcher()
+        var called = false
+        dispatcher.dispatch(
+            message = JsMessage(callbackId = 0, methodName = "missing", params = "{}"),
+            callback = { called = true },
+        )
+        assertTrue(!called)
+    }
+
+    @Test
+    fun unregisterStopsDispatch() {
+        val dispatcher = JsMessageDispatcher()
+        val handler =
+            object : IJsMessageHandler {
+                override fun methodName(): String = "ping"
+
+                override fun handle(
+                    message: JsMessage,
+                    navigator: WebViewNavigator?,
+                    callback: (String) -> Unit,
+                ) {
+                    callback("pong")
+                }
+            }
+        dispatcher.registerJSHandler(handler)
+        dispatcher.unregisterJSHandler(handler)
+
+        var payload: String? = null
+        dispatcher.dispatch(
+            message = JsMessage(callbackId = 0, methodName = "ping", params = "{}"),
+            callback = { payload = it },
+        )
+        assertEquals(null, payload)
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt
new file mode 100644
index 0000000..1b106bb
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt
@@ -0,0 +1,59 @@
+package dev.nucleusframework.webview.jsbridge
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class JsMessageParsingTest {
+    @Test
+    fun parsesStandardBridgeMessage() {
+        val raw =
+            """
+            {"callbackId":7,"methodName":"echo","params":"{\"x\":1}","type":"call"}
+            """.trimIndent()
+
+        val message = parseJsMessage(raw, expectedType = "call")
+        assertNotNull(message)
+        assertEquals(7, message.callbackId)
+        assertEquals("echo", message.methodName)
+        assertEquals("""{"x":1}""", message.params)
+    }
+
+    @Test
+    fun rejectsUnexpectedType() {
+        val raw =
+            """
+            {"callbackId":1,"methodName":"echo","params":"{}","type":"other"}
+            """.trimIndent()
+
+        assertNull(parseJsMessage(raw, expectedType = "call"))
+    }
+
+    @Test
+    fun parsesWasmStyleActionMessage() {
+        val raw =
+            """
+            {"action":"echo","params":{"hello":"world"}}
+            """.trimIndent()
+
+        val message = parseJsMessage(raw)
+        assertNotNull(message)
+        assertEquals("echo", message.methodName)
+        assertEquals(0, message.callbackId)
+        assertEquals("""{"hello":"world"}""", message.params)
+    }
+
+    @Test
+    fun returnsNullForInvalidJson() {
+        assertNull(parseJsMessage("not-json"))
+    }
+
+    @Test
+    fun returnsNullWhenMethodMissing() {
+        assertNull(parseJsMessage("""{"callbackId":1,"params":"{}"}"""))
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridgeTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridgeTest.kt
new file mode 100644
index 0000000..499c58c
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/WebViewJsBridgeTest.kt
@@ -0,0 +1,108 @@
+package dev.nucleusframework.webview.jsbridge
+
+import dev.nucleusframework.webview.web.WebViewNavigator
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class WebViewJsBridgeTest {
+    @Test
+    fun defaultBridgeName() {
+        val bridge = WebViewJsBridge()
+        assertEquals("kmpJsBridge", bridge.jsBridgeName)
+    }
+
+    @Test
+    fun customBridgeName() {
+        val bridge = WebViewJsBridge(jsBridgeName = "myBridge")
+        assertEquals("myBridge", bridge.jsBridgeName)
+    }
+
+    @Test
+    fun registerDispatchAndClear() {
+        val bridge = WebViewJsBridge()
+        val seen = mutableListOf()
+        val handler =
+            object : IJsMessageHandler {
+                override fun methodName(): String = "echo"
+
+                override fun handle(
+                    message: JsMessage,
+                    navigator: WebViewNavigator?,
+                    callback: (String) -> Unit,
+                ) {
+                    seen += message.params
+                    callback("""{"ok":true}""")
+                }
+            }
+
+        bridge.register(handler)
+        bridge.dispatch(
+            JsMessage(callbackId = -1, methodName = "echo", params = """{"x":1}"""),
+        )
+        assertEquals(listOf("""{"x":1}"""), seen)
+
+        bridge.clear()
+        bridge.dispatch(
+            JsMessage(callbackId = -1, methodName = "echo", params = """{"x":2}"""),
+        )
+        // cleared โ†’ no second dispatch
+        assertEquals(listOf("""{"x":1}"""), seen)
+    }
+
+    @Test
+    fun unregisterRemovesHandler() {
+        val bridge = WebViewJsBridge()
+        var calls = 0
+        val handler =
+            object : IJsMessageHandler {
+                override fun methodName(): String = "ping"
+
+                override fun handle(
+                    message: JsMessage,
+                    navigator: WebViewNavigator?,
+                    callback: (String) -> Unit,
+                ) {
+                    calls++
+                }
+            }
+        bridge.register(handler)
+        bridge.unregister(handler)
+        bridge.dispatch(JsMessage(callbackId = -1, methodName = "ping", params = "{}"))
+        assertEquals(0, calls)
+    }
+
+    @Test
+    fun processParamsAndDataToJsonString() {
+        val handler =
+            object : IJsMessageHandler {
+                override fun methodName(): String = "typed"
+
+                override fun handle(
+                    message: JsMessage,
+                    navigator: WebViewNavigator?,
+                    callback: (String) -> Unit,
+                ) = Unit
+            }
+
+        @kotlinx.serialization.Serializable
+        data class Payload(val n: Int, val s: String)
+
+        val message =
+            JsMessage(
+                callbackId = 1,
+                methodName = "typed",
+                params = """{"n":7,"s":"hi"}""",
+            )
+        val decoded = handler.processParams(message)
+        assertEquals(7, decoded.n)
+        assertEquals("hi", decoded.s)
+
+        val encoded = handler.dataToJsonString(Payload(n = 1, s = "x"))
+        assertTrue(encoded.contains("\"n\":1"))
+        assertTrue(encoded.contains("\"s\":\"x\""))
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/request/WebRequestTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/request/WebRequestTest.kt
new file mode 100644
index 0000000..8c8b0cf
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/request/WebRequestTest.kt
@@ -0,0 +1,49 @@
+package dev.nucleusframework.webview.request
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class WebRequestTest {
+    @Test
+    fun defaults() {
+        val request = WebRequest(url = "https://example.com")
+        assertEquals("https://example.com", request.url)
+        assertTrue(request.headers.isEmpty())
+        assertFalse(request.isForMainFrame)
+        assertFalse(request.isRedirect)
+        assertEquals("GET", request.method)
+    }
+
+    @Test
+    fun copyPreservesHeadersMutabilitySemantics() {
+        val request =
+            WebRequest(
+                url = "https://example.com",
+                headers = mutableMapOf("A" to "1"),
+                isForMainFrame = true,
+                isRedirect = true,
+                method = "POST",
+            )
+        assertEquals("POST", request.method)
+        assertTrue(request.isForMainFrame)
+        assertTrue(request.isRedirect)
+        assertEquals("1", request.headers["A"])
+    }
+
+    @Test
+    fun interceptResults() {
+        assertIs(WebRequestInterceptResult.Allow)
+        assertIs(WebRequestInterceptResult.Reject)
+        val modified =
+            WebRequestInterceptResult.Modify(
+                WebRequest(url = "https://redirect.example"),
+            )
+        assertEquals("https://redirect.example", modified.request.url)
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/setting/WebSettingsTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/setting/WebSettingsTest.kt
new file mode 100644
index 0000000..03bcc03
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/setting/WebSettingsTest.kt
@@ -0,0 +1,45 @@
+package dev.nucleusframework.webview.setting
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class WebSettingsTest {
+    @Test
+    fun defaults() {
+        val settings = WebSettings()
+        assertTrue(settings.isJavaScriptEnabled)
+        assertTrue(settings.supportZoom)
+        assertEquals(1.0, settings.zoomLevel)
+        assertNull(settings.customUserAgentString)
+        assertFalse(settings.allowFileAccessFromFileURLs)
+        assertFalse(settings.allowUniversalAccessFromFileURLs)
+    }
+
+    @Test
+    fun platformBucketsExist() {
+        val settings = WebSettings()
+        // Touch platform settings objects so they stay wired on every target.
+        settings.androidWebSettings.domStorageEnabled = true
+        settings.desktopWebSettings.transparent = true
+        settings.iOSWebSettings.opaque = false
+        settings.wasmJSWebSettings.showBorder = true
+
+        assertTrue(settings.androidWebSettings.domStorageEnabled)
+        assertTrue(settings.desktopWebSettings.transparent)
+        assertFalse(settings.iOSWebSettings.opaque)
+        assertTrue(settings.wasmJSWebSettings.showBorder)
+    }
+
+    @Test
+    fun customUserAgentMutable() {
+        val settings = WebSettings()
+        settings.customUserAgentString = "ComposeWebView/e2e"
+        assertEquals("ComposeWebView/e2e", settings.customUserAgentString)
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/LoadingStateTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/LoadingStateTest.kt
new file mode 100644
index 0000000..a778be5
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/LoadingStateTest.kt
@@ -0,0 +1,25 @@
+package dev.nucleusframework.webview.web
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class LoadingStateTest {
+    @Test
+    fun loadingHoldsProgress() {
+        val state = LoadingState.Loading(0.42f)
+        assertIs(state)
+        assertEquals(0.42f, state.progress, absoluteTolerance = 0.0001f)
+    }
+
+    @Test
+    fun finishedAndInitializingAreDistinct() {
+        assertTrue(LoadingState.Finished != LoadingState.Initializing)
+        assertIs(LoadingState.Finished)
+        assertIs(LoadingState.Initializing)
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebContentTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebContentTest.kt
new file mode 100644
index 0000000..49b9399
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebContentTest.kt
@@ -0,0 +1,51 @@
+package dev.nucleusframework.webview.web
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class WebContentTest {
+    @Test
+    fun urlKeepsHeaders() {
+        val content =
+            WebContent.Url(
+                url = "https://example.com/path",
+                additionalHttpHeaders = mapOf("X-Test" to "1"),
+            )
+        assertIs(content)
+        assertEquals("https://example.com/path", content.url)
+        assertEquals(mapOf("X-Test" to "1"), content.additionalHttpHeaders)
+    }
+
+    @Test
+    fun dataDefaults() {
+        val content = WebContent.Data(data = "")
+        assertIs(content)
+        assertEquals("", content.data)
+        assertNull(content.baseUrl)
+        assertEquals("utf-8", content.encoding)
+        assertNull(content.mimeType)
+        assertNull(content.historyUrl)
+    }
+
+    @Test
+    fun fileHoldsReadType() {
+        val content =
+            WebContent.File(
+                fileName = "fixture.html",
+                readType = WebViewFileReadType.COMPOSE_RESOURCE_FILES,
+            )
+        assertIs(content)
+        assertEquals("fixture.html", content.fileName)
+        assertEquals(WebViewFileReadType.COMPOSE_RESOURCE_FILES, content.readType)
+    }
+
+    @Test
+    fun navigatorOnlyIsSingletonStyle() {
+        assertIs(WebContent.NavigatorOnly)
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewErrorTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewErrorTest.kt
new file mode 100644
index 0000000..6cc9f3d
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewErrorTest.kt
@@ -0,0 +1,33 @@
+package dev.nucleusframework.webview.web
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class WebViewErrorTest {
+    @Test
+    fun holdsFields() {
+        val error =
+            WebViewError(
+                code = -2,
+                description = "net::ERR_FAILED",
+                isFromMainFrame = false,
+            )
+        assertEquals(-2, error.code)
+        assertEquals("net::ERR_FAILED", error.description)
+        assertFalse(error.isFromMainFrame)
+    }
+
+    @Test
+    fun equality() {
+        val a = WebViewError(1, "x", true)
+        val b = WebViewError(1, "x", true)
+        val c = WebViewError(1, "y", true)
+        assertEquals(a, b)
+        assertTrue(a != c)
+    }
+}
diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewStateTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewStateTest.kt
new file mode 100644
index 0000000..ff3e833
--- /dev/null
+++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/web/WebViewStateTest.kt
@@ -0,0 +1,66 @@
+package dev.nucleusframework.webview.web
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Shared multiplatform suite โ€” must pass on JVM, Android host, iOS simulator, Wasm.
+ */
+class WebViewStateTest {
+    @Test
+    fun startsInitializingAndReportsLoading() {
+        val state = WebViewState(WebContent.Url("https://example.com"))
+        assertIs(state.loadingState)
+        assertTrue(state.isLoading)
+        assertNull(state.lastLoadedUrl)
+        assertNull(state.pageTitle)
+        assertNull(state.webView)
+    }
+
+    @Test
+    fun isLoadingFalseWhenFinished() {
+        val state = WebViewState(WebContent.Url("https://example.com"))
+        state.loadingState = LoadingState.Finished
+        assertFalse(state.isLoading)
+    }
+
+    @Test
+    fun isLoadingTrueWhileLoading() {
+        val state = WebViewState(WebContent.Url("https://example.com"))
+        state.loadingState = LoadingState.Loading(0.5f)
+        assertTrue(state.isLoading)
+    }
+
+    @Test
+    fun contentCanBeReplaced() {
+        val state = WebViewState(WebContent.Url("https://a.example"))
+        state.content = WebContent.Data("

hi

", baseUrl = "https://b.example") + val data = assertIs(state.content) + assertEquals("

hi

", data.data) + assertEquals("https://b.example", data.baseUrl) + } + + @Test + fun errorsListIsMutable() { + val state = WebViewState(WebContent.Url("https://example.com")) + assertTrue(state.errorsForCurrentRequest.isEmpty()) + state.errorsForCurrentRequest.add( + WebViewError(code = 404, description = "not found", isFromMainFrame = true), + ) + assertEquals(1, state.errorsForCurrentRequest.size) + assertEquals(404, state.errorsForCurrentRequest.first().code) + } + + @Test + fun webSettingsDefaultsAreSensible() { + val state = WebViewState(WebContent.Url("https://example.com")) + assertTrue(state.webSettings.isJavaScriptEnabled) + assertTrue(state.webSettings.supportZoom) + assertEquals(1.0, state.webSettings.zoomLevel) + assertNull(state.webSettings.customUserAgentString) + } +} diff --git a/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt index ea9d755..08e21d3 100644 --- a/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.webview.web import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -36,6 +37,7 @@ actual fun ActualWebView( onCreated: (NativeWebView) -> Unit, onDispose: (NativeWebView) -> Unit, factory: (WebViewFactoryParam) -> NativeWebView, + content: @Composable () -> Unit, ) { val observer = remember { WKWebViewObserver(state, navigator) } val navigationDelegate = remember { WKNavigationDelegate(state, navigator) } @@ -53,77 +55,81 @@ actual fun ActualWebView( webViewJsBridge?.webView = iosWebView } } + content() } } else { - UIKitView( - factory = { - val config = - WKWebViewConfiguration().apply { - defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled - preferences.apply { + Box(modifier) { + 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.fillMaxSize(), + 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( - interactionMode = UIKitInteropInteractionMode.NonCooperative, - isNativeAccessibilityEnabled = true, - ), - ) + wkWebView.navigationDelegate = null + + onDispose(wkWebView) + }, + properties = UIKitInteropProperties( + interactionMode = UIKitInteropInteractionMode.NonCooperative, + isNativeAccessibilityEnabled = true, + ), + ) + content() + } } } diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt index 99a7f50..9cd1885 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt @@ -128,6 +128,7 @@ actual fun ActualWebView( onCreated: (NativeWebView) -> Unit, onDispose: (NativeWebView) -> Unit, factory: (WebViewFactoryParam) -> NativeWebView, + content: @Composable () -> Unit, ) { val currentOnDispose by rememberUpdatedState(onDispose) val scope = rememberCoroutineScope() @@ -291,6 +292,7 @@ actual fun ActualWebView( factory = { linuxWebView.asPlatformView() }, modifier = modifier, update = { }, + content = content, ) LaunchedEffect(nativeWebView) { onCreated(nativeWebView) @@ -301,6 +303,7 @@ actual fun ActualWebView( factory = { macosWebView.asPlatformView() }, modifier = modifier, update = { }, + content = content, ) LaunchedEffect(nativeWebView) { onCreated(nativeWebView) @@ -311,17 +314,19 @@ actual fun ActualWebView( factory = { windowsWebView.asPlatformView() }, modifier = modifier, update = { }, + content = content, ) LaunchedEffect(nativeWebView) { onCreated(nativeWebView) } } else -> { - // Test factory / unsupported / no-op: empty layout slot. + // Test factory / unsupported / no-op: empty layout slot + overlay. Box(modifier) { LaunchedEffect(nativeWebView) { onCreated(nativeWebView) } + content() } } } diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt deleted file mode 100644 index 04b6b84..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/DesktopWebViewE2eTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package dev.nucleusframework.webview.web - -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.core.runtime.Platform -import dev.nucleusframework.webview.web.e2e.E2eHost -import dev.nucleusframework.webview.web.linux.WebKitLinuxBridge -import dev.nucleusframework.webview.web.macos.WebKitMacOsBridge -import dev.nucleusframework.webview.web.windows.WebView2WindowsBridge -import dev.nucleusframework.window.TitleBar -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicReference -import kotlin.concurrent.thread -import kotlin.test.Test - -/** - * End-to-end tests against a real desktop WebView embedded via Nucleus - * NativeView + Tao backend. No mocks. - * - * - **Linux**: WebKit2GTK (`compose_webview_linux`) - * - **macOS**: WKWebView (`compose_webview_macos`) - * - **Windows**: WebView2 (`compose_webview_windows`) - * - * Drivers live in [dev.nucleusframework.webview.web.e2e]. - * Self-skips when the host OS/backend is unavailable. - */ -class DesktopWebViewE2eTest { - @Test - fun allDesktopWebViewFeaturesE2e() { - when (Platform.Current) { - Platform.Linux -> { - if (!WebKitLinuxBridge.isLoaded) { - println("SKIPPED: compose_webview_linux native library not loaded") - return - } - if (System.getenv("DISPLAY").isNullOrBlank() && - System.getenv("WAYLAND_DISPLAY").isNullOrBlank() - ) { - println("SKIPPED: no DISPLAY / WAYLAND_DISPLAY") - return - } - } - Platform.MacOS -> { - if (!WebKitMacOsBridge.isLoaded) { - println("SKIPPED: compose_webview_macos native library not loaded") - return - } - } - Platform.Windows -> { - if (!WebView2WindowsBridge.isLoaded) { - println("SKIPPED: compose_webview_windows native library not loaded") - return - } - } - else -> { - println("SKIPPED: desktop e2e unsupported on ${Platform.Current}") - return - } - } - - val failures = CopyOnWriteArrayList() - val completed = AtomicReference(false) - - thread(isDaemon = true, name = "webview-e2e-watchdog") { - Thread.sleep(WATCHDOG_MS) - if (!completed.get()) { - System.err.println("WATCHDOG: e2e suite timed out") - Runtime.getRuntime().halt(42) - } - } - - nucleusApplication(backend = NucleusBackend.Tao) { - val windowState = rememberWindowState(size = DpSize(900.dp, 700.dp)) - DecoratedWindow( - onCloseRequest = ::exitApplication, - state = windowState, - title = "webview-e2e", - ) { - TitleBar { } - E2eHost( - onFailure = { failures += it }, - onDone = { - completed.set(true) - exitApplication() - }, - ) - } - } - - val failureSummary = - if (failures.isEmpty()) { - null - } else { - "E2E failures:\n" + failures.joinToString("\n") { " - $it" } - } - if (failureSummary != null) { - println(failureSummary) - } - // Prefer a hard AssertionError so the suite cannot be recorded as "skipped" - // when the Tao event loop exits before kotlin.test.fail propagates cleanly. - check(completed.get()) { "suite did not complete" } - check(failureSummary == null) { failureSummary!! } - } - - private companion object { - const val WATCHDOG_MS = 180_000L - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt deleted file mode 100644 index d167b8a..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/CookieDriver.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.cookie.Cookie -import dev.nucleusframework.webview.web.WebViewState -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlinx.coroutines.delay - -@Composable -internal fun CookieDriver( - state: WebViewState, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - val url = "https://e2e.local/" - state.cookieManager.removeAllCookies() - delay(200) - state.cookieManager.setCookie( - url, - Cookie( - name = "e2e_cookie", - value = "v1", - domain = "e2e.local", - path = "/", - isSecure = false, - isHttpOnly = false, - ), - ) - awaitUntil("cookie present") { - state.cookieManager.getCookies(url).any { it.name == "e2e_cookie" && it.value == "v1" } - } - val cookies = state.cookieManager.getCookies(url) - assertTrue(cookies.any { it.name == "e2e_cookie" }, "cookie not found: $cookies") - state.cookieManager.removeCookies(url) - delay(300) - val after = state.cookieManager.getCookies(url) - assertFalse(after.any { it.name == "e2e_cookie" }, "cookie should be removed, still: $after") - println("[e2e] cookies set/get/remove OK") - onDone() - } catch (t: Throwable) { - onFailure("cookies: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt deleted file mode 100644 index 1548d1d..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHelpers.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import dev.nucleusframework.webview.web.LoadingState -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView -import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout - -internal fun isLiveDesktopWebView(state: WebViewState): Boolean { - val nv = state.webView?.nativeWebView ?: return false - return nv.isReady() && - (nv is LinuxWebKitNativeWebView || nv is WindowsWebView2NativeWebView) -} - -internal suspend fun awaitWebViewReady(state: WebViewState, timeoutMs: Long = 15_000) { - awaitUntil("webview attached", timeoutMs) { - isLiveDesktopWebView(state) - } -} - -internal suspend fun awaitUntil( - description: String, - timeoutMs: Long = 15_000, - predicate: suspend () -> Boolean, -) { - withTimeout(timeoutMs) { - while (!predicate()) { - delay(50) - } - } -} - -internal suspend fun awaitJsString( - navigator: WebViewNavigator, - script: String, - timeoutMs: Long = 10_000, -): String { - val deferred = CompletableDeferred() - navigator.evaluateJavaScript(script) { result -> - deferred.complete(result) - } - return withTimeout(timeoutMs) { deferred.await() } -} - -internal suspend fun awaitFinished(state: WebViewState, label: String = "page finished") { - awaitUntil(label) { state.loadingState is LoadingState.Finished } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt deleted file mode 100644 index 329ac76..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2eHost.kt +++ /dev/null @@ -1,115 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -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.setValue -import androidx.compose.ui.Modifier -import dev.nucleusframework.webview.jsbridge.IJsMessageHandler -import dev.nucleusframework.webview.jsbridge.JsMessage -import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge -import dev.nucleusframework.webview.request.RequestInterceptor -import dev.nucleusframework.webview.request.WebRequest -import dev.nucleusframework.webview.request.WebRequestInterceptResult -import dev.nucleusframework.webview.web.WebView -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.rememberWebViewNavigator -import dev.nucleusframework.webview.web.rememberWebViewStateWithHTMLData -import java.util.concurrent.CopyOnWriteArrayList - -@Composable -internal fun E2eHost( - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - val scope = rememberCoroutineScope() - var phase by remember { mutableStateOf(0) } - - val navigator = rememberWebViewNavigator(coroutineScope = scope) - val state = - rememberWebViewStateWithHTMLData( - data = E2E_PAGE_HTML, - baseUrl = "https://e2e.local/", - ) - val jsBridge = rememberWebViewJsBridge(navigator) - - var rejectExample by remember { mutableStateOf(false) } - val interceptor = - remember { - object : RequestInterceptor { - override fun onInterceptUrlRequest( - request: WebRequest, - navigator: WebViewNavigator, - ): WebRequestInterceptResult { - if (rejectExample && request.url.contains("example.com")) { - return WebRequestInterceptResult.Reject - } - return WebRequestInterceptResult.Allow - } - } - } - val interceptNavigator = - rememberWebViewNavigator(coroutineScope = scope, requestInterceptor = interceptor) - - val bridgeHits = remember { CopyOnWriteArrayList() } - LaunchedEffect(jsBridge) { - jsBridge.register( - object : IJsMessageHandler { - override fun methodName(): String = "e2eEcho" - - override fun handle( - message: JsMessage, - navigator: WebViewNavigator?, - callback: (String) -> Unit, - ) { - bridgeHits += message.params - callback("""{"ok":true}""") - } - }, - ) - } - - when (phase) { - 0, 1, 2, 3, 4, 6, 7 -> { - WebView( - state = state, - navigator = navigator, - webViewJsBridge = jsBridge, - modifier = Modifier.fillMaxSize(), - ) - } - 5 -> { - val interceptState = - rememberWebViewStateWithHTMLData( - data = E2E_PAGE_HTML, - baseUrl = "https://e2e.local/", - ) - WebView( - state = interceptState, - navigator = interceptNavigator, - modifier = Modifier.fillMaxSize(), - ) - InterceptorDriver( - state = interceptState, - navigator = interceptNavigator, - rejectExample = { rejectExample = it }, - onFailure = onFailure, - onDone = { phase = 6 }, - ) - } - } - - when (phase) { - 0 -> LoadHtmlDriver(state, navigator, onFailure) { phase = 1 } - 1 -> EvaluateJsDriver(state, navigator, onFailure) { phase = 2 } - 2 -> NavigationDriver(state, navigator, onFailure) { phase = 3 } - 3 -> CookieDriver(state, onFailure) { phase = 4 } - 4 -> JsBridgeDriver(state, navigator, bridgeHits, onFailure) { phase = 5 } - 6 -> ScreenshotDriver(state, onFailure) { phase = 7 } - 7 -> UserAgentDriver(onFailure, onDone) - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt deleted file mode 100644 index 0f36287..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/E2ePages.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -internal val E2E_PAGE_HTML = - """ - - - E2E Page - -

E2E

-
hello-e2e
- - - """.trimIndent() - -internal val E2E_PAGE_A = - """ - A -
page-a
- """.trimIndent() - -internal val E2E_PAGE_B = - """ - B -
page-b
- """.trimIndent() diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt deleted file mode 100644 index afe5685..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/EvaluateJsDriver.kt +++ /dev/null @@ -1,31 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import kotlin.test.assertTrue - -@Composable -internal fun EvaluateJsDriver( - state: WebViewState, - navigator: WebViewNavigator, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - awaitJsString(navigator, "window.__e2e = 41; window.__e2e + 1") - val value = awaitJsString(navigator, "window.__e2e") - assertTrue(value.contains("41"), "expected 41, got $value") - val outer = awaitJsString(navigator, "document.documentElement.outerHTML") - assertTrue(outer.contains("E2E"), "outerHTML missing E2E: $outer") - println("[e2e] evaluateJavaScript OK") - onDone() - } catch (t: Throwable) { - onFailure("evaluateJs: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt deleted file mode 100644 index b79fd89..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/InterceptorDriver.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import kotlin.test.assertTrue -import kotlinx.coroutines.delay - -@Composable -internal fun InterceptorDriver( - state: WebViewState, - navigator: WebViewNavigator, - rejectExample: (Boolean) -> Unit, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - awaitFinished(state, "interceptor page ready") - rejectExample(true) - delay(100) - val urlBefore = state.lastLoadedUrl - navigator.loadUrl("https://example.com/") - delay(1500) - val marker = - runCatching { - awaitJsString( - navigator, - "document.getElementById('marker') ? document.getElementById('marker').textContent : 'missing'", - ) - }.getOrDefault("missing") - assertTrue( - marker.contains("hello-e2e") || marker.contains("page-"), - "expected still on e2e page after reject, marker=$marker lastUrl=${state.lastLoadedUrl} before=$urlBefore", - ) - rejectExample(false) - println("[e2e] request interceptor reject OK") - onDone() - } catch (t: Throwable) { - onFailure("interceptor: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt deleted file mode 100644 index cf80282..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/JsBridgeDriver.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.test.assertTrue -import kotlinx.coroutines.delay - -@Composable -internal fun JsBridgeDriver( - state: WebViewState, - navigator: WebViewNavigator, - bridgeHits: CopyOnWriteArrayList, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - navigator.loadHtml(E2E_PAGE_HTML, baseUrl = "https://e2e.local/") - awaitFinished(state, "bridge page finished") - delay(400) - bridgeHits.clear() - navigator.evaluateJavaScript( - """ - window.kmpJsBridge.callNative('e2eEcho', JSON.stringify({ping:1}), function(r){}); - """.trimIndent(), - ) - awaitUntil("js bridge message received") { bridgeHits.isNotEmpty() } - assertTrue(bridgeHits.first().contains("ping"), "unexpected bridge payload: $bridgeHits") - println("[e2e] JS bridge OK") - onDone() - } catch (t: Throwable) { - onFailure("jsBridge: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt deleted file mode 100644 index f5a383e..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/LoadHtmlDriver.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -@Composable -internal fun LoadHtmlDriver( - state: WebViewState, - navigator: WebViewNavigator, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - awaitFinished(state) - val title = awaitJsString(navigator, "document.title") - assertTrue(title.contains("E2E"), "expected title to contain E2E, got: $title") - val marker = awaitJsString(navigator, "document.getElementById('marker').textContent") - assertEquals("hello-e2e", marker.trim('"')) - val url = state.lastLoadedUrl - assertNotNull(url, "lastLoadedUrl should be set") - // Linux may keep baseUri (e2e.local); WebView2 uses about:blank / data: for NavigateToString. - assertTrue( - url.contains("e2e.local") || - url.startsWith("data:") || - url == "about:blank" || - url.startsWith("about:") || - isLiveDesktopWebView(state), - "unexpected url=$url", - ) - println("[e2e] loadHtml + title + marker OK") - onDone() - } catch (t: Throwable) { - onFailure("loadHtml: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt deleted file mode 100644 index 5fa04dc..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/NavigationDriver.kt +++ /dev/null @@ -1,52 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.web.LoadingState -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState - -@Composable -internal fun NavigationDriver( - state: WebViewState, - navigator: WebViewNavigator, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - navigator.loadHtml(E2E_PAGE_A, baseUrl = "https://e2e.local/a") - awaitUntil("page A loaded") { - state.loadingState is LoadingState.Finished && - awaitJsString(navigator, "document.getElementById('marker').textContent") - .contains("page-a") - } - navigator.loadHtml(E2E_PAGE_B, baseUrl = "https://e2e.local/b") - awaitUntil("page B loaded") { - state.loadingState is LoadingState.Finished && - awaitJsString(navigator, "document.getElementById('marker').textContent") - .contains("page-b") - } - awaitUntil("canGoBack after B") { navigator.canGoBack } - navigator.navigateBack() - awaitUntil("back to A") { - awaitJsString(navigator, "document.getElementById('marker').textContent") - .contains("page-a") - } - awaitUntil("canGoForward after back") { navigator.canGoForward } - navigator.navigateForward() - awaitUntil("forward to B") { - awaitJsString(navigator, "document.getElementById('marker').textContent") - .contains("page-b") - } - navigator.reload() - awaitFinished(state, "reload finished") - println("[e2e] navigation back/forward/reload OK") - onDone() - } catch (t: Throwable) { - onFailure("navigation: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt deleted file mode 100644 index 0e99625..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/ScreenshotDriver.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import dev.nucleusframework.webview.web.WebViewState -import dev.nucleusframework.webview.web.toAwtImage -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlinx.coroutines.delay - -@Composable -internal fun ScreenshotDriver( - state: WebViewState, - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - state.webView?.loadHtml(E2E_PAGE_HTML, "https://e2e.local/") - awaitFinished(state, "screenshot page finished") - delay(500) - val bytes = state.webView?.captureScreenshotOrNull() - assertNotNull(bytes, "screenshot bytes null") - assertTrue(bytes.size > 100, "screenshot too small: ${bytes.size}") - assertEquals(0x89.toByte(), bytes[0]) - assertEquals('P'.code.toByte(), bytes[1]) - assertEquals('N'.code.toByte(), bytes[2]) - assertEquals('G'.code.toByte(), bytes[3]) - val awt = state.webView?.toAwtImage() - assertNotNull(awt, "awt image null") - assertTrue(awt.width > 0 && awt.height > 0, "awt image empty") - println("[e2e] screenshot OK (${bytes.size} bytes, ${awt.width}x${awt.height})") - onDone() - } catch (t: Throwable) { - onFailure("screenshot: ${t.message}") - onDone() - } - } -} diff --git a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt b/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt deleted file mode 100644 index b55e9d4..0000000 --- a/webview-compose/src/jvmTest/kotlin/dev/nucleusframework/webview/web/e2e/UserAgentDriver.kt +++ /dev/null @@ -1,52 +0,0 @@ -package dev.nucleusframework.webview.web.e2e - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import dev.nucleusframework.webview.web.WebView -import dev.nucleusframework.webview.web.rememberWebViewNavigator -import dev.nucleusframework.webview.web.rememberWebViewState -import kotlin.test.assertTrue -import kotlinx.coroutines.delay - -@Composable -internal fun UserAgentDriver( - onFailure: (String) -> Unit, - onDone: () -> Unit, -) { - val scope = rememberCoroutineScope() - val navigator = rememberWebViewNavigator(coroutineScope = scope) - val state = - rememberWebViewState("about:blank") { - customUserAgentString = "ComposeNativeWebView-E2E/1.0" - } - - WebView(state = state, navigator = navigator, modifier = Modifier.fillMaxSize()) - - LaunchedEffect(Unit) { - try { - awaitWebViewReady(state) - navigator.loadHtml( - html = - """ - UA -

-                    
-                    
-                    """.trimIndent(),
-                baseUrl = "https://e2e.local/ua",
-            )
-            awaitFinished(state, "ua page finished")
-            delay(200)
-            val ua = awaitJsString(navigator, "navigator.userAgent")
-            assertTrue(ua.contains("ComposeNativeWebView-E2E/1.0"), "custom UA not applied: $ua")
-            println("[e2e] custom user-agent OK")
-            onDone()
-        } catch (t: Throwable) {
-            onFailure("userAgent: ${t.message}")
-            onDone()
-        }
-    }
-}
diff --git a/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt
index 9a18d82..d5d4ccd 100644
--- a/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt
+++ b/webview-compose/src/wasmJsMain/kotlin/dev/nucleusframework/webview/web/WebView.wasmJs.kt
@@ -2,6 +2,7 @@
 package dev.nucleusframework.webview.web
 
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.*
 import androidx.compose.ui.Modifier
 import dev.nucleusframework.webview.jsbridge.WebViewJsBridge
@@ -96,7 +97,8 @@ actual fun ActualWebView(
     webViewJsBridge: WebViewJsBridge?,
     onCreated: (NativeWebView) -> Unit,
     onDispose: (NativeWebView) -> Unit,
-    factory: (WebViewFactoryParam) -> NativeWebView
+    factory: (WebViewFactoryParam) -> NativeWebView,
+    content: @Composable () -> Unit,
 ) {
     val scope = rememberCoroutineScope()
     val htmlNavigator = rememberHtmlViewNavigator()
@@ -230,68 +232,72 @@ actual fun ActualWebView(
                 state.webView = webViewWrapper
                 onCreated(nativeWebView)
             }
+            content()
         }
     } 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
+        Box(modifier) {
+            HtmlView(
+                state = htmlViewState,
+                modifier = Modifier.fillMaxSize(),
+                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
+                    }
                 }
-            }
-        )
+            )
+            content()
+        }
     }
 }
 

From 94adf74c2fb34ba27050f6f2f9c2ba85d94a58ed Mon Sep 17 00:00:00 2001
From: Elie Gambache 
Date: Fri, 7 Aug 2026 08:11:48 +0300
Subject: [PATCH 07/10] Fix CI: natives artifact paths, Android e2e script, iOS
 Xcode

- Download natives into resources/nucleus/native (upload strips parents)
- Move Android emulator suite into a single bash script (runner is line-by-line)
- Run iOS commonTest on macos-15 / Xcode 16+ for Compose 1.11 UIViewLayoutRegion
---
 .github/workflows/pr-build-check.yml   | 59 ++++++++++++--------------
 .github/workflows/publish-on-maven.yml |  4 +-
 e2e-android/ci-run-suite.sh            | 33 ++++++++++++++
 3 files changed, 64 insertions(+), 32 deletions(-)
 create mode 100755 e2e-android/ci-run-suite.sh

diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index d43f49e..a177e52 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -40,6 +40,9 @@ jobs:
         with:
           pattern: 'natives-*'
           merge-multiple: true
+          # Artifacts are rooted at {linux,darwin,win32}-*/ โ€” place them where the
+          # JVM resource loader and buildNative* onlyIf checks expect them.
+          path: webview-compose/src/jvmMain/resources/nucleus/native
 
       - name: Set up JDK
         uses: actions/setup-java@v4
@@ -157,41 +160,29 @@ jobs:
         with:
           api-level: 30
           arch: x86_64
-          script: |
-            ./gradlew :e2e-android:installDebug --no-configuration-cache
-            adb logcat -c
-            adb uninstall dev.nucleusframework.webview.e2e >/dev/null 2>&1 || true
-            ./gradlew :e2e-android:installDebug --no-configuration-cache
-            adb logcat -c
-            adb shell am start -a android.intent.action.MAIN \
-              -c android.intent.category.LAUNCHER \
-              -n dev.nucleusframework.webview.e2e/.MainActivity
-            # Wait for suite log line (max ~12 min)
-            for i in $(seq 1 360); do
-              if adb logcat -d | grep -q 'SUITE_FINISHED'; then
-                adb logcat -d | grep -E 'SUITE_FINISHED|allGreen' | tail -10
-                if adb logcat -d | grep -q 'SUITE_FINISHED passed=true'; then
-                  echo "Android visual e2e PASSED"
-                  exit 0
-                else
-                  echo "Android visual e2e FAILED" >&2
-                  adb logcat -d | grep 'ComposeWebViewE2E' | tail -120
-                  exit 1
-                fi
-              fi
-              sleep 2
-            done
-            echo "Timed out waiting for SUITE_FINISHED" >&2
-            adb logcat -d | grep -E 'ComposeWebViewE2E|AndroidRuntime' | tail -120
-            exit 1
+          # android-emulator-runner executes the script line-by-line via `sh -c`,
+          # so multi-line shell (backslash continuations, for-loops) must live in
+          # a single script file.
+          script: bash e2e-android/ci-run-suite.sh
 
   # โ”€โ”€ Same commonTest suite on iOS simulator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   common-tests-ios:
-    runs-on: macos-14
+    # Compose Multiplatform 1.11 needs Xcode 16+ (UIViewLayoutRegion / UIUtilities).
+    runs-on: macos-15
     steps:
       - name: Checkout code
         uses: actions/checkout@v4
 
+      - name: Select Xcode 16+
+        run: |
+          # Prefer latest Xcode 16.x available on the image; fall back to default.
+          XCODE=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1 || true)
+          if [ -n "$XCODE" ]; then
+            echo "Using $XCODE"
+            sudo xcode-select -s "$XCODE"
+          fi
+          xcodebuild -version
+
       - name: Set up JDK
         uses: actions/setup-java@v4
         with:
@@ -223,11 +214,12 @@ jobs:
         with:
           pattern: 'natives-*'
           merge-multiple: true
+          path: webview-compose/src/jvmMain/resources/nucleus/native
 
       - name: Verify Linux natives present
         run: |
           f="webview-compose/src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so"
-          test -f "$f" || { echo "MISSING: $f" >&2; exit 1; }
+          test -f "$f" || { echo "MISSING: $f" >&2; find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true; exit 1; }
           echo "OK: $f ($(wc -c < "$f") bytes)"
 
       - name: Set up JDK
@@ -271,13 +263,14 @@ jobs:
         with:
           pattern: 'natives-*'
           merge-multiple: true
+          path: webview-compose/src/jvmMain/resources/nucleus/native
 
       - name: Verify Windows natives present
         shell: bash
         run: |
           f="webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/compose_webview_windows.dll"
           loader="webview-compose/src/jvmMain/resources/nucleus/native/win32-x64/WebView2Loader.dll"
-          test -f "$f" && test -f "$loader" || { echo "MISSING natives" >&2; exit 1; }
+          test -f "$f" && test -f "$loader" || { echo "MISSING natives" >&2; find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true; exit 1; }
           echo "OK: $f / $loader"
 
       - name: Set up JDK
@@ -307,6 +300,7 @@ jobs:
         with:
           pattern: 'natives-*'
           merge-multiple: true
+          path: webview-compose/src/jvmMain/resources/nucleus/native
 
       - name: Verify macOS natives present
         run: |
@@ -320,7 +314,10 @@ jobs:
               MISSING=1
             fi
           done
-          if [ "$MISSING" = "1" ]; then exit 1; fi
+          if [ "$MISSING" = "1" ]; then
+            find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true
+            exit 1
+          fi
 
       - name: Set up JDK
         uses: actions/setup-java@v4
diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml
index 9753727..628fe3b 100644
--- a/.github/workflows/publish-on-maven.yml
+++ b/.github/workflows/publish-on-maven.yml
@@ -22,6 +22,8 @@ jobs:
         with:
           pattern: 'natives-*'
           merge-multiple: true
+          # Artifacts are rooted at {linux,darwin,win32}-*/ (parent path stripped on upload).
+          path: webview-compose/src/jvmMain/resources/nucleus/native
 
       - name: Verify natives present
         run: |
@@ -34,7 +36,7 @@ jobs:
             "webview-compose/src/jvmMain/resources/nucleus/native/darwin-x64/libcompose_webview_macos.dylib"
           )
           for f in "${EXPECTED[@]}"; do
-            test -f "$f" || { echo "MISSING: $f" >&2; exit 1; }
+            test -f "$f" || { echo "MISSING: $f" >&2; find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true; exit 1; }
             echo "OK: $f ($(wc -c < "$f") bytes)"
           done
 
diff --git a/e2e-android/ci-run-suite.sh b/e2e-android/ci-run-suite.sh
new file mode 100755
index 0000000..2dd3028
--- /dev/null
+++ b/e2e-android/ci-run-suite.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Visual e2e driver for CI (android-emulator-runner).
+# Kept as a single script because the action runs `script:` line-by-line with `sh -c`.
+set -euo pipefail
+
+./gradlew :e2e-android:installDebug --no-configuration-cache
+adb logcat -c
+adb uninstall dev.nucleusframework.webview.e2e >/dev/null 2>&1 || true
+./gradlew :e2e-android:installDebug --no-configuration-cache
+adb logcat -c
+adb shell am start -a android.intent.action.MAIN \
+  -c android.intent.category.LAUNCHER \
+  -n dev.nucleusframework.webview.e2e/.MainActivity
+
+# Wait for suite log line (max ~12 min)
+for i in $(seq 1 360); do
+  if adb logcat -d | grep -q 'SUITE_FINISHED'; then
+    adb logcat -d | grep -E 'SUITE_FINISHED|allGreen' | tail -10
+    if adb logcat -d | grep -q 'SUITE_FINISHED passed=true'; then
+      echo "Android visual e2e PASSED"
+      exit 0
+    else
+      echo "Android visual e2e FAILED" >&2
+      adb logcat -d | grep 'ComposeWebViewE2E' | tail -120 || true
+      exit 1
+    fi
+  fi
+  sleep 2
+done
+
+echo "Timed out waiting for SUITE_FINISHED" >&2
+adb logcat -d | grep -E 'ComposeWebViewE2E|AndroidRuntime' | tail -120 || true
+exit 1

From d10e96501c4b63797ccc032a548841f9c2a195f2 Mon Sep 17 00:00:00 2001
From: Elie Gambache 
Date: Fri, 7 Aug 2026 08:26:25 +0300
Subject: [PATCH 08/10] Fix Android C09 content clobber and iOS Xcode 26 for
 CMP 1.11

rememberWebViewState* was re-assigning state.content on every recomposition,
resetting suite mutations (C09) back to the initial boot HTML on Android.

Compose Multiplatform 1.11 needs the iOS 26 SDK (UIViewLayoutRegion); select
Xcode 26.x on the macos-15 runner instead of 16.x.
---
 .github/workflows/pr-build-check.yml          | 11 +++--
 .../webview/web/WebViewState.kt               | 49 +++++++++++++------
 2 files changed, 41 insertions(+), 19 deletions(-)

diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index a177e52..d6850f2 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -173,15 +173,20 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@v4
 
-      - name: Select Xcode 16+
+      - name: Select Xcode 26+ (Compose 1.11 needs iOS 26 SDK)
         run: |
-          # Prefer latest Xcode 16.x available on the image; fall back to default.
-          XCODE=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1 || true)
+          # CMP 1.11 links UIViewLayoutRegion / UIUtilities from the iOS 26 SDK.
+          # Prefer newest Xcode 26.x on the image; fall back to 16.x only as last resort.
+          XCODE=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)
+          if [ -z "$XCODE" ]; then
+            XCODE=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1 || true)
+          fi
           if [ -n "$XCODE" ]; then
             echo "Using $XCODE"
             sudo xcode-select -s "$XCODE"
           fi
           xcodebuild -version
+          xcrun --sdk iphonesimulator --show-sdk-version || true
 
       - name: Set up JDK
         uses: actions/setup-java@v4
diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt
index 0f83bfa..66bc6e3 100644
--- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt
+++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt
@@ -39,13 +39,22 @@ fun rememberWebViewState(
     url: String,
     additionalHttpHeaders: Map = emptyMap(),
     extraSettings: WebSettings.() -> Unit = {},
-): WebViewState =
-    remember {
-        WebViewState(WebContent.Url(url, additionalHttpHeaders))
-    }.apply {
-        this.content = WebContent.Url(url, additionalHttpHeaders)
-        extraSettings(this.webSettings)
+): WebViewState {
+    val state =
+        remember {
+            WebViewState(WebContent.Url(url, additionalHttpHeaders))
+        }
+    // Sync only when caller inputs change. Assigning on every recomposition (the old
+    // `.apply { content = โ€ฆ }` pattern) clobbers programmatic `state.content` updates
+    // (e.g. visual suite C09) as soon as loadingState triggers a parent recompose.
+    LaunchedEffect(url, additionalHttpHeaders) {
+        state.content = WebContent.Url(url, additionalHttpHeaders)
     }
+    SideEffect {
+        extraSettings(state.webSettings)
+    }
+    return state
+}
 
 @Composable
 fun rememberWebViewStateWithHTMLData(
@@ -54,20 +63,28 @@ fun rememberWebViewStateWithHTMLData(
     encoding: String = "utf-8",
     mimeType: String? = null,
     historyUrl: String? = null,
-): WebViewState =
-    remember {
-        WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl))
-    }.apply {
-        this.content = WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl)
+): WebViewState {
+    val state =
+        remember {
+            WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl))
+        }
+    LaunchedEffect(data, baseUrl, encoding, mimeType, historyUrl) {
+        state.content = WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl)
     }
+    return state
+}
 
 @Composable
 fun rememberWebViewStateWithHTMLFile(
     fileName: String,
     readType: WebViewFileReadType,
-): WebViewState =
-    remember {
-        WebViewState(WebContent.File(fileName, readType))
-    }.apply {
-        this.content = WebContent.File(fileName, readType)
+): WebViewState {
+    val state =
+        remember {
+            WebViewState(WebContent.File(fileName, readType))
+        }
+    LaunchedEffect(fileName, readType) {
+        state.content = WebContent.File(fileName, readType)
     }
+    return state
+}

From 0e2ebbaa9a2d22d5a6847d84e7836ebde7221a58 Mon Sep 17 00:00:00 2001
From: Elie Gambache 
Date: Fri, 7 Aug 2026 08:41:00 +0300
Subject: [PATCH 09/10] Fix iOS e2e-shared time APIs for Xcode 26 SDK bindings

NSDate.timeIntervalSince1970 is unresolved under the newer Foundation
cinterop; use kotlin.system getTimeMillis/getTimeNanos instead.
---
 .../kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt     | 5 +++--
 .../webview/e2e/visualsuite/SuitePlatform.ios.kt             | 5 ++---
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt
index 3bdc02a..b487ecc 100644
--- a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt
+++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt
@@ -29,5 +29,6 @@ internal actual fun nowTimestamp(): String {
     }
 }
 
-internal actual fun currentTimeMillis(): Long =
-    (NSDate().timeIntervalSince1970 * 1000.0).toLong()
+// Prefer kotlin.system over NSDate.timeIntervalSince1970 โ€” the latter is not
+// exposed the same way under newer Apple SDKs (Xcode 26 / iOS 26 bindings).
+internal actual fun currentTimeMillis(): Long = kotlin.system.getTimeMillis()
diff --git a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
index 7bb1619..9708866 100644
--- a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
+++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
@@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
 import dev.nucleusframework.webview.web.IWebView
 import dev.nucleusframework.webview.web.WebViewState
 import kotlinx.cinterop.ExperimentalForeignApi
-import platform.Foundation.NSDate
 import platform.Foundation.NSTemporaryDirectory
 import platform.Foundation.NSUUID
 import platform.Foundation.writeToFile
@@ -53,8 +52,8 @@ actual fun writeSuiteReport(
     return path
 }
 
-actual fun currentTimeNanos(): Long =
-    (NSDate().timeIntervalSince1970 * 1_000_000_000.0).toLong()
+// Prefer kotlin.system โ€” NSDate.timeIntervalSince1970 is missing under Xcode 26 SDK bindings.
+actual fun currentTimeNanos(): Long = kotlin.system.getTimeNanos()
 
 @OptIn(ExperimentalForeignApi::class)
 actual fun createTempProfileDirectory(prefix: String): String {

From a4c2bac9c6f10a0c2bc94c223f3ce084d161957a Mon Sep 17 00:00:00 2001
From: Elie Gambache 
Date: Fri, 7 Aug 2026 08:51:43 +0300
Subject: [PATCH 10/10] Fix iOS time APIs via Foundation extension import
 (Xcode 26)

getTimeMillis/getTimeNanos are hard errors on Kotlin 2.4 Native.
NSDate.timeIntervalSince1970 needs the top-level extension import
platform.Foundation.timeIntervalSince1970 (same as IOSCookieManager).
---
 .../kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt   | 7 ++++---
 .../webview/e2e/visualsuite/SuitePlatform.ios.kt           | 7 +++++--
 2 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt
index b487ecc..d21789e 100644
--- a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt
+++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt
@@ -6,6 +6,8 @@ import platform.Foundation.NSCalendarUnitHour
 import platform.Foundation.NSCalendarUnitMinute
 import platform.Foundation.NSCalendarUnitSecond
 import platform.Foundation.NSCalendarUnitNanosecond
+// Xcode 26 cinterop exposes this as a top-level extension, not a member property.
+import platform.Foundation.timeIntervalSince1970
 
 internal actual fun nowTimestamp(): String {
     val calendar = NSCalendar.currentCalendar
@@ -29,6 +31,5 @@ internal actual fun nowTimestamp(): String {
     }
 }
 
-// Prefer kotlin.system over NSDate.timeIntervalSince1970 โ€” the latter is not
-// exposed the same way under newer Apple SDKs (Xcode 26 / iOS 26 bindings).
-internal actual fun currentTimeMillis(): Long = kotlin.system.getTimeMillis()
+internal actual fun currentTimeMillis(): Long =
+    (NSDate().timeIntervalSince1970 * 1000.0).toLong()
diff --git a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
index 9708866..56b3873 100644
--- a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
+++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt
@@ -4,11 +4,14 @@ import androidx.compose.runtime.Composable
 import dev.nucleusframework.webview.web.IWebView
 import dev.nucleusframework.webview.web.WebViewState
 import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDate
 import platform.Foundation.NSTemporaryDirectory
 import platform.Foundation.NSUUID
 import platform.Foundation.writeToFile
 import platform.Foundation.NSString
 import platform.Foundation.NSUTF8StringEncoding
+// Xcode 26 cinterop: property is a top-level extension import.
+import platform.Foundation.timeIntervalSince1970
 import platform.posix.mkdir
 
 actual fun suiteCapabilities(): Set =
@@ -52,8 +55,8 @@ actual fun writeSuiteReport(
     return path
 }
 
-// Prefer kotlin.system โ€” NSDate.timeIntervalSince1970 is missing under Xcode 26 SDK bindings.
-actual fun currentTimeNanos(): Long = kotlin.system.getTimeNanos()
+actual fun currentTimeNanos(): Long =
+    (NSDate().timeIntervalSince1970 * 1_000_000_000.0).toLong()
 
 @OptIn(ExperimentalForeignApi::class)
 actual fun createTempProfileDirectory(prefix: String): String {