@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,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
+ }