@livx.cc/native-kit 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/core/NativeKit.ts +131 -0
- package/src/core/appwrap-adapter.ts +107 -0
- package/src/core/types.ts +78 -0
- package/src/core/web-adapter.ts +487 -0
- package/src/index.ts +48 -0
- package/src/modules/app.ts +20 -0
- package/src/modules/billing/billing.ts +109 -0
- package/src/modules/billing/http.ts +28 -0
- package/src/modules/billing/providers.ts +68 -0
- package/src/modules/billing/types.ts +93 -0
- package/src/modules/billing/validators.ts +66 -0
- package/src/modules/biometrics.ts +19 -0
- package/src/modules/browser.ts +22 -0
- package/src/modules/calendar.ts +23 -0
- package/src/modules/clipboard.ts +17 -0
- package/src/modules/contacts.ts +22 -0
- package/src/modules/device.ts +23 -0
- package/src/modules/geo.ts +41 -0
- package/src/modules/haptics.ts +20 -0
- package/src/modules/health.ts +70 -0
- package/src/modules/lifecycle.ts +23 -0
- package/src/modules/media.ts +75 -0
- package/src/modules/motion.ts +41 -0
- package/src/modules/network.ts +24 -0
- package/src/modules/notifications.ts +45 -0
- package/src/modules/oauth.ts +41 -0
- package/src/modules/photos.ts +39 -0
- package/src/modules/push.ts +71 -0
- package/src/modules/reviews.ts +15 -0
- package/src/modules/screen.ts +55 -0
- package/src/modules/share.ts +37 -0
- package/src/modules/storage.ts +55 -0
- package/src/modules/toast.ts +13 -0
- package/src/modules/ui.ts +135 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { Capability, Handshake, KitError, NativeKitAdapter, Unsubscribe } from './types';
|
|
2
|
+
|
|
3
|
+
/** Decode raw base64 → an ArrayBuffer for File construction (no `data:` prefix expected). */
|
|
4
|
+
function base64ToBuffer(b64: string): ArrayBuffer {
|
|
5
|
+
const bin = atob(b64);
|
|
6
|
+
const out = new Uint8Array(bin.length);
|
|
7
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
8
|
+
return out.buffer;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Map our lock vocabulary to valid ScreenOrientation `OrientationLockType` values. */
|
|
12
|
+
function toWebOrientation(o: string): any {
|
|
13
|
+
switch (o) {
|
|
14
|
+
case 'portrait-upside-down': return 'portrait-secondary';
|
|
15
|
+
case 'landscape-left': return 'landscape-primary';
|
|
16
|
+
case 'landscape-right': return 'landscape-secondary';
|
|
17
|
+
default: return o; // 'portrait' | 'landscape' | 'any' are already valid
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Browser fallback adapter. Fulfills what the Web Platform can, reports honest
|
|
23
|
+
* capability flags for the rest. Methods for unsupported capabilities resolve
|
|
24
|
+
* as no-ops — callers branch on capability flags, not try/catch.
|
|
25
|
+
*/
|
|
26
|
+
export class WebAdapter implements NativeKitAdapter {
|
|
27
|
+
readonly kind = 'web' as const;
|
|
28
|
+
private toastEl: HTMLElement | null = null;
|
|
29
|
+
private wakeLock: any = null;
|
|
30
|
+
private listeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
31
|
+
private geoWatchId: number | null = null;
|
|
32
|
+
private motionHandler: ((e: DeviceMotionEvent) => void) | null = null;
|
|
33
|
+
|
|
34
|
+
detect(): boolean {
|
|
35
|
+
return typeof window !== 'undefined';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async handshake(): Promise<Handshake> {
|
|
39
|
+
const n = navigator as any;
|
|
40
|
+
const caps: Record<string, Capability> = {
|
|
41
|
+
haptics: 'vibrate' in navigator ? 'web' : 'none',
|
|
42
|
+
share: 'share' in navigator ? 'web' : 'none',
|
|
43
|
+
shareFiles: typeof n.canShare === 'function' ? 'web' : 'none', // navigator.canShare({files}) gates at call time
|
|
44
|
+
orientation: (screen as any)?.orientation ? 'web' : 'none', // Screen Orientation API (lock needs fullscreen)
|
|
45
|
+
storage: 'web',
|
|
46
|
+
secureStorage: 'none',
|
|
47
|
+
toast: 'web',
|
|
48
|
+
statusBar: 'none',
|
|
49
|
+
device: 'web',
|
|
50
|
+
clipboard: navigator.clipboard ? 'web' : 'none',
|
|
51
|
+
notifications: 'Notification' in window ? 'web' : 'none',
|
|
52
|
+
biometrics: 'none',
|
|
53
|
+
geo: 'geolocation' in navigator ? 'web' : 'none',
|
|
54
|
+
photos: 'web',
|
|
55
|
+
network: 'web',
|
|
56
|
+
screen: 'wakeLock' in n ? 'web' : 'none',
|
|
57
|
+
dialogs: 'web',
|
|
58
|
+
reviews: 'none',
|
|
59
|
+
themeColor: 'web', // the browser honors <meta name="theme-color"> itself
|
|
60
|
+
motion: typeof DeviceMotionEvent !== 'undefined' ? 'web' : 'none',
|
|
61
|
+
contacts: n.contacts?.select ? 'web' : 'none',
|
|
62
|
+
calendar: 'none',
|
|
63
|
+
camera: 'web', // <input capture> — mobile browsers open the camera
|
|
64
|
+
media: (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
|
|
65
|
+
app: 'web', // openUrl via window.open; openSettings unsupported
|
|
66
|
+
browser: 'web', // new tab/window
|
|
67
|
+
billing: 'none', // no IAP in a plain browser — wire a web checkout yourself
|
|
68
|
+
push: 'none', // remote push (APNs/FCM) is native-only — web push (VAPID) is the app's own concern
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
protocol: 1,
|
|
72
|
+
platform: 'web',
|
|
73
|
+
app: {
|
|
74
|
+
id: location.hostname,
|
|
75
|
+
name: document.title || location.hostname,
|
|
76
|
+
version: '0.0.0',
|
|
77
|
+
},
|
|
78
|
+
capabilities: caps,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async invoke<T>(method: string, params?: unknown): Promise<T> {
|
|
83
|
+
const p = (params ?? {}) as Record<string, any>;
|
|
84
|
+
switch (method) {
|
|
85
|
+
case 'haptics.impact':
|
|
86
|
+
navigator.vibrate?.(10);
|
|
87
|
+
return undefined as T;
|
|
88
|
+
case 'haptics.notify':
|
|
89
|
+
navigator.vibrate?.(p.type === 'error' ? [40, 60, 40] : [20, 40, 20]);
|
|
90
|
+
return undefined as T;
|
|
91
|
+
|
|
92
|
+
case 'share.share':
|
|
93
|
+
if (!navigator.share) throw new KitError('UNSUPPORTED', 'Web Share API unavailable');
|
|
94
|
+
await navigator.share({ title: p.title, text: p.text, url: p.url }).catch((e: Error) => {
|
|
95
|
+
if (e.name === 'AbortError') return; // user dismissed — not an error
|
|
96
|
+
throw new KitError('NATIVE_ERROR', e.message);
|
|
97
|
+
});
|
|
98
|
+
return undefined as T;
|
|
99
|
+
|
|
100
|
+
case 'share.files': {
|
|
101
|
+
const files = ((p.files ?? []) as Array<{ name: string; mimeType: string; base64: string }>).map(
|
|
102
|
+
(f) => new File([base64ToBuffer(f.base64)], f.name, { type: f.mimeType })
|
|
103
|
+
);
|
|
104
|
+
const data: ShareData = { files, title: p.title, text: p.text };
|
|
105
|
+
if (!navigator.canShare?.(data)) throw new KitError('UNSUPPORTED', 'Sharing files is unavailable here');
|
|
106
|
+
await navigator.share(data).catch((e: Error) => {
|
|
107
|
+
if (e.name === 'AbortError') return; // user dismissed
|
|
108
|
+
throw new KitError('NATIVE_ERROR', e.message);
|
|
109
|
+
});
|
|
110
|
+
return undefined as T;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'storage.get':
|
|
114
|
+
return JSON.parse(localStorage.getItem(`kit:${p.key}`) ?? 'null') as T;
|
|
115
|
+
case 'storage.set':
|
|
116
|
+
localStorage.setItem(`kit:${p.key}`, JSON.stringify(p.value ?? null));
|
|
117
|
+
return undefined as T;
|
|
118
|
+
case 'storage.remove':
|
|
119
|
+
localStorage.removeItem(`kit:${p.key}`);
|
|
120
|
+
return undefined as T;
|
|
121
|
+
case 'storage.clear':
|
|
122
|
+
for (const key of Object.keys(localStorage)) {
|
|
123
|
+
if (key.startsWith('kit:')) localStorage.removeItem(key);
|
|
124
|
+
}
|
|
125
|
+
return undefined as T;
|
|
126
|
+
case 'storage.secure.get':
|
|
127
|
+
case 'storage.secure.set':
|
|
128
|
+
case 'storage.secure.remove':
|
|
129
|
+
throw new KitError('UNSUPPORTED', 'No secure storage on web');
|
|
130
|
+
|
|
131
|
+
case 'toast.show':
|
|
132
|
+
this.showToast(String(p.message ?? ''), p.duration === 'long' ? 3500 : 2000);
|
|
133
|
+
return undefined as T;
|
|
134
|
+
case 'ui.statusBar.setStyle':
|
|
135
|
+
return undefined as T; // no-op on web; capability reported as 'none'
|
|
136
|
+
case 'ui.safeArea':
|
|
137
|
+
return this.readSafeArea() as T;
|
|
138
|
+
case 'ui.brightness.get':
|
|
139
|
+
case 'ui.brightness.set':
|
|
140
|
+
throw new KitError('UNSUPPORTED', 'No brightness control on web');
|
|
141
|
+
case 'ui.keepAwake': {
|
|
142
|
+
const wl = (navigator as any).wakeLock;
|
|
143
|
+
if (!wl) throw new KitError('UNSUPPORTED', 'Wake Lock API unavailable');
|
|
144
|
+
if (p.on) this.wakeLock = await wl.request('screen');
|
|
145
|
+
else { await this.wakeLock?.release(); this.wakeLock = null; }
|
|
146
|
+
return undefined as T;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'ui.alert':
|
|
150
|
+
window.alert([p.title, p.message].filter(Boolean).join('\n\n'));
|
|
151
|
+
return undefined as T;
|
|
152
|
+
case 'ui.confirm':
|
|
153
|
+
return window.confirm([p.title, p.message].filter(Boolean).join('\n\n')) as T;
|
|
154
|
+
case 'ui.action':
|
|
155
|
+
return this.showActionSheet(p.title, p.options ?? [], p.cancel ?? 'Cancel') as T;
|
|
156
|
+
case 'ui.setBackgroundColor': {
|
|
157
|
+
// Mirror to <meta name="theme-color"> — the browser tints its own chrome.
|
|
158
|
+
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
|
|
159
|
+
if (!meta) {
|
|
160
|
+
meta = document.createElement('meta');
|
|
161
|
+
meta.name = 'theme-color';
|
|
162
|
+
document.head.appendChild(meta);
|
|
163
|
+
}
|
|
164
|
+
meta.content = String(p.color ?? '');
|
|
165
|
+
return undefined as T;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'screen.orientation.lock': {
|
|
169
|
+
const so = (screen as any)?.orientation;
|
|
170
|
+
if (!so?.lock) throw new KitError('UNSUPPORTED', 'Screen orientation lock unavailable');
|
|
171
|
+
// Most browsers require fullscreen before locking; surface the real reason.
|
|
172
|
+
await so.lock(toWebOrientation(String(p.orientation))).catch((e: Error) => {
|
|
173
|
+
throw new KitError('UNSUPPORTED', e.message || 'Lock rejected (fullscreen may be required)');
|
|
174
|
+
});
|
|
175
|
+
return undefined as T;
|
|
176
|
+
}
|
|
177
|
+
case 'screen.orientation.unlock':
|
|
178
|
+
(screen as any)?.orientation?.unlock?.();
|
|
179
|
+
return undefined as T;
|
|
180
|
+
case 'screen.orientation.current':
|
|
181
|
+
return (((screen as any)?.orientation?.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait') as T;
|
|
182
|
+
|
|
183
|
+
case 'reviews.requestReview':
|
|
184
|
+
throw new KitError('UNSUPPORTED', 'No in-app review prompt on web');
|
|
185
|
+
|
|
186
|
+
case 'device.info': {
|
|
187
|
+
const battery = await (navigator as any).getBattery?.().catch(() => null);
|
|
188
|
+
return {
|
|
189
|
+
model: navigator.userAgent.replace(/^Mozilla\/\d+\.\d+\s*/, '').slice(0, 64),
|
|
190
|
+
os: 'web',
|
|
191
|
+
osVersion: navigator.platform ?? '',
|
|
192
|
+
language: navigator.language,
|
|
193
|
+
battery: battery ? { level: battery.level, charging: battery.charging } : undefined,
|
|
194
|
+
} as T;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case 'clipboard.copy':
|
|
198
|
+
if (!navigator.clipboard) throw new KitError('UNSUPPORTED', 'Clipboard API unavailable');
|
|
199
|
+
await navigator.clipboard.writeText(String(p.text ?? ''));
|
|
200
|
+
return undefined as T;
|
|
201
|
+
case 'clipboard.read':
|
|
202
|
+
if (!navigator.clipboard?.readText) throw new KitError('UNSUPPORTED', 'Clipboard read unavailable');
|
|
203
|
+
return (await navigator.clipboard.readText().catch((e: Error) => {
|
|
204
|
+
throw new KitError('DENIED', e.message);
|
|
205
|
+
})) as T;
|
|
206
|
+
|
|
207
|
+
case 'notifications.requestPermission':
|
|
208
|
+
if (!('Notification' in window)) throw new KitError('UNSUPPORTED', 'Notifications unavailable');
|
|
209
|
+
return (await Notification.requestPermission()) as T;
|
|
210
|
+
case 'notifications.schedule': {
|
|
211
|
+
if (Notification.permission !== 'granted') throw new KitError('DENIED', 'Permission not granted');
|
|
212
|
+
const delay = (p.delaySec ?? 1) * 1000;
|
|
213
|
+
setTimeout(() => new Notification(p.title, { body: p.body }), delay);
|
|
214
|
+
return { id: p.id ?? Date.now() % 100000 } as T;
|
|
215
|
+
}
|
|
216
|
+
case 'notifications.pending':
|
|
217
|
+
return 0 as T; // web timers aren't introspectable
|
|
218
|
+
case 'notifications.setBadge': {
|
|
219
|
+
const setAppBadge = (navigator as any).setAppBadge?.bind(navigator);
|
|
220
|
+
if (!setAppBadge) throw new KitError('UNSUPPORTED', 'Badging API unavailable');
|
|
221
|
+
await setAppBadge(p.count);
|
|
222
|
+
return undefined as T;
|
|
223
|
+
}
|
|
224
|
+
case 'notifications.clear':
|
|
225
|
+
return undefined as T;
|
|
226
|
+
|
|
227
|
+
case 'biometrics.available':
|
|
228
|
+
return { available: false, type: 'none' } as T;
|
|
229
|
+
case 'biometrics.authenticate':
|
|
230
|
+
throw new KitError('UNSUPPORTED', 'No biometrics on web');
|
|
231
|
+
|
|
232
|
+
case 'geo.current':
|
|
233
|
+
return new Promise<T>((resolve, reject) => {
|
|
234
|
+
navigator.geolocation.getCurrentPosition(
|
|
235
|
+
(pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy } as T),
|
|
236
|
+
(err) => reject(new KitError(err.code === 1 ? 'DENIED' : 'NATIVE_ERROR', err.message)),
|
|
237
|
+
{ timeout: 10000 }
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
case 'geo.watch.start':
|
|
241
|
+
if (this.geoWatchId === null) {
|
|
242
|
+
this.geoWatchId = navigator.geolocation.watchPosition(
|
|
243
|
+
(pos) => this.emit('geo.position', {
|
|
244
|
+
lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy,
|
|
245
|
+
}),
|
|
246
|
+
(err) => console.warn('[native-kit] geo.watch error', err.message)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return undefined as T;
|
|
250
|
+
case 'geo.watch.stop':
|
|
251
|
+
if (this.geoWatchId !== null) {
|
|
252
|
+
navigator.geolocation.clearWatch(this.geoWatchId);
|
|
253
|
+
this.geoWatchId = null;
|
|
254
|
+
}
|
|
255
|
+
return undefined as T;
|
|
256
|
+
|
|
257
|
+
case 'motion.start': {
|
|
258
|
+
if (typeof DeviceMotionEvent === 'undefined') throw new KitError('UNSUPPORTED', 'No motion sensors on this browser');
|
|
259
|
+
// iOS Safari gates motion behind an explicit permission prompt
|
|
260
|
+
const req = (DeviceMotionEvent as any).requestPermission;
|
|
261
|
+
if (req) {
|
|
262
|
+
const state = await req().catch((e: Error) => { throw new KitError('DENIED', e.message); });
|
|
263
|
+
if (state !== 'granted') throw new KitError('DENIED', 'Motion permission not granted');
|
|
264
|
+
}
|
|
265
|
+
if (!this.motionHandler) {
|
|
266
|
+
let last = 0;
|
|
267
|
+
this.motionHandler = (e) => {
|
|
268
|
+
const now = performance.now();
|
|
269
|
+
if (now - last < 100) return; // ~10Hz
|
|
270
|
+
last = now;
|
|
271
|
+
const a = e.accelerationIncludingGravity;
|
|
272
|
+
const r = e.rotationRate;
|
|
273
|
+
this.emit('motion.data', {
|
|
274
|
+
ax: a?.x ?? 0, ay: a?.y ?? 0, az: a?.z ?? 0,
|
|
275
|
+
rx: r?.alpha ?? undefined, ry: r?.beta ?? undefined, rz: r?.gamma ?? undefined,
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
window.addEventListener('devicemotion', this.motionHandler);
|
|
279
|
+
}
|
|
280
|
+
return undefined as T;
|
|
281
|
+
}
|
|
282
|
+
case 'motion.stop':
|
|
283
|
+
if (this.motionHandler) {
|
|
284
|
+
window.removeEventListener('devicemotion', this.motionHandler);
|
|
285
|
+
this.motionHandler = null;
|
|
286
|
+
}
|
|
287
|
+
return undefined as T;
|
|
288
|
+
|
|
289
|
+
case 'contacts.pick': {
|
|
290
|
+
const select = (navigator as any).contacts?.select;
|
|
291
|
+
if (!select) throw new KitError('UNSUPPORTED', 'Contact Picker API unavailable');
|
|
292
|
+
const picked = await (navigator as any).contacts
|
|
293
|
+
.select(['name', 'tel', 'email'])
|
|
294
|
+
.catch((e: Error) => { throw new KitError('DENIED', e.message); });
|
|
295
|
+
const c = picked?.[0];
|
|
296
|
+
if (!c) return { picked: false } as T;
|
|
297
|
+
return { picked: true, name: c.name?.[0], phones: c.tel, emails: c.email } as T;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case 'calendar.createEvent':
|
|
301
|
+
throw new KitError('UNSUPPORTED', 'No calendar access on web');
|
|
302
|
+
|
|
303
|
+
case 'photos.pick':
|
|
304
|
+
return this.pickImageFile(false, !!p.dataUrl, p.maxSize) as Promise<T>;
|
|
305
|
+
case 'camera.capture':
|
|
306
|
+
return this.pickImageFile(true, !!p.dataUrl, p.maxSize) as Promise<T>;
|
|
307
|
+
|
|
308
|
+
case 'media.configureAudio':
|
|
309
|
+
return undefined as T; // browser owns the audio session — nothing to tune
|
|
310
|
+
|
|
311
|
+
case 'network.status': {
|
|
312
|
+
const conn = (navigator as any).connection;
|
|
313
|
+
return { online: navigator.onLine, type: conn?.type ?? (navigator.onLine ? 'unknown' : 'none') } as T;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case 'app.openUrl':
|
|
317
|
+
case 'browser.open': {
|
|
318
|
+
const win = window.open(String(p.url ?? ''), '_blank', 'noopener');
|
|
319
|
+
if (!win) throw new KitError('DENIED', 'Popup blocked');
|
|
320
|
+
return undefined as T;
|
|
321
|
+
}
|
|
322
|
+
case 'app.openSettings':
|
|
323
|
+
throw new KitError('UNSUPPORTED', 'No app settings page on web');
|
|
324
|
+
|
|
325
|
+
case 'billing.products':
|
|
326
|
+
case 'billing.purchase':
|
|
327
|
+
case 'billing.restore':
|
|
328
|
+
case 'billing.entitlements':
|
|
329
|
+
case 'billing.manageSubscriptions':
|
|
330
|
+
throw new KitError('UNSUPPORTED', 'No in-app purchases on web — use a web checkout');
|
|
331
|
+
|
|
332
|
+
case 'push.requestPermission':
|
|
333
|
+
case 'push.register':
|
|
334
|
+
case 'push.unregister':
|
|
335
|
+
throw new KitError('UNSUPPORTED', 'No native remote push on web — use Web Push (VAPID) in your app, or run inside the appwrap shell');
|
|
336
|
+
|
|
337
|
+
default:
|
|
338
|
+
throw new KitError('UNSUPPORTED', `Unknown method: ${method}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
on(event: string, cb: (payload: unknown) => void): Unsubscribe {
|
|
343
|
+
if (event === 'network.change') {
|
|
344
|
+
const fire = () => cb({ online: navigator.onLine, type: navigator.onLine ? 'unknown' : 'none' });
|
|
345
|
+
window.addEventListener('online', fire);
|
|
346
|
+
window.addEventListener('offline', fire);
|
|
347
|
+
return () => {
|
|
348
|
+
window.removeEventListener('online', fire);
|
|
349
|
+
window.removeEventListener('offline', fire);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (event === 'screen.orientation.change') {
|
|
353
|
+
const so = (screen as any)?.orientation;
|
|
354
|
+
if (!so) return () => {};
|
|
355
|
+
const fire = () => cb(String(so.type ?? '').startsWith('landscape') ? 'landscape' : 'portrait');
|
|
356
|
+
so.addEventListener('change', fire);
|
|
357
|
+
return () => so.removeEventListener('change', fire);
|
|
358
|
+
}
|
|
359
|
+
if (event === 'app.pause' || event === 'app.resume') {
|
|
360
|
+
const handler = () => {
|
|
361
|
+
const paused = document.visibilityState === 'hidden';
|
|
362
|
+
if ((event === 'app.pause') === paused) cb(undefined);
|
|
363
|
+
};
|
|
364
|
+
document.addEventListener('visibilitychange', handler);
|
|
365
|
+
return () => document.removeEventListener('visibilitychange', handler);
|
|
366
|
+
}
|
|
367
|
+
// Adapter-emitted streams (geo.position, motion.data, …)
|
|
368
|
+
let set = this.listeners.get(event);
|
|
369
|
+
if (!set) {
|
|
370
|
+
set = new Set();
|
|
371
|
+
this.listeners.set(event, set);
|
|
372
|
+
}
|
|
373
|
+
set.add(cb);
|
|
374
|
+
return () => set!.delete(cb);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private emit(event: string, payload: unknown): void {
|
|
378
|
+
this.listeners.get(event)?.forEach((cb) => cb(payload));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private pickImageFile(
|
|
382
|
+
capture: boolean,
|
|
383
|
+
wantDataUrl = false,
|
|
384
|
+
maxSize = 1024
|
|
385
|
+
): Promise<{ picked: boolean; width?: number; height?: number; dataUrl?: string }> {
|
|
386
|
+
return new Promise((resolve) => {
|
|
387
|
+
const input = document.createElement('input');
|
|
388
|
+
input.type = 'file';
|
|
389
|
+
input.accept = 'image/*';
|
|
390
|
+
if (capture) input.setAttribute('capture', 'environment');
|
|
391
|
+
input.onchange = () => {
|
|
392
|
+
const file = input.files?.[0];
|
|
393
|
+
if (!file) return resolve({ picked: false });
|
|
394
|
+
const img = new Image();
|
|
395
|
+
img.onload = () => {
|
|
396
|
+
const out: { picked: boolean; width: number; height: number; dataUrl?: string } = {
|
|
397
|
+
picked: true,
|
|
398
|
+
width: img.naturalWidth,
|
|
399
|
+
height: img.naturalHeight,
|
|
400
|
+
};
|
|
401
|
+
if (wantDataUrl) {
|
|
402
|
+
const scale = Math.min(1, maxSize / Math.max(img.naturalWidth, img.naturalHeight));
|
|
403
|
+
const cv = document.createElement('canvas');
|
|
404
|
+
cv.width = Math.round(img.naturalWidth * scale);
|
|
405
|
+
cv.height = Math.round(img.naturalHeight * scale);
|
|
406
|
+
cv.getContext('2d')?.drawImage(img, 0, 0, cv.width, cv.height);
|
|
407
|
+
out.dataUrl = cv.toDataURL('image/jpeg', 0.85);
|
|
408
|
+
}
|
|
409
|
+
URL.revokeObjectURL(img.src);
|
|
410
|
+
resolve(out);
|
|
411
|
+
};
|
|
412
|
+
img.onerror = () => resolve({ picked: true });
|
|
413
|
+
img.src = URL.createObjectURL(file);
|
|
414
|
+
};
|
|
415
|
+
input.oncancel = () => resolve({ picked: false });
|
|
416
|
+
input.click();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Minimal action sheet — fixed bottom panel, resolves option index or null on cancel. */
|
|
421
|
+
private showActionSheet(title: string | undefined, options: string[], cancel: string): Promise<number | null> {
|
|
422
|
+
return new Promise((resolve) => {
|
|
423
|
+
const wrap = document.createElement('div');
|
|
424
|
+
wrap.style.cssText =
|
|
425
|
+
'position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:99999;display:flex;align-items:flex-end';
|
|
426
|
+
const sheet = document.createElement('div');
|
|
427
|
+
sheet.style.cssText =
|
|
428
|
+
'width:100%;background:#1c1c22;color:#fff;border-radius:16px 16px 0 0;' +
|
|
429
|
+
'padding:12px;font:15px system-ui;display:flex;flex-direction:column;gap:6px';
|
|
430
|
+
const done = (result: number | null) => { wrap.remove(); resolve(result); };
|
|
431
|
+
if (title) {
|
|
432
|
+
const h = document.createElement('div');
|
|
433
|
+
h.textContent = title;
|
|
434
|
+
h.style.cssText = 'text-align:center;opacity:.6;font-size:13px;padding:4px';
|
|
435
|
+
sheet.appendChild(h);
|
|
436
|
+
}
|
|
437
|
+
const mkBtn = (label: string, onClick: () => void) => {
|
|
438
|
+
const b = document.createElement('button');
|
|
439
|
+
b.textContent = label;
|
|
440
|
+
b.style.cssText =
|
|
441
|
+
'background:#2a2a32;color:inherit;border:0;border-radius:10px;padding:12px;font:inherit;cursor:pointer';
|
|
442
|
+
b.onclick = onClick;
|
|
443
|
+
sheet.appendChild(b);
|
|
444
|
+
};
|
|
445
|
+
options.forEach((label, i) => mkBtn(label, () => done(i)));
|
|
446
|
+
mkBtn(cancel, () => done(null));
|
|
447
|
+
wrap.onclick = (e) => { if (e.target === wrap) done(null); };
|
|
448
|
+
wrap.appendChild(sheet);
|
|
449
|
+
document.body.appendChild(wrap);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Measure env(safe-area-inset-*) via a probe element. */
|
|
454
|
+
private readSafeArea() {
|
|
455
|
+
const el = document.createElement('div');
|
|
456
|
+
el.style.cssText =
|
|
457
|
+
'position:fixed;visibility:hidden;pointer-events:none;' +
|
|
458
|
+
'padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)';
|
|
459
|
+
document.body.appendChild(el);
|
|
460
|
+
const cs = getComputedStyle(el);
|
|
461
|
+
const insets = {
|
|
462
|
+
top: parseFloat(cs.paddingTop) || 0,
|
|
463
|
+
right: parseFloat(cs.paddingRight) || 0,
|
|
464
|
+
bottom: parseFloat(cs.paddingBottom) || 0,
|
|
465
|
+
left: parseFloat(cs.paddingLeft) || 0,
|
|
466
|
+
};
|
|
467
|
+
el.remove();
|
|
468
|
+
return insets;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private showToast(message: string, ms: number): void {
|
|
472
|
+
this.toastEl?.remove();
|
|
473
|
+
const el = document.createElement('div');
|
|
474
|
+
el.textContent = message;
|
|
475
|
+
el.style.cssText =
|
|
476
|
+
'position:fixed;bottom:48px;left:50%;transform:translateX(-50%);' +
|
|
477
|
+
'background:rgba(20,20,24,.92);color:#fff;padding:10px 18px;border-radius:20px;' +
|
|
478
|
+
'font:14px/1.3 system-ui;z-index:99999;max-width:80vw;text-align:center;' +
|
|
479
|
+
'box-shadow:0 4px 16px rgba(0,0,0,.25)';
|
|
480
|
+
document.body.appendChild(el);
|
|
481
|
+
this.toastEl = el;
|
|
482
|
+
setTimeout(() => {
|
|
483
|
+
el.remove();
|
|
484
|
+
if (this.toastEl === el) this.toastEl = null;
|
|
485
|
+
}, ms);
|
|
486
|
+
}
|
|
487
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export { NativeKit, NativeKitOptions, kit } from './core/NativeKit';
|
|
2
|
+
export { AppwrapAdapter } from './core/appwrap-adapter';
|
|
3
|
+
export { WebAdapter } from './core/web-adapter';
|
|
4
|
+
export { KitError } from './core/types';
|
|
5
|
+
export type {
|
|
6
|
+
AdapterKind,
|
|
7
|
+
AppInfo,
|
|
8
|
+
Capability,
|
|
9
|
+
Envelope,
|
|
10
|
+
EventEnvelope,
|
|
11
|
+
Handshake,
|
|
12
|
+
InvokeOptions,
|
|
13
|
+
KitErrorCode,
|
|
14
|
+
NativeKitAdapter,
|
|
15
|
+
Platform,
|
|
16
|
+
RequestEnvelope,
|
|
17
|
+
ResponseEnvelope,
|
|
18
|
+
Unsubscribe,
|
|
19
|
+
} from './core/types';
|
|
20
|
+
export type { ImpactStyle, NotifyType } from './modules/haptics';
|
|
21
|
+
export type { SharePayload, ShareFile } from './modules/share';
|
|
22
|
+
export type { Orientation, OrientationLock } from './modules/screen';
|
|
23
|
+
export type { DeviceInfo } from './modules/device';
|
|
24
|
+
export type { ScheduleOptions } from './modules/notifications';
|
|
25
|
+
export type { PushMessage, PushPlatform, PushToken } from './modules/push';
|
|
26
|
+
export type { GeoPosition } from './modules/geo';
|
|
27
|
+
export type { PickedPhoto, PickPhotoOptions } from './modules/photos';
|
|
28
|
+
export type { AudioMode, MediaDeviceLite } from './modules/media';
|
|
29
|
+
export type { NetworkStatus } from './modules/network';
|
|
30
|
+
export type { ActionOptions, AlertOptions, ConfirmOptions, SafeAreaInsets } from './modules/ui';
|
|
31
|
+
export type { MotionSample } from './modules/motion';
|
|
32
|
+
export type { PickedContact } from './modules/contacts';
|
|
33
|
+
export type { CalendarEventOptions } from './modules/calendar';
|
|
34
|
+
export type { BrowserOptions } from './modules/browser';
|
|
35
|
+
export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
|
|
36
|
+
export { ClientTrustedValidator, HttpValidator, HttpValidatorOptions } from './modules/billing/validators';
|
|
37
|
+
export { HttpBillingProvider, HttpBillingProviderOptions } from './modules/billing/providers';
|
|
38
|
+
export type { HeaderProvider } from './modules/billing/http';
|
|
39
|
+
export { HealthModule } from './modules/health';
|
|
40
|
+
export type {
|
|
41
|
+
BillingProvider,
|
|
42
|
+
BillingValidator,
|
|
43
|
+
Entitlement,
|
|
44
|
+
Product,
|
|
45
|
+
ProductType,
|
|
46
|
+
PurchaseReceipt,
|
|
47
|
+
PurchaseResult,
|
|
48
|
+
} from './modules/billing/types';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
/** System-level app actions: hand a URL to the OS, or open this app's settings page. */
|
|
4
|
+
export class AppModule {
|
|
5
|
+
constructor(private kit: NativeKit) {}
|
|
6
|
+
|
|
7
|
+
get capability() {
|
|
8
|
+
return this.kit.capability('app');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Open a URL in the OS default handler (external browser, mail:, tel:, maps:, …). */
|
|
12
|
+
openUrl(url: string): Promise<void> {
|
|
13
|
+
return this.kit.invoke('app.openUrl', { url });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Open this app's page in the OS Settings app (to toggle permissions, etc.). */
|
|
17
|
+
openSettings(): Promise<void> {
|
|
18
|
+
return this.kit.invoke('app.openSettings');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { NativeKit } from '../../core/NativeKit';
|
|
2
|
+
import { KitError, type Capability, type Unsubscribe } from '../../core/types';
|
|
3
|
+
import { ClientTrustedValidator } from './validators';
|
|
4
|
+
import type { BillingProvider, BillingValidator, Entitlement, Product, PurchaseReceipt, PurchaseResult } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-app purchases & subscriptions — ONE API across web and native.
|
|
8
|
+
*
|
|
9
|
+
* - On a native shell the kit drives the device store (iOS StoreKit 1, Android Play
|
|
10
|
+
* Billing) and a {@link BillingValidator} confirms the receipt (server-of-record).
|
|
11
|
+
* - On the web there is no native store, so the app plugs in a {@link BillingProvider}
|
|
12
|
+
* (Stripe / Paddle / custom backend) and the SAME `kit.billing.*` calls dispatch to it.
|
|
13
|
+
*
|
|
14
|
+
* Wire both once via {@link configure}; the module picks the right path per platform.
|
|
15
|
+
*/
|
|
16
|
+
export class BillingModule {
|
|
17
|
+
private validator: BillingValidator;
|
|
18
|
+
private webProvider?: BillingProvider;
|
|
19
|
+
|
|
20
|
+
constructor(private kit: NativeKit) {
|
|
21
|
+
this.validator = new ClientTrustedValidator(() => this.nativeEntitlements());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 'native' on a store-capable shell · 'web' when a web provider is wired · else 'none'. */
|
|
25
|
+
get capability(): Capability {
|
|
26
|
+
if (this.kit.is.native) return this.kit.capability('billing'); // native store (or 'none' if parked)
|
|
27
|
+
return this.webProvider ? 'web' : 'none'; // never silently fall back to web inside a native shell
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param opts.validator server-of-record for entitlements (native receipt check; shared)
|
|
32
|
+
* @param opts.webProvider purchase mechanism on the web (Stripe/Paddle/custom)
|
|
33
|
+
*/
|
|
34
|
+
configure(opts: { validator?: BillingValidator; webProvider?: BillingProvider }): void {
|
|
35
|
+
if (opts.validator) this.validator = opts.validator;
|
|
36
|
+
if (opts.webProvider) this.webProvider = opts.webProvider;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** On the web the purchase mechanism is the provider; require one with an actionable error. */
|
|
40
|
+
private webProviderOrThrow(): BillingProvider {
|
|
41
|
+
if (!this.webProvider) {
|
|
42
|
+
throw new KitError(
|
|
43
|
+
'UNSUPPORTED',
|
|
44
|
+
'No web billing provider configured. On the web, wire one: ' +
|
|
45
|
+
"kit.billing.configure({ webProvider: new HttpBillingProvider({ baseUrl: '/api/billing' }) })"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return this.webProvider;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Localized product catalog — from the store (native) or the provider (web). */
|
|
52
|
+
async products(ids: string[]): Promise<Product[]> {
|
|
53
|
+
await this.kit.ready(); // routing depends on the resolved adapter — never decide pre-handshake
|
|
54
|
+
if (this.kit.is.web) return this.webProviderOrThrow().products(ids);
|
|
55
|
+
return this.kit.invoke('billing.products', { ids });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Buy a product. Native store flow + receipt validation, or web checkout via the provider. */
|
|
59
|
+
async purchase(productId: string): Promise<PurchaseResult> {
|
|
60
|
+
await this.kit.ready();
|
|
61
|
+
if (this.kit.is.web) {
|
|
62
|
+
const entitlements = await this.webProviderOrThrow().purchase(productId);
|
|
63
|
+
return { receipt: { platform: 'web', productId, raw: null }, entitlements };
|
|
64
|
+
}
|
|
65
|
+
const receipt = await this.kit.invoke<PurchaseReceipt>('billing.purchase', { productId }, { timeoutMs: 120_000 });
|
|
66
|
+
return { receipt, entitlements: await this.validator.validate(receipt) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Restore/re-sync prior purchases. */
|
|
70
|
+
async restore(): Promise<Entitlement[]> {
|
|
71
|
+
await this.kit.ready();
|
|
72
|
+
if (this.kit.is.web) {
|
|
73
|
+
const p = this.webProviderOrThrow();
|
|
74
|
+
return (p.restore ?? p.entitlements).call(p); // web has no store "restore" → re-read entitlements
|
|
75
|
+
}
|
|
76
|
+
const receipts = await this.kit.invoke<PurchaseReceipt[]>('billing.restore', undefined, { timeoutMs: 60_000 });
|
|
77
|
+
const out: Entitlement[] = [];
|
|
78
|
+
for (const r of receipts) out.push(...(await this.validator.validate(r)));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Current entitlements from the configured server-of-record (web provider or validator). */
|
|
83
|
+
async entitlements(): Promise<Entitlement[]> {
|
|
84
|
+
await this.kit.ready();
|
|
85
|
+
if (this.kit.is.web) return this.webProviderOrThrow().entitlements();
|
|
86
|
+
return this.validator.entitlements();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Subscription management — OS surface (App Store / Play) or the provider's portal (Stripe). */
|
|
90
|
+
async manageSubscriptions(): Promise<void> {
|
|
91
|
+
await this.kit.ready();
|
|
92
|
+
if (this.kit.is.web) {
|
|
93
|
+
const p = this.webProviderOrThrow();
|
|
94
|
+
if (!p.manageSubscriptions) throw new KitError('UNSUPPORTED', 'This web billing provider has no subscription-management portal');
|
|
95
|
+
return p.manageSubscriptions();
|
|
96
|
+
}
|
|
97
|
+
return this.kit.invoke('billing.manageSubscriptions');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Out-of-band transactions (renewals, Ask-to-Buy, cross-device). Native streams only. */
|
|
101
|
+
onTransaction(cb: (receipt: PurchaseReceipt) => void): Unsubscribe {
|
|
102
|
+
return this.kit.on('billing.transaction', (p) => cb(p as PurchaseReceipt));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Raw device entitlements — StoreKit `currentEntitlements` / Play `queryPurchases`. */
|
|
106
|
+
private nativeEntitlements(): Promise<Entitlement[]> {
|
|
107
|
+
return this.kit.invoke('billing.entitlements');
|
|
108
|
+
}
|
|
109
|
+
}
|