@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,28 @@
|
|
|
1
|
+
/** Shared HTTP helper for the backend-driven billing strategies (validator + web provider). */
|
|
2
|
+
|
|
3
|
+
export type HeaderProvider =
|
|
4
|
+
| Record<string, string>
|
|
5
|
+
| (() => Record<string, string> | Promise<Record<string, string>>);
|
|
6
|
+
|
|
7
|
+
export interface HttpJsonOptions {
|
|
8
|
+
url: string;
|
|
9
|
+
method: 'GET' | 'POST';
|
|
10
|
+
body?: unknown;
|
|
11
|
+
headers?: HeaderProvider;
|
|
12
|
+
/** Injected for tests / non-DOM runtimes. Defaults to global `fetch`. */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** POST/GET JSON, throwing on non-2xx. Keeps validators + providers DRY. */
|
|
17
|
+
export async function httpJson(opts: HttpJsonOptions): Promise<any> {
|
|
18
|
+
const f = opts.fetch ?? (globalThis as any).fetch;
|
|
19
|
+
if (!f) throw new Error('billing: no fetch available — pass one via options.fetch');
|
|
20
|
+
const h = typeof opts.headers === 'function' ? await opts.headers() : opts.headers;
|
|
21
|
+
const res = await f(opts.url, {
|
|
22
|
+
method: opts.method,
|
|
23
|
+
headers: { 'content-type': 'application/json', ...(h ?? {}) },
|
|
24
|
+
body: opts.method === 'POST' ? JSON.stringify(opts.body ?? {}) : undefined,
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) throw new Error(`billing: ${opts.method} ${opts.url} → ${res.status}`);
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { httpJson, type HeaderProvider } from './http';
|
|
2
|
+
import type { BillingProvider, Entitlement, Product } from './types';
|
|
3
|
+
|
|
4
|
+
export class HttpBillingProviderOptions {
|
|
5
|
+
/** Backend base URL exposing /products, /checkout, /entitlements, /portal. */
|
|
6
|
+
baseUrl = '';
|
|
7
|
+
/** Static headers or a thunk (e.g. a fresh session/bearer token). */
|
|
8
|
+
headers?: HeaderProvider;
|
|
9
|
+
/** Injected for tests / non-DOM runtimes. Defaults to global `fetch`. */
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
/** How to send the user to a hosted checkout / portal URL. Defaults to a same-tab
|
|
12
|
+
* navigation — override to open a tab, an in-app browser, etc. */
|
|
13
|
+
redirect: (url: string) => void = (url) => {
|
|
14
|
+
if (typeof window !== 'undefined') window.location.assign(url);
|
|
15
|
+
};
|
|
16
|
+
mapProducts: (json: any) => Product[] = (j) => (j?.products ?? j ?? []) as Product[];
|
|
17
|
+
mapEntitlements: (json: any) => Entitlement[] = (j) => (j?.entitlements ?? j ?? []) as Entitlement[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Web checkout via your backend — the symmetric counterpart to the native store.
|
|
22
|
+
* `purchase()` POSTs to `/checkout`; if the backend returns a hosted-checkout `url`
|
|
23
|
+
* (Stripe Checkout Session, Paddle, LemonSqueezy…), it redirects and entitlements
|
|
24
|
+
* surface on the redirect back; if the backend returns `entitlements` inline, it
|
|
25
|
+
* resolves immediately. One config covers any "backend mints a checkout URL" provider.
|
|
26
|
+
* HTTP over SDK by design — no Stripe.js / vendor packages in the kit.
|
|
27
|
+
*/
|
|
28
|
+
export class HttpBillingProvider implements BillingProvider {
|
|
29
|
+
public options: HttpBillingProviderOptions;
|
|
30
|
+
constructor(options?: Partial<HttpBillingProviderOptions>) {
|
|
31
|
+
this.options = { ...new HttpBillingProviderOptions(), ...options };
|
|
32
|
+
if (!this.options.baseUrl) throw new Error('HttpBillingProvider: baseUrl is required');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async products(ids: string[]): Promise<Product[]> {
|
|
36
|
+
const url = `${this.base()}/products?ids=${encodeURIComponent(ids.join(','))}`;
|
|
37
|
+
return this.options.mapProducts(await httpJson({ ...this.req(), url, method: 'GET' }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async purchase(productId: string): Promise<Entitlement[]> {
|
|
41
|
+
const json = await httpJson({ ...this.req(), url: `${this.base()}/checkout`, method: 'POST', body: { productId } });
|
|
42
|
+
if (json?.url) {
|
|
43
|
+
this.options.redirect(String(json.url)); // hosted checkout — page navigates away
|
|
44
|
+
return []; // entitlements arrive via entitlements() after redirect-back
|
|
45
|
+
}
|
|
46
|
+
return this.options.mapEntitlements(json); // backend completed inline
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async entitlements(): Promise<Entitlement[]> {
|
|
50
|
+
return this.options.mapEntitlements(await httpJson({ ...this.req(), url: `${this.base()}/entitlements`, method: 'GET' }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
restore(): Promise<Entitlement[]> {
|
|
54
|
+
return this.entitlements(); // web has no store "restore" — re-read the server-of-record
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async manageSubscriptions(): Promise<void> {
|
|
58
|
+
const json = await httpJson({ ...this.req(), url: `${this.base()}/portal`, method: 'POST', body: {} });
|
|
59
|
+
if (json?.url) this.options.redirect(String(json.url)); // Stripe Billing Portal, etc.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private base() {
|
|
63
|
+
return this.options.baseUrl.replace(/\/$/, '');
|
|
64
|
+
}
|
|
65
|
+
private req() {
|
|
66
|
+
return { headers: this.options.headers, fetch: this.options.fetch };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Billing / IAP contract — platform-neutral shapes shared by the module,
|
|
2
|
+
* the native handlers, and any pluggable validator (Bodify / RevenueCat / IAPHUB / custom). */
|
|
3
|
+
|
|
4
|
+
export type ProductType =
|
|
5
|
+
| 'consumable'
|
|
6
|
+
| 'nonConsumable'
|
|
7
|
+
| 'autoRenewable'
|
|
8
|
+
| 'nonRenewable'
|
|
9
|
+
| 'unknown';
|
|
10
|
+
|
|
11
|
+
/** A purchasable product as the store describes it (localized). */
|
|
12
|
+
export interface Product {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
description: string;
|
|
16
|
+
/** Numeric price in `currency`, for math/sorting. */
|
|
17
|
+
price: number;
|
|
18
|
+
/** Localized formatted price, e.g. "$4.99" — show this to users. */
|
|
19
|
+
displayPrice: string;
|
|
20
|
+
currency: string;
|
|
21
|
+
type: ProductType;
|
|
22
|
+
/** ISO-8601 duration for auto-renewables, e.g. "P1M", "P1Y". */
|
|
23
|
+
subscriptionPeriod?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A normalized entitlement — "this user owns/subscribes to X". */
|
|
27
|
+
export interface Entitlement {
|
|
28
|
+
productId: string;
|
|
29
|
+
active: boolean;
|
|
30
|
+
/** Epoch ms; subscriptions only. */
|
|
31
|
+
expiresAt?: number;
|
|
32
|
+
/** Whether an auto-renewable will renew at period end. */
|
|
33
|
+
willRenew?: boolean;
|
|
34
|
+
/** Epoch ms of the original purchase. */
|
|
35
|
+
purchasedAt?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The raw proof of a purchase, handed to the validator. Platform-specific fields
|
|
39
|
+
* are optional so one shape serves both stores AND a web checkout. */
|
|
40
|
+
export interface PurchaseReceipt {
|
|
41
|
+
platform: 'ios' | 'android' | 'web';
|
|
42
|
+
productId: string;
|
|
43
|
+
transactionId?: string;
|
|
44
|
+
/** StoreKit 2 signed transaction (JWS) — verify against Apple's root certs. */
|
|
45
|
+
jws?: string;
|
|
46
|
+
/** Base64 StoreKit 1 app receipt — verify via App Store Server API / verifyReceipt. */
|
|
47
|
+
appReceipt?: string;
|
|
48
|
+
/** Google Play Billing purchase token — verify via Play Developer API. */
|
|
49
|
+
purchaseToken?: string;
|
|
50
|
+
/** Web checkout reference (e.g. Stripe Checkout Session / subscription id). */
|
|
51
|
+
providerRef?: string;
|
|
52
|
+
/** The untouched native/provider payload, for validators that want everything. */
|
|
53
|
+
raw: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PurchaseResult {
|
|
57
|
+
receipt: PurchaseReceipt;
|
|
58
|
+
entitlements: Entitlement[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The swappable seam. The kit owns the on-device purchase flow; *who decides the
|
|
63
|
+
* user is actually entitled* is up to the app. Implement this (or configure the
|
|
64
|
+
* bundled `HttpValidator`) to point at Bodify, RevenueCat, IAPHUB, or your own backend.
|
|
65
|
+
*/
|
|
66
|
+
export interface BillingValidator {
|
|
67
|
+
/** Validate a fresh purchase/restore receipt → the entitlements it grants. */
|
|
68
|
+
validate(receipt: PurchaseReceipt): Promise<Entitlement[]>;
|
|
69
|
+
/** Current entitlements from the server-of-record (or the device, for client-trusted). */
|
|
70
|
+
entitlements(): Promise<Entitlement[]>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The *purchase mechanism* on web — the symmetric counterpart to the native store.
|
|
75
|
+
* On mobile the kit drives StoreKit/Play directly; on web there is no native store,
|
|
76
|
+
* so the app plugs in a provider (Stripe / Paddle / LemonSqueezy / custom backend).
|
|
77
|
+
* The same `kit.billing.*` calls dispatch here when running on the web. Implement this,
|
|
78
|
+
* or configure the bundled `HttpBillingProvider` (backend-driven hosted checkout).
|
|
79
|
+
*/
|
|
80
|
+
export interface BillingProvider {
|
|
81
|
+
/** Catalog with localized prices (typically from your backend / Stripe prices). */
|
|
82
|
+
products(ids: string[]): Promise<Product[]>;
|
|
83
|
+
/** Start checkout for a product. Resolves to the granted entitlements when known
|
|
84
|
+
* synchronously; for redirect-based checkout (Stripe hosted page) it navigates away
|
|
85
|
+
* and the entitlements surface via `entitlements()` after the redirect back. */
|
|
86
|
+
purchase(productId: string): Promise<Entitlement[]>;
|
|
87
|
+
/** Current entitlements from the server-of-record (Stripe webhooks → your backend). */
|
|
88
|
+
entitlements(): Promise<Entitlement[]>;
|
|
89
|
+
/** Re-sync entitlements (web has no "restore" — defaults to `entitlements()`). */
|
|
90
|
+
restore?(): Promise<Entitlement[]>;
|
|
91
|
+
/** Open the billing/subscription-management surface (e.g. Stripe Billing Portal). */
|
|
92
|
+
manageSubscriptions?(): Promise<void>;
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { httpJson, type HeaderProvider } from './http';
|
|
2
|
+
import type { BillingValidator, Entitlement, PurchaseReceipt } from './types';
|
|
3
|
+
|
|
4
|
+
export type { HeaderProvider } from './http';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Trusts the device. `validate` marks the purchased product active; `entitlements`
|
|
8
|
+
* reads whatever the native layer reports (StoreKit `currentEntitlements` /
|
|
9
|
+
* Play `queryPurchases`). Zero backend — spoofable on a compromised device, so
|
|
10
|
+
* fine for demos/dev or low-stakes unlocks, NOT for real revenue. The default.
|
|
11
|
+
*/
|
|
12
|
+
export class ClientTrustedValidator implements BillingValidator {
|
|
13
|
+
constructor(private readNative: () => Promise<Entitlement[]>) {}
|
|
14
|
+
|
|
15
|
+
async validate(receipt: PurchaseReceipt): Promise<Entitlement[]> {
|
|
16
|
+
// The native purchase already succeeded — grant that product directly. Do NOT
|
|
17
|
+
// read native entitlements here: on StoreKit 1 that means a restore, which pops
|
|
18
|
+
// an Apple ID prompt right after a buy. Call entitlements() explicitly for that.
|
|
19
|
+
return [{ productId: receipt.productId, active: true, purchasedAt: Date.now() }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
entitlements(): Promise<Entitlement[]> {
|
|
23
|
+
return this.readNative();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class HttpValidatorOptions {
|
|
28
|
+
/** POST endpoint that receives a `PurchaseReceipt` and returns entitlements. */
|
|
29
|
+
validateUrl = '';
|
|
30
|
+
/** GET/POST endpoint for the current entitlements. Defaults to `validateUrl`. */
|
|
31
|
+
entitlementsUrl?: string;
|
|
32
|
+
/** Static headers or a thunk (e.g. to inject a fresh bearer token). */
|
|
33
|
+
headers?: HeaderProvider;
|
|
34
|
+
/** Map the provider's response JSON → `Entitlement[]`. Default expects `{ entitlements: [...] }`. */
|
|
35
|
+
mapResponse: (json: any) => Entitlement[] = (j) => (j?.entitlements ?? []) as Entitlement[];
|
|
36
|
+
/** Injected for tests / non-DOM runtimes. Defaults to global `fetch`. */
|
|
37
|
+
fetch?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generic server-validated strategy. One implementation covers Bodify, RevenueCat,
|
|
42
|
+
* IAPHUB, or any custom backend — they differ only by URL, auth header, and a small
|
|
43
|
+
* response mapper. HTTP over SDK by design (no vendor packages).
|
|
44
|
+
*/
|
|
45
|
+
export class HttpValidator implements BillingValidator {
|
|
46
|
+
public options: HttpValidatorOptions;
|
|
47
|
+
constructor(options?: Partial<HttpValidatorOptions>) {
|
|
48
|
+
this.options = { ...new HttpValidatorOptions(), ...options };
|
|
49
|
+
if (!this.options.validateUrl) throw new Error('HttpValidator: validateUrl is required');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async validate(receipt: PurchaseReceipt): Promise<Entitlement[]> {
|
|
53
|
+
const json = await httpJson({ ...this.req(), url: this.options.validateUrl, method: 'POST', body: receipt });
|
|
54
|
+
return this.options.mapResponse(json);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async entitlements(): Promise<Entitlement[]> {
|
|
58
|
+
const url = this.options.entitlementsUrl ?? this.options.validateUrl;
|
|
59
|
+
const method = this.options.entitlementsUrl ? 'GET' : 'POST';
|
|
60
|
+
return this.options.mapResponse(await httpJson({ ...this.req(), url, method }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private req() {
|
|
64
|
+
return { headers: this.options.headers, fetch: this.options.fetch };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export class BiometricsModule {
|
|
4
|
+
constructor(private kit: NativeKit) {}
|
|
5
|
+
|
|
6
|
+
get capability() {
|
|
7
|
+
return this.kit.capability('biometrics');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** { available, type: 'face' | 'touch' | 'none' } */
|
|
11
|
+
available(): Promise<{ available: boolean; type: string }> {
|
|
12
|
+
return this.kit.invoke('biometrics.available');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Resolves { success } — rejects with DENIED on user cancel/failure. */
|
|
16
|
+
authenticate(reason: string): Promise<{ success: boolean }> {
|
|
17
|
+
return this.kit.invoke('biometrics.authenticate', { reason }, { timeoutMs: 60_000 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export interface BrowserOptions {
|
|
4
|
+
/** Tint for the in-app browser chrome (hex, e.g. "#4b0082").
|
|
5
|
+
* iOS → SFSafariViewController.preferredControlTintColor; Android → toolbar color. */
|
|
6
|
+
toolbarColor?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** In-app browser: iOS SFSafariViewController, Android Chrome Custom Tabs, web new tab.
|
|
10
|
+
* Keeps the user inside the app (vs `app.openUrl`, which leaves for the OS handler). */
|
|
11
|
+
export class BrowserModule {
|
|
12
|
+
constructor(private kit: NativeKit) {}
|
|
13
|
+
|
|
14
|
+
get capability() {
|
|
15
|
+
return this.kit.capability('browser');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Present an in-app browser for the URL. Resolves once it has been presented. */
|
|
19
|
+
open(url: string, opts: BrowserOptions = {}): Promise<void> {
|
|
20
|
+
return this.kit.invoke('browser.open', { url, ...opts });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export interface CalendarEventOptions {
|
|
4
|
+
title: string;
|
|
5
|
+
/** ISO datetime; defaults to now + 1h. */
|
|
6
|
+
start?: string;
|
|
7
|
+
durationMin?: number;
|
|
8
|
+
notes?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Calendar write — native: EventKit (requires `calendar` permission in appwrap.json). Web: 'none'. */
|
|
12
|
+
export class CalendarModule {
|
|
13
|
+
constructor(private kit: NativeKit) {}
|
|
14
|
+
|
|
15
|
+
get capability() {
|
|
16
|
+
return this.kit.capability('calendar');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create an event in the default calendar; resolves its identifier. */
|
|
20
|
+
createEvent(opts: CalendarEventOptions): Promise<{ id: string }> {
|
|
21
|
+
return this.kit.invoke('calendar.createEvent', opts, { timeoutMs: 120_000 });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export class ClipboardModule {
|
|
4
|
+
constructor(private kit: NativeKit) {}
|
|
5
|
+
|
|
6
|
+
get capability() {
|
|
7
|
+
return this.kit.capability('clipboard');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
copy(text: string): Promise<void> {
|
|
11
|
+
return this.kit.invoke('clipboard.copy', { text });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
read(): Promise<string | null> {
|
|
15
|
+
return this.kit.invoke('clipboard.read');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export interface PickedContact {
|
|
4
|
+
picked: boolean;
|
|
5
|
+
name?: string;
|
|
6
|
+
phones?: string[];
|
|
7
|
+
emails?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Contact picker — native: CNContactPicker (no permission needed); web: Contact Picker API where present. */
|
|
11
|
+
export class ContactsModule {
|
|
12
|
+
constructor(private kit: NativeKit) {}
|
|
13
|
+
|
|
14
|
+
get capability() {
|
|
15
|
+
return this.kit.capability('contacts');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Open the system contact picker; resolves { picked: false } when dismissed. */
|
|
19
|
+
pick(): Promise<PickedContact> {
|
|
20
|
+
return this.kit.invoke('contacts.pick', undefined, { timeoutMs: 120_000 });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export interface DeviceInfo {
|
|
4
|
+
model: string;
|
|
5
|
+
os: string;
|
|
6
|
+
osVersion: string;
|
|
7
|
+
language: string;
|
|
8
|
+
region?: string;
|
|
9
|
+
manufacturer?: string;
|
|
10
|
+
battery?: { level: number; charging: boolean };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class DeviceModule {
|
|
14
|
+
constructor(private kit: NativeKit) {}
|
|
15
|
+
|
|
16
|
+
get capability() {
|
|
17
|
+
return this.kit.capability('device');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
info(): Promise<DeviceInfo> {
|
|
21
|
+
return this.kit.invoke('device.info');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
import type { Unsubscribe } from '../core/types';
|
|
3
|
+
|
|
4
|
+
export interface GeoPosition {
|
|
5
|
+
lat: number;
|
|
6
|
+
lng: number;
|
|
7
|
+
accuracy?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class GeoModule {
|
|
11
|
+
constructor(private kit: NativeKit) {}
|
|
12
|
+
|
|
13
|
+
get capability() {
|
|
14
|
+
return this.kit.capability('geo');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Requests permission on first use. */
|
|
18
|
+
current(): Promise<GeoPosition> {
|
|
19
|
+
return this.kit.invoke('geo.current', undefined, { timeoutMs: 60_000 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stream position updates; resolves an unsubscribe once the watch is running. */
|
|
23
|
+
async watch(cb: (pos: GeoPosition) => void): Promise<Unsubscribe> {
|
|
24
|
+
const off = this.kit.on('geo.position', (p) => cb(p as GeoPosition));
|
|
25
|
+
try {
|
|
26
|
+
await this.kit.invoke('geo.watch.start', undefined, { timeoutMs: 60_000 });
|
|
27
|
+
} catch (e) {
|
|
28
|
+
off();
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
let stopped = false;
|
|
32
|
+
return () => {
|
|
33
|
+
if (stopped) return;
|
|
34
|
+
stopped = true;
|
|
35
|
+
off();
|
|
36
|
+
this.kit
|
|
37
|
+
.invoke('geo.watch.stop')
|
|
38
|
+
.catch((e) => console.warn('[native-kit] geo.watch.stop failed', e));
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
export type ImpactStyle = 'light' | 'medium' | 'heavy' | 'soft' | 'rigid';
|
|
4
|
+
export type NotifyType = 'success' | 'warning' | 'error';
|
|
5
|
+
|
|
6
|
+
export class HapticsModule {
|
|
7
|
+
constructor(private kit: NativeKit) {}
|
|
8
|
+
|
|
9
|
+
get capability() {
|
|
10
|
+
return this.kit.capability('haptics');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impact(style: ImpactStyle = 'medium'): Promise<void> {
|
|
14
|
+
return this.kit.invoke('haptics.impact', { style });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
notify(type: NotifyType): Promise<void> {
|
|
18
|
+
return this.kit.invoke('haptics.notify', { type });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
import type { Unsubscribe } from '../core/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step counting. `count()` returns the day's step total; foreground updates come from polling it,
|
|
6
|
+
* and background/while-killed steps are included automatically (the OS records them — no live JS
|
|
7
|
+
* runs while suspended). Opt-in module — enable with `"modules": ["health"]` in appwrap.json.
|
|
8
|
+
*
|
|
9
|
+
* Platform reach:
|
|
10
|
+
* - **iOS** (HealthKit): today's total from the Health app — aggregates iPhone + Apple Watch + other
|
|
11
|
+
* sources, recorded by the OS regardless of the app, so it's global and survives an app kill. No
|
|
12
|
+
* `start()` needed. Requires the `com.apple.developer.healthkit` entitlement + `requestAccess()`.
|
|
13
|
+
* - **Android** (Health Connect): today's total from the system store — Wear-inclusive, survives a
|
|
14
|
+
* kill, mirrors iOS. Needs the `READ_STEPS` permission (granted via `requestAccess()`). Falls back
|
|
15
|
+
* to the `TYPE_STEP_COUNTER` sensor (since `start()`, no kill-survival) when Health Connect isn't
|
|
16
|
+
* installed.
|
|
17
|
+
*/
|
|
18
|
+
export class HealthModule {
|
|
19
|
+
constructor(private kit: NativeKit) {}
|
|
20
|
+
|
|
21
|
+
get capability() {
|
|
22
|
+
return this.kit.capability('health');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Trigger the OS permission prompt (iOS motion usage · Android ACTIVITY_RECOGNITION).
|
|
26
|
+
* Interactive — generous timeout so it waits for the user to respond to the prompt. */
|
|
27
|
+
requestAccess(): Promise<boolean> {
|
|
28
|
+
return this.kit.invoke<boolean>('health.requestAccess', undefined, { timeoutMs: 60_000 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Begin counting. Required on Android (registers the sensor listener); a no-op availability
|
|
32
|
+
* check on iOS (which reads history and needs no session). */
|
|
33
|
+
start(): Promise<void> {
|
|
34
|
+
return this.kit.invoke<void>('health.start');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** End the counting session (Android: unregisters the listener). */
|
|
38
|
+
stop(): Promise<void> {
|
|
39
|
+
return this.kit.invoke<void>('health.stop');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The day's step count from the platform health store — iOS HealthKit / Android Health Connect
|
|
43
|
+
* (both Wear-inclusive, survive a kill). Android falls back to the step sensor (since `start()`). */
|
|
44
|
+
async count(): Promise<number> {
|
|
45
|
+
const r = await this.kit.invoke<{ steps: number }>('health.count');
|
|
46
|
+
return r?.steps ?? 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Start a session and stream the live count (foreground) by polling every `intervalMs`.
|
|
50
|
+
* Resolves an unsubscribe that stops both the poll and the session. */
|
|
51
|
+
async watch(cb: (steps: number) => void, intervalMs = 2000): Promise<Unsubscribe> {
|
|
52
|
+
await this.start();
|
|
53
|
+
const tick = async () => {
|
|
54
|
+
try {
|
|
55
|
+
cb(await this.count());
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.warn('[native-kit] health.count failed', e);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
await tick();
|
|
61
|
+
const id = setInterval(tick, intervalMs);
|
|
62
|
+
let stopped = false;
|
|
63
|
+
return () => {
|
|
64
|
+
if (stopped) return;
|
|
65
|
+
stopped = true;
|
|
66
|
+
clearInterval(id);
|
|
67
|
+
this.stop().catch((e) => console.warn('[native-kit] health.stop failed', e));
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
import type { Unsubscribe } from '../core/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* App lifecycle + deep links — pure event surface.
|
|
6
|
+
* Native: shell emits on suspend/resume and on URL-scheme opens.
|
|
7
|
+
* Web: visibilitychange maps to pause/resume; deep links never fire.
|
|
8
|
+
*/
|
|
9
|
+
export class LifecycleModule {
|
|
10
|
+
constructor(private kit: NativeKit) {}
|
|
11
|
+
|
|
12
|
+
onPause(cb: () => void): Unsubscribe {
|
|
13
|
+
return this.kit.on('app.pause', () => cb());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
onResume(cb: () => void): Unsubscribe {
|
|
17
|
+
return this.kit.on('app.resume', () => cb());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
onDeepLink(cb: (url: string) => void): Unsubscribe {
|
|
21
|
+
return this.kit.on('deeplink.open', (p) => cb((p as { url: string }).url));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live media bridge — mic / camera / speaker. The streams themselves are plain
|
|
5
|
+
* web APIs (getUserMedia / MediaRecorder / WebAudio); the native shell's job is
|
|
6
|
+
* to *unlock* them: grant the WebView's per-origin capture permission and route
|
|
7
|
+
* audio sensibly. This module is the single import surface + capability gate,
|
|
8
|
+
* plus `configureAudio()` which tunes the native audio session (iOS).
|
|
9
|
+
*/
|
|
10
|
+
export type AudioMode = 'playback' | 'playAndRecord' | 'voiceChat' | 'default';
|
|
11
|
+
|
|
12
|
+
export interface MediaDeviceLite {
|
|
13
|
+
kind: MediaDeviceKind;
|
|
14
|
+
label: string;
|
|
15
|
+
deviceId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class MediaModule {
|
|
19
|
+
constructor(private kit: NativeKit) {}
|
|
20
|
+
|
|
21
|
+
/** 'native' (shell grants capture) | 'web' (browser handles it) | 'none'. */
|
|
22
|
+
get capability() {
|
|
23
|
+
return this.kit.capability('media');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True only in a secure context with a usable mediaDevices implementation. */
|
|
27
|
+
get available(): boolean {
|
|
28
|
+
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pre-establish the OS mic/camera permission so the WebView's getUserMedia
|
|
33
|
+
* doesn't re-prompt on every call (a WKWebView artifact). Native shells prompt
|
|
34
|
+
* once via the OS and cache it; no-op on web. Called automatically by
|
|
35
|
+
* {@link getUserMedia}, but exposed for warming the grant up front.
|
|
36
|
+
*/
|
|
37
|
+
ensurePermission(opts: { audio?: boolean; video?: boolean }): Promise<{ audio?: string; video?: string }> {
|
|
38
|
+
if (this.capability !== 'native') return Promise.resolve({});
|
|
39
|
+
return this.kit.invoke('media.ensurePermission', opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Thin wrapper over getUserMedia — same constraints, with an availability guard. */
|
|
43
|
+
async getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream> {
|
|
44
|
+
if (!this.available) {
|
|
45
|
+
throw Object.assign(new Error('getUserMedia unavailable (insecure context?)'), { code: 'UNSUPPORTED' });
|
|
46
|
+
}
|
|
47
|
+
// Native: secure the persistent OS grant first so the WebView prompts once, like native.
|
|
48
|
+
await this.ensurePermission({ audio: !!constraints.audio, video: !!constraints.video }).catch(() => {});
|
|
49
|
+
return navigator.mediaDevices.getUserMedia(constraints);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Enumerate input/output devices (labels populate only after a grant). */
|
|
53
|
+
async devices(): Promise<MediaDeviceLite[]> {
|
|
54
|
+
if (!navigator.mediaDevices?.enumerateDevices) return [];
|
|
55
|
+
const list = await navigator.mediaDevices.enumerateDevices();
|
|
56
|
+
return list.map((d) => ({ kind: d.kind, label: d.label, deviceId: d.deviceId }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tune the native audio session. 'playback' = loud, ignores the iOS silent
|
|
61
|
+
* switch (media apps); 'playAndRecord' = mic + speaker for calls; 'voiceChat'
|
|
62
|
+
* = full-duplex voice with hardware acoustic echo cancellation (AEC) — use
|
|
63
|
+
* this for live voice agents so TTS playback doesn't bleed into the mic / STT.
|
|
64
|
+
* No-op where the platform needs no session config (Android, web).
|
|
65
|
+
*/
|
|
66
|
+
configureAudio(mode: AudioMode = 'playback'): Promise<void> {
|
|
67
|
+
if (this.capability !== 'native') return Promise.resolve();
|
|
68
|
+
return this.kit.invoke('media.configureAudio', { mode });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stop every track on a stream — convenience to release the camera/mic LED. */
|
|
72
|
+
stop(stream: MediaStream | null | undefined): void {
|
|
73
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
import type { Unsubscribe } from '../core/types';
|
|
3
|
+
|
|
4
|
+
export interface MotionSample {
|
|
5
|
+
/** Acceleration incl. gravity, m/s². */
|
|
6
|
+
ax: number;
|
|
7
|
+
ay: number;
|
|
8
|
+
az: number;
|
|
9
|
+
/** Rotation rate, rad/s (absent when no gyro). */
|
|
10
|
+
rx?: number;
|
|
11
|
+
ry?: number;
|
|
12
|
+
rz?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MotionModule {
|
|
16
|
+
constructor(private kit: NativeKit) {}
|
|
17
|
+
|
|
18
|
+
get capability() {
|
|
19
|
+
return this.kit.capability('motion');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stream motion samples (~10Hz); resolves an unsubscribe once streaming starts. */
|
|
23
|
+
async watch(cb: (sample: MotionSample) => void): Promise<Unsubscribe> {
|
|
24
|
+
const off = this.kit.on('motion.data', (p) => cb(p as MotionSample));
|
|
25
|
+
try {
|
|
26
|
+
await this.kit.invoke('motion.start');
|
|
27
|
+
} catch (e) {
|
|
28
|
+
off();
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
let stopped = false;
|
|
32
|
+
return () => {
|
|
33
|
+
if (stopped) return;
|
|
34
|
+
stopped = true;
|
|
35
|
+
off();
|
|
36
|
+
this.kit
|
|
37
|
+
.invoke('motion.stop')
|
|
38
|
+
.catch((e) => console.warn('[native-kit] motion.stop failed', e));
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|