@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 +2 -2
- package/src/core/NativeKit.ts +87 -23
- package/src/core/module-registry.ts +76 -0
- package/src/index.ts +2 -16
- package/src/modules/billing/billing.ts +0 -176
- package/src/modules/billing/http.ts +0 -28
- package/src/modules/billing/providers.ts +0 -72
- package/src/modules/billing/types.ts +0 -135
- package/src/modules/billing/validators.ts +0 -67
- package/src/modules/health.ts +0 -70
- package/src/modules/widget.ts +0 -65
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livx.cc/native-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Isomorphic native-capabilities kit for PWAs
|
|
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",
|
package/src/core/NativeKit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
!
|
|
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
|
-
}
|
package/src/modules/health.ts
DELETED
|
@@ -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
|
-
}
|
package/src/modules/widget.ts
DELETED
|
@@ -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
|
-
}
|