@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 +21 -0
- package/README.md +167 -0
- package/android/com/sigx/webview/SigxWebViewBehavior.kt +19 -0
- package/android/com/sigx/webview/SigxWebViewJavascriptInterface.kt +24 -0
- package/android/com/sigx/webview/SigxWebViewUI.kt +326 -0
- package/dist/WebView.d.ts +45 -0
- package/dist/WebView.js +109 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/jsx-augment.d.ts +71 -0
- package/dist/jsx-augment.js +1 -0
- package/ios/SigxWebViewUI.swift +390 -0
- package/package.json +63 -0
- package/signalx-module.json +18 -0
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
|
+
};
|
package/dist/WebView.js
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|