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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions resources/android/WebviewRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
Expand All @@ -13,6 +14,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.nativephp.mobile.bridge.LaravelEnvironment
import com.nativephp.mobile.bridge.PHPBridge
import com.nativephp.mobile.network.WebViewManager
import com.nativephp.mobile.ui.MainActivity
import com.nativephp.mobile.ui.nativerender.NativeUIBridge
import com.nativephp.mobile.ui.nativerender.NativeUINode

Expand All @@ -27,11 +32,20 @@ import com.nativephp.mobile.ui.nativerender.NativeUINode
*
* Top-frame navigations fire `on_navigated(url)` once committed. External
* schemes (mailto, tel, intent, …) and target=_blank attempts are denied.
*
* The `php` attribute swaps the sandbox for the app's own enriched Laravel
* webview (see [PhpWebView]): pages served per-request by the embedded PHP
* runtime, with the full JS bridge and the process-wide cookie session.
*/
object WebviewRenderer {

@Composable
fun Render(node: NativeUINode, modifier: Modifier) {
if (node.props.getBool("php", false)) {
PhpWebView(node, modifier)
return
}

// Snapshot of the values we care about. `remember(key)` rebuilds
// the WebView only when src/html actually change — avoids
// recreating the surface on every recomposition (which would
Expand Down Expand Up @@ -98,6 +112,112 @@ object WebviewRenderer {
}
}

/**
* Enriched-mode webview: an independent WebView wired exactly like the app's
* main Laravel webview. [WebViewManager.setup] provides the full stack —
* settings, cookie manager, the request-intercepting client that answers
* `http://127.0.0.1` from the embedded PHP runtime, the POST-capture JS
* interface, and the `window.Native` injection.
*
* Two integration hazards are handled here:
* - `setup()` claims the process-wide [WebViewManager.shared] slot, which
* belongs to the app's root webview. It is restored immediately after.
* - The stock client's `onPageStarted` drops out of native-UI mode
* (`isActive = false`) on every page load. Correct for the root webview;
* fatal for one embedded *inside* the native tree — it would unmount this
* very renderer. [PhpEmbedClient] delegates to the stock client (keeping
* the POST-inspector script injection it performs) and re-asserts
* native-UI mode afterwards.
*
* `src` is an app route path in this mode; anything not starting with `/`
* (including empty) falls back to the app's configured start URL.
*/
@Composable
private fun PhpWebView(node: NativeUINode, modifier: Modifier) {
val src = node.props.getString("src", "")
val onNavigatedCb = node.props.getCallbackId("on_navigated")
val nodeId = node.id

AndroidView(
modifier = modifier,
factory = { ctx ->
val activity = (ctx as? MainActivity) ?: MainActivity.instance
?: return@factory WebView(ctx) // no shell activity — bare view, nothing to serve

val webView = WebView(activity)
val previousShared = WebViewManager.shared
WebViewManager(activity, webView, PHPBridge(activity)).setup()
WebViewManager.shared = previousShared

webView.webViewClient = PhpEmbedClient(webView.webViewClient, onNavigatedCb, nodeId)

val path = phpPath(src, activity)
webView.tag = path
webView.loadUrl("http://127.0.0.1$path")
webView
},
update = { webView ->
(webView.webViewClient as? PhpEmbedClient)?.let {
it.navigatedCallbackId = onNavigatedCb
it.nodeId = nodeId
}
val activity = (webView.context as? MainActivity) ?: MainActivity.instance
if (activity != null) {
val path = phpPath(src, activity)
if (webView.tag != path) {
webView.tag = path
webView.loadUrl("http://127.0.0.1$path")
}
}
},
onRelease = { webView ->
webView.stopLoading()
webView.webViewClient = WebViewClient()
webView.webChromeClient = null
webView.destroy()
}
)
}

private fun phpPath(src: String, context: android.content.Context): String =
if (src.startsWith("/")) src else LaravelEnvironment.getStartURL(context)

private class PhpEmbedClient(
private val inner: WebViewClient,
var navigatedCallbackId: Int,
var nodeId: Int
) : WebViewClient() {

override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? = inner.shouldInterceptRequest(view, request)

override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean = inner.shouldOverrideUrlLoading(view, request)

override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
inner.onPageStarted(view, url, favicon)
// The delegate call above just flipped the app to web mode; undo it —
// this webview renders inside the native tree, which must stay mounted.
NativeUIBridge.isActive.value = true
}

override fun onPageFinished(view: WebView, url: String?) {
inner.onPageFinished(view, url)
}

override fun onPageCommitVisible(view: WebView?, url: String?) {
inner.onPageCommitVisible(view, url)
val resolved = url.orEmpty()
if (navigatedCallbackId != 0 && resolved.isNotEmpty()) {
NativeUIBridge.sendTextChangeEvent(navigatedCallbackId, nodeId, resolved)
}
}
}

private fun WebView.loadContent(src: String, html: String) {
if (html.isNotEmpty()) {
// null baseURL → opaque origin. Embedded HTML can't issue
Expand Down
132 changes: 131 additions & 1 deletion resources/ios/NativeUIWebviewRenderer.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
@preconcurrency import WebKit

/// Locked-down WKWebView primitive.
Expand All @@ -11,11 +12,140 @@ import SwiftUI
///
/// Top-frame navigations fire `on_navigated(url)` once committed. External
/// schemes (mailto, tel, sms, …) and target=_blank attempts are denied.
///
/// Two mode attributes change the posture entirely:
/// - `php` — swaps the sandbox for the app's own enriched Laravel webview
/// (`PHPWebViewContainer` below): served over the `php://` scheme by the
/// embedded runtime, sharing the shell webview's session store and
/// `window.Native` bridge scripts.
/// - `fullscreen` — the element arrives with fill layout from the PHP side;
/// here it additionally extends behind the safe areas, matching the old
/// v3 default-webview presentation.
struct NativeUIWebviewRenderer: View {
let node: NativeUINode

var body: some View {
WebViewContainer(node: node)
let content = Group {
if node.props.getBool("php", default: false) {
PHPWebViewContainer(node: node)
} else {
WebViewContainer(node: node)
}
}

if node.props.getBool("fullscreen", default: false) {
content.ignoresSafeArea(.container, edges: .all)
} else {
content
}
}
}

/// Enriched-mode container: an independent WKWebView wired exactly like the
/// shell's classic Laravel webview (`WebView.makeUIView` in ContentView).
/// Content is answered per-request by the embedded PHP runtime through
/// `PHPSchemeHandler`; `WebView.dataStore` is shared so this instance rides
/// the same Laravel session as the app's root webview. The shell's
/// `addNativeHelper` user scripts (safe-area CSS variables + `window.Native`)
/// are borrowed via a throwaway `WebView` value — the method only touches the
/// configuration of the webview passed in.
///
/// Unlike the shell's coordinator, ours never toggles `NativeUIBridge`
/// web/native mode on navigation — this webview lives *inside* the native
/// tree, so page loads here must not unmount it.
private struct PHPWebViewContainer: UIViewRepresentable {
let node: NativeUINode

func makeCoordinator() -> Coordinator {
Coordinator(navigatedCallbackId: node.props.getCallbackId("on_navigated"),
nodeId: node.id)
}

func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(PHPSchemeHandler(), forURLScheme: "php")
config.websiteDataStore = WebView.dataStore
config.allowsInlineMediaPlayback = true

let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.backgroundColor = .clear
webView.isOpaque = false

WebView(shared: SharedWebView(), horizontalSizeClass: nil)
.addNativeHelper(webView: webView)

let path = startPath()
context.coordinator.lastPath = path
load(path, into: webView)
return webView
}

func updateUIView(_ webView: WKWebView, context: Context) {
let path = startPath()
if context.coordinator.lastPath != path {
context.coordinator.lastPath = path
load(path, into: webView)
}
context.coordinator.navigatedCallbackId = node.props.getCallbackId("on_navigated")
context.coordinator.nodeId = node.id
}

/// `src` is an app route path in php mode; anything not starting with
/// `/` (including empty) falls back to the app's configured start URL.
private func startPath() -> String {
let src = node.props.getString("src")
return src.hasPrefix("/") ? src : NativePHPApp.getStartURL()
}

private func load(_ path: String, into webView: WKWebView) {
guard let url = URL(string: "php://127.0.0.1" + path) else { return }
webView.load(URLRequest(url: url))
}

final class Coordinator: NSObject, WKNavigationDelegate {
var navigatedCallbackId: Int
var nodeId: Int
var lastPath: String = ""

init(navigatedCallbackId: Int, nodeId: Int) {
self.navigatedCallbackId = navigatedCallbackId
self.nodeId = nodeId
}

func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}

let isTopFrame = navigationAction.targetFrame?.isMainFrame ?? true
if !isTopFrame {
decisionHandler(.allow)
return
}

switch url.scheme?.lowercased() {
case "php", "about", "data":
decisionHandler(.allow)
default:
// Links out of the app (https, mailto, tel, …) go to the
// system, mirroring the classic webview's behavior.
UIApplication.shared.open(url)
decisionHandler(.cancel)
}
}

func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
guard navigatedCallbackId != 0,
let url = webView.url?.absoluteString else { return }
NativeElementBridge.sendTextChangeEvent(navigatedCallbackId, nodeId: nodeId, text: url)
}
}
}

Expand Down
46 changes: 46 additions & 0 deletions src/Elements/Webview.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,55 @@ public function applyAttributes(array $attrs): void
);
}

if (isset($attrs['fullscreen'])) {
$this->fullscreen(filter_var($attrs['fullscreen'], FILTER_VALIDATE_BOOLEAN));
}

if (isset($attrs['php'])) {
$this->php(filter_var($attrs['php'], FILTER_VALIDATE_BOOLEAN));
}

// `_navigated` isn't in the collector's generic callback allowlist
// (that list only covers core events like `_press` / `_change`), so
// the `@navigated` Blade sugar lands here for us to wire ourselves.
if (isset($attrs['_navigated'])) {
$this->onNavigated((string) $attrs['_navigated']);
}

$this->applyA11yAttributes($attrs);
}

/**
* Fill the screen, v3-default-webview style: the element takes all
* available space and the renderers extend behind the safe areas.
*/
public function fullscreen(bool $value = true): static
{
$this->webviewProps['fullscreen'] = $value;

if ($value) {
$this->fill();
}

return $this;
}

/**
* Enriched mode: instead of a sandboxed foreign-content view, embed the
* app's own Laravel webview — served by the built-in PHP runtime with
* the full `window.Native` bridge, shared session, and asset pipeline.
* `src` becomes an app route path (`/dashboard`); when omitted, the
* app's configured start URL loads. The sandbox opt-ins (`javascript`,
* `dom-storage`) don't apply — the enriched webview needs both and the
* renderers force them on.
*/
public function php(bool $value = true): static
{
$this->webviewProps['php'] = $value;

return $this;
}

/**
* `@navigated="onUrlChange"` — fires once per top-frame URL load
* (committed navigation), with the resolved URL as the first arg.
Expand Down
Loading
Loading