@livx.cc/appwrap 0.52.0 → 0.54.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 +1 -1
- package/runtime/app/shell/billing-offer.ts +54 -0
- package/runtime/app/shell/capabilities.manifest.ts +6 -2
- package/runtime/app/shell/custom-webview.android.ts +73 -10
- package/runtime/app/shell/handlers-billing.ts +418 -9
- package/runtime/app/shell/web-quirks.ts +20 -19
- package/runtime/tests/billing-offer.test.ts +61 -0
- package/src/cli.ts +142 -33
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure Play-Billing offer selection — NO NativeScript/native deps, so it is unit-testable in
|
|
2
|
+
// isolation from com.android.billingclient.* objects. handlers-billing.ts adapts native
|
|
3
|
+
// SubscriptionOfferDetails into these plain shapes and delegates the CHOICE here.
|
|
4
|
+
//
|
|
5
|
+
// WHY this exists (money/product-critical): getSubscriptionOfferDetails() returns EVERY offer the
|
|
6
|
+
// user is eligible for, in NO guaranteed order. A trial-eligible (first-time) user's list carries
|
|
7
|
+
// BOTH the base-plan offer (offerId==null, a single paid phase) AND a separate free-trial offer
|
|
8
|
+
// (tag "freetrial", two phases: first priceAmountMicros==0, then the base price). A lapsed /
|
|
9
|
+
// ineligible user gets ONLY the base-plan offer. Blindly taking offers.get(0) can drop the
|
|
10
|
+
// MANDATORY free trial. We must pick the trial offer WHEN PRESENT, else the base plan.
|
|
11
|
+
|
|
12
|
+
/** One pricing phase of an offer (only the fields the selection/pricing logic needs). */
|
|
13
|
+
export interface OfferPhase {
|
|
14
|
+
/** Micros of `currency`; 0 marks a free-trial phase. */
|
|
15
|
+
priceAmountMicros: number;
|
|
16
|
+
/** ISO-8601 billing period, e.g. "P1W", "P1M". */
|
|
17
|
+
billingPeriod?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A single subscription offer, flattened from Play's SubscriptionOfferDetails. */
|
|
21
|
+
export interface OfferCandidate {
|
|
22
|
+
/** null/"" for the base-plan offer; a non-empty id for a promotional offer (trial). */
|
|
23
|
+
offerId: string | null;
|
|
24
|
+
phases: OfferPhase[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when the offer contains a zero-price (free-trial) phase. */
|
|
28
|
+
export function hasFreeTrialPhase(offer: OfferCandidate): boolean {
|
|
29
|
+
return offer.phases.some((p) => p.priceAmountMicros === 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Select the offer to purchase / price from. Prefers the free-trial offer (a zero-price phase) when
|
|
34
|
+
* present, so a trial-eligible user actually gets the trial; otherwise falls back to the base-plan
|
|
35
|
+
* offer (offerId==null), else the first offer. Returns null for an empty list.
|
|
36
|
+
*/
|
|
37
|
+
export function selectSubscriptionOffer<T extends OfferCandidate>(offers: readonly T[]): T | null {
|
|
38
|
+
if (!offers || offers.length === 0) return null;
|
|
39
|
+
const trial = offers.find(hasFreeTrialPhase);
|
|
40
|
+
if (trial) return trial;
|
|
41
|
+
const base = offers.find((o) => o.offerId == null || o.offerId === '');
|
|
42
|
+
return base ?? offers[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The recurring paid phase to display (first phase with a price > 0), or the last phase as fallback. */
|
|
46
|
+
export function recurringPhase(offer: OfferCandidate): OfferPhase | undefined {
|
|
47
|
+
const paid = offer.phases.find((p) => p.priceAmountMicros > 0);
|
|
48
|
+
return paid ?? offer.phases[offer.phases.length - 1];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The intro/free-trial phase (first zero-price phase), if any. */
|
|
52
|
+
export function introPhase(offer: OfferCandidate): OfferPhase | undefined {
|
|
53
|
+
return offer.phases.find((p) => p.priceAmountMicros === 0);
|
|
54
|
+
}
|
|
@@ -150,10 +150,14 @@ export const MODULES: ModuleManifest[] = [
|
|
|
150
150
|
},
|
|
151
151
|
{
|
|
152
152
|
name: 'billing', group: 'billing',
|
|
153
|
-
capabilities: { billing: { ios: true, android:
|
|
154
|
-
// iOS
|
|
153
|
+
capabilities: { billing: { ios: true, android: true } },
|
|
154
|
+
// iOS: Swift shim (AppwrapManageSubscriptions.swift) bridging StoreKit 2's Swift-async
|
|
155
155
|
// showManageSubscriptions(in:) to an @objc completion the ObjC bridge can call. Compiled into
|
|
156
156
|
// the app target only when `billing` is active (NS auto-compiles App_Resources/iOS/src/*.swift).
|
|
157
|
+
// Android: Play Billing Library v7 (handlers-billing.ts registerAndroidBilling). The BILLING
|
|
158
|
+
// permission is auto-merged by the library's own manifest, so no android.permissions entry —
|
|
159
|
+
// only the gradle dep is needed, and it lands ONLY when `billing` is active (strippable weight).
|
|
160
|
+
android: { gradleDeps: ['com.android.billingclient:billing:7.1.1'] },
|
|
157
161
|
nativeSrc: 'billing',
|
|
158
162
|
},
|
|
159
163
|
|
|
@@ -94,6 +94,17 @@ export class CustomWebView extends WebView {
|
|
|
94
94
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
95
95
|
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A TLS handshake this view REFUSED (onReceivedSslError → handler.cancel()), pending attribution to the
|
|
99
|
+
* navigation it killed. Cancelling an SSL error does NOT route through onReceivedError — Chromium instead
|
|
100
|
+
* swaps in its own error page and reports it via onPageFinished with the ORIGINAL url, which would read as
|
|
101
|
+
* a SUCCESSFUL load (see `pageFinished`). Recorded here so that onPageFinished can tell the two apart.
|
|
102
|
+
* Host-scoped (a cert is per-host, so a same-host sub-resource can't fail while the main frame succeeds),
|
|
103
|
+
* and cleared on any COMMITTED navigation (`pageStarted`) — a refused one never commits, so the record
|
|
104
|
+
* always outlives the failure it describes and never leaks onto the next page.
|
|
105
|
+
*/
|
|
106
|
+
pendingSslError: { host: string; reason: string } | null = null;
|
|
107
|
+
|
|
97
108
|
// STRONG JS references to the native clients. Chromium holds the Java WebViewClient /
|
|
98
109
|
// WebChromeClient, but NativeScript's mark-and-sweep does NOT treat that native hold as a
|
|
99
110
|
// GC root — so without a JS-side reference the JS peer gets collected under memory pressure
|
|
@@ -416,6 +427,18 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
416
427
|
};
|
|
417
428
|
|
|
418
429
|
const pageStarted = (view: android.webkit.WebView): void => {
|
|
430
|
+
// A navigation COMMITTED, so any earlier TLS refusal is answered for — drop it (see pendingSslError).
|
|
431
|
+
// A refused nav never reaches here (device-verified on WebView 138: no onPageStarted on an SSL cancel),
|
|
432
|
+
// so this cannot clear the record before `pageFinished` reads it.
|
|
433
|
+
//
|
|
434
|
+
// ⚠️ That is UNDOCUMENTED Chromium behaviour, not a contract. If a future WebView fires onPageStarted
|
|
435
|
+
// for its own error-page commit (i.e. AFTER onReceivedSslError), this clears the record and the
|
|
436
|
+
// swallowed-as-success bug returns SILENTLY. If that regresses, look here first.
|
|
437
|
+
// Redirects are safe for a stronger reason than the above: on http://x → 302 → https://y(bad),
|
|
438
|
+
// onPageStarted(x) fires BEFORE the handshake, so it clears an OLDER record and the refusal is
|
|
439
|
+
// recorded after — clear-before-write ordering holds regardless.
|
|
440
|
+
const owner = CustomWebView.forNative(view);
|
|
441
|
+
if (owner) owner.pendingSslError = null;
|
|
419
442
|
// Fallback injection — no-op when the document-start scripts already ran
|
|
420
443
|
view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
|
|
421
444
|
};
|
|
@@ -449,9 +472,33 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
449
472
|
// firing on a SUCCESSFUL load. A one-shot `loadFinished` listener (env-switcher's reloadToEffective)
|
|
450
473
|
// would then never unhook on success and would leak onto the NEXT navigation, blaming a later error on
|
|
451
474
|
// an earlier switch (and naming the earlier switch's host). Both halves or neither.
|
|
475
|
+
//
|
|
476
|
+
// …EXCEPT this callback is ALSO how Chromium reports its OWN error page after a REFUSED TLS handshake
|
|
477
|
+
// (device-verified sequence: onReceivedSslError → handler.cancel() → onPageFinished with the original
|
|
478
|
+
// url, and NO onReceivedError). Forwarding that as a success is what silently swallowed a cert failure
|
|
479
|
+
// into a blank page — and it unhooked the env-switcher's one-shot listener too, so the failure could
|
|
480
|
+
// never be reported at all. Attribute it via the recorded refusal instead (see pendingSslError).
|
|
452
481
|
const pageFinished = (view: android.webkit.WebView, url: string): void => {
|
|
482
|
+
const owner = CustomWebView.forNative(view);
|
|
483
|
+
const finishedUrl = String(url ?? '');
|
|
484
|
+
const ssl = owner?.pendingSslError;
|
|
485
|
+
if (ssl && ssl.host === hostOf(finishedUrl)) {
|
|
486
|
+
owner!.pendingSslError = null;
|
|
487
|
+
(owner as any)?._onLoadFinished(finishedUrl, ssl.reason);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
453
490
|
// Mirrors NS's own `owner._onLoadFinished(url, undefined)` — absent `error` is what marks success.
|
|
454
|
-
(
|
|
491
|
+
(owner as any)?._onLoadFinished(finishedUrl);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/** Android SslError primary codes → why the cert was rejected (SslError.SSL_* ordinals). */
|
|
495
|
+
const SSL_REASONS: Record<number, string> = {
|
|
496
|
+
0: 'the certificate is not valid yet',
|
|
497
|
+
1: 'the certificate has expired',
|
|
498
|
+
2: "the certificate's hostname does not match",
|
|
499
|
+
3: 'the certificate issuer is not trusted',
|
|
500
|
+
4: 'the certificate date is invalid',
|
|
501
|
+
5: 'the certificate is invalid',
|
|
455
502
|
};
|
|
456
503
|
|
|
457
504
|
// DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
|
|
@@ -460,7 +507,13 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
460
507
|
// Proceed past it ONLY in a debug build, only in server-loader mode, AND only for the ONE host the
|
|
461
508
|
// build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) — never blanket-trust every host. Production app:// builds
|
|
462
509
|
// never reach this (local assets, no TLS). NEVER active in a store build (SHELL_CONFIG.debug false).
|
|
510
|
+
//
|
|
511
|
+
// NOT trusting it is a LOAD FAILURE, and Chromium reports it through no other callback — so the cancel
|
|
512
|
+
// path RECORDS the refusal on the owning view for `pageFinished` to raise as `loadFinished(error)`.
|
|
513
|
+
// Without that the whole cert class is invisible to JS (blank page, no feedback) — the exact incident the
|
|
514
|
+
// env-switcher's reload feedback exists to name.
|
|
463
515
|
const receivedSslError = (
|
|
516
|
+
view: android.webkit.WebView,
|
|
464
517
|
handler: android.webkit.SslErrorHandler,
|
|
465
518
|
error: android.net.http.SslError
|
|
466
519
|
): void => {
|
|
@@ -469,14 +522,20 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
469
522
|
// Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
|
|
470
523
|
// resources on an alt port share one self-signed cert) — same `hostOf` normalization + port strip.
|
|
471
524
|
const stripPort = (h: string) => h.replace(/:\d+$/, '');
|
|
525
|
+
const errUrl = String(error?.getUrl?.() ?? '');
|
|
472
526
|
const allowedHost = stripPort(hostOf(SHELL_CONFIG.serverUrl));
|
|
473
|
-
const errHost = stripPort(hostOf(
|
|
527
|
+
const errHost = stripPort(hostOf(errUrl));
|
|
474
528
|
if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server' && !!allowedHost && errHost === allowedHost) {
|
|
475
529
|
console.warn('AppWrap: trusting self-signed dev-server cert (debug + host-scoped):', errHost);
|
|
476
530
|
handler.proceed();
|
|
477
|
-
|
|
478
|
-
handler.cancel();
|
|
531
|
+
return;
|
|
479
532
|
}
|
|
533
|
+
const why = SSL_REASONS[error?.getPrimaryError?.()] ?? 'the certificate was rejected';
|
|
534
|
+
const owner = CustomWebView.forNative(view);
|
|
535
|
+
// Host WITH port here (unlike the trust match above) — this record is matched against the finished
|
|
536
|
+
// url via the same `hostOf`, so both sides must normalize identically.
|
|
537
|
+
if (owner) owner.pendingSslError = { host: hostOf(errUrl), reason: `TLS certificate rejected — ${why}.` };
|
|
538
|
+
handler.cancel();
|
|
480
539
|
};
|
|
481
540
|
|
|
482
541
|
// TWO literals, selected by loader — NOT one literal with a conditional spread. Both the omission and
|
|
@@ -492,8 +551,12 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
492
551
|
// declared later in the bundle (this cost us a FATAL `LookedUpClassNotFound:
|
|
493
552
|
// cc.livx.appwrap.AppwrapMessagingService` boot crash that built green). For the same reason don't
|
|
494
553
|
// hoist the object into a variable and pass `.extend(methods)`: SBG requires an ObjectExpression at
|
|
495
|
-
// the call site and silently skips anything else.
|
|
496
|
-
//
|
|
554
|
+
// the call site and silently skips anything else.
|
|
555
|
+
// ⚠️ NOTHING VERIFIES THIS. An earlier version of this comment claimed `appwrap doctor:bindings`
|
|
556
|
+
// (run by the android build) fails the build on a lost @JavaProxy binding — that command does not
|
|
557
|
+
// exist and no test asserts the shape. The requirement is hand-maintained. A lost binding means the
|
|
558
|
+
// callback silently never fires, which reads as "no TLS errors ever" — a green from something that
|
|
559
|
+
// never ran. Either implement the check or keep this warning honest; do not restore the false claim.
|
|
497
560
|
assetClientClass = SHELL_CONFIG.loader === 'server'
|
|
498
561
|
? (android.webkit.WebViewClient as any).extend({
|
|
499
562
|
shouldOverrideUrlLoading(view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): boolean {
|
|
@@ -505,8 +568,8 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
505
568
|
onPageFinished(view: android.webkit.WebView, url: string): void {
|
|
506
569
|
pageFinished(view, url);
|
|
507
570
|
},
|
|
508
|
-
onReceivedSslError(
|
|
509
|
-
receivedSslError(handler, error);
|
|
571
|
+
onReceivedSslError(view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
|
|
572
|
+
receivedSslError(view, handler, error);
|
|
510
573
|
},
|
|
511
574
|
onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
|
|
512
575
|
receivedError(view, request, error);
|
|
@@ -525,8 +588,8 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
525
588
|
onPageFinished(view: android.webkit.WebView, url: string): void {
|
|
526
589
|
pageFinished(view, url);
|
|
527
590
|
},
|
|
528
|
-
onReceivedSslError(
|
|
529
|
-
receivedSslError(handler, error);
|
|
591
|
+
onReceivedSslError(view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
|
|
592
|
+
receivedSslError(view, handler, error);
|
|
530
593
|
},
|
|
531
594
|
onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
|
|
532
595
|
receivedError(view, request, error);
|
|
@@ -1,21 +1,33 @@
|
|
|
1
|
-
import { Utils, isIOS } from '@nativescript/core';
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { appwrapNativeLog } from './native-log';
|
|
4
4
|
import { SHELL_CONFIG } from './config';
|
|
5
|
+
import { selectSubscriptionOffer, type OfferCandidate } from './billing-offer';
|
|
5
6
|
|
|
6
7
|
// @objc Swift shim (AppwrapManageSubscriptions.swift, billing nativeSrc) — no NS types; bridges
|
|
7
8
|
// StoreKit 2's Swift-async showManageSubscriptions(in:) to a completion the ObjC bridge can call.
|
|
8
9
|
declare const AppwrapManageSubscriptions: { present(completion: (message: string | null) => void): void };
|
|
9
10
|
|
|
11
|
+
// no NS types for the Play Billing Library (com.android.billingclient.*) — referenced via FFI,
|
|
12
|
+
// lazily, so a non-billing Android build never touches the class (mirrors handlers-reviews.ts).
|
|
13
|
+
declare const com: any;
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
|
-
* In-app purchases —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
16
|
+
* In-app purchases — ONE bridge contract, two native stores.
|
|
17
|
+
*
|
|
18
|
+
* - iOS: StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction) is Swift-only and unreachable
|
|
19
|
+
* via the ObjC bridge, so we use the delegate-based SK1 API every NativeScript/Cordova IAP plugin
|
|
20
|
+
* uses. Returns a base64 app receipt.
|
|
21
|
+
* - Android: Play Billing Library v7 (BillingClient / queryProductDetailsAsync / launchBillingFlow /
|
|
22
|
+
* queryPurchasesAsync). Returns a PurchaseReceipt carrying the Play `purchaseToken` (+ productId +
|
|
23
|
+
* packageName) — the exact shape AGF's server-side PlayStoreTokenValidator consumes.
|
|
24
|
+
*
|
|
25
|
+
* The web layer's validator (server-of-record) turns the returned receipt into entitlements; both
|
|
26
|
+
* platforms register the SAME `billing.*` methods with the SAME return shapes.
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
const PERIOD_UNIT = ['D', 'W', 'M', 'Y']; // SKProductPeriodUnit: Day/Week/Month/Year
|
|
30
|
+
const DISCOUNT_FREE_TRIAL = 2; // SKProductDiscountPaymentMode.freeTrial (0=PayAsYouGo, 1=PayUpFront, 2=FreeTrial)
|
|
19
31
|
const STATE = { Purchasing: 0, Purchased: 1, Failed: 2, Restored: 3, Deferred: 4 };
|
|
20
32
|
const SK_ERR_CANCELLED = 2; // SKErrorPaymentCancelled
|
|
21
33
|
|
|
@@ -35,12 +47,40 @@ function appReceipt(): string {
|
|
|
35
47
|
return data ? data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions) : '';
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
/**
|
|
51
|
+
* SKProductDiscount → the platform-neutral IntroOffer (parity with the Android mapper's zero-price
|
|
52
|
+
* phase, same `{ price, displayPrice, period, isFreeTrial }` shape native-kit's `IntroOffer` types).
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ ELIGIBILITY CAVEAT: StoreKit 1 exposes `introductoryPrice` on the SKProduct REGARDLESS of whether
|
|
55
|
+
* this user is eligible — SK1 has no eligibility API (SK2's `isEligibleForIntroOffer` is Swift-async,
|
|
56
|
+
* unreachable via the ObjC bridge this handler uses). So a user who already consumed a trial in this
|
|
57
|
+
* group MAY see trial copy they won't actually get; StoreKit's own purchase sheet shows the truth at
|
|
58
|
+
* checkout. Gating on real eligibility is a follow-up if/when this handler moves to StoreKit 2.
|
|
59
|
+
*/
|
|
60
|
+
function mapIntroOffer(d: SKProductDiscount, locale: NSLocale): {
|
|
61
|
+
price: number; displayPrice: string; period: string | undefined; isFreeTrial: boolean;
|
|
62
|
+
} {
|
|
63
|
+
const f = NSNumberFormatter.new();
|
|
64
|
+
f.numberStyle = 2; // CurrencyStyle
|
|
65
|
+
f.locale = locale;
|
|
66
|
+
const sp = d.subscriptionPeriod;
|
|
67
|
+
// The discount period is ONE cycle; numberOfPeriods is how many cycles it repeats. Total trial
|
|
68
|
+
// length = numberOfUnits × numberOfPeriods (e.g. 1 week × 1 = P1W).
|
|
69
|
+
const totalUnits = (sp ? sp.numberOfUnits : 0) * (d.numberOfPeriods || 1);
|
|
70
|
+
return {
|
|
71
|
+
price: d.price ? d.price.doubleValue : 0,
|
|
72
|
+
displayPrice: String(f.stringFromNumber(d.price as unknown as number) ?? ''),
|
|
73
|
+
period: sp ? `P${totalUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
|
|
74
|
+
isFreeTrial: d.paymentMode === DISCOUNT_FREE_TRIAL || (d.price ? d.price.doubleValue : 0) === 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
function mapProduct(p: SKProduct) {
|
|
39
79
|
const f = NSNumberFormatter.new();
|
|
40
80
|
f.numberStyle = 2; // CurrencyStyle
|
|
41
81
|
f.locale = p.priceLocale;
|
|
42
82
|
const sp = p.subscriptionPeriod;
|
|
43
|
-
|
|
83
|
+
const product: any = {
|
|
44
84
|
id: String(p.productIdentifier),
|
|
45
85
|
title: String(p.localizedTitle ?? ''),
|
|
46
86
|
description: String(p.localizedDescription ?? ''),
|
|
@@ -51,6 +91,10 @@ function mapProduct(p: SKProduct) {
|
|
|
51
91
|
type: sp ? 'autoRenewable' : 'unknown',
|
|
52
92
|
subscriptionPeriod: sp ? `P${sp.numberOfUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
|
|
53
93
|
};
|
|
94
|
+
// Surface the intro / free-trial phase so the paywall can show trial terms (see eligibility caveat
|
|
95
|
+
// on mapIntroOffer). Populated only when StoreKit reports an introductory price for this product.
|
|
96
|
+
if (p.introductoryPrice) product.introOffer = mapIntroOffer(p.introductoryPrice, p.priceLocale);
|
|
97
|
+
return product;
|
|
54
98
|
}
|
|
55
99
|
|
|
56
100
|
function receiptFor(productId: string, transactionId?: string) {
|
|
@@ -102,6 +146,8 @@ function ensureIosDelegates(): void {
|
|
|
102
146
|
}
|
|
103
147
|
case STATE.Failed: {
|
|
104
148
|
queue.finishTransaction(t);
|
|
149
|
+
// 'DENIED' here means exactly ONE thing: the user dismissed the sheet. Anything the
|
|
150
|
+
// device refuses outright (payments restricted) is 'UNSUPPORTED' — see billing.purchase.
|
|
105
151
|
const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
|
|
106
152
|
const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
|
|
107
153
|
purchaseWaiters.get(pid)?.reject(e);
|
|
@@ -170,8 +216,11 @@ function requestSKProducts(ids: string[]): Promise<SKProduct[]> {
|
|
|
170
216
|
}
|
|
171
217
|
|
|
172
218
|
export function registerBillingHandlers(): void {
|
|
173
|
-
if (
|
|
219
|
+
if (isIOS) registerIosBilling();
|
|
220
|
+
else if (isAndroid) registerAndroidBilling();
|
|
221
|
+
}
|
|
174
222
|
|
|
223
|
+
function registerIosBilling(): void {
|
|
175
224
|
ensureIosDelegates();
|
|
176
225
|
observer = buildObserver();
|
|
177
226
|
SKPaymentQueue.defaultQueue().addTransactionObserver(observer);
|
|
@@ -182,7 +231,11 @@ export function registerBillingHandlers(): void {
|
|
|
182
231
|
});
|
|
183
232
|
|
|
184
233
|
bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
|
|
185
|
-
|
|
234
|
+
// 'UNSUPPORTED', not 'DENIED': this device CANNOT pay at all (Screen Time / parental controls
|
|
235
|
+
// / MDM restriction) — a standing condition retrying will never clear. 'DENIED' is reserved for
|
|
236
|
+
// the user dismissing the StoreKit sheet (SKErrorPaymentCancelled, below), which is a per-attempt
|
|
237
|
+
// choice. Conflating them made consumers silently swallow this as a cancel and strand the user.
|
|
238
|
+
if (!SKPaymentQueue.canMakePayments()) throw err('UNSUPPORTED', 'Purchases are disabled on this device');
|
|
186
239
|
const sk = (await requestSKProducts([productId]))[0];
|
|
187
240
|
if (!sk) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
|
|
188
241
|
return new Promise((resolve, reject) => {
|
|
@@ -251,3 +304,359 @@ function restoreTransactions(): Promise<any[]> {
|
|
|
251
304
|
SKPaymentQueue.defaultQueue().restoreCompletedTransactions();
|
|
252
305
|
});
|
|
253
306
|
}
|
|
307
|
+
|
|
308
|
+
// ─────────────────────────── Android — Google Play Billing ───────────────────────────
|
|
309
|
+
// Play Billing Library v7. All com.android.billingclient.* access is lazy (inside handler bodies)
|
|
310
|
+
// so the shared module loads on iOS and a non-billing Android build never links the class.
|
|
311
|
+
//
|
|
312
|
+
// ACKNOWLEDGEMENT DECISION (money-critical): Play auto-refunds any purchase not acknowledged within
|
|
313
|
+
// 3 days. We acknowledge CLIENT-SIDE here (BillingClient.acknowledgePurchase) immediately after a
|
|
314
|
+
// PURCHASED update and on restore — the simplest correct choice, since this handler owns the
|
|
315
|
+
// BillingClient. The AGF server validator (PlayStoreTokenValidator) MAY also acknowledge via the
|
|
316
|
+
// Play Developer API; a second acknowledge is a harmless no-op on Google's side (idempotent), so
|
|
317
|
+
// the two can coexist. Tradeoff: we ack before the server confirms the grant, so a failed server
|
|
318
|
+
// validation still consumes the purchase (no auto-refund) — acceptable because the server is the
|
|
319
|
+
// source-of-record and re-validates the token on demand. If the server takes over acknowledgement
|
|
320
|
+
// exclusively, delete acknowledgeAndroidPurchase() calls here.
|
|
321
|
+
|
|
322
|
+
// BillingResponseCode (com.android.billingclient.api.BillingClient.BillingResponseCode).
|
|
323
|
+
const PLAY_RESP = {
|
|
324
|
+
OK: 0, USER_CANCELED: 1, SERVICE_UNAVAILABLE: 2, BILLING_UNAVAILABLE: 3, ITEM_UNAVAILABLE: 4,
|
|
325
|
+
DEVELOPER_ERROR: 5, ERROR: 6, ITEM_ALREADY_OWNED: 7, FEATURE_NOT_SUPPORTED: -2, SERVICE_DISCONNECTED: -1,
|
|
326
|
+
};
|
|
327
|
+
const PURCHASE_STATE_PURCHASED = 1; // com.android.billingclient.api.Purchase.PurchaseState.PURCHASED
|
|
328
|
+
|
|
329
|
+
let billingClient: any = null; // com.android.billingclient.api.BillingClient
|
|
330
|
+
let clientConnecting: Promise<any> | null = null;
|
|
331
|
+
// Purchases arrive asynchronously via the single PurchasesUpdatedListener; route them to the
|
|
332
|
+
// in-flight purchase() caller by product id (module scope, like the iOS purchaseWaiters).
|
|
333
|
+
const androidPurchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
|
|
334
|
+
// Single-purchase gate: with only one in-flight purchase, "reject all on error" (which lacks product
|
|
335
|
+
// context) can never reject a DIFFERENT caller's promise. Set synchronously in billing.purchase.
|
|
336
|
+
let androidPurchaseInFlight = false;
|
|
337
|
+
|
|
338
|
+
function billingApi(): any {
|
|
339
|
+
return com.android.billingclient.api;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function packageName(): string {
|
|
343
|
+
return String(Utils.android.getApplicationContext().getPackageName());
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Java List<T> from a JS array (Play Billing builders take java.util.List, not a marshaled JS array). */
|
|
347
|
+
function toJavaList(items: any[]): any {
|
|
348
|
+
const list = new java.util.ArrayList();
|
|
349
|
+
for (const it of items) list.add(it);
|
|
350
|
+
return list;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Map a Play BillingResult to a bridge error, mirroring the iOS cancel/unsupported semantics. */
|
|
354
|
+
function mapBillingErr(result: any): Error {
|
|
355
|
+
const code = result?.getResponseCode?.();
|
|
356
|
+
const msg = String(result?.getDebugMessage?.() ?? 'Play Billing error');
|
|
357
|
+
if (code === PLAY_RESP.USER_CANCELED) return err('DENIED', 'Purchase cancelled');
|
|
358
|
+
if (code === PLAY_RESP.BILLING_UNAVAILABLE || code === PLAY_RESP.FEATURE_NOT_SUPPORTED)
|
|
359
|
+
return err('UNSUPPORTED', msg || 'Billing unavailable on this device');
|
|
360
|
+
return err('NATIVE_ERROR', `${msg} (code ${code})`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Lazily build + connect the BillingClient. Resolves when the service is READY. */
|
|
364
|
+
function ensureAndroidClient(): Promise<any> {
|
|
365
|
+
if (billingClient && billingClient.isReady()) return Promise.resolve(billingClient);
|
|
366
|
+
if (clientConnecting) return clientConnecting;
|
|
367
|
+
|
|
368
|
+
clientConnecting = new Promise((resolve, reject) => {
|
|
369
|
+
const api = billingApi();
|
|
370
|
+
const ctx = Utils.android.getApplicationContext();
|
|
371
|
+
// v7 requires PendingPurchasesParams; enableOneTimeProducts() is harmless for a subs-only app
|
|
372
|
+
// (pending purchases are always enabled for subscriptions).
|
|
373
|
+
const pending = api.PendingPurchasesParams.newBuilder().enableOneTimeProducts().build();
|
|
374
|
+
const listener = new api.PurchasesUpdatedListener({
|
|
375
|
+
onPurchasesUpdated(result: any, purchases: any) {
|
|
376
|
+
onAndroidPurchasesUpdated(result, purchases);
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
billingClient = api.BillingClient.newBuilder(ctx)
|
|
380
|
+
.setListener(listener)
|
|
381
|
+
.enablePendingPurchases(pending)
|
|
382
|
+
.build();
|
|
383
|
+
billingClient.startConnection(new api.BillingClientStateListener({
|
|
384
|
+
onBillingSetupFinished(result: any) {
|
|
385
|
+
if (result.getResponseCode() === PLAY_RESP.OK) resolve(billingClient);
|
|
386
|
+
else reject(mapBillingErr(result));
|
|
387
|
+
},
|
|
388
|
+
onBillingServiceDisconnected() {
|
|
389
|
+
// Force a fresh connection on the next call (Google recommends reconnect-on-demand).
|
|
390
|
+
clientConnecting = null;
|
|
391
|
+
},
|
|
392
|
+
}));
|
|
393
|
+
}).catch((e) => {
|
|
394
|
+
clientConnecting = null;
|
|
395
|
+
throw e;
|
|
396
|
+
});
|
|
397
|
+
return clientConnecting;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Query one product's ProductDetails (SUBS). */
|
|
401
|
+
function queryAndroidProductDetails(ids: string[]): Promise<any[]> {
|
|
402
|
+
return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
|
|
403
|
+
const api = billingApi();
|
|
404
|
+
const products = ids.map((id) =>
|
|
405
|
+
api.QueryProductDetailsParams.Product.newBuilder()
|
|
406
|
+
.setProductId(id)
|
|
407
|
+
.setProductType(api.BillingClient.ProductType.SUBS)
|
|
408
|
+
.build());
|
|
409
|
+
const params = api.QueryProductDetailsParams.newBuilder().setProductList(toJavaList(products)).build();
|
|
410
|
+
client.queryProductDetailsAsync(params, new api.ProductDetailsResponseListener({
|
|
411
|
+
onProductDetailsResponse(result: any, list: any) {
|
|
412
|
+
if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
|
|
413
|
+
const out: any[] = [];
|
|
414
|
+
const n = list ? list.size() : 0;
|
|
415
|
+
for (let i = 0; i < n; i++) out.push(list.get(i));
|
|
416
|
+
resolve(out);
|
|
417
|
+
},
|
|
418
|
+
}));
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Flatten a ProductDetails' SubscriptionOfferDetails into pure {offerId, phases} + a ref to the
|
|
423
|
+
* native offer (so the caller can read its token / native phases after selection). */
|
|
424
|
+
function androidOfferCandidates(pd: any): Array<OfferCandidate & { native: any }> {
|
|
425
|
+
const offers = pd.getSubscriptionOfferDetails();
|
|
426
|
+
const out: Array<OfferCandidate & { native: any }> = [];
|
|
427
|
+
const n = offers ? offers.size() : 0;
|
|
428
|
+
for (let i = 0; i < n; i++) {
|
|
429
|
+
const o = offers.get(i);
|
|
430
|
+
const list = o.getPricingPhases().getPricingPhaseList();
|
|
431
|
+
const phases = [];
|
|
432
|
+
for (let j = 0; j < list.size(); j++) {
|
|
433
|
+
const ph = list.get(j);
|
|
434
|
+
phases.push({
|
|
435
|
+
priceAmountMicros: Number(ph.getPriceAmountMicros()),
|
|
436
|
+
billingPeriod: String(ph.getBillingPeriod() ?? ''),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const id = o.getOfferId ? o.getOfferId() : null;
|
|
440
|
+
out.push({ offerId: id != null ? String(id) : null, phases, native: o });
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** First native pricing phase whose micros satisfy `pred`. */
|
|
446
|
+
function pickNativePhase(list: any, pred: (micros: number) => boolean): any {
|
|
447
|
+
for (let i = 0; i < list.size(); i++) {
|
|
448
|
+
const p = list.get(i);
|
|
449
|
+
if (pred(Number(p.getPriceAmountMicros()))) return p;
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** ProductDetails → the platform-neutral Product shape, from the SELECTED offer (trial-aware). */
|
|
455
|
+
function mapAndroidProduct(pd: any): any {
|
|
456
|
+
const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
|
|
457
|
+
if (!selected) return null; // not a subscription / no offer
|
|
458
|
+
const list = selected.native.getPricingPhases().getPricingPhaseList();
|
|
459
|
+
// Recurring (paid) phase for the display price; fall back to the last phase.
|
|
460
|
+
const phase = pickNativePhase(list, (m) => m > 0) ?? list.get(list.size() - 1);
|
|
461
|
+
const period = String(phase.getBillingPeriod() ?? '');
|
|
462
|
+
const product: any = {
|
|
463
|
+
id: String(pd.getProductId()),
|
|
464
|
+
title: String(pd.getTitle() ?? pd.getName() ?? ''),
|
|
465
|
+
description: String(pd.getDescription() ?? ''),
|
|
466
|
+
price: Number(phase.getPriceAmountMicros()) / 1_000_000,
|
|
467
|
+
displayPrice: String(phase.getFormattedPrice() ?? ''),
|
|
468
|
+
currency: String(phase.getPriceCurrencyCode() ?? ''),
|
|
469
|
+
type: 'autoRenewable',
|
|
470
|
+
subscriptionPeriod: period || undefined,
|
|
471
|
+
};
|
|
472
|
+
// Surface the free-trial / intro phase so the UI can show trial terms (Play also shows them in its
|
|
473
|
+
// own sheet). Populated only when the selected offer carries a zero-price phase (trial-eligible).
|
|
474
|
+
const intro = pickNativePhase(list, (m) => m === 0);
|
|
475
|
+
if (intro) {
|
|
476
|
+
const introPeriod = String(intro.getBillingPeriod() ?? '');
|
|
477
|
+
product.introOffer = {
|
|
478
|
+
price: Number(intro.getPriceAmountMicros()) / 1_000_000,
|
|
479
|
+
displayPrice: String(intro.getFormattedPrice() ?? ''),
|
|
480
|
+
period: introPeriod || undefined,
|
|
481
|
+
isFreeTrial: Number(intro.getPriceAmountMicros()) === 0,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return product;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Play Purchase → PurchaseReceipt (the token is the critical field the server validator consumes). */
|
|
488
|
+
function androidReceiptFor(purchase: any, productId: string): any {
|
|
489
|
+
return {
|
|
490
|
+
platform: 'android',
|
|
491
|
+
productId,
|
|
492
|
+
transactionId: String(purchase.getOrderId?.() ?? ''),
|
|
493
|
+
purchaseToken: String(purchase.getPurchaseToken()),
|
|
494
|
+
packageName: String(purchase.getPackageName?.() ?? packageName()),
|
|
495
|
+
raw: {
|
|
496
|
+
originalJson: String(purchase.getOriginalJson?.() ?? ''),
|
|
497
|
+
signature: String(purchase.getSignature?.() ?? ''),
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Client-side acknowledge (see ACKNOWLEDGEMENT DECISION above). Fire-and-forget; logs on failure. */
|
|
503
|
+
function acknowledgeAndroidPurchase(purchase: any): void {
|
|
504
|
+
try {
|
|
505
|
+
if (purchase.isAcknowledged()) return;
|
|
506
|
+
const api = billingApi();
|
|
507
|
+
const params = api.AcknowledgePurchaseParams.newBuilder()
|
|
508
|
+
.setPurchaseToken(purchase.getPurchaseToken())
|
|
509
|
+
.build();
|
|
510
|
+
billingClient.acknowledgePurchase(params, new api.AcknowledgePurchaseResponseListener({
|
|
511
|
+
onAcknowledgePurchaseResponse(result: any) {
|
|
512
|
+
if (result.getResponseCode() !== PLAY_RESP.OK) {
|
|
513
|
+
console.warn('AppWrap: billing acknowledge failed', result.getResponseCode(), String(result.getDebugMessage?.() ?? ''));
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
}));
|
|
517
|
+
} catch (e) {
|
|
518
|
+
console.warn('AppWrap: billing acknowledge threw', e);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The single PurchasesUpdatedListener callback — routes results to the purchase() waiter(s). */
|
|
523
|
+
function onAndroidPurchasesUpdated(result: any, purchases: any): void {
|
|
524
|
+
const code = result.getResponseCode();
|
|
525
|
+
if (code !== PLAY_RESP.OK || !purchases) {
|
|
526
|
+
// No product context here (Play doesn't say which flow) → reject all in-flight purchases with the
|
|
527
|
+
// mapped error (USER_CANCELED → DENIED, mirroring iOS' cancel semantics).
|
|
528
|
+
const e = mapBillingErr(result);
|
|
529
|
+
for (const w of androidPurchaseWaiters.values()) w.reject(e);
|
|
530
|
+
androidPurchaseWaiters.clear();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const n = purchases.size();
|
|
534
|
+
for (let i = 0; i < n; i++) {
|
|
535
|
+
const purchase = purchases.get(i);
|
|
536
|
+
// PENDING (cash/bank/parental-approval) → don't resolve/ack/grant now. This NATIVE waiter has NO
|
|
537
|
+
// timeout; the JS `invoke` rejects at 120s, so by the time Play flips PENDING→PURCHASED (hours/
|
|
538
|
+
// days later) the purchase() promise is long gone. The completion is recoverable ONLY via the
|
|
539
|
+
// billing.transaction event emitted below when this listener fires again with PURCHASED — NOT via
|
|
540
|
+
// the original purchase() promise. Do NOT ack or grant a PENDING purchase.
|
|
541
|
+
if (purchase.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
|
|
542
|
+
acknowledgeAndroidPurchase(purchase);
|
|
543
|
+
const prods = purchase.getProducts();
|
|
544
|
+
const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
|
|
545
|
+
const receipt = androidReceiptFor(purchase, pid);
|
|
546
|
+
bridge.emit('billing.transaction', receipt);
|
|
547
|
+
const w = androidPurchaseWaiters.get(pid);
|
|
548
|
+
if (w) { w.resolve(receipt); androidPurchaseWaiters.delete(pid); }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** queryPurchasesAsync(SUBS) → active PurchaseReceipts (used by restore / appReceipt / entitlements). */
|
|
553
|
+
function androidActivePurchases(): Promise<any[]> {
|
|
554
|
+
return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
|
|
555
|
+
const api = billingApi();
|
|
556
|
+
const params = api.QueryPurchasesParams.newBuilder().setProductType(api.BillingClient.ProductType.SUBS).build();
|
|
557
|
+
client.queryPurchasesAsync(params, new api.PurchasesResponseListener({
|
|
558
|
+
onQueryPurchasesResponse(result: any, list: any) {
|
|
559
|
+
if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
|
|
560
|
+
const receipts: any[] = [];
|
|
561
|
+
const n = list ? list.size() : 0;
|
|
562
|
+
for (let i = 0; i < n; i++) {
|
|
563
|
+
const p = list.get(i);
|
|
564
|
+
if (p.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
|
|
565
|
+
acknowledgeAndroidPurchase(p); // ack any not-yet-acked active sub (in-place migration safety)
|
|
566
|
+
const prods = p.getProducts();
|
|
567
|
+
const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
|
|
568
|
+
receipts.push(androidReceiptFor(p, pid));
|
|
569
|
+
}
|
|
570
|
+
resolve(receipts);
|
|
571
|
+
},
|
|
572
|
+
}));
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function registerAndroidBilling(): void {
|
|
577
|
+
bridge.register('billing.products', async ({ ids = [] }: { ids: string[] }) => {
|
|
578
|
+
if (!ids.length) return [];
|
|
579
|
+
return (await queryAndroidProductDetails(ids)).map(mapAndroidProduct).filter(Boolean);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
|
|
583
|
+
// SERIALIZE: Play's PurchasesUpdatedListener gives NO product context on error, so a single
|
|
584
|
+
// in-flight purchase is the only way "reject all on error" can't cross-talk between callers.
|
|
585
|
+
// The app is single-product; a concurrent second purchase() is rejected outright, guaranteeing
|
|
586
|
+
// there is only ever ONE entry in androidPurchaseWaiters. Guard is set SYNCHRONOUSLY (before any
|
|
587
|
+
// await) so two near-simultaneous calls can't both pass it.
|
|
588
|
+
if (androidPurchaseInFlight) throw err('NATIVE_ERROR', 'A purchase is already in progress');
|
|
589
|
+
androidPurchaseInFlight = true;
|
|
590
|
+
try {
|
|
591
|
+
const client = await ensureAndroidClient();
|
|
592
|
+
const pd = (await queryAndroidProductDetails([productId]))[0];
|
|
593
|
+
if (!pd) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
|
|
594
|
+
const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
|
|
595
|
+
if (!selected) throw err('NATIVE_ERROR', `No subscription offer for ${productId}`);
|
|
596
|
+
// Trial-aware: the SELECTED offer's token carries the free trial when the user is eligible.
|
|
597
|
+
const offerToken = selected.native.getOfferToken();
|
|
598
|
+
const api = billingApi();
|
|
599
|
+
const pdParams = api.BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
600
|
+
.setProductDetails(pd)
|
|
601
|
+
.setOfferToken(offerToken)
|
|
602
|
+
.build();
|
|
603
|
+
const flowParams = api.BillingFlowParams.newBuilder()
|
|
604
|
+
.setProductDetailsParamsList(toJavaList([pdParams]))
|
|
605
|
+
.build();
|
|
606
|
+
return await new Promise((resolve, reject) => {
|
|
607
|
+
androidPurchaseWaiters.set(productId, { resolve, reject });
|
|
608
|
+
Utils.dispatchToMainThread(() => {
|
|
609
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
610
|
+
if (!activity) {
|
|
611
|
+
androidPurchaseWaiters.delete(productId);
|
|
612
|
+
reject(err('NOT_READY', 'no foreground activity'));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// launchBillingFlow's synchronous result reports only launch failures; the purchase itself
|
|
616
|
+
// resolves later via onAndroidPurchasesUpdated → the waiter above.
|
|
617
|
+
const launch = client.launchBillingFlow(activity, flowParams);
|
|
618
|
+
if (launch.getResponseCode() !== PLAY_RESP.OK) {
|
|
619
|
+
androidPurchaseWaiters.delete(productId);
|
|
620
|
+
reject(mapBillingErr(launch));
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
} finally {
|
|
625
|
+
// Runs when the purchase settles (resolve/reject) or on any setup throw — re-opening the gate.
|
|
626
|
+
androidPurchaseInFlight = false;
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
bridge.register('billing.restore', () => androidActivePurchases());
|
|
631
|
+
|
|
632
|
+
// Android has no StoreKit-1-style on-disk app receipt; the analog is the most recent active sub's
|
|
633
|
+
// purchaseToken (empty when none). Mirrors iOS' { platform:'ios', appReceipt }.
|
|
634
|
+
bridge.register('billing.appReceipt', async () => {
|
|
635
|
+
const receipts = await androidActivePurchases();
|
|
636
|
+
return { platform: 'android', purchaseToken: receipts[0]?.purchaseToken };
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Thin, device-derived entitlements (server is the source-of-record — same posture as iOS).
|
|
640
|
+
bridge.register('billing.entitlements', async () => {
|
|
641
|
+
const receipts = await androidActivePurchases();
|
|
642
|
+
const seen = new Set<string>();
|
|
643
|
+
const ents: any[] = [];
|
|
644
|
+
for (const r of receipts) {
|
|
645
|
+
if (!r.productId || seen.has(r.productId)) continue;
|
|
646
|
+
seen.add(r.productId);
|
|
647
|
+
ents.push({ productId: r.productId, active: true });
|
|
648
|
+
}
|
|
649
|
+
return ents;
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Android has no in-app management sheet; the Play subscriptions deep link is the analog for BOTH
|
|
653
|
+
// manageSubscriptions() and showManageSubscriptionsSheet() (the JS layer dispatches the sheet call
|
|
654
|
+
// here now that the capability is 'native' on Android).
|
|
655
|
+
const openManage = ({ productId }: { productId?: string } = {}) => {
|
|
656
|
+
const base = 'https://play.google.com/store/account/subscriptions';
|
|
657
|
+
const url = productId ? `${base}?sku=${encodeURIComponent(productId)}&package=${packageName()}` : base;
|
|
658
|
+
Utils.openUrl(url);
|
|
659
|
+
};
|
|
660
|
+
bridge.register('billing.manageSubscriptions', (p: { productId?: string } = {}) => openManage(p));
|
|
661
|
+
bridge.register('billing.manageSubscriptionsSheet', (p: { productId?: string } = {}) => openManage(p));
|
|
662
|
+
}
|
|
@@ -37,29 +37,30 @@ window.__APPWRAP_DEBUG__=${SHELL_CONFIG.debug ? 'true' : 'false'};
|
|
|
37
37
|
});
|
|
38
38
|
window.addEventListener('error', function(e){ post('[uncaught] ' + (e.message || e) + ' @ ' + (e.filename || '') + ':' + (e.lineno || 0)); });
|
|
39
39
|
window.addEventListener('unhandledrejection', function(e){ var r = e.reason; post('[rejection] ' + ((r && (r.stack || r.message)) || r)); });
|
|
40
|
-
// Debug builds only: a
|
|
41
|
-
//
|
|
42
|
-
//
|
|
40
|
+
// Debug builds only: a small, non-interactive RED DOT parked just under the top safe area. A
|
|
41
|
+
// dev-pointing/dev-signed ipa (debug:true) must never be mistaken for a store/prod build — the dot is
|
|
42
|
+
// the minimum always-on "this is not a store build" tell. It deliberately carries NO text (it replaced
|
|
43
|
+
// a full-width 'DEV · <host>' strip that was too heavy): WHICH backend is answerable on demand via
|
|
44
|
+
// shake → dev menu → App Info (remote host). Sits BELOW safe-area-inset-top so it never overlaps the
|
|
45
|
+
// notch/Dynamic Island, is position:fixed (no layout shift) and pointer-events:none (never steals a tap).
|
|
43
46
|
// SUPPRESSED when the env-switcher is enabled: its bottom env-banner (shown only on a NON-default
|
|
44
|
-
// env) is the better, less-intrusive "which env am I on" indicator and supersedes this
|
|
45
|
-
// without envSwitcher keep the
|
|
46
|
-
var
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function mountDevStrip(){
|
|
50
|
-
if (document.getElementById('__appwrap_dev_strip__') || !document.body) return;
|
|
47
|
+
// env) is the better, less-intrusive "which env am I on" indicator and supersedes this. Apps
|
|
48
|
+
// without envSwitcher keep the dot. (Store builds never reach here — debug is false.)
|
|
49
|
+
var __appwrapDevDot = ${SHELL_CONFIG.envSwitcher?.enabled ? 'false' : 'true'};
|
|
50
|
+
function mountDevDot(){
|
|
51
|
+
if (document.getElementById('__appwrap_dev_dot__') || !document.body) return;
|
|
51
52
|
var el = document.createElement('div');
|
|
52
|
-
el.id = '
|
|
53
|
-
|
|
54
|
-
el.style.cssText = 'position:fixed;
|
|
55
|
-
+ '
|
|
56
|
-
+ '
|
|
57
|
-
+ '
|
|
53
|
+
el.id = '__appwrap_dev_dot__';
|
|
54
|
+
// White ring + drop shadow so a 9px dot stays legible over BOTH light and dark app backgrounds.
|
|
55
|
+
el.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;'
|
|
56
|
+
+ 'top:calc(env(safe-area-inset-top,0px) + 6px);right:calc(env(safe-area-inset-right,0px) + 8px);'
|
|
57
|
+
+ 'width:9px;height:9px;border-radius:50%;background:#ef4444;'
|
|
58
|
+
+ 'box-shadow:0 0 0 1.5px rgba(255,255,255,.9),0 1px 3px rgba(0,0,0,.45);';
|
|
58
59
|
document.body.appendChild(el);
|
|
59
60
|
}
|
|
60
|
-
if (
|
|
61
|
-
if (document.body)
|
|
62
|
-
else document.addEventListener('DOMContentLoaded',
|
|
61
|
+
if (__appwrapDevDot) {
|
|
62
|
+
if (document.body) mountDevDot();
|
|
63
|
+
else document.addEventListener('DOMContentLoaded', mountDevDot);
|
|
63
64
|
}
|
|
64
65
|
})();`;
|
|
65
66
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
hasFreeTrialPhase,
|
|
4
|
+
introPhase,
|
|
5
|
+
recurringPhase,
|
|
6
|
+
selectSubscriptionOffer,
|
|
7
|
+
type OfferCandidate,
|
|
8
|
+
} from '../app/shell/billing-offer';
|
|
9
|
+
|
|
10
|
+
// The two offers a trial-ELIGIBLE user sees. Order is deliberately trial-LAST to prove index-0 is
|
|
11
|
+
// unsafe by contract (the old offers.get(0) bug picked the base plan and dropped the trial).
|
|
12
|
+
const basePlan: OfferCandidate = {
|
|
13
|
+
offerId: null,
|
|
14
|
+
phases: [{ priceAmountMicros: 4_990_000, billingPeriod: 'P1M' }],
|
|
15
|
+
};
|
|
16
|
+
const trialOffer: OfferCandidate = {
|
|
17
|
+
offerId: 'freetrial',
|
|
18
|
+
phases: [
|
|
19
|
+
{ priceAmountMicros: 0, billingPeriod: 'P1W' },
|
|
20
|
+
{ priceAmountMicros: 4_990_000, billingPeriod: 'P1M' },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('selectSubscriptionOffer', () => {
|
|
25
|
+
test('trial present (base first) → picks the trial offer', () => {
|
|
26
|
+
expect(selectSubscriptionOffer([basePlan, trialOffer])).toBe(trialOffer);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('trial present (trial first) → still picks the trial offer regardless of order', () => {
|
|
30
|
+
expect(selectSubscriptionOffer([trialOffer, basePlan])).toBe(trialOffer);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('trial absent (ineligible user) → falls back to the base-plan offer', () => {
|
|
34
|
+
expect(selectSubscriptionOffer([basePlan])).toBe(basePlan);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('trial absent, offerId not null → returns the first offer as last resort', () => {
|
|
38
|
+
const promoNoTrial: OfferCandidate = { offerId: 'promo', phases: [{ priceAmountMicros: 2_990_000 }] };
|
|
39
|
+
expect(selectSubscriptionOffer([promoNoTrial])).toBe(promoNoTrial);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('empty list → null', () => {
|
|
43
|
+
expect(selectSubscriptionOffer([])).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Falsifiability guard: the trial offer's FIRST phase is free — if the code silently used
|
|
47
|
+
// offers.get(0) again, this would fail because base-first order would pick basePlan.
|
|
48
|
+
test('the selected trial offer exposes a zero-price intro phase and the recurring price', () => {
|
|
49
|
+
const chosen = selectSubscriptionOffer([basePlan, trialOffer])!;
|
|
50
|
+
expect(hasFreeTrialPhase(chosen)).toBe(true);
|
|
51
|
+
expect(introPhase(chosen)?.priceAmountMicros).toBe(0);
|
|
52
|
+
expect(introPhase(chosen)?.billingPeriod).toBe('P1W');
|
|
53
|
+
expect(recurringPhase(chosen)?.priceAmountMicros).toBe(4_990_000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('base plan has no intro phase; recurring == its only paid phase', () => {
|
|
57
|
+
expect(hasFreeTrialPhase(basePlan)).toBe(false);
|
|
58
|
+
expect(introPhase(basePlan)).toBeUndefined();
|
|
59
|
+
expect(recurringPhase(basePlan)?.priceAmountMicros).toBe(4_990_000);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -644,6 +644,24 @@ export const SHELL_CONFIG = {
|
|
|
644
644
|
writeFileSync(join(outDir, 'app/shell/config.ts'), content);
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
/** `deploy` (ios + android) stamps the shell with **`debug: true` FORCED, overriding your config**.
|
|
648
|
+
*
|
|
649
|
+
* This is INTENTIONAL and load-bearing: `deploy` is the local dev-install loop, and debug mode is what
|
|
650
|
+
* enables keep-awake + the WebView inspector for continuous troubleshooting. `sync`, `release` and
|
|
651
|
+
* `submit` all honour `cfg.debug` verbatim — `deploy` is the deliberate exception, not an oversight.
|
|
652
|
+
*
|
|
653
|
+
* ⚠️ THE TRAP: any feature gated on `SHELL_CONFIG.debug` is **unfalsifiable on a deployed build** — the
|
|
654
|
+
* gate is always open, so "it worked when I deployed it" proves nothing about a real (`debug:false`)
|
|
655
|
+
* build, and a debug-gated regression cannot reproduce here. To verify debug-gated behaviour, build via
|
|
656
|
+
* `appwrap release` (which honours the config) — do NOT conclude from a `deploy` build.
|
|
657
|
+
* Hence the printed line: the forcing must never be silent. */
|
|
658
|
+
function stampDeployShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
659
|
+
stampShellConfig(outDir, { ...cfg, debug: true });
|
|
660
|
+
console.log(cfg.debug === false
|
|
661
|
+
? ' ⚠ deploy forces debug:true (keep-awake + WebView inspector) — OVERRIDING debug:false from your config.\n debug-gated behaviour cannot be verified on this build; use `appwrap release` for that.'
|
|
662
|
+
: ' ℹ debug:true — forced by deploy (keep-awake + WebView inspector); debug-gated code is always ON here.');
|
|
663
|
+
}
|
|
664
|
+
|
|
647
665
|
function stampNativeScriptConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
648
666
|
const file = join(outDir, 'nativescript.config.ts');
|
|
649
667
|
const src = readFileSync(file, 'utf8').replace(/id: '[^']*'/, `id: '${cfg.id}'`);
|
|
@@ -3253,40 +3271,122 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
|
|
|
3253
3271
|
|
|
3254
3272
|
// ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────
|
|
3255
3273
|
|
|
3256
|
-
/**
|
|
3257
|
-
*
|
|
3258
|
-
*
|
|
3274
|
+
/** Files under TEMPLATE_DIR that are NOT build inputs, for fingerprinting. Deliberately a superset-keeper
|
|
3275
|
+
* vs `templateCopyFilter`: `modules-native/` IS excluded there (copied selectively per active module) but
|
|
3276
|
+
* IS compiled into bundle.js, so editing it MUST invalidate the cache — it stays in the fingerprint here.
|
|
3277
|
+
* `app/www` (PWA staging) is build OUTPUT and `node_modules`/`platforms`/`hooks` are generated, so all four
|
|
3278
|
+
* would make the hash unstable across runs. Matched RELATIVE to TEMPLATE_DIR: when installed from npm
|
|
3279
|
+
* TEMPLATE_DIR itself lives under node_modules/, and testing the absolute path would exclude everything. */
|
|
3280
|
+
const templateFingerprintFilter = (src: string): boolean =>
|
|
3281
|
+
!/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(TEMPLATE_DIR.length));
|
|
3282
|
+
|
|
3283
|
+
/** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config + the appwrap
|
|
3284
|
+
* `runtime/` template tree + this CLI's version.
|
|
3285
|
+
* App_Resources/ (under the app's native/ outDir) is intentionally excluded — sync() rewrites it every
|
|
3286
|
+
* run, so its mtime always changes and would make the fingerprint permanently stale.
|
|
3287
|
+
* runtime/ IS included: it is compiled straight into bundle.js, so omitting it made a runtime edit print
|
|
3288
|
+
* "Skipping build — inputs unchanged" and then install an artifact carrying the PREVIOUS runtime — a
|
|
3289
|
+
* silent stale build that reads as a pass. CLI_VERSION covers codegen changes that touch no source file.
|
|
3290
|
+
* Cost: ~150 stat() calls on a 1MB tree, sub-millisecond — no need for content hashing.
|
|
3259
3291
|
* Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3292
|
+
function mtimeStats(p: string): { sum: number; newest: number } {
|
|
3293
|
+
if (!existsSync(p)) return { sum: 0, newest: 0 };
|
|
3294
|
+
try {
|
|
3295
|
+
const s = statSync(p);
|
|
3296
|
+
if (s.isDirectory()) {
|
|
3297
|
+
let sum = 0, newest = 0;
|
|
3298
|
+
for (const e of readdirSync(p, { withFileTypes: true })) {
|
|
3299
|
+
const c = mtimeStats(join(p, e.name));
|
|
3300
|
+
sum += c.sum;
|
|
3301
|
+
if (c.newest > newest) newest = c.newest;
|
|
3270
3302
|
}
|
|
3271
|
-
return
|
|
3272
|
-
}
|
|
3273
|
-
|
|
3303
|
+
return { sum, newest };
|
|
3304
|
+
}
|
|
3305
|
+
return { sum: s.mtimeMs, newest: s.mtimeMs };
|
|
3306
|
+
} catch { return { sum: 0, newest: 0 }; }
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
/** THE build-input set, walked once — the single source of truth for both consumers below:
|
|
3310
|
+
* `parts` (per-input mtime SUMS) feed the fingerprint hash; `newest` (max mtime across every input)
|
|
3311
|
+
* feeds the `--resume` staleness gate. Keeping them on one walk means an input can never be in the
|
|
3312
|
+
* hash but out of the gate (or vice versa) — that divergence is exactly how stale builds ship. */
|
|
3313
|
+
function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string>): { parts: number[]; newest: number } {
|
|
3274
3314
|
// Fingerprint EVERY app source the build actually consumes — not just dist. Missing the overrides dir
|
|
3275
3315
|
// (native escape hatch) or a `.js`/`.json`/`--config` config file made edits there produce a stale
|
|
3276
3316
|
// "inputs unchanged" skip. Resolve the real config path (ts→js→json / --config) instead of hardcoding.
|
|
3277
3317
|
const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
|
|
3278
3318
|
const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
|
|
3279
|
-
const
|
|
3319
|
+
const stats = [mtimeStats(distDir), mtimeStats(overridesDir), mtimeStats(resolveConfigPath(cwd, flags))];
|
|
3320
|
+
// The appwrap runtime template — bundled into bundle.js, so it is a first-class build input.
|
|
3321
|
+
let runtime = 0, runtimeNewest = 0;
|
|
3322
|
+
for (const rel of collectRelFiles(TEMPLATE_DIR, templateFingerprintFilter)) {
|
|
3323
|
+
const s = mtimeStats(join(TEMPLATE_DIR, rel));
|
|
3324
|
+
runtime += s.sum;
|
|
3325
|
+
if (s.newest > runtimeNewest) runtimeNewest = s.newest;
|
|
3326
|
+
}
|
|
3327
|
+
stats.push({ sum: runtime, newest: runtimeNewest });
|
|
3328
|
+
return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/** Newest mtime across every build input — the evidence the `--resume` gate needs when no build cache
|
|
3332
|
+
* exists to compare a fingerprint against. Same input set as `buildFingerprint` by construction. */
|
|
3333
|
+
export function newestBuildInputMtime(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): number {
|
|
3334
|
+
return buildInputStats(cwd, cfg, flags).newest;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
export function buildFingerprint(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): string {
|
|
3338
|
+
const { parts } = buildInputStats(cwd, cfg, flags);
|
|
3280
3339
|
// Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
|
|
3281
3340
|
let h = 5381;
|
|
3282
3341
|
for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
|
|
3342
|
+
// Mix in the CLI version — a `bunx appwrap` upgrade changes codegen without touching any app source.
|
|
3343
|
+
for (const c of CLI_VERSION) h = (((h << 5) + h) ^ c.charCodeAt(0)) >>> 0;
|
|
3283
3344
|
return h.toString(36);
|
|
3284
3345
|
}
|
|
3285
3346
|
|
|
3347
|
+
/** The iOS build-skip decision — pure, so it is testable without a device, an Xcode toolchain, or an .ipa.
|
|
3348
|
+
*
|
|
3349
|
+
* INVARIANT (the whole point of this function): a skip REQUIRES positive evidence that the existing .ipa
|
|
3350
|
+
* already contains the current inputs. Two forms of evidence are accepted:
|
|
3351
|
+
* 1. fingerprintMatch — the recorded cache fingerprint equals the current one. Strongest; the default.
|
|
3352
|
+
* 2. --resume with NO cache (manual Xcode build / first run) — nothing recorded a fingerprint, so we
|
|
3353
|
+
* fall back to the .ipa being at least as new as every build input. An input touched after the .ipa
|
|
3354
|
+
* was written provably postdates it, so the .ipa cannot contain it → rebuild.
|
|
3355
|
+
* `--resume` relaxes WHICH evidence is required; it never removes the requirement. Before this, form 2
|
|
3356
|
+
* was `resume && !cache` with NO comparison at all — `--resume` silently shipped stale .ipas, the exact
|
|
3357
|
+
* failure the fingerprint exists to kill. Android has no equivalent branch (strictly form 1).
|
|
3358
|
+
*
|
|
3359
|
+
* Honest limit: mtime proves the artifact POSTDATES its inputs, not that it was BUILT from them (a
|
|
3360
|
+
* hand-`touch`ed .ipa still fools it). That is the trust `--resume` explicitly asks for, and it is
|
|
3361
|
+
* strictly stronger than the unconditional skip it replaces. `--force` overrides everything.
|
|
3362
|
+
*
|
|
3363
|
+
* `adoptCache` → record the fingerprint for a form-2 skip, so every LATER run is gated by form 1. */
|
|
3364
|
+
export function decideIosBuildSkip(o: {
|
|
3365
|
+
force: boolean;
|
|
3366
|
+
resume: boolean;
|
|
3367
|
+
fp: string;
|
|
3368
|
+
ipaPath?: string;
|
|
3369
|
+
ipaMtime: number;
|
|
3370
|
+
cache: BuildCache | null;
|
|
3371
|
+
newestInputMtime: () => number;
|
|
3372
|
+
}): { skip: boolean; reason: string; adoptCache: boolean } {
|
|
3373
|
+
if (o.force) return { skip: false, reason: '', adoptCache: false };
|
|
3374
|
+
if (!o.ipaPath) return { skip: false, reason: '', adoptCache: false };
|
|
3375
|
+
if (o.cache) {
|
|
3376
|
+
return o.cache.fingerprint === o.fp && o.cache.artifactPath === o.ipaPath
|
|
3377
|
+
? { skip: true, reason: 'inputs unchanged since last build', adoptCache: false }
|
|
3378
|
+
: { skip: false, reason: '', adoptCache: false };
|
|
3379
|
+
}
|
|
3380
|
+
if (!o.resume) return { skip: false, reason: '', adoptCache: false };
|
|
3381
|
+
return o.ipaMtime >= o.newestInputMtime()
|
|
3382
|
+
? { skip: true, reason: '--resume (no cache; .ipa is newer than every build input)', adoptCache: true }
|
|
3383
|
+
: { skip: false, adoptCache: false, reason: '--resume ignored — the existing .ipa is older than a build input (it cannot contain your latest change); rebuilding.' };
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3286
3386
|
// Per-platform build cache (iOS .ipa / Android .apk) — separate files so the two don't clobber each
|
|
3287
3387
|
// other's fingerprint (that's what enables the build-skip on BOTH platforms).
|
|
3288
3388
|
const buildCacheFile = (platform: string) => `.appwrap-build-cache-${platform}.json`;
|
|
3289
|
-
interface BuildCache { fingerprint: string; artifactPath: string; builtAt: string }
|
|
3389
|
+
export interface BuildCache { fingerprint: string; artifactPath: string; builtAt: string }
|
|
3290
3390
|
|
|
3291
3391
|
function readBuildCache(outDir: string, platform: string): BuildCache | null {
|
|
3292
3392
|
try { return JSON.parse(readFileSync(join(outDir, buildCacheFile(platform)), 'utf8')); } catch { return null; }
|
|
@@ -3577,11 +3677,11 @@ async function deployAndroid(cwd: string, flags: Record<string, string>, cfgOver
|
|
|
3577
3677
|
|
|
3578
3678
|
buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle; server → skip (both printed)
|
|
3579
3679
|
await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist
|
|
3580
|
-
|
|
3680
|
+
stampDeployShellConfig(outDir, cfg); // forces debug:true — see the helper's doc for why + the trap
|
|
3581
3681
|
|
|
3582
3682
|
const apk = join(outDir, 'platforms/android/app/build/outputs/apk/debug/app-debug.apk');
|
|
3583
|
-
// Fingerprint build-skip (parity with deploy ios): skip the gradle build when dist + config
|
|
3584
|
-
// unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
|
|
3683
|
+
// Fingerprint build-skip (parity with deploy ios): skip the gradle build when dist + config + the
|
|
3684
|
+
// appwrap runtime/ are unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
|
|
3585
3685
|
// bumps the fingerprint; pair with --no-web-build to actually hit this fast-path.)
|
|
3586
3686
|
const force = 'force' in flags || 'f' in flags;
|
|
3587
3687
|
const fp = buildFingerprint(cwd, cfg, flags);
|
|
@@ -3651,8 +3751,8 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3651
3751
|
|
|
3652
3752
|
buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle (no stale dist); server → skip (printed)
|
|
3653
3753
|
await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
|
|
3654
|
-
// Dev deploy → debug mode: keep-awake + WebView inspector for
|
|
3655
|
-
|
|
3754
|
+
// Dev deploy → debug mode forced: keep-awake + WebView inspector. See the helper's doc for the trap.
|
|
3755
|
+
stampDeployShellConfig(outDir, cfg);
|
|
3656
3756
|
// Self-heal: drop platforms/ios if a prior `release`/`publish` left App-Store/manual signing there
|
|
3657
3757
|
// (NS preserves it across prepares + it overrides build.xcconfig) → clean automatic dev signing.
|
|
3658
3758
|
resetStaleSigningForAutoLane(outDir, cfg);
|
|
@@ -3660,24 +3760,29 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3660
3760
|
const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
|
|
3661
3761
|
|
|
3662
3762
|
// Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
|
|
3663
|
-
// --resume (-r):
|
|
3664
|
-
//
|
|
3763
|
+
// --resume (-r): additionally allow a skip when there is NO build cache yet (e.g. the .ipa came from a
|
|
3764
|
+
// manual Xcode build, or this is the first run) — gated on the .ipa being newer than every build
|
|
3765
|
+
// input. It relaxes WHICH evidence is required, never whether evidence is required.
|
|
3665
3766
|
// Auto: always checks fingerprint; never skips if sources/deps changed.
|
|
3666
3767
|
const resume = 'resume' in flags || 'r' in flags;
|
|
3667
3768
|
const force = 'force' in flags || 'f' in flags;
|
|
3668
3769
|
const fp = buildFingerprint(cwd, cfg, flags);
|
|
3669
3770
|
const cache = readBuildCache(outDir, 'ios');
|
|
3670
3771
|
const existingIpa = newestIpa(ipaDir);
|
|
3671
|
-
const
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3772
|
+
const decision = decideIosBuildSkip({
|
|
3773
|
+
force, resume, fp,
|
|
3774
|
+
ipaPath: existingIpa ? join(ipaDir, existingIpa) : undefined,
|
|
3775
|
+
ipaMtime: existingIpa ? statSync(join(ipaDir, existingIpa)).mtimeMs : 0,
|
|
3776
|
+
cache,
|
|
3777
|
+
newestInputMtime: () => newestBuildInputMtime(cwd, cfg, flags),
|
|
3778
|
+
});
|
|
3779
|
+
const { skip: canSkipBuild, adoptCache } = decision;
|
|
3676
3780
|
|
|
3677
3781
|
if (canSkipBuild) {
|
|
3678
|
-
|
|
3679
|
-
console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
|
|
3782
|
+
console.log(`⚡ Skipping build — ${decision.reason} (${existingIpa})`);
|
|
3680
3783
|
} else {
|
|
3784
|
+
// Asked to resume but the .ipa predates a build input → say so instead of quietly rebuilding.
|
|
3785
|
+
if (decision.reason) console.log(`↻ ${decision.reason}`);
|
|
3681
3786
|
// Manual signing (signing:'manual') → pass the main-app profile as --provision so NS emits a
|
|
3682
3787
|
// manual exportOptions.plist covering the app + its extensions (see stampManualSigning sidecar).
|
|
3683
3788
|
const nsArgs = ['build', 'ios', '--for-device'];
|
|
@@ -3728,7 +3833,11 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3728
3833
|
const ipa = builtIpa;
|
|
3729
3834
|
if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
|
|
3730
3835
|
const ipaPath = join(ipaDir, ipa);
|
|
3731
|
-
|
|
3836
|
+
// Write the cache after a real build, AND after a --resume skip: adopting the fingerprint converts
|
|
3837
|
+
// that one-time mtime-based trust into a recorded baseline, so EVERY later run is strictly
|
|
3838
|
+
// fingerprint-gated. Without this, --resume stayed in the no-cache branch forever — permanently
|
|
3839
|
+
// ungated. (A fingerprintMatch skip already has an identical cache; nothing to write.)
|
|
3840
|
+
if (!canSkipBuild || adoptCache) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
|
|
3732
3841
|
|
|
3733
3842
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
3734
3843
|
let installedViaUsbmux = false;
|