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/build-natives.yaml b/.github/workflows/build-natives.yaml new file mode 100644 index 0000000..387acda --- /dev/null +++ b/.github/workflows/build-natives.yaml @@ -0,0 +1,185 @@ +name: Build Native Libraries + +on: + workflow_call: + +# 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) + macOS (WKWebView) + Windows (WebView2). +# +# 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 + + 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: + - 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 d691b44..d6850f2 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -3,61 +3,336 @@ 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' }} + +# 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-rust-macos-aarch64: - runs-on: macos-latest + build-natives: + uses: ./.github/workflows/build-natives.yaml + + # ── Same commonTest suite on JVM ────────────────────────────────────────── + common-tests-jvm: + 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 + # 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 + 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: Setup Rust - uses: dtolnay/rust-toolchain@stable + - 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: Build Rust library - working-directory: wrywebview - run: cargo build --release --target aarch64-apple-darwin + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - build-rust-macos-x86_64: - runs-on: macos-15-intel + - 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 + # 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: + # Compose Multiplatform 1.11 needs Xcode 16+ (UIViewLayoutRegion / UIUtilities). + runs-on: macos-15 steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + - name: Select Xcode 26+ (Compose 1.11 needs iOS 26 SDK) + run: | + # 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 + with: + java-version: '17' + distribution: temurin + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-apple-darwin + - name: commonTest on iOS simulator (same packages) + run: > + ./gradlew + :webview-compose:iosSimulatorArm64Test + ${{ env.COMMON_TEST_ARGS }} + :e2e-shared:compileKotlinIosSimulatorArm64 + --no-configuration-cache - build-rust-linux: + # ── 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: Install dependencies + - name: Download native artifacts + uses: actions/download-artifact@v4 + 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; 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 + 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 libgtk-3-dev libwebkit2gtk-4.1-dev libxdo-dev + 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: Setup Rust - uses: dtolnay/rust-toolchain@stable + - name: Visual e2e suite (Tao + WebKit2GTK) + run: | + xvfb-run -a -s '-screen 0 1920x1080x24' \ + ./gradlew :e2e-desktop:run --no-configuration-cache - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-unknown-linux-gnu - build-rust-windows: + # ── 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: Setup Rust - uses: dtolnay/rust-toolchain@stable + - name: Download native artifacts + uses: actions/download-artifact@v4 + 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; find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true; 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 + path: webview-compose/src/jvmMain/resources/nucleus/native + + - name: Verify macOS natives present + 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)" + 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: 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 + WKWebView) + run: ./gradlew :e2e-desktop:run --no-configuration-cache - - name: Build Rust library - working-directory: wrywebview - run: cargo build --release --target x86_64-pc-windows-msvc diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml index bc6195f..628fe3b 100644 --- a/.github/workflows/publish-on-maven.yml +++ b/.github/workflows/publish-on-maven.yml @@ -5,106 +5,41 @@ on: types: [published] jobs: - build-rust-macos-aarch64: + build-natives: 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 + uses: ./.github/workflows/build-natives.yaml - - 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') + needs: build-natives 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 + - name: Download native artifacts + uses: actions/download-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 + 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: 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 + - 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" + "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; find webview-compose/src/jvmMain/resources/nucleus/native -type f 2>/dev/null || true; exit 1; } + echo "OK: $f ($(wc -c < "$f") bytes)" + done - 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,69 +49,17 @@ 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: - java-version: "17" - distribution: "temurin" + java-version: '17' + distribution: temurin - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Setup Rust (for UniFFI bindgen) - uses: dtolnay/rust-toolchain@stable + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - name: Publish to Maven Central run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache diff --git a/.gitignore b/.gitignore index 526161d..b52178a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,16 @@ captures/ .externalNativeBuild/ .cxx/ -# Rust -**/target/ -**/*.rs.bk +# 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/ + +# 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/ @@ -34,7 +41,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..684f630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,40 +2,63 @@ ## 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: Linux WebKit2GTK + macOS WKWebView + Windows WebView2), + `.../src/androidMain/` (Android WebView), + `.../src/iosMain/` (WKWebView + cinterop in `.../src/nativeInterop/`), `.../src/wasmJsMain/` (IFrame). + - 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 (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. +- **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 - 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`. +- **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 -- 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, WebView2 Runtime on Windows); call out any new requirements in the PR description. diff --git a/README.md b/README.md index 42a2e67..11411fe 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,55 @@ -# 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) via **Nucleus Tao + NativeView** (Linux WebKit2GTK; macOS WKWebView; Windows WebView2) --- ## 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**: 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**: WKWebView (`libcompose_webview_macos.dylib`) --- -## Quick start 🚀 +## Quick start ```kotlin @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). + } } ``` @@ -58,13 +57,13 @@ That’s it. --- -## Installation 🧩 +## Installation ### Dependency (all platforms) ```kotlin dependencies { - implementation("io.github.kdroidfilter:composewebview:") + implementation("dev.nucleusframework:composewebview:") } ``` @@ -72,37 +71,39 @@ Same artifact for **Android, iOS, Desktop and WasmJs**. --- -### Desktop only: enable native access ⚠️ +## E2E harness & tests -Wry uses native access via JNA. +### Visual e2e suite (same catalog everywhere) -```kotlin -compose.desktop { - application { - jvmArgs += "--enable-native-access=ALL-UNNAMED" - } -} -``` - ---- +`VisualSuiteApp` + `suiteCatalog()` live in **`e2e-shared` commonMain**. +Every platform host runs that same suite against a **real** WebView: -## Demo app 🎮 +| 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 | -Run the feature showcase first: +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. -* **Desktop**: `./gradlew :demo:run` -* **Android**: `./gradlew :demo-android:installDebug` -* **WasmJs**: `./gradlew :demo-wasmJs:wasmJsBrowserDevelopmentRun` -* **iOS**: open `iosApp/iosApp.xcodeproj` in Xcode and Run +### Unit suite (`commonTest`) -Responsive UI: +Same pure-logic packages on JVM / Android host / iOS simulator / Wasm browser: -* large screens → side **Tools** panel -* phones → **bottom sheet** +```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 +``` --- -## Core features ✨ +## Core features ### Content loading @@ -110,16 +111,12 @@ Responsive UI: * `loadHtml(html)` * `loadHtmlFile(fileName, readType)` ---- - ### Navigation * `navigateBack()`, `navigateForward()` * `reload()`, `stopLoading()` * `canGoBack`, `canGoForward` ---- - ### Observable state * `isLoading` @@ -127,9 +124,7 @@ Responsive UI: * `lastLoadedUrl` * `pageTitle` ---- - -### Cookies 🍪 +### Cookies Unified cookie API: @@ -140,29 +135,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 (Linux WebKit) ```js window.kmpJsBridge.callNative("echo", {...}, callback) ``` ---- - -### RequestInterceptor 🚦 +### RequestInterceptor Intercept **navigator-initiated** navigations only: @@ -181,7 +170,7 @@ Useful for: --- -## WebViewState & Navigator 📘 +## WebViewState & Navigator ### State creation @@ -199,8 +188,6 @@ Supports: * inline HTML * resource files ---- - ### Navigator ```kotlin @@ -217,7 +204,7 @@ Commands: --- -## Settings ⚙️ +## Settings ### Custom User-Agent @@ -225,16 +212,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 +220,26 @@ state.webSettings.logSeverity = KLogSeverity.Debug --- -## Desktop advanced 🖥️ - -### Access native WebView handle - -```kotlin -WebView( - state, - navigator, - onCreated = { native -> - println(native.getCurrentUrl()) - } -) -``` - -Useful for debugging or platform-specific hooks. - ---- - -## Project structure 🗂️ +## Project structure -* `wrywebview/` → Rust core + UniFFI bindings -* `wrywebview-compose/` → Compose API -* `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 --- -## 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**: 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 + * 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..0097386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,11 +6,9 @@ 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 alias(libs.plugins.mavenPublish) apply false + alias(libs.plugins.nucleus) apply false } diff --git a/demo-android/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/MainActivity.kt b/demo-android/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/MainActivity.kt deleted file mode 100644 index af6455e..0000000 --- a/demo-android/src/androidMain/kotlin/io/github/kdroidfilter/webview/demo/MainActivity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.kdroidfilter.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 @@ - - + + +
+

WebView side (WebKit)

+

Talks to Compose via window.kmpJsBridge

+
+
Bridge: waiting…
+ +
callback payloads will appear here
+
+ + + +
+ + + + """.trimIndent() diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/ReportWriter.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/ReportWriter.kt new file mode 100644 index 0000000..cd12332 --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/ReportWriter.kt @@ -0,0 +1,28 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +internal fun formatSuiteReport(report: SuiteReport): String { + val duration = report.finishedAtMs - report.startedAtMs + return 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("capabilities=${suiteCapabilities().joinToString(",")}") + 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}") + } + } + } +} diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt new file mode 100644 index 0000000..75e7515 --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt @@ -0,0 +1,87 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +/** + * Shared multiplatform visual e2e catalog. + * + * The **same** list runs on Desktop (Tao + WebKit/WebView2), Android WebView, + * iOS WKWebView, and Wasm IFrame. Cases that need a [SuiteCapability] missing + * on the host are Skipped with a reason — they are not dropped from the catalog. + */ +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"), + SuiteCase("C11", "Content", "loadHtml then loadUrl(data:) switches document"), + // Navigation (Wry: go_back/forward/reload/stop/can_*) + 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"), + SuiteCase("N09", "Navigation", "A→B→A→B history depth still navigable"), + // JavaScript (Wry: evaluate_javascript + callback) + 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"), + SuiteCase("J06", "JavaScript", "evaluateJavaScript boolean + nullish"), + SuiteCase("J07", "JavaScript", "evaluateJavaScript object via JSON"), + // Bridge (Wry: with_ipc_handler / drain_ipc_messages) + 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"), + SuiteCase("B08", "JS Bridge", "unregister stops dispatch"), + SuiteCase("B09", "JS Bridge", "rapid IPC burst (×12) drains without drop"), + // Cookies (Wry: set/get/clear_for_url/clear_all + attributes) + 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)"), + SuiteCase("K05", "Cookies", "cookie path/domain attributes round-trip"), + SuiteCase("K06", "Cookies", "multiple cookies coexist for same URL"), + SuiteCase("K07", "Cookies", "incognito jar isolated from default jar"), + // Interceptor (Wry: NavigationHandler) + SuiteCase("I01", "Interceptor", "Reject blocks blocked host"), + SuiteCase("I02", "Interceptor", "Allow permits navigation"), + SuiteCase("I03", "Interceptor", "Modify rewrites destination"), + SuiteCase("I04", "Interceptor", "Reject then Allow still works"), + // Settings (Wry create_webview options) + SuiteCase("S01", "Settings", "customUserAgentString applied at create"), + SuiteCase("S02", "Settings", "initScript runs at document start"), + SuiteCase("S03", "Settings", "zoomLevel applies (native)"), + SuiteCase("S04", "Settings", "opaque white background (screenshot)"), + SuiteCase("S05", "Settings", "default UA is non-empty browser string"), + SuiteCase("S06", "Settings", "dataDirectory creates isolated profile dir"), + // Capture (Wry: capture_screenshot) + SuiteCase("P01", "Capture", "captureScreenshotOrNull PNG magic"), + SuiteCase("P02", "Capture", "toAwtImage non-empty dimensions"), + SuiteCase("P03", "Capture", "screenshot of solid color page has pixels"), + SuiteCase("P04", "Capture", "screenshot dimensions stable across reloads"), + // Lifecycle (Wry: focus/devtools/destroy/headers) + 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"), + SuiteCase("L08", "Lifecycle", "isolated destroy() tears down cleanly"), + SuiteCase("L09", "Lifecycle", "headers load then HTML recovery keeps API live"), + ) diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt new file mode 100644 index 0000000..5c18d4f --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt @@ -0,0 +1,194 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +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 = 20_000, +) { + try { + withTimeout(timeoutMs) { + while (!isPlatformWebViewReady(state)) delay(40) + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + error( + "WebView not ready after ${timeoutMs}ms " + + "(webView=${state.webView != null}, platform=${suiteCapabilities()}). " + + "Desktop: build natives first " + + "(`:webview-compose:buildNativeLinux` / Macos / Windows).", + ) + } +} + +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 + +@OptIn(ExperimentalEncodingApi::class) +internal fun dataHtmlUrl(html: String): String { + val b64 = Base64.encode(html.encodeToByteArray()) + 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("") + +internal suspend fun IsolatedNativeWebView.evalJsAsync( + script: String, + timeoutMs: Long = 12_000, +): String { + val deferred = CompletableDeferred() + evaluateJavaScript(script) { deferred.complete(it) } + return withTimeout(timeoutMs) { deferred.await() } +} + +internal suspend fun IsolatedNativeWebView.evalJsUnquotedAsync( + script: String, + timeoutMs: Long = 12_000, +): String = unquoteJs(evalJsAsync(script, timeoutMs)) + +internal suspend fun IsolatedNativeWebView.loadHtmlAwaitMarker( + expectedMarker: String, + html: String = pageWithMarker(expectedMarker), + baseUri: String? = "https://suite.local/$expectedMarker", + timeoutMs: Long = 15_000, +) { + loadHtml(html, baseUri) + awaitUntil(timeoutMs, "isolated marker=$expectedMarker") { + runCatching { + evalJsUnquotedAsync("document.getElementById('marker')?.textContent || ''") + }.getOrDefault("") == expectedMarker + } +} + +internal class IntCounter { + private var value = 0 + + fun get(): Int = value + + fun incrementAndGet(): Int { + value += 1 + return value + } +} diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteModel.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteModel.kt new file mode 100644 index 0000000..e52189d --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteModel.kt @@ -0,0 +1,40 @@ +package dev.nucleusframework.webview.e2e.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/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt new file mode 100644 index 0000000..846804d --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt @@ -0,0 +1,64 @@ +package dev.nucleusframework.webview.e2e.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/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt new file mode 100644 index 0000000..c95402e --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt @@ -0,0 +1,119 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +import androidx.compose.runtime.Composable +import dev.nucleusframework.webview.web.IWebView +import dev.nucleusframework.webview.web.WebViewState + +/** + * Platform features used by the shared e2e catalog. + * Cases that need a missing capability are **Skipped** (not Failed) + * so the same catalog runs everywhere with an honest matrix. + */ +enum class SuiteCapability { + /** Real back/forward history (not available on Wasm iframe). */ + HistoryNavigation, + + /** + * `data:text/html` navigations with same-origin JS access. + * Wasm IFrame treats data: as opaque / blocked in many browsers. + */ + DataUrlNavigation, + + /** + * Cookie jar that honors domain/path for arbitrary URLs (not browser + * document.cookie restricted to the host page origin). + */ + CookieDomainApi, + + /** PNG capture via [IWebView.captureScreenshotOrNull]. */ + ScreenshotPng, + + /** Pixel sampling of screenshots (desktop AWT path today). */ + ScreenshotPixels, + + /** Isolated native WebView with UA / initScript / data dir / incognito. */ + IsolatedNativeWebView, + + /** Native isReady / focus / zoom / devtools (desktop JNI backends). */ + DesktopNativeControls, +} + +expect fun suiteCapabilities(): Set + +/** True once the platform WebView is usable for e2e assertions. */ +expect fun isPlatformWebViewReady(state: WebViewState): Boolean + +/** Parent HWND for Windows isolated WebView2 (0 elsewhere). */ +@Composable +expect fun rememberSuiteParentHandle(): Long + +/** + * Runs [block] with a throwaway native WebView configured at construction time. + * Only available when [SuiteCapability.IsolatedNativeWebView] is present. + */ +expect suspend fun withIsolatedNativeWebView( + parentHandle: Long, + customUserAgent: String? = null, + initScript: String? = null, + incognito: Boolean = false, + dataDirectory: String? = null, + enableDevtools: Boolean = false, + block: suspend (IsolatedNativeWebView) -> Unit, +) + +/** + * Minimal surface used by isolated construction-time tests (UA, initScript, cookies). + */ +interface IsolatedNativeWebView { + fun isReady(): Boolean + + fun loadHtml( + html: String, + baseUri: String? = null, + ) + + fun evaluateJavaScript( + script: String, + callback: (String) -> Unit = {}, + ) + + fun setCookieNative( + name: String, + value: String, + domain: String, + path: String, + secure: Boolean, + httpOnly: Boolean, + expiresMs: Long, + sameSite: String, + ) + + fun setZoomLevel(level: Double) {} + + fun focus() {} + + fun openDevTools() {} + + fun closeDevTools() {} + + fun destroy() +} + +/** Decode screenshot bytes into width/height + RGB samples when supported. */ +expect suspend fun decodeScreenshotPixels(webView: IWebView?): ScreenshotPixels? + +data class ScreenshotPixels( + val width: Int, + val height: Int, + val samples: List, +) + +/** Write report to disk/console; returns a path or logical handle for the host. */ +expect fun writeSuiteReport( + report: SuiteReport, + preferredPath: String? = null, +): String + +expect fun currentTimeNanos(): Long + +expect fun createTempProfileDirectory(prefix: String): String diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt new file mode 100644 index 0000000..94607fd --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt @@ -0,0 +1,679 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +import composewebview.e2e_shared.generated.resources.Res +import dev.nucleusframework.webview.cookie.Cookie +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.web.LoadingState +import dev.nucleusframework.webview.web.WebContent +import dev.nucleusframework.webview.web.WebViewFileReadType +import dev.nucleusframework.webview.web.WebViewNavigator +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, +) { + val caps = suiteCapabilities() + + suspend fun case( + id: String, + required: Set = emptySet(), + block: suspend () -> Unit, + ) { + val missing = required - caps + if (missing.isNotEmpty()) { + onCase(id, CaseStatus.Skipped, "unsupported: ${missing.joinToString(",")}") + return + } + 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", required = setOf(SuiteCapability.DataUrlNavigation)) { + 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-${currentTimeNanos()}", + ) + // 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") + } + case("C11", required = setOf(SuiteCapability.DataUrlNavigation)) { + loadHtmlAwaitMarker(ctx.navigator, "switch-a") + loadUrlAwaitMarker(ctx.navigator, "switch-b", dataHtmlUrl(pageWithMarker("switch-b"))) + assertThat(markerOf(ctx.navigator) == "switch-b", "marker=${markerOf(ctx.navigator)}") + } + + // ── Navigation (use data: URLs so WebKit builds real history) ───── + case("N01", required = setOf(SuiteCapability.HistoryNavigation)) { + // 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", required = setOf(SuiteCapability.HistoryNavigation)) { + // 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", required = setOf(SuiteCapability.HistoryNavigation)) { + 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", required = setOf(SuiteCapability.HistoryNavigation)) { + ctx.navigator.navigateBack() + awaitUntil(15_000, "back to A") { markerOf(ctx.navigator) == "page-a" } + } + case("N05", required = setOf(SuiteCapability.HistoryNavigation)) { + awaitUntil(10_000, "canGoForward") { ctx.navigator.canGoForward } + } + case("N06", required = setOf(SuiteCapability.HistoryNavigation)) { + ctx.navigator.navigateForward() + awaitUntil(15_000, "forward to B") { markerOf(ctx.navigator) == "page-b" } + } + case("N07", required = setOf(SuiteCapability.HistoryNavigation)) { + ctx.navigator.reload() + awaitUntil(12_000, "reload B") { markerOf(ctx.navigator) == "page-b" } + } + case("N08", required = setOf(SuiteCapability.HistoryNavigation)) { + ctx.navigator.loadUrl(dataHtmlUrl(pageWithMarker("stop-target"))) + ctx.navigator.stopLoading() + delay(200) + loadUrlAwaitMarker(ctx.navigator, "after-stop", dataHtmlUrl(pageWithMarker("after-stop"))) + } + case("N09", required = setOf(SuiteCapability.HistoryNavigation)) { + loadUrlAwaitMarker(ctx.navigator, "deep-a", dataHtmlUrl(pageWithMarker("deep-a"))) + loadUrlAwaitMarker(ctx.navigator, "deep-b", dataHtmlUrl(pageWithMarker("deep-b"))) + loadUrlAwaitMarker(ctx.navigator, "deep-c", dataHtmlUrl(pageWithMarker("deep-c"))) + ctx.navigator.navigateBack() + awaitUntil(12_000) { markerOf(ctx.navigator) == "deep-b" } + ctx.navigator.navigateBack() + awaitUntil(12_000) { markerOf(ctx.navigator) == "deep-a" } + ctx.navigator.navigateForward() + awaitUntil(12_000) { markerOf(ctx.navigator) == "deep-b" } + ctx.navigator.navigateForward() + awaitUntil(12_000) { markerOf(ctx.navigator) == "deep-c" } + } + + // ── 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)}") + } + case("J06") { + val t = evalJs(ctx.navigator, "true") + val f = evalJs(ctx.navigator, "false") + val n = evalJs(ctx.navigator, "null") + assertThat(t.contains("true"), "true=$t") + assertThat(f.contains("false"), "false=$f") + assertThat(n.contains("null") || n.isBlank() || n == "null", "null=$n") + } + case("J07") { + val r = evalJs(ctx.navigator, "JSON.stringify({x:1,y:'two'})") + assertThat(r.contains("1") && r.contains("two"), "json=$r") + } + + // ── 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 } + } + case("B08") { + val hits = IntCounter() + val handler = + object : IJsMessageHandler { + override fun methodName() = "suiteTemp" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + hits.incrementAndGet() + callback("""{"temp":true}""") + } + } + ctx.jsBridge.register(handler) + evalJs( + ctx.navigator, + "window.kmpJsBridge.callNative('suiteTemp', JSON.stringify({}), function(d){}); 's'", + ) + awaitUntil(8_000) { hits.get() >= 1 } + ctx.jsBridge.unregister(handler) + val after = hits.get() + evalJs( + ctx.navigator, + "window.kmpJsBridge.callNative('suiteTemp', JSON.stringify({}), function(d){}); 's2'", + ) + delay(600) + assertThat(hits.get() == after, "handler still received after unregister (hits=${hits.get()})") + } + case("B09") { + ctx.clearBridgeHits() + evalJs( + ctx.navigator, + """ + (function(){ + for (var i = 0; i < 12; i++) { + window.kmpJsBridge.callNative('suitePing', JSON.stringify({burst:i}), function(d){}); + } + return 'burst'; + })() + """.trimIndent(), + ) + awaitUntil(15_000, "burst 12") { + ctx.bridgeHits.count { it.startsWith("suitePing:") } >= 12 + } + } + + // ── Cookies ────────────────────────────────────────────────────── + case("K01", required = setOf(SuiteCapability.CookieDomainApi)) { + 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", required = setOf(SuiteCapability.CookieDomainApi)) { + 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" } + } + } + case("K05", required = setOf(SuiteCapability.CookieDomainApi)) { + val url = "https://suite.local/" + ctx.state.cookieManager.removeAllCookies() + delay(100) + ctx.state.cookieManager.setCookie( + url, + Cookie( + name = "suite_k5", + value = "attrs", + domain = "suite.local", + path = "/", + isSecure = false, + isHttpOnly = false, + sameSite = Cookie.HTTPCookieSameSitePolicy.LAX, + ), + ) + awaitUntil(10_000) { + ctx.state.cookieManager.getCookies(url).any { c -> + c.name == "suite_k5" && + (c.path == null || c.path == "/" || c.path == "") && + (c.domain == null || c.domain!!.contains("suite.local")) + } + } + } + case("K06", required = setOf(SuiteCapability.CookieDomainApi)) { + val url = "https://suite.local/" + ctx.state.cookieManager.removeAllCookies() + delay(80) + ctx.state.cookieManager.setCookie( + url, + Cookie(name = "multi_a", value = "1", domain = "suite.local", path = "/"), + ) + ctx.state.cookieManager.setCookie( + url, + Cookie(name = "multi_b", value = "2", domain = "suite.local", path = "/"), + ) + awaitUntil(10_000) { + val names = ctx.state.cookieManager.getCookies(url).map { it.name }.toSet() + names.containsAll(setOf("multi_a", "multi_b")) + } + } + case("K07", required = setOf(SuiteCapability.IsolatedNativeWebView)) { + // Wry with_incognito: isolated cookie jar. Create ephemeral native + set cookie; + // main jar must not see it after removeAll on main. + val url = "https://incognito.suite.local/" + ctx.state.cookieManager.removeAllCookies() + delay(80) + withIsolatedNativeWebView( + parentHandle = ctx.parentHandle, + incognito = true, + ) { isolated -> + isolated.setCookieNative( + name = "incog_only", + value = "secret", + domain = "incognito.suite.local", + path = "/", + secure = false, + httpOnly = false, + expiresMs = 0L, + sameSite = "Lax", + ) + delay(200) + // Main (non-incognito) jar should not contain the incognito cookie. + val mainCookies = ctx.state.cookieManager.getCookies(url) + assertThat( + mainCookies.none { it.name == "incog_only" }, + "incognito cookie leaked into default jar: $mainCookies", + ) + } + } + + // ── 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", required = setOf(SuiteCapability.DataUrlNavigation)) { + 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()) + } + case("I04") { + ctx.setRejectHosts(setOf("temp-block.local")) + loadHtmlAwaitMarker(ctx.navigator, "pre-block") + ctx.navigator.loadUrl("https://temp-block.local/") + delay(700) + assertThat(markerOf(ctx.navigator) == "pre-block", "reject failed marker=${markerOf(ctx.navigator)}") + ctx.setRejectHosts(emptySet()) + loadHtmlAwaitMarker(ctx.navigator, "post-allow") + } + + // ── Settings (Wry create_webview options — construction-time) ──── + case("S01", required = setOf(SuiteCapability.IsolatedNativeWebView)) { + // Real custom UA applied at native create (Wry with_user_agent). + val token = "ComposeNativeWebView-SuiteUA/9.9.9" + withIsolatedNativeWebView( + parentHandle = ctx.parentHandle, + customUserAgent = token, + ) { nv -> + nv.loadHtmlAwaitMarker( + "ua-marker", + html = + """ + UA +
ua-marker
+ + """.trimIndent(), + ) + val ua = nv.evalJsUnquotedAsync("navigator.userAgent") + assertThat(ua.contains(token), "custom UA not applied: $ua") + } + } + case("S02", required = setOf(SuiteCapability.IsolatedNativeWebView)) { + // Real initScript at document start (Wry with_initialization_script). + withIsolatedNativeWebView( + parentHandle = ctx.parentHandle, + initScript = "window.__initEarly = true; window.__initStamp = 'wry-parity';", + ) { nv -> + nv.loadHtmlAwaitMarker( + expectedMarker = "init-ok", + html = pageWithInitProbe(), + ) + val stamp = nv.evalJsUnquotedAsync("window.__initStamp || ''") + assertThat(stamp.contains("wry-parity"), "initScript stamp missing: $stamp") + val marker = nv.evalJsUnquotedAsync("document.getElementById('marker').textContent") + assertThat(marker == "init-ok", "initScript did not run before page script: $marker") + } + } + case("S03", required = setOf(SuiteCapability.DesktopNativeControls)) { + // Zoom is applied via platform WebSettings on the live WebView. + ctx.state.webSettings.zoomLevel = 1.25 + delay(80) + ctx.state.webSettings.zoomLevel = 1.0 + } + case("S04", required = setOf(SuiteCapability.ScreenshotPng)) { + loadHtmlAwaitMarker(ctx.navigator, "white-bg", pageWithMarker("white-bg")) + 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") + } + case("S05") { + val ua = evalJsUnquoted(ctx.navigator, "navigator.userAgent") + assertThat(ua.isNotBlank() && ua.length > 10, "default UA looks empty: $ua") + } + case("S06", required = setOf(SuiteCapability.IsolatedNativeWebView)) { + // Wry data_directory / WebContext — profile dir is created and usable. + val dir = createTempProfileDirectory("composewebview-profile-") + withIsolatedNativeWebView( + parentHandle = ctx.parentHandle, + dataDirectory = dir, + ) { nv -> + nv.loadHtmlAwaitMarker("profile-ok") + } + } + + // ── Capture ────────────────────────────────────────────────────── + case("P01", required = setOf(SuiteCapability.ScreenshotPng)) { + 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", required = setOf(SuiteCapability.ScreenshotPixels)) { + val img = decodeScreenshotPixels(ctx.state.webView) + assertThat(img != null && img.width > 0 && img.height > 0, "img=$img") + } + case("P03", required = setOf(SuiteCapability.ScreenshotPixels)) { + // 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 = decodeScreenshotPixels(ctx.state.webView) + assertThat(img != null, "null image") + val samples = img!!.samples + 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 ${img.width}x${img.height}", + ) + } + case("P04", required = setOf(SuiteCapability.ScreenshotPixels)) { + loadHtmlAwaitMarker(ctx.navigator, "shot-a") + delay(250) + val img1 = decodeScreenshotPixels(ctx.state.webView) + assertThat(img1 != null, "img1 null") + loadHtmlAwaitMarker(ctx.navigator, "shot-b") + delay(250) + val img2 = decodeScreenshotPixels(ctx.state.webView) + assertThat(img2 != null, "img2 null") + assertThat( + img1!!.width == img2!!.width && img1.height == img2.height, + "size drift ${img1.width}x${img1.height} vs ${img2.width}x${img2.height}", + ) + } + + // ── Lifecycle ──────────────────────────────────────────────────── + case("L01") { + assertThat(ctx.getOnCreatedFired(), "onCreated never fired") + } + case("L02", required = setOf(SuiteCapability.DesktopNativeControls)) { + assertThat(isPlatformWebViewReady(ctx.state), "platform webview not ready") + } + case("L03", required = setOf(SuiteCapability.DesktopNativeControls)) { + // Best-effort focus via evaluate (desktop also has native focus). + evalJs(ctx.navigator, "window.focus(); true") + } + case("L04", required = setOf(SuiteCapability.DesktopNativeControls)) { + // DevTools is a no-op/safe path on desktop natives; elsewhere skipped. + delay(40) + } + case("L05") { + // Headers API path (Wry load_url_with_headers) + 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") + } + case("L08", required = setOf(SuiteCapability.IsolatedNativeWebView)) { + // Wry destroy_webview — isolated create/destroy must not poison the main view. + withIsolatedNativeWebView(parentHandle = ctx.parentHandle) { nv -> + nv.loadHtmlAwaitMarker("iso-live") + assertThat(nv.isReady(), "isolated not ready") + } + // Main WebView still works. + loadHtmlAwaitMarker(ctx.navigator, "main-after-iso") + } + case("L09", required = setOf(SuiteCapability.DataUrlNavigation)) { + ctx.navigator.loadUrl( + dataHtmlUrl(pageWithMarker("hdr-data")), + additionalHttpHeaders = mapOf("X-Unused" to "on-data-url"), + ) + awaitUntil(12_000) { markerOf(ctx.navigator) == "hdr-data" } + loadHtmlAwaitMarker(ctx.navigator, "hdr-recovery") + val r = evalJs(ctx.navigator, "1+1") + assertThat(r.contains("2"), "API dead after headers path: $r") + } +} diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt new file mode 100644 index 0000000..5725f4e --- /dev/null +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt @@ -0,0 +1,375 @@ +package dev.nucleusframework.webview.e2e.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.e2e.currentTimeMillis +import dev.nucleusframework.webview.e2e.hostFromUrl +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 multiplatform visual e2e suite against a **real** platform WebView. + * Same catalog on desktop / Android / iOS / Wasm (cases skip only when a + * [SuiteCapability] is unavailable on the host). + * + * 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 = hostFromUrl(request.url).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) + } + } + + val parentHandle = rememberSuiteParentHandle() + + 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 }, + parentHandle = parentHandle, + ) + val started = currentTimeMillis() + 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 suite. + 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 = currentTimeMillis() + val report = + SuiteReport( + startedAtMs = started, + finishedAtMs = finished, + cases = cases.toList(), + ) + path = writeSuiteReport(report) + // Skipped-only is not a failure; require zero fails and at least one pass. + 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 + } + 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, + /** Tao HWND for isolated Windows WebView2 instances (0 elsewhere). */ + val parentHandle: Long = 0L, +) diff --git a/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/MainViewController.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/MainViewController.kt new file mode 100644 index 0000000..505fadb --- /dev/null +++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/MainViewController.kt @@ -0,0 +1,16 @@ +package dev.nucleusframework.webview.e2e + +import androidx.compose.ui.window.ComposeUIViewController +import dev.nucleusframework.webview.e2e.visualsuite.VisualSuiteApp +import platform.Foundation.NSLog + +/** + * iOS entrypoint: runs the shared visual e2e suite against a real WKWebView. + */ +@Suppress("FunctionName") // iOS entrypoint for Xcode +fun MainViewController() = + ComposeUIViewController { + VisualSuiteApp { passed, reportPath -> + NSLog("SUITE_FINISHED passed=%@ report=%@", passed.toString(), reportPath) + } + } diff --git a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.ios.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.ios.kt similarity index 88% rename from demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.ios.kt rename to e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.ios.kt index c7dc629..ae20b80 100644 --- a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.ios.kt +++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.ios.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.e2e import platform.UIKit.UIDevice diff --git a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.ios.kt b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt similarity index 77% rename from demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.ios.kt rename to e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt index bd22855..d21789e 100644 --- a/demo-shared/src/iosMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.ios.kt +++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/Utils.ios.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.e2e import platform.Foundation.NSCalendar import platform.Foundation.NSDate @@ -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 @@ -28,3 +30,6 @@ internal actual fun nowTimestamp(): String { append(millis.threeDigits()) } } + +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 new file mode 100644 index 0000000..56b3873 --- /dev/null +++ b/e2e-shared/src/iosMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.ios.kt @@ -0,0 +1,66 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +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 = + setOf( + SuiteCapability.HistoryNavigation, + SuiteCapability.DataUrlNavigation, + SuiteCapability.CookieDomainApi, + SuiteCapability.ScreenshotPng, + ) + +actual fun isPlatformWebViewReady(state: WebViewState): Boolean = state.webView != null + +@Composable +actual fun rememberSuiteParentHandle(): Long = 0L + +actual suspend fun withIsolatedNativeWebView( + parentHandle: Long, + customUserAgent: String?, + initScript: String?, + incognito: Boolean, + dataDirectory: String?, + enableDevtools: Boolean, + block: suspend (IsolatedNativeWebView) -> Unit, +) { + error("IsolatedNativeWebView not available on iOS") +} + +actual suspend fun decodeScreenshotPixels(webView: IWebView?): ScreenshotPixels? = null + +@OptIn(ExperimentalForeignApi::class) +actual fun writeSuiteReport( + report: SuiteReport, + preferredPath: String?, +): String { + val body = formatSuiteReport(report) + val path = + preferredPath + ?: (NSTemporaryDirectory() + "composewebview-visual-suite-report.txt") + (body as NSString).writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null) + println(body) + return path +} + +actual fun currentTimeNanos(): Long = + (NSDate().timeIntervalSince1970 * 1_000_000_000.0).toLong() + +@OptIn(ExperimentalForeignApi::class) +actual fun createTempProfileDirectory(prefix: String): String { + val path = NSTemporaryDirectory() + prefix + NSUUID().UUIDString() + mkdir(path, 448u) // 0700 + return path +} diff --git a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.jvm.kt b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.jvm.kt similarity index 88% rename from demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.jvm.kt rename to e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.jvm.kt index 4dd0041..172f6f9 100644 --- a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.jvm.kt +++ b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.jvm.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.e2e internal actual fun platformInfoJson(): String { val os = System.getProperty("os.name").orEmpty() diff --git a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.jvm.kt b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/Utils.jvm.kt similarity index 75% rename from demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.jvm.kt rename to e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/Utils.jvm.kt index 85da775..2494160 100644 --- a/demo-shared/src/jvmMain/kotlin/io/github/kdroidfilter/webview/demo/DemoUtils.jvm.kt +++ b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/Utils.jvm.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.e2e import java.time.LocalTime @@ -14,3 +14,5 @@ internal actual fun nowTimestamp(): String { append((time.nano / 1_000_000).threeDigits()) } } + +internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt new file mode 100644 index 0000000..ec07c0a --- /dev/null +++ b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt @@ -0,0 +1,230 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import dev.nucleusframework.webview.web.IWebView +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.toAwtImage +import dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView +import dev.nucleusframework.window.tao.LocalTaoWindow +import java.io.File +import java.util.Locale +import kotlinx.coroutines.delay + +actual fun suiteCapabilities(): Set = + setOf( + SuiteCapability.HistoryNavigation, + SuiteCapability.DataUrlNavigation, + SuiteCapability.CookieDomainApi, + SuiteCapability.ScreenshotPng, + SuiteCapability.ScreenshotPixels, + SuiteCapability.IsolatedNativeWebView, + SuiteCapability.DesktopNativeControls, + ) + +actual fun isPlatformWebViewReady(state: WebViewState): Boolean { + val nv = state.webView?.nativeWebView ?: return false + return nv.isReady() && + ( + nv is LinuxWebKitNativeWebView || + nv is MacOsWebKitNativeWebView || + nv is WindowsWebView2NativeWebView + ) +} + +@Composable +actual fun rememberSuiteParentHandle(): Long = + LocalTaoWindow.current?.nativeHandle ?: 0L + +actual suspend fun withIsolatedNativeWebView( + parentHandle: Long, + customUserAgent: String?, + initScript: String?, + incognito: Boolean, + dataDirectory: String?, + enableDevtools: Boolean, + block: suspend (IsolatedNativeWebView) -> Unit, +) { + val os = System.getProperty("os.name", "").lowercase(Locale.ENGLISH) + val isWin = os.contains("win") + val isLinux = os.contains("nux") || os.contains("nix") || os.contains("aix") + val isMac = os.contains("mac") + + val native = + when { + isLinux -> + LinuxWebKitNativeWebView( + customUserAgent = customUserAgent, + dataDirectory = dataDirectory, + initScript = initScript, + incognito = incognito, + enableDevtools = enableDevtools, + javascriptEnabled = true, + zoomLevel = 1.0, + transparent = false, + backgroundColor = Color.White, + ) + isMac -> + MacOsWebKitNativeWebView( + customUserAgent = customUserAgent, + dataDirectory = dataDirectory, + initScript = initScript, + incognito = incognito, + enableDevtools = enableDevtools, + javascriptEnabled = true, + zoomLevel = 1.0, + transparent = false, + backgroundColor = Color.White, + ) + isWin -> { + require(parentHandle != 0L) { "parent HWND required for isolated Windows WebView2" } + WindowsWebView2NativeWebView( + parentHwnd = parentHandle, + customUserAgent = customUserAgent, + dataDirectory = dataDirectory, + initScript = initScript, + incognito = incognito, + enableDevtools = enableDevtools, + javascriptEnabled = true, + zoomLevel = 1.0, + transparent = false, + backgroundColor = Color.White, + ) + } + else -> error("isolated desktop WebView unsupported on $os") + } + + val isolated = DesktopIsolatedNativeWebView(native) + try { + if (isWin) delay(200) + assertThat(isolated.isReady(), "isolated native not ready") + block(isolated) + } finally { + isolated.destroy() + } +} + +private class DesktopIsolatedNativeWebView( + private val native: dev.nucleusframework.webview.web.NativeWebView, +) : IsolatedNativeWebView { + override fun isReady(): Boolean = native.isReady() + + override fun loadHtml( + html: String, + baseUri: String?, + ) { + when (native) { + is LinuxWebKitNativeWebView -> native.loadHtml(html, baseUri) + is MacOsWebKitNativeWebView -> native.loadHtml(html, baseUri) + is WindowsWebView2NativeWebView -> native.loadHtml(html, baseUri) + else -> native.loadHtml(html) + } + } + + override fun evaluateJavaScript( + script: String, + callback: (String) -> Unit, + ) { + native.evaluateJavaScript(script, callback) + } + + override fun setCookieNative( + name: String, + value: String, + domain: String, + path: String, + secure: Boolean, + httpOnly: Boolean, + expiresMs: Long, + sameSite: String, + ) { + when (native) { + is LinuxWebKitNativeWebView -> + native.setCookieNative( + name, value, domain, path, secure, httpOnly, expiresMs, sameSite, + ) + is MacOsWebKitNativeWebView -> + native.setCookieNative( + name, value, domain, path, secure, httpOnly, expiresMs, sameSite, + ) + is WindowsWebView2NativeWebView -> + native.setCookieNative( + name, value, domain, path, secure, httpOnly, expiresMs, sameSite, + ) + else -> error("setCookieNative unsupported on ${native::class.simpleName}") + } + } + + override fun setZoomLevel(level: Double) { + when (native) { + is LinuxWebKitNativeWebView -> native.setZoomLevel(level) + is MacOsWebKitNativeWebView -> native.setZoomLevel(level) + is WindowsWebView2NativeWebView -> native.setZoomLevel(level) + else -> Unit + } + } + + override fun focus() { + native.focus() + } + + override fun openDevTools() { + native.openDevTools() + } + + override fun closeDevTools() { + native.closeDevTools() + } + + override fun destroy() { + native.destroy() + } +} + +actual suspend fun decodeScreenshotPixels(webView: IWebView?): ScreenshotPixels? { + val img = webView?.toAwtImage() ?: return null + val w = img.width + val h = img.height + if (w <= 0 || h <= 0) return null + val samples = + listOf( + img.getRGB(w / 2, h / 2), + img.getRGB(w / 3, h / 3), + img.getRGB(2 * w / 3, 2 * h / 3), + ) + return ScreenshotPixels(width = w, height = h, samples = samples) +} + +actual fun writeSuiteReport( + report: SuiteReport, + preferredPath: String?, +): String { + val path = + preferredPath + ?: System.getenv("COMPOSEWEBVIEW_SUITE_REPORT") + ?: run { + val dir = System.getProperty("java.io.tmpdir")?.trimEnd('/', '\\') ?: "." + "$dir${File.separator}composewebview-visual-suite-report.txt" + } + val body = formatSuiteReport(report) + File(path).apply { + parentFile?.mkdirs() + writeText(body) + } + println(body) + return path +} + +actual fun currentTimeNanos(): Long = System.nanoTime() + +actual fun createTempProfileDirectory(prefix: String): String { + val dir = + File.createTempFile(prefix, null).apply { + delete() + mkdirs() + deleteOnExit() + } + return dir.absolutePath +} diff --git a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt b/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.wasmJs.kt similarity index 89% rename from demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt rename to e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.wasmJs.kt index 099cc99..d4f6917 100644 --- a/demo-shared/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/demo/PlatformInfo.wasmJs.kt +++ b/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/PlatformInfo.wasmJs.kt @@ -1,4 +1,4 @@ -package io.github.kdroidfilter.webview.demo +package dev.nucleusframework.webview.e2e import kotlinx.browser.window import kotlinx.serialization.json.buildJsonObject diff --git a/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/Utils.wasmJs.kt b/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/Utils.wasmJs.kt new file mode 100644 index 0000000..b817b63 --- /dev/null +++ b/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/Utils.wasmJs.kt @@ -0,0 +1,11 @@ +package dev.nucleusframework.webview.e2e + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun nowTimestamp(): String = js( + "new Date().toISOString().slice(11, 19)" +) + +@OptIn(ExperimentalWasmJsInterop::class) +private fun jsNow(): Double = js("Date.now()") + +internal actual fun currentTimeMillis(): Long = jsNow().toLong() diff --git a/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.wasmJs.kt b/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.wasmJs.kt new file mode 100644 index 0000000..d58c9e6 --- /dev/null +++ b/e2e-shared/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.wasmJs.kt @@ -0,0 +1,49 @@ +package dev.nucleusframework.webview.e2e.visualsuite + +import androidx.compose.runtime.Composable +import dev.nucleusframework.webview.web.IWebView +import dev.nucleusframework.webview.web.WebViewState +import kotlinx.browser.window + +actual fun suiteCapabilities(): Set = + // Wasm IFrame limits: no history, no data: JS access, host-only cookies, + // screenshot needs optional html2canvas (not bundled). + emptySet() + +actual fun isPlatformWebViewReady(state: WebViewState): Boolean = state.webView != null + +@Composable +actual fun rememberSuiteParentHandle(): Long = 0L + +actual suspend fun withIsolatedNativeWebView( + parentHandle: Long, + customUserAgent: String?, + initScript: String?, + incognito: Boolean, + dataDirectory: String?, + enableDevtools: Boolean, + block: suspend (IsolatedNativeWebView) -> Unit, +) { + error("IsolatedNativeWebView not available on Wasm") +} + +actual suspend fun decodeScreenshotPixels(webView: IWebView?): ScreenshotPixels? = null + +actual fun writeSuiteReport( + report: SuiteReport, + preferredPath: String?, +): String { + val body = formatSuiteReport(report) + println(body) + runCatching { + window.localStorage.setItem("composewebview-visual-suite-report", body) + } + return preferredPath ?: "browser:localStorage:composewebview-visual-suite-report" +} + +@OptIn(ExperimentalWasmJsInterop::class) +private fun jsNowMs(): Double = js("Date.now()") + +actual fun currentTimeNanos(): Long = (jsNowMs() * 1_000_000.0).toLong() + +actual fun createTempProfileDirectory(prefix: String): String = "wasm-profile-$prefix" diff --git a/demo-wasmJs/build.gradle.kts b/e2e-wasmJs/build.gradle.kts similarity index 92% rename from demo-wasmJs/build.gradle.kts rename to e2e-wasmJs/build.gradle.kts index b0fe8cb..581011c 100644 --- a/demo-wasmJs/build.gradle.kts +++ b/e2e-wasmJs/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) - implementation(project(":demo-shared")) + implementation(project(":e2e-shared")) } } } diff --git a/e2e-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/main.kt b/e2e-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/main.kt new file mode 100644 index 0000000..5236177 --- /dev/null +++ b/e2e-wasmJs/src/wasmJsMain/kotlin/dev/nucleusframework/webview/e2e/main.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.webview.e2e + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import dev.nucleusframework.webview.e2e.visualsuite.VisualSuiteApp +import kotlinx.browser.document +import org.w3c.dom.HTMLElement + +/** + * Wasm entrypoint: runs the shared visual e2e suite against a real IFrame WebView. + */ +fun main() { + val body: HTMLElement = document.body ?: return + ComposeViewport(body) { + VisualSuiteApp { passed, reportPath -> + println("SUITE_FINISHED passed=$passed report=$reportPath") + } + } +} diff --git a/demo-wasmJs/src/wasmJsMain/resources/index.html b/e2e-wasmJs/src/wasmJsMain/resources/index.html similarity index 82% rename from demo-wasmJs/src/wasmJsMain/resources/index.html rename to e2e-wasmJs/src/wasmJsMain/resources/index.html index 1b1998c..481bd21 100644 --- a/demo-wasmJs/src/wasmJsMain/resources/index.html +++ b/e2e-wasmJs/src/wasmJsMain/resources/index.html @@ -3,7 +3,7 @@ - Demo + ComposeWebView E2E - + diff --git a/gradle.properties b/gradle.properties index c286b38..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 @@ -11,16 +12,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..1c561f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,16 @@ [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" -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" +androidx-activity = "1.13.0" +mavenPublish = "0.37.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.14.0" +kotlin = "2.4.10" +kotlinx-coroutines = "1.11.0" +kotlinx-serialization = "1.11.0" +nucleus = "2.3.1" [libraries] compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } @@ -25,9 +23,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" } -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" } +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" } @@ -39,7 +37,5 @@ 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" } +nucleus = { id = "dev.nucleusframework", version.ref = "nucleus" } 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 2838cce..21bfd64 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 -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 d082e3f..861b907 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() } } @@ -32,10 +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(":wrywebview") +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 f5fcca6..0000000 --- a/webview-compose-test/build.gradle.kts +++ /dev/null @@ -1,33 +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")) - } - - jvmMain.dependencies { - api(libs.playwright) - } - } -} - -mavenPublishing { - configure(KotlinMultiplatform(sourcesJar = true)) - publishToMavenCentral() - if (project.findProperty("signingInMemoryKey") != null) { - signAllPublications() - } - coordinates(artifactId = "composewebview-test") - pom { - name.set("ComposeWebView Testing") - description.set("Testing utilities for Compose Multiplatform WebView library") - } -} diff --git a/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt b/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt deleted file mode 100644 index 0dfd8bf..0000000 --- a/webview-compose-test/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/PlaywrightWebView.kt +++ /dev/null @@ -1,103 +0,0 @@ -package io.github.kdroidfilter.webview.web - -import com.microsoft.playwright.Playwright -import io.github.kdroidfilter.webview.wry.Rgba -import io.github.kdroidfilter.webview.wry.WryWebViewPanel -import java.awt.Color -import java.awt.image.BufferedImage -import java.io.ByteArrayInputStream -import javax.imageio.ImageIO - -const val PLAYWRIGHT_PAGE_WIDTH = 1024 -const val PLAYWRIGHT_PAGE_HEIGHT = 720 - -/** - * A mock implementation of [WryWebViewPanel] that uses Playwright for some operations. - * This is useful for testing without a real native WebView. - */ -class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel( - initialUrl = (param.state.content as? WebContent.Url)?.url ?: "about:blank", - backgroundColor = Rgba(0u.toUByte(), 0u.toUByte(), 0u.toUByte(), 0u.toUByte()) -) { - val evaluatedScripts = mutableListOf() - var currentContent: String = (param.state.content as? WebContent.Url)?.url ?: "about:blank" - - override fun evaluateJavaScript(script: String, callback: (String) -> Unit) { - evaluatedScripts.add(script) - if (script == "document.documentElement.outerHTML") { - if (currentContent.startsWith("http")) { - runCatching { - Playwright.create().use { playwright -> - playwright.chromium().launch().use { browser -> - browser.newPage().use { page -> - page.navigate(currentContent) - val html = page.content() - callback(html) - } - } - } - }.onFailure { - callback("Playwright failed: ${it.message}") - } - } else { - callback("Mock Content: $currentContent") - } - } else { - callback("true") - } - } - - override fun isReady(): Boolean = true - override fun isLoading(): Boolean = false - override fun getCurrentUrl(): String = currentContent - override fun getTitle(): String = "Playwright WebView" - - override fun loadUrl(url: String, additionalHttpHeaders: Map) { - currentContent = url - } - - override fun loadHtml(html: String) { - currentContent = "HTML content" - } - - override fun stopLoading() {} - override fun reload() {} - override fun goBack() {} - override fun goForward() {} - - override fun captureScreenshot(nativeBytes: ByteArray?): BufferedImage { - // Try to use Playwright for a real screenshot if it's a URL - if (currentContent.startsWith("http")) { - runCatching { - Playwright.create().use { playwright -> - playwright.chromium().launch().use { browser -> - browser.newPage().use { page -> - page.setViewportSize(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT) - page.navigate(currentContent) - val bytes = page.screenshot() - return ImageIO.read(ByteArrayInputStream(bytes)) - } - } - } - }.onFailure { - println("Playwright failed: ${it.message}. Falling back to mock.") - } - } - - val img = BufferedImage(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT, BufferedImage.TYPE_INT_ARGB) - val g = img.createGraphics() - // Fill background with a recognizable color (e.g., Light Gray) - g.color = Color.LIGHT_GRAY - g.fillRect(0, 0, PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT) - // Draw some "content" - g.color = Color.BLACK - g.drawString("Mock: $currentContent", 5, 50) - g.dispose() - return img - } -} - -/** - * A factory function that creates a [PlaywrightWebView]. - */ -fun playwrightWebViewFactory(param: WebViewFactoryParam): NativeWebView = PlaywrightWebView(param) diff --git a/webview-compose/README.md b/webview-compose/README.md index b2512cb..cd9c42f 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)**: Nucleus Tao `NativeView` — WebKit2GTK (Linux), WKWebView (macOS), WebView2 (Windows) diff --git a/webview-compose/build.gradle.kts b/webview-compose/build.gradle.kts index 0730223..a203ec5 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,92 @@ plugins { alias(libs.plugins.mavenPublish) } +// ── Native build (Linux / macOS / Windows) ────────────────────────────────── +// Same pattern as Nucleus: compile host-arch natives into +// src/jvmMain/resources/nucleus/native/{linux,darwin,win32}-{x64,aarch64}/. +// CI builds via matrix and downloads artifacts before package/publish. +// 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") + +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") +} + +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" + 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 + val loaderFile = nativeResourceDir.file("$archDir/WebView2Loader.dll").asFile + onlyIf { + Os.isFamily(Os.FAMILY_WINDOWS) && (!checkFile.exists() || !loaderFile.exists()) + } + inputs.dir(nativeWindowsDir) + 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 / jar include the native lib when packaging on host OS. +tasks.matching { + it.name == "jvmProcessResources" || + it.name == "processJvmMainResources" || + it.name == "jvmJar" +}.configureEach { + dependsOn(buildNativeLinux, buildNativeMacos, buildNativeWindows) +} + +tasks.configureEach { + if (name == "sourcesJar" || name == "jvmSourcesJar") { + dependsOn(buildNativeLinux, buildNativeMacos, buildNativeWindows) + } +} + kotlin { applyDefaultHierarchyTemplate() @@ -22,7 +109,6 @@ kotlin { } listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -46,13 +132,19 @@ kotlin { implementation(libs.kotlinx.serializationJson) } + commonTest.dependencies { + implementation(kotlin("test")) + } + androidMain.dependencies { implementation(libs.kotlinx.coroutinesAndroid) } jvmMain.dependencies { - api(project(":wrywebview")) 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) } iosMain.dependencies { } @@ -62,7 +154,7 @@ kotlin { } android { - namespace = "io.github.kdroidfilter.webview" + namespace = "dev.nucleusframework.webview" compileSdk = 35 defaultConfig { @@ -105,7 +197,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 59% 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..d53bc84 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,11 +1,13 @@ -package io.github.kdroidfilter.webview.cookie +package dev.nucleusframework.webview.cookie import android.webkit.CookieManager as PlatformCookieManager 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/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 82% 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..ca7d8df 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 @@ -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/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 94% 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..3e0ecdc 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 @@ -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 @@ -14,10 +15,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( @@ -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/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 81% 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..0c6a98d 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 @@ -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/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 67% 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..c010fcc 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,14 +1,22 @@ -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 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) } } @@ -42,10 +51,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() } } } @@ -58,7 +68,8 @@ fun WebView( webViewJsBridge = webViewJsBridge, onCreated = onCreated, onDispose = onDispose, - factory = factory + factory = factory, + content = content, ) DisposableEffect(Unit) { @@ -79,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/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/dev/nucleusframework/webview/web/WebViewState.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt new file mode 100644 index 0000000..66bc6e3 --- /dev/null +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/WebViewState.kt @@ -0,0 +1,90 @@ +package dev.nucleusframework.webview.web + +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import dev.nucleusframework.webview.cookie.CookieManager +import dev.nucleusframework.webview.cookie.WebViewCookieManager +import dev.nucleusframework.webview.setting.WebSettings + +@Stable +class WebViewState( + webContent: WebContent, +) { + var lastLoadedUrl: String? by mutableStateOf(null) + internal set + + var content: WebContent by mutableStateOf(webContent) + + var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing) + internal set + + val isLoading: Boolean + get() = loadingState !is LoadingState.Finished + + var pageTitle: String? by mutableStateOf(null) + internal set + + val errorsForCurrentRequest: SnapshotStateList = mutableStateListOf() + + val webSettings: WebSettings by mutableStateOf(WebSettings()) + + var webView: IWebView? by mutableStateOf(null) + internal set + + val cookieManager: CookieManager by mutableStateOf(WebViewCookieManager()) +} + +@Composable +fun rememberWebViewState( + url: String, + additionalHttpHeaders: Map = emptyMap(), + extraSettings: WebSettings.() -> Unit = {}, +): 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( + data: String, + baseUrl: String? = null, + encoding: String = "utf-8", + mimeType: String? = null, + historyUrl: String? = null, +): 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 { + val state = + remember { + WebViewState(WebContent.File(fileName, readType)) + } + LaunchedEffect(fileName, readType) { + state.content = WebContent.File(fileName, readType) + } + return state +} diff --git a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt b/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt deleted file mode 100644 index dbfbc5c..0000000 --- a/webview-compose/src/commonMain/kotlin/io/github/kdroidfilter/webview/web/WebViewState.kt +++ /dev/null @@ -1,73 +0,0 @@ -package io.github.kdroidfilter.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 - -@Stable -class WebViewState( - webContent: WebContent, -) { - var lastLoadedUrl: String? by mutableStateOf(null) - internal set - - var content: WebContent by mutableStateOf(webContent) - - var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing) - internal set - - val isLoading: Boolean - get() = loadingState !is LoadingState.Finished - - var pageTitle: String? by mutableStateOf(null) - internal set - - val errorsForCurrentRequest: SnapshotStateList = mutableStateListOf() - - val webSettings: WebSettings by mutableStateOf(WebSettings()) - - var webView: IWebView? by mutableStateOf(null) - internal set - - val cookieManager: CookieManager by mutableStateOf(WebViewCookieManager()) -} - -@Composable -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) - } - -@Composable -fun rememberWebViewStateWithHTMLData( - data: String, - baseUrl: String? = null, - 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) - } - -@Composable -fun rememberWebViewStateWithHTMLFile( - fileName: String, - readType: WebViewFileReadType, -): WebViewState = - remember { - WebViewState(WebContent.File(fileName, readType)) - }.apply { - this.content = WebContent.File(fileName, readType) - } 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/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/dev/nucleusframework/webview/web/WebViewIos.kt b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt new file mode 100644 index 0000000..08e21d3 --- /dev/null +++ b/webview-compose/src/iosMain/kotlin/dev/nucleusframework/webview/web/WebViewIos.kt @@ -0,0 +1,168 @@ +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode +import androidx.compose.ui.viewinterop.UIKitInteropProperties +import androidx.compose.ui.viewinterop.UIKitView +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 +import platform.CoreGraphics.CGRectZero +import platform.Foundation.NSOperatingSystemVersion +import platform.Foundation.NSProcessInfo +import platform.Foundation.setValue +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration +import platform.WebKit.javaScriptEnabled + +/** + * iOS WebView implementation. + */ +@OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) +@Composable +actual fun ActualWebView( + state: WebViewState, + modifier: Modifier, + navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, + onCreated: (NativeWebView) -> Unit, + onDispose: (NativeWebView) -> Unit, + factory: (WebViewFactoryParam) -> NativeWebView, + content: @Composable () -> Unit, +) { + val observer = remember { WKWebViewObserver(state, navigator) } + val navigationDelegate = remember { WKNavigationDelegate(state, navigator) } + val scope = rememberCoroutineScope() + + if (LocalWebViewFactory.current != null) { + Box(modifier) { + val scope = rememberCoroutineScope() + remember(state) { + val config = WKWebViewConfiguration() + factory(WebViewFactoryParam(config)).apply { + onCreated(this) + val iosWebView = IOSWebView(this, scope, webViewJsBridge) + state.webView = iosWebView + webViewJsBridge?.webView = iosWebView + } + } + content() + } + } else { + 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( + value = state.webSettings.allowUniversalAccessFromFileURLs, + forKey = "allowUniversalAccessFromFileURLs", + ) + } + + 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( + state.webSettings.allowFileAccessFromFileURLs, + forKey = "allowFileAccessFromFileURLs", + ) + 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, + ), + ) + content() + } + } +} + +actual data class WebViewFactoryParam( + val config: WKWebViewConfiguration, +) + +@OptIn(ExperimentalForeignApi::class) +actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView = + WKWebView( + frame = CGRectZero.readValue(), + configuration = param.config, + ) + +@OptIn(ExperimentalForeignApi::class) +private fun applyIOSSettings(webView: WKWebView, settings: WebSettings) { + val iOSSettings = settings.iOSWebSettings + val backgroundColor = (iOSSettings.backgroundColor ?: settings.backgroundColor).toUIColor() + + webView.setOpaque(iOSSettings.opaque) + if (!iOSSettings.opaque) { + webView.setBackgroundColor(backgroundColor) + webView.scrollView.setBackgroundColor(backgroundColor) + } + webView.scrollView.pinchGestureRecognizer?.enabled = settings.supportZoom + + val minSetInspectableVersion = + cValue { + majorVersion = 16 + minorVersion = 4 + patchVersion = 0 + } + if (NSProcessInfo.processInfo.isOperatingSystemAtLeastVersion(minSetInspectableVersion)) { + webView.setInspectable(iOSSettings.isInspectable) + } +} diff --git a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt b/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt deleted file mode 100644 index 708e783..0000000 --- a/webview-compose/src/iosMain/kotlin/io/github/kdroidfilter/webview/web/WebViewIos.kt +++ /dev/null @@ -1,162 +0,0 @@ -package io.github.kdroidfilter.webview.web - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.ExperimentalComposeUiApi -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 kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.cValue -import kotlinx.cinterop.readValue -import platform.CoreGraphics.CGRectZero -import platform.Foundation.NSOperatingSystemVersion -import platform.Foundation.NSProcessInfo -import platform.Foundation.setValue -import platform.WebKit.WKWebView -import platform.WebKit.WKWebViewConfiguration -import platform.WebKit.javaScriptEnabled - -/** - * iOS WebView implementation. - */ -@OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) -@Composable -actual fun ActualWebView( - state: WebViewState, - modifier: Modifier, - navigator: WebViewNavigator, - webViewJsBridge: WebViewJsBridge?, - onCreated: (NativeWebView) -> Unit, - onDispose: (NativeWebView) -> Unit, - factory: (WebViewFactoryParam) -> NativeWebView, -) { - val observer = remember { WKWebViewObserver(state, navigator) } - val navigationDelegate = remember { WKNavigationDelegate(state, navigator) } - val scope = rememberCoroutineScope() - - if (LocalWebViewFactory.current != null) { - Box(modifier) { - val scope = rememberCoroutineScope() - remember(state) { - val config = WKWebViewConfiguration() - factory(WebViewFactoryParam(config)).apply { - onCreated(this) - val iosWebView = IOSWebView(this, scope, webViewJsBridge) - state.webView = iosWebView - webViewJsBridge?.webView = iosWebView - } - } - } - } else { - UIKitView( - factory = { - val config = - WKWebViewConfiguration().apply { - defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled - preferences.apply { - setValue( - state.webSettings.allowFileAccessFromFileURLs, - forKey = "allowFileAccessFromFileURLs", - ) - javaScriptEnabled = state.webSettings.isJavaScriptEnabled - } - setValue( - value = state.webSettings.allowUniversalAccessFromFileURLs, - forKey = "allowUniversalAccessFromFileURLs", - ) - } - - factory(WebViewFactoryParam(config)).apply { - onCreated(this) - - customUserAgent = state.webSettings.customUserAgentString - - addProgressObservers(observer) - this.navigationDelegate = navigationDelegate - - applyIOSSettings(this, state.webSettings) - }.also { wkWebView -> - val iosWebView = IOSWebView(wkWebView, scope, webViewJsBridge) - state.webView = iosWebView - webViewJsBridge?.webView = iosWebView - } - }, - modifier = modifier, - update = { wkWebView -> - wkWebView.customUserAgent = state.webSettings.customUserAgentString - - wkWebView.configuration.defaultWebpagePreferences.allowsContentJavaScript = state.webSettings.isJavaScriptEnabled - wkWebView.configuration.preferences.apply { - setValue( - state.webSettings.allowFileAccessFromFileURLs, - forKey = "allowFileAccessFromFileURLs", - ) - 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, - ), - ) - } -} - -actual data class WebViewFactoryParam( - val config: WKWebViewConfiguration, -) - -@OptIn(ExperimentalForeignApi::class) -actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView = - WKWebView( - frame = CGRectZero.readValue(), - configuration = param.config, - ) - -@OptIn(ExperimentalForeignApi::class) -private fun applyIOSSettings(webView: WKWebView, settings: WebSettings) { - val iOSSettings = settings.iOSWebSettings - val backgroundColor = (iOSSettings.backgroundColor ?: settings.backgroundColor).toUIColor() - - webView.setOpaque(iOSSettings.opaque) - if (!iOSSettings.opaque) { - webView.setBackgroundColor(backgroundColor) - webView.scrollView.setBackgroundColor(backgroundColor) - } - webView.scrollView.pinchGestureRecognizer?.enabled = settings.supportZoom - - val minSetInspectableVersion = - cValue { - majorVersion = 16 - minorVersion = 4 - patchVersion = 0 - } - if (NSProcessInfo.processInfo.isOperatingSystemAtLeastVersion(minSetInspectableVersion)) { - webView.setInspectable(iOSSettings.isInspectable) - } -} 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..d0f3d06 --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/cookie/DesktopCookieManager.kt @@ -0,0 +1,166 @@ +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 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Desktop cookie manager. Backed by WebKit2GTK on Linux, WKWebView on macOS, + * and WebView2 on Windows. + */ +internal class DesktopCookieManager : CookieManager { + @Volatile + private var nativeWebView: NativeWebView? = null + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + internal fun attach(webView: NativeWebView?) { + 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 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}" } + 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 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, + 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 native = nativeWebView + return withContext(Dispatchers.Main) { + runCatching { + val raw = + when (native) { + is LinuxWebKitNativeWebView -> native.getCookiesJson(url) + is MacOsWebKitNativeWebView -> 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" } + emptyList() + } + } + } + + override suspend fun removeAllCookies() { + val native = nativeWebView + withContext(Dispatchers.Main) { + runCatching { + when (native) { + is LinuxWebKitNativeWebView -> native.removeAllCookiesNative() + is MacOsWebKitNativeWebView -> native.removeAllCookiesNative() + is WindowsWebView2NativeWebView -> native.removeAllCookiesNative() + else -> Unit + } + }.onFailure { KLogger.e(it, tag = "DesktopCookieManager") { "removeAllCookies failed" } } + } + } + + override suspend fun removeCookies(url: String) { + val native = nativeWebView + withContext(Dispatchers.Main) { + runCatching { + when (native) { + is LinuxWebKitNativeWebView -> native.removeCookiesForUrlNative(url) + is MacOsWebKitNativeWebView -> native.removeCookiesForUrlNative(url) + is WindowsWebView2NativeWebView -> native.removeCookiesForUrlNative(url) + else -> Unit + } + }.onFailure { + KLogger.e(it, tag = "DesktopCookieManager") { "removeCookies failed url=$url" } + } + } + } +} + +@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/io/github/kdroidfilter/webview/web/DesktopWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt similarity index 69% rename from webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt rename to webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt index 294eb56..61a7ff9 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/kdroidfilter/webview/web/DesktopWebView.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/DesktopWebView.kt @@ -1,12 +1,19 @@ -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 kotlinx.coroutines.CoroutineScope -import java.io.ByteArrayOutputStream +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 javax.imageio.ImageIO +import kotlinx.coroutines.CoroutineScope +/** + * Desktop [IWebView] implementation. + * + * 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, override val scope: CoroutineScope, @@ -35,7 +42,12 @@ internal class DesktopWebView( historyUrl: String?, ) { if (html == null) return - nativeWebView.loadHtml(html) + 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) + } } override suspend fun loadHtmlFile( @@ -60,7 +72,10 @@ internal class DesktopWebView( candidates.add("composeResources/files/$normalized") candidates.add("composeResources/assets/$normalized") val loaders = - listOfNotNull(Thread.currentThread().contextClassLoader, this::class.java.classLoader) + listOfNotNull( + Thread.currentThread().contextClassLoader, + this::class.java.classLoader, + ) candidates.firstNotNullOfOrNull { path -> loaders.firstNotNullOfOrNull { loader -> loader.getResourceAsStream(path) @@ -72,8 +87,8 @@ internal class DesktopWebView( URL(fileName).openStream().use { it.readBytes().toString(Charsets.UTF_8) } } }.getOrElse { e -> - // language=HTML - val errorHtml = """ + val errorHtml = + """ Error Loading File @@ -83,7 +98,7 @@ internal class DesktopWebView(
${e.stackTraceToString()}
- """.trimIndent() + """.trimIndent() KLogger.e(e, tag = "DesktopWebView") { "loadHtmlFile failed" } errorHtml } @@ -99,42 +114,39 @@ internal class DesktopWebView( override fun stopLoading() = nativeWebView.stopLoading() override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { - KLogger.d { - "evaluateJavaScript: $script" - } + 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() + when (val native = nativeWebView) { + is LinuxWebKitNativeWebView -> return native.captureScreenshotAsync() + is MacOsWebKitNativeWebView -> return native.captureScreenshotAsync() + is WindowsWebView2NativeWebView -> return native.captureScreenshotAsync() + } + return nativeWebView.captureScreenshotNative() } override fun injectJsBridge() { val bridge = webViewJsBridge ?: return super.injectJsBridge() - //language=JavaScript - val js = """ + val js = + """ if (window.${bridge.jsBridgeName} && window.ipc && window.ipc.postMessage) { window.${bridge.jsBridgeName}.postMessage = function (message) { window.ipc.postMessage(message); }; } - """.trimIndent() + """.trimIndent() evaluateJavaScript(js) } override fun initJsBridge(webViewJsBridge: WebViewJsBridge) { - // No-op: IPC is configured in the Rust layer via wry's `with_ipc_handler`. + // IPC is wired natively: + // - 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 new file mode 100644 index 0000000..ad65fc5 --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt @@ -0,0 +1,55 @@ +package dev.nucleusframework.webview.web + +/** + * Desktop [NativeWebView] base. + * + * 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 + */ +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..9cd1885 --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt @@ -0,0 +1,353 @@ +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.core.runtime.Platform +import dev.nucleusframework.webview.cookie.DesktopCookieManager +import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +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.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 +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 = "", + /** Windows only: parent Tao HWND. Required to create a real WebView2. */ + val parentHwnd: Long = 0L, +) + +/** + * 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 + 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) { + 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, + ) + } + + 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 && + 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 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). + * + * Outside a Tao [dev.nucleusframework.application.DecoratedWindow], + * [NativeView] falls back to an empty box — the WebView only works with + * the Tao backend. + */ +@Composable +actual fun ActualWebView( + state: WebViewState, + modifier: Modifier, + navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, + onCreated: (NativeWebView) -> Unit, + onDispose: (NativeWebView) -> Unit, + factory: (WebViewFactoryParam) -> NativeWebView, + content: @Composable () -> Unit, +) { + val currentOnDispose by rememberUpdatedState(onDispose) + val scope = rememberCoroutineScope() + + 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) { + DesktopWebView( + nativeWebView = nativeWebView, + scope = scope, + webViewJsBridge = webViewJsBridge, + ) + } + + LaunchedEffect(desktopWebView) { + state.webView = desktopWebView + webViewJsBridge?.webView = desktopWebView + (state.cookieManager as? DesktopCookieManager)?.attach(nativeWebView) + if (!nativeWebView.isLiveBackend()) { + // No-op backend: mark finished so demos don't spin forever. + state.loadingState = LoadingState.Finished + navigator.canGoBack = false + navigator.canGoForward = false + } + } + + // Poll native state (URL / loading / title / nav) and drain IPC. + LaunchedEffect(nativeWebView, state, navigator) { + if (!nativeWebView.isLiveBackend()) 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 + } + } + } + + // Always publish the latest source — do not gate on isLoading. + // (A stuck isLoading flag must not leave lastLoadedUrl blank.) + if (!url.isNullOrBlank()) { + 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() + + delay(120.milliseconds) + } + } + + LaunchedEffect(nativeWebView, webViewJsBridge) { + if (!nativeWebView.isLiveBackend() || 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 + val macosWebView = nativeWebView as? MacOsWebKitNativeWebView + val windowsWebView = nativeWebView as? WindowsWebView2NativeWebView + when { + linuxWebView != null && LocalWebViewFactory.current == null -> { + NativeView( + factory = { linuxWebView.asPlatformView() }, + modifier = modifier, + update = { }, + content = content, + ) + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + } + macosWebView != null && LocalWebViewFactory.current == null -> { + NativeView( + factory = { macosWebView.asPlatformView() }, + modifier = modifier, + update = { }, + content = content, + ) + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + } + windowsWebView != null && LocalWebViewFactory.current == null -> { + NativeView( + factory = { windowsWebView.asPlatformView() }, + modifier = modifier, + update = { }, + content = content, + ) + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + } + else -> { + // Test factory / unsupported / no-op: empty layout slot + overlay. + Box(modifier) { + LaunchedEffect(nativeWebView) { + onCreated(nativeWebView) + } + content() + } + } + } + + DisposableEffect(nativeWebView) { + onDispose { + state.webView = null + webViewJsBridge?.webView = null + (state.cookieManager as? DesktopCookieManager)?.attach(null) + currentOnDispose(nativeWebView) + nativeWebView.destroy() + } + } +} + +/** + * 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/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..fcc73ae --- /dev/null +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt @@ -0,0 +1,232 @@ +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. + */ +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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..1d356da --- /dev/null +++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/native-image.properties @@ -0,0 +1,9 @@ +# 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 new file mode 100644 index 0000000..af82e85 --- /dev/null +++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json @@ -0,0 +1,140 @@ +{ + "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.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, + "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.macos.MacOsWebKitNativeWebView", + "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, + "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[]"] } + ] + }, + { + "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": [ + { "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..f496577 --- /dev/null +++ b/webview-compose/src/jvmMain/resources/META-INF/proguard/composewebview.pro @@ -0,0 +1,44 @@ +# 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 *; +} +-keep class dev.nucleusframework.webview.web.linux.LinuxWebKitNativeWebView { + (...); + 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 *; +} +-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/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 76% 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..d5d4ccd 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,14 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package io.github.kdroidfilter.webview.web +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 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 @@ -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() + } } } 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"