@livx.cc/native-kit 0.41.0 → 0.43.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@livx.cc/native-kit",
3
- "version": "0.41.0",
4
- "description": "Isomorphic native-capabilities kit for PWAs — same API in browser and in an appwrap native shell. Zero dependencies.",
3
+ "version": "0.43.0",
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",
7
7
  "homepage": "https://github.com/Livshitz/appwrap#readme",
@@ -1,10 +1,11 @@
1
1
  import { AppwrapAdapter } from './appwrap-adapter';
2
2
  import { WebAdapter } from './web-adapter';
3
+ import { ModuleRegistry } from './module-registry';
4
+ import type { KitModuleRegistry } from './module-registry';
3
5
  import { Capability, Handshake, InvokeOptions, KIT_PROTOCOL, KitError, NativeKitAdapter, Platform, Unsubscribe } from './types';
4
6
  import { AppModule } from '../modules/app';
5
7
  import { AppleSignInModule } from '../modules/appleSignIn';
6
8
  import { BackgroundTaskModule } from '../modules/backgroundTask';
7
- import { BillingModule } from '../modules/billing/billing';
8
9
  import { BiometricsModule } from '../modules/biometrics';
9
10
  import { BrowserModule } from '../modules/browser';
10
11
  import { CalendarModule } from '../modules/calendar';
@@ -15,7 +16,6 @@ import { FsModule } from '../modules/fs';
15
16
  import { GeoModule } from '../modules/geo';
16
17
  import { HeadingModule } from '../modules/heading';
17
18
  import { HapticsModule } from '../modules/haptics';
18
- import { HealthModule } from '../modules/health';
19
19
  import { KeyboardModule } from '../modules/keyboard';
20
20
  import { LifecycleModule } from '../modules/lifecycle';
21
21
  import { MediaModule } from '../modules/media';
@@ -35,7 +35,6 @@ import { ToastModule } from '../modules/toast';
35
35
  import { TrackingModule } from '../modules/tracking';
36
36
  import { UiModule } from '../modules/ui';
37
37
  import { UpdatesModule } from '../modules/updates';
38
- import { WidgetModule } from '../modules/widget';
39
38
 
40
39
  export class NativeKitOptions {
41
40
  /** Priority order; first adapter whose detect() passes wins. */
@@ -43,22 +42,6 @@ export class NativeKitOptions {
43
42
  handshakeTimeoutMs = 3000;
44
43
  }
45
44
 
46
- /**
47
- * Method domains the web fallback must NEVER be retried against — deliberately NOT an option:
48
- * this is a policy invariant, not a knob a consumer should be able to switch off.
49
- *
50
- * `billing.`: purchases inside a native shell belong to the device store, full stop.
51
- * - {@link BillingModule.capability} promises to "never silently fall back to web inside a
52
- * native shell" — the generic retry here would bypass that contract behind its back.
53
- * - The WebAdapter's only billing behaviour is a hardcoded throw ("use a web checkout"), so
54
- * retrying billing can't succeed — it can only REPLACE the shell's real, actionable error
55
- * (e.g. UNSUPPORTED "Purchases are disabled on this device" from a Screen-Time/MDM-restricted
56
- * iPhone) with a false one claiming the user is on the web. Zero upside, only downside.
57
- * - Steering an iOS user to an external checkout is App Store Guideline 3.1.1 territory. Today
58
- * that string only reaches a `catch`; this keeps it from ever reaching a paywall.
59
- */
60
- const WEB_FALLBACK_EXCLUDED_DOMAINS = ['billing.'];
61
-
62
45
  /**
63
46
  * Flat, vendor-neutral analytics bag — spread straight into your analytics provider's
64
47
  * super-properties (e.g. `mixpanel.register(await kit.context())`). Native-only fields
@@ -112,7 +95,6 @@ export class NativeKit {
112
95
  public readonly lifecycle = new LifecycleModule(this);
113
96
  public readonly reviews = new ReviewsModule(this);
114
97
  public readonly motion = new MotionModule(this);
115
- public readonly health = new HealthModule(this);
116
98
  public readonly media = new MediaModule(this);
117
99
  public readonly contacts = new ContactsModule(this);
118
100
  public readonly scanner = new ScannerModule(this);
@@ -121,12 +103,18 @@ export class NativeKit {
121
103
  public readonly app = new AppModule(this);
122
104
  public readonly browser = new BrowserModule(this);
123
105
  public readonly oauth = new OAuthModule(this);
124
- public readonly billing = new BillingModule(this);
125
106
  public readonly updates = new UpdatesModule(this);
126
107
  public readonly backgroundTask = new BackgroundTaskModule(this);
127
108
  public readonly tracking = new TrackingModule(this);
128
109
  public readonly appleSignIn = new AppleSignInModule(this);
129
- public readonly widget = new WidgetModule(this);
110
+
111
+ /**
112
+ * Additive, string-keyed registry mirroring the eager fields above (SAME instances) — a new
113
+ * capability-access path out-of-repo module packs register into (e.g. the billing/health/widget
114
+ * EE packs call `kit.modules.registerModule(...)`). For core modules `kit.getModule('haptics')
115
+ * === kit.haptics`. Fails loud on miss.
116
+ */
117
+ public readonly modules = new ModuleRegistry();
130
118
 
131
119
  public handshakeInfo: Handshake | null = null;
132
120
  public options: NativeKitOptions;
@@ -137,11 +125,48 @@ export class NativeKit {
137
125
  * for it. Capability keys the shell reports as 'none'/absent surface as 'web' when the
138
126
  * fallback can fulfil them. */
139
127
  private webFallback: NativeKitAdapter | null = null;
128
+ /**
129
+ * Method-name prefixes the web fallback must NEVER be retried against. Empty by default;
130
+ * a module pack declares its own policy via {@link excludeWebFallback} at registration.
131
+ * e.g. the billing pack excludes `billing.` — purchases inside a native shell belong to the
132
+ * device store, and retrying them on the WebAdapter would replace the shell's real, actionable
133
+ * error with a false "use a web checkout" one (App Store Guideline 3.1.1 territory).
134
+ */
135
+ private webFallbackExcludedDomains = new Set<string>();
140
136
  private readyPromise: Promise<Handshake> | null = null;
141
137
  private contextPromise: Promise<KitContext> | null = null;
142
138
 
143
139
  constructor(options?: Partial<NativeKitOptions>) {
144
140
  this.options = { ...new NativeKitOptions(), ...options };
141
+ // Mirror the eager CORE fields into the string-keyed registry (same instances, no behaviour
142
+ // change). Typed as a plain string map — not `Record<keyof KitModuleRegistry, unknown>` — because
143
+ // out-of-repo packs augment KitModuleRegistry with keys CE never mounts (billing/health/widget);
144
+ // those are registered by the pack's register<Name>Kit(), not here.
145
+ const eager: Record<string, unknown> = {
146
+ haptics: this.haptics, share: this.share, screen: this.screen, keyboard: this.keyboard,
147
+ storage: this.storage, fs: this.fs, toast: this.toast, ui: this.ui, device: this.device,
148
+ clipboard: this.clipboard, notifications: this.notifications, push: this.push,
149
+ biometrics: this.biometrics, geo: this.geo, heading: this.heading, photos: this.photos,
150
+ network: this.network, lifecycle: this.lifecycle, reviews: this.reviews, motion: this.motion,
151
+ media: this.media, contacts: this.contacts, scanner: this.scanner,
152
+ speech: this.speech, calendar: this.calendar, app: this.app, browser: this.browser,
153
+ oauth: this.oauth, updates: this.updates,
154
+ backgroundTask: this.backgroundTask, tracking: this.tracking, appleSignIn: this.appleSignIn,
155
+ };
156
+ for (const [name, inst] of Object.entries(eager)) this.modules.registerModule(name, inst);
157
+ }
158
+
159
+ /** Typed, string-keyed module lookup — delegates to {@link ModuleRegistry.getModule}.
160
+ * Throws (listing mounted modules) if the name isn't registered. */
161
+ getModule<K extends keyof KitModuleRegistry>(name: K): KitModuleRegistry[K] {
162
+ return this.modules.getModule(name);
163
+ }
164
+
165
+ /** Declare a method-name prefix the web fallback must never retry against (see
166
+ * {@link webFallbackExcludedDomains}). Module packs call this at registration to own their
167
+ * own policy — e.g. the billing pack excludes `billing.`. Idempotent. */
168
+ excludeWebFallback(prefix: string): void {
169
+ this.webFallbackExcludedDomains.add(prefix);
145
170
  }
146
171
 
147
172
  /** Resolve the environment and perform the handshake. Idempotent. */
@@ -285,7 +310,7 @@ export class NativeKit {
285
310
  this.webFallback &&
286
311
  e instanceof KitError &&
287
312
  e.code === 'UNSUPPORTED' &&
288
- !WEB_FALLBACK_EXCLUDED_DOMAINS.some((d) => method.startsWith(d))
313
+ ![...this.webFallbackExcludedDomains].some((d) => method.startsWith(d))
289
314
  ) {
290
315
  return this.webFallback.invoke<T>(method, params, opts);
291
316
  }
@@ -317,5 +342,44 @@ export class NativeKit {
317
342
  }
318
343
  }
319
344
 
345
+ /** Typed registry map — augment via declaration merging so `getModule(name)` returns the
346
+ * strongly-typed module. Out-of-repo packs augment this same interface to add their own. */
347
+ declare module './module-registry' {
348
+ interface KitModuleRegistry {
349
+ haptics: HapticsModule;
350
+ share: ShareModule;
351
+ screen: ScreenModule;
352
+ keyboard: KeyboardModule;
353
+ storage: StorageModule;
354
+ fs: FsModule;
355
+ toast: ToastModule;
356
+ ui: UiModule;
357
+ device: DeviceModule;
358
+ clipboard: ClipboardModule;
359
+ notifications: NotificationsModule;
360
+ push: PushModule;
361
+ biometrics: BiometricsModule;
362
+ geo: GeoModule;
363
+ heading: HeadingModule;
364
+ photos: PhotosModule;
365
+ network: NetworkModule;
366
+ lifecycle: LifecycleModule;
367
+ reviews: ReviewsModule;
368
+ motion: MotionModule;
369
+ media: MediaModule;
370
+ contacts: ContactsModule;
371
+ scanner: ScannerModule;
372
+ speech: SpeechModule;
373
+ calendar: CalendarModule;
374
+ app: AppModule;
375
+ browser: BrowserModule;
376
+ oauth: OAuthModule;
377
+ updates: UpdatesModule;
378
+ backgroundTask: BackgroundTaskModule;
379
+ tracking: TrackingModule;
380
+ appleSignIn: AppleSignInModule;
381
+ }
382
+ }
383
+
320
384
  /** Shared default instance — `import { kit } from '@livx.cc/native-kit'`. */
321
385
  export const kit = new NativeKit();
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tiny, dependency-free, string-keyed registry for kit modules.
3
+ *
4
+ * Deliberately in-house (no di container): native-kit is bundled into consumer PWAs and
5
+ * must stay ZERO-dependency. Keys are explicit strings — never constructor/parameter names —
6
+ * because minification renames identifiers and would silently break name-based lookup.
7
+ *
8
+ * A future step lets out-of-repo "module packs" register their own client here; today it runs
9
+ * in parallel with the eager `NativeKit` fields (same instances, no behaviour change).
10
+ *
11
+ * Typed access is via TypeScript declaration merging: augment {@link KitModuleRegistry} with
12
+ * `name -> ModuleType` and {@link ModuleRegistry.getModule} returns the strongly-typed module.
13
+ */
14
+
15
+ /** Augment via `declare module` to map a module name to its type (see NativeKit.ts). */
16
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
17
+ export interface KitModuleRegistry {}
18
+
19
+ export type ModuleFactory<T = unknown> = () => T;
20
+
21
+ export class ModuleRegistry {
22
+ private instances = new Map<string, unknown>();
23
+ private factories = new Map<string, ModuleFactory>();
24
+
25
+ /** Register a module by string name — either a ready instance or a lazy factory
26
+ * (called at most once, then memoized). Re-registering replaces the prior entry. */
27
+ registerModule(name: string, instanceOrFactory: unknown | ModuleFactory): void {
28
+ if (typeof instanceOrFactory === 'function') {
29
+ this.factories.set(name, instanceOrFactory as ModuleFactory);
30
+ this.instances.delete(name);
31
+ } else {
32
+ this.instances.set(name, instanceOrFactory);
33
+ this.factories.delete(name);
34
+ }
35
+ }
36
+
37
+ hasModule(name: string): boolean {
38
+ return this.instances.has(name) || this.factories.has(name);
39
+ }
40
+
41
+ /** Currently-mounted module names, sorted. */
42
+ moduleNames(): string[] {
43
+ return [...new Set([...this.instances.keys(), ...this.factories.keys()])].sort();
44
+ }
45
+
46
+ /**
47
+ * Typed, string-keyed lookup. STRICT by design: the ONLY public signature is keyed to
48
+ * `keyof KitModuleRegistry`, so `getModule('billing')` is a COMPILE ERROR unless the billing pack's
49
+ * kit client has been imported (its `declare module` augments the registry) — you get compile-time
50
+ * verification + autocomplete of exactly the modules you've wired, never a runtime surprise. For a
51
+ * genuinely dynamic name, use {@link getModuleUnsafe}.
52
+ */
53
+ getModule<K extends keyof KitModuleRegistry>(name: K): KitModuleRegistry[K];
54
+ getModule(name: string): unknown {
55
+ if (this.instances.has(name)) return this.instances.get(name);
56
+ const factory = this.factories.get(name);
57
+ if (factory) {
58
+ const inst = factory(); // memoize once, then drop the factory
59
+ this.instances.set(name, inst);
60
+ this.factories.delete(name);
61
+ return inst;
62
+ }
63
+ // Fail LOUD: a missing module must throw at the call site, never queue/return undefined.
64
+ throw new Error(
65
+ `[native-kit] module '${name}' is not registered. ` +
66
+ `Mounted modules: [${this.moduleNames().join(', ')}]. ` +
67
+ `Did you list its pack in modulePacks?`
68
+ );
69
+ }
70
+
71
+ /** Escape hatch for a genuinely DYNAMIC module name (not a compile-time literal) — bypasses the
72
+ * keyed check. Prefer {@link getModule}; reach for this only when the name isn't statically known. */
73
+ getModuleUnsafe<T = unknown>(name: string): T {
74
+ return (this.getModule as (n: string) => unknown)(name) as T;
75
+ }
76
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { NativeKit, NativeKitOptions, kit } from './core/NativeKit';
2
2
  export type { KitContext } from './core/NativeKit';
3
+ export { ModuleRegistry } from './core/module-registry';
4
+ export type { KitModuleRegistry, ModuleFactory } from './core/module-registry';
3
5
  export type { AppEnvironment, AppShortcut, InstallSource } from './modules/app';
4
6
  export { AppwrapAdapter } from './core/appwrap-adapter';
5
7
  export { WebAdapter } from './core/web-adapter';
@@ -43,7 +45,6 @@ export type { CalendarEventOptions } from './modules/calendar';
43
45
  export type { BrowserOptions } from './modules/browser';
44
46
  export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
45
47
  export type { TrackingStatus } from './modules/tracking';
46
- export type { WidgetEntry, WidgetPayload, WidgetStat, WidgetPublishResult } from './modules/widget';
47
48
  export type {
48
49
  AppleSignInName,
49
50
  AppleSignInParams,
@@ -51,20 +52,5 @@ export type {
51
52
  AppleSignInCancelled,
52
53
  } from './modules/appleSignIn';
53
54
  export { isAppleSignInResult } from './modules/appleSignIn';
54
- export { ClientTrustedValidator, HttpValidator, HttpValidatorOptions } from './modules/billing/validators';
55
- export { HttpBillingProvider, HttpBillingProviderOptions } from './modules/billing/providers';
56
- export type { HeaderProvider } from './modules/billing/http';
57
- export { HealthModule } from './modules/health';
58
55
  export { BackgroundTaskModule } from './modules/backgroundTask';
59
56
  export type { BackgroundTaskHandler, ScheduleBackgroundTaskOptions } from './modules/backgroundTask';
60
- export type {
61
- BillingProvider,
62
- BillingValidator,
63
- Entitlement,
64
- Product,
65
- ProductType,
66
- PurchaseReceipt,
67
- PurchaseResult,
68
- ReceiptEntitlements,
69
- ReceiptOutcome,
70
- } from './modules/billing/types';
@@ -1,176 +0,0 @@
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 {
5
- BillingProvider,
6
- BillingValidator,
7
- Entitlement,
8
- Product,
9
- PurchaseReceipt,
10
- PurchaseResult,
11
- ReceiptEntitlements,
12
- ReceiptOutcome,
13
- } from './types';
14
-
15
- /**
16
- * In-app purchases & subscriptions — ONE API across web and native.
17
- *
18
- * - On a native shell the kit drives the device store (iOS StoreKit 1, Android Play
19
- * Billing) and a {@link BillingValidator} confirms the receipt (server-of-record).
20
- * - On the web there is no native store, so the app plugs in a {@link BillingProvider}
21
- * (Stripe / Paddle / custom backend) and the SAME `kit.billing.*` calls dispatch to it.
22
- *
23
- * Wire both once via {@link configure}; the module picks the right path per platform.
24
- */
25
- export class BillingModule {
26
- private validator: BillingValidator;
27
- private webProvider?: BillingProvider;
28
-
29
- constructor(private kit: NativeKit) {
30
- this.validator = new ClientTrustedValidator(() => this.nativeEntitlements());
31
- }
32
-
33
- /** 'native' on a store-capable shell · 'web' when a web provider is wired · else 'none'. */
34
- get capability(): Capability {
35
- if (this.kit.is.native) return this.kit.capability('billing'); // native store (or 'none' if parked)
36
- return this.webProvider ? 'web' : 'none'; // never silently fall back to web inside a native shell
37
- }
38
-
39
- /**
40
- * @param opts.validator server-of-record for entitlements (native receipt check; shared)
41
- * @param opts.webProvider purchase mechanism on the web (Stripe/Paddle/custom)
42
- */
43
- configure(opts: { validator?: BillingValidator; webProvider?: BillingProvider }): void {
44
- if (opts.validator) this.validator = opts.validator;
45
- if (opts.webProvider) this.webProvider = opts.webProvider;
46
- }
47
-
48
- /** On the web the purchase mechanism is the provider; require one with an actionable error. */
49
- private webProviderOrThrow(): BillingProvider {
50
- if (!this.webProvider) {
51
- throw new KitError(
52
- 'UNSUPPORTED',
53
- 'No web billing provider configured. On the web, wire one: ' +
54
- "kit.billing.configure({ webProvider: new HttpBillingProvider({ baseUrl: '/api/billing' }) })"
55
- );
56
- }
57
- return this.webProvider;
58
- }
59
-
60
- /** Localized product catalog — from the store (native) or the provider (web). */
61
- async products(ids: string[]): Promise<Product[]> {
62
- await this.kit.ready(); // routing depends on the resolved adapter — never decide pre-handshake
63
- if (this.kit.is.web) return this.webProviderOrThrow().products(ids);
64
- return this.kit.invoke('billing.products', { ids });
65
- }
66
-
67
- /** Buy a product. Native store flow + receipt validation, or web checkout via the provider. */
68
- async purchase(productId: string): Promise<PurchaseResult> {
69
- await this.kit.ready();
70
- if (this.kit.is.web) {
71
- const entitlements = await this.webProviderOrThrow().purchase(productId);
72
- return { receipt: { platform: 'web', productId, raw: null }, entitlements };
73
- }
74
- const receipt = await this.kit.invoke<PurchaseReceipt>('billing.purchase', { productId }, { timeoutMs: 120_000 });
75
- return { receipt, entitlements: await this.validator.validate(receipt) };
76
- }
77
-
78
- /** Restore/re-sync prior purchases. */
79
- async restore(): Promise<Entitlement[]> {
80
- await this.kit.ready();
81
- if (this.kit.is.web) {
82
- const p = this.webProviderOrThrow();
83
- return (p.restore ?? p.entitlements).call(p); // web has no store "restore" → re-read entitlements
84
- }
85
- const receipts = await this.kit.invoke<PurchaseReceipt[]>('billing.restore', undefined, { timeoutMs: 60_000 });
86
- const out: Entitlement[] = [];
87
- for (const r of receipts) out.push(...(await this.validator.validate(r)));
88
- return out;
89
- }
90
-
91
- /**
92
- * Confirm entitlements **silently** from the on-device App Store receipt — no Apple-ID
93
- * prompt and no `restore()` round-trip (unlike {@link restore}/{@link entitlements} on
94
- * StoreKit 1). For an in-place update of the SAME bundle id the receipt already holds the
95
- * user's active/grandfathered subscriptions, so this is the zero-user-action path for
96
- * migrating existing subscribers into a new build.
97
- *
98
- * Native iOS only, and only meaningful with a **server validator** (the bare receipt has no
99
- * product id — a client-trusted validator can't interpret it). Resolves `[]` on the web,
100
- * on Android (no StoreKit-1 receipt), or when no receipt is present (e.g. a dev build).
101
- *
102
- * The `[]` is AMBIGUOUS by design-history: it means both "no entitlements" and "never even
103
- * asked". Use {@link receiptEntitlements} to tell those apart.
104
- */
105
- async entitlementsFromReceipt(): Promise<Entitlement[]> {
106
- return (await this.receiptEntitlements()).entitlements;
107
- }
108
-
109
- /**
110
- * {@link entitlementsFromReceipt} plus **why** — the same check, but the three
111
- * didn't-even-ask paths are reported instead of being flattened into `[]`. See
112
- * {@link ReceiptOutcome}: only `'validated'` makes an empty list a real answer.
113
- *
114
- * A validator that errors (network, Apple status 21004, …) REJECTS rather than
115
- * reporting an outcome — a failed check is not a verdict about the user.
116
- */
117
- async receiptEntitlements(): Promise<ReceiptEntitlements> {
118
- await this.kit.ready();
119
- if (this.capability !== 'native') return { entitlements: [], outcome: 'not-native' }; // web, or a shell with no native store handler (Android IS native now, but has no on-disk receipt → yields 'no-receipt' below)
120
- if (this.validator instanceof ClientTrustedValidator) return { entitlements: [], outcome: 'no-validator' }; // bare receipt has no product id → would grant a bogus active entitlement; needs a server validator
121
- const receipt = await this.kit.invoke<PurchaseReceipt>('billing.appReceipt');
122
- if (!receipt?.appReceipt) return { entitlements: [], outcome: 'no-receipt' };
123
- return { entitlements: await this.validator.validate(receipt), outcome: 'validated' }; // server-of-record turns the receipt into entitlements
124
- }
125
-
126
- /** Current entitlements from the configured server-of-record (web provider or validator). */
127
- async entitlements(): Promise<Entitlement[]> {
128
- await this.kit.ready();
129
- if (this.kit.is.web) return this.webProviderOrThrow().entitlements();
130
- return this.validator.entitlements();
131
- }
132
-
133
- /** Subscription management — OS surface (App Store / Play) or the provider's portal (Stripe). */
134
- async manageSubscriptions(): Promise<void> {
135
- await this.kit.ready();
136
- if (this.kit.is.web) {
137
- const p = this.webProviderOrThrow();
138
- if (!p.manageSubscriptions) throw new KitError('UNSUPPORTED', 'This web billing provider has no subscription-management portal');
139
- return p.manageSubscriptions();
140
- }
141
- return this.kit.invoke('billing.manageSubscriptions');
142
- }
143
-
144
- /**
145
- * The in-app / native subscription-management surface. On iOS 15+ this presents StoreKit 2's
146
- * **in-app** sheet (`AppStore.showManageSubscriptions(in:)`) — unlike {@link manageSubscriptions}
147
- * (App Store account page, StoreKit 1), it stays inside the app AND shows sandbox / TestFlight
148
- * subscriptions, so it's the path to verify/cancel a sub during testing. On Android (native billing
149
- * enabled) there is no in-app sheet, so this dispatches to the same Play subscriptions deep link as
150
- * {@link manageSubscriptions} (the native handler maps both). Opt-in on iOS: the default
151
- * `manageSubscriptions()` deep-link is unchanged; call this explicitly to use the sheet.
152
- *
153
- * Throws `UNSUPPORTED` only on the web (no native store), and `NATIVE_ERROR` on iOS <15 / no
154
- * presentable scene / a StoreKit error. Resolves once the user dismisses the sheet (iOS) or the
155
- * deep link is opened (Android).
156
- */
157
- async showManageSubscriptionsSheet(): Promise<void> {
158
- await this.kit.ready();
159
- if (this.capability !== 'native') {
160
- throw new KitError('UNSUPPORTED', 'The subscriptions sheet needs a native store. On the web, use manageSubscriptions() (your web billing provider portal).');
161
- }
162
- // Dismiss-bound: resolves only when the USER closes the sheet (no meaningful deadline).
163
- // A watchdog could only false-timeout mid-sheet → spurious fallback, so disable it.
164
- return this.kit.invoke('billing.manageSubscriptionsSheet', undefined, { timeoutMs: 'none' });
165
- }
166
-
167
- /** Out-of-band transactions (renewals, Ask-to-Buy, cross-device). Native streams only. */
168
- onTransaction(cb: (receipt: PurchaseReceipt) => void): Unsubscribe {
169
- return this.kit.on('billing.transaction', (p) => cb(p as PurchaseReceipt));
170
- }
171
-
172
- /** Raw device entitlements — StoreKit `currentEntitlements` / Play `queryPurchases`. */
173
- private nativeEntitlements(): Promise<Entitlement[]> {
174
- return this.kit.invoke('billing.entitlements');
175
- }
176
- }
@@ -1,28 +0,0 @@
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<unknown> {
18
- const f = opts.fetch ?? globalThis.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
- }
@@ -1,72 +0,0 @@
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: 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) ?? []);
20
- }
21
-
22
- /**
23
- * Web checkout via your backend — the symmetric counterpart to the native store.
24
- * `purchase()` POSTs to `/checkout`; if the backend returns a hosted-checkout `url`
25
- * (Stripe Checkout Session, Paddle, LemonSqueezy…), it redirects and entitlements
26
- * surface on the redirect back; if the backend returns `entitlements` inline, it
27
- * resolves immediately. One config covers any "backend mints a checkout URL" provider.
28
- * HTTP over SDK by design — no Stripe.js / vendor packages in the kit.
29
- */
30
- export class HttpBillingProvider implements BillingProvider {
31
- public options: HttpBillingProviderOptions;
32
- constructor(options?: Partial<HttpBillingProviderOptions>) {
33
- this.options = { ...new HttpBillingProviderOptions(), ...options };
34
- if (!this.options.baseUrl) throw new Error('HttpBillingProvider: baseUrl is required');
35
- }
36
-
37
- async products(ids: string[]): Promise<Product[]> {
38
- const url = `${this.base()}/products?ids=${encodeURIComponent(ids.join(','))}`;
39
- return this.options.mapProducts(await httpJson({ ...this.req(), url, method: 'GET' }));
40
- }
41
-
42
- async purchase(productId: string): Promise<Entitlement[]> {
43
- const json = await httpJson({ ...this.req(), url: `${this.base()}/checkout`, method: 'POST', body: { productId } });
44
- const url = (json as { url?: unknown } | null)?.url;
45
- if (url) {
46
- this.options.redirect(String(url)); // hosted checkout — page navigates away
47
- return []; // entitlements arrive via entitlements() after redirect-back
48
- }
49
- return this.options.mapEntitlements(json); // backend completed inline
50
- }
51
-
52
- async entitlements(): Promise<Entitlement[]> {
53
- return this.options.mapEntitlements(await httpJson({ ...this.req(), url: `${this.base()}/entitlements`, method: 'GET' }));
54
- }
55
-
56
- restore(): Promise<Entitlement[]> {
57
- return this.entitlements(); // web has no store "restore" — re-read the server-of-record
58
- }
59
-
60
- async manageSubscriptions(): Promise<void> {
61
- const json = await httpJson({ ...this.req(), url: `${this.base()}/portal`, method: 'POST', body: {} });
62
- const url = (json as { url?: unknown } | null)?.url;
63
- if (url) this.options.redirect(String(url)); // Stripe Billing Portal, etc.
64
- }
65
-
66
- private base() {
67
- return this.options.baseUrl.replace(/\/$/, '');
68
- }
69
- private req() {
70
- return { headers: this.options.headers, fetch: this.options.fetch };
71
- }
72
- }
@@ -1,135 +0,0 @@
1
- /** Billing / IAP contract — platform-neutral shapes shared by the module,
2
- * the native handlers, and any pluggable validator (RevenueCat / IAPHUB / custom). */
3
-
4
- export type ProductType =
5
- | 'consumable'
6
- | 'nonConsumable'
7
- | 'autoRenewable'
8
- | 'nonRenewable'
9
- | 'unknown';
10
-
11
- /** An introductory / free-trial phase that precedes the standard subscription price. */
12
- export interface IntroOffer {
13
- /** Price during the intro phase in `currency`; 0 for a free trial. */
14
- price: number;
15
- /** Localized formatted intro price, e.g. "Free" / "$0.99" — show this to users. */
16
- displayPrice: string;
17
- /** ISO-8601 duration of the intro phase, e.g. "P1W", "P7D". */
18
- period?: string;
19
- /** True when `price === 0` (a free trial). */
20
- isFreeTrial: boolean;
21
- }
22
-
23
- /** A purchasable product as the store describes it (localized). */
24
- export interface Product {
25
- id: string;
26
- title: string;
27
- description: string;
28
- /** Numeric price in `currency`, for math/sorting. */
29
- price: number;
30
- /** Localized formatted price, e.g. "$4.99" — show this to users. */
31
- displayPrice: string;
32
- currency: string;
33
- type: ProductType;
34
- /** ISO-8601 duration for auto-renewables, e.g. "P1M", "P1Y". */
35
- subscriptionPeriod?: string;
36
- /**
37
- * Introductory / free-trial offer the CURRENT user is eligible for, when the store reports one.
38
- * Populated on Android (Play Billing SubscriptionOfferDetails' zero-price phase). Not yet populated
39
- * on iOS — StoreKit 1's `introductoryPrice` is unread by the current iOS mapper (parity follow-up);
40
- * the trial still APPLIES at checkout because StoreKit/Play show trial terms in their own sheet.
41
- */
42
- introOffer?: IntroOffer;
43
- }
44
-
45
- /** A normalized entitlement — "this user owns/subscribes to X". */
46
- export interface Entitlement {
47
- productId: string;
48
- active: boolean;
49
- /** Epoch ms; subscriptions only. */
50
- expiresAt?: number;
51
- /** Whether an auto-renewable will renew at period end. */
52
- willRenew?: boolean;
53
- /** Epoch ms of the original purchase. */
54
- purchasedAt?: number;
55
- }
56
-
57
- /** The raw proof of a purchase, handed to the validator. Platform-specific fields
58
- * are optional so one shape serves both stores AND a web checkout. */
59
- export interface PurchaseReceipt {
60
- platform: 'ios' | 'android' | 'web';
61
- productId: string;
62
- transactionId?: string;
63
- /** StoreKit 2 signed transaction (JWS) — verify against Apple's root certs. */
64
- jws?: string;
65
- /** Base64 StoreKit 1 app receipt — verify via App Store Server API / verifyReceipt. */
66
- appReceipt?: string;
67
- /** Google Play Billing purchase token — verify via Play Developer API. */
68
- purchaseToken?: string;
69
- /** Web checkout reference (e.g. Stripe Checkout Session / subscription id). */
70
- providerRef?: string;
71
- /** The untouched native/provider payload, for validators that want everything. */
72
- raw: unknown;
73
- }
74
-
75
- export interface PurchaseResult {
76
- receipt: PurchaseReceipt;
77
- entitlements: Entitlement[];
78
- }
79
-
80
- /**
81
- * Why a receipt check produced what it did — the four outcomes that `Entitlement[]`
82
- * alone collapses into an indistinguishable `[]`.
83
- *
84
- * - `validated` the server-of-record read the receipt; `entitlements` is its verdict
85
- * (still `[]` when the user genuinely owns nothing — the only outcome
86
- * where an empty list is an ANSWER rather than a non-answer).
87
- * - `not-native` web, or a shell with no native store handler. (Android now HAS a native billing
88
- * handler, so it reports `native`/`no-receipt` here — not `not-native`; it just has
89
- * no StoreKit-1-style on-disk app receipt, so `receiptEntitlements` yields `no-receipt`.)
90
- * - `no-validator` only a {@link ClientTrustedValidator} is wired; a bare app receipt carries no
91
- * product id, so trusting it would grant a bogus entitlement. Wire a server validator.
92
- * - `no-receipt` this build has no App Store receipt. StoreKit only issues one after a purchase or
93
- * restore, so locally-installed dev builds legitimately have none.
94
- */
95
- export type ReceiptOutcome = 'validated' | 'not-native' | 'no-validator' | 'no-receipt';
96
-
97
- /** The outcome-carrying result of {@link BillingModule.receiptEntitlements}. */
98
- export interface ReceiptEntitlements {
99
- entitlements: Entitlement[];
100
- outcome: ReceiptOutcome;
101
- }
102
-
103
- /**
104
- * The swappable seam. The kit owns the on-device purchase flow; *who decides the
105
- * user is actually entitled* is up to the app. Implement this (or configure the
106
- * bundled `HttpValidator`) to point at RevenueCat, IAPHUB, or your own backend.
107
- */
108
- export interface BillingValidator {
109
- /** Validate a fresh purchase/restore receipt → the entitlements it grants. */
110
- validate(receipt: PurchaseReceipt): Promise<Entitlement[]>;
111
- /** Current entitlements from the server-of-record (or the device, for client-trusted). */
112
- entitlements(): Promise<Entitlement[]>;
113
- }
114
-
115
- /**
116
- * The *purchase mechanism* on web — the symmetric counterpart to the native store.
117
- * On mobile the kit drives StoreKit/Play directly; on web there is no native store,
118
- * so the app plugs in a provider (Stripe / Paddle / LemonSqueezy / custom backend).
119
- * The same `kit.billing.*` calls dispatch here when running on the web. Implement this,
120
- * or configure the bundled `HttpBillingProvider` (backend-driven hosted checkout).
121
- */
122
- export interface BillingProvider {
123
- /** Catalog with localized prices (typically from your backend / Stripe prices). */
124
- products(ids: string[]): Promise<Product[]>;
125
- /** Start checkout for a product. Resolves to the granted entitlements when known
126
- * synchronously; for redirect-based checkout (Stripe hosted page) it navigates away
127
- * and the entitlements surface via `entitlements()` after the redirect back. */
128
- purchase(productId: string): Promise<Entitlement[]>;
129
- /** Current entitlements from the server-of-record (Stripe webhooks → your backend). */
130
- entitlements(): Promise<Entitlement[]>;
131
- /** Re-sync entitlements (web has no "restore" — defaults to `entitlements()`). */
132
- restore?(): Promise<Entitlement[]>;
133
- /** Open the billing/subscription-management surface (e.g. Stripe Billing Portal). */
134
- manageSubscriptions?(): Promise<void>;
135
- }
@@ -1,67 +0,0 @@
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: unknown) => Entitlement[] = (j) =>
36
- ((j as { entitlements?: Entitlement[] } | null)?.entitlements ?? []);
37
- /** Injected for tests / non-DOM runtimes. Defaults to global `fetch`. */
38
- fetch?: typeof fetch;
39
- }
40
-
41
- /**
42
- * Generic server-validated strategy. One implementation covers RevenueCat,
43
- * IAPHUB, or any custom backend — they differ only by URL, auth header, and a small
44
- * response mapper. HTTP over SDK by design (no vendor packages).
45
- */
46
- export class HttpValidator implements BillingValidator {
47
- public options: HttpValidatorOptions;
48
- constructor(options?: Partial<HttpValidatorOptions>) {
49
- this.options = { ...new HttpValidatorOptions(), ...options };
50
- if (!this.options.validateUrl) throw new Error('HttpValidator: validateUrl is required');
51
- }
52
-
53
- async validate(receipt: PurchaseReceipt): Promise<Entitlement[]> {
54
- const json = await httpJson({ ...this.req(), url: this.options.validateUrl, method: 'POST', body: receipt });
55
- return this.options.mapResponse(json);
56
- }
57
-
58
- async entitlements(): Promise<Entitlement[]> {
59
- const url = this.options.entitlementsUrl ?? this.options.validateUrl;
60
- const method = this.options.entitlementsUrl ? 'GET' : 'POST';
61
- return this.options.mapResponse(await httpJson({ ...this.req(), url, method }));
62
- }
63
-
64
- private req() {
65
- return { headers: this.options.headers, fetch: this.options.fetch };
66
- }
67
- }
@@ -1,70 +0,0 @@
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
- }
@@ -1,65 +0,0 @@
1
- import type { NativeKit } from '../core/NativeKit';
2
-
3
- /** One tile/row in an icon-grid or list widget. `deepLink` is the full URL the tile opens (routed by
4
- * the app's deep-link handling); `iconUrl` is fetched to a tile image on the device. */
5
- export interface WidgetEntry {
6
- label: string;
7
- sublabel?: string;
8
- deepLink?: string;
9
- iconUrl?: string;
10
- /** unread/attention count drawn as a bubble on the tile (icon-grid); absent/0 = no badge. */
11
- badge?: number;
12
- }
13
-
14
- /** A single big number for the `stat` template. */
15
- export interface WidgetStat {
16
- value: string;
17
- label?: string;
18
- sublabel?: string;
19
- }
20
-
21
- /** Payload published to the home-screen widget. `template` picks the layout:
22
- * - `icon-grid` — grid of {@link WidgetEntry} tiles (the app-launcher use)
23
- * - `list` — rows of {@link WidgetEntry}
24
- * - `stat` — one {@link WidgetStat} (optional whole-widget `deepLink`) */
25
- export interface WidgetPayload {
26
- template: 'icon-grid' | 'list' | 'stat';
27
- title?: string;
28
- entries?: WidgetEntry[];
29
- stat?: WidgetStat;
30
- /** Whole-widget deep link (stat template, or the empty-state fallback). */
31
- deepLink?: string;
32
- /** Which home-screen widget kind this targets. `launcher` (default) = the app-launcher/icon-grid
33
- * widget; `data` = the separate data widget an in-app view can drive. Two kinds so a data publish
34
- * never clobbers the launcher (each renders from its own shared-store key). */
35
- slot?: 'launcher' | 'data';
36
- }
37
-
38
- export interface WidgetPublishResult {
39
- published: boolean;
40
- reason?: 'unsupported';
41
- }
42
-
43
- /**
44
- * Publish content to the app's home-screen widget (iOS WidgetKit / Android AppWidget). The shell writes
45
- * the payload into a shared container the widget reads at render, fetching any `iconUrl` to a local
46
- * tile image, then reloads the widget. Web/unsupported builds resolve `{published:false}`. Branch on
47
- * {@link WidgetModule.capability}.
48
- */
49
- export class WidgetModule {
50
- constructor(private kit: NativeKit) {}
51
-
52
- /** 'native' where a home-screen widget surface exists (iOS 14+ / Android AppWidget) · else 'none'. */
53
- get capability() {
54
- return this.kit.capability('widget');
55
- }
56
-
57
- publish(payload: WidgetPayload): Promise<WidgetPublishResult> {
58
- return this.kit.invoke('widget.publish', payload);
59
- }
60
-
61
- /** Clear the widget (renders its empty state). */
62
- clear(): Promise<WidgetPublishResult> {
63
- return this.kit.invoke('widget.publish', { template: 'icon-grid', entries: [] });
64
- }
65
- }