@livx.cc/appwrap 0.26.0 → 0.28.1

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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
  3. package/runtime/app/app.ts +57 -39
  4. package/runtime/app/shell/android-helpers.ts +6 -6
  5. package/runtime/app/shell/background-bootstrap.generated.ts +8 -0
  6. package/runtime/app/shell/background-context.ts +20 -0
  7. package/runtime/app/shell/banner.ts +9 -4
  8. package/runtime/app/shell/bridge.ts +2 -0
  9. package/runtime/app/shell/capabilities.manifest.ts +22 -3
  10. package/runtime/app/shell/custom-webview.android.ts +50 -23
  11. package/runtime/app/shell/custom-webview.ios.ts +90 -78
  12. package/runtime/app/shell/devmenu.ts +2 -2
  13. package/runtime/app/shell/env.ts +1 -3
  14. package/runtime/app/shell/events.ts +25 -4
  15. package/runtime/app/shell/fcm-firebase.d.ts +2 -1
  16. package/runtime/app/shell/handlers-android.ts +16 -12
  17. package/runtime/app/shell/handlers-background.ts +290 -0
  18. package/runtime/app/shell/handlers-billing.ts +90 -82
  19. package/runtime/app/shell/handlers-extended.ts +141 -110
  20. package/runtime/app/shell/handlers-fs.ts +53 -41
  21. package/runtime/app/shell/handlers-health.ts +27 -19
  22. package/runtime/app/shell/handlers-media.ts +8 -14
  23. package/runtime/app/shell/handlers-oauth.ts +19 -19
  24. package/runtime/app/shell/handlers-parity.ts +118 -89
  25. package/runtime/app/shell/handlers-push.ts +5 -15
  26. package/runtime/app/shell/handlers-reviews.ts +5 -5
  27. package/runtime/app/shell/handlers-scanner.ts +36 -38
  28. package/runtime/app/shell/handlers-speech.ts +43 -49
  29. package/runtime/app/shell/handlers-system.ts +122 -1
  30. package/runtime/app/shell/handlers.ts +10 -3
  31. package/runtime/app/shell/ios-frameworks.d.ts +9 -44
  32. package/runtime/app/shell/ios-ui-delegate.ts +112 -108
  33. package/runtime/app/shell/native-log.ts +1 -3
  34. package/runtime/app/shell/status-bar.ts +7 -4
  35. package/runtime/app/shell/toast.ts +1 -1
  36. package/runtime/references.d.ts +21 -0
  37. package/runtime/tsconfig.json +1 -0
  38. package/src/cli.ts +74 -18
  39. package/src/config.ts +20 -0
  40. package/src/derive.ts +75 -0
@@ -0,0 +1,290 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { SHELL_CONFIG } from './config';
4
+ import { setPendingBackgroundTaskId } from './background-context';
5
+ import { CustomWebView } from './custom-webview';
6
+
7
+ // BackgroundTasks (iOS), NSDate, and the `android`/`java` namespaces resolve from the full SDK
8
+ // (@nativescript/types-ios + types-android) — no declares needed.
9
+ declare const androidx: any; // no NS types: androidx (not in types-android)
10
+
11
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
12
+
13
+ /**
14
+ * Headless background execution with a HEADLESS JS HANDLER contract.
15
+ *
16
+ * Bridge methods (called from the app's foreground OR the offscreen background WebView):
17
+ * - `backgroundTask.schedule` → ask the OS to (re)schedule a wake for an id.
18
+ * - `backgroundTask.cancel` → drop a scheduled wake.
19
+ * - `backgroundTask.finish` → the JS handler is done → complete the OS task + reschedule.
20
+ *
21
+ * Headless runner (the OS-wake path): {@link registerBackgroundTaskLaunchHandlers} (iOS, called from
22
+ * the AppDelegate at didFinishLaunching) registers a BGTask launch handler per PERMITTED id. On fire
23
+ * it stamps the wake id ({@link setPendingBackgroundTaskId}), builds an OFFSCREEN WebView (reusing
24
+ * `CustomWebView` → same scheme handler + bridge wiring as the visible one), loads the app so the
25
+ * handshake reports the id, and awaits `backgroundTask.finish`. Android runs the same loop inside a
26
+ * `WorkManager` Worker (see {@link AppwrapBackgroundWorker}).
27
+ *
28
+ * ⚠ DEVICE-UNVERIFIED: the native background-wake path (offscreen WebView under BGTask / WorkManager)
29
+ * COMPILES ONLY — it has NOT been run on a device this session. The any-typed FFI hides selector
30
+ * typos and the cold-launch headless-WebView lifecycle is the genuinely device-gated unknown. The
31
+ * bridge contract (schedule/cancel/finish ⇄ kit) and the dispatch logic are unit-tested in the kit.
32
+ */
33
+ export function registerBackgroundTaskHandlers(): void {
34
+ if (isIOS) registerIos();
35
+ else if (isAndroid) registerAndroid();
36
+ }
37
+
38
+ /** Permitted task ids (iOS BGTaskSchedulerPermittedIdentifiers / the app's declared set). Read from
39
+ * the stamped Info.plist on iOS; on Android any id WorkManager enqueues is valid. */
40
+ function permittedIosIds(): string[] {
41
+ try {
42
+ const arr = NSBundle.mainBundle.objectForInfoDictionaryKey('BGTaskSchedulerPermittedIdentifiers');
43
+ const out: string[] = [];
44
+ const n = arr?.count ?? 0;
45
+ for (let i = 0; i < n; i++) out.push(String(arr.objectAtIndex(i)));
46
+ return out;
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ // ── shared headless-run plumbing ────────────────────────────────────────────
53
+ // One offscreen WebView per in-flight task id, kept alive (ARC/GC + the bridge attach) until the JS
54
+ // handler reports finish. The `finish` handler resolves the matching pending run.
55
+ interface PendingRun { resolve: (success: boolean) => void; webView: CustomWebView | null; }
56
+ const pendingRuns = new Map<string, PendingRun>();
57
+
58
+ /** Build an offscreen WebView, attach the bridge, and load the app so its handshake reports `id`. The
59
+ * returned promise resolves when the JS handler calls `backgroundTask.finish` (or `abort()` fires).
60
+ * REUSES `CustomWebView` (scheme handler + bridge injection) — no duplicated transport. */
61
+ function runHeadless(id: string): Promise<boolean> {
62
+ return new Promise<boolean>((resolve) => {
63
+ setPendingBackgroundTaskId(id); // the next handshake reports this wake id
64
+ const webView = new CustomWebView();
65
+ pendingRuns.set(id, { resolve, webView });
66
+ // Detach the bridge from whatever (visible) WebView it held, attach the offscreen one so this run's
67
+ // invokes (incl. backgroundTask.finish) route here. A cold background launch has no visible view.
68
+ bridge.attach(webView);
69
+ Utils.dispatchToMainThread(() => {
70
+ try {
71
+ loadAppInto(webView, id);
72
+ } catch (e) {
73
+ finishRun(id, false); // load failed → report failure, let the OS reschedule
74
+ }
75
+ });
76
+ });
77
+ }
78
+
79
+ // An offscreen WebView's native peer (`.ios`/`.android`) is created lazily; off the visual tree it may
80
+ // never materialize (the device-gated cold-launch risk). Bound the poll so we can't spin forever —
81
+ // ~2s (40 × 50ms), then give up and let the OS reschedule rather than leak a timer / drain battery.
82
+ const LOAD_PEER_MAX_ATTEMPTS = 40;
83
+
84
+ /** Point an (offscreen) WebView at the app entry — mirrors main-page.loadBundle's loader branches so
85
+ * the same www/app:// / server URL serves the background run. Retries until the native peer exists,
86
+ * bounded by {@link LOAD_PEER_MAX_ATTEMPTS} → finish(false) on exhaustion. */
87
+ function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
88
+ const retry = (peerMissing: boolean): boolean => {
89
+ if (!peerMissing) return false;
90
+ if (attempt >= LOAD_PEER_MAX_ATTEMPTS) { finishRun(id, false); return true; }
91
+ setTimeout(() => loadAppInto(webView, id, attempt + 1), 50);
92
+ return true;
93
+ };
94
+ if (isIOS) {
95
+ const wk = webView.ios as WKWebView;
96
+ if (retry(!wk)) return;
97
+ if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
98
+ wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(SHELL_CONFIG.serverUrl)));
99
+ } else {
100
+ wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://local/${SHELL_CONFIG.entry}`)));
101
+ }
102
+ } else {
103
+ if (retry(!webView.android)) return;
104
+ webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
105
+ ? SHELL_CONFIG.serverUrl
106
+ : `https://appwrap.local/${SHELL_CONFIG.entry}`;
107
+ }
108
+ }
109
+
110
+ /** Resolve an in-flight headless run (called by `backgroundTask.finish`, the safety abort, or a load
111
+ * failure). Tears the offscreen WebView's bridge attachment down. Idempotent. */
112
+ function finishRun(id: string, success: boolean): void {
113
+ const run = pendingRuns.get(id);
114
+ if (!run) return;
115
+ pendingRuns.delete(id);
116
+ try { if (bridge.getWebView() === run.webView) bridge.detach(); } catch { /* noop */ }
117
+ run.resolve(success);
118
+ }
119
+
120
+ // ── iOS ─────────────────────────────────────────────────────────────────────
121
+ function registerIos(): void {
122
+ const scheduler = () => BGTaskScheduler.sharedScheduler;
123
+
124
+ // schedule: app-refresh by default; processing when it needs network/charging (iOS routes those via
125
+ // BGProcessingTaskRequest, which exposes requiresNetworkConnectivity / requiresExternalPower).
126
+ bridge.register('backgroundTask.schedule', (p: any) => {
127
+ const id = String(p?.id ?? '');
128
+ if (!id) throw err('NATIVE_ERROR', 'backgroundTask.schedule: missing id');
129
+ submitIosRequest(id, p);
130
+ });
131
+
132
+ bridge.register('backgroundTask.cancel', (p: any) => {
133
+ const id = String(p?.id ?? '');
134
+ if (id) scheduler().cancelTaskRequestWithIdentifier(id);
135
+ });
136
+
137
+ // finish: the JS handler is done → resolve the headless run, which (in the launch handler) completes
138
+ // the BGTask + resubmits the next request. Also reschedule here so a finish from the FOREGROUND
139
+ // (manual handler run) keeps the OS wake alive.
140
+ bridge.register('backgroundTask.finish', (p: any) => {
141
+ const id = String(p?.id ?? '');
142
+ const success = p?.success !== false;
143
+ if (id) finishRun(id, success);
144
+ });
145
+ }
146
+
147
+ /** Submit a BGTaskScheduler request for `id`. Processing request when network/charging constrained,
148
+ * else a lighter app-refresh request. `minIntervalMs` → earliestBeginDate floor. */
149
+ function submitIosRequest(id: string, p: any): void {
150
+ const needsProcessing = !!p?.requiresNetwork || !!p?.requiresCharging;
151
+ let req: BGProcessingTaskRequest | BGAppRefreshTaskRequest;
152
+ if (needsProcessing) {
153
+ const proc = BGProcessingTaskRequest.alloc().initWithIdentifier(id);
154
+ proc.requiresNetworkConnectivity = !!p?.requiresNetwork;
155
+ proc.requiresExternalPower = !!p?.requiresCharging;
156
+ req = proc;
157
+ } else {
158
+ req = BGAppRefreshTaskRequest.alloc().initWithIdentifier(id);
159
+ }
160
+ if (p?.minIntervalMs) {
161
+ // earliestBeginDate is a JS Date (NS marshals it to NSDate) — `now + interval`.
162
+ req.earliestBeginDate = new Date(Date.now() + Number(p.minIntervalMs));
163
+ }
164
+ // submitTaskRequestError throws via the out-error; NS surfaces it as a thrown JS error.
165
+ // (out-error param omitted — NS marshals the ObjC error into a thrown JS exception.)
166
+ BGTaskScheduler.sharedScheduler.submitTaskRequestError(req);
167
+ }
168
+
169
+ /**
170
+ * Register a BGTask LAUNCH handler per permitted id — MUST run at `applicationDidFinishLaunchingWith
171
+ * Options` (Apple's rule), so the AppDelegate calls this (see the generated background bootstrap). On
172
+ * fire: stamp the wake id, set the task's expirationHandler to abort the run, run the headless WebView
173
+ * loop, then `setTaskCompleted(success:)` + resubmit the next request. @internal (called from app.ts).
174
+ */
175
+ export function registerBackgroundTaskLaunchHandlers(): void {
176
+ if (!isIOS) return;
177
+ // This runs INSIDE applicationDidFinishLaunchingWithOptions, so ANY throw here crashes app launch.
178
+ // Guard hard: a background-task convenience must NEVER take down boot. If BGTaskScheduler is
179
+ // unavailable (framework not resolved) or a registration throws (Apple's strict rules), log and
180
+ // continue WITHOUT background wakes rather than crash. Each id is isolated so one bad id can't stop
181
+ // the rest. (`typeof` is a safe undefined-check — no ReferenceError on an absent native global.)
182
+ if (typeof BGTaskScheduler === 'undefined' || !BGTaskScheduler) {
183
+ console.log('[backgroundTask] BGTaskScheduler unavailable — skipping launch-handler registration');
184
+ return;
185
+ }
186
+ for (const id of permittedIosIds()) {
187
+ try {
188
+ // The launch handler runs on the queue we pass (null = a default background queue). It receives the
189
+ // BGTask; we drive the JS handler, then complete + reschedule.
190
+ BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifierUsingQueueLaunchHandler(
191
+ id,
192
+ null as any, // interop: pass nil queue → OS default background queue (param is non-optional)
193
+ (task: BGTask) => {
194
+ // The OS budget is nearly spent → abort the run (the kit also self-aborts at ~25s). Reporting
195
+ // failure lets the OS learn the task overran.
196
+ task.expirationHandler = () => finishRun(id, false);
197
+ runHeadless(id)
198
+ .then((success) => {
199
+ try { submitIosRequest(id, {}); } catch { /* a failed resubmit shouldn't crash the wake */ }
200
+ task.setTaskCompletedWithSuccess(success);
201
+ })
202
+ .catch(() => {
203
+ try { submitIosRequest(id, {}); } catch { /* noop */ }
204
+ task.setTaskCompletedWithSuccess(false);
205
+ });
206
+ }
207
+ );
208
+ } catch (e: any) {
209
+ console.log(`[backgroundTask] launch-handler registration failed for '${id}': ${e?.message ?? e}`);
210
+ }
211
+ }
212
+ }
213
+
214
+ // ── Android (WorkManager) ────────────────────────────────────────────────────
215
+ function registerAndroid(): void {
216
+ const ctx = () => Utils.android.getApplicationContext();
217
+ const WM = () => androidx.work.WorkManager.getInstance(ctx());
218
+
219
+ bridge.register('backgroundTask.schedule', (p: any) => {
220
+ const id = String(p?.id ?? '');
221
+ if (!id) throw err('NATIVE_ERROR', 'backgroundTask.schedule: missing id');
222
+ // WorkManager periodic floor is 15 min; clamp a smaller hint up so enqueue doesn't reject it.
223
+ const ms = Math.max(15 * 60_000, Number(p?.minIntervalMs ?? 15 * 60_000));
224
+ const builder = new androidx.work.PeriodicWorkRequest.Builder(
225
+ AppwrapBackgroundWorker.class,
226
+ ms, java.util.concurrent.TimeUnit.MILLISECONDS
227
+ );
228
+ // The id rides as input data → the Worker reads it to drive the matching JS handler.
229
+ builder.setInputData(
230
+ new androidx.work.Data.Builder().putString('appwrap.taskId', id).build()
231
+ );
232
+ const constraints = new androidx.work.Constraints.Builder();
233
+ if (p?.requiresNetwork) constraints.setRequiredNetworkType(androidx.work.NetworkType.CONNECTED);
234
+ if (p?.requiresCharging) constraints.setRequiresCharging(true);
235
+ builder.setConstraints(constraints.build());
236
+ WM().enqueueUniquePeriodicWork(id, androidx.work.ExistingPeriodicWorkPolicy.UPDATE, builder.build());
237
+ });
238
+
239
+ bridge.register('backgroundTask.cancel', (p: any) => {
240
+ const id = String(p?.id ?? '');
241
+ if (id) WM().cancelUniqueWork(id);
242
+ });
243
+
244
+ // finish: resolve the run → the Worker's CountDownLatch releases → doWork returns success/failure.
245
+ // WorkManager reschedules periodic work itself (no resubmit needed, unlike iOS BGTask).
246
+ bridge.register('backgroundTask.finish', (p: any) => {
247
+ const id = String(p?.id ?? '');
248
+ finishRun(id, p?.success !== false);
249
+ });
250
+ }
251
+
252
+ /**
253
+ * WorkManager headless Worker — created by WorkManager on a background launch. `doWork()` posts to the
254
+ * MAIN looper (the WebView must be built + driven on the UI thread), runs the headless WebView loop for
255
+ * the input task id, and blocks the worker thread on a `CountDownLatch` until `backgroundTask.finish`
256
+ * (or a timeout). Returns success/failure; WorkManager handles periodic rescheduling.
257
+ *
258
+ * ⚠ DEVICE-UNVERIFIED — compiles only (see the file header).
259
+ */
260
+ @NativeClass()
261
+ @JavaProxy('cc.livx.appwrap.AppwrapBackgroundWorker')
262
+ export class AppwrapBackgroundWorker extends androidx.work.Worker {
263
+ constructor(context: any, params: any) {
264
+ super(context, params);
265
+ }
266
+
267
+ doWork(): any {
268
+ const id = this.getInputData().getString('appwrap.taskId') ?? '';
269
+ if (!id) return androidx.work.ListenableWorker.Result.failure();
270
+
271
+ const latch = new java.util.concurrent.CountDownLatch(1);
272
+ const result = { success: false };
273
+ Utils.dispatchToMainThread(() => {
274
+ runHeadless(id)
275
+ .then((ok: boolean) => { result.success = ok; latch.countDown(); })
276
+ .catch(() => { latch.countDown(); });
277
+ });
278
+ // Block the worker thread (bounded — below the WorkManager 10-min ceiling) until the JS handler
279
+ // finishes. A timeout returns failure so WorkManager retries on its schedule.
280
+ const completed = latch.await(9, java.util.concurrent.TimeUnit.MINUTES);
281
+ if (!completed) finishRun(id, false);
282
+ return completed && result.success
283
+ ? androidx.work.ListenableWorker.Result.success()
284
+ : androidx.work.ListenableWorker.Result.failure();
285
+ }
286
+ }
287
+
288
+ // Reference the Worker class so the bundler/NS metadata retains the JavaProxy (mirrors how the FCM
289
+ // service is kept alive via its import side-effect). Without a reference the class can be tree-shaken.
290
+ void AppwrapBackgroundWorker;
@@ -9,16 +9,6 @@ import { bridge } from './bridge';
9
9
  * separate handler (Play Billing) and is currently unimplemented (capability 'none').
10
10
  */
11
11
 
12
- declare const SKPaymentQueue: any;
13
- declare const SKPayment: any;
14
- declare const SKProductsRequest: any;
15
- declare const SKProductsRequestDelegate: any;
16
- declare const SKPaymentTransactionObserver: any;
17
- declare const NSSet: any;
18
- declare const NSBundle: any;
19
- declare const NSData: any;
20
- declare const NSNumberFormatter: any;
21
-
22
12
  const PERIOD_UNIT = ['D', 'W', 'M', 'Y']; // SKProductPeriodUnit: Day/Week/Month/Year
23
13
  const STATE = { Purchasing: 0, Purchased: 1, Failed: 2, Restored: 3, Deferred: 4 };
24
14
  const SK_ERR_CANCELLED = 2; // SKErrorPaymentCancelled
@@ -35,10 +25,11 @@ function appReceipt(): string {
35
25
  const url = NSBundle.mainBundle.appStoreReceiptURL;
36
26
  if (!url) return '';
37
27
  const data = NSData.dataWithContentsOfURL(url);
38
- return data ? data.base64EncodedStringWithOptions(0) : '';
28
+ // 0 = NSDataBase64Encoding default (no options); enum-cast for the typed selector.
29
+ return data ? data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions) : '';
39
30
  }
40
31
 
41
- function mapProduct(p: any) {
32
+ function mapProduct(p: SKProduct) {
42
33
  const f = NSNumberFormatter.new();
43
34
  f.numberStyle = 2; // CurrencyStyle
44
35
  f.locale = p.priceLocale;
@@ -48,7 +39,8 @@ function mapProduct(p: any) {
48
39
  title: String(p.localizedTitle ?? ''),
49
40
  description: String(p.localizedDescription ?? ''),
50
41
  price: p.price ? p.price.doubleValue : 0,
51
- displayPrice: String(f.stringFromNumber(p.price) ?? ''),
42
+ // stringFromNumber takes an NSNumber* at runtime; the .d.ts types it as number, so cast the NSDecimalNumber.
43
+ displayPrice: String(f.stringFromNumber(p.price as unknown as number) ?? ''),
52
44
  currency: String(p.priceLocale?.currencyCode ?? ''),
53
45
  type: sp ? 'autoRenewable' : 'unknown',
54
46
  subscriptionPeriod: sp ? `P${sp.numberOfUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
@@ -62,82 +54,98 @@ function receiptFor(productId: string, transactionId?: string) {
62
54
  // In-flight resolvers, keyed so the single queue observer can route callbacks.
63
55
  const purchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
64
56
  let restoreWaiter: { receipts: any[]; resolve: (r: any[]) => void; reject: (e: any) => void } | null = null;
65
- const productRequests = new Set<any>(); // keep strong refs to in-flight requests + delegates
66
- let observer: any = null;
67
-
68
- function buildObserver() {
69
- const ObserverClass = (NSObject as any).extend(
70
- {
71
- paymentQueueUpdatedTransactions(queue: any, transactions: any): void {
72
- const n = transactions.count;
73
- for (let i = 0; i < n; i++) {
74
- const t = transactions.objectAtIndex(i);
75
- const pid = String(t.payment.productIdentifier);
76
- switch (t.transactionState) {
77
- case STATE.Purchased: {
78
- queue.finishTransaction(t);
79
- const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
80
- bridge.emit('billing.transaction', r);
81
- purchaseWaiters.get(pid)?.resolve(r);
82
- purchaseWaiters.delete(pid);
83
- break;
84
- }
85
- case STATE.Restored: {
86
- queue.finishTransaction(t);
87
- const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
88
- if (restoreWaiter) restoreWaiter.receipts.push(r);
89
- bridge.emit('billing.transaction', r);
90
- break;
91
- }
92
- case STATE.Failed: {
93
- queue.finishTransaction(t);
94
- const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
95
- const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
96
- purchaseWaiters.get(pid)?.reject(e);
97
- purchaseWaiters.delete(pid);
98
- break;
99
- }
100
- // Purchasing / Deferred: nothing to do.
101
- }
57
+ const productRequests = new Set<{ request: SKProductsRequest; delegate: ProductsDelegate }>(); // keep strong refs to in-flight requests + delegates
58
+ let observer: PaymentObserver | null = null;
59
+
60
+ // SKPaymentQueue transaction observer. State (purchaseWaiters/restoreWaiter) lives at module
61
+ // scope, so the delegate needs no instance fields.
62
+ @NativeClass()
63
+ class PaymentObserver extends NSObject implements SKPaymentTransactionObserver {
64
+ static ObjCProtocols = [SKPaymentTransactionObserver];
65
+ static new(): PaymentObserver {
66
+ return <PaymentObserver>super.new();
67
+ }
68
+ paymentQueueUpdatedTransactions(queue: SKPaymentQueue, transactions: NSArray<SKPaymentTransaction>): void {
69
+ const n = transactions.count;
70
+ for (let i = 0; i < n; i++) {
71
+ const t = transactions.objectAtIndex(i);
72
+ const pid = String(t.payment.productIdentifier);
73
+ switch (t.transactionState) {
74
+ case STATE.Purchased: {
75
+ queue.finishTransaction(t);
76
+ const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
77
+ bridge.emit('billing.transaction', r);
78
+ purchaseWaiters.get(pid)?.resolve(r);
79
+ purchaseWaiters.delete(pid);
80
+ break;
81
+ }
82
+ case STATE.Restored: {
83
+ queue.finishTransaction(t);
84
+ const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
85
+ if (restoreWaiter) restoreWaiter.receipts.push(r);
86
+ bridge.emit('billing.transaction', r);
87
+ break;
102
88
  }
103
- },
104
- paymentQueueRestoreCompletedTransactionsFinished(): void {
105
- restoreWaiter?.resolve(restoreWaiter.receipts);
106
- restoreWaiter = null;
107
- },
108
- paymentQueueRestoreCompletedTransactionsFailedWithError(_q: any, error: any): void {
109
- restoreWaiter?.reject(err('NATIVE_ERROR', error?.localizedDescription ?? 'Restore failed'));
110
- restoreWaiter = null;
111
- },
112
- },
113
- { protocols: [SKPaymentTransactionObserver] }
114
- );
115
- return ObserverClass.new();
89
+ case STATE.Failed: {
90
+ queue.finishTransaction(t);
91
+ const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
92
+ const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
93
+ purchaseWaiters.get(pid)?.reject(e);
94
+ purchaseWaiters.delete(pid);
95
+ break;
96
+ }
97
+ // Purchasing / Deferred: nothing to do.
98
+ }
99
+ }
100
+ }
101
+ paymentQueueRestoreCompletedTransactionsFinished(_queue: SKPaymentQueue): void {
102
+ restoreWaiter?.resolve(restoreWaiter.receipts);
103
+ restoreWaiter = null;
104
+ }
105
+ paymentQueueRestoreCompletedTransactionsFailedWithError(_queue: SKPaymentQueue, error: NSError): void {
106
+ restoreWaiter?.reject(err('NATIVE_ERROR', error?.localizedDescription ?? 'Restore failed'));
107
+ restoreWaiter = null;
108
+ }
109
+ }
110
+
111
+ function buildObserver(): PaymentObserver {
112
+ return PaymentObserver.new();
113
+ }
114
+
115
+ // SKProductsRequest delegate. Closure-captured state (resolve/reject + the strong-ref holder)
116
+ // is held as instance fields, assigned after new().
117
+ @NativeClass()
118
+ class ProductsDelegate extends NSObject implements SKProductsRequestDelegate {
119
+ static ObjCProtocols = [SKProductsRequestDelegate];
120
+ onResolve?: (products: SKProduct[]) => void;
121
+ onReject?: (e: Error) => void;
122
+ holder?: { request: SKProductsRequest; delegate: ProductsDelegate };
123
+ static new(): ProductsDelegate {
124
+ return <ProductsDelegate>super.new();
125
+ }
126
+ productsRequestDidReceiveResponse(_request: SKProductsRequest, response: SKProductsResponse): void {
127
+ const out: SKProduct[] = [];
128
+ const products = response.products;
129
+ for (let i = 0; i < products.count; i++) out.push(products.objectAtIndex(i));
130
+ if (this.holder) productRequests.delete(this.holder);
131
+ this.onResolve?.(out);
132
+ }
133
+ requestDidFailWithError(_request: SKRequest, error: NSError): void {
134
+ if (this.holder) productRequests.delete(this.holder);
135
+ this.onReject?.(err('NATIVE_ERROR', error?.localizedDescription ?? 'Products request failed'));
136
+ }
116
137
  }
117
138
 
118
139
  /** One SKProductsRequest → resolves the raw SKProduct objects (used by both products + purchase). */
119
- function requestSKProducts(ids: string[]): Promise<any[]> {
140
+ function requestSKProducts(ids: string[]): Promise<SKProduct[]> {
120
141
  return new Promise((resolve, reject) => {
121
- const set = NSSet.setWithArray(ids as any);
142
+ const set = NSSet.setWithArray<string>(ids);
122
143
  const request = SKProductsRequest.alloc().initWithProductIdentifiers(set);
123
- const DelegateClass = (NSObject as any).extend(
124
- {
125
- productsRequestDidReceiveResponse(_req: any, response: any): void {
126
- const out: any[] = [];
127
- const products = response.products;
128
- for (let i = 0; i < products.count; i++) out.push(products.objectAtIndex(i));
129
- productRequests.delete(holder);
130
- resolve(out);
131
- },
132
- requestDidFailWithError(_req: any, error: any): void {
133
- productRequests.delete(holder);
134
- reject(err('NATIVE_ERROR', error?.localizedDescription ?? 'Products request failed'));
135
- },
136
- },
137
- { protocols: [SKProductsRequestDelegate] }
138
- );
139
- const delegate = DelegateClass.new();
144
+ const delegate = ProductsDelegate.new();
140
145
  const holder = { request, delegate };
146
+ delegate.onResolve = resolve;
147
+ delegate.onReject = reject;
148
+ delegate.holder = holder;
141
149
  productRequests.add(holder); // strong ref until the callback fires
142
150
  request.delegate = delegate;
143
151
  request.start();