@sigx/lynx-webview 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # @sigx/lynx-webview
2
+
3
+ Native WebView component for sigx-lynx. `WKWebView` on iOS, `android.webkit.WebView` on Android.
4
+
5
+ Use for: OAuth fallback flows, embedded help / TOS / changelog pages, hybrid screens during an incremental migration to native.
6
+
7
+ > **Not Lynx's `<webview>`.** ByteDance's Lynx team has confirmed they ship a `<webview>` element internally, but it isn't open-sourced ([lynx-family/lynx#627](https://github.com/lynx-family/lynx/issues/627)). This package registers a sigx-flavored `<sigx-webview>` so apps can ship today.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @sigx/lynx-webview
13
+ ```
14
+
15
+ `sigx prebuild` auto-discovers the package and:
16
+ - iOS: emits a `GeneratedComponentRegistry.swift` call to `LynxConfig.registerUI(SigxWebViewUI.self, withName: "sigx-webview")` in `LynxSetupService.initialize`.
17
+ - Android: emits a `GeneratedBehaviors.attachAll(builder)` call alongside the existing `XElementBehaviors().create()` in `MainActivity.kt` and the dev-client path.
18
+
19
+ No app-side wiring required.
20
+
21
+ ## Usage
22
+
23
+ ```tsx
24
+ import { WebView } from '@sigx/lynx-webview';
25
+
26
+ <WebView
27
+ src="https://example.com"
28
+ onLoad={(e) => console.log('loaded', e.detail.url)}
29
+ onError={(e) => console.warn('failed', e.detail.message)}
30
+ onMessage={(e) => console.log('page said', e.detail.data)}
31
+ />
32
+ ```
33
+
34
+ Inline HTML works too — no network:
35
+
36
+ ```tsx
37
+ <WebView html="<h1>Hello from sigx-lynx</h1>" />
38
+ ```
39
+
40
+ ### Page → app messaging
41
+
42
+ The native side injects `window.sigx.postMessage(payload)` at document start. Any page rendered inside the WebView can call it; the payload arrives in JS as `event.detail.data` (always a string).
43
+
44
+ ```html
45
+ <button onclick="window.sigx.postMessage(JSON.stringify({ click: 'hi' }))">
46
+ Send
47
+ </button>
48
+ ```
49
+
50
+ ```tsx
51
+ <WebView
52
+ html={pageHtml}
53
+ onMessage={(e) => {
54
+ const data = JSON.parse(e.detail.data);
55
+ // data.click === 'hi'
56
+ }}
57
+ />
58
+ ```
59
+
60
+ ### Imperative methods (v2)
61
+
62
+ Two valid call patterns depending on which thread your handler runs on. **There is no one-size-fits-all helper** — `MainThread.Element` only lives on the MT side, and `SelectorQuery` only works from BG.
63
+
64
+ **Pattern A — from a main-thread tap handler (recommended for toolbar buttons).** Capture a `MainThreadRef`, then call `.invoke()` directly on `ref.current` inside a `'main thread'` handler. Bind the handler to a raw `<view main-thread:bindtap=…>` element — daisyui's `<Button>` only exposes an `onPress` callback (BG-thread) and silently drops `main-thread:bindtap`, so the tap would never reach the handler.
65
+
66
+ ```tsx
67
+ import { useMainThreadRef, type MainThread } from '@sigx/lynx';
68
+ import { WebView } from '@sigx/lynx-webview';
69
+
70
+ const ref = useMainThreadRef<MainThread.Element | null>(null);
71
+
72
+ const onBack = () => {
73
+ 'main thread';
74
+ ref.current?.invoke('goBack', {});
75
+ };
76
+ const onReload = () => {
77
+ 'main thread';
78
+ ref.current?.invoke('reload', {});
79
+ };
80
+
81
+ <WebView mtRef={ref} id="my-webview" src="https://example.com" />
82
+ <view main-thread:bindtap={onBack}><Text>‹ Back</Text></view>
83
+ <view main-thread:bindtap={onReload}><Text>↻ Reload</Text></view>
84
+ ```
85
+
86
+ The `'main thread'` directive compiles the handler into a separate main-thread bundle that can't reach cross-package imports — so you must call `.invoke()` directly, not via a JS wrapper from another package.
87
+
88
+ **Pattern B — from a BG-thread handler (e.g. daisyui `<Button onPress>`).** Use Lynx's `SelectorQuery` to dispatch by element id. Give the WebView an `id` and select by it.
89
+
90
+ ```tsx
91
+ import { Button } from '@sigx/lynx-daisyui';
92
+
93
+ <WebView id="my-webview" src="https://example.com" />
94
+ <Button onPress={() => {
95
+ lynx.createSelectorQuery().select('#my-webview').invoke({
96
+ method: 'reload',
97
+ params: {},
98
+ success: () => {},
99
+ fail: (e) => console.warn(e),
100
+ }).exec();
101
+ }}>
102
+ Reload
103
+ </Button>
104
+ ```
105
+
106
+ A typed wrapper that bundles this is exported as `WebViewMethods` for apps that prefer not to write the `SelectorQuery` boilerplate by hand — but note it only works when the relevant `MainThread.Element` is reachable (i.e. inside a `runOnMainThread` block, not from a bare `onPress`).
107
+
108
+ | Method | Signature | Notes |
109
+ |---|---|---|
110
+ | `goBack` | `(el)` → `void` | No-op when no back history. |
111
+ | `goForward` | `(el)` → `void` | No-op when no forward history. |
112
+ | `reload` | `(el)` → `void` | |
113
+ | `stopLoading` | `(el)` → `void` | |
114
+ | `canGoBack` | `(el)` → `Promise<boolean>` | Resolves `false` when `el` is null. |
115
+ | `canGoForward` | `(el)` → `Promise<boolean>` | Same. |
116
+ | `injectJavaScript` | `(el, code)` → `Promise<string>` | Last-expression value, stringified. Non-string results (numbers, dicts) coerce via `String(result)`. `null` / `undefined` → `""`. |
117
+ | `postMessage` | `(el, data)` → `void` | Delivers to `window.sigx.onmessage(data)` inside the page. Pages that haven't subscribed get a silent no-op. |
118
+
119
+ Host → page subscription happens in the page itself:
120
+
121
+ ```html
122
+ <script>
123
+ window.sigx.onmessage = function(data) {
124
+ console.log('from host', data);
125
+ };
126
+ </script>
127
+ ```
128
+
129
+ All `WebViewMethods.*` accept `el | null` and no-op when null, so you can pass `ref.current` straight through without an `if` guard.
130
+
131
+ The same methods are also reachable via Lynx's `SelectorQuery` API for apps that prefer ID-based dispatch:
132
+
133
+ ```ts
134
+ lynx.createSelectorQuery().select('#my-webview').invoke({
135
+ method: 'reload',
136
+ params: {},
137
+ success: () => {},
138
+ fail: (e) => console.warn(e),
139
+ }).exec();
140
+ ```
141
+
142
+ ## Props
143
+
144
+ | Prop | Type | Notes |
145
+ |---|---|---|
146
+ | `src` | `string` | URL to load. Mutually exclusive with `html`. |
147
+ | `html` | `string` | Inline HTML, no network. Base URL is `null` — relative URLs won't resolve. |
148
+ | `userAgent` | `string` | Override the WebView's User-Agent. |
149
+ | `debug` | `boolean` | iOS 16.4+: Safari Web Inspector via `WKWebView.isInspectable`. Android: `WebView.setWebContentsDebuggingEnabled(true)` — note this is **process-wide** on Android, not per-instance. |
150
+ | `class` / `style` | string / object | Standard Lynx layout. |
151
+ | `onLoad` | `(e) => void` | Main-frame navigation finished. `e.detail.url`. |
152
+ | `onError` | `(e) => void` | Main-frame load failed. `e.detail.{url, message}`. Subresource errors (favicon, image) are suppressed. |
153
+ | `onMessage` | `(e) => void` | Page called `window.sigx.postMessage(payload)`. `e.detail.data` is a string. |
154
+ | `mtRef` | `MainThreadRef<MainThread.Element \| null>` | Captures the underlying native element so you can drive the v2 imperative methods. |
155
+
156
+ ## Gotchas
157
+
158
+ - **`src` is restricted to `http(s):` and `about:blank`**. `javascript:` URLs are refused (logged + ignored) — they're the headline XSS vector for embedded WebViews. `file:` is refused too; Android also disables `allowFileAccess`/`allowContentAccess` + the legacy file-URL universal-access flags by default. Apps that need richer content should use the `html` prop (rendered with a null base URL → fully sandboxed).
159
+ - **iOS App Transport Security**: HTTPS-only by default in release builds. To load HTTP URLs you'd need to relax ATS in `Info.plist` (and accept the App Store review risk). Dev builds already allow LAN HTTP via the existing `NSAllowsLocalNetworking` flag.
160
+ - **Android `mixed-content`**: defaults block HTTP subresources on HTTPS pages. If you load a mixed-content page, set `webView.settings.mixedContentMode` manually — not currently exposed as a prop.
161
+ - **Cookies**: the WebView uses its own `WKWebsiteDataStore` (iOS) / `CookieManager` (Android). Cookies are **not** shared with the system Safari / Chrome.
162
+ - **No file uploads / downloads in v1**: file `<input type="file">` and `Content-Disposition: attachment` aren't wired through.
163
+ - **`debug` on Android is process-wide**: enabling it on any `<WebView>` instance also flips the flag for every other WebView in the app. Toggling off after enable doesn't detach already-attached `chrome://inspect` sessions.
164
+
165
+ ## Reference app
166
+
167
+ `examples/showcase/src/screens/TripGuide.tsx` is a real-app integration: each trip's destination ("Lisbon, May 2026" → `Lisbon`) drives a Wikivoyage URL loaded in a WebView, with an `onLoad` loading overlay, an `onError` retry surface, and a bottom toolbar wiring Back / Forward / Reload via `WebViewMethods` so the user can navigate Wikivoyage internal links without leaving the screen. Reachable from `TripDetail` via the `Guide` header button.
@@ -0,0 +1,19 @@
1
+ package com.sigx.webview
2
+
3
+ import com.lynx.tasm.behavior.Behavior
4
+ import com.lynx.tasm.behavior.LynxContext
5
+ import com.lynx.tasm.behavior.ui.LynxUI
6
+
7
+ /**
8
+ * Registers the `<sigx-webview>` JSX tag with Lynx's UI registry.
9
+ *
10
+ * Discovered by the autolinker via `signalx-module.json`'s `android.behaviors`
11
+ * field; the generated `GeneratedBehaviors.attachAll(builder)` calls
12
+ * `builder.addBehavior(SigxWebViewBehavior())` for every `LynxViewBuilder`
13
+ * in the app (production + dev-client path).
14
+ */
15
+ class SigxWebViewBehavior : Behavior("sigx-webview") {
16
+ override fun createUI(context: LynxContext): LynxUI<*> {
17
+ return SigxWebViewUI(context)
18
+ }
19
+ }
@@ -0,0 +1,24 @@
1
+ package com.sigx.webview
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.webkit.JavascriptInterface
6
+
7
+ /**
8
+ * JS bridge exposed to the embedded page as `window.sigxBridge`. Calls into
9
+ * [postMessage] arrive on the WebView's background JS thread; we hop to the
10
+ * main thread before firing the `bindmessage` event so [SigxWebViewUI.fireEvent]
11
+ * runs on the same thread Lynx expects.
12
+ */
13
+ class SigxWebViewJavascriptInterface(private val owner: SigxWebViewUI) {
14
+
15
+ private val mainHandler = Handler(Looper.getMainLooper())
16
+
17
+ @JavascriptInterface
18
+ fun postMessage(payload: String?) {
19
+ val data = payload ?: ""
20
+ mainHandler.post {
21
+ owner.fireEvent("message", mapOf("data" to data))
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,326 @@
1
+ package com.sigx.webview
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.Bitmap
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.webkit.WebResourceError
9
+ import android.webkit.WebResourceRequest
10
+ import android.webkit.WebView
11
+ import android.webkit.WebViewClient
12
+ import com.lynx.react.bridge.Callback
13
+ import com.lynx.react.bridge.JavaOnlyMap
14
+ import com.lynx.react.bridge.ReadableMap
15
+ import com.lynx.tasm.behavior.LynxContext
16
+ import com.lynx.tasm.behavior.LynxProp
17
+ import com.lynx.tasm.behavior.LynxUIMethod
18
+ import com.lynx.tasm.behavior.LynxUIMethodConstants
19
+ import com.lynx.tasm.behavior.ui.LynxUI
20
+ import com.lynx.tasm.event.LynxDetailEvent
21
+ import org.json.JSONArray
22
+
23
+ /**
24
+ * Native UI for the `<sigx-webview>` JSX element on Android.
25
+ *
26
+ * Prop / event surface (v1):
27
+ * - `src` → load a URL
28
+ * - `html` → load inline HTML
29
+ * - `user-agent` → custom UA string
30
+ * - `enable-debug` → `WebView.setWebContentsDebuggingEnabled` (process-wide)
31
+ * - `bindload` → page finished loading
32
+ * - `binderror` → load failed
33
+ * - `bindmessage` → page called `window.sigx.postMessage(payload)`
34
+ *
35
+ * Imperative methods (goBack / reload / postMessage from JS / injectJavaScript)
36
+ * are tracked as a v2 follow-up.
37
+ */
38
+ class SigxWebViewUI(context: LynxContext) : LynxUI<WebView>(context) {
39
+
40
+ companion object {
41
+ /** JS-side bridge name exposed to the page. Kept in sync with [bridgeUserScript]. */
42
+ private const val BRIDGE_NAME = "sigxBridge"
43
+
44
+ /**
45
+ * Injected at `WebViewClient.onPageStarted` so it survives navigations.
46
+ * Aliases `window.sigx.postMessage` to the `addJavascriptInterface`
47
+ * bridge so authors get the same `window.sigx.postMessage('hi')` shape
48
+ * as iOS. Also exposes `window.sigxBridge.dispatchMessage` — the host
49
+ * calls this from `evaluateJavascript` to deliver host → page
50
+ * messages, which forwards to whatever the page registered as
51
+ * `window.sigx.onmessage`.
52
+ */
53
+ private val bridgeUserScript = """
54
+ (function() {
55
+ var bridge = window.${BRIDGE_NAME};
56
+ if (!bridge) return;
57
+ window.sigx = window.sigx || {};
58
+ if (!window.sigx.postMessage) {
59
+ window.sigx.postMessage = function(payload) {
60
+ try { bridge.postMessage(typeof payload === 'string' ? payload : JSON.stringify(payload)); }
61
+ catch (e) { /* swallowed */ }
62
+ };
63
+ }
64
+ // Host → page delivery slot. No-op until the page subscribes
65
+ // by setting window.sigx.onmessage; handler exceptions are
66
+ // swallowed so a bad page can't break the bridge.
67
+ bridge.dispatchMessage = function(payload) {
68
+ try {
69
+ var fn = window.sigx && window.sigx.onmessage;
70
+ if (typeof fn === 'function') fn(payload);
71
+ } catch (e) { /* swallowed */ }
72
+ };
73
+ })();
74
+ """.trimIndent()
75
+ }
76
+
77
+ @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
78
+ override fun createView(context: Context): WebView {
79
+ val webView = WebView(context)
80
+ webView.settings.apply {
81
+ javaScriptEnabled = true
82
+ domStorageEnabled = true
83
+ // Hardened defaults — the platform's pre-API-30 defaults for the
84
+ // four flags below were `true`, which lets a malicious page loaded
85
+ // via `file://` read arbitrary files and cross-origin URLs. Apps
86
+ // that genuinely need local-file content can override these on a
87
+ // forked component; we deliberately don't expose them as v1 props.
88
+ allowFileAccess = false
89
+ allowContentAccess = false
90
+ @Suppress("DEPRECATION")
91
+ allowFileAccessFromFileURLs = false
92
+ @Suppress("DEPRECATION")
93
+ allowUniversalAccessFromFileURLs = false
94
+ }
95
+ webView.webViewClient = object : WebViewClient() {
96
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
97
+ super.onPageStarted(view, url, favicon)
98
+ // Inject the alias at start so the page can call
99
+ // window.sigx.postMessage from inline / early scripts. The
100
+ // underlying addJavascriptInterface bridge is always present.
101
+ view?.evaluateJavascript(bridgeUserScript, null)
102
+ }
103
+
104
+ override fun onPageFinished(view: WebView?, url: String?) {
105
+ super.onPageFinished(view, url)
106
+ fireEvent("load", mapOf("url" to (url ?: "")))
107
+ }
108
+
109
+ override fun onReceivedError(
110
+ view: WebView?,
111
+ request: WebResourceRequest?,
112
+ error: WebResourceError?,
113
+ ) {
114
+ super.onReceivedError(view, request, error)
115
+ // Only surface main-frame failures to JS — subresource errors
116
+ // (a missing favicon, a CDN image 404) shouldn't fire as a
117
+ // top-level binderror.
118
+ if (request?.isForMainFrame != true) return
119
+ fireEvent(
120
+ "error",
121
+ mapOf(
122
+ "url" to (request.url?.toString() ?: ""),
123
+ "message" to (error?.description?.toString() ?: "unknown error"),
124
+ ),
125
+ )
126
+ }
127
+ }
128
+ webView.addJavascriptInterface(SigxWebViewJavascriptInterface(this), BRIDGE_NAME)
129
+ return webView
130
+ }
131
+
132
+ // ── Prop setters ─────────────────────────────────────────────────────
133
+
134
+ @LynxProp(name = "src")
135
+ fun setSrc(value: String?) {
136
+ if (value.isNullOrEmpty()) return
137
+ if (!isSchemeAllowed(value)) {
138
+ android.util.Log.w(
139
+ "SigxWebView",
140
+ "Rejected src with unsupported scheme: ${value.take(32)}…",
141
+ )
142
+ return
143
+ }
144
+ mView.loadUrl(value)
145
+ }
146
+
147
+ @LynxProp(name = "html")
148
+ fun setHtml(value: String?) {
149
+ if (value == null) return
150
+ // baseURL=null intentionally — pages loaded via loadDataWithBaseURL
151
+ // with a `file://` base could read local files in older Android
152
+ // versions; a null base keeps the content fully sandboxed.
153
+ mView.loadDataWithBaseURL(null, value, "text/html", "UTF-8", null)
154
+ }
155
+
156
+ @LynxProp(name = "user-agent")
157
+ fun setUserAgent(value: String?) {
158
+ // WebSettings.setUserAgentString(null) restores the platform default
159
+ // — that's fine when the prop is reset, but we still null-guard for
160
+ // clarity and to dodge the (rare) NPE Copilot flagged on older
161
+ // WebView implementations.
162
+ val ua = value ?: return
163
+ mView.settings.userAgentString = ua
164
+ }
165
+
166
+ @LynxProp(name = "enable-debug")
167
+ fun setEnableDebug(value: Boolean) {
168
+ // Note: setWebContentsDebuggingEnabled is process-wide on Android, not
169
+ // per-instance. Once enabled by any WebView, chrome://inspect picks it
170
+ // up. Disabling is a no-op for already-attached debuggers.
171
+ WebView.setWebContentsDebuggingEnabled(value)
172
+ }
173
+
174
+ // ── Imperative methods ───────────────────────────────────────────────
175
+ //
176
+ // Each `@LynxUIMethod` block is discovered by Lynx's annotation processor
177
+ // (`com.lynx.tasm.behavior.LynxUIMethodsProcessor`) and routed via
178
+ // `__InvokeUIMethod`. Status codes come from `LynxUIMethodConstants`:
179
+ // `SUCCESS = 0`, `UNKNOWN = 1`. WebView methods MUST run on the UI
180
+ // thread — Lynx's invoke dispatch is already main-thread on Android but
181
+ // we hop explicitly to keep this readable.
182
+
183
+ private val mainHandler = Handler(Looper.getMainLooper())
184
+
185
+ @LynxUIMethod
186
+ fun goBack(params: ReadableMap?, callback: Callback?) {
187
+ mainHandler.post {
188
+ if (mView.canGoBack()) mView.goBack()
189
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
190
+ }
191
+ }
192
+
193
+ @LynxUIMethod
194
+ fun goForward(params: ReadableMap?, callback: Callback?) {
195
+ mainHandler.post {
196
+ if (mView.canGoForward()) mView.goForward()
197
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
198
+ }
199
+ }
200
+
201
+ @LynxUIMethod
202
+ fun reload(params: ReadableMap?, callback: Callback?) {
203
+ mainHandler.post {
204
+ mView.reload()
205
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
206
+ }
207
+ }
208
+
209
+ @LynxUIMethod
210
+ fun stopLoading(params: ReadableMap?, callback: Callback?) {
211
+ mainHandler.post {
212
+ mView.stopLoading()
213
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
214
+ }
215
+ }
216
+
217
+ @LynxUIMethod
218
+ fun canGoBack(params: ReadableMap?, callback: Callback?) {
219
+ mainHandler.post {
220
+ val result = JavaOnlyMap().apply { putBoolean("value", mView.canGoBack()) }
221
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, result)
222
+ }
223
+ }
224
+
225
+ @LynxUIMethod
226
+ fun canGoForward(params: ReadableMap?, callback: Callback?) {
227
+ mainHandler.post {
228
+ val result = JavaOnlyMap().apply { putBoolean("value", mView.canGoForward()) }
229
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, result)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Evaluate JS in the page; return the last-expression value, stringified.
235
+ * Non-string results (numbers, dicts, undefined) land as their
236
+ * `toString()` so the wire shape matches iOS exactly.
237
+ */
238
+ @LynxUIMethod
239
+ fun injectJavaScript(params: ReadableMap?, callback: Callback?) {
240
+ val code = params?.getString("code").orEmpty()
241
+ if (code.isEmpty()) {
242
+ callback?.invoke(LynxUIMethodConstants.UNKNOWN, "injectJavaScript: missing `code` param")
243
+ return
244
+ }
245
+ mainHandler.post {
246
+ mView.evaluateJavascript(code) { result ->
247
+ // Android wraps results in JSON-encoded quotes for strings;
248
+ // strip them for parity with iOS's plain string form.
249
+ val str = result?.let { stripJsonQuotes(it) } ?: ""
250
+ val payload = JavaOnlyMap().apply { putString("result", str) }
251
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, payload)
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Host → page message. The bridge user-script's `dispatchMessage` helper
258
+ * forwards to whatever handler the page registered as
259
+ * `window.sigx.onmessage`. JSON-encoding via `JSONArray` puts the
260
+ * payload through the same escape rules the engine uses, so embedded
261
+ * quotes / newlines round-trip cleanly into the JS source.
262
+ */
263
+ @LynxUIMethod
264
+ fun postMessage(params: ReadableMap?, callback: Callback?) {
265
+ val data = params?.getString("data").orEmpty()
266
+ val arrayLiteral = JSONArray().put(data).toString() // e.g. `["hi"]`
267
+ val jsLiteral = arrayLiteral.substring(1, arrayLiteral.length - 1)
268
+ val js = "window.sigxBridge && window.sigxBridge.dispatchMessage && window.sigxBridge.dispatchMessage($jsLiteral);"
269
+ mainHandler.post {
270
+ mView.evaluateJavascript(js) { _ ->
271
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
272
+ }
273
+ }
274
+ }
275
+
276
+ private fun stripJsonQuotes(raw: String): String {
277
+ // Normalise JS `null` / `undefined` → empty string for parity with
278
+ // the documented contract + the iOS implementation. Android's
279
+ // `evaluateJavascript` returns:
280
+ // - "null" for JS `null` AND JS `undefined`
281
+ // - the JSON-encoded value otherwise
282
+ // (a JS string `"hello"` arrives as the 6-char string `"hello"`).
283
+ // We strip the JSON quotes for the string case and treat nullish
284
+ // literals as empty strings — same wire shape as iOS.
285
+ if (raw == "null" || raw == "undefined" || raw.isEmpty()) return ""
286
+ if (raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) {
287
+ // The simplest safe path — parse as a 1-element JSON array so
288
+ // escape sequences (`\\n`, `\\u00ff`, etc.) decode properly
289
+ // instead of leaking into the wire.
290
+ return try {
291
+ JSONArray("[$raw]").getString(0)
292
+ } catch (_: Throwable) {
293
+ raw.substring(1, raw.length - 1)
294
+ }
295
+ }
296
+ return raw
297
+ }
298
+
299
+ // ── Event firing ─────────────────────────────────────────────────────
300
+
301
+ internal fun fireEvent(name: String, params: Map<String, Any?>) {
302
+ val event = LynxDetailEvent(sign, name)
303
+ for ((k, v) in params) {
304
+ event.addDetail(k, v)
305
+ }
306
+ lynxContext.eventEmitter.sendCustomEvent(event)
307
+ }
308
+
309
+ /**
310
+ * Allow only `http(s)` and `about:` for the `src` prop. `javascript:` is
311
+ * the headline XSS vector — a malicious server could redirect to it and
312
+ * execute arbitrary JS in the WebView's renderer. `file:` is rejected
313
+ * because we've disabled file access on the settings anyway, but the
314
+ * scheme check is defense-in-depth in case a future maintainer flips
315
+ * one back. Apps that genuinely need other schemes can use the `html`
316
+ * prop (which is fully sandboxed) instead.
317
+ */
318
+ private fun isSchemeAllowed(url: String): Boolean {
319
+ val trimmed = url.trim()
320
+ if (trimmed.equals("about:blank", ignoreCase = true)) return true
321
+ val colon = trimmed.indexOf(':')
322
+ if (colon <= 0) return false
323
+ val scheme = trimmed.substring(0, colon).lowercase()
324
+ return scheme == "http" || scheme == "https"
325
+ }
326
+ }
@@ -0,0 +1,45 @@
1
+ import { type Define, type MainThread, type MainThreadRef } from '@sigx/lynx';
2
+ import './jsx-augment.js';
3
+ import type { WebViewErrorEvent, WebViewLoadEvent, WebViewMessageEvent } from './jsx-augment.js';
4
+ /**
5
+ * Ref shape consumers pass via `mtRef` to capture the underlying native
6
+ * element. The current element handle is `ref.current` inside main-thread
7
+ * event handlers — pass it through `WebViewMethods.*` for typed access to
8
+ * `goBack`, `reload`, `injectJavaScript`, etc.
9
+ */
10
+ export type WebViewRef = MainThreadRef<MainThread.Element | null>;
11
+ export type WebViewProps = Define.Prop<'src', string, false> & Define.Prop<'html', string, false> & Define.Prop<'userAgent', string, false> & Define.Prop<'debug', boolean, false> & Define.Prop<'class', string, false> & Define.Prop<'style', string | Record<string, string | number>, false> & Define.Prop<'mtRef', WebViewRef, false> & Define.Prop<'onLoad', (e: WebViewLoadEvent) => void, false> & Define.Prop<'onError', (e: WebViewErrorEvent) => void, false> & Define.Prop<'onMessage', (e: WebViewMessageEvent) => void, false>;
12
+ /**
13
+ * Native WebView component.
14
+ *
15
+ * On iOS this wraps a `WKWebView`; on Android, `android.webkit.WebView`.
16
+ *
17
+ * - Page → host: `window.sigx.postMessage(payload)` inside the page surfaces
18
+ * on `onMessage` as `event.detail.data` (always a string).
19
+ * - Host → page: pass `mtRef` and call `WebViewMethods.postMessage(ref.current, data)`
20
+ * from a main-thread event handler. The page subscribes by setting
21
+ * `window.sigx.onmessage = (data) => { … }`.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * import { useMainThreadRef, type MainThread } from '@sigx/lynx';
26
+ * import { WebView, WebViewMethods } from '@sigx/lynx-webview';
27
+ *
28
+ * const ref = useMainThreadRef<MainThread.Element | null>(null);
29
+ * const onBack = () => { 'main thread'; WebViewMethods.goBack(ref.current); };
30
+ *
31
+ * <WebView mtRef={ref} src="https://example.com" />
32
+ * <Button onPress={onBack}>Back</Button>
33
+ * ```
34
+ */
35
+ export declare const WebView: import("@sigx/runtime-core").ComponentFactory<WebViewProps, void, {}>;
36
+ export declare const WebViewMethods: {
37
+ readonly goBack: (el: MainThread.Element | null) => void;
38
+ readonly goForward: (el: MainThread.Element | null) => void;
39
+ readonly reload: (el: MainThread.Element | null) => void;
40
+ readonly stopLoading: (el: MainThread.Element | null) => void;
41
+ readonly canGoBack: (el: MainThread.Element | null) => Promise<boolean>;
42
+ readonly canGoForward: (el: MainThread.Element | null) => Promise<boolean>;
43
+ readonly injectJavaScript: (el: MainThread.Element | null, code: string) => Promise<string>;
44
+ readonly postMessage: (el: MainThread.Element | null, data: string) => void;
45
+ };
@@ -0,0 +1,109 @@
1
+ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
2
+ import { component } from '@sigx/lynx';
3
+ import './jsx-augment.js';
4
+ /**
5
+ * Native WebView component.
6
+ *
7
+ * On iOS this wraps a `WKWebView`; on Android, `android.webkit.WebView`.
8
+ *
9
+ * - Page → host: `window.sigx.postMessage(payload)` inside the page surfaces
10
+ * on `onMessage` as `event.detail.data` (always a string).
11
+ * - Host → page: pass `mtRef` and call `WebViewMethods.postMessage(ref.current, data)`
12
+ * from a main-thread event handler. The page subscribes by setting
13
+ * `window.sigx.onmessage = (data) => { … }`.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * import { useMainThreadRef, type MainThread } from '@sigx/lynx';
18
+ * import { WebView, WebViewMethods } from '@sigx/lynx-webview';
19
+ *
20
+ * const ref = useMainThreadRef<MainThread.Element | null>(null);
21
+ * const onBack = () => { 'main thread'; WebViewMethods.goBack(ref.current); };
22
+ *
23
+ * <WebView mtRef={ref} src="https://example.com" />
24
+ * <Button onPress={onBack}>Back</Button>
25
+ * ```
26
+ */
27
+ export const WebView = component(({ props }) => {
28
+ return () => (_jsx("sigx-webview", { src: props.src, html: props.html, "user-agent": props.userAgent, "enable-debug": props.debug, class: props.class, style: props.style, "main-thread:ref": props.mtRef, bindload: props.onLoad, binderror: props.onError, bindmessage: props.onMessage }));
29
+ });
30
+ /**
31
+ * Typed wrappers around `MainThread.Element.invoke(method, params)` for each
32
+ * v2 imperative method. Lives outside the component so it's directly callable
33
+ * from any main-thread handler without dragging the component closure in.
34
+ *
35
+ * All methods accept `el | null` so call sites can pass `ref.current` directly
36
+ * without nesting an `if`. When `el` is null the call is a no-op (returns
37
+ * `void` synchronously or `Promise<void>`/default value asynchronously).
38
+ *
39
+ * Method semantics mirror the iOS / Android native implementations:
40
+ *
41
+ * - `goBack` / `goForward` are no-ops when there's no history.
42
+ * - `reload` always succeeds even on the current document.
43
+ * - `canGoBack` / `canGoForward` resolve with `false` if the element is
44
+ * gone.
45
+ * - `injectJavaScript` returns the last-expression value stringified.
46
+ * `null` / `undefined` results land as `""`.
47
+ * - `postMessage` delivers to `window.sigx.onmessage(data)` inside the
48
+ * page; pages that haven't subscribed get a silent no-op.
49
+ */
50
+ // `invoke()` returns a Promise that can reject when the underlying UI method
51
+ // rejects (native error, method missing on a stale element, …). For the
52
+ // fire-and-forget wrappers below, swallow the rejection so callers don't
53
+ // have to wrap every tap handler in try/catch and don't accumulate unhandled
54
+ // rejection warnings. For the async getters, catch + fall back to the
55
+ // documented default value (`false` / `""`) so the failure mode matches
56
+ // the el-is-null mode — callers only have to handle one shape.
57
+ function fireAndForget(p) {
58
+ p?.catch(() => { });
59
+ }
60
+ export const WebViewMethods = {
61
+ goBack(el) {
62
+ fireAndForget(el?.invoke('goBack', {}));
63
+ },
64
+ goForward(el) {
65
+ fireAndForget(el?.invoke('goForward', {}));
66
+ },
67
+ reload(el) {
68
+ fireAndForget(el?.invoke('reload', {}));
69
+ },
70
+ stopLoading(el) {
71
+ fireAndForget(el?.invoke('stopLoading', {}));
72
+ },
73
+ async canGoBack(el) {
74
+ if (!el)
75
+ return false;
76
+ try {
77
+ const r = await el.invoke('canGoBack', {});
78
+ return r?.value ?? false;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ },
84
+ async canGoForward(el) {
85
+ if (!el)
86
+ return false;
87
+ try {
88
+ const r = await el.invoke('canGoForward', {});
89
+ return r?.value ?? false;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ },
95
+ async injectJavaScript(el, code) {
96
+ if (!el)
97
+ return '';
98
+ try {
99
+ const r = await el.invoke('injectJavaScript', { code });
100
+ return r?.result ?? '';
101
+ }
102
+ catch {
103
+ return '';
104
+ }
105
+ },
106
+ postMessage(el, data) {
107
+ fireAndForget(el?.invoke('postMessage', { data }));
108
+ },
109
+ };
@@ -0,0 +1,4 @@
1
+ import './jsx-augment.js';
2
+ export { WebView, WebViewMethods } from './WebView.js';
3
+ export type { WebViewProps, WebViewRef } from './WebView.js';
4
+ export type { SigxWebViewAttributes, WebViewLoadEvent, WebViewLoadEventDetail, WebViewErrorEvent, WebViewErrorEventDetail, WebViewMessageEvent, WebViewMessageEventDetail, } from './jsx-augment.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import './jsx-augment.js';
2
+ export { WebView, WebViewMethods } from './WebView.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * JSX intrinsic type augmentation for `<sigx-webview>`.
3
+ *
4
+ * Importing this module registers `'sigx-webview'` as a valid JSX intrinsic
5
+ * with the prop + event surface implemented by `SigxWebViewUI` (iOS) and
6
+ * `SigxWebViewUI.kt` (Android). Pulled in automatically by
7
+ * `@sigx/lynx-webview`'s entry point so consumers do not need to import it
8
+ * directly.
9
+ *
10
+ * Element availability requires `sigx prebuild` to have run after adding
11
+ * this package as a dependency — the autolinker emits the `LynxConfig`
12
+ * registration (iOS) and `Behavior` attachment (Android) that bind the tag
13
+ * to the native UI class.
14
+ */
15
+ import type { LynxCommonAttributes, LynxEventHandler } from '@sigx/lynx-runtime';
16
+ export interface WebViewLoadEventDetail {
17
+ url: string;
18
+ [k: string]: unknown;
19
+ }
20
+ export interface WebViewLoadEvent {
21
+ type: 'load';
22
+ detail: WebViewLoadEventDetail;
23
+ }
24
+ export interface WebViewErrorEventDetail {
25
+ url: string;
26
+ message: string;
27
+ [k: string]: unknown;
28
+ }
29
+ export interface WebViewErrorEvent {
30
+ type: 'error';
31
+ detail: WebViewErrorEventDetail;
32
+ }
33
+ export interface WebViewMessageEventDetail {
34
+ /**
35
+ * Payload the page sent via `window.sigx.postMessage(payload)`. Always a
36
+ * string on the wire — apps that send JSON should `JSON.parse` here.
37
+ */
38
+ data: string;
39
+ [k: string]: unknown;
40
+ }
41
+ export interface WebViewMessageEvent {
42
+ type: 'message';
43
+ detail: WebViewMessageEventDetail;
44
+ }
45
+ export interface SigxWebViewAttributes extends LynxCommonAttributes {
46
+ /** URL to load. Setting both `src` and `html` is undefined behavior — pick one. */
47
+ src?: string;
48
+ /** Inline HTML to render (no network fetch). */
49
+ html?: string;
50
+ /** Override the WebView's User-Agent string. */
51
+ 'user-agent'?: string;
52
+ /**
53
+ * Enable platform debugging — Safari Web Inspector on iOS 16.4+,
54
+ * `chrome://inspect` on Android. Note: Android's flag is **process-wide**.
55
+ */
56
+ 'enable-debug'?: boolean;
57
+ /** Fires once the main-frame navigation finishes. */
58
+ bindload?: LynxEventHandler<WebViewLoadEvent>;
59
+ /** Fires on main-frame load failure. */
60
+ binderror?: LynxEventHandler<WebViewErrorEvent>;
61
+ /** Fires when the page calls `window.sigx.postMessage(payload)`. */
62
+ bindmessage?: LynxEventHandler<WebViewMessageEvent>;
63
+ }
64
+ declare global {
65
+ namespace JSX {
66
+ interface IntrinsicElements {
67
+ 'sigx-webview': SigxWebViewAttributes;
68
+ }
69
+ }
70
+ }
71
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,390 @@
1
+ import Foundation
2
+ import UIKit
3
+ import WebKit
4
+ import Lynx
5
+
6
+ /// Native UI for the `<sigx-webview>` JSX element.
7
+ ///
8
+ /// Registered via the autolinker — `signalx-module.json`'s `ios.uiComponents`
9
+ /// produces a `config.registerUI(SigxWebViewUI.self, withName: "sigx-webview")`
10
+ /// call in the generated `GeneratedComponentRegistry.swift`.
11
+ ///
12
+ /// Prop / event surface (v1):
13
+ /// - `src` → load a URL
14
+ /// - `html` → load inline HTML
15
+ /// - `user-agent` → custom UA string
16
+ /// - `enable-debug` → Web Inspector (iOS 16.4+ also flips `isInspectable`)
17
+ /// - `bindload` → page finished loading
18
+ /// - `binderror` → load failed
19
+ /// - `bindmessage` → page called `window.sigx.postMessage(payload)`
20
+ ///
21
+ /// Imperative methods (goBack / reload / postMessage from JS / injectJavaScript)
22
+ /// are tracked as a v2 follow-up — they need the Lynx UIMethodInvoker surface.
23
+ // Class is NOT marked `@objc` — Swift forbids that on generic subclasses
24
+ // (`LynxUI<__covariant V>` is an ObjC lightweight generic, so this is one).
25
+ // Member-level `@objc` / `@objc(name)` annotations still bridge because
26
+ // `LynxUI` itself is `@objc`, so the inherited ObjC machinery picks up
27
+ // `__lynx_prop_config__*` and `__lynx_ui_method_config__*` class methods
28
+ // as expected by `LynxPropsProcessor` / `LynxUIMethodProcessor`.
29
+ public class SigxWebViewUI: LynxUI<WKWebView> {
30
+
31
+ /// `WKUserContentController` handler name. On the page side this is
32
+ /// reached via `window.webkit.messageHandlers.sigxBridge.postMessage(...)`
33
+ /// (WKWebView's standard surface). The user script injected by this
34
+ /// component then aliases the friendlier `window.sigx.postMessage(...)`
35
+ /// on top of it so authors get the same API as on Android. Keep in
36
+ /// sync with `bridgeUserScript`.
37
+ private static let bridgeName = "sigxBridge"
38
+
39
+ private lazy var navigationDelegate = SigxWebViewNavigationDelegate(owner: self)
40
+ private lazy var scriptMessageHandler = SigxWebViewScriptMessageHandler(owner: self)
41
+
42
+ // MARK: - LynxUI overrides
43
+
44
+ public override func createView() -> WKWebView? {
45
+ let config = WKWebViewConfiguration()
46
+ let controller = WKUserContentController()
47
+ controller.add(scriptMessageHandler, name: SigxWebViewUI.bridgeName)
48
+ controller.addUserScript(WKUserScript(
49
+ source: SigxWebViewUI.bridgeUserScript,
50
+ injectionTime: .atDocumentStart,
51
+ forMainFrameOnly: false
52
+ ))
53
+ config.userContentController = controller
54
+
55
+ // Start with a non-zero placeholder frame so WKWebView's internal
56
+ // layout engine doesn't sit in a "no size, no render" steady state.
57
+ // Lynx's `updateFrame` overrides this immediately with the real
58
+ // layout dims; the placeholder is just to bootstrap WKWebView's
59
+ // initial render pipeline.
60
+ let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
61
+ webView.navigationDelegate = navigationDelegate
62
+ // Disable the autoresizing-mask translation so when Lynx sets a new
63
+ // frame, WKWebView re-lays its WKContentView at the new size.
64
+ // Without this, WKWebView sometimes pins its internal content view
65
+ // to the initial frame and ignores subsequent frame changes from
66
+ // outside the iOS auto-layout system.
67
+ webView.translatesAutoresizingMaskIntoConstraints = true
68
+ webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
69
+ return webView
70
+ }
71
+
72
+ // Explicit prop-setter registration. The runtime prefers this path
73
+ // over the per-method `__lynx_prop_config__*` discovery — see
74
+ // `LynxPropsProcessor.mm:206-211`. Returning a single `NSArray` of
75
+ // `[propName, fullSelector]` pairs is more robust than relying on
76
+ // Swift `@objc(name)` class methods being enumerable by
77
+ // `class_copyMethodList(metaclass)` (which has subtle quirks for
78
+ // Swift-defined classes that don't carry a class-level `@objc`).
79
+ @objc public class func propSetterLookUp() -> NSArray {
80
+ return [
81
+ ["src", "setSrc:requestReset:"],
82
+ ["html", "setHtml:requestReset:"],
83
+ ["user-agent", "setUserAgent:requestReset:"],
84
+ ["enable-debug", "setEnableDebug:requestReset:"],
85
+ ] as NSArray
86
+ }
87
+
88
+ // MARK: - Prop setters
89
+ //
90
+ // Each `@objc(...)` selector matches the runtime's `__lynx_prop_config__*`
91
+ // discovery convention from `LynxPropsProcessor.h`. The corresponding
92
+ // setter follows the macro shape:
93
+ // -(void)set<Name>:(type)value requestReset:(BOOL)requestReset
94
+ // which Swift emits naturally for `@objc func setX(_:requestReset:)`.
95
+
96
+ @objc public func setSrc(_ value: NSString?, requestReset: Bool) {
97
+ guard let raw = value as String?, !raw.isEmpty, let url = URL(string: raw) else { return }
98
+ guard SigxWebViewUI.isSchemeAllowed(url) else {
99
+ NSLog("[SigxWebView] Rejected src with unsupported scheme: \(raw.prefix(32))")
100
+ return
101
+ }
102
+ view().load(URLRequest(url: url))
103
+ }
104
+
105
+ @objc(__lynx_prop_config__src)
106
+ public class func __lynxPropConfigSrc() -> [String] {
107
+ return ["src", "setSrc", "NSString *"]
108
+ }
109
+
110
+ /// Allow only `http(s)` and `about:blank`. `javascript:` is the headline
111
+ /// XSS vector — a redirect from a remote page could execute arbitrary JS
112
+ /// in the WebView's context. `file:` is rejected so the embedded page
113
+ /// can't read app-bundle resources. Apps that need other schemes can
114
+ /// use the `html` prop (no base URL → no file access) instead.
115
+ static func isSchemeAllowed(_ url: URL) -> Bool {
116
+ if url.absoluteString.caseInsensitiveCompare("about:blank") == .orderedSame {
117
+ return true
118
+ }
119
+ guard let scheme = url.scheme?.lowercased() else { return false }
120
+ return scheme == "http" || scheme == "https"
121
+ }
122
+
123
+ @objc public func setHtml(_ value: NSString?, requestReset: Bool) {
124
+ guard let raw = value as String? else { return }
125
+ view().loadHTMLString(raw, baseURL: nil)
126
+ }
127
+
128
+ @objc(__lynx_prop_config__html)
129
+ public class func __lynxPropConfigHtml() -> [String] {
130
+ return ["html", "setHtml", "NSString *"]
131
+ }
132
+
133
+ @objc public func setUserAgent(_ value: NSString?, requestReset: Bool) {
134
+ // `view()` is lazily built — calling it from a prop setter is safe
135
+ // because LynxUI has already created the underlying view by the
136
+ // time the runtime hands us prop values. No "view not built yet"
137
+ // branch needed.
138
+ let ua = (value as String?) ?? ""
139
+ view().customUserAgent = ua.isEmpty ? nil : ua
140
+ }
141
+
142
+ @objc(__lynx_prop_config__user_agent)
143
+ public class func __lynxPropConfigUserAgent() -> [String] {
144
+ return ["user-agent", "setUserAgent", "NSString *"]
145
+ }
146
+
147
+ @objc public func setEnableDebug(_ value: Bool, requestReset: Bool) {
148
+ // iOS 16.4+ exposes `isInspectable` for the Safari Web Inspector. On
149
+ // older iOS, the WebKit-private `developerExtrasEnabled` preference is
150
+ // gated behind App-Store-reject risk; we only flip the public flag.
151
+ if #available(iOS 16.4, *) {
152
+ view().isInspectable = value
153
+ }
154
+ }
155
+
156
+ @objc(__lynx_prop_config__enable_debug)
157
+ public class func __lynxPropConfigEnableDebug() -> [String] {
158
+ return ["enable-debug", "setEnableDebug", "BOOL"]
159
+ }
160
+
161
+ // MARK: - Imperative methods
162
+ //
163
+ // Each method is declared the same way as v1's prop setters: a Swift
164
+ // instance method matching the macro-generated `<name>:withResult:`
165
+ // signature plus a `@objc(__lynx_ui_method_config__<name>)` class method
166
+ // returning the method name as an NSString. Lynx's
167
+ // `LynxUIMethodProcessor` introspects every class method whose selector
168
+ // begins with `__lynx_ui_method_config__` to build its dispatch table
169
+ // (see `Pods/Lynx/.../LynxUIMethodProcessor.h`).
170
+ //
171
+ // Status codes: `kUIMethodSuccess = 0`, `kUIMethodUnknown = 1`. Defined
172
+ // as raw Int32 here so we don't have to import the C enum — the values
173
+ // are stable across Lynx 3.x.
174
+
175
+ private static let kUIMethodSuccess: Int32 = 0
176
+ private static let kUIMethodUnknown: Int32 = 1
177
+
178
+ @objc public func goBack(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
179
+ DispatchQueue.main.async {
180
+ if self.view().canGoBack { self.view().goBack() }
181
+ callback(SigxWebViewUI.kUIMethodSuccess, nil)
182
+ }
183
+ }
184
+ @objc(__lynx_ui_method_config__goBack)
185
+ dynamic public class func __lynxUIMethodConfigGoBack() -> NSString { return "goBack" }
186
+
187
+ @objc public func goForward(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
188
+ DispatchQueue.main.async {
189
+ if self.view().canGoForward { self.view().goForward() }
190
+ callback(SigxWebViewUI.kUIMethodSuccess, nil)
191
+ }
192
+ }
193
+ @objc(__lynx_ui_method_config__goForward)
194
+ dynamic public class func __lynxUIMethodConfigGoForward() -> NSString { return "goForward" }
195
+
196
+ @objc public func reload(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
197
+ DispatchQueue.main.async {
198
+ self.view().reload()
199
+ callback(SigxWebViewUI.kUIMethodSuccess, nil)
200
+ }
201
+ }
202
+ @objc(__lynx_ui_method_config__reload)
203
+ dynamic public class func __lynxUIMethodConfigReload() -> NSString { return "reload" }
204
+
205
+ @objc public func stopLoading(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
206
+ DispatchQueue.main.async {
207
+ self.view().stopLoading()
208
+ callback(SigxWebViewUI.kUIMethodSuccess, nil)
209
+ }
210
+ }
211
+ @objc(__lynx_ui_method_config__stopLoading)
212
+ dynamic public class func __lynxUIMethodConfigStopLoading() -> NSString { return "stopLoading" }
213
+
214
+ @objc public func canGoBack(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
215
+ DispatchQueue.main.async {
216
+ callback(SigxWebViewUI.kUIMethodSuccess, ["value": self.view().canGoBack])
217
+ }
218
+ }
219
+ @objc(__lynx_ui_method_config__canGoBack)
220
+ dynamic public class func __lynxUIMethodConfigCanGoBack() -> NSString { return "canGoBack" }
221
+
222
+ @objc public func canGoForward(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
223
+ DispatchQueue.main.async {
224
+ callback(SigxWebViewUI.kUIMethodSuccess, ["value": self.view().canGoForward])
225
+ }
226
+ }
227
+ @objc(__lynx_ui_method_config__canGoForward)
228
+ dynamic public class func __lynxUIMethodConfigCanGoForward() -> NSString { return "canGoForward" }
229
+
230
+ /// Evaluate arbitrary JS in the page and return the last-expression
231
+ /// value. Results are stringified — JS code that returns a non-string
232
+ /// (number, dict, undefined) lands as its `String(describing:)` form so
233
+ /// the wire shape stays predictable.
234
+ @objc public func injectJavaScript(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
235
+ let code = (params?["code"] as? String) ?? ""
236
+ if code.isEmpty {
237
+ callback(SigxWebViewUI.kUIMethodUnknown, "injectJavaScript: missing `code` param")
238
+ return
239
+ }
240
+ DispatchQueue.main.async {
241
+ self.view().evaluateJavaScript(code) { result, error in
242
+ if let error = error {
243
+ callback(SigxWebViewUI.kUIMethodUnknown, error.localizedDescription)
244
+ return
245
+ }
246
+ // Normalise JS `null` / `undefined` → empty string for
247
+ // wire-shape parity with Android and the documented
248
+ // `null/undefined → ""` contract. WKWebView returns:
249
+ // - `nil` for `undefined`
250
+ // - `NSNull` for JS `null`
251
+ // - the boxed value otherwise (NSString, NSNumber, …).
252
+ // The default `String(describing: NSNull())` of "<null>"
253
+ // would leak into JS otherwise.
254
+ let str: String
255
+ if result == nil || result is NSNull {
256
+ str = ""
257
+ } else if let s = result as? String {
258
+ str = s
259
+ } else {
260
+ str = "\(result!)"
261
+ }
262
+ callback(SigxWebViewUI.kUIMethodSuccess, ["result": str])
263
+ }
264
+ }
265
+ }
266
+ @objc(__lynx_ui_method_config__injectJavaScript)
267
+ dynamic public class func __lynxUIMethodConfigInjectJavaScript() -> NSString { return "injectJavaScript" }
268
+
269
+ /// Host → page message. The user-script (`bridgeUserScript`) injects a
270
+ /// `window.sigxBridge.dispatchMessage(data)` helper that no-ops when the
271
+ /// page hasn't subscribed via `window.sigx.onmessage = …`. We pass the
272
+ /// payload through `JSON.stringify` on the host side so embedded quotes
273
+ /// don't break the eval'd JS literal.
274
+ @objc public func postMessage(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
275
+ let data = (params?["data"] as? String) ?? ""
276
+ // Encode as JSON string literal so any quotes / newlines in the
277
+ // payload survive the round-trip into the JS source.
278
+ guard let payloadData = try? JSONSerialization.data(withJSONObject: [data], options: []),
279
+ let arrayLiteral = String(data: payloadData, encoding: .utf8),
280
+ arrayLiteral.count >= 2 else {
281
+ callback(SigxWebViewUI.kUIMethodUnknown, "postMessage: failed to encode payload")
282
+ return
283
+ }
284
+ // Slice off the array brackets to extract just the quoted-string form.
285
+ let jsLiteral = String(arrayLiteral.dropFirst().dropLast())
286
+ let js = "window.sigxBridge && window.sigxBridge.dispatchMessage && window.sigxBridge.dispatchMessage(\(jsLiteral));"
287
+ DispatchQueue.main.async {
288
+ self.view().evaluateJavaScript(js) { _, error in
289
+ if let error = error {
290
+ callback(SigxWebViewUI.kUIMethodUnknown, error.localizedDescription)
291
+ } else {
292
+ callback(SigxWebViewUI.kUIMethodSuccess, nil)
293
+ }
294
+ }
295
+ }
296
+ }
297
+ @objc(__lynx_ui_method_config__postMessage)
298
+ dynamic public class func __lynxUIMethodConfigPostMessage() -> NSString { return "postMessage" }
299
+
300
+ // MARK: - Event firing
301
+
302
+ /// Dispatch a `bind<name>` custom event with the given detail. JS handlers
303
+ /// see `event.detail` carrying the params.
304
+ fileprivate func fireEvent(_ name: String, params: [String: Any]) {
305
+ let event = LynxCustomEvent(name: name, targetSign: sign, params: params)
306
+ // Swift renames the ObjC `sendCustomEvent:` selector to `send(_:)`
307
+ // when LynxCustomEvent is the parameter type — both ObjC and Swift
308
+ // call the same underlying method.
309
+ context?.eventEmitter?.send(event)
310
+ }
311
+
312
+ /// User-script injected at `documentStart` on every frame. Forwards
313
+ /// `window.sigx.postMessage(json)` calls to the native script-message
314
+ /// handler (page → host direction). Also exposes
315
+ /// `window.sigxBridge.dispatchMessage` — the host calls this via
316
+ /// `evaluateJavaScript` to deliver host → page messages, which forwards
317
+ /// to whatever handler the page registered as `window.sigx.onmessage`.
318
+ private static let bridgeUserScript: String = """
319
+ (function() {
320
+ var bridgeNs = window.sigxBridge = window.sigxBridge || {};
321
+ var raw = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.sigxBridge;
322
+ var post = function(payload) {
323
+ if (!raw) return;
324
+ try { raw.postMessage(typeof payload === 'string' ? payload : JSON.stringify(payload)); }
325
+ catch (e) { /* raw.postMessage already serialised the error */ }
326
+ };
327
+ // Host → page delivery slot. No-op if the page hasn't subscribed,
328
+ // and any handler exception is swallowed so a bad page can't break
329
+ // the bridge.
330
+ bridgeNs.dispatchMessage = function(payload) {
331
+ try {
332
+ var fn = window.sigx && window.sigx.onmessage;
333
+ if (typeof fn === 'function') fn(payload);
334
+ } catch (e) { /* swallowed */ }
335
+ };
336
+ window.sigx = window.sigx || {};
337
+ if (!window.sigx.postMessage) window.sigx.postMessage = post;
338
+ })();
339
+ """
340
+ }
341
+
342
+ /// `WKNavigationDelegate` adapter that re-fires lifecycle events back to JS.
343
+ final class SigxWebViewNavigationDelegate: NSObject, WKNavigationDelegate {
344
+ private weak var owner: SigxWebViewUI?
345
+
346
+ init(owner: SigxWebViewUI) { self.owner = owner }
347
+
348
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
349
+ let url = webView.url?.absoluteString ?? ""
350
+ owner?.fireEvent("load", params: ["url": url])
351
+ }
352
+
353
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
354
+ owner?.fireEvent("error", params: [
355
+ "url": webView.url?.absoluteString ?? "",
356
+ "message": error.localizedDescription,
357
+ ])
358
+ }
359
+
360
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
361
+ // Provisional failures (DNS, TLS, unreachable) never hit didFinish;
362
+ // surface the same shape so JS only has to wire one handler.
363
+ owner?.fireEvent("error", params: [
364
+ "url": webView.url?.absoluteString ?? "",
365
+ "message": error.localizedDescription,
366
+ ])
367
+ }
368
+ }
369
+
370
+ /// `WKScriptMessageHandler` for `window.sigxBridge.postMessage(string)`.
371
+ /// Forwards the payload as the `data` field of a `bindmessage` event so JS
372
+ /// sees `event.detail.data` matching the Android side.
373
+ final class SigxWebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
374
+ private weak var owner: SigxWebViewUI?
375
+
376
+ init(owner: SigxWebViewUI) { self.owner = owner }
377
+
378
+ func userContentController(_ userContentController: WKUserContentController,
379
+ didReceive message: WKScriptMessage) {
380
+ let data: String
381
+ if let s = message.body as? String {
382
+ data = s
383
+ } else {
384
+ // Non-string bodies (numbers, dicts) — best-effort stringify so
385
+ // the wire shape is consistent.
386
+ data = "\(message.body)"
387
+ }
388
+ owner?.fireEvent("message", params: ["data": data])
389
+ }
390
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@sigx/lynx-webview",
3
+ "version": "0.4.1",
4
+ "description": "Native WebView component for sigx-lynx (WKWebView on iOS, android.webkit.WebView on Android).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./signalx-module.json": "./signalx-module.json",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "ios",
20
+ "android",
21
+ "signalx-module.json",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "peerDependencies": {
26
+ "@sigx/lynx": "^0.4.1"
27
+ },
28
+ "devDependencies": {
29
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^4.1.7",
32
+ "@sigx/lynx": "^0.4.1"
33
+ },
34
+ "author": "Andreas Ekdahl",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/signalxjs/lynx.git",
39
+ "directory": "packages/lynx-webview"
40
+ },
41
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-webview",
42
+ "bugs": {
43
+ "url": "https://github.com/signalxjs/lynx/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "keywords": [
49
+ "signalx",
50
+ "sigx",
51
+ "lynx",
52
+ "webview",
53
+ "wkwebview",
54
+ "ios",
55
+ "android"
56
+ ],
57
+ "scripts": {
58
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
59
+ "dev": "tsgo --watch",
60
+ "test": "vitest run",
61
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
62
+ }
63
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "WebView",
3
+ "package": "@sigx/lynx-webview",
4
+ "description": "Native WebView component (WKWebView / android.webkit.WebView)",
5
+ "platforms": ["android", "ios"],
6
+ "ios": {
7
+ "sourceDir": "ios",
8
+ "uiComponents": [
9
+ { "name": "sigx-webview", "uiClass": "SigxWebViewUI" }
10
+ ]
11
+ },
12
+ "android": {
13
+ "sourceDir": "android",
14
+ "behaviors": [
15
+ { "name": "sigx-webview", "behaviorClass": "com.sigx.webview.SigxWebViewBehavior" }
16
+ ]
17
+ }
18
+ }