@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.51.3",
3
+ "version": "0.53.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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: false } },
154
- // iOS-only Swift shim (AppwrapManageSubscriptions.swift) bridging StoreKit 2's Swift-async
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(String(error?.getUrl?.() ?? '')));
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
- } else {
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. `appwrap doctor:bindings` (run by the android
462
- // build) now fails the build if a @JavaProxy ever loses its binding again.
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
- onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
472
- receivedSslError(handler, error);
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
- onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
486
- receivedSslError(handler, error);
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 — iOS StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction)
12
- * is Swift-only and unreachable via the ObjC bridge, so we use the delegate-based
13
- * SK1 API every NativeScript/Cordova IAP plugin uses. The web layer's validator turns
14
- * the returned app receipt into entitlements (server-of-record). Android billing is a
15
- * separate handler (Play Billing) and is currently unimplemented (capability 'none').
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 (!isIOS) return; // Android billing is a separate, not-yet-wired handler
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
- if (!SKPaymentQueue.canMakePayments()) throw err('DENIED', 'Purchases disabled on this device');
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 loud, non-interactive DEV strip pinned to the top edge showing the server
41
- // this shell points at. A dev-pointing ipa (debug:true) must never be mistaken for a store/prod
42
- // build — the host in the strip makes "which backend am I on?" answerable at a glance.
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 strip. Apps
45
- // without envSwitcher keep the strip. (Store builds never reach here — debug is false.)
46
- var __appwrapDevStrip = ${SHELL_CONFIG.envSwitcher?.enabled ? 'false' : 'true'};
47
- var srv = ${JSON.stringify(SHELL_CONFIG.serverUrl || '')};
48
- var host = ''; try { host = srv ? new URL(srv).host : (location && location.host) || ''; } catch (e) { host = srv || ''; }
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 = '__appwrap_dev_strip__';
53
- el.textContent = 'DEV · ' + host;
54
- el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;pointer-events:none;'
55
- + 'background:#b91c1c;color:#fff;text-align:center;letter-spacing:.5px;'
56
- + 'font:600 10px/1 -apple-system,system-ui,sans-serif;'
57
- + 'padding:calc(env(safe-area-inset-top,0px) + 2px) 6px 2px;';
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 (__appwrapDevStrip) {
61
- if (document.body) mountDevStrip();
62
- else document.addEventListener('DOMContentLoaded', mountDevStrip);
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
- /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
3257
- * App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
3258
- * changes and would make the fingerprint permanently stale.
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
- export function buildFingerprint(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): string {
3261
- const mtime = (p: string): number => {
3262
- if (!existsSync(p)) return 0;
3263
- try {
3264
- const s = statSync(p);
3265
- if (s.isDirectory()) {
3266
- let sum = 0;
3267
- for (const e of readdirSync(p, { withFileTypes: true }))
3268
- sum += mtime(join(p, e.name));
3269
- return sum;
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 s.mtimeMs;
3272
- } catch { return 0; }
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 parts = [mtime(distDir), mtime(overridesDir), mtime(resolveConfigPath(cwd, flags))];
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
- stampShellConfig(outDir, { ...cfg, debug: true }); // debug: keep-awake + WebView inspector (parity with deploy ios)
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 are
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 continuous troubleshooting.
3655
- stampShellConfig(outDir, { ...cfg, debug: true });
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): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
3664
- // the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
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 fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.artifactPath === join(ipaDir, existingIpa);
3672
- // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
3673
- // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
3674
- const noCache = existingIpa && !cache;
3675
- const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));
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
- const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
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
- if (!canSkipBuild) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
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;