@livx.cc/appwrap 0.61.24 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.61.24",
3
+ "version": "0.61.25",
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,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
- * 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
+ * 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
- const reveal = () => { wv.setVisibility(android.view.View.VISIBLE); wv.bringToFront(); };
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(() => {