@livx.cc/native-kit 0.37.0 → 0.39.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/README.md CHANGED
@@ -26,7 +26,7 @@ mixpanel.register(await kit.context());
26
26
 
27
27
  The kit detects whether it's running inside the appwrap shell and routes each call to the native bridge or a web fallback, so a single web build works everywhere. Capabilities advertise their real backing (`native` / `web` / `none`) so your UI can be honest about what's available.
28
28
 
29
- Covers haptics, share, storage, notifications, OAuth, billing (IAP), health, geo, camera, photos, contacts, calendar, motion, reviews, tracking (iOS App Tracking Transparency), and more.
29
+ Covers haptics, share, storage, notifications, OAuth, billing (IAP), health, geo, heading (compass), camera, photos, contacts, calendar, motion, reviews, tracking (iOS App Tracking Transparency), and more.
30
30
 
31
31
  **Full documentation, capability model, and the bridge protocol:** see the [appwrap README](https://github.com/Livshitz/appwrap#readme).
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/native-kit",
3
- "version": "0.37.0",
3
+ "version": "0.39.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",
@@ -13,6 +13,7 @@ import { ContactsModule } from '../modules/contacts';
13
13
  import { DeviceModule } from '../modules/device';
14
14
  import { FsModule } from '../modules/fs';
15
15
  import { GeoModule } from '../modules/geo';
16
+ import { HeadingModule } from '../modules/heading';
16
17
  import { HapticsModule } from '../modules/haptics';
17
18
  import { HealthModule } from '../modules/health';
18
19
  import { KeyboardModule } from '../modules/keyboard';
@@ -89,6 +90,7 @@ export class NativeKit {
89
90
  public readonly push = new PushModule(this);
90
91
  public readonly biometrics = new BiometricsModule(this);
91
92
  public readonly geo = new GeoModule(this);
93
+ public readonly heading = new HeadingModule(this);
92
94
  public readonly photos = new PhotosModule(this);
93
95
  public readonly network = new NetworkModule(this);
94
96
  public readonly lifecycle = new LifecycleModule(this);
@@ -53,6 +53,10 @@ export class WebAdapter implements NativeKitAdapter {
53
53
  private listeners = new Map<string, Set<(payload: unknown) => void>>();
54
54
  private geoWatchId: number | null = null;
55
55
  private motionHandler: ((e: DeviceMotionEvent) => void) | null = null;
56
+ private headingHandler: ((e: DeviceOrientationEvent) => void) | null = null;
57
+ /** the orientation event actually feeding headingHandler ('deviceorientationabsolute' on Android,
58
+ * 'deviceorientation' on iOS via webkitCompassHeading) — remembered so stop removes the right one. */
59
+ private headingEvent: 'deviceorientation' | 'deviceorientationabsolute' | null = null;
56
60
  /** Tear-down for an in-progress scanner.scan loop (stops the camera, removes the overlay). */
57
61
  private scanCancel: (() => void) | null = null;
58
62
  /** Stop an in-progress speech.listen session (resolves it with the best transcript so far). */
@@ -90,6 +94,7 @@ export class WebAdapter implements NativeKitAdapter {
90
94
  reviews: 'none',
91
95
  themeColor: 'web', // the browser honors <meta name="theme-color"> itself
92
96
  motion: typeof DeviceMotionEvent !== 'undefined' ? 'web' : 'none',
97
+ heading: typeof DeviceOrientationEvent !== 'undefined' ? 'web' : 'none',
93
98
  contacts: n.contacts?.select ? 'web' : 'none',
94
99
  calendar: 'none',
95
100
  camera: 'web', // <input capture> — mobile browsers open the camera
@@ -343,6 +348,52 @@ export class WebAdapter implements NativeKitAdapter {
343
348
  }
344
349
  return undefined as T;
345
350
 
351
+ case 'heading.start': {
352
+ if (typeof DeviceOrientationEvent === 'undefined') throw new KitError('UNSUPPORTED', 'No orientation sensor on this browser');
353
+ // iOS Safari gates DeviceOrientation behind the same explicit permission prompt as motion.
354
+ const req = (DeviceOrientationEvent as unknown as DeviceOrientationEventWithPermission).requestPermission;
355
+ if (req) {
356
+ const state = await req().catch((e: Error) => { throw new KitError('DENIED', e.message); });
357
+ if (state !== 'granted') throw new KitError('DENIED', 'Orientation permission not granted');
358
+ }
359
+ if (!this.headingHandler) {
360
+ const hz = Math.min(60, Math.max(1, Number(p.hz) || 10));
361
+ const minMs = 1000 / hz;
362
+ let last = 0;
363
+ this.headingHandler = (e) => {
364
+ const now = performance.now();
365
+ if (now - last < minMs) return;
366
+ // iOS: webkitCompassHeading is already a TRUE 0–360 compass heading (clockwise from north).
367
+ // Android/standard: `deviceorientationabsolute`'s alpha is 0–360 counter-clockwise from
368
+ // north, so the compass heading is (360 − alpha). `e.absolute` guards against a relative
369
+ // reading that isn't north-referenced (useless as a compass).
370
+ let deg: number | null = null;
371
+ let accuracy: number | undefined;
372
+ if (typeof e.webkitCompassHeading === 'number') {
373
+ deg = e.webkitCompassHeading;
374
+ accuracy = e.webkitCompassAccuracy != null ? Math.abs(e.webkitCompassAccuracy) : undefined;
375
+ } else if (e.absolute && typeof e.alpha === 'number') {
376
+ deg = 360 - e.alpha; // normalized to [0,360) once at emit below
377
+ }
378
+ if (deg == null) return; // non-absolute reading — not a usable compass heading
379
+ last = now;
380
+ this.emit('heading.data', { deg: ((deg % 360) + 360) % 360, accuracy });
381
+ };
382
+ // Prefer the north-referenced 'deviceorientationabsolute' (Android/Chrome); fall back to
383
+ // 'deviceorientation' (iOS delivers webkitCompassHeading there — no absolute event).
384
+ this.headingEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
385
+ window.addEventListener(this.headingEvent, this.headingHandler as EventListener);
386
+ }
387
+ return undefined as T;
388
+ }
389
+ case 'heading.stop':
390
+ if (this.headingHandler && this.headingEvent) {
391
+ window.removeEventListener(this.headingEvent, this.headingHandler as EventListener);
392
+ this.headingHandler = null;
393
+ this.headingEvent = null;
394
+ }
395
+ return undefined as T;
396
+
346
397
  case 'contacts.pick': {
347
398
  const select = navigator.contacts?.select;
348
399
  if (!select) throw new KitError('UNSUPPORTED', 'Contact Picker API unavailable');
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export type { DeviceInfo } from './modules/device';
28
28
  export type { ScheduleOptions } from './modules/notifications';
29
29
  export type { PushMessage, PushPlatform, PushToken } from './modules/push';
30
30
  export type { GeoPosition } from './modules/geo';
31
+ export type { HeadingSample } from './modules/heading';
31
32
  export type { PickedPhoto, PickPhotoOptions } from './modules/photos';
32
33
  export type { AudioMode, MediaDeviceLite, AudioState } from './modules/media';
33
34
  export type { NetworkStatus } from './modules/network';
@@ -64,4 +65,6 @@ export type {
64
65
  ProductType,
65
66
  PurchaseReceipt,
66
67
  PurchaseResult,
68
+ ReceiptEntitlements,
69
+ ReceiptOutcome,
67
70
  } from './modules/billing/types';
@@ -1,7 +1,16 @@
1
1
  import type { NativeKit } from '../../core/NativeKit';
2
2
  import { KitError, type Capability, type Unsubscribe } from '../../core/types';
3
3
  import { ClientTrustedValidator } from './validators';
4
- import type { BillingProvider, BillingValidator, Entitlement, Product, PurchaseReceipt, PurchaseResult } from './types';
4
+ import type {
5
+ BillingProvider,
6
+ BillingValidator,
7
+ Entitlement,
8
+ Product,
9
+ PurchaseReceipt,
10
+ PurchaseResult,
11
+ ReceiptEntitlements,
12
+ ReceiptOutcome,
13
+ } from './types';
5
14
 
6
15
  /**
7
16
  * In-app purchases & subscriptions — ONE API across web and native.
@@ -89,14 +98,29 @@ export class BillingModule {
89
98
  * Native iOS only, and only meaningful with a **server validator** (the bare receipt has no
90
99
  * product id — a client-trusted validator can't interpret it). Resolves `[]` on the web,
91
100
  * on Android (no StoreKit-1 receipt), or when no receipt is present (e.g. a dev build).
101
+ *
102
+ * The `[]` is AMBIGUOUS by design-history: it means both "no entitlements" and "never even
103
+ * asked". Use {@link receiptEntitlements} to tell those apart.
92
104
  */
93
105
  async entitlementsFromReceipt(): Promise<Entitlement[]> {
106
+ return (await this.receiptEntitlements()).entitlements;
107
+ }
108
+
109
+ /**
110
+ * {@link entitlementsFromReceipt} plus **why** — the same check, but the three
111
+ * didn't-even-ask paths are reported instead of being flattened into `[]`. See
112
+ * {@link ReceiptOutcome}: only `'validated'` makes an empty list a real answer.
113
+ *
114
+ * A validator that errors (network, Apple status 21004, …) REJECTS rather than
115
+ * reporting an outcome — a failed check is not a verdict about the user.
116
+ */
117
+ async receiptEntitlements(): Promise<ReceiptEntitlements> {
94
118
  await this.kit.ready();
95
- if (this.capability !== 'native') return []; // web, or a shell with no native store handler (Android today)
96
- if (this.validator instanceof ClientTrustedValidator) return []; // bare receipt has no product id → would grant a bogus active entitlement; needs a server validator
119
+ if (this.capability !== 'native') return { entitlements: [], outcome: 'not-native' }; // web, or a shell with no native store handler (Android today)
120
+ if (this.validator instanceof ClientTrustedValidator) return { entitlements: [], outcome: 'no-validator' }; // bare receipt has no product id → would grant a bogus active entitlement; needs a server validator
97
121
  const receipt = await this.kit.invoke<PurchaseReceipt>('billing.appReceipt');
98
- if (!receipt?.appReceipt) return [];
99
- return this.validator.validate(receipt); // server-of-record turns the receipt into entitlements
122
+ if (!receipt?.appReceipt) return { entitlements: [], outcome: 'no-receipt' };
123
+ return { entitlements: await this.validator.validate(receipt), outcome: 'validated' }; // server-of-record turns the receipt into entitlements
100
124
  }
101
125
 
102
126
  /** Current entitlements from the configured server-of-record (web provider or validator). */
@@ -58,6 +58,27 @@ export interface PurchaseResult {
58
58
  entitlements: Entitlement[];
59
59
  }
60
60
 
61
+ /**
62
+ * Why a receipt check produced what it did — the four outcomes that `Entitlement[]`
63
+ * alone collapses into an indistinguishable `[]`.
64
+ *
65
+ * - `validated` the server-of-record read the receipt; `entitlements` is its verdict
66
+ * (still `[]` when the user genuinely owns nothing — the only outcome
67
+ * where an empty list is an ANSWER rather than a non-answer).
68
+ * - `not-native` web, or a shell with no native store handler (Android: no StoreKit-1 receipt).
69
+ * - `no-validator` only a {@link ClientTrustedValidator} is wired; a bare app receipt carries no
70
+ * product id, so trusting it would grant a bogus entitlement. Wire a server validator.
71
+ * - `no-receipt` this build has no App Store receipt. StoreKit only issues one after a purchase or
72
+ * restore, so locally-installed dev builds legitimately have none.
73
+ */
74
+ export type ReceiptOutcome = 'validated' | 'not-native' | 'no-validator' | 'no-receipt';
75
+
76
+ /** The outcome-carrying result of {@link BillingModule.receiptEntitlements}. */
77
+ export interface ReceiptEntitlements {
78
+ entitlements: Entitlement[];
79
+ outcome: ReceiptOutcome;
80
+ }
81
+
61
82
  /**
62
83
  * The swappable seam. The kit owns the on-device purchase flow; *who decides the
63
84
  * user is actually entitled* is up to the app. Implement this (or configure the
@@ -0,0 +1,38 @@
1
+ import type { NativeKit } from '../core/NativeKit';
2
+ import type { Unsubscribe } from '../core/types';
3
+
4
+ export interface HeadingSample {
5
+ /** Compass heading in degrees, 0–360 (0 = north, 90 = east). */
6
+ deg: number;
7
+ /** Heading accuracy in degrees (± this many deg), when the platform reports it. */
8
+ accuracy?: number;
9
+ }
10
+
11
+ export class HeadingModule {
12
+ constructor(private kit: NativeKit) {}
13
+
14
+ get capability() {
15
+ return this.kit.capability('heading');
16
+ }
17
+
18
+ /** Stream compass heading updates; resolves an unsubscribe once streaming starts. Requests
19
+ * sensor permission (iOS gesture gate) on first use. Mirrors {@link MotionModule.watch}. */
20
+ async watch(cb: (sample: HeadingSample) => void, opts?: { hz?: number }): Promise<Unsubscribe> {
21
+ const off = this.kit.on('heading.data', (p) => cb(p as HeadingSample));
22
+ try {
23
+ await this.kit.invoke('heading.start', opts?.hz ? { hz: opts.hz } : undefined);
24
+ } catch (e) {
25
+ off();
26
+ throw e;
27
+ }
28
+ let stopped = false;
29
+ return () => {
30
+ if (stopped) return;
31
+ stopped = true;
32
+ off();
33
+ this.kit
34
+ .invoke('heading.stop')
35
+ .catch((e) => console.warn('[native-kit] heading.stop failed', e));
36
+ };
37
+ }
38
+ }
@@ -84,4 +84,16 @@ declare global {
84
84
  interface DeviceMotionEventWithPermission {
85
85
  requestPermission?(): Promise<'granted' | 'denied' | 'prompt'>;
86
86
  }
87
+
88
+ /** iOS Safari gates DeviceOrientation behind the same static permission prompt as DeviceMotion. */
89
+ interface DeviceOrientationEventWithPermission {
90
+ requestPermission?(): Promise<'granted' | 'denied' | 'prompt'>;
91
+ }
92
+
93
+ /** iOS exposes a true compass heading (0–360, magnetic north) on the orientation event — a
94
+ * non-standard field the lib doesn't declare. Merge it on (plus its accuracy). */
95
+ interface DeviceOrientationEvent {
96
+ webkitCompassHeading?: number;
97
+ webkitCompassAccuracy?: number;
98
+ }
87
99
  }