@livx.cc/native-kit 0.37.0 → 0.38.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.37.0",
3
+ "version": "0.38.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';
@@ -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
  }