@livx.cc/appwrap 0.61.24 → 0.61.26
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
|
@@ -28,7 +28,19 @@ 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
|
+
* webview.float {on, hit?: [{x,y,w,h}]} — the app's WebView draws OVER the overlay (transparent wherever its own page
|
|
39
|
+
* is), e.g. chat bubbles floating over a shown page without resizing it.
|
|
40
|
+
* Touches inside `hit` (app CSS px) go to the app; everywhere else to the
|
|
41
|
+
* overlay. Call again to move the rects; on:false restores. iOS; Android
|
|
42
|
+
* resolves {floating:false} (not supported — keep a fallback).
|
|
43
|
+
* Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change; `webview.fab` on a tap.
|
|
32
44
|
*
|
|
33
45
|
* Swipe back/forward on, target=_blank/window.open loads in place (shared ios-ui-delegate), keyboard +
|
|
34
46
|
* safe areas handled by WKWebView's own scroll-view insets (contentInsetAdjustment automatic).
|
|
@@ -51,6 +63,17 @@ const evalJs = (p?: { js?: string } | null) => {
|
|
|
51
63
|
if (!js) throw err('NATIVE_ERROR', 'webview.eval: empty js');
|
|
52
64
|
return js;
|
|
53
65
|
};
|
|
66
|
+
/** webview.fab params (sizes in CSS px). */
|
|
67
|
+
type Fab = { show?: boolean; bottom?: number; right?: number; size?: number; radius?: number; color?: string; symbol?: string; text?: string; label?: string };
|
|
68
|
+
const fabOpts = (p?: Fab | null) => ({
|
|
69
|
+
show: !!p?.show, bottom: Math.max(0, Number(p?.bottom) || 16), right: Math.max(0, Number(p?.right) || 16),
|
|
70
|
+
size: Math.min(96, Math.max(32, Number(p?.size) || 52)), rgb: /^#?([0-9a-f]{6})$/i.exec(String(p?.color ?? '').trim())?.[1] ?? '2f6b4c',
|
|
71
|
+
symbol: p?.symbol === '' ? '' : String(p?.symbol || 'sparkles'), text: p?.text === '' ? '' : String(p?.text || '✦'),
|
|
72
|
+
radius: p?.radius === undefined ? null : Math.max(0, Number(p.radius) || 0), label: String(p?.label || 'Assistant'),
|
|
73
|
+
});
|
|
74
|
+
/** webview.float hit rects (app CSS px); junk entries dropped. */
|
|
75
|
+
const floatRects = (l: unknown) => (Array.isArray(l) ? l : []).map((r: any) => ({ x: +r?.x, y: +r?.y, w: +r?.w, h: +r?.h }))
|
|
76
|
+
.filter((r) => [r.x, r.y, r.w, r.h].every(Number.isFinite) && r.w > 0 && r.h > 0);
|
|
54
77
|
const parsed = (s: unknown) => { try { return JSON.parse(String(s)); } catch { return s ?? null; } };
|
|
55
78
|
const EVAL_TIMEOUT = 30000;
|
|
56
79
|
|
|
@@ -60,6 +83,9 @@ const snapOpts = (p?: { width?: number; quality?: number } | null) => ({
|
|
|
60
83
|
quality: Math.min(1, Math.max(0.1, Number(p?.quality) || 0.6)),
|
|
61
84
|
});
|
|
62
85
|
|
|
86
|
+
/** webview.float is on: the app WebView must stay see-through (keyboard.ts's backdrop paint skips it). */
|
|
87
|
+
export let appFloating = false;
|
|
88
|
+
|
|
63
89
|
export function registerWebViewHandlers(): void {
|
|
64
90
|
if (isIOS) registerIos();
|
|
65
91
|
else if (isAndroid) registerAndroid();
|
|
@@ -76,6 +102,22 @@ function registerIos(): void {
|
|
|
76
102
|
let bottomConstraint: NSLayoutConstraint | null = null;
|
|
77
103
|
let emitQueued = false;
|
|
78
104
|
let ObserverClass: any; // KVO sink, built once (ObjC class names are global)
|
|
105
|
+
let fab: UIButton | null = null;
|
|
106
|
+
let fabTarget: any = null, FabTargetClass: any; // its tap target (an exposedMethods NSObject, built once)
|
|
107
|
+
let fabBottom: NSLayoutConstraint | null = null, fabRight: NSLayoutConstraint | null = null, fabW: NSLayoutConstraint | null = null, fabH: NSLayoutConstraint | null = null;
|
|
108
|
+
// webview.float: the app WebView renders above the overlay (layer zPosition — draw order only) while the overlay stays
|
|
109
|
+
// first in hit-testing (subview order): it sits in a pass-through wrapper that declines the touches inside `floatHit`
|
|
110
|
+
// (app CSS px), which fall to the app. (A wrapper, not a WKWebView subclass: its super hitTest recursed in NativeScript.)
|
|
111
|
+
let floating = false, floatHit: { x: number; y: number; w: number; h: number }[] = [], hostBg: UIColor | null = null, hostOpaque = true;
|
|
112
|
+
let wrap: UIView | null = null, WrapClass: any;
|
|
113
|
+
const appHit = (view: UIView, pt: CGPoint) => {
|
|
114
|
+
const host = bridge.getWebView()?.ios as UIView | undefined;
|
|
115
|
+
if (!floating || !host) return false;
|
|
116
|
+
const p = view.convertPointToView(pt, host);
|
|
117
|
+
return floatHit.some((r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h);
|
|
118
|
+
};
|
|
119
|
+
/** The button stays above the overlay (which comes to the front on open/show). */
|
|
120
|
+
const fabFront = () => { if (fab && !fab.hidden) fab.superview?.bringSubviewToFront(fab); };
|
|
79
121
|
|
|
80
122
|
const state = () => ({
|
|
81
123
|
url: wv?.URL?.absoluteString ?? '',
|
|
@@ -114,7 +156,16 @@ function registerIos(): void {
|
|
|
114
156
|
if ((view as any).inspectable !== undefined) (view as any).inspectable = true; // iOS 16.4+ Safari inspector
|
|
115
157
|
|
|
116
158
|
view.translatesAutoresizingMaskIntoConstraints = false;
|
|
117
|
-
|
|
159
|
+
WrapClass ??= (UIView as any).extend({
|
|
160
|
+
hitTestWithEvent(pt: CGPoint, ev: _UIEvent) {
|
|
161
|
+
if (!wv || appHit(this, pt)) return null;
|
|
162
|
+
return wv.hitTestWithEvent(this.convertPointToView(pt, wv), ev);
|
|
163
|
+
},
|
|
164
|
+
}, { name: 'AppwrapOverlayWrap' });
|
|
165
|
+
wrap = WrapClass.alloc().initWithFrame(CGRectZero);
|
|
166
|
+
wrap!.translatesAutoresizingMaskIntoConstraints = false;
|
|
167
|
+
container.addSubview(wrap!);
|
|
168
|
+
wrap!.addSubview(view);
|
|
118
169
|
// Anchor to the app WebView's box so `top`/`bottom` are in the page's own CSS px (== points).
|
|
119
170
|
const ref: any = host ?? container;
|
|
120
171
|
topConstraint = view.topAnchor.constraintEqualToAnchorConstant(ref.topAnchor, top);
|
|
@@ -124,6 +175,8 @@ function registerIos(): void {
|
|
|
124
175
|
bottomConstraint,
|
|
125
176
|
view.leadingAnchor.constraintEqualToAnchor(ref.leadingAnchor),
|
|
126
177
|
view.trailingAnchor.constraintEqualToAnchor(ref.trailingAnchor),
|
|
178
|
+
wrap!.topAnchor.constraintEqualToAnchor(ref.topAnchor), wrap!.bottomAnchor.constraintEqualToAnchor(ref.bottomAnchor),
|
|
179
|
+
wrap!.leadingAnchor.constraintEqualToAnchor(ref.leadingAnchor), wrap!.trailingAnchor.constraintEqualToAnchor(ref.trailingAnchor),
|
|
127
180
|
] as any);
|
|
128
181
|
|
|
129
182
|
ObserverClass ??= (NSObject as any).extend(
|
|
@@ -146,7 +199,7 @@ function registerIos(): void {
|
|
|
146
199
|
if (!wv) return;
|
|
147
200
|
for (const k of KVO_KEYS) { try { wv.removeObserverForKeyPath(observer!, k); } catch { /* not observed */ } }
|
|
148
201
|
wv.stopLoading();
|
|
149
|
-
wv.removeFromSuperview();
|
|
202
|
+
wv.removeFromSuperview(); wrap?.removeFromSuperview(); wrap = null;
|
|
150
203
|
wv = null; observer = null; uiDelegate = null; topConstraint = null; bottomConstraint = null;
|
|
151
204
|
bridge.emit('webview.closed', {});
|
|
152
205
|
};
|
|
@@ -170,7 +223,8 @@ function registerIos(): void {
|
|
|
170
223
|
if (!wv) create(inset(top), inset(bottom));
|
|
171
224
|
else setInsets(top, bottom);
|
|
172
225
|
wv!.hidden = false;
|
|
173
|
-
|
|
226
|
+
wrap?.superview?.bringSubviewToFront(wrap);
|
|
227
|
+
fabFront();
|
|
174
228
|
load(target);
|
|
175
229
|
return state();
|
|
176
230
|
});
|
|
@@ -190,11 +244,66 @@ function registerIos(): void {
|
|
|
190
244
|
|
|
191
245
|
bridge.register('webview.hide', () => onMain(() => { if (wv) wv.hidden = true; return { open: !!wv }; }));
|
|
192
246
|
bridge.register('webview.show', (p?: { top?: number; bottom?: number } | null) => onMain(() => {
|
|
193
|
-
if (wv) { setInsets(p?.top, p?.bottom); wv.hidden = false;
|
|
247
|
+
if (wv) { setInsets(p?.top, p?.bottom); wv.hidden = false; wrap?.superview?.bringSubviewToFront(wrap); fabFront(); }
|
|
194
248
|
return { open: !!wv };
|
|
195
249
|
}));
|
|
196
250
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
197
251
|
|
|
252
|
+
bridge.register('webview.fab', (p?: Fab | null) => {
|
|
253
|
+
const o = fabOpts(p);
|
|
254
|
+
return onMain(() => {
|
|
255
|
+
if (!fab) {
|
|
256
|
+
if (!o.show) return { shown: false };
|
|
257
|
+
const host = bridge.getWebView()?.ios as WKWebView | undefined;
|
|
258
|
+
const container = host?.superview ?? Utils.ios.getRootViewController()?.view;
|
|
259
|
+
if (!container) throw err('NATIVE_ERROR', 'webview.fab: no host view');
|
|
260
|
+
FabTargetClass ??= (NSObject as any).extend({ tap() { bridge.emit('webview.fab', {}); } }, { name: 'AppwrapFabTarget', exposedMethods: { tap: { returns: interop.types.void } } });
|
|
261
|
+
fabTarget = FabTargetClass.new();
|
|
262
|
+
const b = UIButton.buttonWithType(UIButtonType.System);
|
|
263
|
+
b.translatesAutoresizingMaskIntoConstraints = false;
|
|
264
|
+
b.addTargetActionForControlEvents(fabTarget, 'tap', UIControlEvents.TouchUpInside);
|
|
265
|
+
b.layer.shadowColor = UIColor.blackColor.CGColor; b.layer.shadowOpacity = 0.28; b.layer.shadowRadius = 9; b.layer.shadowOffset = CGSizeMake(0, 5);
|
|
266
|
+
container.addSubview(b);
|
|
267
|
+
const ref: any = host ?? container;
|
|
268
|
+
fabBottom = b.bottomAnchor.constraintEqualToAnchorConstant(ref.bottomAnchor, -o.bottom);
|
|
269
|
+
fabRight = b.trailingAnchor.constraintEqualToAnchorConstant(ref.trailingAnchor, -o.right);
|
|
270
|
+
fabW = b.widthAnchor.constraintEqualToConstant(o.size); fabH = b.heightAnchor.constraintEqualToConstant(o.size);
|
|
271
|
+
NSLayoutConstraint.activateConstraints([fabBottom, fabRight, fabW, fabH] as any);
|
|
272
|
+
fab = b;
|
|
273
|
+
}
|
|
274
|
+
const n = parseInt(o.rgb, 16);
|
|
275
|
+
fab.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1);
|
|
276
|
+
// the glyph in black or white, whichever reads on the colour
|
|
277
|
+
const light = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) > 150;
|
|
278
|
+
fab.tintColor = light ? UIColor.colorWithRedGreenBlueAlpha(0.06, 0.15, 0.1, 1) : UIColor.whiteColor;
|
|
279
|
+
fab.setImageForState(o.symbol ? UIImage.systemImageNamedWithConfiguration(o.symbol, UIImageSymbolConfiguration.configurationWithPointSizeWeight(o.size * 0.42, UIImageSymbolWeight.Semibold)) : null, UIControlState.Normal);
|
|
280
|
+
fab.layer.cornerRadius = o.radius ?? o.size / 2;
|
|
281
|
+
fab.accessibilityLabel = o.label;
|
|
282
|
+
if (fabBottom) fabBottom.constant = -o.bottom;
|
|
283
|
+
if (fabRight) fabRight.constant = -o.right;
|
|
284
|
+
if (fabW) fabW.constant = o.size;
|
|
285
|
+
if (fabH) fabH.constant = o.size; // (a later call may resize it)
|
|
286
|
+
fab.hidden = !o.show;
|
|
287
|
+
fabFront();
|
|
288
|
+
return { shown: o.show };
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
bridge.register('webview.float', (p?: { on?: boolean; hit?: unknown } | null) => onMain(() => {
|
|
293
|
+
const host = bridge.getWebView()?.ios as WKWebView | undefined;
|
|
294
|
+
if (!host) throw err('NATIVE_ERROR', 'webview.float: no host view');
|
|
295
|
+
floatHit = floatRects(p?.hit);
|
|
296
|
+
const on = !!p?.on;
|
|
297
|
+
if (on !== floating) {
|
|
298
|
+
floating = appFloating = on;
|
|
299
|
+
if (on) { hostBg = host.backgroundColor; hostOpaque = host.opaque; host.opaque = false; host.backgroundColor = UIColor.clearColor; host.scrollView.backgroundColor = UIColor.clearColor; }
|
|
300
|
+
else { host.opaque = hostOpaque; host.backgroundColor = hostBg; host.scrollView.backgroundColor = hostBg; }
|
|
301
|
+
host.layer.zPosition = on ? 1 : 0;
|
|
302
|
+
if (fab) fab.layer.zPosition = on ? 2 : 0;
|
|
303
|
+
}
|
|
304
|
+
return { floating };
|
|
305
|
+
}));
|
|
306
|
+
|
|
198
307
|
bridge.register('webview.snapshot', (p?: { width?: number; quality?: number } | null) => {
|
|
199
308
|
const o = snapOpts(p);
|
|
200
309
|
return onMain(() => new Promise<{ jpeg: string; width: number; height: number }>((resolve, reject) => {
|
|
@@ -355,7 +464,22 @@ function registerAndroid(): void {
|
|
|
355
464
|
if (bottom !== undefined) insets.bottom = inset(bottom);
|
|
356
465
|
layout();
|
|
357
466
|
};
|
|
358
|
-
|
|
467
|
+
let fab: any = null, fabO = fabOpts(null);
|
|
468
|
+
/** Place the button at the app WebView's bottom-right (`bottom`/`right` CSS px inside it), above the overlay. */
|
|
469
|
+
const fabLayout = () => {
|
|
470
|
+
const frame = content();
|
|
471
|
+
if (!fab || !frame) return;
|
|
472
|
+
const d = density(), px = Math.round(fabO.size * d), fl = [0, 0], hl = [0, 0];
|
|
473
|
+
const h = host ?? bridge.getWebView()?.android;
|
|
474
|
+
let boxBottom = 0, boxRight = 0;
|
|
475
|
+
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()); }
|
|
476
|
+
const lp = new android.widget.FrameLayout.LayoutParams(px, px, android.view.Gravity.BOTTOM | android.view.Gravity.END);
|
|
477
|
+
lp.bottomMargin = Math.max(0, Math.round(boxBottom + fabO.bottom * d));
|
|
478
|
+
lp.rightMargin = Math.max(0, Math.round(boxRight + fabO.right * d));
|
|
479
|
+
fab.setLayoutParams(lp);
|
|
480
|
+
if (fab.getVisibility() === android.view.View.VISIBLE) fab.bringToFront();
|
|
481
|
+
};
|
|
482
|
+
const reveal = () => { wv.setVisibility(android.view.View.VISIBLE); wv.bringToFront(); if (fab?.getVisibility() === android.view.View.VISIBLE) fab.bringToFront(); };
|
|
359
483
|
|
|
360
484
|
bridge.register('webview.open', ({ url, top, bottom }: { url: string; top?: number; bottom?: number }) => {
|
|
361
485
|
const target = String(url ?? '');
|
|
@@ -388,6 +512,35 @@ function registerAndroid(): void {
|
|
|
388
512
|
}));
|
|
389
513
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
390
514
|
|
|
515
|
+
bridge.register('webview.fab', (p?: Fab | null) => {
|
|
516
|
+
fabO = fabOpts(p);
|
|
517
|
+
return onMain(() => {
|
|
518
|
+
const frame = content();
|
|
519
|
+
if (!fab) {
|
|
520
|
+
if (!fabO.show) return { shown: false };
|
|
521
|
+
if (!frame) throw err('NATIVE_ERROR', 'webview.fab: no host view');
|
|
522
|
+
fab = new android.widget.TextView(activity());
|
|
523
|
+
fab.setGravity(android.view.Gravity.CENTER);
|
|
524
|
+
fab.setElevation(8 * density());
|
|
525
|
+
fab.setOnClickListener(new android.view.View.OnClickListener({ onClick() { bridge.emit('webview.fab', {}); } }));
|
|
526
|
+
frame.addView(fab);
|
|
527
|
+
}
|
|
528
|
+
const n = parseInt(fabO.rgb, 16), light = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) > 150;
|
|
529
|
+
const bg = new android.graphics.drawable.GradientDrawable();
|
|
530
|
+
if (fabO.radius === null) bg.setShape(android.graphics.drawable.GradientDrawable.OVAL);
|
|
531
|
+
else bg.setCornerRadius(fabO.radius * density());
|
|
532
|
+
bg.setColor(android.graphics.Color.rgb((n >> 16) & 255, (n >> 8) & 255, n & 255));
|
|
533
|
+
fab.setBackground(bg);
|
|
534
|
+
fab.setText(fabO.text);
|
|
535
|
+
fab.setTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, fabO.size * 0.42);
|
|
536
|
+
fab.setTextColor(light ? android.graphics.Color.rgb(16, 38, 26) : android.graphics.Color.WHITE);
|
|
537
|
+
fab.setContentDescription(fabO.label);
|
|
538
|
+
fab.setVisibility(fabO.show ? android.view.View.VISIBLE : android.view.View.GONE);
|
|
539
|
+
fabLayout();
|
|
540
|
+
return { shown: fabO.show };
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
391
544
|
bridge.register('webview.snapshot', (p?: { width?: number; quality?: number } | null) => {
|
|
392
545
|
const o = snapOpts(p);
|
|
393
546
|
return onMain(() => {
|
|
@@ -432,6 +585,8 @@ function registerAndroid(): void {
|
|
|
432
585
|
})();
|
|
433
586
|
});
|
|
434
587
|
|
|
588
|
+
bridge.register('webview.float', () => ({ floating: false })); // (not supported here: callers keep their fallback)
|
|
589
|
+
|
|
435
590
|
bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
|
|
436
591
|
const list = cookieList(p);
|
|
437
592
|
return onMain(() => new Promise<{ set: number }>((resolve) => {
|
|
@@ -3,6 +3,7 @@ import { bridge } from './bridge';
|
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
4
|
import { createKeyboardShowCycle, type KeyboardEventTag } from './keyboard-frame';
|
|
5
5
|
import { appwrapNativeLog } from './native-log';
|
|
6
|
+
import { appFloating } from './handlers-webview';
|
|
6
7
|
|
|
7
8
|
declare const android: any;
|
|
8
9
|
// iOS keyboard-notification globals (marshalled from UIKit at runtime).
|
|
@@ -231,7 +232,8 @@ function syncBackdropColor(): void {
|
|
|
231
232
|
const color = UIColor.colorWithRedGreenBlueAlpha(+m[1] / 255, +m[2] / 255, +m[3] / 255, 1);
|
|
232
233
|
// The keyboard's rounded corners (iOS 26) show whatever native view is behind the webview — paint
|
|
233
234
|
// the whole chain up to the window, not only the webview + window (a dark host view showed through).
|
|
234
|
-
|
|
235
|
+
// (floating over a page — webview.float — the webview itself stays clear, or it covers the page)
|
|
236
|
+
for (let v: UIView | null = appFloating ? w.superview : w; v; v = v.superview) v.backgroundColor = color;
|
|
235
237
|
if (w.window) w.window.backgroundColor = color;
|
|
236
238
|
if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] backdrop ← rgb(${m[1]},${m[2]},${m[3]})`);
|
|
237
239
|
});
|