@livx.cc/native-kit 0.28.0 → 0.31.1

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.28.0",
3
+ "version": "0.31.1",
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",
@@ -2,6 +2,7 @@ import { AppwrapAdapter } from './appwrap-adapter';
2
2
  import { WebAdapter } from './web-adapter';
3
3
  import { Capability, Handshake, InvokeOptions, KIT_PROTOCOL, KitError, NativeKitAdapter, Platform, Unsubscribe } from './types';
4
4
  import { AppModule } from '../modules/app';
5
+ import { BackgroundTaskModule } from '../modules/backgroundTask';
5
6
  import { BillingModule } from '../modules/billing/billing';
6
7
  import { BiometricsModule } from '../modules/biometrics';
7
8
  import { BrowserModule } from '../modules/browser';
@@ -101,6 +102,7 @@ export class NativeKit {
101
102
  public readonly oauth = new OAuthModule(this);
102
103
  public readonly billing = new BillingModule(this);
103
104
  public readonly updates = new UpdatesModule(this);
105
+ public readonly backgroundTask = new BackgroundTaskModule(this);
104
106
 
105
107
  public handshakeInfo: Handshake | null = null;
106
108
  public options: NativeKitOptions;
@@ -133,6 +135,9 @@ export class NativeKit {
133
135
  this.handshakeInfo = handshake;
134
136
  // Zero-config: a native server-loader app begins polling for remote updates.
135
137
  this.updates.__autostart();
138
+ // A background launch carries the wake id in the handshake → dispatch the registered handler
139
+ // (the app's boot register() may have already run, or land moments later — both dispatch).
140
+ this.backgroundTask.__onReady(handshake.backgroundTaskId);
136
141
  return this.handshakeInfo;
137
142
  })();
138
143
  }
package/src/core/types.ts CHANGED
@@ -24,6 +24,11 @@ export interface Handshake {
24
24
  platform: Platform;
25
25
  app: AppInfo;
26
26
  capabilities: Record<string, Capability>;
27
+ /** Set ONLY on a background launch: the OS woke the app (possibly cold, headless, no visible
28
+ * WebView) to run this registered background-task id. The shell populates it; `kit.backgroundTask`
29
+ * reads it from {@link NativeKit.handshakeInfo} and dispatches the registered handler. Absent on a
30
+ * normal foreground launch. See {@link BackgroundTaskModule}. */
31
+ backgroundTaskId?: string;
27
32
  /** Optional diagnostic payload (breadcrumbs, etc.). */
28
33
  debug?: Record<string, unknown>;
29
34
  }
@@ -1,3 +1,4 @@
1
+ /// <reference path="../web-experimental.d.ts" />
1
2
  import { Capability, Handshake, KitError, NativeKitAdapter, Unsubscribe } from './types';
2
3
 
3
4
  /** Decode raw base64 → an ArrayBuffer for File construction (no `data:` prefix expected). */
@@ -17,7 +18,7 @@ function bufferToBase64(buf: ArrayBuffer): string {
17
18
  }
18
19
 
19
20
  /** Map our lock vocabulary to valid ScreenOrientation `OrientationLockType` values. */
20
- function toWebOrientation(o: string): any {
21
+ function toWebOrientation(o: string): string {
21
22
  switch (o) {
22
23
  case 'portrait-upside-down': return 'portrait-secondary';
23
24
  case 'landscape-left': return 'landscape-primary';
@@ -26,6 +27,20 @@ function toWebOrientation(o: string): any {
26
27
  }
27
28
  }
28
29
 
30
+ /** Options accepted by `speech.speak` (a subset of the wire params, all optional). */
31
+ interface SpeakOptions {
32
+ lang?: string;
33
+ rate?: number;
34
+ pitch?: number;
35
+ voice?: string;
36
+ }
37
+
38
+ /** Options accepted by `speech.listen`. */
39
+ interface ListenOptions {
40
+ lang?: string;
41
+ partial?: boolean;
42
+ }
43
+
29
44
  /**
30
45
  * Browser fallback adapter. Fulfills what the Web Platform can, reports honest
31
46
  * capability flags for the rest. Methods for unsupported capabilities resolve
@@ -34,7 +49,7 @@ function toWebOrientation(o: string): any {
34
49
  export class WebAdapter implements NativeKitAdapter {
35
50
  readonly kind = 'web' as const;
36
51
  private toastEl: HTMLElement | null = null;
37
- private wakeLock: any = null;
52
+ private wakeLock: WakeLockSentinel | null = null;
38
53
  private listeners = new Map<string, Set<(payload: unknown) => void>>();
39
54
  private geoWatchId: number | null = null;
40
55
  private motionHandler: ((e: DeviceMotionEvent) => void) | null = null;
@@ -48,29 +63,29 @@ export class WebAdapter implements NativeKitAdapter {
48
63
  }
49
64
 
50
65
  async handshake(): Promise<Handshake> {
51
- const n = navigator as any;
66
+ const n = navigator;
52
67
  const caps: Record<string, Capability> = {
53
68
  haptics: 'vibrate' in navigator ? 'web' : 'none',
54
69
  share: 'share' in navigator ? 'web' : 'none',
55
70
  shareFiles: typeof n.canShare === 'function' ? 'web' : 'none', // navigator.canShare({files}) gates at call time
56
- orientation: (screen as any)?.orientation ? 'web' : 'none', // Screen Orientation API (lock needs fullscreen)
71
+ orientation: screen?.orientation ? 'web' : 'none', // Screen Orientation API (lock needs fullscreen)
57
72
  storage: 'web',
58
73
  secureStorage: 'none',
59
74
  // OPFS (navigator.storage.getDirectory) backs read/write/list; pickFile falls back to
60
75
  // <input type=file> even without OPFS, so 'web' whenever either surface exists.
61
- fs: n.storage?.getDirectory || typeof document !== 'undefined' ? 'web' : 'none',
76
+ fs: typeof n.storage?.getDirectory === 'function' || typeof document !== 'undefined' ? 'web' : 'none',
62
77
  toast: 'web',
63
78
  statusBar: 'none',
64
79
  device: 'web',
65
80
  clipboard: navigator.clipboard ? 'web' : 'none',
66
81
  notifications: 'Notification' in window ? 'web' : 'none',
67
- badge: typeof (navigator as any).setAppBadge === 'function' ? 'web' : 'none', // Badging API (PWA icon badge)
82
+ badge: typeof navigator.setAppBadge === 'function' ? 'web' : 'none', // Badging API (PWA icon badge)
68
83
  biometrics: 'none',
69
84
  geo: 'geolocation' in navigator ? 'web' : 'none',
70
85
  photos: 'web',
71
86
  network: 'web',
72
87
  screen: 'wakeLock' in n ? 'web' : 'none',
73
- keyboard: (window as any).visualViewport ? 'web' : 'none', // VisualViewport resize ≈ keyboard show/hide
88
+ keyboard: window.visualViewport ? 'web' : 'none', // VisualViewport resize ≈ keyboard show/hide
74
89
  dialogs: 'web',
75
90
  reviews: 'none',
76
91
  themeColor: 'web', // the browser honors <meta name="theme-color"> itself
@@ -78,20 +93,25 @@ export class WebAdapter implements NativeKitAdapter {
78
93
  contacts: n.contacts?.select ? 'web' : 'none',
79
94
  calendar: 'none',
80
95
  camera: 'web', // <input capture> — mobile browsers open the camera
81
- media: (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
96
+ media: typeof navigator.mediaDevices?.getUserMedia === 'function' ? 'web' : 'none',
82
97
  // BarcodeDetector (Chrome/Android) decodes; needs a getUserMedia stream to feed it. Both → 'web'.
83
- scanner: typeof (window as any).BarcodeDetector !== 'undefined' && (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
98
+ scanner: typeof window.BarcodeDetector !== 'undefined' && typeof navigator.mediaDevices?.getUserMedia === 'function' ? 'web' : 'none',
84
99
  // TTS: SpeechSynthesis API (broad support). STT: SpeechRecognition (Chrome/webkit only).
85
- speech: typeof (window as any).speechSynthesis !== 'undefined' ? 'web' : 'none',
100
+ speech: typeof window.speechSynthesis !== 'undefined' ? 'web' : 'none',
86
101
  speechRecognition:
87
- typeof (window as any).SpeechRecognition !== 'undefined' ||
88
- typeof (window as any).webkitSpeechRecognition !== 'undefined'
102
+ typeof window.SpeechRecognition !== 'undefined' ||
103
+ typeof window.webkitSpeechRecognition !== 'undefined'
89
104
  ? 'web'
90
105
  : 'none',
91
- app: 'web', // openUrl via window.open; openSettings unsupported
106
+ app: 'web', // openUrl via window.open; openSettings/canOpenUrl unsupported
107
+ shortcuts: 'none', // no home-screen quick actions for a PWA
108
+ privacyScreen: 'none', // a browser can't hide content in the app-switcher or block screenshots
92
109
  browser: 'web', // new tab/window
93
110
  billing: 'none', // no IAP in a plain browser — wire a web checkout yourself
94
111
  push: 'none', // remote push (APNs/FCM) is native-only — web push (VAPID) is the app's own concern
112
+ // headless background execution is native-only — a PWA's Background/Periodic Sync lives in a
113
+ // service worker the kit doesn't own → honest 'none' (schedule/cancel resolve as no-ops).
114
+ backgroundTask: 'none',
95
115
  };
96
116
  return {
97
117
  protocol: 1,
@@ -106,6 +126,8 @@ export class WebAdapter implements NativeKitAdapter {
106
126
  }
107
127
 
108
128
  async invoke<T>(method: string, params?: unknown): Promise<T> {
129
+ // no type: untyped JSON-wire param bag dispatched dynamically — fields flow into ~50 typed sinks
130
+ // (String()/Notification/etc.); `unknown` would force a cast at every read for zero real safety.
109
131
  const p = (params ?? {}) as Record<string, any>;
110
132
  switch (method) {
111
133
  case 'haptics.impact':
@@ -165,7 +187,7 @@ export class WebAdapter implements NativeKitAdapter {
165
187
  case 'ui.brightness.set':
166
188
  throw new KitError('UNSUPPORTED', 'No brightness control on web');
167
189
  case 'ui.keepAwake': {
168
- const wl = (navigator as any).wakeLock;
190
+ const wl = navigator.wakeLock;
169
191
  if (!wl) throw new KitError('UNSUPPORTED', 'Wake Lock API unavailable');
170
192
  if (p.on) this.wakeLock = await wl.request('screen');
171
193
  else { await this.wakeLock?.release(); this.wakeLock = null; }
@@ -192,7 +214,7 @@ export class WebAdapter implements NativeKitAdapter {
192
214
  }
193
215
 
194
216
  case 'screen.orientation.lock': {
195
- const so = (screen as any)?.orientation;
217
+ const so = screen?.orientation;
196
218
  if (!so?.lock) throw new KitError('UNSUPPORTED', 'Screen orientation lock unavailable');
197
219
  // Most browsers require fullscreen before locking; surface the real reason.
198
220
  await so.lock(toWebOrientation(String(p.orientation))).catch((e: Error) => {
@@ -201,10 +223,10 @@ export class WebAdapter implements NativeKitAdapter {
201
223
  return undefined as T;
202
224
  }
203
225
  case 'screen.orientation.unlock':
204
- (screen as any)?.orientation?.unlock?.();
226
+ screen?.orientation?.unlock?.();
205
227
  return undefined as T;
206
228
  case 'screen.orientation.current':
207
- return (((screen as any)?.orientation?.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait') as T;
229
+ return ((screen?.orientation?.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait') as T;
208
230
 
209
231
  case 'keyboard.hide':
210
232
  // No programmatic IME on web — blurring the focused field dismisses it.
@@ -215,7 +237,7 @@ export class WebAdapter implements NativeKitAdapter {
215
237
  throw new KitError('UNSUPPORTED', 'No in-app review prompt on web');
216
238
 
217
239
  case 'device.info': {
218
- const battery = await (navigator as any).getBattery?.().catch(() => null);
240
+ const battery = await navigator.getBattery?.().catch(() => null);
219
241
  return {
220
242
  model: navigator.userAgent.replace(/^Mozilla\/\d+\.\d+\s*/, '').slice(0, 64),
221
243
  os: 'web',
@@ -247,7 +269,7 @@ export class WebAdapter implements NativeKitAdapter {
247
269
  case 'notifications.pending':
248
270
  return 0 as T; // web timers aren't introspectable
249
271
  case 'notifications.setBadge': {
250
- const setAppBadge = (navigator as any).setAppBadge?.bind(navigator);
272
+ const setAppBadge = navigator.setAppBadge?.bind(navigator);
251
273
  if (!setAppBadge) throw new KitError('UNSUPPORTED', 'Badging API unavailable');
252
274
  await setAppBadge(p.count);
253
275
  return undefined as T;
@@ -288,7 +310,7 @@ export class WebAdapter implements NativeKitAdapter {
288
310
  case 'motion.start': {
289
311
  if (typeof DeviceMotionEvent === 'undefined') throw new KitError('UNSUPPORTED', 'No motion sensors on this browser');
290
312
  // iOS Safari gates motion behind an explicit permission prompt
291
- const req = (DeviceMotionEvent as any).requestPermission;
313
+ const req = (DeviceMotionEvent as unknown as DeviceMotionEventWithPermission).requestPermission;
292
314
  if (req) {
293
315
  const state = await req().catch((e: Error) => { throw new KitError('DENIED', e.message); });
294
316
  if (state !== 'granted') throw new KitError('DENIED', 'Motion permission not granted');
@@ -318,9 +340,9 @@ export class WebAdapter implements NativeKitAdapter {
318
340
  return undefined as T;
319
341
 
320
342
  case 'contacts.pick': {
321
- const select = (navigator as any).contacts?.select;
343
+ const select = navigator.contacts?.select;
322
344
  if (!select) throw new KitError('UNSUPPORTED', 'Contact Picker API unavailable');
323
- const picked = await (navigator as any).contacts
345
+ const picked = await navigator.contacts!
324
346
  .select(['name', 'tel', 'email'])
325
347
  .catch((e: Error) => { throw new KitError('DENIED', e.message); });
326
348
  const c = picked?.[0];
@@ -343,7 +365,7 @@ export class WebAdapter implements NativeKitAdapter {
343
365
  case 'speech.speak':
344
366
  return this.speak(String(p.text ?? ''), p) as Promise<T>;
345
367
  case 'speech.stop':
346
- (window as any).speechSynthesis?.cancel?.();
368
+ window.speechSynthesis?.cancel?.();
347
369
  return undefined as T;
348
370
  case 'speech.voices':
349
371
  return this.listVoices() as Promise<T>;
@@ -382,7 +404,7 @@ export class WebAdapter implements NativeKitAdapter {
382
404
  return undefined as T; // browser owns the audio session — nothing to tune
383
405
 
384
406
  case 'network.status': {
385
- const conn = (navigator as any).connection;
407
+ const conn = navigator.connection;
386
408
  return { online: navigator.onLine, type: conn?.type ?? (navigator.onLine ? 'unknown' : 'none') } as T;
387
409
  }
388
410
 
@@ -395,6 +417,14 @@ export class WebAdapter implements NativeKitAdapter {
395
417
  case 'app.openSettings':
396
418
  throw new KitError('UNSUPPORTED', 'No app settings page on web');
397
419
 
420
+ case 'app.canOpenUrl':
421
+ // A PWA can't probe installed apps — be honest rather than guess from the scheme.
422
+ return false as T;
423
+ case 'app.setShortcuts':
424
+ return undefined as T; // no home-screen quick actions in a browser — no-op (cap reported 'none')
425
+ case 'screen.setPrivacy':
426
+ return undefined as T; // a browser can't hide content in the app-switcher — no-op (cap 'none')
427
+
398
428
  case 'app.environment':
399
429
  // On web there's no install — report the honest 'web' source. is_emulator is native-only.
400
430
  return { source: 'web', isEmulator: false } as T;
@@ -417,9 +447,14 @@ export class WebAdapter implements NativeKitAdapter {
417
447
  case 'push.requestPermission':
418
448
  case 'push.register':
419
449
  case 'push.unregister':
420
- case 'push.sendTest':
421
450
  throw new KitError('UNSUPPORTED', 'No native remote push on web — use Web Push (VAPID) in your app, or run inside the appwrap shell');
422
451
 
452
+ case 'backgroundTask.schedule':
453
+ case 'backgroundTask.cancel':
454
+ // Honest no-op: a browser has no OS-scheduled headless wake the kit owns (that's a service
455
+ // worker's Background/Periodic Sync). Resolve so callers don't have to branch on platform.
456
+ return undefined as T;
457
+
423
458
  default:
424
459
  throw new KitError('UNSUPPORTED', `Unknown method: ${method}`);
425
460
  }
@@ -436,14 +471,14 @@ export class WebAdapter implements NativeKitAdapter {
436
471
  };
437
472
  }
438
473
  if (event === 'screen.orientation.change') {
439
- const so = (screen as any)?.orientation;
474
+ const so = screen?.orientation;
440
475
  if (!so) return () => {};
441
476
  const fire = () => cb(String(so.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait');
442
477
  so.addEventListener('change', fire);
443
478
  return () => so.removeEventListener('change', fire);
444
479
  }
445
480
  if (event === 'keyboard.show' || event === 'keyboard.hide') {
446
- const vv = (window as any).visualViewport;
481
+ const vv = window.visualViewport;
447
482
  if (!vv) return () => {};
448
483
  let shown = false;
449
484
  const onResize = () => {
@@ -503,9 +538,9 @@ export class WebAdapter implements NativeKitAdapter {
503
538
  private scanBarcode(
504
539
  formats: string | string[] | undefined,
505
540
  facingMode: 'environment' | 'user'
506
- ): Promise<{ value: string; format: string; bounds?: any } | { cancelled: true }> {
507
- const Detector = (window as any).BarcodeDetector;
508
- const getUserMedia = (navigator as any).mediaDevices?.getUserMedia?.bind((navigator as any).mediaDevices);
541
+ ): Promise<{ value: string; format: string; bounds?: { x: number; y: number; width: number; height: number } } | { cancelled: true }> {
542
+ const Detector = window.BarcodeDetector;
543
+ const getUserMedia = navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices);
509
544
  if (!Detector || !getUserMedia) throw new KitError('UNSUPPORTED', 'BarcodeDetector / camera unavailable in this browser');
510
545
 
511
546
  return new Promise(async (resolve, reject) => {
@@ -534,7 +569,7 @@ export class WebAdapter implements NativeKitAdapter {
534
569
  video.setAttribute('playsinline', '');
535
570
  video.muted = true;
536
571
  video.style.cssText = 'flex:1;width:100%;height:100%;object-fit:cover';
537
- (video as any).srcObject = stream;
572
+ video.srcObject = stream;
538
573
  const close = document.createElement('button');
539
574
  close.textContent = 'Cancel';
540
575
  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';
@@ -563,9 +598,11 @@ export class WebAdapter implements NativeKitAdapter {
563
598
  raf = requestAnimationFrame(tick);
564
599
  };
565
600
  raf = requestAnimationFrame(tick);
566
- } catch (e: any) {
601
+ } catch (e: unknown) {
567
602
  teardown();
568
- reject(new KitError(e?.name === 'NotAllowedError' ? 'DENIED' : 'NATIVE_ERROR', e?.message ?? 'scan failed'));
603
+ const name = e instanceof Error ? e.name : '';
604
+ const message = e instanceof Error ? e.message : 'scan failed';
605
+ reject(new KitError(name === 'NotAllowedError' ? 'DENIED' : 'NATIVE_ERROR', message));
569
606
  }
570
607
  });
571
608
  }
@@ -573,29 +610,29 @@ export class WebAdapter implements NativeKitAdapter {
573
610
  // ── speech (Web Speech API) ─────────────────────────────────────────
574
611
 
575
612
  /** Speak via SpeechSynthesis; resolve when the utterance ends (or rejects on synth error). */
576
- private speak(text: string, opts: Record<string, any>): Promise<void> {
577
- const synth = (window as any).speechSynthesis;
613
+ private speak(text: string, opts: SpeakOptions): Promise<void> {
614
+ const synth = window.speechSynthesis;
578
615
  if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
579
616
  return new Promise((resolve, reject) => {
580
- const u = new (window as any).SpeechSynthesisUtterance(text);
617
+ const u = new window.SpeechSynthesisUtterance(text);
581
618
  if (opts.lang) u.lang = opts.lang;
582
619
  if (opts.rate != null) u.rate = opts.rate;
583
620
  if (opts.pitch != null) u.pitch = opts.pitch;
584
621
  if (opts.voice) {
585
- const v = synth.getVoices().find((vc: any) => vc.voiceURI === opts.voice || vc.name === opts.voice);
622
+ const v = synth.getVoices().find((vc) => vc.voiceURI === opts.voice || vc.name === opts.voice);
586
623
  if (v) u.voice = v;
587
624
  }
588
625
  u.onend = () => resolve();
589
- u.onerror = (e: any) => reject(new KitError('NATIVE_ERROR', e?.error ?? 'speech synthesis failed'));
626
+ u.onerror = (e: SpeechSynthesisErrorEvent) => reject(new KitError('NATIVE_ERROR', e?.error ?? 'speech synthesis failed'));
590
627
  synth.speak(u);
591
628
  });
592
629
  }
593
630
 
594
631
  /** List synthesizer voices, awaiting the async `voiceschanged` event when the list is empty. */
595
632
  private listVoices(): Promise<Array<{ id: string; name: string; lang: string }>> {
596
- const synth = (window as any).speechSynthesis;
633
+ const synth = window.speechSynthesis;
597
634
  if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
598
- const map = (vs: any[]) => vs.map((v) => ({ id: v.voiceURI ?? v.name, name: v.name, lang: v.lang }));
635
+ const map = (vs: SpeechSynthesisVoice[]) => vs.map((v) => ({ id: v.voiceURI ?? v.name, name: v.name, lang: v.lang }));
599
636
  const ready = synth.getVoices();
600
637
  if (ready.length) return Promise.resolve(map(ready));
601
638
  // Chrome populates voices asynchronously — wait once for voiceschanged, with a short fallback.
@@ -612,8 +649,8 @@ export class WebAdapter implements NativeKitAdapter {
612
649
  }
613
650
 
614
651
  /** Capture the mic via SpeechRecognition; resolve the FINAL transcript, stream partials. */
615
- private listen(opts: Record<string, any>): Promise<string> {
616
- const Rec = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
652
+ private listen(opts: ListenOptions): Promise<string> {
653
+ const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
617
654
  if (!Rec) throw new KitError('UNSUPPORTED', 'SpeechRecognition unavailable (Chrome only)');
618
655
  return new Promise((resolve, reject) => {
619
656
  const rec = new Rec();
@@ -630,7 +667,7 @@ export class WebAdapter implements NativeKitAdapter {
630
667
  };
631
668
  const stop = () => { try { rec.stop(); } catch { /* already stopped */ } };
632
669
  this.listenStop = stop;
633
- rec.onresult = (e: any) => {
670
+ rec.onresult = (e) => {
634
671
  let interim = '';
635
672
  let finalText = '';
636
673
  for (let i = 0; i < e.results.length; i++) {
@@ -641,7 +678,7 @@ export class WebAdapter implements NativeKitAdapter {
641
678
  best = (finalText || interim).trim();
642
679
  if (opts.partial && interim) this.emit('speech.partial', { transcript: interim.trim() });
643
680
  };
644
- rec.onerror = (e: any) => {
681
+ rec.onerror = (e) => {
645
682
  if (settled) return;
646
683
  settled = true;
647
684
  if (this.listenStop === stop) this.listenStop = null;
@@ -695,10 +732,10 @@ export class WebAdapter implements NativeKitAdapter {
695
732
  // OPFS is one private root per origin — our `dir` enum has no meaning here, so paths resolve
696
733
  // straight off the root. Slash-separated paths walk nested dirs.
697
734
 
698
- private async opfsRoot(): Promise<any> {
699
- const dir = (navigator as any).storage?.getDirectory;
735
+ private async opfsRoot(): Promise<FileSystemDirectoryHandle> {
736
+ const dir = navigator.storage?.getDirectory;
700
737
  if (!dir) throw new KitError('UNSUPPORTED', 'No OPFS in this browser');
701
- return (navigator as any).storage.getDirectory();
738
+ return navigator.storage.getDirectory();
702
739
  }
703
740
 
704
741
  /** Split a path into clean segments, rejecting any `..` so it can't escape the OPFS root. */
@@ -709,7 +746,7 @@ export class WebAdapter implements NativeKitAdapter {
709
746
  }
710
747
 
711
748
  /** Walk `path` to its parent dir handle + leaf name. `create` makes intermediate dirs. */
712
- private async opfsResolveParent(path: string, create: boolean): Promise<{ parent: any; name: string }> {
749
+ private async opfsResolveParent(path: string, create: boolean): Promise<{ parent: FileSystemDirectoryHandle; name: string }> {
713
750
  const parts = this.opfsSegments(path);
714
751
  const name = parts.pop();
715
752
  if (!name) throw new KitError('NATIVE_ERROR', 'Empty path');
@@ -746,7 +783,7 @@ export class WebAdapter implements NativeKitAdapter {
746
783
  let dir = await this.opfsRoot();
747
784
  for (const seg of this.opfsSegments(path)) dir = await dir.getDirectoryHandle(seg);
748
785
  const out: Array<{ name: string; type: 'file' | 'dir' }> = [];
749
- for await (const [name, handle] of (dir as any).entries()) {
786
+ for await (const [name, handle] of dir.entries()) {
750
787
  out.push({ name, type: handle.kind === 'directory' ? 'dir' : 'file' });
751
788
  }
752
789
  return out;
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { NativeKit, NativeKitOptions, kit } from './core/NativeKit';
2
2
  export type { KitContext } from './core/NativeKit';
3
- export type { AppEnvironment, InstallSource } from './modules/app';
3
+ export type { AppEnvironment, AppShortcut, InstallSource } from './modules/app';
4
4
  export { AppwrapAdapter } from './core/appwrap-adapter';
5
5
  export { WebAdapter } from './core/web-adapter';
6
6
  export { KitError } from './core/types';
@@ -45,6 +45,8 @@ export { ClientTrustedValidator, HttpValidator, HttpValidatorOptions } from './m
45
45
  export { HttpBillingProvider, HttpBillingProviderOptions } from './modules/billing/providers';
46
46
  export type { HeaderProvider } from './modules/billing/http';
47
47
  export { HealthModule } from './modules/health';
48
+ export { BackgroundTaskModule } from './modules/backgroundTask';
49
+ export type { BackgroundTaskHandler, ScheduleBackgroundTaskOptions } from './modules/backgroundTask';
48
50
  export type {
49
51
  BillingProvider,
50
52
  BillingValidator,
@@ -1,4 +1,13 @@
1
1
  import type { NativeKit } from '../core/NativeKit';
2
+ import type { Unsubscribe } from '../core/types';
3
+
4
+ /** A home-screen long-press quick action. `id` is echoed back to {@link AppModule.onShortcut} when
5
+ * the user activates it; keep it stable so your router can map it. Custom icons are v1-omitted. */
6
+ export interface AppShortcut {
7
+ id: string;
8
+ title: string;
9
+ subtitle?: string;
10
+ }
2
11
 
3
12
  /** Where this build was installed from. Drives beta-vs-prod analytics cohorts.
4
13
  * - `appstore` / `playstore` — public store install
@@ -57,8 +66,49 @@ export class AppModule {
57
66
  return this.kit.invoke('app.openUrl', { url });
58
67
  }
59
68
 
69
+ /**
70
+ * Probe whether the OS can open `url` — i.e. some installed app (or the OS) handles its scheme.
71
+ * Use it to hide a "Open in <App>" button when the target app isn't installed.
72
+ *
73
+ * Common schemes (http/https/tel/mailto/sms) resolve without any declaration. To probe a CUSTOM
74
+ * scheme (e.g. `whatsapp://`) it MUST be declared up-front, else the OS reports false for privacy:
75
+ * - Custom scheme, BOTH platforms — list the scheme in `appwrap.json.queryUrlSchemes`. It stamps iOS
76
+ * Info.plist `LSApplicationQueriesSchemes` AND Android `<queries>` (a VIEW `<intent>` per scheme),
77
+ * so a scheme probe works identically on iOS and Android with one declaration.
78
+ * - Android explicit package (optional) — to probe a specific package directly, list it in
79
+ * `appwrap.json.queryPackages` (→ AndroidManifest `<queries><package>`). Android-only.
80
+ * Web is always `false` — a PWA can't probe installed apps (honest).
81
+ */
82
+ canOpenUrl(url: string): Promise<boolean> {
83
+ return this.kit.invoke('app.canOpenUrl', { url });
84
+ }
85
+
60
86
  /** Open this app's page in the OS Settings app (to toggle permissions, etc.). */
61
87
  openSettings(): Promise<void> {
62
88
  return this.kit.invoke('app.openSettings');
63
89
  }
90
+
91
+ /** 'native' where the OS exposes home-screen quick actions (iOS 3D-Touch/long-press shortcut items;
92
+ * Android 7.1+ app shortcuts) · else 'none'. Branch on this, not try/catch. */
93
+ get shortcutsCapability() {
94
+ return this.kit.capability('shortcuts');
95
+ }
96
+
97
+ /**
98
+ * Set the app's home-screen long-press quick actions (replaces any previously set). Pass `[]` to
99
+ * clear. iOS assigns `UIApplication.shortcutItems`; Android sets dynamic shortcuts (API 25+, no-op
100
+ * below); web is a no-op. Activation is delivered via {@link onShortcut}. Custom icons are v1-omitted.
101
+ */
102
+ setShortcuts(items: AppShortcut[]): Promise<void> {
103
+ return this.kit.invoke('app.setShortcuts', { items });
104
+ }
105
+
106
+ /**
107
+ * Fire when the user activates a home-screen shortcut, with its `id`. Like deep links, a shortcut
108
+ * that COLD-LAUNCHED the app is buffered natively until the handshake, so a listener registered at
109
+ * startup still receives it.
110
+ */
111
+ onShortcut(cb: (id: string) => void): Unsubscribe {
112
+ return this.kit.on('app.shortcut', (p) => cb((p as { id: string }).id));
113
+ }
64
114
  }
@@ -0,0 +1,122 @@
1
+ import type { NativeKit } from '../core/NativeKit';
2
+
3
+ /**
4
+ * A headless background-task handler. Runs (possibly cold, with no visible WebView) when the OS wakes
5
+ * the app for `ctx.id`. `ctx.signal` aborts when the OS budget is nearly spent (~25s, below the iOS
6
+ * ~30s ceiling) or the OS calls the expiration handler — observe it for any long await and bail
7
+ * promptly, or the OS may kill the app and refuse future wakes. Resolve when done; a rejection is
8
+ * reported to the OS as a failed run (it still reschedules).
9
+ */
10
+ export type BackgroundTaskHandler = (ctx: { id: string; signal: AbortSignal }) => Promise<void>;
11
+
12
+ /** Constraints for {@link BackgroundTaskModule.schedule}. The OS treats these as HINTS — actual wake
13
+ * timing is the OS's call (it batches by power/network/usage), so a wake is opportunistic, not a timer. */
14
+ export interface ScheduleBackgroundTaskOptions {
15
+ /** Task id — must be one of `appwrap.json.backgroundTasks` (iOS requires identifiers declared at
16
+ * build time). The same id is matched against the wake handshake + the registered handler. */
17
+ id: string;
18
+ /** Earliest the OS should consider waking again (a floor, not a guarantee). iOS clamps app-refresh
19
+ * to its own minimum; Android `WorkManager` periodic work has a 15-min platform minimum. */
20
+ minIntervalMs?: number;
21
+ /** Only run when the network is reachable (iOS `BGProcessingTaskRequest.requiresNetworkConnectivity`
22
+ * / Android `NetworkType.CONNECTED`). */
23
+ requiresNetwork?: boolean;
24
+ /** Only run while charging (iOS `BGProcessingTaskRequest.requiresExternalPower` / Android
25
+ * `setRequiresCharging`). */
26
+ requiresCharging?: boolean;
27
+ }
28
+
29
+ /** Wall-clock guard below the iOS ~30s background budget. The kit aborts the signal at this point so a
30
+ * handler that ignores the OS expiration still releases the task before the OS force-kills the app. */
31
+ const SAFETY_TIMEOUT_MS = 25_000;
32
+
33
+ /**
34
+ * Headless background execution with a JS-handler contract — ONE API across platforms.
35
+ *
36
+ * Flow: at EVERY launch the app calls {@link register} (idempotent) for each task id. When the OS woke
37
+ * the app for a task, the handshake carries that id ({@link import('../core/types').Handshake.backgroundTaskId});
38
+ * the kit invokes the matching handler with an {@link AbortSignal}, arms a {@link SAFETY_TIMEOUT_MS}
39
+ * abort, and on resolve/reject/timeout calls `backgroundTask.finish` so the shell completes the OS
40
+ * task + reschedules (BGTask is one-shot — it MUST resubmit). Registration may land before OR after
41
+ * {@link NativeKit.ready} resolves; both orderings dispatch (a pending wake is replayed to a late
42
+ * registration, and an early registration is dispatched the moment ready resolves with a wake id).
43
+ *
44
+ * Native (iOS `BGTaskScheduler` + an offscreen `WKWebView`, Android `WorkManager` + a headless
45
+ * `WebView`) reports `capability === 'native'`. Web is honestly `'none'`: a PWA's background-sync
46
+ * lives in a service worker the kit doesn't own — {@link schedule}/{@link cancel} resolve as no-ops
47
+ * and {@link register} records nothing.
48
+ */
49
+ export class BackgroundTaskModule {
50
+ private handlers = new Map<string, BackgroundTaskHandler>();
51
+ /** A wake id seen before its handler was registered — replayed on the late {@link register}. */
52
+ private pendingWakeId: string | null = null;
53
+ /** Ids already dispatched this session — guards against a double-fire (replay + ready both matching). */
54
+ private dispatched = new Set<string>();
55
+
56
+ constructor(private kit: NativeKit) {}
57
+
58
+ /** Called by {@link NativeKit.ready} once the handshake resolves (never triggers the handshake
59
+ * itself — same lazy contract as the rest of the kit). A background launch carries the wake id in
60
+ * the handshake: if its handler already registered (early) → dispatch now; else remember it for the
61
+ * late {@link register}. @internal */
62
+ __onReady(backgroundTaskId?: string): void {
63
+ if (!backgroundTaskId) return;
64
+ if (this.handlers.has(backgroundTaskId)) this.dispatch(backgroundTaskId);
65
+ else this.pendingWakeId = backgroundTaskId;
66
+ }
67
+
68
+ /** 'native' on a shell · 'none' on web (PWA background-sync is the app's service-worker concern). */
69
+ get capability() {
70
+ return this.kit.capability('backgroundTask');
71
+ }
72
+
73
+ /**
74
+ * Record the handler for `id`. Call at boot on EVERY launch (idempotent — re-registering replaces).
75
+ * If the app was woken for this id (the handshake carried it), the handler dispatches immediately,
76
+ * whether the wake was already known (ready resolved first) or arrives later.
77
+ */
78
+ register(id: string, handler: BackgroundTaskHandler): void {
79
+ this.handlers.set(id, handler);
80
+ if (this.pendingWakeId === id) {
81
+ this.pendingWakeId = null;
82
+ this.dispatch(id);
83
+ }
84
+ }
85
+
86
+ /** Ask the OS to (re)schedule a wake for `id`. No-op resolve on web. */
87
+ schedule(opts: ScheduleBackgroundTaskOptions): Promise<void> {
88
+ return this.kit.invoke<void>('backgroundTask.schedule', opts);
89
+ }
90
+
91
+ /** Cancel a scheduled task. No-op resolve on web. */
92
+ cancel(id: string): Promise<void> {
93
+ return this.kit.invoke<void>('backgroundTask.cancel', { id });
94
+ }
95
+
96
+ /** Run the registered handler under an abort-guarded budget, then report completion to the shell so
97
+ * it finishes the OS task + reschedules. Idempotent per id per session. */
98
+ private async dispatch(id: string): Promise<void> {
99
+ if (this.dispatched.has(id)) return;
100
+ this.dispatched.add(id);
101
+ const handler = this.handlers.get(id);
102
+ if (!handler) return;
103
+
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort(), SAFETY_TIMEOUT_MS);
106
+ let success = true;
107
+ try {
108
+ await handler({ id, signal: controller.signal });
109
+ } catch (e) {
110
+ success = false;
111
+ console.warn('[native-kit] backgroundTask handler rejected', id, e);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ // Tell the shell to complete the OS task + resubmit the next request. The handler's own work is
115
+ // done; a finish-report failure must not mask it, so swallow (logged) — the OS budget is spent
116
+ // either way.
117
+ await this.kit
118
+ .invoke<void>('backgroundTask.finish', { id, success })
119
+ .catch((e) => console.warn('[native-kit] backgroundTask.finish failed', id, e));
120
+ }
121
+ }
122
+ }
@@ -14,8 +14,8 @@ export interface HttpJsonOptions {
14
14
  }
15
15
 
16
16
  /** POST/GET JSON, throwing on non-2xx. Keeps validators + providers DRY. */
17
- export async function httpJson(opts: HttpJsonOptions): Promise<any> {
18
- const f = opts.fetch ?? (globalThis as any).fetch;
17
+ export async function httpJson(opts: HttpJsonOptions): Promise<unknown> {
18
+ const f = opts.fetch ?? globalThis.fetch;
19
19
  if (!f) throw new Error('billing: no fetch available — pass one via options.fetch');
20
20
  const h = typeof opts.headers === 'function' ? await opts.headers() : opts.headers;
21
21
  const res = await f(opts.url, {
@@ -13,8 +13,10 @@ export class HttpBillingProviderOptions {
13
13
  redirect: (url: string) => void = (url) => {
14
14
  if (typeof window !== 'undefined') window.location.assign(url);
15
15
  };
16
- mapProducts: (json: any) => Product[] = (j) => (j?.products ?? j ?? []) as Product[];
17
- mapEntitlements: (json: any) => Entitlement[] = (j) => (j?.entitlements ?? j ?? []) as Entitlement[];
16
+ mapProducts: (json: unknown) => Product[] = (j) =>
17
+ ((j as { products?: Product[] } | null)?.products ?? (j as Product[] | null) ?? []);
18
+ mapEntitlements: (json: unknown) => Entitlement[] = (j) =>
19
+ ((j as { entitlements?: Entitlement[] } | null)?.entitlements ?? (j as Entitlement[] | null) ?? []);
18
20
  }
19
21
 
20
22
  /**
@@ -39,8 +41,9 @@ export class HttpBillingProvider implements BillingProvider {
39
41
 
40
42
  async purchase(productId: string): Promise<Entitlement[]> {
41
43
  const json = await httpJson({ ...this.req(), url: `${this.base()}/checkout`, method: 'POST', body: { productId } });
42
- if (json?.url) {
43
- this.options.redirect(String(json.url)); // hosted checkout — page navigates away
44
+ const url = (json as { url?: unknown } | null)?.url;
45
+ if (url) {
46
+ this.options.redirect(String(url)); // hosted checkout — page navigates away
44
47
  return []; // entitlements arrive via entitlements() after redirect-back
45
48
  }
46
49
  return this.options.mapEntitlements(json); // backend completed inline
@@ -56,7 +59,8 @@ export class HttpBillingProvider implements BillingProvider {
56
59
 
57
60
  async manageSubscriptions(): Promise<void> {
58
61
  const json = await httpJson({ ...this.req(), url: `${this.base()}/portal`, method: 'POST', body: {} });
59
- if (json?.url) this.options.redirect(String(json.url)); // Stripe Billing Portal, etc.
62
+ const url = (json as { url?: unknown } | null)?.url;
63
+ if (url) this.options.redirect(String(url)); // Stripe Billing Portal, etc.
60
64
  }
61
65
 
62
66
  private base() {
@@ -32,7 +32,8 @@ export class HttpValidatorOptions {
32
32
  /** Static headers or a thunk (e.g. to inject a fresh bearer token). */
33
33
  headers?: HeaderProvider;
34
34
  /** Map the provider's response JSON → `Entitlement[]`. Default expects `{ entitlements: [...] }`. */
35
- mapResponse: (json: any) => Entitlement[] = (j) => (j?.entitlements ?? []) as Entitlement[];
35
+ mapResponse: (json: unknown) => Entitlement[] = (j) =>
36
+ ((j as { entitlements?: Entitlement[] } | null)?.entitlements ?? []);
36
37
  /** Injected for tests / non-DOM runtimes. Defaults to global `fetch`. */
37
38
  fetch?: typeof fetch;
38
39
  }
@@ -65,13 +65,6 @@ export class PushModule {
65
65
  return this.kit.invoke('push.unregister');
66
66
  }
67
67
 
68
- /** Dev/demo: ask your backend (via `push.registrationUrl`) to push THIS device on demand. The shell
69
- * POSTs the token natively (no WebView CORS) with `test:true`. Resolves `{ status }` (the backend's
70
- * HTTP code). Requires a configured registrationUrl + a registered token. */
71
- sendTest(): Promise<{ status: number }> {
72
- return this.kit.invoke('push.sendTest', undefined, { timeoutMs: 20_000 });
73
- }
74
-
75
68
  /** Foreground message delivery (app open, no tap). */
76
69
  onMessage(cb: (m: PushMessage) => void): Unsubscribe {
77
70
  return this.kit.on('push.message', (p) => cb(p as PushMessage));
@@ -11,13 +11,29 @@ export type OrientationLock =
11
11
  | 'landscape-right'
12
12
  | 'any';
13
13
 
14
- /** Screen-level controls. Today: orientation; brightness/keepAwake live on `kit.ui`. */
14
+ /** Screen-level controls. Today: orientation + privacy screen; brightness/keepAwake live on `kit.ui`. */
15
15
  export class ScreenModule {
16
16
  readonly orientation: OrientationController;
17
17
 
18
- constructor(kit: NativeKit) {
18
+ constructor(private kit: NativeKit) {
19
19
  this.orientation = new OrientationController(kit);
20
20
  }
21
+
22
+ /** 'native' on a shell · else 'none' — the browser can't hide content in the app-switcher or block
23
+ * screenshots. Branch on this, not try/catch. */
24
+ get privacyCapability() {
25
+ return this.kit.capability('privacyScreen');
26
+ }
27
+
28
+ /**
29
+ * Privacy screen — hide app content in the app-switcher / on backgrounding and block screenshots.
30
+ * `true` enables, `false` disables (the state persists across background/foreground until changed).
31
+ * iOS covers the window with a blur while inactive/backgrounded; Android sets `FLAG_SECURE` (which
32
+ * also blocks screenshots and screen recording — expected). Web is an honest no-op.
33
+ */
34
+ setPrivacy(enabled: boolean): Promise<void> {
35
+ return this.kit.invoke('screen.setPrivacy', { enabled });
36
+ }
21
37
  }
22
38
 
23
39
  /**
@@ -156,7 +156,7 @@ export class UpdatesModule {
156
156
  * `<meta name="app-version">` tag. '' (unknown) when neither is present — in which case we never
157
157
  * raise a false update prompt. */
158
158
  function bootVersion(): string {
159
- const g = typeof window !== 'undefined' ? (window as any).__APP_VERSION__ : undefined;
159
+ const g = typeof window !== 'undefined' ? (window as { __APP_VERSION__?: string }).__APP_VERSION__ : undefined;
160
160
  if (g) return String(g);
161
161
  if (typeof document !== 'undefined') {
162
162
  const meta = document.querySelector('meta[name="app-version"]');
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Ambient augmentations for the non-standard / experimental Web APIs the WebAdapter
3
+ * feature-detects. These are NOT in the current TS DOM lib, so without these decls
4
+ * each touch would need an `as any` cast. Shapes are intentionally MINIMAL — only the
5
+ * members web-adapter.ts actually reads — but REAL (no `any`), so call sites stay typed.
6
+ *
7
+ * Anything the DOM lib already types (setAppBadge/clearAppBadge, speechSynthesis,
8
+ * SpeechSynthesisUtterance, visualViewport, mediaDevices, wakeLock, ScreenOrientation
9
+ * core, storage.getDirectory, HTMLVideoElement.playsInline/srcObject) is deliberately
10
+ * absent here and used straight off the lib types.
11
+ */
12
+
13
+ export {};
14
+
15
+ /** A picked contact from the Contact Picker API (navigator.contacts.select). */
16
+ interface WebContact {
17
+ name?: string[];
18
+ tel?: string[];
19
+ email?: string[];
20
+ }
21
+
22
+ /** A detected code from the Barcode Detection API. */
23
+ interface DetectedBarcode {
24
+ rawValue: string;
25
+ format: string;
26
+ boundingBox?: { x: number; y: number; width: number; height: number };
27
+ }
28
+
29
+ /** Minimal BarcodeDetector surface (Chrome/Android). */
30
+ interface BarcodeDetectorLike {
31
+ detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
32
+ }
33
+ interface BarcodeDetectorCtor {
34
+ new (options?: { formats?: string[] }): BarcodeDetectorLike;
35
+ }
36
+
37
+ /** Minimal Web Speech `SpeechRecognition` surface (Chrome/webkit). */
38
+ interface SpeechRecognitionLike {
39
+ lang: string;
40
+ interimResults: boolean;
41
+ continuous: boolean;
42
+ onresult: ((event: { results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }> }) => void) | null;
43
+ onerror: ((event: { error?: string }) => void) | null;
44
+ onend: (() => void) | null;
45
+ start(): void;
46
+ stop(): void;
47
+ }
48
+ interface SpeechRecognitionCtor {
49
+ new (): SpeechRecognitionLike;
50
+ }
51
+
52
+ declare global {
53
+ interface Navigator {
54
+ /** Battery Status API — resolves a snapshot the adapter reads `level`/`charging` off. */
55
+ getBattery?(): Promise<{ level: number; charging: boolean }>;
56
+ /** Contact Picker API. */
57
+ contacts?: {
58
+ select(properties: string[], options?: { multiple?: boolean }): Promise<WebContact[]>;
59
+ };
60
+ /** Network Information API (non-standard `connection`). */
61
+ connection?: { type?: string };
62
+ }
63
+
64
+ interface Window {
65
+ BarcodeDetector?: BarcodeDetectorCtor;
66
+ SpeechRecognition?: SpeechRecognitionCtor;
67
+ webkitSpeechRecognition?: SpeechRecognitionCtor;
68
+ }
69
+
70
+ /** Screen Orientation lock/unlock — newer than the lib's ScreenOrientation interface. */
71
+ interface ScreenOrientation {
72
+ lock?(orientation: string): Promise<void>;
73
+ unlock?(): void;
74
+ }
75
+
76
+ /** OPFS directory async iterator — newer than the lib's FileSystemDirectoryHandle interface. */
77
+ interface FileSystemDirectoryHandle {
78
+ entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
79
+ }
80
+
81
+ /** iOS Safari gates DeviceMotion behind an explicit permission prompt (static method) — the lib
82
+ * already declares `DeviceMotionEvent` as a `var`, so this can't merge onto it; web-adapter casts
83
+ * the constructor to this shape at the single call site instead. */
84
+ interface DeviceMotionEventWithPermission {
85
+ requestPermission?(): Promise<'granted' | 'denied' | 'prompt'>;
86
+ }
87
+ }