@livx.cc/native-kit 0.31.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.31.
|
|
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",
|
package/src/core/web-adapter.ts
CHANGED
|
@@ -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):
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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,14 +93,14 @@ 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:
|
|
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
|
|
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
|
|
100
|
+
speech: typeof window.speechSynthesis !== 'undefined' ? 'web' : 'none',
|
|
86
101
|
speechRecognition:
|
|
87
|
-
typeof
|
|
88
|
-
typeof
|
|
102
|
+
typeof window.SpeechRecognition !== 'undefined' ||
|
|
103
|
+
typeof window.webkitSpeechRecognition !== 'undefined'
|
|
89
104
|
? 'web'
|
|
90
105
|
: 'none',
|
|
91
106
|
app: 'web', // openUrl via window.open; openSettings/canOpenUrl unsupported
|
|
@@ -111,6 +126,8 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
111
126
|
}
|
|
112
127
|
|
|
113
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.
|
|
114
131
|
const p = (params ?? {}) as Record<string, any>;
|
|
115
132
|
switch (method) {
|
|
116
133
|
case 'haptics.impact':
|
|
@@ -170,7 +187,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
170
187
|
case 'ui.brightness.set':
|
|
171
188
|
throw new KitError('UNSUPPORTED', 'No brightness control on web');
|
|
172
189
|
case 'ui.keepAwake': {
|
|
173
|
-
const wl =
|
|
190
|
+
const wl = navigator.wakeLock;
|
|
174
191
|
if (!wl) throw new KitError('UNSUPPORTED', 'Wake Lock API unavailable');
|
|
175
192
|
if (p.on) this.wakeLock = await wl.request('screen');
|
|
176
193
|
else { await this.wakeLock?.release(); this.wakeLock = null; }
|
|
@@ -197,7 +214,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
case 'screen.orientation.lock': {
|
|
200
|
-
const so =
|
|
217
|
+
const so = screen?.orientation;
|
|
201
218
|
if (!so?.lock) throw new KitError('UNSUPPORTED', 'Screen orientation lock unavailable');
|
|
202
219
|
// Most browsers require fullscreen before locking; surface the real reason.
|
|
203
220
|
await so.lock(toWebOrientation(String(p.orientation))).catch((e: Error) => {
|
|
@@ -206,10 +223,10 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
206
223
|
return undefined as T;
|
|
207
224
|
}
|
|
208
225
|
case 'screen.orientation.unlock':
|
|
209
|
-
|
|
226
|
+
screen?.orientation?.unlock?.();
|
|
210
227
|
return undefined as T;
|
|
211
228
|
case 'screen.orientation.current':
|
|
212
|
-
return ((
|
|
229
|
+
return ((screen?.orientation?.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait') as T;
|
|
213
230
|
|
|
214
231
|
case 'keyboard.hide':
|
|
215
232
|
// No programmatic IME on web — blurring the focused field dismisses it.
|
|
@@ -220,7 +237,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
220
237
|
throw new KitError('UNSUPPORTED', 'No in-app review prompt on web');
|
|
221
238
|
|
|
222
239
|
case 'device.info': {
|
|
223
|
-
const battery = await
|
|
240
|
+
const battery = await navigator.getBattery?.().catch(() => null);
|
|
224
241
|
return {
|
|
225
242
|
model: navigator.userAgent.replace(/^Mozilla\/\d+\.\d+\s*/, '').slice(0, 64),
|
|
226
243
|
os: 'web',
|
|
@@ -252,7 +269,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
252
269
|
case 'notifications.pending':
|
|
253
270
|
return 0 as T; // web timers aren't introspectable
|
|
254
271
|
case 'notifications.setBadge': {
|
|
255
|
-
const setAppBadge =
|
|
272
|
+
const setAppBadge = navigator.setAppBadge?.bind(navigator);
|
|
256
273
|
if (!setAppBadge) throw new KitError('UNSUPPORTED', 'Badging API unavailable');
|
|
257
274
|
await setAppBadge(p.count);
|
|
258
275
|
return undefined as T;
|
|
@@ -293,7 +310,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
293
310
|
case 'motion.start': {
|
|
294
311
|
if (typeof DeviceMotionEvent === 'undefined') throw new KitError('UNSUPPORTED', 'No motion sensors on this browser');
|
|
295
312
|
// iOS Safari gates motion behind an explicit permission prompt
|
|
296
|
-
const req = (DeviceMotionEvent as
|
|
313
|
+
const req = (DeviceMotionEvent as unknown as DeviceMotionEventWithPermission).requestPermission;
|
|
297
314
|
if (req) {
|
|
298
315
|
const state = await req().catch((e: Error) => { throw new KitError('DENIED', e.message); });
|
|
299
316
|
if (state !== 'granted') throw new KitError('DENIED', 'Motion permission not granted');
|
|
@@ -323,9 +340,9 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
323
340
|
return undefined as T;
|
|
324
341
|
|
|
325
342
|
case 'contacts.pick': {
|
|
326
|
-
const select =
|
|
343
|
+
const select = navigator.contacts?.select;
|
|
327
344
|
if (!select) throw new KitError('UNSUPPORTED', 'Contact Picker API unavailable');
|
|
328
|
-
const picked = await
|
|
345
|
+
const picked = await navigator.contacts!
|
|
329
346
|
.select(['name', 'tel', 'email'])
|
|
330
347
|
.catch((e: Error) => { throw new KitError('DENIED', e.message); });
|
|
331
348
|
const c = picked?.[0];
|
|
@@ -348,7 +365,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
348
365
|
case 'speech.speak':
|
|
349
366
|
return this.speak(String(p.text ?? ''), p) as Promise<T>;
|
|
350
367
|
case 'speech.stop':
|
|
351
|
-
|
|
368
|
+
window.speechSynthesis?.cancel?.();
|
|
352
369
|
return undefined as T;
|
|
353
370
|
case 'speech.voices':
|
|
354
371
|
return this.listVoices() as Promise<T>;
|
|
@@ -387,7 +404,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
387
404
|
return undefined as T; // browser owns the audio session — nothing to tune
|
|
388
405
|
|
|
389
406
|
case 'network.status': {
|
|
390
|
-
const conn =
|
|
407
|
+
const conn = navigator.connection;
|
|
391
408
|
return { online: navigator.onLine, type: conn?.type ?? (navigator.onLine ? 'unknown' : 'none') } as T;
|
|
392
409
|
}
|
|
393
410
|
|
|
@@ -454,14 +471,14 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
454
471
|
};
|
|
455
472
|
}
|
|
456
473
|
if (event === 'screen.orientation.change') {
|
|
457
|
-
const so =
|
|
474
|
+
const so = screen?.orientation;
|
|
458
475
|
if (!so) return () => {};
|
|
459
476
|
const fire = () => cb(String(so.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait');
|
|
460
477
|
so.addEventListener('change', fire);
|
|
461
478
|
return () => so.removeEventListener('change', fire);
|
|
462
479
|
}
|
|
463
480
|
if (event === 'keyboard.show' || event === 'keyboard.hide') {
|
|
464
|
-
const vv =
|
|
481
|
+
const vv = window.visualViewport;
|
|
465
482
|
if (!vv) return () => {};
|
|
466
483
|
let shown = false;
|
|
467
484
|
const onResize = () => {
|
|
@@ -521,9 +538,9 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
521
538
|
private scanBarcode(
|
|
522
539
|
formats: string | string[] | undefined,
|
|
523
540
|
facingMode: 'environment' | 'user'
|
|
524
|
-
): Promise<{ value: string; format: string; bounds?:
|
|
525
|
-
const Detector =
|
|
526
|
-
const getUserMedia =
|
|
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);
|
|
527
544
|
if (!Detector || !getUserMedia) throw new KitError('UNSUPPORTED', 'BarcodeDetector / camera unavailable in this browser');
|
|
528
545
|
|
|
529
546
|
return new Promise(async (resolve, reject) => {
|
|
@@ -552,7 +569,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
552
569
|
video.setAttribute('playsinline', '');
|
|
553
570
|
video.muted = true;
|
|
554
571
|
video.style.cssText = 'flex:1;width:100%;height:100%;object-fit:cover';
|
|
555
|
-
|
|
572
|
+
video.srcObject = stream;
|
|
556
573
|
const close = document.createElement('button');
|
|
557
574
|
close.textContent = 'Cancel';
|
|
558
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';
|
|
@@ -581,9 +598,11 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
581
598
|
raf = requestAnimationFrame(tick);
|
|
582
599
|
};
|
|
583
600
|
raf = requestAnimationFrame(tick);
|
|
584
|
-
} catch (e:
|
|
601
|
+
} catch (e: unknown) {
|
|
585
602
|
teardown();
|
|
586
|
-
|
|
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));
|
|
587
606
|
}
|
|
588
607
|
});
|
|
589
608
|
}
|
|
@@ -591,29 +610,29 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
591
610
|
// ── speech (Web Speech API) ─────────────────────────────────────────
|
|
592
611
|
|
|
593
612
|
/** Speak via SpeechSynthesis; resolve when the utterance ends (or rejects on synth error). */
|
|
594
|
-
private speak(text: string, opts:
|
|
595
|
-
const synth =
|
|
613
|
+
private speak(text: string, opts: SpeakOptions): Promise<void> {
|
|
614
|
+
const synth = window.speechSynthesis;
|
|
596
615
|
if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
|
|
597
616
|
return new Promise((resolve, reject) => {
|
|
598
|
-
const u = new
|
|
617
|
+
const u = new window.SpeechSynthesisUtterance(text);
|
|
599
618
|
if (opts.lang) u.lang = opts.lang;
|
|
600
619
|
if (opts.rate != null) u.rate = opts.rate;
|
|
601
620
|
if (opts.pitch != null) u.pitch = opts.pitch;
|
|
602
621
|
if (opts.voice) {
|
|
603
|
-
const v = synth.getVoices().find((vc
|
|
622
|
+
const v = synth.getVoices().find((vc) => vc.voiceURI === opts.voice || vc.name === opts.voice);
|
|
604
623
|
if (v) u.voice = v;
|
|
605
624
|
}
|
|
606
625
|
u.onend = () => resolve();
|
|
607
|
-
u.onerror = (e:
|
|
626
|
+
u.onerror = (e: SpeechSynthesisErrorEvent) => reject(new KitError('NATIVE_ERROR', e?.error ?? 'speech synthesis failed'));
|
|
608
627
|
synth.speak(u);
|
|
609
628
|
});
|
|
610
629
|
}
|
|
611
630
|
|
|
612
631
|
/** List synthesizer voices, awaiting the async `voiceschanged` event when the list is empty. */
|
|
613
632
|
private listVoices(): Promise<Array<{ id: string; name: string; lang: string }>> {
|
|
614
|
-
const synth =
|
|
633
|
+
const synth = window.speechSynthesis;
|
|
615
634
|
if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
|
|
616
|
-
const map = (vs:
|
|
635
|
+
const map = (vs: SpeechSynthesisVoice[]) => vs.map((v) => ({ id: v.voiceURI ?? v.name, name: v.name, lang: v.lang }));
|
|
617
636
|
const ready = synth.getVoices();
|
|
618
637
|
if (ready.length) return Promise.resolve(map(ready));
|
|
619
638
|
// Chrome populates voices asynchronously — wait once for voiceschanged, with a short fallback.
|
|
@@ -630,8 +649,8 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
630
649
|
}
|
|
631
650
|
|
|
632
651
|
/** Capture the mic via SpeechRecognition; resolve the FINAL transcript, stream partials. */
|
|
633
|
-
private listen(opts:
|
|
634
|
-
const Rec =
|
|
652
|
+
private listen(opts: ListenOptions): Promise<string> {
|
|
653
|
+
const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
635
654
|
if (!Rec) throw new KitError('UNSUPPORTED', 'SpeechRecognition unavailable (Chrome only)');
|
|
636
655
|
return new Promise((resolve, reject) => {
|
|
637
656
|
const rec = new Rec();
|
|
@@ -648,7 +667,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
648
667
|
};
|
|
649
668
|
const stop = () => { try { rec.stop(); } catch { /* already stopped */ } };
|
|
650
669
|
this.listenStop = stop;
|
|
651
|
-
rec.onresult = (e
|
|
670
|
+
rec.onresult = (e) => {
|
|
652
671
|
let interim = '';
|
|
653
672
|
let finalText = '';
|
|
654
673
|
for (let i = 0; i < e.results.length; i++) {
|
|
@@ -659,7 +678,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
659
678
|
best = (finalText || interim).trim();
|
|
660
679
|
if (opts.partial && interim) this.emit('speech.partial', { transcript: interim.trim() });
|
|
661
680
|
};
|
|
662
|
-
rec.onerror = (e
|
|
681
|
+
rec.onerror = (e) => {
|
|
663
682
|
if (settled) return;
|
|
664
683
|
settled = true;
|
|
665
684
|
if (this.listenStop === stop) this.listenStop = null;
|
|
@@ -713,10 +732,10 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
713
732
|
// OPFS is one private root per origin — our `dir` enum has no meaning here, so paths resolve
|
|
714
733
|
// straight off the root. Slash-separated paths walk nested dirs.
|
|
715
734
|
|
|
716
|
-
private async opfsRoot(): Promise<
|
|
717
|
-
const dir =
|
|
735
|
+
private async opfsRoot(): Promise<FileSystemDirectoryHandle> {
|
|
736
|
+
const dir = navigator.storage?.getDirectory;
|
|
718
737
|
if (!dir) throw new KitError('UNSUPPORTED', 'No OPFS in this browser');
|
|
719
|
-
return
|
|
738
|
+
return navigator.storage.getDirectory();
|
|
720
739
|
}
|
|
721
740
|
|
|
722
741
|
/** Split a path into clean segments, rejecting any `..` so it can't escape the OPFS root. */
|
|
@@ -727,7 +746,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
727
746
|
}
|
|
728
747
|
|
|
729
748
|
/** Walk `path` to its parent dir handle + leaf name. `create` makes intermediate dirs. */
|
|
730
|
-
private async opfsResolveParent(path: string, create: boolean): Promise<{ parent:
|
|
749
|
+
private async opfsResolveParent(path: string, create: boolean): Promise<{ parent: FileSystemDirectoryHandle; name: string }> {
|
|
731
750
|
const parts = this.opfsSegments(path);
|
|
732
751
|
const name = parts.pop();
|
|
733
752
|
if (!name) throw new KitError('NATIVE_ERROR', 'Empty path');
|
|
@@ -764,7 +783,7 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
764
783
|
let dir = await this.opfsRoot();
|
|
765
784
|
for (const seg of this.opfsSegments(path)) dir = await dir.getDirectoryHandle(seg);
|
|
766
785
|
const out: Array<{ name: string; type: 'file' | 'dir' }> = [];
|
|
767
|
-
for await (const [name, handle] of
|
|
786
|
+
for await (const [name, handle] of dir.entries()) {
|
|
768
787
|
out.push({ name, type: handle.kind === 'directory' ? 'dir' : 'file' });
|
|
769
788
|
}
|
|
770
789
|
return out;
|
|
@@ -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<
|
|
18
|
-
const f = opts.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:
|
|
17
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/src/modules/updates.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|