@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 +1 -1
- package/src/core/NativeKit.ts +5 -0
- package/src/core/types.ts +5 -0
- package/src/core/web-adapter.ts +86 -49
- package/src/index.ts +3 -1
- package/src/modules/app.ts +50 -0
- package/src/modules/backgroundTask.ts +122 -0
- package/src/modules/billing/http.ts +2 -2
- package/src/modules/billing/providers.ts +9 -5
- package/src/modules/billing/validators.ts +2 -1
- package/src/modules/push.ts +0 -7
- package/src/modules/screen.ts +18 -2
- package/src/modules/updates.ts +1 -1
- package/src/web-experimental.d.ts +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livx.cc/native-kit",
|
|
3
|
-
"version": "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",
|
package/src/core/NativeKit.ts
CHANGED
|
@@ -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
|
}
|
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,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:
|
|
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
|
-
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 =
|
|
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 =
|
|
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
|
-
|
|
226
|
+
screen?.orientation?.unlock?.();
|
|
205
227
|
return undefined as T;
|
|
206
228
|
case 'screen.orientation.current':
|
|
207
|
-
return ((
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
343
|
+
const select = navigator.contacts?.select;
|
|
322
344
|
if (!select) throw new KitError('UNSUPPORTED', 'Contact Picker API unavailable');
|
|
323
|
-
const picked = await
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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?:
|
|
507
|
-
const Detector =
|
|
508
|
-
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);
|
|
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
|
-
|
|
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:
|
|
601
|
+
} catch (e: unknown) {
|
|
567
602
|
teardown();
|
|
568
|
-
|
|
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:
|
|
577
|
-
const synth =
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
633
|
+
const synth = window.speechSynthesis;
|
|
597
634
|
if (!synth) throw new KitError('UNSUPPORTED', 'SpeechSynthesis unavailable');
|
|
598
|
-
const map = (vs:
|
|
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:
|
|
616
|
-
const Rec =
|
|
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
|
|
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
|
|
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<
|
|
699
|
-
const dir =
|
|
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
|
|
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:
|
|
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
|
|
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,
|
package/src/modules/app.ts
CHANGED
|
@@ -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<
|
|
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/push.ts
CHANGED
|
@@ -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));
|
package/src/modules/screen.ts
CHANGED
|
@@ -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
|
/**
|
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
|
+
}
|