@livx.cc/appwrap 0.61.23 → 0.61.25
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/package.json +1 -1
- package/runtime/app/shell/bridge-origin.ts +2 -0
- package/runtime/app/shell/custom-webview.android.ts +12 -12
- package/runtime/app/shell/handlers-webview.ts +108 -3
- package/runtime/app/shell/prompt-transport.ts +37 -0
- package/runtime/tests/bridge-origin.test.ts +11 -0
- package/runtime/tests/prompt-transport.test.ts +39 -0
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ export function isTrustedOrigin(origin: string | null | undefined): boolean {
|
|
|
29
29
|
const o = originOf(origin);
|
|
30
30
|
if (!o) return false;
|
|
31
31
|
if (o === 'app://localhost' || (SHELL_CONFIG.loader === 'file' && o === 'file://')) return true;
|
|
32
|
+
// Android serves the bundle from https://appwrap.local (custom-webview.android APP_ORIGIN, intercepted in-process).
|
|
33
|
+
if (SHELL_CONFIG.loader !== 'server' && SHELL_CONFIG.loader !== 'file' && o === 'https://appwrap.local') return true;
|
|
32
34
|
if (SHELL_CONFIG.loader === 'server' && o === originOf(effectiveServerUrl())) return true;
|
|
33
35
|
return (SHELL_CONFIG.appBoundDomains ?? []).some((h) => o === `https://${String(h).toLowerCase()}`);
|
|
34
36
|
}
|
|
@@ -6,6 +6,7 @@ import { envGlobalsJs } from './env';
|
|
|
6
6
|
import { handleWebPermissionRequest } from './android-helpers';
|
|
7
7
|
import { showFileChooser } from './file-chooser.android';
|
|
8
8
|
import { hostOf } from './env-switcher';
|
|
9
|
+
import { TRANSPORT_SHIM, PROMPT_PREFIX, CHUNK_PREFIX, takeChunk } from './prompt-transport';
|
|
9
10
|
|
|
10
11
|
// `android` + `java` resolve to the real types-android namespaces (no declare needed).
|
|
11
12
|
declare const androidx: any; // no NS types: androidx.webkit.* (WebViewFeature/WebViewCompat) not in the android-32 platform typings
|
|
@@ -13,18 +14,7 @@ declare const androidx: any; // no NS types: androidx.webkit.* (WebViewFeature/W
|
|
|
13
14
|
/** Stable in-app origin — secure context, ES modules work (file:// blocks them). */
|
|
14
15
|
export const APP_ORIGIN = 'https://appwrap.local';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
* Web→native transport shim. postMessage tunnels through window.prompt(), which
|
|
18
|
-
* the shell intercepts synchronously in WebChromeClient.onJsPrompt — no compiled
|
|
19
|
-
* @JavascriptInterface class, no polling. Injected at document start (androidx.webkit)
|
|
20
|
-
* with an onPageStarted fallback; the guard makes double-injection a no-op.
|
|
21
|
-
*/
|
|
22
|
-
const TRANSPORT_SHIM = `(function(){
|
|
23
|
-
if (window.appwrapNative) return;
|
|
24
|
-
window.appwrapNative = { postMessage: function(json){ window.prompt('__appwrap__:' + json); } };
|
|
25
|
-
})();`;
|
|
26
|
-
|
|
27
|
-
const PROMPT_PREFIX = '__appwrap__:';
|
|
17
|
+
// Web→native transport (prompt() tunnel, chunked past Chromium's ~10K prompt cap): ./prompt-transport.
|
|
28
18
|
|
|
29
19
|
/** Document-start scripts: env hints + framework globals (backend origin) + bridge transport +
|
|
30
20
|
* native-feel. Globals first so the page can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__ before its
|
|
@@ -60,6 +50,12 @@ function getChromeClientClass(): any {
|
|
|
60
50
|
CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length), url);
|
|
61
51
|
return true;
|
|
62
52
|
}
|
|
53
|
+
if (typeof message === 'string' && message.startsWith(CHUNK_PREFIX)) {
|
|
54
|
+
result.confirm('');
|
|
55
|
+
const json = takeChunk(message.slice(CHUNK_PREFIX.length), url);
|
|
56
|
+
if (json !== null) CustomWebView.forNative(view)?.onAppwrapMessage?.(json, url);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
63
59
|
return false; // genuine page prompt — default handling
|
|
64
60
|
},
|
|
65
61
|
|
|
@@ -152,6 +148,10 @@ export class CustomWebView extends WebView {
|
|
|
152
148
|
settings.setDomStorageEnabled(true);
|
|
153
149
|
settings.setAllowFileAccess(true);
|
|
154
150
|
settings.setMediaPlaybackRequiresUserGesture(false); // speaker: autoplay allowed
|
|
151
|
+
// The bundle runs at https://appwrap.local, so Chromium blocks its fetch/XHR/ws to plain-http hosts as mixed
|
|
152
|
+
// content — iOS's app:// origin is not https and never hits this. Parity: a bundled app may talk to http LAN
|
|
153
|
+
// hosts (cleartext is already allowed in the manifest); a server-loaded page keeps the default block.
|
|
154
|
+
if (SHELL_CONFIG.loader !== 'server' && SHELL_CONFIG.loader !== 'file') settings.setMixedContentMode(android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
|
155
155
|
// Native feel: no pinch-zoom controls.
|
|
156
156
|
settings.setSupportZoom(false);
|
|
157
157
|
settings.setBuiltInZoomControls(false);
|
|
@@ -28,7 +28,14 @@ const KVO_KEYS = ['URL', 'title', 'canGoBack', 'canGoForward', 'loading'];
|
|
|
28
28
|
* overlay's page; resolves {result} (the returned value, JSON round-tripped).
|
|
29
29
|
* `world` = a named isolated JS world (iOS; Android has one world). The
|
|
30
30
|
* hub's own (trusted) origin only — never in bridge-origin's FOREIGN_ALLOWED.
|
|
31
|
-
*
|
|
31
|
+
* webview.fab {show, bottom?, right?, size?, color?, symbol?, text?, label?}
|
|
32
|
+
* — a round floating button over the app AND the overlay (an "assistant"
|
|
33
|
+
* button that stays tappable while a page is shown): `bottom`/`right` = CSS px
|
|
34
|
+
* from the app WebView's bottom-right, `size` (52), `color` #rrggbb, `symbol` an
|
|
35
|
+
* SF Symbol (iOS, default 'sparkles'), `text` its glyph on Android ('✦'),
|
|
36
|
+
* `label` = accessibility, `radius` (default round), symbol/text '' = a
|
|
37
|
+
* plain shape. show:false hides it. Taps emit `webview.fab`.
|
|
38
|
+
* Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change; `webview.fab` on a tap.
|
|
32
39
|
*
|
|
33
40
|
* Swipe back/forward on, target=_blank/window.open loads in place (shared ios-ui-delegate), keyboard +
|
|
34
41
|
* safe areas handled by WKWebView's own scroll-view insets (contentInsetAdjustment automatic).
|
|
@@ -51,6 +58,14 @@ const evalJs = (p?: { js?: string } | null) => {
|
|
|
51
58
|
if (!js) throw err('NATIVE_ERROR', 'webview.eval: empty js');
|
|
52
59
|
return js;
|
|
53
60
|
};
|
|
61
|
+
/** webview.fab params (sizes in CSS px). */
|
|
62
|
+
type Fab = { show?: boolean; bottom?: number; right?: number; size?: number; radius?: number; color?: string; symbol?: string; text?: string; label?: string };
|
|
63
|
+
const fabOpts = (p?: Fab | null) => ({
|
|
64
|
+
show: !!p?.show, bottom: Math.max(0, Number(p?.bottom) || 16), right: Math.max(0, Number(p?.right) || 16),
|
|
65
|
+
size: Math.min(96, Math.max(32, Number(p?.size) || 52)), rgb: /^#?([0-9a-f]{6})$/i.exec(String(p?.color ?? '').trim())?.[1] ?? '2f6b4c',
|
|
66
|
+
symbol: p?.symbol === '' ? '' : String(p?.symbol || 'sparkles'), text: p?.text === '' ? '' : String(p?.text || '✦'),
|
|
67
|
+
radius: p?.radius === undefined ? null : Math.max(0, Number(p.radius) || 0), label: String(p?.label || 'Assistant'),
|
|
68
|
+
});
|
|
54
69
|
const parsed = (s: unknown) => { try { return JSON.parse(String(s)); } catch { return s ?? null; } };
|
|
55
70
|
const EVAL_TIMEOUT = 30000;
|
|
56
71
|
|
|
@@ -76,6 +91,11 @@ function registerIos(): void {
|
|
|
76
91
|
let bottomConstraint: NSLayoutConstraint | null = null;
|
|
77
92
|
let emitQueued = false;
|
|
78
93
|
let ObserverClass: any; // KVO sink, built once (ObjC class names are global)
|
|
94
|
+
let fab: UIButton | null = null;
|
|
95
|
+
let fabTarget: any = null, FabTargetClass: any; // its tap target (an exposedMethods NSObject, built once)
|
|
96
|
+
let fabBottom: NSLayoutConstraint | null = null, fabRight: NSLayoutConstraint | null = null, fabW: NSLayoutConstraint | null = null, fabH: NSLayoutConstraint | null = null;
|
|
97
|
+
/** The button stays above the overlay (which comes to the front on open/show). */
|
|
98
|
+
const fabFront = () => { if (fab && !fab.hidden) fab.superview?.bringSubviewToFront(fab); };
|
|
79
99
|
|
|
80
100
|
const state = () => ({
|
|
81
101
|
url: wv?.URL?.absoluteString ?? '',
|
|
@@ -171,6 +191,7 @@ function registerIos(): void {
|
|
|
171
191
|
else setInsets(top, bottom);
|
|
172
192
|
wv!.hidden = false;
|
|
173
193
|
wv!.superview?.bringSubviewToFront(wv!);
|
|
194
|
+
fabFront();
|
|
174
195
|
load(target);
|
|
175
196
|
return state();
|
|
176
197
|
});
|
|
@@ -190,11 +211,51 @@ function registerIos(): void {
|
|
|
190
211
|
|
|
191
212
|
bridge.register('webview.hide', () => onMain(() => { if (wv) wv.hidden = true; return { open: !!wv }; }));
|
|
192
213
|
bridge.register('webview.show', (p?: { top?: number; bottom?: number } | null) => onMain(() => {
|
|
193
|
-
if (wv) { setInsets(p?.top, p?.bottom); wv.hidden = false; wv.superview?.bringSubviewToFront(wv); }
|
|
214
|
+
if (wv) { setInsets(p?.top, p?.bottom); wv.hidden = false; wv.superview?.bringSubviewToFront(wv); fabFront(); }
|
|
194
215
|
return { open: !!wv };
|
|
195
216
|
}));
|
|
196
217
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
197
218
|
|
|
219
|
+
bridge.register('webview.fab', (p?: Fab | null) => {
|
|
220
|
+
const o = fabOpts(p);
|
|
221
|
+
return onMain(() => {
|
|
222
|
+
if (!fab) {
|
|
223
|
+
if (!o.show) return { shown: false };
|
|
224
|
+
const host = bridge.getWebView()?.ios as WKWebView | undefined;
|
|
225
|
+
const container = host?.superview ?? Utils.ios.getRootViewController()?.view;
|
|
226
|
+
if (!container) throw err('NATIVE_ERROR', 'webview.fab: no host view');
|
|
227
|
+
FabTargetClass ??= (NSObject as any).extend({ tap() { bridge.emit('webview.fab', {}); } }, { name: 'AppwrapFabTarget', exposedMethods: { tap: { returns: interop.types.void } } });
|
|
228
|
+
fabTarget = FabTargetClass.new();
|
|
229
|
+
const b = UIButton.buttonWithType(UIButtonType.System);
|
|
230
|
+
b.translatesAutoresizingMaskIntoConstraints = false;
|
|
231
|
+
b.addTargetActionForControlEvents(fabTarget, 'tap', UIControlEvents.TouchUpInside);
|
|
232
|
+
b.layer.shadowColor = UIColor.blackColor.CGColor; b.layer.shadowOpacity = 0.28; b.layer.shadowRadius = 9; b.layer.shadowOffset = CGSizeMake(0, 5);
|
|
233
|
+
container.addSubview(b);
|
|
234
|
+
const ref: any = host ?? container;
|
|
235
|
+
fabBottom = b.bottomAnchor.constraintEqualToAnchorConstant(ref.bottomAnchor, -o.bottom);
|
|
236
|
+
fabRight = b.trailingAnchor.constraintEqualToAnchorConstant(ref.trailingAnchor, -o.right);
|
|
237
|
+
fabW = b.widthAnchor.constraintEqualToConstant(o.size); fabH = b.heightAnchor.constraintEqualToConstant(o.size);
|
|
238
|
+
NSLayoutConstraint.activateConstraints([fabBottom, fabRight, fabW, fabH] as any);
|
|
239
|
+
fab = b;
|
|
240
|
+
}
|
|
241
|
+
const n = parseInt(o.rgb, 16);
|
|
242
|
+
fab.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1);
|
|
243
|
+
// the glyph in black or white, whichever reads on the colour
|
|
244
|
+
const light = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) > 150;
|
|
245
|
+
fab.tintColor = light ? UIColor.colorWithRedGreenBlueAlpha(0.06, 0.15, 0.1, 1) : UIColor.whiteColor;
|
|
246
|
+
fab.setImageForState(o.symbol ? UIImage.systemImageNamedWithConfiguration(o.symbol, UIImageSymbolConfiguration.configurationWithPointSizeWeight(o.size * 0.42, UIImageSymbolWeight.Semibold)) : null, UIControlState.Normal);
|
|
247
|
+
fab.layer.cornerRadius = o.radius ?? o.size / 2;
|
|
248
|
+
fab.accessibilityLabel = o.label;
|
|
249
|
+
if (fabBottom) fabBottom.constant = -o.bottom;
|
|
250
|
+
if (fabRight) fabRight.constant = -o.right;
|
|
251
|
+
if (fabW) fabW.constant = o.size;
|
|
252
|
+
if (fabH) fabH.constant = o.size; // (a later call may resize it)
|
|
253
|
+
fab.hidden = !o.show;
|
|
254
|
+
fabFront();
|
|
255
|
+
return { shown: o.show };
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
198
259
|
bridge.register('webview.snapshot', (p?: { width?: number; quality?: number } | null) => {
|
|
199
260
|
const o = snapOpts(p);
|
|
200
261
|
return onMain(() => new Promise<{ jpeg: string; width: number; height: number }>((resolve, reject) => {
|
|
@@ -355,7 +416,22 @@ function registerAndroid(): void {
|
|
|
355
416
|
if (bottom !== undefined) insets.bottom = inset(bottom);
|
|
356
417
|
layout();
|
|
357
418
|
};
|
|
358
|
-
|
|
419
|
+
let fab: any = null, fabO = fabOpts(null);
|
|
420
|
+
/** Place the button at the app WebView's bottom-right (`bottom`/`right` CSS px inside it), above the overlay. */
|
|
421
|
+
const fabLayout = () => {
|
|
422
|
+
const frame = content();
|
|
423
|
+
if (!fab || !frame) return;
|
|
424
|
+
const d = density(), px = Math.round(fabO.size * d), fl = [0, 0], hl = [0, 0];
|
|
425
|
+
const h = host ?? bridge.getWebView()?.android;
|
|
426
|
+
let boxBottom = 0, boxRight = 0;
|
|
427
|
+
if (h) { frame.getLocationInWindow(fl); h.getLocationInWindow(hl); boxBottom = frame.getHeight() - (hl[1] - fl[1] + h.getHeight()); boxRight = frame.getWidth() - (hl[0] - fl[0] + h.getWidth()); }
|
|
428
|
+
const lp = new android.widget.FrameLayout.LayoutParams(px, px, android.view.Gravity.BOTTOM | android.view.Gravity.END);
|
|
429
|
+
lp.bottomMargin = Math.max(0, Math.round(boxBottom + fabO.bottom * d));
|
|
430
|
+
lp.rightMargin = Math.max(0, Math.round(boxRight + fabO.right * d));
|
|
431
|
+
fab.setLayoutParams(lp);
|
|
432
|
+
if (fab.getVisibility() === android.view.View.VISIBLE) fab.bringToFront();
|
|
433
|
+
};
|
|
434
|
+
const reveal = () => { wv.setVisibility(android.view.View.VISIBLE); wv.bringToFront(); if (fab?.getVisibility() === android.view.View.VISIBLE) fab.bringToFront(); };
|
|
359
435
|
|
|
360
436
|
bridge.register('webview.open', ({ url, top, bottom }: { url: string; top?: number; bottom?: number }) => {
|
|
361
437
|
const target = String(url ?? '');
|
|
@@ -388,6 +464,35 @@ function registerAndroid(): void {
|
|
|
388
464
|
}));
|
|
389
465
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
390
466
|
|
|
467
|
+
bridge.register('webview.fab', (p?: Fab | null) => {
|
|
468
|
+
fabO = fabOpts(p);
|
|
469
|
+
return onMain(() => {
|
|
470
|
+
const frame = content();
|
|
471
|
+
if (!fab) {
|
|
472
|
+
if (!fabO.show) return { shown: false };
|
|
473
|
+
if (!frame) throw err('NATIVE_ERROR', 'webview.fab: no host view');
|
|
474
|
+
fab = new android.widget.TextView(activity());
|
|
475
|
+
fab.setGravity(android.view.Gravity.CENTER);
|
|
476
|
+
fab.setElevation(8 * density());
|
|
477
|
+
fab.setOnClickListener(new android.view.View.OnClickListener({ onClick() { bridge.emit('webview.fab', {}); } }));
|
|
478
|
+
frame.addView(fab);
|
|
479
|
+
}
|
|
480
|
+
const n = parseInt(fabO.rgb, 16), light = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) > 150;
|
|
481
|
+
const bg = new android.graphics.drawable.GradientDrawable();
|
|
482
|
+
if (fabO.radius === null) bg.setShape(android.graphics.drawable.GradientDrawable.OVAL);
|
|
483
|
+
else bg.setCornerRadius(fabO.radius * density());
|
|
484
|
+
bg.setColor(android.graphics.Color.rgb((n >> 16) & 255, (n >> 8) & 255, n & 255));
|
|
485
|
+
fab.setBackground(bg);
|
|
486
|
+
fab.setText(fabO.text);
|
|
487
|
+
fab.setTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, fabO.size * 0.42);
|
|
488
|
+
fab.setTextColor(light ? android.graphics.Color.rgb(16, 38, 26) : android.graphics.Color.WHITE);
|
|
489
|
+
fab.setContentDescription(fabO.label);
|
|
490
|
+
fab.setVisibility(fabO.show ? android.view.View.VISIBLE : android.view.View.GONE);
|
|
491
|
+
fabLayout();
|
|
492
|
+
return { shown: fabO.show };
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
391
496
|
bridge.register('webview.snapshot', (p?: { width?: number; quality?: number } | null) => {
|
|
392
497
|
const o = snapOpts(p);
|
|
393
498
|
return onMain(() => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android web→native transport over window.prompt(). Chromium truncates a prompt() message at ~10K chars
|
|
3
|
+
* (device-measured: a 10.1K envelope arrives, 10.3K arrives cut → unparseable → the call hangs to its timeout),
|
|
4
|
+
* so an envelope longer than CHUNK goes as numbered parts `__appwrapc__:<id>:<i>:<n>:<part>`, reassembled here.
|
|
5
|
+
*/
|
|
6
|
+
export const PROMPT_PREFIX = '__appwrap__:';
|
|
7
|
+
export const CHUNK_PREFIX = '__appwrapc__:';
|
|
8
|
+
const CHUNK = 8000;
|
|
9
|
+
|
|
10
|
+
/** Injected at document start; the guard makes double-injection a no-op. Never splits a surrogate pair. */
|
|
11
|
+
export const TRANSPORT_SHIM = `(function(){
|
|
12
|
+
if (window.appwrapNative) return;
|
|
13
|
+
var seq = 0;
|
|
14
|
+
window.appwrapNative = { postMessage: function(json){
|
|
15
|
+
if (json.length <= ${CHUNK}) return window.prompt('${PROMPT_PREFIX}' + json);
|
|
16
|
+
var parts = [], i = 0;
|
|
17
|
+
while (i < json.length) { var e = Math.min(json.length, i + ${CHUNK}); var c = json.charCodeAt(e - 1); if (e < json.length && c >= 0xD800 && c <= 0xDBFF) e--; parts.push(json.slice(i, e)); i = e; }
|
|
18
|
+
var id = (++seq) + Math.random().toString(36).slice(2);
|
|
19
|
+
for (var k = 0; k < parts.length; k++) window.prompt('${CHUNK_PREFIX}' + id + ':' + k + ':' + parts.length + ':' + parts[k]);
|
|
20
|
+
} };
|
|
21
|
+
})();`;
|
|
22
|
+
|
|
23
|
+
const pending = new Map<string, { url: string; parts: string[]; got: number }>();
|
|
24
|
+
|
|
25
|
+
/** One chunk in; the whole envelope out once its last part lands (all parts must come from the same page url). */
|
|
26
|
+
export function takeChunk(message: string, url: string): string | null {
|
|
27
|
+
const m = /^([^:]+):(\d+):(\d+):/.exec(message);
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
const [head, id, i, n] = [m[0], m[1], +m[2], +m[3]];
|
|
30
|
+
let p = pending.get(id);
|
|
31
|
+
if (!p) { if (pending.size > 32) pending.clear(); p = { url, parts: new Array(n), got: 0 }; pending.set(id, p); }
|
|
32
|
+
if (p.url !== url || i >= p.parts.length || p.parts[i] !== undefined) { pending.delete(id); return null; }
|
|
33
|
+
p.parts[i] = message.slice(head.length);
|
|
34
|
+
if (++p.got < p.parts.length) return null;
|
|
35
|
+
pending.delete(id);
|
|
36
|
+
return p.parts.join('');
|
|
37
|
+
}
|
|
@@ -60,6 +60,17 @@ describe('Bridge origin gate — a foreign page must not reach the app’s store
|
|
|
60
60
|
} finally { Object.assign(SHELL_CONFIG, prev); }
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test('Android bundle origin (https://appwrap.local) is trusted for a bundled loader, not for server', () => {
|
|
64
|
+
const prev = { loader: SHELL_CONFIG.loader, serverUrl: SHELL_CONFIG.serverUrl };
|
|
65
|
+
try {
|
|
66
|
+
Object.assign(SHELL_CONFIG, { loader: 'app' });
|
|
67
|
+
expect(bridgeAllows('https://appwrap.local/index.html', 'webview.eval')).toBe(true);
|
|
68
|
+
expect(bridgeAllows('https://appwrap.local.evil.io', 'webview.eval')).toBe(false);
|
|
69
|
+
Object.assign(SHELL_CONFIG, { loader: 'server', serverUrl: 'https://app.example.com' });
|
|
70
|
+
expect(bridgeAllows('https://appwrap.local', 'webview.eval')).toBe(false);
|
|
71
|
+
} finally { Object.assign(SHELL_CONFIG, prev); }
|
|
72
|
+
});
|
|
73
|
+
|
|
63
74
|
test('originOf normalises', () => {
|
|
64
75
|
expect(originOf('HTTP://U:p@Host:80/a?b#c')).toBe('http://host');
|
|
65
76
|
expect(originOf('app://localhost/index.html')).toBe('app://localhost');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { TRANSPORT_SHIM, PROMPT_PREFIX, CHUNK_PREFIX, takeChunk } from '../app/shell/prompt-transport';
|
|
3
|
+
|
|
4
|
+
/** Run the shim against a fake window; returns what native's onJsPrompt would see. */
|
|
5
|
+
const send = (json: string) => {
|
|
6
|
+
const prompts: string[] = [];
|
|
7
|
+
const window: any = { prompt: (m: string) => { prompts.push(m); } };
|
|
8
|
+
new Function('window', TRANSPORT_SHIM)(window);
|
|
9
|
+
window.appwrapNative.postMessage(json);
|
|
10
|
+
return prompts;
|
|
11
|
+
};
|
|
12
|
+
const deliver = (prompts: string[], url = 'https://appwrap.local/') => {
|
|
13
|
+
const out: string[] = [];
|
|
14
|
+
for (const m of prompts) {
|
|
15
|
+
if (m.startsWith(PROMPT_PREFIX)) out.push(m.slice(PROMPT_PREFIX.length));
|
|
16
|
+
else { const j = takeChunk(m.slice(CHUNK_PREFIX.length), url); if (j !== null) out.push(j); }
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('prompt transport', () => {
|
|
22
|
+
test('a small envelope is one prompt', () => {
|
|
23
|
+
const p = send('{"a":1}');
|
|
24
|
+
expect(p).toEqual([PROMPT_PREFIX + '{"a":1}']);
|
|
25
|
+
});
|
|
26
|
+
test('a large envelope is chunked under the ~10K prompt cap and reassembled exactly (surrogates intact)', () => {
|
|
27
|
+
const json = JSON.stringify({ js: 'x'.repeat(7999) + '😀'.repeat(5000) + 'tail' });
|
|
28
|
+
const p = send(json);
|
|
29
|
+
expect(p.length).toBeGreaterThan(1);
|
|
30
|
+
for (const m of p) expect(m.length).toBeLessThan(10000);
|
|
31
|
+
for (const m of p) expect(/[\uD800-\uDBFF]$/.test(m)).toBe(false);
|
|
32
|
+
expect(deliver(p)).toEqual([json]);
|
|
33
|
+
});
|
|
34
|
+
test('parts from a different page url are dropped', () => {
|
|
35
|
+
const p = send('y'.repeat(20000));
|
|
36
|
+
const out: (string | null)[] = p.map((m, i) => takeChunk(m.slice(CHUNK_PREFIX.length), i ? 'https://evil.example/' : 'https://appwrap.local/'));
|
|
37
|
+
expect(out.every((x) => x === null)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|