@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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
- package/runtime/app/app.ts +57 -39
- package/runtime/app/shell/android-helpers.ts +6 -6
- package/runtime/app/shell/background-bootstrap.generated.ts +8 -0
- package/runtime/app/shell/background-context.ts +20 -0
- package/runtime/app/shell/banner.ts +9 -4
- package/runtime/app/shell/bridge.ts +2 -0
- package/runtime/app/shell/capabilities.manifest.ts +22 -3
- package/runtime/app/shell/custom-webview.android.ts +50 -23
- package/runtime/app/shell/custom-webview.ios.ts +90 -78
- package/runtime/app/shell/devmenu.ts +2 -2
- package/runtime/app/shell/env.ts +1 -3
- package/runtime/app/shell/events.ts +25 -4
- package/runtime/app/shell/fcm-firebase.d.ts +2 -1
- package/runtime/app/shell/handlers-android.ts +16 -12
- package/runtime/app/shell/handlers-background.ts +290 -0
- package/runtime/app/shell/handlers-billing.ts +90 -82
- package/runtime/app/shell/handlers-extended.ts +141 -110
- package/runtime/app/shell/handlers-fs.ts +53 -41
- package/runtime/app/shell/handlers-health.ts +27 -19
- package/runtime/app/shell/handlers-media.ts +8 -14
- package/runtime/app/shell/handlers-oauth.ts +19 -19
- package/runtime/app/shell/handlers-parity.ts +118 -89
- package/runtime/app/shell/handlers-push.ts +5 -15
- package/runtime/app/shell/handlers-reviews.ts +5 -5
- package/runtime/app/shell/handlers-scanner.ts +36 -38
- package/runtime/app/shell/handlers-speech.ts +43 -49
- package/runtime/app/shell/handlers-system.ts +122 -1
- package/runtime/app/shell/handlers.ts +10 -3
- package/runtime/app/shell/ios-frameworks.d.ts +9 -44
- package/runtime/app/shell/ios-ui-delegate.ts +112 -108
- package/runtime/app/shell/native-log.ts +1 -3
- package/runtime/app/shell/status-bar.ts +7 -4
- package/runtime/app/shell/toast.ts +1 -1
- package/runtime/references.d.ts +21 -0
- package/runtime/tsconfig.json +1 -0
- package/src/cli.ts +74 -18
- package/src/config.ts +20 -0
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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<
|
|
66
|
-
let observer:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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<
|
|
140
|
+
function requestSKProducts(ids: string[]): Promise<SKProduct[]> {
|
|
120
141
|
return new Promise((resolve, reject) => {
|
|
121
|
-
const set = NSSet.setWithArray(ids
|
|
142
|
+
const set = NSSet.setWithArray<string>(ids);
|
|
122
143
|
const request = SKProductsRequest.alloc().initWithProductIdentifiers(set);
|
|
123
|
-
const
|
|
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();
|