@livx.cc/native-kit 0.40.0 → 0.42.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 +92 -7
- package/src/core/module-registry.ts +64 -0
- package/src/index.ts +2 -16
- package/src/modules/billing/billing.ts +0 -173
- package/src/modules/billing/http.ts +0 -28
- package/src/modules/billing/providers.ts +0 -72
- package/src/modules/billing/types.ts +0 -114
- 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.42.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. */
|
|
@@ -96,7 +95,6 @@ export class NativeKit {
|
|
|
96
95
|
public readonly lifecycle = new LifecycleModule(this);
|
|
97
96
|
public readonly reviews = new ReviewsModule(this);
|
|
98
97
|
public readonly motion = new MotionModule(this);
|
|
99
|
-
public readonly health = new HealthModule(this);
|
|
100
98
|
public readonly media = new MediaModule(this);
|
|
101
99
|
public readonly contacts = new ContactsModule(this);
|
|
102
100
|
public readonly scanner = new ScannerModule(this);
|
|
@@ -105,12 +103,18 @@ export class NativeKit {
|
|
|
105
103
|
public readonly app = new AppModule(this);
|
|
106
104
|
public readonly browser = new BrowserModule(this);
|
|
107
105
|
public readonly oauth = new OAuthModule(this);
|
|
108
|
-
public readonly billing = new BillingModule(this);
|
|
109
106
|
public readonly updates = new UpdatesModule(this);
|
|
110
107
|
public readonly backgroundTask = new BackgroundTaskModule(this);
|
|
111
108
|
public readonly tracking = new TrackingModule(this);
|
|
112
109
|
public readonly appleSignIn = new AppleSignInModule(this);
|
|
113
|
-
|
|
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();
|
|
114
118
|
|
|
115
119
|
public handshakeInfo: Handshake | null = null;
|
|
116
120
|
public options: NativeKitOptions;
|
|
@@ -121,11 +125,48 @@ export class NativeKit {
|
|
|
121
125
|
* for it. Capability keys the shell reports as 'none'/absent surface as 'web' when the
|
|
122
126
|
* fallback can fulfil them. */
|
|
123
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>();
|
|
124
136
|
private readyPromise: Promise<Handshake> | null = null;
|
|
125
137
|
private contextPromise: Promise<KitContext> | null = null;
|
|
126
138
|
|
|
127
139
|
constructor(options?: Partial<NativeKitOptions>) {
|
|
128
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);
|
|
129
170
|
}
|
|
130
171
|
|
|
131
172
|
/** Resolve the environment and perform the handshake. Idempotent. */
|
|
@@ -265,7 +306,12 @@ export class NativeKit {
|
|
|
265
306
|
try {
|
|
266
307
|
return await this.adapter!.invoke<T>(method, params, opts);
|
|
267
308
|
} catch (e) {
|
|
268
|
-
if (
|
|
309
|
+
if (
|
|
310
|
+
this.webFallback &&
|
|
311
|
+
e instanceof KitError &&
|
|
312
|
+
e.code === 'UNSUPPORTED' &&
|
|
313
|
+
![...this.webFallbackExcludedDomains].some((d) => method.startsWith(d))
|
|
314
|
+
) {
|
|
269
315
|
return this.webFallback.invoke<T>(method, params, opts);
|
|
270
316
|
}
|
|
271
317
|
throw e;
|
|
@@ -296,5 +342,44 @@ export class NativeKit {
|
|
|
296
342
|
}
|
|
297
343
|
}
|
|
298
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
|
+
|
|
299
384
|
/** Shared default instance — `import { kit } from '@livx.cc/native-kit'`. */
|
|
300
385
|
export const kit = new NativeKit();
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
getModule<K extends keyof KitModuleRegistry>(name: K): KitModuleRegistry[K];
|
|
47
|
+
getModule<T = unknown>(name: string): T;
|
|
48
|
+
getModule(name: string): unknown {
|
|
49
|
+
if (this.instances.has(name)) return this.instances.get(name);
|
|
50
|
+
const factory = this.factories.get(name);
|
|
51
|
+
if (factory) {
|
|
52
|
+
const inst = factory(); // memoize once, then drop the factory
|
|
53
|
+
this.instances.set(name, inst);
|
|
54
|
+
this.factories.delete(name);
|
|
55
|
+
return inst;
|
|
56
|
+
}
|
|
57
|
+
// Fail LOUD: a missing module must throw at the call site, never queue/return undefined.
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[native-kit] module '${name}' is not registered. ` +
|
|
60
|
+
`Mounted modules: [${this.moduleNames().join(', ')}]. ` +
|
|
61
|
+
`Did you list its pack in modulePacks?`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
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,173 +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 today)
|
|
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
|
-
* iOS 15+ native-only: present StoreKit 2's **in-app** subscription-management sheet
|
|
146
|
-
* (`AppStore.showManageSubscriptions(in:)`). Unlike {@link manageSubscriptions} (which deep-links
|
|
147
|
-
* out to the App Store account page, StoreKit 1), this stays inside the app AND shows sandbox /
|
|
148
|
-
* TestFlight subscriptions — so it's the path to verify/cancel a sub during testing. Opt-in: the
|
|
149
|
-
* default `manageSubscriptions()` deep-link is unchanged; call this explicitly to use the sheet.
|
|
150
|
-
*
|
|
151
|
-
* Throws `UNSUPPORTED` on the web or Android (no StoreKit), and `NATIVE_ERROR` on iOS <15 / no
|
|
152
|
-
* presentable scene / a StoreKit error. Resolves once the user dismisses the sheet.
|
|
153
|
-
*/
|
|
154
|
-
async showManageSubscriptionsSheet(): Promise<void> {
|
|
155
|
-
await this.kit.ready();
|
|
156
|
-
if (this.capability !== 'native') {
|
|
157
|
-
throw new KitError('UNSUPPORTED', 'The in-app subscriptions sheet is iOS-only (StoreKit 2). Use manageSubscriptions() on web/Android.');
|
|
158
|
-
}
|
|
159
|
-
// Dismiss-bound: resolves only when the USER closes the sheet (no meaningful deadline).
|
|
160
|
-
// A watchdog could only false-timeout mid-sheet → spurious fallback, so disable it.
|
|
161
|
-
return this.kit.invoke('billing.manageSubscriptionsSheet', undefined, { timeoutMs: 'none' });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Out-of-band transactions (renewals, Ask-to-Buy, cross-device). Native streams only. */
|
|
165
|
-
onTransaction(cb: (receipt: PurchaseReceipt) => void): Unsubscribe {
|
|
166
|
-
return this.kit.on('billing.transaction', (p) => cb(p as PurchaseReceipt));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Raw device entitlements — StoreKit `currentEntitlements` / Play `queryPurchases`. */
|
|
170
|
-
private nativeEntitlements(): Promise<Entitlement[]> {
|
|
171
|
-
return this.kit.invoke('billing.entitlements');
|
|
172
|
-
}
|
|
173
|
-
}
|
|
@@ -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,114 +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
|
-
/** A purchasable product as the store describes it (localized). */
|
|
12
|
-
export interface Product {
|
|
13
|
-
id: string;
|
|
14
|
-
title: string;
|
|
15
|
-
description: string;
|
|
16
|
-
/** Numeric price in `currency`, for math/sorting. */
|
|
17
|
-
price: number;
|
|
18
|
-
/** Localized formatted price, e.g. "$4.99" — show this to users. */
|
|
19
|
-
displayPrice: string;
|
|
20
|
-
currency: string;
|
|
21
|
-
type: ProductType;
|
|
22
|
-
/** ISO-8601 duration for auto-renewables, e.g. "P1M", "P1Y". */
|
|
23
|
-
subscriptionPeriod?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A normalized entitlement — "this user owns/subscribes to X". */
|
|
27
|
-
export interface Entitlement {
|
|
28
|
-
productId: string;
|
|
29
|
-
active: boolean;
|
|
30
|
-
/** Epoch ms; subscriptions only. */
|
|
31
|
-
expiresAt?: number;
|
|
32
|
-
/** Whether an auto-renewable will renew at period end. */
|
|
33
|
-
willRenew?: boolean;
|
|
34
|
-
/** Epoch ms of the original purchase. */
|
|
35
|
-
purchasedAt?: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** The raw proof of a purchase, handed to the validator. Platform-specific fields
|
|
39
|
-
* are optional so one shape serves both stores AND a web checkout. */
|
|
40
|
-
export interface PurchaseReceipt {
|
|
41
|
-
platform: 'ios' | 'android' | 'web';
|
|
42
|
-
productId: string;
|
|
43
|
-
transactionId?: string;
|
|
44
|
-
/** StoreKit 2 signed transaction (JWS) — verify against Apple's root certs. */
|
|
45
|
-
jws?: string;
|
|
46
|
-
/** Base64 StoreKit 1 app receipt — verify via App Store Server API / verifyReceipt. */
|
|
47
|
-
appReceipt?: string;
|
|
48
|
-
/** Google Play Billing purchase token — verify via Play Developer API. */
|
|
49
|
-
purchaseToken?: string;
|
|
50
|
-
/** Web checkout reference (e.g. Stripe Checkout Session / subscription id). */
|
|
51
|
-
providerRef?: string;
|
|
52
|
-
/** The untouched native/provider payload, for validators that want everything. */
|
|
53
|
-
raw: unknown;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface PurchaseResult {
|
|
57
|
-
receipt: PurchaseReceipt;
|
|
58
|
-
entitlements: Entitlement[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Why a receipt check produced what it did — the four outcomes that `Entitlement[]`
|
|
63
|
-
* alone collapses into an indistinguishable `[]`.
|
|
64
|
-
*
|
|
65
|
-
* - `validated` the server-of-record read the receipt; `entitlements` is its verdict
|
|
66
|
-
* (still `[]` when the user genuinely owns nothing — the only outcome
|
|
67
|
-
* where an empty list is an ANSWER rather than a non-answer).
|
|
68
|
-
* - `not-native` web, or a shell with no native store handler (Android: no StoreKit-1 receipt).
|
|
69
|
-
* - `no-validator` only a {@link ClientTrustedValidator} is wired; a bare app receipt carries no
|
|
70
|
-
* product id, so trusting it would grant a bogus entitlement. Wire a server validator.
|
|
71
|
-
* - `no-receipt` this build has no App Store receipt. StoreKit only issues one after a purchase or
|
|
72
|
-
* restore, so locally-installed dev builds legitimately have none.
|
|
73
|
-
*/
|
|
74
|
-
export type ReceiptOutcome = 'validated' | 'not-native' | 'no-validator' | 'no-receipt';
|
|
75
|
-
|
|
76
|
-
/** The outcome-carrying result of {@link BillingModule.receiptEntitlements}. */
|
|
77
|
-
export interface ReceiptEntitlements {
|
|
78
|
-
entitlements: Entitlement[];
|
|
79
|
-
outcome: ReceiptOutcome;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The swappable seam. The kit owns the on-device purchase flow; *who decides the
|
|
84
|
-
* user is actually entitled* is up to the app. Implement this (or configure the
|
|
85
|
-
* bundled `HttpValidator`) to point at RevenueCat, IAPHUB, or your own backend.
|
|
86
|
-
*/
|
|
87
|
-
export interface BillingValidator {
|
|
88
|
-
/** Validate a fresh purchase/restore receipt → the entitlements it grants. */
|
|
89
|
-
validate(receipt: PurchaseReceipt): Promise<Entitlement[]>;
|
|
90
|
-
/** Current entitlements from the server-of-record (or the device, for client-trusted). */
|
|
91
|
-
entitlements(): Promise<Entitlement[]>;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The *purchase mechanism* on web — the symmetric counterpart to the native store.
|
|
96
|
-
* On mobile the kit drives StoreKit/Play directly; on web there is no native store,
|
|
97
|
-
* so the app plugs in a provider (Stripe / Paddle / LemonSqueezy / custom backend).
|
|
98
|
-
* The same `kit.billing.*` calls dispatch here when running on the web. Implement this,
|
|
99
|
-
* or configure the bundled `HttpBillingProvider` (backend-driven hosted checkout).
|
|
100
|
-
*/
|
|
101
|
-
export interface BillingProvider {
|
|
102
|
-
/** Catalog with localized prices (typically from your backend / Stripe prices). */
|
|
103
|
-
products(ids: string[]): Promise<Product[]>;
|
|
104
|
-
/** Start checkout for a product. Resolves to the granted entitlements when known
|
|
105
|
-
* synchronously; for redirect-based checkout (Stripe hosted page) it navigates away
|
|
106
|
-
* and the entitlements surface via `entitlements()` after the redirect back. */
|
|
107
|
-
purchase(productId: string): Promise<Entitlement[]>;
|
|
108
|
-
/** Current entitlements from the server-of-record (Stripe webhooks → your backend). */
|
|
109
|
-
entitlements(): Promise<Entitlement[]>;
|
|
110
|
-
/** Re-sync entitlements (web has no "restore" — defaults to `entitlements()`). */
|
|
111
|
-
restore?(): Promise<Entitlement[]>;
|
|
112
|
-
/** Open the billing/subscription-management surface (e.g. Stripe Billing Portal). */
|
|
113
|
-
manageSubscriptions?(): Promise<void>;
|
|
114
|
-
}
|
|
@@ -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
|
-
}
|