@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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/components/WebViewWeb.vue +134 -0
- package/flutter/.dart_tool/flutter_build/dart_plugin_registrant.dart +132 -0
- package/flutter/.dart_tool/package_config.json +371 -0
- package/flutter/.dart_tool/package_config_subset +241 -0
- package/flutter/.dart_tool/version +1 -0
- package/flutter/.flutter-plugins +13 -0
- package/flutter/.flutter-plugins-dependencies +1 -0
- package/flutter/build/native_assets/macos/native_assets.yaml +5 -0
- package/flutter/build/test_cache/build/cache.dill.track.dill +4 -0
- package/flutter/build/unit_test_assets/AssetManifest.bin +0 -0
- package/flutter/build/unit_test_assets/AssetManifest.json +1 -0
- package/flutter/build/unit_test_assets/FontManifest.json +1 -0
- package/flutter/build/unit_test_assets/NOTICES.Z +0 -0
- package/flutter/build/unit_test_assets/shaders/ink_sparkle.frag +0 -0
- package/flutter/lib/fjs_webview.dart +472 -0
- package/flutter/pubspec.lock +468 -0
- package/flutter/pubspec.yaml +30 -0
- package/flutter/test/web_view_test.dart +369 -0
- package/index.ts +210 -0
- package/package.json +69 -0
- package/prepare.mjs +40 -0
- package/public/demo.html +79 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// The app half of specs/013-web-view. The web twin is
|
|
2
|
+
// ../../test/web-view-web.test.ts, and the payload strings asserted here are
|
|
3
|
+
// the same ones it asserts — that pair IS the "two ends, one contract" check.
|
|
4
|
+
// EagerGestureRecognizer lives here, not in material.dart — without this the
|
|
5
|
+
// whole file failed to compile, which `flutter test` reports as one failed
|
|
6
|
+
// "loading …" case rather than as a missing import.
|
|
7
|
+
import 'package:flutter/gestures.dart';
|
|
8
|
+
import 'package:flutter/material.dart';
|
|
9
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
10
|
+
import 'package:flutter_fjs/flutter_fjs.dart';
|
|
11
|
+
import 'package:fjs_webview/fjs_webview.dart';
|
|
12
|
+
import 'package:webview_flutter/webview_flutter.dart';
|
|
13
|
+
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
|
14
|
+
|
|
15
|
+
MirrorNode nodeWith(Map<String, Object?> props) {
|
|
16
|
+
final node = MirrorNode(7, 'web-view');
|
|
17
|
+
node.props = props;
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
void main() {
|
|
22
|
+
setUp(resetFjsWebViewWarnings);
|
|
23
|
+
|
|
24
|
+
group('payloads mirror the JS side', () {
|
|
25
|
+
test('load, error and message', () {
|
|
26
|
+
expect(
|
|
27
|
+
fjsWebViewLoadPayload('https://example.com/a'),
|
|
28
|
+
'{"src":"https://example.com/a"}',
|
|
29
|
+
);
|
|
30
|
+
expect(
|
|
31
|
+
fjsWebViewErrorPayload('https://example.com/a'),
|
|
32
|
+
'{"src":"https://example.com/a","errMsg":"web-view load failed"}',
|
|
33
|
+
);
|
|
34
|
+
expect(fjsWebViewMessagePayload('hello #1'), '{"data":"hello #1"}');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('escapes what JSON has to escape', () {
|
|
38
|
+
expect(fjsWebViewMessagePayload('a "b"\nc'), '{"data":"a \\"b\\"\\nc"}');
|
|
39
|
+
expect(fjsWebViewMessagePayload('tab\there'), '{"data":"tab\\there"}');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
group('src', () {
|
|
44
|
+
test('classifies the loadable schemes and refuses the rest', () {
|
|
45
|
+
expect(fjsClassifyWebViewSrc('https://a'), FjsWebViewSrcKind.http);
|
|
46
|
+
expect(fjsClassifyWebViewSrc('http://a'), FjsWebViewSrcKind.http);
|
|
47
|
+
expect(fjsClassifyWebViewSrc('asset://a.html'), FjsWebViewSrcKind.asset);
|
|
48
|
+
expect(fjsClassifyWebViewSrc(''), FjsWebViewSrcKind.empty);
|
|
49
|
+
expect(fjsClassifyWebViewSrc(null), FjsWebViewSrcKind.empty);
|
|
50
|
+
for (final src in const [
|
|
51
|
+
'file:///etc/passwd',
|
|
52
|
+
'javascript:alert(1)',
|
|
53
|
+
'data:text/html,x',
|
|
54
|
+
'example.com',
|
|
55
|
+
]) {
|
|
56
|
+
expect(fjsClassifyWebViewSrc(src), FjsWebViewSrcKind.unsupported,
|
|
57
|
+
reason: src);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an asset path cannot escape the module directory', () {
|
|
62
|
+
expect(fjsWebViewAssetPath('asset://demo.html'), 'demo.html');
|
|
63
|
+
expect(fjsWebViewAssetPath('asset:///demo.html'), 'demo.html');
|
|
64
|
+
expect(fjsWebViewAssetPath('asset://../secret'), isNull);
|
|
65
|
+
expect(fjsWebViewAssetPath('asset://'), isNull);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('asset:// goes to the dev server while fjs dev is connected', () {
|
|
69
|
+
final target = fjsResolveWebViewSrc(
|
|
70
|
+
'asset://demo.html',
|
|
71
|
+
devUri: Uri.parse('http://127.0.0.1:38900/'),
|
|
72
|
+
);
|
|
73
|
+
expect(target.url, 'http://127.0.0.1:38900/modules/webview/demo.html');
|
|
74
|
+
expect(target.asset, isNull);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('asset:// is a Flutter asset without one', () {
|
|
78
|
+
final target = fjsResolveWebViewSrc('asset://demo.html');
|
|
79
|
+
// loadFlutterAsset takes a key, not a URL — hence the separate shape
|
|
80
|
+
expect(target.asset, 'assets/fjs/modules/webview/demo.html');
|
|
81
|
+
expect(target.url, isNull);
|
|
82
|
+
expect(target.suffix, isEmpty);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('an asset key is separate from the document suffix', () {
|
|
86
|
+
// FWFURLParsingError happens when `demo.html?q=hello` is used as the
|
|
87
|
+
// manifest key. The suffix is restored on the local document URL
|
|
88
|
+
// instead, so the page still receives its parameters.
|
|
89
|
+
final release = fjsResolveWebViewSrc('asset://demo.html?q=hello#top');
|
|
90
|
+
expect(release.asset, 'assets/fjs/modules/webview/demo.html');
|
|
91
|
+
expect(release.suffix, '?q=hello#top');
|
|
92
|
+
|
|
93
|
+
final dev = fjsResolveWebViewSrc(
|
|
94
|
+
'asset://demo.html?q=hello#top',
|
|
95
|
+
devUri: Uri.parse('http://127.0.0.1:38900'),
|
|
96
|
+
);
|
|
97
|
+
expect(dev.url,
|
|
98
|
+
'http://127.0.0.1:38900/modules/webview/demo.html?q=hello#top');
|
|
99
|
+
expect(dev.suffix, isEmpty);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a root path is the app\'s own page, not the module\'s', () {
|
|
103
|
+
// classifySrc in ../../index.ts has the same three kinds; missing this
|
|
104
|
+
// branch on one end shows up as `unsupported` + a warn on that end
|
|
105
|
+
// only, never as an exception (constitution I).
|
|
106
|
+
expect(fjsClassifyWebViewSrc('/html/guide.html'),
|
|
107
|
+
FjsWebViewSrcKind.local);
|
|
108
|
+
expect(fjsWebViewLocalPath('/html/guide.html'), 'html/guide.html');
|
|
109
|
+
expect(fjsWebViewLocalPath('///html/guide.html'), 'html/guide.html');
|
|
110
|
+
expect(fjsWebViewLocalPath('/../secret'), isNull);
|
|
111
|
+
expect(fjsWebViewLocalPath('/'), isNull);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a root path goes to the dev server while fjs dev is connected', () {
|
|
115
|
+
final target = fjsResolveWebViewSrc(
|
|
116
|
+
'/html/guide.html',
|
|
117
|
+
devUri: Uri.parse('http://127.0.0.1:38900/'),
|
|
118
|
+
);
|
|
119
|
+
expect(target.url, 'http://127.0.0.1:38900/html/guide.html');
|
|
120
|
+
expect(target.asset, isNull);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a root path is an app asset without one', () {
|
|
124
|
+
final target = fjsResolveWebViewSrc('/html/guide.html');
|
|
125
|
+
expect(target.asset, 'assets/fjs/public/html/guide.html');
|
|
126
|
+
expect(target.url, isNull);
|
|
127
|
+
expect(target.suffix, isEmpty);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a root path splits key and suffix the same way asset:// does', () {
|
|
131
|
+
final release = fjsResolveWebViewSrc('/html/guide.html?q=1#top');
|
|
132
|
+
expect(release.asset, 'assets/fjs/public/html/guide.html');
|
|
133
|
+
expect(release.suffix, '?q=1#top');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('strips a fragment too', () {
|
|
137
|
+
expect(fjsWebViewStripQuery('a.html#top'), 'a.html');
|
|
138
|
+
expect(fjsWebViewStripQuery('a.html'), 'a.html');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('http is left alone either way', () {
|
|
142
|
+
expect(fjsResolveWebViewSrc('https://example.com/a').url,
|
|
143
|
+
'https://example.com/a');
|
|
144
|
+
expect(
|
|
145
|
+
fjsResolveWebViewSrc('https://example.com/a',
|
|
146
|
+
devUri: Uri.parse('http://127.0.0.1:38900'))
|
|
147
|
+
.url,
|
|
148
|
+
'https://example.com/a',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('nothing to load for empty and unsupported', () {
|
|
153
|
+
expect(fjsResolveWebViewSrc('').isNothing, isTrue);
|
|
154
|
+
expect(fjsResolveWebViewSrc('file:///x').isNothing, isTrue);
|
|
155
|
+
expect(fjsResolveWebViewSrc('asset://../x').isNothing, isTrue);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
group('load cycle', () {
|
|
160
|
+
test('reports one terminal event per load', () {
|
|
161
|
+
final cycle = FjsWebViewLoadCycle();
|
|
162
|
+
final generation = cycle.begin();
|
|
163
|
+
expect(cycle.finish(generation), isTrue);
|
|
164
|
+
// error after load, or a second onPageFinished, is not a second event
|
|
165
|
+
expect(cycle.finish(generation), isFalse);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('drops the previous page after the src changes', () {
|
|
169
|
+
final cycle = FjsWebViewLoadCycle();
|
|
170
|
+
final first = cycle.begin();
|
|
171
|
+
final second = cycle.begin();
|
|
172
|
+
expect(cycle.finish(first), isFalse);
|
|
173
|
+
expect(cycle.finish(second), isTrue);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('accepts messages only from the current page', () {
|
|
177
|
+
final cycle = FjsWebViewLoadCycle();
|
|
178
|
+
final first = cycle.begin();
|
|
179
|
+
expect(cycle.accepts(first), isTrue);
|
|
180
|
+
cycle.begin();
|
|
181
|
+
expect(cycle.accepts(first), isFalse);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
group('release asset navigation', () {
|
|
186
|
+
test('reattaches parameters before the first page script', () {
|
|
187
|
+
final navigation = FjsWebViewAssetNavigation('?q=hello#top');
|
|
188
|
+
expect(
|
|
189
|
+
navigation.redirect('file:///bundle/demo.html'),
|
|
190
|
+
'file:///bundle/demo.html?q=hello#top',
|
|
191
|
+
);
|
|
192
|
+
expect(
|
|
193
|
+
navigation.accepts('file:///bundle/demo.html?q=hello#top'),
|
|
194
|
+
isTrue,
|
|
195
|
+
);
|
|
196
|
+
expect(navigation.redirect('file:///bundle/demo.html'), isNull);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('does not redirect an already parameterized URL', () {
|
|
200
|
+
final navigation = FjsWebViewAssetNavigation('?q=hello');
|
|
201
|
+
expect(
|
|
202
|
+
navigation.redirect('file:///bundle/demo.html?q=hello'),
|
|
203
|
+
isNull,
|
|
204
|
+
);
|
|
205
|
+
expect(navigation.redirect('file:///bundle/demo.html'), isNotNull);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('stops protecting the base URL after the redirected page finishes',
|
|
209
|
+
() {
|
|
210
|
+
final navigation = FjsWebViewAssetNavigation('?q=hello');
|
|
211
|
+
navigation.redirect('file:///bundle/demo.html');
|
|
212
|
+
expect(
|
|
213
|
+
navigation.shouldPreventBaseNavigation('file:///bundle/demo.html'),
|
|
214
|
+
isTrue,
|
|
215
|
+
);
|
|
216
|
+
navigation.markFinished('file:///bundle/demo.html?q=hello');
|
|
217
|
+
expect(
|
|
218
|
+
navigation.shouldPreventBaseNavigation('file:///bundle/demo.html'),
|
|
219
|
+
isFalse,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
group('box', () {
|
|
225
|
+
test('needs a bounded height, because a page has no natural one', () {
|
|
226
|
+
expect(
|
|
227
|
+
fjsWebViewFitsBox(
|
|
228
|
+
const BoxConstraints.tightFor(width: 300, height: 200)),
|
|
229
|
+
isTrue,
|
|
230
|
+
);
|
|
231
|
+
expect(
|
|
232
|
+
fjsWebViewFitsBox(const BoxConstraints(maxWidth: 300)),
|
|
233
|
+
isFalse,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
group('widget', () {
|
|
239
|
+
testWidgets('an empty src builds nothing at all', (tester) async {
|
|
240
|
+
final events = <(int, String?)>[];
|
|
241
|
+
await tester.pumpWidget(MaterialApp(
|
|
242
|
+
home: FjsWebViewWidget(
|
|
243
|
+
node: nodeWith(const {}),
|
|
244
|
+
dispatch: (id, type, {String? text}) => events.add((type, text)),
|
|
245
|
+
),
|
|
246
|
+
));
|
|
247
|
+
expect(find.byType(SizedBox), findsOneWidget);
|
|
248
|
+
expect(events, isEmpty);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
testWidgets('an unsupported scheme warns once and loads nothing',
|
|
252
|
+
(tester) async {
|
|
253
|
+
final logs = <String>[];
|
|
254
|
+
final original = debugPrint;
|
|
255
|
+
debugPrint = (message, {wrapWidth}) => logs.add(message ?? '');
|
|
256
|
+
try {
|
|
257
|
+
await tester.pumpWidget(MaterialApp(
|
|
258
|
+
home: FjsWebViewWidget(
|
|
259
|
+
node: nodeWith(const {'src': 'file:///etc/passwd'}),
|
|
260
|
+
dispatch: (id, type, {String? text}) {},
|
|
261
|
+
),
|
|
262
|
+
));
|
|
263
|
+
} finally {
|
|
264
|
+
debugPrint = original;
|
|
265
|
+
}
|
|
266
|
+
expect(logs.where((l) => l.contains('file:///etc/passwd')), isNotEmpty);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
testWidgets('claims gestures inside the platform view', (tester) async {
|
|
270
|
+
final platform = _FakeWebViewPlatform();
|
|
271
|
+
WebViewPlatform.instance = platform;
|
|
272
|
+
addTearDown(() => WebViewPlatform.instance = _FakeWebViewPlatform());
|
|
273
|
+
|
|
274
|
+
await tester.pumpWidget(MaterialApp(
|
|
275
|
+
home: FjsWebViewWidget(
|
|
276
|
+
node: nodeWith(const {'src': 'https://example.com'}),
|
|
277
|
+
dispatch: (id, type, {String? text}) {},
|
|
278
|
+
),
|
|
279
|
+
));
|
|
280
|
+
|
|
281
|
+
final params = platform.lastWidgetParams;
|
|
282
|
+
expect(params, isNotNull);
|
|
283
|
+
expect(params!.gestureRecognizers, hasLength(1));
|
|
284
|
+
expect(
|
|
285
|
+
params.gestureRecognizers.single.constructor(),
|
|
286
|
+
isA<EagerGestureRecognizer>(),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
class _FakeWebViewPlatform extends WebViewPlatform {
|
|
293
|
+
PlatformWebViewWidgetCreationParams? lastWidgetParams;
|
|
294
|
+
|
|
295
|
+
@override
|
|
296
|
+
PlatformWebViewController createPlatformWebViewController(
|
|
297
|
+
PlatformWebViewControllerCreationParams params,
|
|
298
|
+
) => _FakeWebViewController(params);
|
|
299
|
+
|
|
300
|
+
@override
|
|
301
|
+
PlatformWebViewWidget createPlatformWebViewWidget(
|
|
302
|
+
PlatformWebViewWidgetCreationParams params,
|
|
303
|
+
) {
|
|
304
|
+
lastWidgetParams = params;
|
|
305
|
+
return _FakeWebViewWidget(params);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@override
|
|
309
|
+
PlatformNavigationDelegate createPlatformNavigationDelegate(
|
|
310
|
+
PlatformNavigationDelegateCreationParams params,
|
|
311
|
+
) => _FakeNavigationDelegate(params);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
class _FakeWebViewController extends PlatformWebViewController {
|
|
315
|
+
_FakeWebViewController(super.params) : super.implementation();
|
|
316
|
+
|
|
317
|
+
@override
|
|
318
|
+
Future<void> setJavaScriptMode(JavaScriptMode javaScriptMode) async {}
|
|
319
|
+
|
|
320
|
+
@override
|
|
321
|
+
Future<void> setPlatformNavigationDelegate(
|
|
322
|
+
PlatformNavigationDelegate handler,
|
|
323
|
+
) async {}
|
|
324
|
+
|
|
325
|
+
@override
|
|
326
|
+
Future<void> addJavaScriptChannel(
|
|
327
|
+
JavaScriptChannelParams javaScriptChannelParams,
|
|
328
|
+
) async {}
|
|
329
|
+
|
|
330
|
+
@override
|
|
331
|
+
Future<void> loadFlutterAsset(String key) async {}
|
|
332
|
+
|
|
333
|
+
@override
|
|
334
|
+
Future<void> loadRequest(LoadRequestParams params) async {}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
class _FakeWebViewWidget extends PlatformWebViewWidget {
|
|
338
|
+
_FakeWebViewWidget(super.params) : super.implementation();
|
|
339
|
+
|
|
340
|
+
@override
|
|
341
|
+
Widget build(BuildContext context) => const SizedBox();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
class _FakeNavigationDelegate extends PlatformNavigationDelegate {
|
|
345
|
+
_FakeNavigationDelegate(super.params) : super.implementation();
|
|
346
|
+
|
|
347
|
+
// PlatformNavigationDelegate's setters throw UnimplementedError by
|
|
348
|
+
// default, and NavigationDelegate's constructor calls every one it was
|
|
349
|
+
// given a callback for. The widget under test registers all four, so a
|
|
350
|
+
// fake that overrides none cannot be built at all.
|
|
351
|
+
@override
|
|
352
|
+
Future<void> setOnNavigationRequest(
|
|
353
|
+
NavigationRequestCallback onNavigationRequest,
|
|
354
|
+
) async {}
|
|
355
|
+
|
|
356
|
+
@override
|
|
357
|
+
Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {}
|
|
358
|
+
|
|
359
|
+
@override
|
|
360
|
+
Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {}
|
|
361
|
+
|
|
362
|
+
@override
|
|
363
|
+
Future<void> setOnWebResourceError(
|
|
364
|
+
WebResourceErrorCallback onWebResourceError,
|
|
365
|
+
) async {}
|
|
366
|
+
|
|
367
|
+
@override
|
|
368
|
+
Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {}
|
|
369
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// web-view — one tag, a platform WebView on the app and an iframe on the web.
|
|
2
|
+
//
|
|
3
|
+
// <web-view src="https://example.com" @message="onMessage" />
|
|
4
|
+
//
|
|
5
|
+
// This file is the part both targets agree on: the three event payloads, how
|
|
6
|
+
// a `src` is classified and resolved, and the "one terminal event per load"
|
|
7
|
+
// gate. Neither side imports the other's code — the app side is Dart
|
|
8
|
+
// (flutter/lib/fjs_webview.dart) — so, as with scroll/metrics.ts in the
|
|
9
|
+
// runtime, this file is the SPEC as much as the implementation, and the Dart
|
|
10
|
+
// half mirrors it and points back here.
|
|
11
|
+
//
|
|
12
|
+
// Why a module and not a built-in tag: webview_flutter needs Dart SDK ^3.5,
|
|
13
|
+
// and flutter_fjs still declares >=3.3. Built in, every app would pay that;
|
|
14
|
+
// as a module, only the apps that install it do.
|
|
15
|
+
|
|
16
|
+
/** The module's short name — the npm scope is stripped by the toolchain, so
|
|
17
|
+
* this is what appears in `/modules/<name>/…` and in the Flutter asset path. */
|
|
18
|
+
export const WEB_VIEW_MODULE = 'webview';
|
|
19
|
+
|
|
20
|
+
/** Where the web target serves the module's `public/` from. The prepare hook
|
|
21
|
+
* copies the files there because vite does not serve `.fjs/`. */
|
|
22
|
+
export const WEB_ASSET_BASE = `/fjs-modules/${WEB_VIEW_MODULE}`;
|
|
23
|
+
|
|
24
|
+
/** Where the app's own local files land in a release build — the one
|
|
25
|
+
* directory the CLI syncs public/ and html/ into (specs/017). */
|
|
26
|
+
export const APP_ASSET_BASE = 'assets/fjs/public';
|
|
27
|
+
|
|
28
|
+
/** Stable failure text. WKWebView and the browser word their errors
|
|
29
|
+
* completely differently, and a payload that changes per platform is not a
|
|
30
|
+
* contract — the platform's own message stays in the platform's log. */
|
|
31
|
+
export const WEB_VIEW_ERROR = 'web-view load failed';
|
|
32
|
+
|
|
33
|
+
/** `@load`: which page finished. Field order is part of the contract. */
|
|
34
|
+
export function loadPayload(src: string): string {
|
|
35
|
+
return JSON.stringify({ src });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `@error`: which page failed, and a fixed message. */
|
|
39
|
+
export function errorPayload(src: string): string {
|
|
40
|
+
return JSON.stringify({ src, errMsg: WEB_VIEW_ERROR });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `@message`: what the page passed to fjs.postMessage, verbatim. Objects
|
|
44
|
+
* are the page's job to stringify — only strings cross this boundary. */
|
|
45
|
+
export function messagePayload(data: string): string {
|
|
46
|
+
return JSON.stringify({ data });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SrcKind = 'empty' | 'http' | 'asset' | 'local' | 'unsupported';
|
|
50
|
+
|
|
51
|
+
/** What a `src` is, before anyone tries to load it.
|
|
52
|
+
*
|
|
53
|
+
* Three loadable shapes. `http(s)` is the network. `asset://` is a file THIS
|
|
54
|
+
* MODULE ships — it resolves under `modules/webview/`, so an app cannot
|
|
55
|
+
* name its own page that way. A root path (`/html/guide.html`) is the app's
|
|
56
|
+
* own page, from the project's `html/` directory
|
|
57
|
+
* (specs/018-src-hints-and-html-dir).
|
|
58
|
+
*
|
|
59
|
+
* Everything else stays refused: `file:`, `javascript:`, `data:` and friends
|
|
60
|
+
* differ so much between WKWebView and a browser that accepting them would
|
|
61
|
+
* be handing pages a portability trap. */
|
|
62
|
+
export function classifySrc(raw: unknown): SrcKind {
|
|
63
|
+
const src = raw == null ? '' : String(raw).trim();
|
|
64
|
+
if (!src) return 'empty';
|
|
65
|
+
if (src.startsWith('http://') || src.startsWith('https://')) return 'http';
|
|
66
|
+
if (src.startsWith('asset://')) return 'asset';
|
|
67
|
+
if (src.startsWith('/')) return 'local';
|
|
68
|
+
return 'unsupported';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function unsupportedSrcMessage(raw: unknown): string {
|
|
72
|
+
return (
|
|
73
|
+
`<web-view> will not load "${String(raw)}": only http(s)://, ` +
|
|
74
|
+
'a root path like "/html/page.html" (a file in the project\'s html/ ' +
|
|
75
|
+
'directory) and asset:// (a file this module ships) are supported. ' +
|
|
76
|
+
'Other schemes behave too differently between WKWebView and the ' +
|
|
77
|
+
'browser to promise.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The path part of an `asset://` src, with the scheme and any leading
|
|
82
|
+
* slashes removed. Returns null for anything that escapes the module's own
|
|
83
|
+
* directory. */
|
|
84
|
+
export function assetPath(raw: string): string | null {
|
|
85
|
+
const path = raw.slice('asset://'.length).replace(/^\/+/, '');
|
|
86
|
+
if (!path || path.includes('..')) return null;
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The path part of a root-absolute src, with leading slashes removed.
|
|
91
|
+
* Returns null for anything that escapes the app's own files. */
|
|
92
|
+
export function localPath(raw: string): string | null {
|
|
93
|
+
const path = raw.replace(/^\/+/, '');
|
|
94
|
+
if (!path || path.includes('..')) return null;
|
|
95
|
+
return path;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where the loaded page lives, per target.
|
|
99
|
+
*
|
|
100
|
+
* * `web` — the app's own static root, put there by the prepare hook;
|
|
101
|
+
* * `app-dev` — the dev server already serves `/modules/<name>/…`;
|
|
102
|
+
* * `app-release` — a Flutter asset, which the Dart side loads with
|
|
103
|
+
* `loadFlutterAsset` rather than as a URL. It is returned as a distinct
|
|
104
|
+
* shape for exactly that reason.
|
|
105
|
+
*/
|
|
106
|
+
export type SrcTarget =
|
|
107
|
+
| { target: 'web' }
|
|
108
|
+
| { target: 'app-dev'; devHost: string }
|
|
109
|
+
| { target: 'app-release' };
|
|
110
|
+
|
|
111
|
+
export type ResolvedSrc =
|
|
112
|
+
| { kind: 'url'; url: string }
|
|
113
|
+
| { kind: 'flutter-asset'; asset: string; suffix: string }
|
|
114
|
+
| { kind: 'none' };
|
|
115
|
+
|
|
116
|
+
/** Everything before `?` / `#`. A Flutter asset key cannot carry either. */
|
|
117
|
+
export function stripQuery(path: string): string {
|
|
118
|
+
const cut = path.search(/[?#]/);
|
|
119
|
+
return cut < 0 ? path : path.slice(0, cut);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The part that belongs to the document URL rather than the asset key. */
|
|
123
|
+
export function assetSuffix(path: string): string {
|
|
124
|
+
const cut = path.search(/[?#]/);
|
|
125
|
+
return cut < 0 ? '' : path.slice(cut);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveSrc(raw: unknown, where: SrcTarget): ResolvedSrc {
|
|
129
|
+
const src = raw == null ? '' : String(raw).trim();
|
|
130
|
+
switch (classifySrc(src)) {
|
|
131
|
+
case 'http':
|
|
132
|
+
return { kind: 'url', url: src };
|
|
133
|
+
case 'local': {
|
|
134
|
+
// The app's own file. It rides the same root-path contract images use
|
|
135
|
+
// (specs/017-local-image-assets): the browser serves it from the site
|
|
136
|
+
// root, the dev server answers for it, and a release build has it as a
|
|
137
|
+
// Flutter asset under assets/fjs/public/.
|
|
138
|
+
const path = localPath(src);
|
|
139
|
+
if (!path) return { kind: 'none' };
|
|
140
|
+
if (where.target === 'web') return { kind: 'url', url: `/${path}` };
|
|
141
|
+
if (where.target === 'app-dev') {
|
|
142
|
+
const host = where.devHost.replace(/\/+$/, '');
|
|
143
|
+
return { kind: 'url', url: `${host}/${path}` };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
kind: 'flutter-asset',
|
|
147
|
+
asset: `${APP_ASSET_BASE}/${stripQuery(path)}`,
|
|
148
|
+
suffix: assetSuffix(path),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
case 'asset': {
|
|
152
|
+
const path = assetPath(src);
|
|
153
|
+
if (!path) return { kind: 'none' };
|
|
154
|
+
if (where.target === 'web') {
|
|
155
|
+
return { kind: 'url', url: `${WEB_ASSET_BASE}/${path}` };
|
|
156
|
+
}
|
|
157
|
+
if (where.target === 'app-dev') {
|
|
158
|
+
const host = where.devHost.replace(/\/+$/, '');
|
|
159
|
+
return { kind: 'url', url: `${host}/modules/${WEB_VIEW_MODULE}/${path}` };
|
|
160
|
+
}
|
|
161
|
+
// A Flutter asset is a KEY, not a URL: loadFlutterAsset looks the
|
|
162
|
+
// string up in the bundle's manifest, so `demo.html?q=1` is simply not
|
|
163
|
+
// a file and the platform throws. The key is stripped while the caller
|
|
164
|
+
// keeps the suffix for the document URL. The two values must stay
|
|
165
|
+
// separate: one is a bundle manifest key, the other is page state.
|
|
166
|
+
return {
|
|
167
|
+
kind: 'flutter-asset',
|
|
168
|
+
asset: `assets/fjs/modules/${WEB_VIEW_MODULE}/${stripQuery(path)}`,
|
|
169
|
+
suffix: assetSuffix(path),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
return { kind: 'none' };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One terminal event per load, and never the previous page's.
|
|
178
|
+
*
|
|
179
|
+
* Same shape as the image module's cycle: a `src` change begins a new
|
|
180
|
+
* generation, a result carrying an old one is dropped, and `load`/`error`
|
|
181
|
+
* are mutually exclusive within a generation. */
|
|
182
|
+
export class LoadCycle {
|
|
183
|
+
private generation = 0;
|
|
184
|
+
private settled = false;
|
|
185
|
+
|
|
186
|
+
/** Starts a new load; returns its generation. */
|
|
187
|
+
begin(): number {
|
|
188
|
+
this.generation += 1;
|
|
189
|
+
this.settled = false;
|
|
190
|
+
return this.generation;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get current(): number {
|
|
194
|
+
return this.generation;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when this result should be reported: it belongs to the current
|
|
198
|
+
* load and nothing has settled it yet. */
|
|
199
|
+
finish(generation: number): boolean {
|
|
200
|
+
if (generation !== this.generation || this.settled) return false;
|
|
201
|
+
this.settled = true;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Whether a message from [generation] is still the current page's. A
|
|
206
|
+
* message is not terminal, so it does not settle anything. */
|
|
207
|
+
accepts(generation: number): boolean {
|
|
208
|
+
return generation === this.generation;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ufjs/webview",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "web-view for fjs — one <web-view /> tag, a WKWebView/WebView on the app and an iframe on the web",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/snice/flutter-js.git",
|
|
9
|
+
"directory": "packages/fjs-webview"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/snice/flutter-js#readme",
|
|
12
|
+
"bugs": "https://github.com/snice/flutter-js/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"fjs",
|
|
15
|
+
"flutter",
|
|
16
|
+
"webview",
|
|
17
|
+
"iframe",
|
|
18
|
+
"vue"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"types": "./index.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.ts",
|
|
24
|
+
"./components/*": "./components/*",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.ts",
|
|
29
|
+
"components",
|
|
30
|
+
"flutter",
|
|
31
|
+
"public",
|
|
32
|
+
"prepare.mjs",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@ufjs/runtime": ">=0.1.3",
|
|
41
|
+
"vue": "^3.4.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@vitejs/plugin-vue": "^6.0.3",
|
|
45
|
+
"happy-dom": "^20.12.0",
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"vitest": "^4.1.11",
|
|
48
|
+
"vue": "^3.5.42"
|
|
49
|
+
},
|
|
50
|
+
"fjs": {
|
|
51
|
+
"module": true,
|
|
52
|
+
"widgets": {
|
|
53
|
+
"web-view": {
|
|
54
|
+
"web": "./components/WebViewWeb.vue"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"flutter": {
|
|
58
|
+
"package": "fjs_webview",
|
|
59
|
+
"path": "./flutter",
|
|
60
|
+
"import": "package:fjs_webview/fjs_webview.dart",
|
|
61
|
+
"register": "FjsWebview.register(engine)"
|
|
62
|
+
},
|
|
63
|
+
"prepare": "./prepare.mjs"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"typecheck": "tsc --noEmit"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/prepare.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The module's build step. fjs runs this before every build, dev start and
|
|
2
|
+
// Vite start, because package.json says `"fjs": { "prepare": "./prepare.mjs" }`.
|
|
3
|
+
//
|
|
4
|
+
// It copies this module's `public/` — the pages it ships — into the one
|
|
5
|
+
// place both targets read from:
|
|
6
|
+
//
|
|
7
|
+
// .fjs/modules/webview/<file> the only copy
|
|
8
|
+
// → app dev: `fjs dev` serves /modules/webview/<file>
|
|
9
|
+
// → app release: the build copies it to assets/fjs/modules/webview/
|
|
10
|
+
// → web: the fjs vite plugin serves it at /fjs-modules/webview/,
|
|
11
|
+
// and the web builds copy it to the same path
|
|
12
|
+
//
|
|
13
|
+
// It used to write a SECOND copy into the app's own `public/fjs-modules/` —
|
|
14
|
+
// the only hook that ever wrote outside ctx.outDir — because that was the
|
|
15
|
+
// one place vite would serve it from without the app editing its config.
|
|
16
|
+
// Once `public/` started riding into the Flutter bundle wholesale
|
|
17
|
+
// (specs/017-local-image-assets), that second copy became a duplicate file
|
|
18
|
+
// in every release build, and the app side never read it. The toolchain now
|
|
19
|
+
// gives the one copy its web URL instead (specs/018-src-hints-and-html-dir),
|
|
20
|
+
// so a hook is back to writing only where it should.
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const MODULE = 'webview';
|
|
25
|
+
|
|
26
|
+
export default async function prepare(ctx) {
|
|
27
|
+
const source = path.join(ctx.module.dir, 'public');
|
|
28
|
+
if (!fs.existsSync(source)) return;
|
|
29
|
+
|
|
30
|
+
const files = fs
|
|
31
|
+
.readdirSync(source, { withFileTypes: true })
|
|
32
|
+
.filter((entry) => entry.isFile())
|
|
33
|
+
.map((entry) => entry.name);
|
|
34
|
+
|
|
35
|
+
for (const name of files) {
|
|
36
|
+
ctx.write(name, fs.readFileSync(path.join(source, name), 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
ctx.log(`${files.length} page(s) → .fjs/modules/${MODULE}/`);
|
|
40
|
+
}
|