@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.61.24",
3
+ "version": "0.61.26",
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",
@@ -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
- * Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change.
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
- container.addSubview(view);
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
- wv!.superview?.bringSubviewToFront(wv!);
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; wv.superview?.bringSubviewToFront(wv); }
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
- const reveal = () => { wv.setVisibility(android.view.View.VISIBLE); wv.bringToFront(); };
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
- for (let v: UIView | null = w; v; v = v.superview) v.backgroundColor = color;
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
  });