@livx.cc/native-kit 0.23.0 → 0.24.0

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/native-kit",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Isomorphic native-capabilities kit for PWAs — same API in browser and in an appwrap native shell. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -23,6 +23,7 @@ import { OAuthModule } from '../modules/oauth';
23
23
  import { PhotosModule } from '../modules/photos';
24
24
  import { PushModule } from '../modules/push';
25
25
  import { ReviewsModule } from '../modules/reviews';
26
+ import { ScannerModule } from '../modules/scanner';
26
27
  import { ScreenModule } from '../modules/screen';
27
28
  import { ShareModule } from '../modules/share';
28
29
  import { StorageModule } from '../modules/storage';
@@ -91,6 +92,7 @@ export class NativeKit {
91
92
  public readonly health = new HealthModule(this);
92
93
  public readonly media = new MediaModule(this);
93
94
  public readonly contacts = new ContactsModule(this);
95
+ public readonly scanner = new ScannerModule(this);
94
96
  public readonly calendar = new CalendarModule(this);
95
97
  public readonly app = new AppModule(this);
96
98
  public readonly browser = new BrowserModule(this);
@@ -38,6 +38,8 @@ export class WebAdapter implements NativeKitAdapter {
38
38
  private listeners = new Map<string, Set<(payload: unknown) => void>>();
39
39
  private geoWatchId: number | null = null;
40
40
  private motionHandler: ((e: DeviceMotionEvent) => void) | null = null;
41
+ /** Tear-down for an in-progress scanner.scan loop (stops the camera, removes the overlay). */
42
+ private scanCancel: (() => void) | null = null;
41
43
 
42
44
  detect(): boolean {
43
45
  return typeof window !== 'undefined';
@@ -75,6 +77,8 @@ export class WebAdapter implements NativeKitAdapter {
75
77
  calendar: 'none',
76
78
  camera: 'web', // <input capture> — mobile browsers open the camera
77
79
  media: (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
80
+ // BarcodeDetector (Chrome/Android) decodes; needs a getUserMedia stream to feed it. Both → 'web'.
81
+ scanner: typeof (window as any).BarcodeDetector !== 'undefined' && (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
78
82
  app: 'web', // openUrl via window.open; openSettings unsupported
79
83
  browser: 'web', // new tab/window
80
84
  billing: 'none', // no IAP in a plain browser — wire a web checkout yourself
@@ -321,6 +325,12 @@ export class WebAdapter implements NativeKitAdapter {
321
325
  case 'calendar.createEvent':
322
326
  throw new KitError('UNSUPPORTED', 'No calendar access on web');
323
327
 
328
+ case 'scanner.scan':
329
+ return this.scanBarcode(p.formats, p.camera === 'front' ? 'user' : 'environment') as Promise<T>;
330
+ case 'scanner.cancel':
331
+ this.scanCancel?.();
332
+ return undefined as T;
333
+
324
334
  case 'photos.pick':
325
335
  return this.pickImageFile(false, !!p.dataUrl, p.maxSize) as Promise<T>;
326
336
  case 'camera.capture':
@@ -447,6 +457,96 @@ export class WebAdapter implements NativeKitAdapter {
447
457
  this.listeners.get(event)?.forEach((cb) => cb(payload));
448
458
  }
449
459
 
460
+ /** Map our small format enum → BarcodeDetector `formats` strings. Omit/`'all'` = detector default. */
461
+ private toDetectorFormats(formats?: string | string[]): string[] | undefined {
462
+ const want = (Array.isArray(formats) ? formats : formats ? [formats] : []).filter((f) => f && f !== 'all');
463
+ if (!want.length) return undefined;
464
+ const map: Record<string, string> = { qr: 'qr_code', ean13: 'ean_13', code128: 'code_128' };
465
+ return want.map((f) => map[f] ?? f);
466
+ }
467
+
468
+ /** Reverse map a detector format string → our enum (best-effort). */
469
+ private fromDetectorFormat(f: string): 'qr' | 'ean13' | 'code128' | 'unknown' {
470
+ if (f === 'qr_code') return 'qr';
471
+ if (f === 'ean_13') return 'ean13';
472
+ if (f === 'code_128') return 'code128';
473
+ return 'unknown';
474
+ }
475
+
476
+ /**
477
+ * Open a full-screen camera overlay, run a BarcodeDetector loop, resolve the FIRST hit then tear
478
+ * down. Resolves `{ cancelled: true }` if the user taps the close button or `scanner.cancel` fires.
479
+ */
480
+ private scanBarcode(
481
+ formats: string | string[] | undefined,
482
+ facingMode: 'environment' | 'user'
483
+ ): Promise<{ value: string; format: string; bounds?: any } | { cancelled: true }> {
484
+ const Detector = (window as any).BarcodeDetector;
485
+ const getUserMedia = (navigator as any).mediaDevices?.getUserMedia?.bind((navigator as any).mediaDevices);
486
+ if (!Detector || !getUserMedia) throw new KitError('UNSUPPORTED', 'BarcodeDetector / camera unavailable in this browser');
487
+
488
+ return new Promise(async (resolve, reject) => {
489
+ let stream: MediaStream | null = null;
490
+ let raf = 0;
491
+ let done = false;
492
+ const wrap = document.createElement('div');
493
+ const video = document.createElement('video');
494
+
495
+ const teardown = () => {
496
+ if (done) return;
497
+ done = true;
498
+ cancelAnimationFrame(raf);
499
+ stream?.getTracks().forEach((t) => t.stop());
500
+ wrap.remove();
501
+ // Only clear if it's still OUR handler — a newer scan may have replaced it.
502
+ if (this.scanCancel === cancelHandler) this.scanCancel = null;
503
+ };
504
+ const cancelHandler = () => { teardown(); resolve({ cancelled: true }); };
505
+ this.scanCancel = cancelHandler;
506
+
507
+ try {
508
+ const detector = new Detector(this.toDetectorFormats(formats) ? { formats: this.toDetectorFormats(formats) } : undefined);
509
+ stream = await getUserMedia({ video: { facingMode } });
510
+ wrap.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column';
511
+ video.setAttribute('playsinline', '');
512
+ video.muted = true;
513
+ video.style.cssText = 'flex:1;width:100%;height:100%;object-fit:cover';
514
+ (video as any).srcObject = stream;
515
+ const close = document.createElement('button');
516
+ close.textContent = 'Cancel';
517
+ close.style.cssText = 'position:absolute;top:max(12px,env(safe-area-inset-top));right:16px;z-index:1;background:rgba(0,0,0,.5);color:#fff;border:0;border-radius:18px;padding:8px 16px;font:15px system-ui';
518
+ close.onclick = () => { teardown(); resolve({ cancelled: true }); };
519
+ wrap.appendChild(video);
520
+ wrap.appendChild(close);
521
+ document.body.appendChild(wrap);
522
+ await video.play();
523
+
524
+ const tick = async () => {
525
+ if (done) return;
526
+ try {
527
+ const codes = await detector.detect(video);
528
+ if (codes?.length) {
529
+ const c = codes[0];
530
+ const b = c.boundingBox;
531
+ teardown();
532
+ resolve({
533
+ value: c.rawValue,
534
+ format: this.fromDetectorFormat(c.format),
535
+ bounds: b ? { x: b.x, y: b.y, width: b.width, height: b.height } : undefined,
536
+ });
537
+ return;
538
+ }
539
+ } catch { /* transient detect error — keep looping */ }
540
+ raf = requestAnimationFrame(tick);
541
+ };
542
+ raf = requestAnimationFrame(tick);
543
+ } catch (e: any) {
544
+ teardown();
545
+ reject(new KitError(e?.name === 'NotAllowedError' ? 'DENIED' : 'NATIVE_ERROR', e?.message ?? 'scan failed'));
546
+ }
547
+ });
548
+ }
549
+
450
550
  private pickImageFile(
451
551
  capture: boolean,
452
552
  wantDataUrl = false,
package/src/index.ts CHANGED
@@ -35,6 +35,8 @@ export type { ActionOptions, AlertOptions, ConfirmOptions, SafeAreaInsets } from
35
35
  export type { MotionSample } from './modules/motion';
36
36
  export type { UpdateStatus, UpdatesOptions } from './modules/updates';
37
37
  export type { PickedContact } from './modules/contacts';
38
+ export type { ScanFormat, ScanOptions, ScanResult, ScanCancelled } from './modules/scanner';
39
+ export { isScanResult } from './modules/scanner';
38
40
  export type { CalendarEventOptions } from './modules/calendar';
39
41
  export type { BrowserOptions } from './modules/browser';
40
42
  export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
@@ -0,0 +1,63 @@
1
+ import type { NativeKit } from '../core/NativeKit';
2
+
3
+ /** Symbologies the scanner can decode. `'all'` (default) accepts every supported format. */
4
+ export type ScanFormat = 'qr' | 'ean13' | 'code128' | 'all';
5
+
6
+ /** A successfully decoded code. */
7
+ export interface ScanResult {
8
+ /** Decoded payload (the string the barcode/QR encodes). */
9
+ value: string;
10
+ /** Detected symbology — one of {@link ScanFormat} minus `'all'`, or `'unknown'`. */
11
+ format: ScanFormat | 'unknown';
12
+ /** Bounding box of the code in the camera preview (CSS px), when the platform reports it. */
13
+ bounds?: { x: number; y: number; width: number; height: number };
14
+ }
15
+
16
+ /** Scan resolved without a code because the user dismissed the scanner. */
17
+ export interface ScanCancelled {
18
+ cancelled: true;
19
+ }
20
+
21
+ export interface ScanOptions {
22
+ /** Symbology filter — a single format or a list. Omit / `'all'` = any supported code. */
23
+ formats?: ScanFormat | ScanFormat[];
24
+ /** Which camera to open. Default `'back'`. */
25
+ camera?: 'back' | 'front';
26
+ }
27
+
28
+ /** True when a {@link ScanResult.value} is present (not the cancelled shape). */
29
+ export function isScanResult(r: ScanResult | ScanCancelled): r is ScanResult {
30
+ return (r as ScanCancelled).cancelled !== true;
31
+ }
32
+
33
+ /**
34
+ * Camera barcode / QR decoding — ONE API across platforms.
35
+ *
36
+ * {@link scan} opens a native full-screen capture UI, returns the FIRST decoded code, then
37
+ * auto-dismisses. If the user dismisses it first, it resolves to `{ cancelled: true }` (NOT a
38
+ * rejection) — branch with {@link isScanResult}. {@link cancel} dismisses an in-progress scan
39
+ * programmatically, which makes that same pending {@link scan} resolve `{ cancelled: true }`.
40
+ *
41
+ * Native (iOS `AVCaptureMetadataOutput`, Android ZXing capture Activity) reports
42
+ * `capability === 'native'`. Web maps to the `BarcodeDetector` API where present (Chrome/Android
43
+ * → `'web'`); where absent (Safari/Firefox today) it's `'none'` and {@link scan} throws
44
+ * `KitError('UNSUPPORTED')` — no heavy JS decoder fallback. Branch on {@link capability}.
45
+ */
46
+ export class ScannerModule {
47
+ constructor(private kit: NativeKit) {}
48
+
49
+ /** 'native' on a shell · 'web' where BarcodeDetector exists · else 'none'. */
50
+ get capability() {
51
+ return this.kit.capability('scanner');
52
+ }
53
+
54
+ /** Open the scanner; resolve the first code, or `{ cancelled: true }` if dismissed. */
55
+ scan(opts: ScanOptions = {}): Promise<ScanResult | ScanCancelled> {
56
+ return this.kit.invoke('scanner.scan', opts);
57
+ }
58
+
59
+ /** Dismiss an in-progress {@link scan} (its promise then resolves `{ cancelled: true }`). */
60
+ cancel(): Promise<void> {
61
+ return this.kit.invoke('scanner.cancel');
62
+ }
63
+ }