@livx.cc/native-kit 0.32.1 → 0.34.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/README.md +1 -1
- package/package.json +1 -1
- package/src/core/NativeKit.ts +2 -0
- package/src/core/appwrap-adapter.ts +13 -4
- package/src/core/types.ts +19 -3
- package/src/index.ts +1 -0
- package/src/modules/billing/billing.ts +3 -4
- package/src/modules/lifecycle.ts +11 -0
- package/src/modules/oauth.ts +4 -4
- package/src/modules/push.ts +8 -2
- package/src/modules/tracking.ts +73 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ mixpanel.register(await kit.context());
|
|
|
26
26
|
|
|
27
27
|
The kit detects whether it's running inside the appwrap shell and routes each call to the native bridge or a web fallback, so a single web build works everywhere. Capabilities advertise their real backing (`native` / `web` / `none`) so your UI can be honest about what's available.
|
|
28
28
|
|
|
29
|
-
Covers haptics, share, storage, notifications, OAuth, billing (IAP), health, geo, camera, photos, contacts, calendar, motion, reviews, and more.
|
|
29
|
+
Covers haptics, share, storage, notifications, OAuth, billing (IAP), health, geo, camera, photos, contacts, calendar, motion, reviews, tracking (iOS App Tracking Transparency), and more.
|
|
30
30
|
|
|
31
31
|
**Full documentation, capability model, and the bridge protocol:** see the [appwrap README](https://github.com/Livshitz/appwrap#readme).
|
|
32
32
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livx.cc/native-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
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
|
@@ -30,6 +30,7 @@ import { ShareModule } from '../modules/share';
|
|
|
30
30
|
import { SpeechModule } from '../modules/speech';
|
|
31
31
|
import { StorageModule } from '../modules/storage';
|
|
32
32
|
import { ToastModule } from '../modules/toast';
|
|
33
|
+
import { TrackingModule } from '../modules/tracking';
|
|
33
34
|
import { UiModule } from '../modules/ui';
|
|
34
35
|
import { UpdatesModule } from '../modules/updates';
|
|
35
36
|
|
|
@@ -103,6 +104,7 @@ export class NativeKit {
|
|
|
103
104
|
public readonly billing = new BillingModule(this);
|
|
104
105
|
public readonly updates = new UpdatesModule(this);
|
|
105
106
|
public readonly backgroundTask = new BackgroundTaskModule(this);
|
|
107
|
+
public readonly tracking = new TrackingModule(this);
|
|
106
108
|
|
|
107
109
|
public handshakeInfo: Handshake | null = null;
|
|
108
110
|
public options: NativeKitOptions;
|
|
@@ -45,6 +45,12 @@ export class AppwrapAdapter implements NativeKitAdapter {
|
|
|
45
45
|
return this.request<T>(method, params, opts?.timeoutMs ?? 10_000);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** `'none'` or `0` → no watchdog (dismiss-bound calls); otherwise the deadline in ms. */
|
|
49
|
+
private static resolveTimeout(timeoutMs: number | 'none'): number | null {
|
|
50
|
+
if (timeoutMs === 'none' || timeoutMs === 0) return null;
|
|
51
|
+
return timeoutMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
on(event: string, cb: (payload: unknown) => void): Unsubscribe {
|
|
49
55
|
let set = this.listeners.get(event);
|
|
50
56
|
if (!set) this.listeners.set(event, (set = new Set()));
|
|
@@ -52,14 +58,17 @@ export class AppwrapAdapter implements NativeKitAdapter {
|
|
|
52
58
|
return () => set!.delete(cb);
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
private request<T>(method: string, params: unknown, timeoutMs: number): Promise<T> {
|
|
61
|
+
private request<T>(method: string, params: unknown, timeoutMs: number | 'none'): Promise<T> {
|
|
56
62
|
const id = `k${++this.seq}`;
|
|
57
63
|
const envelope: RequestEnvelope = { v: 1, id, kind: 'request', method, params };
|
|
64
|
+
const deadline = AppwrapAdapter.resolveTimeout(timeoutMs);
|
|
58
65
|
return new Promise<T>((resolve, reject) => {
|
|
59
|
-
|
|
66
|
+
// `null` deadline = dismiss-bound call: no watchdog, resolves only on the
|
|
67
|
+
// native response (e.g. the user closes the sheet).
|
|
68
|
+
const timer = deadline === null ? undefined : setTimeout(() => {
|
|
60
69
|
this.pending.delete(id);
|
|
61
|
-
reject(new KitError('TIMEOUT', `${method} timed out after ${
|
|
62
|
-
},
|
|
70
|
+
reject(new KitError('TIMEOUT', `${method} timed out after ${deadline}ms`));
|
|
71
|
+
}, deadline);
|
|
63
72
|
this.pending.set(id, {
|
|
64
73
|
resolve: (v) => { clearTimeout(timer); resolve(v as T); },
|
|
65
74
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
package/src/core/types.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface Handshake {
|
|
|
29
29
|
* reads it from {@link NativeKit.handshakeInfo} and dispatches the registered handler. Absent on a
|
|
30
30
|
* normal foreground launch. See {@link BackgroundTaskModule}. */
|
|
31
31
|
backgroundTaskId?: string;
|
|
32
|
+
/** Set ONLY on a cold launch FROM a deep link (url-scheme open or notification tap that started the
|
|
33
|
+
* app): the link that launched us, handed back synchronously so the PWA can route to the target
|
|
34
|
+
* route BEFORE first paint — avoiding a brief home-screen flash. A WARM deep link (app already
|
|
35
|
+
* running) is NOT carried here; it arrives via the `deeplink.open` event. Read it at `ready()` via
|
|
36
|
+
* {@link LifecycleModule.launchDeepLink}. Absent on a normal launch. */
|
|
37
|
+
deepLink?: string;
|
|
32
38
|
/** Optional diagnostic payload (breadcrumbs, etc.). */
|
|
33
39
|
debug?: Record<string, unknown>;
|
|
34
40
|
}
|
|
@@ -71,9 +77,19 @@ export class KitError extends Error {
|
|
|
71
77
|
export type Unsubscribe = () => void;
|
|
72
78
|
|
|
73
79
|
export interface InvokeOptions {
|
|
74
|
-
/** Per-call response deadline
|
|
75
|
-
*
|
|
76
|
-
|
|
80
|
+
/** Per-call response deadline (default 10_000ms).
|
|
81
|
+
*
|
|
82
|
+
* Interactive flows (pickers, dialogs, auth prompts) should pass a generous
|
|
83
|
+
* value — the user may take their time.
|
|
84
|
+
*
|
|
85
|
+
* Pass `'none'` (or `0`) to DISABLE the watchdog entirely. Use this ONLY for
|
|
86
|
+
* dismiss-bound / present-and-wait calls: native UI that resolves solely when
|
|
87
|
+
* the user dismisses a sheet/modal (manage-subscriptions sheet,
|
|
88
|
+
* ASWebAuthenticationSession, share sheet, …). For these there is no
|
|
89
|
+
* meaningful deadline — a watchdog could only false-timeout mid-interaction
|
|
90
|
+
* and force a spurious fallback. Do NOT use it for fire-and-fast calls: a
|
|
91
|
+
* finite timeout is what surfaces a real native hang. */
|
|
92
|
+
timeoutMs?: number | 'none';
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
export interface NativeKitAdapter {
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type { SpeechVoice, SpeakOptions, ListenOptions, SpeechPartial } from './
|
|
|
41
41
|
export type { CalendarEventOptions } from './modules/calendar';
|
|
42
42
|
export type { BrowserOptions } from './modules/browser';
|
|
43
43
|
export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
|
|
44
|
+
export type { TrackingStatus } from './modules/tracking';
|
|
44
45
|
export { ClientTrustedValidator, HttpValidator, HttpValidatorOptions } from './modules/billing/validators';
|
|
45
46
|
export { HttpBillingProvider, HttpBillingProviderOptions } from './modules/billing/providers';
|
|
46
47
|
export type { HeaderProvider } from './modules/billing/http';
|
|
@@ -132,10 +132,9 @@ export class BillingModule {
|
|
|
132
132
|
if (this.capability !== 'native') {
|
|
133
133
|
throw new KitError('UNSUPPORTED', 'The in-app subscriptions sheet is iOS-only (StoreKit 2). Use manageSubscriptions() on web/Android.');
|
|
134
134
|
}
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
return this.kit.invoke('billing.manageSubscriptionsSheet', undefined, { timeoutMs: 600_000 });
|
|
135
|
+
// Dismiss-bound: resolves only when the USER closes the sheet (no meaningful deadline).
|
|
136
|
+
// A watchdog could only false-timeout mid-sheet → spurious fallback, so disable it.
|
|
137
|
+
return this.kit.invoke('billing.manageSubscriptionsSheet', undefined, { timeoutMs: 'none' });
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
/** Out-of-band transactions (renewals, Ask-to-Buy, cross-device). Native streams only. */
|
package/src/modules/lifecycle.ts
CHANGED
|
@@ -20,4 +20,15 @@ export class LifecycleModule {
|
|
|
20
20
|
onDeepLink(cb: (url: string) => void): Unsubscribe {
|
|
21
21
|
return this.kit.on('deeplink.open', (p) => cb((p as { url: string }).url));
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The deep link the app was COLD-LAUNCHED from (url-scheme open / notification tap that started the
|
|
26
|
+
* app), if any — exposed synchronously from the handshake so the app can route to the target route
|
|
27
|
+
* BEFORE first paint and skip the brief home flash. Returns null on a normal launch or on web.
|
|
28
|
+
* Available once `kit.ready()` has resolved. Warm deep links (app already open) do NOT appear here —
|
|
29
|
+
* subscribe to {@link onDeepLink} for those.
|
|
30
|
+
*/
|
|
31
|
+
get launchDeepLink(): string | null {
|
|
32
|
+
return this.kit.handshakeInfo?.deepLink ?? null;
|
|
33
|
+
}
|
|
23
34
|
}
|
package/src/modules/oauth.ts
CHANGED
|
@@ -39,9 +39,9 @@ export class OAuthModule {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
authorize(params: OAuthAuthorizeParams): Promise<OAuthResult> {
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
return this.kit.invoke('oauth.authorize', params as unknown as Record<string, unknown>, { timeoutMs:
|
|
42
|
+
// Dismiss-bound: the ASWebAuthenticationSession / Custom Tab resolves only when the user
|
|
43
|
+
// finishes (password + 2FA + consent) or cancels — there is no meaningful deadline. A watchdog
|
|
44
|
+
// would abandon the request id before the redirect returns and silently drop the login.
|
|
45
|
+
return this.kit.invoke('oauth.authorize', params as unknown as Record<string, unknown>, { timeoutMs: 'none' });
|
|
46
46
|
}
|
|
47
47
|
}
|
package/src/modules/push.ts
CHANGED
|
@@ -9,6 +9,11 @@ export type PushPlatform = 'apns' | 'fcm';
|
|
|
9
9
|
export interface PushToken {
|
|
10
10
|
platform: PushPlatform;
|
|
11
11
|
token: string;
|
|
12
|
+
/** The app's bundle/package id. On iOS this is the APNs `apns-topic` your backend MUST send
|
|
13
|
+
* (APNs rejects a mismatch with `DeviceTokenNotForTopic`); on Android it's the package id —
|
|
14
|
+
* informational, since FCM doesn't use apns-topic. Optional: absent on web / un-provisioned
|
|
15
|
+
* builds and older shells that don't supply it (a missing topic is tolerated). */
|
|
16
|
+
topic?: string;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/** A remote message surfaced to the page. `data` is the sender's custom payload; `title`/`body`
|
|
@@ -27,8 +32,9 @@ export interface PushMessage {
|
|
|
27
32
|
* ```ts
|
|
28
33
|
* if (kit.push.capability === 'native') {
|
|
29
34
|
* if (await kit.push.requestPermission() === 'granted') {
|
|
30
|
-
* const { platform, token } = await kit.push.register();
|
|
31
|
-
*
|
|
35
|
+
* const { platform, token, topic } = await kit.push.register();
|
|
36
|
+
* // `topic` = the iOS bundle id → your backend sets it as the APNs apns-topic header.
|
|
37
|
+
* await fetch('/api/push/register', { method:'POST', body: JSON.stringify({ platform, token, topic }) });
|
|
32
38
|
* }
|
|
33
39
|
* kit.push.onMessage((m) => …); // foreground delivery
|
|
34
40
|
* kit.push.onTap((m) => …); // user opened a notification
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
/** App Tracking Transparency authorization status (iOS ATTrackingManager). Mirrors Apple's enum.
|
|
4
|
+
* - `notDetermined` — the user hasn't been asked yet (call {@link TrackingModule.requestPermission}).
|
|
5
|
+
* - `restricted` — tracking is disallowed by device policy (e.g. parental controls / MDM).
|
|
6
|
+
* - `denied` — the user declined; you MUST NOT track / read the IDFA.
|
|
7
|
+
* - `authorized` — the user allowed tracking; {@link TrackingModule.idfa} returns the IDFA. */
|
|
8
|
+
export type TrackingStatus = 'notDetermined' | 'restricted' | 'denied' | 'authorized';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* App Tracking Transparency (iOS, `tracking` module — opt-in). The native-only compliance seam for
|
|
12
|
+
* apps that track the user across other companies' apps/sites (IDFA, cross-app identity): Apple
|
|
13
|
+
* REQUIRES the ATT prompt + `NSUserTrackingUsageDescription` and forbids tracking before consent.
|
|
14
|
+
*
|
|
15
|
+
* Provider-agnostic, like `kit.push`/`kit.oauth`: the shell owns only the device-side primitive
|
|
16
|
+
* (show the prompt, report status, hand back the IDFA when authorized). What you DO with consent —
|
|
17
|
+
* init an ad SDK, set an analytics super-prop — is the PWA's job.
|
|
18
|
+
*
|
|
19
|
+
* Capability gating (honest):
|
|
20
|
+
* - iOS (14.5+): `'native'`.
|
|
21
|
+
* - Android / web / iOS < 14.5: `'none'` — there is NO ATT. The methods still resolve safely:
|
|
22
|
+
* `requestPermission()`/`status()` → `'authorized'` (no consent gate exists, so tracking is not
|
|
23
|
+
* blocked by the OS — Play's Advertising-ID consent is a separate, app-declared concern), and
|
|
24
|
+
* `idfa()` → `undefined` (the IDFA is iOS-only; GAID needs the AD_ID permission + Play's own flow).
|
|
25
|
+
*
|
|
26
|
+
* Most apps need only first-party analytics and should NOT enable this module (no string = Apple
|
|
27
|
+
* assumes no tracking). Ship it only when the app genuinely tracks across companies.
|
|
28
|
+
*/
|
|
29
|
+
export class TrackingModule {
|
|
30
|
+
constructor(private kit: NativeKit) {}
|
|
31
|
+
|
|
32
|
+
get capability() {
|
|
33
|
+
return this.kit.capability('tracking');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Honest fallback when ATT doesn't exist (Android/web/iOS<14.5): nothing gates tracking at the OS
|
|
37
|
+
* level, so report `authorized` rather than a misleading `notDetermined`. */
|
|
38
|
+
private get fallback(): TrackingStatus {
|
|
39
|
+
return 'authorized';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Show the iOS ATT prompt and resolve with the resulting status. The prompt only appears once per
|
|
44
|
+
* install while status is `notDetermined`; subsequent calls resolve immediately with the prior
|
|
45
|
+
* choice. Dismiss-bound (the user decides at their leisure) → no watchdog timeout.
|
|
46
|
+
* On a non-ATT platform resolves to {@link fallback} without showing UI.
|
|
47
|
+
*/
|
|
48
|
+
requestPermission(): Promise<TrackingStatus> {
|
|
49
|
+
if (this.capability !== 'native') return Promise.resolve(this.fallback);
|
|
50
|
+
return this.kit.invoke<TrackingStatus>('tracking.requestPermission', undefined, { timeoutMs: 'none' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Current ATT authorization status WITHOUT prompting. {@link fallback} on a non-ATT platform. */
|
|
54
|
+
status(): Promise<TrackingStatus> {
|
|
55
|
+
if (this.capability !== 'native') return Promise.resolve(this.fallback);
|
|
56
|
+
return this.kit.invoke<TrackingStatus>('tracking.status');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Alias of {@link status} (parity with other permissioned modules' `permissionStatus`). */
|
|
60
|
+
permissionStatus(): Promise<TrackingStatus> {
|
|
61
|
+
return this.status();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The IDFA (iOS advertising identifier) — returned ONLY while ATT status is `authorized`; otherwise
|
|
66
|
+
* (denied/restricted/notDetermined, or the all-zero placeholder) resolves `undefined`. Always
|
|
67
|
+
* `undefined` off iOS (no IDFA exists — use Play's Advertising ID via your own flow if needed).
|
|
68
|
+
*/
|
|
69
|
+
idfa(): Promise<string | undefined> {
|
|
70
|
+
if (this.capability !== 'native') return Promise.resolve(undefined);
|
|
71
|
+
return this.kit.invoke<string | undefined>('tracking.idfa');
|
|
72
|
+
}
|
|
73
|
+
}
|