@livx.cc/appwrap 0.51.3 → 0.53.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/main-page.ts +8 -6
- 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 +118 -9
- package/runtime/app/shell/env-keepawake.ts +94 -0
- package/runtime/app/shell/env-switcher.ts +45 -3
- package/runtime/app/shell/handlers-billing.ts +384 -8
- 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
package/runtime/app/main-page.ts
CHANGED
|
@@ -17,6 +17,7 @@ import './shell/fcm-bootstrap.generated'; // side-effect: registers the FCM serv
|
|
|
17
17
|
import { startEventForwarding } from './shell/events';
|
|
18
18
|
import { startDevMenu } from './shell/devmenu';
|
|
19
19
|
import { showEnvBannerIfActive } from './shell/env-banner';
|
|
20
|
+
import { reassertEnvKeepAwake } from './shell/env-keepawake';
|
|
20
21
|
import { SHELL_CONFIG } from './shell/config';
|
|
21
22
|
import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
|
|
22
23
|
import { CustomWebView } from './shell/custom-webview';
|
|
@@ -95,12 +96,6 @@ export function onPageLoaded(args: EventData): void {
|
|
|
95
96
|
if (initialized) return;
|
|
96
97
|
initialized = true;
|
|
97
98
|
|
|
98
|
-
// Debug mode: keep the screen awake (no auto-lock while foreground) so the dev/inspect session
|
|
99
|
-
// and the iterate loop stay alive. iOS via the idle timer; Android via WebView keepScreenOn.
|
|
100
|
-
if (isIOS && SHELL_CONFIG.debug) {
|
|
101
|
-
try { UIApplication.sharedApplication.idleTimerDisabled = true; } catch (e) { /* no-op */ }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
99
|
registerHandlers();
|
|
105
100
|
registerExtendedHandlers();
|
|
106
101
|
registerParityHandlers();
|
|
@@ -138,6 +133,9 @@ export function onPageLoaded(args: EventData): void {
|
|
|
138
133
|
// Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
|
|
139
134
|
// active (env-switcher only). Bottom-safe-area, auto-shrinks to a pill after 3s. No-op otherwise.
|
|
140
135
|
showEnvBannerIfActive();
|
|
136
|
+
// Keep the screen awake while pointed at a NON-DEFAULT backend (or in a debug build) — same signal as the
|
|
137
|
+
// banner above, so "banner showing" ⟺ "no auto-lock". Subsumes the old iOS-only debug idle-timer block.
|
|
138
|
+
reassertEnvKeepAwake();
|
|
141
139
|
|
|
142
140
|
// Halt the WebView render + JS-timer pipeline while backgrounded so a page running a continuous
|
|
143
141
|
// animation (Android doesn't auto-pause rAF off-screen) stops burning CPU/battery. No-op on iOS.
|
|
@@ -147,6 +145,10 @@ export function onPageLoaded(args: EventData): void {
|
|
|
147
145
|
// iOS: wake the WebContent renderer NOW (it stays THROTTLED for ~30-45s after a full-window system
|
|
148
146
|
// surface — StoreKit manage-subscriptions sheet, itms-apps deep link, backgrounding — froze it).
|
|
149
147
|
webView.wakeWebContent();
|
|
148
|
+
// Neither platform guarantees the wake lock survives a background trip (Android drops the window flag
|
|
149
|
+
// outright if the Activity was recreated). Assert-only: never write `false`, so an app driving the
|
|
150
|
+
// public `ui.keepAwake` bridge API itself (e.g. during video) isn't clobbered on resume.
|
|
151
|
+
reassertEnvKeepAwake();
|
|
150
152
|
});
|
|
151
153
|
|
|
152
154
|
// DEBUG: trace the iOS app-lifecycle event sequence to the pullable log sink so we can see WHICH
|
|
@@ -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,17 +427,93 @@ 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
|
};
|
|
422
445
|
|
|
446
|
+
// Main-frame load FAILURE → NativeScript's `loadFinished` event (its `error` arg). We replace NS's own
|
|
447
|
+
// WebViewClientImpl wholesale (see initNativeView), and NS only raises that event from ITS client — so
|
|
448
|
+
// without this forward the event NEVER fires on Android and a failed load (server down, DNS, refused)
|
|
449
|
+
// is invisible to JS. iOS keeps the event because DevCertNavDelegate forwards didFail*Navigation to
|
|
450
|
+
// NS's delegate; this restores the SAME seam here rather than adding a parallel Android-only one
|
|
451
|
+
// (consumer: env-switcher's reloadToEffective).
|
|
452
|
+
// MAIN FRAME ONLY: API 23+ also reports sub-resource failures here, and a dead favicon/image must not
|
|
453
|
+
// be mistaken for "the page didn't load". minSdk is 26, so only this modern overload is ever dispatched
|
|
454
|
+
// (the legacy string-arg overload is API < 23).
|
|
455
|
+
const receivedError = (
|
|
456
|
+
view: android.webkit.WebView,
|
|
457
|
+
request: android.webkit.WebResourceRequest,
|
|
458
|
+
error: android.webkit.WebResourceError
|
|
459
|
+
): void => {
|
|
460
|
+
if (!request?.isForMainFrame?.()) return;
|
|
461
|
+
// `_onLoadFinished` is NS's internal event-raiser (the `_` API index.android.js itself calls) — not
|
|
462
|
+
// in the public typings, hence the cast.
|
|
463
|
+
(CustomWebView.forNative(view) as any)?._onLoadFinished(
|
|
464
|
+
String(request.getUrl?.() ?? ''),
|
|
465
|
+
`${error?.getDescription?.() ?? 'Load failed'} (${error?.getErrorCode?.() ?? '?'})`
|
|
466
|
+
);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Main-frame load SUCCESS → NativeScript's `loadFinished` event with NO `error` arg. The counterpart of
|
|
470
|
+
// `receivedError` above, and NOT optional: NS raises this from ITS WebViewClientImpl.onPageFinished,
|
|
471
|
+
// which we replace wholesale — so forwarding only the FAILURE half would leave `loadFinished` never
|
|
472
|
+
// firing on a SUCCESSFUL load. A one-shot `loadFinished` listener (env-switcher's reloadToEffective)
|
|
473
|
+
// would then never unhook on success and would leak onto the NEXT navigation, blaming a later error on
|
|
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).
|
|
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
|
+
}
|
|
490
|
+
// Mirrors NS's own `owner._onLoadFinished(url, undefined)` — absent `error` is what marks success.
|
|
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',
|
|
502
|
+
};
|
|
503
|
+
|
|
423
504
|
// DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
|
|
424
505
|
// points at a LAN dev server that almost always uses a self-signed / mkcert TLS cert the device's
|
|
425
506
|
// trust store doesn't know — the WebView would otherwise hard-fail with ERR_CERT_AUTHORITY_INVALID.
|
|
426
507
|
// Proceed past it ONLY in a debug build, only in server-loader mode, AND only for the ONE host the
|
|
427
508
|
// build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) — never blanket-trust every host. Production app:// builds
|
|
428
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.
|
|
429
515
|
const receivedSslError = (
|
|
516
|
+
view: android.webkit.WebView,
|
|
430
517
|
handler: android.webkit.SslErrorHandler,
|
|
431
518
|
error: android.net.http.SslError
|
|
432
519
|
): void => {
|
|
@@ -435,14 +522,20 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
435
522
|
// Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
|
|
436
523
|
// resources on an alt port share one self-signed cert) — same `hostOf` normalization + port strip.
|
|
437
524
|
const stripPort = (h: string) => h.replace(/:\d+$/, '');
|
|
525
|
+
const errUrl = String(error?.getUrl?.() ?? '');
|
|
438
526
|
const allowedHost = stripPort(hostOf(SHELL_CONFIG.serverUrl));
|
|
439
|
-
const errHost = stripPort(hostOf(
|
|
527
|
+
const errHost = stripPort(hostOf(errUrl));
|
|
440
528
|
if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server' && !!allowedHost && errHost === allowedHost) {
|
|
441
529
|
console.warn('AppWrap: trusting self-signed dev-server cert (debug + host-scoped):', errHost);
|
|
442
530
|
handler.proceed();
|
|
443
|
-
|
|
444
|
-
handler.cancel();
|
|
531
|
+
return;
|
|
445
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();
|
|
446
539
|
};
|
|
447
540
|
|
|
448
541
|
// TWO literals, selected by loader — NOT one literal with a conditional spread. Both the omission and
|
|
@@ -458,8 +551,12 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
458
551
|
// declared later in the bundle (this cost us a FATAL `LookedUpClassNotFound:
|
|
459
552
|
// cc.livx.appwrap.AppwrapMessagingService` boot crash that built green). For the same reason don't
|
|
460
553
|
// hoist the object into a variable and pass `.extend(methods)`: SBG requires an ObjectExpression at
|
|
461
|
-
// the call site and silently skips anything else.
|
|
462
|
-
//
|
|
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.
|
|
463
560
|
assetClientClass = SHELL_CONFIG.loader === 'server'
|
|
464
561
|
? (android.webkit.WebViewClient as any).extend({
|
|
465
562
|
shouldOverrideUrlLoading(view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): boolean {
|
|
@@ -468,8 +565,14 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
468
565
|
onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
|
|
469
566
|
pageStarted(view);
|
|
470
567
|
},
|
|
471
|
-
|
|
472
|
-
|
|
568
|
+
onPageFinished(view: android.webkit.WebView, url: string): void {
|
|
569
|
+
pageFinished(view, url);
|
|
570
|
+
},
|
|
571
|
+
onReceivedSslError(view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
|
|
572
|
+
receivedSslError(view, handler, error);
|
|
573
|
+
},
|
|
574
|
+
onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
|
|
575
|
+
receivedError(view, request, error);
|
|
473
576
|
},
|
|
474
577
|
})
|
|
475
578
|
: (android.webkit.WebViewClient as any).extend({
|
|
@@ -482,8 +585,14 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
482
585
|
onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
|
|
483
586
|
pageStarted(view);
|
|
484
587
|
},
|
|
485
|
-
|
|
486
|
-
|
|
588
|
+
onPageFinished(view: android.webkit.WebView, url: string): void {
|
|
589
|
+
pageFinished(view, url);
|
|
590
|
+
},
|
|
591
|
+
onReceivedSslError(view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
|
|
592
|
+
receivedSslError(view, handler, error);
|
|
593
|
+
},
|
|
594
|
+
onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
|
|
595
|
+
receivedError(view, request, error);
|
|
487
596
|
},
|
|
488
597
|
});
|
|
489
598
|
return new assetClientClass();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { SHELL_CONFIG } from './config';
|
|
3
|
+
import { isNonDefaultOverride } from './env-switcher';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keep the screen awake while the shell is pointed at a NON-PRODUCTION backend, so a long on-device test
|
|
7
|
+
* session isn't killed by auto-lock. Companion to `env-banner.ts`: same trigger, same reconcile points.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE BACKEND, NOT THE BUILD: `SHELL_CONFIG.debug` is a BUILD-TIME fact, and it misses the case that
|
|
10
|
+
* actually matters — a RELEASE/TestFlight binary (`debug:false`) whose env-switcher is pointed at lab or a
|
|
11
|
+
* local dev server is a test session in every way that counts, and it auto-locks mid-test. Conversely the
|
|
12
|
+
* signal must never fire for a real user. `isNonDefaultOverride()` is exactly that line: it is true only
|
|
13
|
+
* when a persisted `kit:serverUrlOverride` resolves to a host DIFFERENT from the build-time default
|
|
14
|
+
* (`SHELL_CONFIG.serverUrl`). A real user never sets an override, so this cannot leak into production —
|
|
15
|
+
* and it is the SAME predicate the amber env banner keys off, so "banner visible" ⟺ "screen stays awake",
|
|
16
|
+
* by construction rather than by two rules kept in sync by hand.
|
|
17
|
+
*
|
|
18
|
+
* `debug` is retained as an OR, not a replacement: a debug build has its own reason to stay awake (the
|
|
19
|
+
* inspect/iterate loop) even on the default env. This subsumes the iOS-only `idleTimerDisabled` block that
|
|
20
|
+
* used to sit inline in main-page.ts; the debug-gated Android half still lives in `custom-webview.android.ts`
|
|
21
|
+
* (`wv.setKeepScreenOn(true)`), which is harmlessly redundant with this module's Android path in a debug
|
|
22
|
+
* build — both are idempotent "keep on" assertions, and only this one also covers the env-override case.
|
|
23
|
+
*
|
|
24
|
+
* Inert for `loader !== 'server'` / no envSwitcher config: `isNonDefaultOverride()` returns false via
|
|
25
|
+
* `isEnvSwitcherEnabled()`, so a non-switcher app reduces to the plain `debug` behaviour (no crash).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Desired wake-lock state: a non-default backend override (the banner's signal), or a debug build. */
|
|
29
|
+
export function shouldKeepAwake(): boolean {
|
|
30
|
+
return !!SHELL_CONFIG.debug || isNonDefaultOverride();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let androidRetries = 0; // bounds the boot "activity not ready" retry so it can't spin forever
|
|
34
|
+
|
|
35
|
+
/** Apply the wake lock natively. iOS: the app-wide idle timer. Android: the window's KEEP_SCREEN_ON flag.
|
|
36
|
+
*
|
|
37
|
+
* The Android boot retry mirrors `env-banner.ts`: at `onPageLoaded` on a relaunch the Activity is not
|
|
38
|
+
* necessarily attached yet, so `foregroundActivity` can be null. Returning silently there would drop the
|
|
39
|
+
* wake lock in exactly the common case (relaunch straight into lab) — so retry, bounded (~2s), and
|
|
40
|
+
* re-read the DESIRED state at each attempt so a switch/reset landing mid-window wins over a stale one. */
|
|
41
|
+
function applyKeepAwake(on: boolean): void {
|
|
42
|
+
if (isIOS) {
|
|
43
|
+
Utils.dispatchToMainThread(() => {
|
|
44
|
+
try { UIApplication.sharedApplication.idleTimerDisabled = on; } catch (e) { /* no-op */ }
|
|
45
|
+
});
|
|
46
|
+
} else if (isAndroid) {
|
|
47
|
+
const activity = Application.android?.foregroundActivity || Application.android?.startActivity;
|
|
48
|
+
if (!activity) {
|
|
49
|
+
if (androidRetries++ < 20) setTimeout(() => applyKeepAwake(shouldKeepAwake()), 100);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
androidRetries = 0;
|
|
53
|
+
activity.runOnUiThread(new java.lang.Runnable({
|
|
54
|
+
run() {
|
|
55
|
+
try {
|
|
56
|
+
const window = activity.getWindow();
|
|
57
|
+
if (!window) return;
|
|
58
|
+
const flag = android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
|
|
59
|
+
on ? window.addFlags(flag) : window.clearFlags(flag);
|
|
60
|
+
} catch (e) { /* no-op */ }
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reconcile the wake lock to the EXACT desired state — including turning it OFF. For explicit env
|
|
68
|
+
* transitions only (`applySwitch`, alongside `refreshEnvBanner`), where clearing on a reset-to-default is
|
|
69
|
+
* the whole point. Idempotent.
|
|
70
|
+
*/
|
|
71
|
+
export function refreshEnvKeepAwake(): void {
|
|
72
|
+
applyKeepAwake(shouldKeepAwake());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Assert the wake lock if this env wants it — but NEVER write `false`. For the boot and resume paths.
|
|
77
|
+
*
|
|
78
|
+
* WHY ASSERT-ONLY, not a full reconcile: the hosted app may hold the screen on ITSELF — either via the
|
|
79
|
+
* PUBLIC `ui.keepAwake` bridge API (handlers-extended / handlers-android), or via the web Screen Wake Lock
|
|
80
|
+
* API (`navigator.wakeLock`), which Android WebView honours by keeping the same KEEP_SCREEN_ON state. That
|
|
81
|
+
* is not hypothetical: the AGF app holds a `navigator.wakeLock` for the duration of a live room, and the
|
|
82
|
+
* flag is observable on its window (`dumpsys window` → `fl=KEEP_SCREEN_ON`) with no override set at all.
|
|
83
|
+
* A full reconcile on resume would write `false` on the default env and silently CLOBBER that app's own
|
|
84
|
+
* lock after any background trip. Writing `false` here would also buy nothing: a fresh process
|
|
85
|
+
* starts with the idle timer enabled and a fresh Activity window has no KEEP_SCREEN_ON flag, so "off" is
|
|
86
|
+
* already the state at boot. Only the ON direction needs re-asserting.
|
|
87
|
+
*
|
|
88
|
+
* ON DOES need re-asserting: neither platform guarantees the lock survives a background trip. Android
|
|
89
|
+
* drops the window flag outright if the Activity is recreated (config change / process-death restore) —
|
|
90
|
+
* a NEW window has no flag, and `initialized` in main-page.ts would not re-run the boot path.
|
|
91
|
+
*/
|
|
92
|
+
export function reassertEnvKeepAwake(): void {
|
|
93
|
+
if (shouldKeepAwake()) applyKeepAwake(true);
|
|
94
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ApplicationSettings, Dialogs, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import type { EventData, LoadEventData } from '@nativescript/core';
|
|
2
3
|
import { SHELL_CONFIG } from './config';
|
|
3
4
|
import { OVERRIDE_KEY, effectiveServerUrl, isUrlAllowed } from './server-url';
|
|
4
5
|
import { bridge } from './bridge';
|
|
5
6
|
import { refreshEnvBanner } from './env-banner';
|
|
7
|
+
import { refreshEnvKeepAwake } from './env-keepawake';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Runtime env-switcher — re-point a `loader:'server'` shell between declared environments (prod / lab /
|
|
@@ -72,12 +74,51 @@ function clearOverride(): void {
|
|
|
72
74
|
ApplicationSettings.remove(OVERRIDE_KEY);
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The switch is PERSISTED but the visible page did NOT change — the exact gap the confirm prompt's "The
|
|
79
|
+
* app will reload" leaves the user staring at. Silence here reads as "the switch didn't work" (it did:
|
|
80
|
+
* the override is already written, so a cold start WILL land on it), which is the actionable half and the
|
|
81
|
+
* half that was missing. Name the host and the reason so an unreachable target (dev server down, DNS,
|
|
82
|
+
* TLS/ATS refusal) is distinguishable from a broken switcher.
|
|
83
|
+
*/
|
|
84
|
+
function notifyNotReloaded(url: string, reason: string): void {
|
|
85
|
+
console.warn(`AppWrap: env switch persisted but the in-session reload did not happen (${hostOf(url)}): ${reason}`);
|
|
86
|
+
void Dialogs.alert({
|
|
87
|
+
title: 'Still on the old environment',
|
|
88
|
+
message: `Couldn't load ${hostOf(url)}.\n${reason}\n\nThe environment change IS saved — relaunch the app to use it.`,
|
|
89
|
+
okButtonText: 'OK',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
/** Load the current effective server URL into the live WebView (immediate switch — no wait for a cold
|
|
76
|
-
* start; the persisted override also makes it stick across relaunch via the boot loader).
|
|
94
|
+
* start; the persisted override also makes it stick across relaunch via the boot loader).
|
|
95
|
+
*
|
|
96
|
+
* NEVER fail silently: both the no-WebView path and a FAILED load report through `notifyNotReloaded`.
|
|
97
|
+
* The failure seam is NativeScript's own `loadFinished` event, whose `error` arg both platforms already
|
|
98
|
+
* populate for a main-frame failure (iOS: WKNavigationDelegate didFail[Provisional]Navigation — a refused
|
|
99
|
+
* connection is PROVISIONAL, which is exactly the case that bit; Android: WebViewClient.onReceivedError,
|
|
100
|
+
* re-wired to NS in custom-webview.android.ts because our client replaces NS's). Reusing it keeps ONE
|
|
101
|
+
* cross-platform seam — do NOT install a second WKNavigationDelegate here: custom-webview.ios.ts already
|
|
102
|
+
* owns one (DevCertNavDelegate) and a competing delegate would silently unhook its cert trust.
|
|
103
|
+
*
|
|
104
|
+
* The handler is ONE-SHOT and armed immediately before the load, so it observes this switch's outcome and
|
|
105
|
+
* cannot leak or mis-attribute a later navigation's error to the switch.
|
|
106
|
+
*/
|
|
77
107
|
function reloadToEffective(): void {
|
|
78
|
-
const wv = bridge.getWebView();
|
|
79
|
-
if (!wv) return;
|
|
80
108
|
const url = effectiveServerUrl();
|
|
109
|
+
const wv = bridge.getWebView();
|
|
110
|
+
if (!wv) {
|
|
111
|
+
notifyNotReloaded(url, 'The app view is not available.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// `on` resolves to Observable's generic overload here (CustomWebView widens the WebView-specific one),
|
|
115
|
+
// so take EventData and narrow — `loadFinished` always carries LoadEventData.
|
|
116
|
+
const onLoadFinished = (args: EventData) => {
|
|
117
|
+
wv.off('loadFinished', onLoadFinished);
|
|
118
|
+
const error = (args as LoadEventData).error;
|
|
119
|
+
if (error) notifyNotReloaded(url, String(error));
|
|
120
|
+
};
|
|
121
|
+
wv.on('loadFinished', onLoadFinished);
|
|
81
122
|
Utils.dispatchToMainThread(() => {
|
|
82
123
|
if (isIOS && wv.ios) {
|
|
83
124
|
(wv.ios as WKWebView).loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(url)));
|
|
@@ -104,6 +145,7 @@ async function applySwitch(url: string | null, label: string): Promise<void> {
|
|
|
104
145
|
else clearOverride();
|
|
105
146
|
reloadToEffective();
|
|
106
147
|
refreshEnvBanner(); // in-session: reflect the new env (switch) or hide (reset) — not just on relaunch
|
|
148
|
+
refreshEnvKeepAwake(); // same signal as the banner: awake off-default, normal on a reset — never disagree
|
|
107
149
|
}
|
|
108
150
|
|
|
109
151
|
let menuOpen = false;
|
|
@@ -1,18 +1,29 @@
|
|
|
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
|
|
@@ -102,6 +113,8 @@ function ensureIosDelegates(): void {
|
|
|
102
113
|
}
|
|
103
114
|
case STATE.Failed: {
|
|
104
115
|
queue.finishTransaction(t);
|
|
116
|
+
// 'DENIED' here means exactly ONE thing: the user dismissed the sheet. Anything the
|
|
117
|
+
// device refuses outright (payments restricted) is 'UNSUPPORTED' — see billing.purchase.
|
|
105
118
|
const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
|
|
106
119
|
const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
|
|
107
120
|
purchaseWaiters.get(pid)?.reject(e);
|
|
@@ -170,8 +183,11 @@ function requestSKProducts(ids: string[]): Promise<SKProduct[]> {
|
|
|
170
183
|
}
|
|
171
184
|
|
|
172
185
|
export function registerBillingHandlers(): void {
|
|
173
|
-
if (
|
|
186
|
+
if (isIOS) registerIosBilling();
|
|
187
|
+
else if (isAndroid) registerAndroidBilling();
|
|
188
|
+
}
|
|
174
189
|
|
|
190
|
+
function registerIosBilling(): void {
|
|
175
191
|
ensureIosDelegates();
|
|
176
192
|
observer = buildObserver();
|
|
177
193
|
SKPaymentQueue.defaultQueue().addTransactionObserver(observer);
|
|
@@ -182,7 +198,11 @@ export function registerBillingHandlers(): void {
|
|
|
182
198
|
});
|
|
183
199
|
|
|
184
200
|
bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
|
|
185
|
-
|
|
201
|
+
// 'UNSUPPORTED', not 'DENIED': this device CANNOT pay at all (Screen Time / parental controls
|
|
202
|
+
// / MDM restriction) — a standing condition retrying will never clear. 'DENIED' is reserved for
|
|
203
|
+
// the user dismissing the StoreKit sheet (SKErrorPaymentCancelled, below), which is a per-attempt
|
|
204
|
+
// choice. Conflating them made consumers silently swallow this as a cancel and strand the user.
|
|
205
|
+
if (!SKPaymentQueue.canMakePayments()) throw err('UNSUPPORTED', 'Purchases are disabled on this device');
|
|
186
206
|
const sk = (await requestSKProducts([productId]))[0];
|
|
187
207
|
if (!sk) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
|
|
188
208
|
return new Promise((resolve, reject) => {
|
|
@@ -251,3 +271,359 @@ function restoreTransactions(): Promise<any[]> {
|
|
|
251
271
|
SKPaymentQueue.defaultQueue().restoreCompletedTransactions();
|
|
252
272
|
});
|
|
253
273
|
}
|
|
274
|
+
|
|
275
|
+
// ─────────────────────────── Android — Google Play Billing ───────────────────────────
|
|
276
|
+
// Play Billing Library v7. All com.android.billingclient.* access is lazy (inside handler bodies)
|
|
277
|
+
// so the shared module loads on iOS and a non-billing Android build never links the class.
|
|
278
|
+
//
|
|
279
|
+
// ACKNOWLEDGEMENT DECISION (money-critical): Play auto-refunds any purchase not acknowledged within
|
|
280
|
+
// 3 days. We acknowledge CLIENT-SIDE here (BillingClient.acknowledgePurchase) immediately after a
|
|
281
|
+
// PURCHASED update and on restore — the simplest correct choice, since this handler owns the
|
|
282
|
+
// BillingClient. The AGF server validator (PlayStoreTokenValidator) MAY also acknowledge via the
|
|
283
|
+
// Play Developer API; a second acknowledge is a harmless no-op on Google's side (idempotent), so
|
|
284
|
+
// the two can coexist. Tradeoff: we ack before the server confirms the grant, so a failed server
|
|
285
|
+
// validation still consumes the purchase (no auto-refund) — acceptable because the server is the
|
|
286
|
+
// source-of-record and re-validates the token on demand. If the server takes over acknowledgement
|
|
287
|
+
// exclusively, delete acknowledgeAndroidPurchase() calls here.
|
|
288
|
+
|
|
289
|
+
// BillingResponseCode (com.android.billingclient.api.BillingClient.BillingResponseCode).
|
|
290
|
+
const PLAY_RESP = {
|
|
291
|
+
OK: 0, USER_CANCELED: 1, SERVICE_UNAVAILABLE: 2, BILLING_UNAVAILABLE: 3, ITEM_UNAVAILABLE: 4,
|
|
292
|
+
DEVELOPER_ERROR: 5, ERROR: 6, ITEM_ALREADY_OWNED: 7, FEATURE_NOT_SUPPORTED: -2, SERVICE_DISCONNECTED: -1,
|
|
293
|
+
};
|
|
294
|
+
const PURCHASE_STATE_PURCHASED = 1; // com.android.billingclient.api.Purchase.PurchaseState.PURCHASED
|
|
295
|
+
|
|
296
|
+
let billingClient: any = null; // com.android.billingclient.api.BillingClient
|
|
297
|
+
let clientConnecting: Promise<any> | null = null;
|
|
298
|
+
// Purchases arrive asynchronously via the single PurchasesUpdatedListener; route them to the
|
|
299
|
+
// in-flight purchase() caller by product id (module scope, like the iOS purchaseWaiters).
|
|
300
|
+
const androidPurchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
|
|
301
|
+
// Single-purchase gate: with only one in-flight purchase, "reject all on error" (which lacks product
|
|
302
|
+
// context) can never reject a DIFFERENT caller's promise. Set synchronously in billing.purchase.
|
|
303
|
+
let androidPurchaseInFlight = false;
|
|
304
|
+
|
|
305
|
+
function billingApi(): any {
|
|
306
|
+
return com.android.billingclient.api;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function packageName(): string {
|
|
310
|
+
return String(Utils.android.getApplicationContext().getPackageName());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Java List<T> from a JS array (Play Billing builders take java.util.List, not a marshaled JS array). */
|
|
314
|
+
function toJavaList(items: any[]): any {
|
|
315
|
+
const list = new java.util.ArrayList();
|
|
316
|
+
for (const it of items) list.add(it);
|
|
317
|
+
return list;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Map a Play BillingResult to a bridge error, mirroring the iOS cancel/unsupported semantics. */
|
|
321
|
+
function mapBillingErr(result: any): Error {
|
|
322
|
+
const code = result?.getResponseCode?.();
|
|
323
|
+
const msg = String(result?.getDebugMessage?.() ?? 'Play Billing error');
|
|
324
|
+
if (code === PLAY_RESP.USER_CANCELED) return err('DENIED', 'Purchase cancelled');
|
|
325
|
+
if (code === PLAY_RESP.BILLING_UNAVAILABLE || code === PLAY_RESP.FEATURE_NOT_SUPPORTED)
|
|
326
|
+
return err('UNSUPPORTED', msg || 'Billing unavailable on this device');
|
|
327
|
+
return err('NATIVE_ERROR', `${msg} (code ${code})`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Lazily build + connect the BillingClient. Resolves when the service is READY. */
|
|
331
|
+
function ensureAndroidClient(): Promise<any> {
|
|
332
|
+
if (billingClient && billingClient.isReady()) return Promise.resolve(billingClient);
|
|
333
|
+
if (clientConnecting) return clientConnecting;
|
|
334
|
+
|
|
335
|
+
clientConnecting = new Promise((resolve, reject) => {
|
|
336
|
+
const api = billingApi();
|
|
337
|
+
const ctx = Utils.android.getApplicationContext();
|
|
338
|
+
// v7 requires PendingPurchasesParams; enableOneTimeProducts() is harmless for a subs-only app
|
|
339
|
+
// (pending purchases are always enabled for subscriptions).
|
|
340
|
+
const pending = api.PendingPurchasesParams.newBuilder().enableOneTimeProducts().build();
|
|
341
|
+
const listener = new api.PurchasesUpdatedListener({
|
|
342
|
+
onPurchasesUpdated(result: any, purchases: any) {
|
|
343
|
+
onAndroidPurchasesUpdated(result, purchases);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
billingClient = api.BillingClient.newBuilder(ctx)
|
|
347
|
+
.setListener(listener)
|
|
348
|
+
.enablePendingPurchases(pending)
|
|
349
|
+
.build();
|
|
350
|
+
billingClient.startConnection(new api.BillingClientStateListener({
|
|
351
|
+
onBillingSetupFinished(result: any) {
|
|
352
|
+
if (result.getResponseCode() === PLAY_RESP.OK) resolve(billingClient);
|
|
353
|
+
else reject(mapBillingErr(result));
|
|
354
|
+
},
|
|
355
|
+
onBillingServiceDisconnected() {
|
|
356
|
+
// Force a fresh connection on the next call (Google recommends reconnect-on-demand).
|
|
357
|
+
clientConnecting = null;
|
|
358
|
+
},
|
|
359
|
+
}));
|
|
360
|
+
}).catch((e) => {
|
|
361
|
+
clientConnecting = null;
|
|
362
|
+
throw e;
|
|
363
|
+
});
|
|
364
|
+
return clientConnecting;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Query one product's ProductDetails (SUBS). */
|
|
368
|
+
function queryAndroidProductDetails(ids: string[]): Promise<any[]> {
|
|
369
|
+
return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
|
|
370
|
+
const api = billingApi();
|
|
371
|
+
const products = ids.map((id) =>
|
|
372
|
+
api.QueryProductDetailsParams.Product.newBuilder()
|
|
373
|
+
.setProductId(id)
|
|
374
|
+
.setProductType(api.BillingClient.ProductType.SUBS)
|
|
375
|
+
.build());
|
|
376
|
+
const params = api.QueryProductDetailsParams.newBuilder().setProductList(toJavaList(products)).build();
|
|
377
|
+
client.queryProductDetailsAsync(params, new api.ProductDetailsResponseListener({
|
|
378
|
+
onProductDetailsResponse(result: any, list: any) {
|
|
379
|
+
if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
|
|
380
|
+
const out: any[] = [];
|
|
381
|
+
const n = list ? list.size() : 0;
|
|
382
|
+
for (let i = 0; i < n; i++) out.push(list.get(i));
|
|
383
|
+
resolve(out);
|
|
384
|
+
},
|
|
385
|
+
}));
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Flatten a ProductDetails' SubscriptionOfferDetails into pure {offerId, phases} + a ref to the
|
|
390
|
+
* native offer (so the caller can read its token / native phases after selection). */
|
|
391
|
+
function androidOfferCandidates(pd: any): Array<OfferCandidate & { native: any }> {
|
|
392
|
+
const offers = pd.getSubscriptionOfferDetails();
|
|
393
|
+
const out: Array<OfferCandidate & { native: any }> = [];
|
|
394
|
+
const n = offers ? offers.size() : 0;
|
|
395
|
+
for (let i = 0; i < n; i++) {
|
|
396
|
+
const o = offers.get(i);
|
|
397
|
+
const list = o.getPricingPhases().getPricingPhaseList();
|
|
398
|
+
const phases = [];
|
|
399
|
+
for (let j = 0; j < list.size(); j++) {
|
|
400
|
+
const ph = list.get(j);
|
|
401
|
+
phases.push({
|
|
402
|
+
priceAmountMicros: Number(ph.getPriceAmountMicros()),
|
|
403
|
+
billingPeriod: String(ph.getBillingPeriod() ?? ''),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const id = o.getOfferId ? o.getOfferId() : null;
|
|
407
|
+
out.push({ offerId: id != null ? String(id) : null, phases, native: o });
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** First native pricing phase whose micros satisfy `pred`. */
|
|
413
|
+
function pickNativePhase(list: any, pred: (micros: number) => boolean): any {
|
|
414
|
+
for (let i = 0; i < list.size(); i++) {
|
|
415
|
+
const p = list.get(i);
|
|
416
|
+
if (pred(Number(p.getPriceAmountMicros()))) return p;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** ProductDetails → the platform-neutral Product shape, from the SELECTED offer (trial-aware). */
|
|
422
|
+
function mapAndroidProduct(pd: any): any {
|
|
423
|
+
const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
|
|
424
|
+
if (!selected) return null; // not a subscription / no offer
|
|
425
|
+
const list = selected.native.getPricingPhases().getPricingPhaseList();
|
|
426
|
+
// Recurring (paid) phase for the display price; fall back to the last phase.
|
|
427
|
+
const phase = pickNativePhase(list, (m) => m > 0) ?? list.get(list.size() - 1);
|
|
428
|
+
const period = String(phase.getBillingPeriod() ?? '');
|
|
429
|
+
const product: any = {
|
|
430
|
+
id: String(pd.getProductId()),
|
|
431
|
+
title: String(pd.getTitle() ?? pd.getName() ?? ''),
|
|
432
|
+
description: String(pd.getDescription() ?? ''),
|
|
433
|
+
price: Number(phase.getPriceAmountMicros()) / 1_000_000,
|
|
434
|
+
displayPrice: String(phase.getFormattedPrice() ?? ''),
|
|
435
|
+
currency: String(phase.getPriceCurrencyCode() ?? ''),
|
|
436
|
+
type: 'autoRenewable',
|
|
437
|
+
subscriptionPeriod: period || undefined,
|
|
438
|
+
};
|
|
439
|
+
// Surface the free-trial / intro phase so the UI can show trial terms (Play also shows them in its
|
|
440
|
+
// own sheet). Populated only when the selected offer carries a zero-price phase (trial-eligible).
|
|
441
|
+
const intro = pickNativePhase(list, (m) => m === 0);
|
|
442
|
+
if (intro) {
|
|
443
|
+
const introPeriod = String(intro.getBillingPeriod() ?? '');
|
|
444
|
+
product.introOffer = {
|
|
445
|
+
price: Number(intro.getPriceAmountMicros()) / 1_000_000,
|
|
446
|
+
displayPrice: String(intro.getFormattedPrice() ?? ''),
|
|
447
|
+
period: introPeriod || undefined,
|
|
448
|
+
isFreeTrial: Number(intro.getPriceAmountMicros()) === 0,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return product;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Play Purchase → PurchaseReceipt (the token is the critical field the server validator consumes). */
|
|
455
|
+
function androidReceiptFor(purchase: any, productId: string): any {
|
|
456
|
+
return {
|
|
457
|
+
platform: 'android',
|
|
458
|
+
productId,
|
|
459
|
+
transactionId: String(purchase.getOrderId?.() ?? ''),
|
|
460
|
+
purchaseToken: String(purchase.getPurchaseToken()),
|
|
461
|
+
packageName: String(purchase.getPackageName?.() ?? packageName()),
|
|
462
|
+
raw: {
|
|
463
|
+
originalJson: String(purchase.getOriginalJson?.() ?? ''),
|
|
464
|
+
signature: String(purchase.getSignature?.() ?? ''),
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Client-side acknowledge (see ACKNOWLEDGEMENT DECISION above). Fire-and-forget; logs on failure. */
|
|
470
|
+
function acknowledgeAndroidPurchase(purchase: any): void {
|
|
471
|
+
try {
|
|
472
|
+
if (purchase.isAcknowledged()) return;
|
|
473
|
+
const api = billingApi();
|
|
474
|
+
const params = api.AcknowledgePurchaseParams.newBuilder()
|
|
475
|
+
.setPurchaseToken(purchase.getPurchaseToken())
|
|
476
|
+
.build();
|
|
477
|
+
billingClient.acknowledgePurchase(params, new api.AcknowledgePurchaseResponseListener({
|
|
478
|
+
onAcknowledgePurchaseResponse(result: any) {
|
|
479
|
+
if (result.getResponseCode() !== PLAY_RESP.OK) {
|
|
480
|
+
console.warn('AppWrap: billing acknowledge failed', result.getResponseCode(), String(result.getDebugMessage?.() ?? ''));
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
}));
|
|
484
|
+
} catch (e) {
|
|
485
|
+
console.warn('AppWrap: billing acknowledge threw', e);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The single PurchasesUpdatedListener callback — routes results to the purchase() waiter(s). */
|
|
490
|
+
function onAndroidPurchasesUpdated(result: any, purchases: any): void {
|
|
491
|
+
const code = result.getResponseCode();
|
|
492
|
+
if (code !== PLAY_RESP.OK || !purchases) {
|
|
493
|
+
// No product context here (Play doesn't say which flow) → reject all in-flight purchases with the
|
|
494
|
+
// mapped error (USER_CANCELED → DENIED, mirroring iOS' cancel semantics).
|
|
495
|
+
const e = mapBillingErr(result);
|
|
496
|
+
for (const w of androidPurchaseWaiters.values()) w.reject(e);
|
|
497
|
+
androidPurchaseWaiters.clear();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const n = purchases.size();
|
|
501
|
+
for (let i = 0; i < n; i++) {
|
|
502
|
+
const purchase = purchases.get(i);
|
|
503
|
+
// PENDING (cash/bank/parental-approval) → don't resolve/ack/grant now. This NATIVE waiter has NO
|
|
504
|
+
// timeout; the JS `invoke` rejects at 120s, so by the time Play flips PENDING→PURCHASED (hours/
|
|
505
|
+
// days later) the purchase() promise is long gone. The completion is recoverable ONLY via the
|
|
506
|
+
// billing.transaction event emitted below when this listener fires again with PURCHASED — NOT via
|
|
507
|
+
// the original purchase() promise. Do NOT ack or grant a PENDING purchase.
|
|
508
|
+
if (purchase.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
|
|
509
|
+
acknowledgeAndroidPurchase(purchase);
|
|
510
|
+
const prods = purchase.getProducts();
|
|
511
|
+
const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
|
|
512
|
+
const receipt = androidReceiptFor(purchase, pid);
|
|
513
|
+
bridge.emit('billing.transaction', receipt);
|
|
514
|
+
const w = androidPurchaseWaiters.get(pid);
|
|
515
|
+
if (w) { w.resolve(receipt); androidPurchaseWaiters.delete(pid); }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** queryPurchasesAsync(SUBS) → active PurchaseReceipts (used by restore / appReceipt / entitlements). */
|
|
520
|
+
function androidActivePurchases(): Promise<any[]> {
|
|
521
|
+
return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
|
|
522
|
+
const api = billingApi();
|
|
523
|
+
const params = api.QueryPurchasesParams.newBuilder().setProductType(api.BillingClient.ProductType.SUBS).build();
|
|
524
|
+
client.queryPurchasesAsync(params, new api.PurchasesResponseListener({
|
|
525
|
+
onQueryPurchasesResponse(result: any, list: any) {
|
|
526
|
+
if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
|
|
527
|
+
const receipts: any[] = [];
|
|
528
|
+
const n = list ? list.size() : 0;
|
|
529
|
+
for (let i = 0; i < n; i++) {
|
|
530
|
+
const p = list.get(i);
|
|
531
|
+
if (p.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
|
|
532
|
+
acknowledgeAndroidPurchase(p); // ack any not-yet-acked active sub (in-place migration safety)
|
|
533
|
+
const prods = p.getProducts();
|
|
534
|
+
const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
|
|
535
|
+
receipts.push(androidReceiptFor(p, pid));
|
|
536
|
+
}
|
|
537
|
+
resolve(receipts);
|
|
538
|
+
},
|
|
539
|
+
}));
|
|
540
|
+
}));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function registerAndroidBilling(): void {
|
|
544
|
+
bridge.register('billing.products', async ({ ids = [] }: { ids: string[] }) => {
|
|
545
|
+
if (!ids.length) return [];
|
|
546
|
+
return (await queryAndroidProductDetails(ids)).map(mapAndroidProduct).filter(Boolean);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
|
|
550
|
+
// SERIALIZE: Play's PurchasesUpdatedListener gives NO product context on error, so a single
|
|
551
|
+
// in-flight purchase is the only way "reject all on error" can't cross-talk between callers.
|
|
552
|
+
// The app is single-product; a concurrent second purchase() is rejected outright, guaranteeing
|
|
553
|
+
// there is only ever ONE entry in androidPurchaseWaiters. Guard is set SYNCHRONOUSLY (before any
|
|
554
|
+
// await) so two near-simultaneous calls can't both pass it.
|
|
555
|
+
if (androidPurchaseInFlight) throw err('NATIVE_ERROR', 'A purchase is already in progress');
|
|
556
|
+
androidPurchaseInFlight = true;
|
|
557
|
+
try {
|
|
558
|
+
const client = await ensureAndroidClient();
|
|
559
|
+
const pd = (await queryAndroidProductDetails([productId]))[0];
|
|
560
|
+
if (!pd) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
|
|
561
|
+
const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
|
|
562
|
+
if (!selected) throw err('NATIVE_ERROR', `No subscription offer for ${productId}`);
|
|
563
|
+
// Trial-aware: the SELECTED offer's token carries the free trial when the user is eligible.
|
|
564
|
+
const offerToken = selected.native.getOfferToken();
|
|
565
|
+
const api = billingApi();
|
|
566
|
+
const pdParams = api.BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
567
|
+
.setProductDetails(pd)
|
|
568
|
+
.setOfferToken(offerToken)
|
|
569
|
+
.build();
|
|
570
|
+
const flowParams = api.BillingFlowParams.newBuilder()
|
|
571
|
+
.setProductDetailsParamsList(toJavaList([pdParams]))
|
|
572
|
+
.build();
|
|
573
|
+
return await new Promise((resolve, reject) => {
|
|
574
|
+
androidPurchaseWaiters.set(productId, { resolve, reject });
|
|
575
|
+
Utils.dispatchToMainThread(() => {
|
|
576
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
577
|
+
if (!activity) {
|
|
578
|
+
androidPurchaseWaiters.delete(productId);
|
|
579
|
+
reject(err('NOT_READY', 'no foreground activity'));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// launchBillingFlow's synchronous result reports only launch failures; the purchase itself
|
|
583
|
+
// resolves later via onAndroidPurchasesUpdated → the waiter above.
|
|
584
|
+
const launch = client.launchBillingFlow(activity, flowParams);
|
|
585
|
+
if (launch.getResponseCode() !== PLAY_RESP.OK) {
|
|
586
|
+
androidPurchaseWaiters.delete(productId);
|
|
587
|
+
reject(mapBillingErr(launch));
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
} finally {
|
|
592
|
+
// Runs when the purchase settles (resolve/reject) or on any setup throw — re-opening the gate.
|
|
593
|
+
androidPurchaseInFlight = false;
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
bridge.register('billing.restore', () => androidActivePurchases());
|
|
598
|
+
|
|
599
|
+
// Android has no StoreKit-1-style on-disk app receipt; the analog is the most recent active sub's
|
|
600
|
+
// purchaseToken (empty when none). Mirrors iOS' { platform:'ios', appReceipt }.
|
|
601
|
+
bridge.register('billing.appReceipt', async () => {
|
|
602
|
+
const receipts = await androidActivePurchases();
|
|
603
|
+
return { platform: 'android', purchaseToken: receipts[0]?.purchaseToken };
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Thin, device-derived entitlements (server is the source-of-record — same posture as iOS).
|
|
607
|
+
bridge.register('billing.entitlements', async () => {
|
|
608
|
+
const receipts = await androidActivePurchases();
|
|
609
|
+
const seen = new Set<string>();
|
|
610
|
+
const ents: any[] = [];
|
|
611
|
+
for (const r of receipts) {
|
|
612
|
+
if (!r.productId || seen.has(r.productId)) continue;
|
|
613
|
+
seen.add(r.productId);
|
|
614
|
+
ents.push({ productId: r.productId, active: true });
|
|
615
|
+
}
|
|
616
|
+
return ents;
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Android has no in-app management sheet; the Play subscriptions deep link is the analog for BOTH
|
|
620
|
+
// manageSubscriptions() and showManageSubscriptionsSheet() (the JS layer dispatches the sheet call
|
|
621
|
+
// here now that the capability is 'native' on Android).
|
|
622
|
+
const openManage = ({ productId }: { productId?: string } = {}) => {
|
|
623
|
+
const base = 'https://play.google.com/store/account/subscriptions';
|
|
624
|
+
const url = productId ? `${base}?sku=${encodeURIComponent(productId)}&package=${packageName()}` : base;
|
|
625
|
+
Utils.openUrl(url);
|
|
626
|
+
};
|
|
627
|
+
bridge.register('billing.manageSubscriptions', (p: { productId?: string } = {}) => openManage(p));
|
|
628
|
+
bridge.register('billing.manageSubscriptionsSheet', (p: { productId?: string } = {}) => openManage(p));
|
|
629
|
+
}
|
|
@@ -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;
|