From b6ddbb51105fd08ed3ae544db1f28a0d96df5fc4 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 18 Jul 2026 23:17:58 +0100 Subject: [PATCH] feat(mobile-ui): webview php + fullscreen modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` swaps the locked-down sandbox for the app's own enriched Laravel webview — pages served by the embedded PHP runtime with the full window.Native bridge, shared session store, and asset pipeline. `src` becomes an app route path; empty/relative falls back to the app's configured start URL. iOS builds an independent WKWebView wired like the shell's classic one (PHPSchemeHandler on php://, shared WebView.dataStore, addNativeHelper user scripts borrowed from the shell). Android runs the full WebViewManager.setup() wiring on a renderer-owned WebView, restoring the process-wide `shared` slot afterwards and wrapping the stock client so its "page load = web mode" isActive flip can't unmount the native tree this webview lives inside. `` gives the v3 default-webview presentation: fill layout from the PHP side plus ignoresSafeArea on iOS. Also fixes `@navigated` never firing: `_navigated` isn't in the collector's callback allowlist, so the element now wires it in applyAttributes itself. Co-Authored-By: Claude Fable 5 --- resources/android/WebviewRenderer.kt | 120 ++++++++++++++++++ resources/ios/NativeUIWebviewRenderer.swift | 132 +++++++++++++++++++- src/Elements/Webview.php | 46 +++++++ tests/WebviewTest.php | 108 ++++++++++++++++ 4 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 tests/WebviewTest.php diff --git a/resources/android/WebviewRenderer.kt b/resources/android/WebviewRenderer.kt index bc48482..baa2125 100644 --- a/resources/android/WebviewRenderer.kt +++ b/resources/android/WebviewRenderer.kt @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/resources/ios/NativeUIWebviewRenderer.swift b/resources/ios/NativeUIWebviewRenderer.swift index 089fc0d..03021af 100644 --- a/resources/ios/NativeUIWebviewRenderer.swift +++ b/resources/ios/NativeUIWebviewRenderer.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit @preconcurrency import WebKit /// Locked-down WKWebView primitive. @@ -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) + } } } diff --git a/src/Elements/Webview.php b/src/Elements/Webview.php index 85a8c80..f31f83e 100644 --- a/src/Elements/Webview.php +++ b/src/Elements/Webview.php @@ -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. diff --git a/tests/WebviewTest.php b/tests/WebviewTest.php new file mode 100644 index 0000000..c7e8dcb --- /dev/null +++ b/tests/WebviewTest.php @@ -0,0 +1,108 @@ + 'https://example.com', + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['type'])->toBe('webview'); + expect($tree['props']['src'])->toBe('https://example.com'); + expect($tree['props'])->not->toHaveKey('javascript'); + expect($tree['props'])->not->toHaveKey('dom_storage'); + expect($tree['props'])->not->toHaveKey('php'); + expect($tree['props'])->not->toHaveKey('fullscreen'); +}); + +it('applies sandbox opt-ins', function () { + NativeElementCollector::leaf('webview', [ + 'src' => 'https://example.com', + 'javascript' => true, + 'dom-storage' => true, + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['props']['javascript'])->toBeTrue(); + expect($tree['props']['dom_storage'])->toBeTrue(); +}); + +it('fullscreen sets the prop and fill layout', function () { + // Bare Blade attribute (``) arrives as bool true. + NativeElementCollector::leaf('webview', [ + 'src' => 'https://example.com', + 'fullscreen' => true, + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['props']['fullscreen'])->toBeTrue(); + expect($tree['layout']['width'])->toBe('fill'); + expect($tree['layout']['height'])->toBe('fill'); +}); + +it('fullscreen=false leaves layout alone', function () { + NativeElementCollector::leaf('webview', [ + 'src' => 'https://example.com', + 'fullscreen' => 'false', + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['props']['fullscreen'])->toBeFalse(); + expect($tree['layout']['width'] ?? null)->not->toBe('fill'); +}); + +it('php mode passes the flag and route path', function () { + NativeElementCollector::leaf('webview', [ + 'php' => true, + 'src' => '/dashboard', + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['props']['php'])->toBeTrue(); + expect($tree['props']['src'])->toBe('/dashboard'); +}); + +it('registers the @navigated callback', function () { + NativeElementCollector::leaf('webview', [ + 'src' => 'https://example.com', + '_navigated' => 'onUrlChange', + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['props']['on_navigated'])->toBeInt(); + expect($registry->resolve($tree['props']['on_navigated'])) + ->toBe(['method' => 'onUrlChange', 'args' => []]); +});