@livx.cc/native-kit 0.35.0 → 0.35.2

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
@@ -3,7 +3,7 @@
3
3
  Isomorphic native-capabilities kit for PWAs — the **same API in the browser and inside an [appwrap](https://github.com/Livshitz/appwrap) native shell**. Zero dependencies.
4
4
 
5
5
  ```bash
6
- bun add @livx.cc/native-kit # or: npm i @livx.cc/native-kit
6
+ bun add @livx.cc/native-kit
7
7
  ```
8
8
 
9
9
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/native-kit",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "description": "Isomorphic native-capabilities kit for PWAs \u2014 same API in browser and in an appwrap native shell. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -122,10 +122,26 @@ export class NativeKit {
122
122
  ready(): Promise<Handshake> {
123
123
  if (!this.readyPromise) {
124
124
  this.readyPromise = (async () => {
125
- const adapter = this.options.adapters.find((a) => a.detect());
126
- if (!adapter) throw new KitError('NOT_READY', 'No adapter detected this environment');
127
- this.adapter = adapter;
128
- const handshake = await adapter.handshake(this.options.handshakeTimeoutMs);
125
+ const candidates = this.options.adapters.filter((a) => a.detect());
126
+ if (!candidates.length) throw new KitError('NOT_READY', 'No adapter detected this environment');
127
+ // Try each detected adapter in priority order. A transport can LOOK native yet refuse
128
+ // the handshake — e.g. a host shell exposing a capability-GATED `appwrap` message
129
+ // handler to an embedded/mini-app page (CAP_DENIED, or no handshake handler at all).
130
+ // Falling through to the next adapter (web) lets the app degrade to standard web
131
+ // APIs instead of dying with no transport at all.
132
+ let handshake: Handshake | null = null;
133
+ let lastError: unknown = null;
134
+ for (const adapter of candidates) {
135
+ try {
136
+ handshake = await adapter.handshake(this.options.handshakeTimeoutMs);
137
+ this.adapter = adapter;
138
+ break;
139
+ } catch (e) {
140
+ lastError = e;
141
+ console.warn(`[native-kit] ${adapter.kind} adapter handshake failed (${(e as Error)?.message ?? e}) — trying next adapter`);
142
+ }
143
+ }
144
+ if (!handshake) throw lastError ?? new KitError('NOT_READY', 'All adapters failed the handshake');
129
145
  // Version-skew safety net: a native shell from an older `appwrap init` may speak a
130
146
  // different protocol. Fail loud rather than silently mis-degrade. The web adapter
131
147
  // always reports the kit's own protocol, so this only ever fires against a stale shell.
@@ -316,10 +316,14 @@ export class WebAdapter implements NativeKitAdapter {
316
316
  if (state !== 'granted') throw new KitError('DENIED', 'Motion permission not granted');
317
317
  }
318
318
  if (!this.motionHandler) {
319
+ // Honor the requested rate (default 10 Hz, clamped 5–60 — mirrors the native cap);
320
+ // a tilt game asks 60 and 10 Hz steering is unplayably laggy.
321
+ const hz = Math.min(60, Math.max(5, Number(p.hz) || 10));
322
+ const minMs = 1000 / hz;
319
323
  let last = 0;
320
324
  this.motionHandler = (e) => {
321
325
  const now = performance.now();
322
- if (now - last < 100) return; // ~10Hz
326
+ if (now - last < minMs) return;
323
327
  last = now;
324
328
  const a = e.accelerationIncludingGravity;
325
329
  const r = e.rotationRate;
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@ 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
31
  export type { PickedPhoto, PickPhotoOptions } from './modules/photos';
32
- export type { AudioMode, MediaDeviceLite } from './modules/media';
32
+ export type { AudioMode, MediaDeviceLite, AudioState } from './modules/media';
33
33
  export type { NetworkStatus } from './modules/network';
34
34
  export type { ActionOptions, AlertOptions, ConfirmOptions, SafeAreaInsets } from './modules/ui';
35
35
  export type { MotionSample } from './modules/motion';
@@ -1,4 +1,5 @@
1
1
  import type { NativeKit } from '../core/NativeKit';
2
+ import type { Unsubscribe } from '../core/types';
2
3
 
3
4
  /**
4
5
  * Live media bridge — mic / camera / speaker. The streams themselves are plain
@@ -9,6 +10,14 @@ import type { NativeKit } from '../core/NativeKit';
9
10
  */
10
11
  export type AudioMode = 'playback' | 'playAndRecord' | 'voiceChat' | 'default';
11
12
 
13
+ /** Whether the device would actually play game/media sound right now, plus the media volume (0–1). */
14
+ export interface AudioState {
15
+ /** True when output is effectively silenced — iOS mute switch OR zero media volume; Android: zero media volume. */
16
+ silent: boolean;
17
+ /** Media output volume, 0–1 (iOS AVAudioSession.outputVolume / Android STREAM_MUSIC). */
18
+ volume: number;
19
+ }
20
+
12
21
  export interface MediaDeviceLite {
13
22
  kind: MediaDeviceKind;
14
23
  label: string;
@@ -68,6 +77,36 @@ export class MediaModule {
68
77
  return this.kit.invoke('media.configureAudio', { mode });
69
78
  }
70
79
 
80
+ /**
81
+ * Watch whether sound would actually be heard — for reflecting the OS mute/volume state in UI
82
+ * (e.g. disabling an in-app speaker toggle while the device is silenced). Fires `cb` with the
83
+ * current {@link AudioState} immediately and again whenever it changes (~1s granularity). Native
84
+ * only — on web (no OS silent concept) this is a no-op returning a noop unsubscribe.
85
+ */
86
+ async watchAudio(cb: (state: AudioState) => void): Promise<Unsubscribe> {
87
+ // Wait for the handshake so `platform` is resolved (it defaults to 'web' until ready — callers
88
+ // often subscribe during early app setup, before the bridge handshake lands).
89
+ await this.kit.ready().catch(() => {});
90
+ // Gate on PLATFORM, not the 'media' capability: the audio-state handler is always registered in
91
+ // the native shell, so this works even for apps that don't opt into the full media module. Only
92
+ // the browser (no OS silent/volume concept) is a genuine no-op.
93
+ if (this.kit.platform === 'web') return () => {};
94
+ const off = this.kit.on('media.audioState', (p) => cb(p as AudioState));
95
+ try {
96
+ await this.kit.invoke('media.audioWatch.start');
97
+ } catch (e) {
98
+ off();
99
+ throw e;
100
+ }
101
+ let stopped = false;
102
+ return () => {
103
+ if (stopped) return;
104
+ stopped = true;
105
+ off();
106
+ this.kit.invoke('media.audioWatch.stop').catch((e) => console.warn('[native-kit] audioWatch.stop failed', e));
107
+ };
108
+ }
109
+
71
110
  /** Stop every track on a stream — convenience to release the camera/mic LED. */
72
111
  stop(stream: MediaStream | null | undefined): void {
73
112
  stream?.getTracks().forEach((t) => t.stop());
@@ -19,11 +19,12 @@ export class MotionModule {
19
19
  return this.kit.capability('motion');
20
20
  }
21
21
 
22
- /** Stream motion samples (~10Hz); resolves an unsubscribe once streaming starts. */
23
- async watch(cb: (sample: MotionSample) => void): Promise<Unsubscribe> {
22
+ /** Stream motion samples; resolves an unsubscribe once streaming starts. `opts.hz` sets the emit
23
+ * rate (default 10, clamped 5–60 native-side) — bump it for crisp tilt (e.g. a game asks 60). */
24
+ async watch(cb: (sample: MotionSample) => void, opts?: { hz?: number }): Promise<Unsubscribe> {
24
25
  const off = this.kit.on('motion.data', (p) => cb(p as MotionSample));
25
26
  try {
26
- await this.kit.invoke('motion.start');
27
+ await this.kit.invoke('motion.start', opts?.hz ? { hz: opts.hz } : undefined);
27
28
  } catch (e) {
28
29
  off();
29
30
  throw e;