@livx.cc/appwrap 0.61.22 → 0.61.24

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.61.22",
3
+ "version": "0.61.24",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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);
@@ -21,6 +21,13 @@ const KVO_KEYS = ['URL', 'title', 'canGoBack', 'canGoForward', 'loading'];
21
21
  * webview.close — tear down; emits `webview.closed`
22
22
  * webview.cookies {cookies: Cookie[]} — write cookies into the overlay's (persistent) jar; resolves {set}
23
23
  * once all are stored (Bare Remote's opt-in "sign in like the Mac")
24
+ * webview.snapshot {width?, quality?} — what the overlay shows now as a JPEG ~`width` px wide (default 360,
25
+ * quality 0.6); resolves {jpeg: base64, width, height}. Must run while
26
+ * it is still shown (take it before hide/close).
27
+ * webview.eval {js, world?} — run `js` (an ASYNC function body: may `await`, `return`s the result) in the
28
+ * overlay's page; resolves {result} (the returned value, JSON round-tripped).
29
+ * `world` = a named isolated JS world (iOS; Android has one world). The
30
+ * hub's own (trusted) origin only — never in bridge-origin's FOREIGN_ALLOWED.
24
31
  * Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change.
25
32
  *
26
33
  * Swipe back/forward on, target=_blank/window.open loads in place (shared ios-ui-delegate), keyboard +
@@ -37,6 +44,22 @@ const cookieList = (p: { cookies?: Cookie[] } | null): Cookie[] => {
37
44
  return l.filter((c) => c && c.name && c.domain);
38
45
  };
39
46
 
47
+ /** webview.eval: the page side wraps the body so any value crosses as JSON (undefined → null). */
48
+ const evalBody = (js: string) => `const __v = await (async () => { ${js}\n })(); return JSON.stringify(__v === undefined ? null : __v);`;
49
+ const evalJs = (p?: { js?: string } | null) => {
50
+ const js = String(p?.js ?? '');
51
+ if (!js) throw err('NATIVE_ERROR', 'webview.eval: empty js');
52
+ return js;
53
+ };
54
+ const parsed = (s: unknown) => { try { return JSON.parse(String(s)); } catch { return s ?? null; } };
55
+ const EVAL_TIMEOUT = 30000;
56
+
57
+ /** webview.snapshot params → target pixel width (clamped) + JPEG quality 0..1. */
58
+ const snapOpts = (p?: { width?: number; quality?: number } | null) => ({
59
+ width: Math.round(Math.min(1024, Math.max(64, Number(p?.width) || 360))),
60
+ quality: Math.min(1, Math.max(0.1, Number(p?.quality) || 0.6)),
61
+ });
62
+
40
63
  export function registerWebViewHandlers(): void {
41
64
  if (isIOS) registerIos();
42
65
  else if (isAndroid) registerAndroid();
@@ -172,6 +195,32 @@ function registerIos(): void {
172
195
  }));
173
196
  bridge.register('webview.close', () => onMain(() => { destroy(); }));
174
197
 
198
+ bridge.register('webview.snapshot', (p?: { width?: number; quality?: number } | null) => {
199
+ const o = snapOpts(p);
200
+ return onMain(() => new Promise<{ jpeg: string; width: number; height: number }>((resolve, reject) => {
201
+ if (!wv || wv.hidden) return reject(err('NATIVE_ERROR', 'webview.snapshot: not shown'));
202
+ const cfg = WKSnapshotConfiguration.new();
203
+ cfg.snapshotWidth = o.width / (UIScreen.mainScreen.scale || 1) as any; // points; the image comes at screen scale
204
+ wv.takeSnapshotWithConfigurationCompletionHandler(cfg, (img: UIImage, e: NSError) => {
205
+ const data = img && UIImageJPEGRepresentation(img, o.quality);
206
+ if (!data) return reject(err('NATIVE_ERROR', `webview.snapshot: ${e?.localizedDescription || 'no image'}`));
207
+ resolve({ jpeg: data.base64EncodedStringWithOptions(0 as any), width: Math.round(img.size.width * img.scale), height: Math.round(img.size.height * img.scale) });
208
+ });
209
+ }));
210
+ });
211
+
212
+ bridge.register('webview.eval', (p?: { js?: string; world?: string } | null) => {
213
+ const js = evalJs(p);
214
+ return onMain(() => new Promise<{ result: unknown }>((resolve, reject) => {
215
+ if (!wv) return reject(err('NATIVE_ERROR', 'webview.eval: not open'));
216
+ const world = p?.world ? WKContentWorld.worldWithName(String(p.world)) : WKContentWorld.pageWorld;
217
+ wv.callAsyncJavaScriptArgumentsInFrameInContentWorldCompletionHandler(evalBody(js), null, null, world, (v: any, e: NSError) => {
218
+ if (e) return reject(err('NATIVE_ERROR', `webview.eval: ${e.userInfo?.objectForKey('WKJavaScriptExceptionMessage') || e.localizedDescription}`));
219
+ resolve({ result: parsed(v) });
220
+ });
221
+ }));
222
+ });
223
+
175
224
  bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
176
225
  const list = cookieList(p);
177
226
  return onMain(() => new Promise<{ set: number }>((resolve) => {
@@ -339,6 +388,50 @@ function registerAndroid(): void {
339
388
  }));
340
389
  bridge.register('webview.close', () => onMain(() => { destroy(); }));
341
390
 
391
+ bridge.register('webview.snapshot', (p?: { width?: number; quality?: number } | null) => {
392
+ const o = snapOpts(p);
393
+ return onMain(() => {
394
+ if (!wv || wv.getVisibility() !== android.view.View.VISIBLE || !wv.getWidth() || !wv.getHeight()) throw err('NATIVE_ERROR', 'webview.snapshot: not shown');
395
+ const s = o.width / wv.getWidth(), h = Math.max(1, Math.round(wv.getHeight() * s));
396
+ const bmp = android.graphics.Bitmap.createBitmap(o.width, h, android.graphics.Bitmap.Config.ARGB_8888);
397
+ const c = new android.graphics.Canvas(bmp);
398
+ c.scale(s, s);
399
+ c.translate(-wv.getScrollX(), -wv.getScrollY()); // draw() paints from the content origin
400
+ wv.draw(c);
401
+ const out = new java.io.ByteArrayOutputStream();
402
+ bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, Math.round(o.quality * 100), out);
403
+ bmp.recycle();
404
+ return { jpeg: android.util.Base64.encodeToString(out.toByteArray(), android.util.Base64.NO_WRAP), width: o.width, height: h };
405
+ });
406
+ });
407
+
408
+ // evaluateJavascript can't await: start the async body under an id, then poll its slot until it settles.
409
+ let evalSeq = 0;
410
+ bridge.register('webview.eval', (p?: { js?: string } | null) => {
411
+ const js = evalJs(p), id = ++evalSeq, slot = `(window.__awEval||(window.__awEval={}))`;
412
+ const start = `(function(){var s=${slot};(async()=>{ ${evalBody(js)}\n })().then(function(v){s[${id}]={v:v}},function(e){s[${id}]={e:String(e&&e.message||e)}});return 1})()`;
413
+ const poll = `(function(){var s=window.__awEval,r=s&&s[${id}];if(r)delete s[${id}];return r?JSON.stringify(r):''})()`;
414
+ const run = (code: string) => onMain(() => new Promise<string>((resolve, reject) => {
415
+ if (!wv) return reject(err('NATIVE_ERROR', 'webview.eval: not open'));
416
+ wv.evaluateJavascript(code, new android.webkit.ValueCallback({ onReceiveValue: (v: string) => resolve(parsed(v) as string) }));
417
+ }));
418
+ const end = Date.now() + EVAL_TIMEOUT;
419
+ // Each native call races the deadline: a torn-down overlay never answers its callback.
420
+ const timed = (code: string) => Promise.race([run(code), new Promise<string>((_, rej) => setTimeout(() => rej(err('NATIVE_ERROR', 'webview.eval: timed out (or the page navigated)')), Math.max(0, end - Date.now())))]);
421
+ return (async () => {
422
+ // The start script answers 1 once it runs; anything else = it didn't parse (a syntax error never settles a slot).
423
+ if (String(await timed(start)) !== '1') throw err('NATIVE_ERROR', 'webview.eval: syntax error');
424
+ for (; Date.now() < end; await new Promise((r) => setTimeout(r, 50))) {
425
+ const r = await timed(poll);
426
+ if (!r) continue;
427
+ const o = JSON.parse(r);
428
+ if ('e' in o) throw err('NATIVE_ERROR', `webview.eval: ${o.e}`);
429
+ return { result: parsed(o.v) };
430
+ }
431
+ throw err('NATIVE_ERROR', 'webview.eval: timed out (or the page navigated)');
432
+ })();
433
+ });
434
+
342
435
  bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
343
436
  const list = cookieList(p);
344
437
  return onMain(() => new Promise<{ set: number }>((resolve) => {
@@ -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
+ }
@@ -34,7 +34,7 @@ describe('Bridge origin gate — a foreign page must not reach the app’s store
34
34
  });
35
35
 
36
36
  test('a foreign page (a LAN/Tailscale server) is FORBIDDEN storage.secure, fs, webview cookies', async () => {
37
- for (const m of ['storage.secure.get', 'storage.get', 'fs.read', 'webview.getCookies', 'clipboard.read', 'share.files']) {
37
+ for (const m of ['storage.secure.get', 'storage.get', 'fs.read', 'webview.getCookies', 'webview.eval', 'webview.snapshot', 'clipboard.read', 'share.files']) {
38
38
  const r = await call(m, 'http://192.168.1.5:7707');
39
39
  expect(r.result).toBeUndefined();
40
40
  expect(r.error.code).toBe('FORBIDDEN');
@@ -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
+ });