@livx.cc/appwrap 0.61.21 → 0.61.23
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
|
@@ -2,6 +2,7 @@ import { Utils, isIOS, isAndroid, Application } from '@nativescript/core';
|
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { createUiDelegate } from './ios-ui-delegate';
|
|
4
4
|
import { handleWebPermissionRequest } from './android-helpers';
|
|
5
|
+
import { SHELL_CONFIG } from './config';
|
|
5
6
|
|
|
6
7
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
7
8
|
|
|
@@ -20,6 +21,13 @@ const KVO_KEYS = ['URL', 'title', 'canGoBack', 'canGoForward', 'loading'];
|
|
|
20
21
|
* webview.close — tear down; emits `webview.closed`
|
|
21
22
|
* webview.cookies {cookies: Cookie[]} — write cookies into the overlay's (persistent) jar; resolves {set}
|
|
22
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.
|
|
23
31
|
* Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change.
|
|
24
32
|
*
|
|
25
33
|
* Swipe back/forward on, target=_blank/window.open loads in place (shared ios-ui-delegate), keyboard +
|
|
@@ -36,6 +44,22 @@ const cookieList = (p: { cookies?: Cookie[] } | null): Cookie[] => {
|
|
|
36
44
|
return l.filter((c) => c && c.name && c.domain);
|
|
37
45
|
};
|
|
38
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
|
+
|
|
39
63
|
export function registerWebViewHandlers(): void {
|
|
40
64
|
if (isIOS) registerIos();
|
|
41
65
|
else if (isAndroid) registerAndroid();
|
|
@@ -171,6 +195,32 @@ function registerIos(): void {
|
|
|
171
195
|
}));
|
|
172
196
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
173
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
|
+
|
|
174
224
|
bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
|
|
175
225
|
const list = cookieList(p);
|
|
176
226
|
return onMain(() => new Promise<{ set: number }>((resolve) => {
|
|
@@ -274,7 +324,7 @@ function registerAndroid(): void {
|
|
|
274
324
|
(view as any)._appwrapClients = [client, chrome]; // keep JS peers alive (NS GC doesn't see the native hold)
|
|
275
325
|
const bg = host?.getBackground?.();
|
|
276
326
|
if (bg instanceof android.graphics.drawable.ColorDrawable) view.setBackgroundColor(bg.getColor());
|
|
277
|
-
android.webkit.WebView.setWebContentsDebuggingEnabled(true);
|
|
327
|
+
if (SHELL_CONFIG.debug) android.webkit.WebView.setWebContentsDebuggingEnabled(true); // remote DevTools only in debug builds, never store builds
|
|
278
328
|
wv = view;
|
|
279
329
|
frame.addView(view);
|
|
280
330
|
layout();
|
|
@@ -338,6 +388,50 @@ function registerAndroid(): void {
|
|
|
338
388
|
}));
|
|
339
389
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
340
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
|
+
|
|
341
435
|
bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
|
|
342
436
|
const list = cookieList(p);
|
|
343
437
|
return onMain(() => new Promise<{ set: number }>((resolve) => {
|
|
@@ -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');
|