@ufjs/webview 0.1.3

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.
@@ -0,0 +1,472 @@
1
+ // Flutter side of the fjs module "webview": the WebView behind <web-view />.
2
+ //
3
+ // fjs autolinks this — the generated host depends on this package and calls
4
+ // FjsWebview.register(engine) before runApp, because the module's
5
+ // package.json says so in its "fjs.flutter" field.
6
+ //
7
+ // Three decisions worth writing down (specs/013-web-view):
8
+ //
9
+ // * `webview_flutter`, not `flutter_inappwebview`. The latter can intercept
10
+ // requests, inject scripts and manage cookies — all of which this spec
11
+ // puts out of scope. The contract here (src, three events) is small
12
+ // enough that swapping the implementation later would not reach pages.
13
+ // * `@error` only reports MAIN-DOCUMENT failures. A page whose tracking
14
+ // pixel 404s has not failed to load, and reporting it would make the
15
+ // event useless. The browser cannot report even that much (docs/web.md).
16
+ // * `asset://` has three resolutions, not two: the dev server while
17
+ // `fjs dev` is connected, a Flutter asset in a release build, and the
18
+ // app's own static root on the web. The rules live once in the module's
19
+ // index.ts; this file mirrors the two that are Dart's.
20
+ import 'dart:async';
21
+
22
+ import 'package:flutter/foundation.dart';
23
+ import 'package:flutter/gestures.dart';
24
+ import 'package:flutter/widgets.dart';
25
+ import 'package:flutter_fjs/flutter_fjs.dart';
26
+ import 'package:webview_flutter/webview_flutter.dart';
27
+
28
+ /// The module's short name: what `/modules/<name>/…` and the asset path use.
29
+ /// Mirrors WEB_VIEW_MODULE in ../../index.ts.
30
+ const String fjsWebViewModule = 'webview';
31
+
32
+ /// Stable failure text — mirrors WEB_VIEW_ERROR in ../../index.ts. The
33
+ /// platform's own wording stays in the platform's log: an error string that
34
+ /// changes per platform is not a contract.
35
+ const String fjsWebViewErrorMessage = 'web-view load failed';
36
+
37
+ /// The platform view must win the pointer sequence that starts inside its
38
+ /// rectangle. With the plugin's default empty set, an enclosing
39
+ /// SingleChildScrollView can claim the vertical drag before the WebView gets
40
+ /// it, leaving the page unable to scroll when <web-view> is nested in
41
+ /// <scroll-view>.
42
+ final Set<Factory<OneSequenceGestureRecognizer>> _fjsWebViewGestures = {
43
+ Factory<EagerGestureRecognizer>(EagerGestureRecognizer.new),
44
+ };
45
+
46
+ /// `@load` / `@error` / `@message` payloads. Field order is part of the
47
+ /// contract (../../index.ts writes the same three).
48
+ String fjsWebViewLoadPayload(String src) => '{"src":${_json(src)}}';
49
+
50
+ String fjsWebViewErrorPayload(String src) =>
51
+ '{"src":${_json(src)},"errMsg":${_json(fjsWebViewErrorMessage)}}';
52
+
53
+ String fjsWebViewMessagePayload(String data) => '{"data":${_json(data)}}';
54
+
55
+ String _json(String value) {
56
+ final out = StringBuffer('"');
57
+ for (final rune in value.runes) {
58
+ switch (rune) {
59
+ case 0x22:
60
+ out.write(r'\"');
61
+ case 0x5C:
62
+ out.write(r'\\');
63
+ case 0x0A:
64
+ out.write(r'\n');
65
+ case 0x0D:
66
+ out.write(r'\r');
67
+ case 0x09:
68
+ out.write(r'\t');
69
+ default:
70
+ if (rune < 0x20) {
71
+ out.write('\\u${rune.toRadixString(16).padLeft(4, '0')}');
72
+ } else {
73
+ out.writeCharCode(rune);
74
+ }
75
+ }
76
+ }
77
+ out.write('"');
78
+ return out.toString();
79
+ }
80
+
81
+ enum FjsWebViewSrcKind { empty, http, asset, local, unsupported }
82
+
83
+ /// Where the app's own local files land in a release build — the one
84
+ /// directory the CLI syncs `public/` and `html/` into. Mirrors
85
+ /// APP_ASSET_BASE in ../../index.ts (specs/017-local-image-assets).
86
+ const String fjsAppAssetBase = 'assets/fjs/public';
87
+
88
+ /// What a `src` is, before anyone tries to load it. Mirrors classifySrc in
89
+ /// ../../index.ts.
90
+ FjsWebViewSrcKind fjsClassifyWebViewSrc(Object? raw) {
91
+ final src = (raw?.toString() ?? '').trim();
92
+ if (src.isEmpty) return FjsWebViewSrcKind.empty;
93
+ if (src.startsWith('http://') || src.startsWith('https://')) {
94
+ return FjsWebViewSrcKind.http;
95
+ }
96
+ if (src.startsWith('asset://')) return FjsWebViewSrcKind.asset;
97
+ // A root path is the app's own page, from the project's html/ directory
98
+ // (specs/018-src-hints-and-html-dir). `asset://` stays what it always was:
99
+ // a file THIS MODULE ships, resolved under modules/webview/.
100
+ if (src.startsWith('/')) return FjsWebViewSrcKind.local;
101
+ return FjsWebViewSrcKind.unsupported;
102
+ }
103
+
104
+ /// The path part of a root-absolute src, or null when it escapes the app's
105
+ /// own files. Mirrors localPath in ../../index.ts.
106
+ String? fjsWebViewLocalPath(String raw) {
107
+ var path = raw;
108
+ while (path.startsWith('/')) {
109
+ path = path.substring(1);
110
+ }
111
+ if (path.isEmpty || path.contains('..')) return null;
112
+ return path;
113
+ }
114
+
115
+ /// The path inside the module's own directory, or null when it escapes it.
116
+ String? fjsWebViewAssetPath(String raw) {
117
+ var path = raw.substring('asset://'.length);
118
+ while (path.startsWith('/')) {
119
+ path = path.substring(1);
120
+ }
121
+ if (path.isEmpty || path.contains('..')) return null;
122
+ return path;
123
+ }
124
+
125
+ /// Where a resolved `src` lives. A Flutter asset is a separate shape because
126
+ /// it is loaded with `loadFlutterAsset`, not as a URL.
127
+ class FjsWebViewTarget {
128
+ const FjsWebViewTarget.url(this.url)
129
+ : asset = null,
130
+ suffix = '';
131
+ const FjsWebViewTarget.asset(this.asset, {this.suffix = ''}) : url = null;
132
+ const FjsWebViewTarget.none()
133
+ : url = null,
134
+ asset = null,
135
+ suffix = '';
136
+
137
+ final String? url;
138
+ final String? asset;
139
+
140
+ /// Query and fragment to append after the platform resolves the asset key
141
+ /// to its real local file URL. The key and document URL are separate:
142
+ /// only the former is used by the Flutter asset manifest.
143
+ final String suffix;
144
+
145
+ bool get isNothing => url == null && asset == null;
146
+ }
147
+
148
+ /// Everything before `?` / `#`. A Flutter asset key is looked up in the
149
+ /// bundle manifest, so it cannot carry either — passing one through makes
150
+ /// loadFlutterAsset throw FWFURLParsingError.
151
+ String fjsWebViewStripQuery(String path) {
152
+ final cut = path.indexOf(RegExp(r'[?#]'));
153
+ return cut < 0 ? path : path.substring(0, cut);
154
+ }
155
+
156
+ /// The part that belongs to the document URL rather than the asset key.
157
+ String fjsWebViewAssetSuffix(String path) {
158
+ final cut = path.indexOf(RegExp(r'[?#]'));
159
+ return cut < 0 ? '' : path.substring(cut);
160
+ }
161
+
162
+ /// Mirrors resolveSrc in ../../index.ts for the two app cases: with a dev
163
+ /// connection an `asset://` is served by `fjs dev`, otherwise it is a
164
+ /// Flutter asset the build copied in.
165
+ FjsWebViewTarget fjsResolveWebViewSrc(Object? raw, {Uri? devUri}) {
166
+ final src = (raw?.toString() ?? '').trim();
167
+ switch (fjsClassifyWebViewSrc(src)) {
168
+ case FjsWebViewSrcKind.http:
169
+ return FjsWebViewTarget.url(src);
170
+ case FjsWebViewSrcKind.local:
171
+ final path = fjsWebViewLocalPath(src);
172
+ if (path == null) return const FjsWebViewTarget.none();
173
+ if (devUri != null) {
174
+ final base = devUri.toString().replaceAll(RegExp(r'/+$'), '');
175
+ return FjsWebViewTarget.url('$base/$path');
176
+ }
177
+ return FjsWebViewTarget.asset(
178
+ '$fjsAppAssetBase/${fjsWebViewStripQuery(path)}',
179
+ suffix: fjsWebViewAssetSuffix(path),
180
+ );
181
+ case FjsWebViewSrcKind.asset:
182
+ final path = fjsWebViewAssetPath(src);
183
+ if (path == null) return const FjsWebViewTarget.none();
184
+ if (devUri != null) {
185
+ final base = devUri.toString().replaceAll(RegExp(r'/+$'), '');
186
+ return FjsWebViewTarget.url('$base/modules/$fjsWebViewModule/$path');
187
+ }
188
+ final key = fjsWebViewStripQuery(path);
189
+ return FjsWebViewTarget.asset(
190
+ 'assets/fjs/modules/$fjsWebViewModule/$key',
191
+ suffix: fjsWebViewAssetSuffix(path),
192
+ );
193
+ case FjsWebViewSrcKind.empty:
194
+ case FjsWebViewSrcKind.unsupported:
195
+ return const FjsWebViewTarget.none();
196
+ }
197
+ }
198
+
199
+ /// One terminal event per load, and never the previous page's — the Dart
200
+ /// half of LoadCycle in ../../index.ts.
201
+ ///
202
+ /// It is a class of its own so it can be tested without a WebViewController:
203
+ /// building one needs a platform implementation, which a widget test does
204
+ /// not have. What a test here cannot prove is that the NavigationDelegate is
205
+ /// wired to it; that is what the simulator pass is for.
206
+ class FjsWebViewLoadCycle {
207
+ int _generation = 0;
208
+ bool _settled = false;
209
+
210
+ int get current => _generation;
211
+
212
+ int begin() {
213
+ _generation += 1;
214
+ _settled = false;
215
+ return _generation;
216
+ }
217
+
218
+ bool finish(int generation) {
219
+ if (generation != _generation || _settled) return false;
220
+ _settled = true;
221
+ return true;
222
+ }
223
+
224
+ bool accepts(int generation) => generation == _generation;
225
+ }
226
+
227
+ /// Reattaches an asset src's query and fragment to the local URL that
228
+ /// `loadFlutterAsset` resolved. The redirect must happen before the document
229
+ /// executes, otherwise its first script can observe the wrong location.
230
+ class FjsWebViewAssetNavigation {
231
+ FjsWebViewAssetNavigation(this.suffix);
232
+
233
+ final String suffix;
234
+ bool _redirected = false;
235
+ bool _finished = false;
236
+
237
+ String? redirect(String platformUrl) {
238
+ if (_redirected || suffix.isEmpty) return null;
239
+ if (platformUrl.contains(RegExp(r'[?#]'))) return null;
240
+ _redirected = true;
241
+ return '$platformUrl$suffix';
242
+ }
243
+
244
+ bool accepts(String platformUrl) =>
245
+ suffix.isEmpty || platformUrl.endsWith(suffix);
246
+
247
+ bool shouldPreventBaseNavigation(String platformUrl) =>
248
+ !_finished &&
249
+ _redirected &&
250
+ suffix.isNotEmpty &&
251
+ !accepts(platformUrl) &&
252
+ !platformUrl.contains(RegExp(r'[?#]'));
253
+
254
+ void markFinished(String platformUrl) {
255
+ if (accepts(platformUrl)) _finished = true;
256
+ }
257
+ }
258
+
259
+ /// Whether a web-view can fill the box it was given.
260
+ ///
261
+ /// A web page has no intrinsic height, so an unbounded main axis has no
262
+ /// answer — and a guessed one would give every page a number nobody asked
263
+ /// for. Pure so the rule can be tested; the caller warns (constitution V).
264
+ bool fjsWebViewFitsBox(BoxConstraints constraints) =>
265
+ constraints.hasBoundedHeight;
266
+
267
+ final Set<String> _warned = <String>{};
268
+
269
+ /// The core's fjsWarnOnce is not exported, so the module keeps its own —
270
+ /// same channel and same prefix, so a page author cannot tell them apart.
271
+ void fjsWebViewWarnOnce(String key, String message) {
272
+ if (!_warned.add(key)) return;
273
+ debugPrint('[fjs] $message');
274
+ }
275
+
276
+ @visibleForTesting
277
+ void resetFjsWebViewWarnings() => _warned.clear();
278
+
279
+ class FjsWebview {
280
+ static FjsEngine? _engine;
281
+
282
+ /// Registers <web-view /> on the engine. `registry/component.dart` calls
283
+ /// itself the extension point for platform views, and this is one.
284
+ static void register(FjsEngine engine) {
285
+ _engine = engine;
286
+ engine.components.register('web-view', _build);
287
+ }
288
+
289
+ static final ComponentBuilder _build =
290
+ (context, node, children, dispatch) => FjsWebViewWidget(
291
+ key: ValueKey<int>(node.id),
292
+ node: node,
293
+ dispatch: dispatch,
294
+ devUri: _engine?.devUri,
295
+ );
296
+ }
297
+
298
+ class FjsWebViewWidget extends StatefulWidget {
299
+ const FjsWebViewWidget({
300
+ super.key,
301
+ required this.node,
302
+ required this.dispatch,
303
+ this.devUri,
304
+ @visibleForTesting this.controllerOverride,
305
+ });
306
+
307
+ final MirrorNode node;
308
+ final void Function(int nodeId, int eventType, {String? text}) dispatch;
309
+ final Uri? devUri;
310
+ final WebViewController? controllerOverride;
311
+
312
+ @override
313
+ State<FjsWebViewWidget> createState() => _FjsWebViewWidgetState();
314
+ }
315
+
316
+ class _FjsWebViewWidgetState extends State<FjsWebViewWidget> {
317
+ WebViewController? _controller;
318
+
319
+ /// Which load the results arriving now belong to. A `src` change bumps it,
320
+ /// so the previous page's onPageFinished, error and messages are dropped
321
+ /// instead of being reported against the new URL — the same generation
322
+ /// trick widgets/image.dart uses.
323
+ final FjsWebViewLoadCycle _cycle = FjsWebViewLoadCycle();
324
+ String _src = '';
325
+ String _loaded = '';
326
+
327
+ @override
328
+ void initState() {
329
+ super.initState();
330
+ _src = widget.node.props['src']?.toString() ?? '';
331
+ _configure();
332
+ }
333
+
334
+ @override
335
+ void didUpdateWidget(covariant FjsWebViewWidget oldWidget) {
336
+ super.didUpdateWidget(oldWidget);
337
+ final next = widget.node.props['src']?.toString() ?? '';
338
+ if (next == _src) return;
339
+ _src = next;
340
+ _configure();
341
+ }
342
+
343
+ void _configure() {
344
+ final kind = fjsClassifyWebViewSrc(_src);
345
+ if (kind == FjsWebViewSrcKind.unsupported) {
346
+ fjsWebViewWarnOnce(
347
+ 'web-view-src:$_src',
348
+ '<web-view> will not load "$_src": only http(s):// and asset:// '
349
+ '(a file this module ships) are supported. Other schemes behave '
350
+ 'too differently between WKWebView and the browser to promise.',
351
+ );
352
+ }
353
+ final target = fjsResolveWebViewSrc(_src, devUri: widget.devUri);
354
+ if (target.isNothing) {
355
+ // Nothing to show: no controller, no request, no events.
356
+ setState(() {
357
+ _controller = null;
358
+ _loaded = '';
359
+ });
360
+ return;
361
+ }
362
+ final generation = _cycle.begin();
363
+ final assetNavigation =
364
+ target.asset == null ? null : FjsWebViewAssetNavigation(target.suffix);
365
+ _loaded = target.url ?? 'asset://${_src.substring('asset://'.length)}';
366
+ final controller = widget.controllerOverride ?? WebViewController();
367
+ controller
368
+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
369
+ ..setNavigationDelegate(
370
+ NavigationDelegate(
371
+ onNavigationRequest: (request) {
372
+ final navigation = assetNavigation;
373
+ if (navigation == null) return NavigationDecision.navigate;
374
+ if (navigation.shouldPreventBaseNavigation(request.url)) {
375
+ return NavigationDecision.prevent;
376
+ }
377
+ final redirect = navigation.redirect(request.url);
378
+ if (redirect == null) return NavigationDecision.navigate;
379
+ // loadFlutterAsset gives us the platform's real local file URL.
380
+ // Reusing it preserves relative resources on both platforms,
381
+ // while this second navigation supplies the page's parameters
382
+ // before its own scripts run.
383
+ unawaited(controller.loadRequest(Uri.parse(redirect)));
384
+ return NavigationDecision.prevent;
385
+ },
386
+ onPageStarted: (url) {
387
+ // Some platform implementations do not ask for a navigation
388
+ // decision for the initial loadFlutterAsset request. This early
389
+ // callback is the fallback; it still runs before page scripts.
390
+ final navigation = assetNavigation;
391
+ final redirect = navigation?.redirect(url);
392
+ if (redirect != null) {
393
+ unawaited(controller.loadRequest(Uri.parse(redirect)));
394
+ }
395
+ },
396
+ onPageFinished: (url) {
397
+ final navigation = assetNavigation;
398
+ if (navigation != null && !navigation.accepts(url)) return;
399
+ navigation?.markFinished(url);
400
+ _settle(generation, error: false);
401
+ },
402
+ // Only the main document. A page whose favicon 404s has loaded.
403
+ onWebResourceError: (error) {
404
+ if (error.isForMainFrame == false) return;
405
+ _settle(generation, error: true);
406
+ },
407
+ ),
408
+ )
409
+ ..addJavaScriptChannel(
410
+ // The name the loaded page calls: fjs.postMessage('…'). The web
411
+ // stand-in cannot inject this, so a page brings a shim; see
412
+ // public/demo.html.
413
+ 'fjs',
414
+ onMessageReceived: (message) {
415
+ if (!_cycle.accepts(generation) || !mounted) return;
416
+ widget.dispatch(
417
+ widget.node.id,
418
+ FjsEvent.message,
419
+ text: fjsWebViewMessagePayload(message.message),
420
+ );
421
+ },
422
+ );
423
+ if (target.asset != null) {
424
+ unawaited(controller.loadFlutterAsset(target.asset!));
425
+ } else {
426
+ unawaited(controller.loadRequest(Uri.parse(target.url!)));
427
+ }
428
+ setState(() => _controller = controller);
429
+ }
430
+
431
+ void _settle(int generation, {required bool error}) {
432
+ if (!mounted || !_cycle.finish(generation)) return;
433
+ widget.dispatch(
434
+ widget.node.id,
435
+ error ? FjsEvent.error : FjsEvent.load,
436
+ text: error
437
+ ? fjsWebViewErrorPayload(_loaded)
438
+ : fjsWebViewLoadPayload(_loaded),
439
+ );
440
+ }
441
+
442
+ @override
443
+ Widget build(BuildContext context) {
444
+ final controller = _controller;
445
+ if (controller == null) return const SizedBox.shrink();
446
+ return LayoutBuilder(
447
+ builder: (context, constraints) {
448
+ // A WebView has no intrinsic height, so an unbounded main axis has
449
+ // no answer — guessing one would give every page a number nobody
450
+ // asked for. Say so and render nothing (constitution V).
451
+ if (!fjsWebViewFitsBox(constraints)) {
452
+ fjsWebViewWarnOnce(
453
+ 'web-view-unbounded:${widget.node.id}',
454
+ '<web-view> node ${widget.node.id} has no height to fill: give '
455
+ 'it a height, or a flex-grow, or put it in a box that has '
456
+ 'one. A web page has no natural height to fall back on.',
457
+ );
458
+ return const SizedBox.shrink();
459
+ }
460
+ // This only claims pointers hit inside the platform view. A drag that
461
+ // starts on a sibling remains available to the enclosing scroll-view.
462
+ // We intentionally do not hand a drag to the parent when the page
463
+ // reaches its own edge; that requires platform-specific nested-scroll
464
+ // callbacks and is outside this module's cross-platform contract.
465
+ return WebViewWidget(
466
+ controller: controller,
467
+ gestureRecognizers: _fjsWebViewGestures,
468
+ );
469
+ },
470
+ );
471
+ }
472
+ }