@livx.cc/appwrap 0.51.0 → 0.51.2

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.0",
3
+ "version": "0.51.2",
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",
@@ -56,6 +56,18 @@ export class Bridge {
56
56
  this.deliver(JSON.stringify({ v: 1, kind: 'event', event, payload }));
57
57
  }
58
58
 
59
+ /** Response-delivery retry cadence. iOS THROTTLES/freezes the WebContent renderer for ~30-45s while
60
+ * a native surface (permission alert, sheet, picker) sits over the WebView — `evaluateJavaScript`
61
+ * simply never runs — so we keep re-offering the response until the renderer wakes. The window is
62
+ * deliberately just under native-kit's 60s invoke watchdog: landing a real answer at 50s still beats
63
+ * the bogus TIMEOUT, and past that the caller has already given up. Static so tests can shrink them. */
64
+ static responseRetryMs = 2_000;
65
+ static responseRetryWindowMs = 50_000;
66
+ /** Per-ATTEMPT cap. A throttled WKWebView does not necessarily fail the eval — its completion
67
+ * handler may simply never fire, leaving the promise pending forever. Without this, a frozen
68
+ * renderer would silently stall the retry loop and we would be back to the 60s lie. */
69
+ static responseAttemptTimeoutMs = 3_000;
70
+
59
71
  private async onMessage(json: string): Promise<void> {
60
72
  let req: RequestEnvelope;
61
73
  try {
@@ -79,13 +91,60 @@ export class Bridge {
79
91
  }
80
92
  }
81
93
 
94
+ /**
95
+ * A response is the ONLY thing that settles the caller's promise. native-kit arms a per-invoke
96
+ * watchdog, so a response we fail to deliver does not surface as "delivery failed" — it surfaces to
97
+ * the USER as a bogus `<method> timed out after 60000ms`, i.e. a permission state or a real native
98
+ * error masquerading as a hang. That is exactly how geo.current's 15s `location timeout` became a
99
+ * silent 60s lie: the CoreLocation prompt froze the renderer, this eval no-op'd, and the old
100
+ * fire-and-forget `.catch(console.error)` swallowed it.
101
+ *
102
+ * So: retry until the renderer wakes, and if we truly give up, say so LOUDLY — never silently.
103
+ * Retry is RESPONSE-ONLY and safe: the adapter drops an id it has already settled (handleResponse
104
+ * `if (!p) return`), so a double-delivery is a no-op. Events are NOT idempotent → still one-shot.
105
+ */
82
106
  private respond(id: string, result?: unknown, error?: { code: string; message: string }): void {
83
- this.deliver(JSON.stringify({ v: 1, id, kind: 'response', result, error }));
107
+ const json = JSON.stringify({ v: 1, id, kind: 'response', result, error });
108
+ void this.deliverResponse(json, id, error?.code);
109
+ }
110
+
111
+ private async deliverResponse(envelopeJson: string, id: string, code?: string): Promise<void> {
112
+ const deadline = Date.now() + Bridge.responseRetryWindowMs;
113
+ let attempt = 0;
114
+ let lastErr: unknown;
115
+ for (;;) {
116
+ try {
117
+ // Race the eval against a per-attempt cap: a frozen renderer may never call back at all.
118
+ await Promise.race([
119
+ this.deliverOnce(envelopeJson),
120
+ new Promise((_r, rej) =>
121
+ setTimeout(() => rej(new Error('eval did not complete (renderer throttled?)')),
122
+ Bridge.responseAttemptTimeoutMs)),
123
+ ]);
124
+ if (attempt > 0) console.log(`Bridge: response ${id} delivered after ${attempt} retries`);
125
+ return;
126
+ } catch (e) {
127
+ lastErr = e;
128
+ attempt++;
129
+ if (Date.now() + Bridge.responseRetryMs >= deadline) break;
130
+ await new Promise((r) => setTimeout(r, Bridge.responseRetryMs));
131
+ }
132
+ }
133
+ // Undeliverable. The caller WILL report a bogus timeout for this id — leave the real reason behind.
134
+ console.error(
135
+ `Bridge: response ${id}${code ? ` (${code})` : ''} UNDELIVERABLE after ${attempt} attempts ` +
136
+ `— the caller will report a false TIMEOUT. Last error:`,
137
+ lastErr
138
+ );
84
139
  }
85
140
 
86
141
  private deliver(envelopeJson: string): void {
142
+ this.deliverOnce(envelopeJson).catch((e) => console.error('Bridge: deliver failed', e));
143
+ }
144
+
145
+ private deliverOnce(envelopeJson: string): Promise<unknown> {
87
146
  const js = `window.__appwrapDeliver && window.__appwrapDeliver(${JSON.stringify(envelopeJson)})`;
88
- this.evalJs(js).catch((e) => console.error('Bridge: deliver failed', e));
147
+ return this.evalJs(js);
89
148
  }
90
149
 
91
150
  /** Evaluate JS in the WebView and resolve its value (used by deliver + the dev-menu version probe). */
@@ -107,6 +107,14 @@ export class CustomWebView extends WebView {
107
107
  // object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
108
108
  // so a destroyed view can be GC'd; entry pruned on dispose.
109
109
  private static registry = new Map<number, WeakRef<CustomWebView>>();
110
+ // Retired asset-serving WebViewClients (app/file loader ONLY — see disposeNativeView), kept
111
+ // process-alive so a shouldInterceptRequest already in flight on a background thread when the view is
112
+ // disposed can't fire against a GC'd JS peer → "Cannot find runtime for instance". Holding the (JS-peer)
113
+ // object here keeps NS's per-object runtime mapping resolvable. Server mode installs no interceptor, so
114
+ // its clients are NOT parked (all their callbacks are main-thread → safe to GC). Growth is bounded by
115
+ // the number of interceptor-bearing WebViews that dispose — note handlers-background can spin up an
116
+ // offscreen WebView per background task, so this is per-disposed-interceptor-view, not strictly one.
117
+ private static retiredClients = new Set<android.webkit.WebViewClient>();
110
118
  static forNative(view: android.webkit.WebView): CustomWebView | undefined {
111
119
  const owner = CustomWebView.registry.get(view.hashCode())?.deref();
112
120
  // identityHashCode is not unique: on a (rare) collision the map holds the LAST writer —
@@ -155,9 +163,20 @@ export class CustomWebView extends WebView {
155
163
  disposeNativeView(): void {
156
164
  const wv = this.android as android.webkit.WebView | undefined;
157
165
  if (wv) CustomWebView.registry.delete(wv.hashCode());
158
- // Drop the retained clients now that the native view is going away, so their peers can be GC'd.
159
- this.webViewClient = null;
166
+ // WebChromeClient (onJsPrompt) only fires on the main thread → safe to release for GC now.
160
167
  this.webChromeClient = null;
168
+ // WebViewClient is different: shouldInterceptRequest can be dispatched on a BACKGROUND thread, so a
169
+ // request already in flight when the view tears down would fire AFTER dispose. Releasing the client's
170
+ // JS peer here would let that late off-thread call crash with "Cannot find runtime for instance".
171
+ // Park it in a process-lived set so the peer stays registered for any straggler callback — but ONLY
172
+ // when the interceptor is actually installed (app/file loader). Server mode installs no
173
+ // shouldInterceptRequest (see createAssetServingClient), so its client has no off-thread callback to
174
+ // outlive dispose: parking it would be pure dead retention (an accumulating leak across background-
175
+ // task WebViews), so just release it for GC.
176
+ if (this.webViewClient) {
177
+ if (SHELL_CONFIG.loader !== 'server') CustomWebView.retiredClients.add(this.webViewClient);
178
+ this.webViewClient = null;
179
+ }
161
180
  super.disposeNativeView();
162
181
  }
163
182
 
@@ -307,14 +326,30 @@ function createAssetServingClient(): android.webkit.WebViewClient {
307
326
  return new android.webkit.WebResourceResponse(mimeFor(ext, rel), 'utf-8', 200, 'OK', outHeaders, pin);
308
327
  };
309
328
 
329
+ // WebViewClient callbacks. All are dispatched on the UI/main thread EXCEPT shouldInterceptRequest,
330
+ // which Android runs on a BACKGROUND thread (ShouldInterceptRequestMediator). NativeScript resolves
331
+ // the JS peer for a callback's instance via a per-object runtime lookup (Runtime.getObjectRuntime);
332
+ // if that peer is momentarily unregistered — freshly created and not yet mapped on boot, or disposed
333
+ // and GC'd on relaunch / RESTART-from-crash — the off-thread call throws
334
+ // `NativeScriptException: Cannot find runtime for instance` (the red NS crash screen). NS marshals a
335
+ // background-thread call to the main thread fine ONCE the peer is mapped, so this is a registration/
336
+ // lifetime race, not a plain GC one — which is why the earlier strong-JS-ref fix didn't cover the boot path.
337
+ //
338
+ // In server mode (loader:'server') the page loads from a real https `serverUrl`: there are NO
339
+ // https://appwrap.local requests and `backendOrigin` is same-origin, so the asset interceptor does
340
+ // ZERO work — yet it is the ONLY off-main-thread callback, i.e. the only one exposed to that race.
341
+ // So OMIT it in server mode (the server-mode literal below simply doesn't declare it): Android's default
342
+ // pure-Java interception runs, NS is never crossed into JS off-thread, and the crash is structurally
343
+ // impossible. This omission was originally written as a conditional spread — see the SBG warning below
344
+ // for why that shape silently broke the build and must not come back.
345
+ // app/file loaders MUST serve bundled www/ assets, so they keep it (and retain the client for the
346
+ // process lifetime — see CustomWebView.disposeNativeView — so a late in-flight call can't hit a dead peer).
310
347
  // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
311
- assetClientClass = (android.webkit.WebViewClient as any).extend({
312
- // Modern (API 21+) overload passes a WebResourceRequest; the legacy overload passed a string URL.
313
- // We defensively handle both, so the param is the union.
314
- shouldInterceptRequest(
315
- _view: android.webkit.WebView,
316
- request: android.webkit.WebResourceRequest | string
317
- ): android.webkit.WebResourceResponse | null {
348
+ // Modern (API 21+) overload passes a WebResourceRequest; the legacy overload passed a string URL.
349
+ // We defensively handle both, so the param is the union.
350
+ const interceptRequest = (
351
+ request: android.webkit.WebResourceRequest | string
352
+ ): android.webkit.WebResourceResponse | null => {
318
353
  const url: string = typeof request !== 'string' ? String(request.getUrl().toString()) : request;
319
354
  if (!url.startsWith(APP_ORIGIN)) return null; // external — let WebView handle it
320
355
 
@@ -343,16 +378,16 @@ function createAssetServingClient(): android.webkit.WebViewClient {
343
378
  return new android.webkit.WebResourceResponse(
344
379
  'text/plain', 'utf-8', 404, 'Not Found', new java.util.HashMap<string, string>(), emptyStream()
345
380
  );
346
- },
381
+ };
347
382
 
348
- // External URL schemes (mailto:/tel:/sms:/geo:/intent:/webcal:/market:/…) can't be rendered by the
349
- // WebView — without this override it tries to LOAD them and dumps a full-page net::ERR_UNKNOWN_URL_SCHEME
350
- // over the app. Hand every non-WebView scheme to the OS via ACTION_VIEW (intent: via parseUri) and
351
- // return true so the WebView never navigates. http/https/file/about/blob/data/javascript stay in-WebView.
352
- shouldOverrideUrlLoading(
353
- view: android.webkit.WebView,
354
- request: android.webkit.WebResourceRequest | string
355
- ): boolean {
383
+ // External URL schemes (mailto:/tel:/sms:/geo:/intent:/webcal:/market:/…) can't be rendered by the
384
+ // WebView — without this override it tries to LOAD them and dumps a full-page net::ERR_UNKNOWN_URL_SCHEME
385
+ // over the app. Hand every non-WebView scheme to the OS via ACTION_VIEW (intent: via parseUri) and
386
+ // return true so the WebView never navigates. http/https/file/about/blob/data/javascript stay in-WebView.
387
+ const overrideUrlLoading = (
388
+ view: android.webkit.WebView,
389
+ request: android.webkit.WebResourceRequest | string
390
+ ): boolean => {
356
391
  const url: string = typeof request !== 'string' ? String(request.getUrl().toString()) : request;
357
392
  const scheme = (url.split(':')[0] || '').toLowerCase();
358
393
  if (['http', 'https', 'file', 'about', 'blob', 'data', 'javascript'].includes(scheme)) return false;
@@ -378,24 +413,23 @@ function createAssetServingClient(): android.webkit.WebViewClient {
378
413
  console.warn('[appwrap] no OS handler for external URL: ' + url + ' (' + e + ')');
379
414
  }
380
415
  return true;
381
- },
416
+ };
382
417
 
383
- onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
418
+ const pageStarted = (view: android.webkit.WebView): void => {
384
419
  // Fallback injection — no-op when the document-start scripts already ran
385
420
  view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
386
- },
421
+ };
387
422
 
388
- // DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
389
- // points at a LAN dev server that almost always uses a self-signed / mkcert TLS cert the device's
390
- // trust store doesn't know — the WebView would otherwise hard-fail with ERR_CERT_AUTHORITY_INVALID.
391
- // Proceed past it ONLY in a debug build, only in server-loader mode, AND only for the ONE host the
392
- // build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) — never blanket-trust every host. Production app:// builds
393
- // never reach this (local assets, no TLS). NEVER active in a store build (SHELL_CONFIG.debug false).
394
- onReceivedSslError(
395
- _view: android.webkit.WebView,
396
- handler: android.webkit.SslErrorHandler,
397
- error: android.net.http.SslError
398
- ): void {
423
+ // DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
424
+ // points at a LAN dev server that almost always uses a self-signed / mkcert TLS cert the device's
425
+ // trust store doesn't know — the WebView would otherwise hard-fail with ERR_CERT_AUTHORITY_INVALID.
426
+ // Proceed past it ONLY in a debug build, only in server-loader mode, AND only for the ONE host the
427
+ // build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) — never blanket-trust every host. Production app:// builds
428
+ // never reach this (local assets, no TLS). NEVER active in a store build (SHELL_CONFIG.debug false).
429
+ const receivedSslError = (
430
+ handler: android.webkit.SslErrorHandler,
431
+ error: android.net.http.SslError
432
+ ): void => {
399
433
  // BUILD-TIME serverUrl host ONLY — never the switchable `effectiveServerUrl()` override (would let a
400
434
  // switched host's self-signed cert be trusted → MITM). The dev server is always the build-time URL.
401
435
  // Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
@@ -409,7 +443,48 @@ function createAssetServingClient(): android.webkit.WebViewClient {
409
443
  } else {
410
444
  handler.cancel();
411
445
  }
412
- },
413
- });
446
+ };
447
+
448
+ // TWO literals, selected by loader — NOT one literal with a conditional spread. Both the omission and
449
+ // this shape are load-bearing:
450
+ // * The OMISSION (see the comment above) is what makes the off-thread race structurally impossible in
451
+ // server mode. Do not "simplify" this to one literal that always defines shouldInterceptRequest and
452
+ // early-returns null in server mode — the JS peer would still be crossed off-thread and the
453
+ // `Cannot find runtime for instance` crash returns.
454
+ // * The SHAPE must stay a spread-free object LITERAL of method shorthands. NativeScript's static
455
+ // binding generator parses every `X.extend({...})` literal and reads `prop.key.name` for each
456
+ // property: a SpreadElement has no `key`, so `...(cond ? {} : {...})` throws inside its js_parser,
457
+ // which then ABANDONS THE WHOLE FILE and still exits 0 — silently dropping every @JavaProxy binding
458
+ // declared later in the bundle (this cost us a FATAL `LookedUpClassNotFound:
459
+ // cc.livx.appwrap.AppwrapMessagingService` boot crash that built green). For the same reason don't
460
+ // 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.
463
+ assetClientClass = SHELL_CONFIG.loader === 'server'
464
+ ? (android.webkit.WebViewClient as any).extend({
465
+ shouldOverrideUrlLoading(view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): boolean {
466
+ return overrideUrlLoading(view, request);
467
+ },
468
+ onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
469
+ pageStarted(view);
470
+ },
471
+ onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
472
+ receivedSslError(handler, error);
473
+ },
474
+ })
475
+ : (android.webkit.WebViewClient as any).extend({
476
+ shouldInterceptRequest(_view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): android.webkit.WebResourceResponse | null {
477
+ return interceptRequest(request);
478
+ },
479
+ shouldOverrideUrlLoading(view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): boolean {
480
+ return overrideUrlLoading(view, request);
481
+ },
482
+ onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
483
+ pageStarted(view);
484
+ },
485
+ onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
486
+ receivedSslError(handler, error);
487
+ },
488
+ });
414
489
  return new assetClientClass();
415
490
  }
@@ -39,6 +39,12 @@ class AppwrapScriptHandler extends NSObject implements WKScriptMessageHandler {
39
39
  // Envelopes always travel as JSON strings (protocol v1)
40
40
  if (view?.onAppwrapMessage && typeof body === 'string') {
41
41
  view.onAppwrapMessage(body);
42
+ } else {
43
+ // Nothing to dispatch to (bridge detached / view gone) or a non-string body ⇒ this request is
44
+ // DROPPED and can never be answered — the caller's watchdog will report a false TIMEOUT for it.
45
+ // We cannot respond (there is no bridge here), so at least never drop it in silence.
46
+ appendWebLog('[native] appwrap request DROPPED (no bridge attached) — caller will see a false TIMEOUT');
47
+ console.error('AppWrap: request dropped — no bridge attached; caller will report a false TIMEOUT');
42
48
  }
43
49
  }
44
50
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * PURE CoreLocation authorization-status → action mapping (no NativeScript imports; bun-tested).
3
+ * Mirrors notif-identity.ts: the decision lives here so the permission semantics are verifiable
4
+ * off-device, and handlers-extended.ts only does the CoreLocation plumbing.
5
+ *
6
+ * WHY this exists: a permission state must NEVER surface to the user as a timeout. `denied` and
7
+ * `restricted` are TERMINAL answers we already know — they must reject INSTANTLY with a distinct
8
+ * code, not sit until some watchdog gives up and reports a hang.
9
+ */
10
+
11
+ /** CLAuthorizationStatus raw values (stable ObjC enum; hardcoded so this module stays iOS-global-free). */
12
+ export const CL_AUTH = {
13
+ notDetermined: 0,
14
+ restricted: 1,
15
+ denied: 2,
16
+ authorizedAlways: 3,
17
+ authorizedWhenInUse: 4,
18
+ } as const;
19
+
20
+ export type GeoAuthAction =
21
+ /** Terminal: reject now with this KitError code — no surface, no waiting. */
22
+ | { kind: 'reject'; code: 'DENIED' | 'RESTRICTED'; message: string }
23
+ /** Authorized: start locating. The caller arms its location timer HERE (not before). */
24
+ | { kind: 'start' }
25
+ /** Not yet asked: present the system prompt and WAIT for the decision. The caller must NOT arm a
26
+ * timer — the prompt is dismiss-bound (the user may take as long as they like) and, worse, iOS
27
+ * THROTTLES the WebContent renderer while it is up, so a timer that fires now produces a response
28
+ * that cannot be delivered. See Bridge.respond. */
29
+ | { kind: 'request' };
30
+
31
+ /** Map a CLAuthorizationStatus to what geo.current should do. Unknown/future values → request
32
+ * (asking is safe and self-correcting: the delegate re-enters here with the real status). */
33
+ export function geoAuthAction(status: number): GeoAuthAction {
34
+ switch (status) {
35
+ case CL_AUTH.denied:
36
+ return { kind: 'reject', code: 'DENIED', message: 'location permission denied' };
37
+ case CL_AUTH.restricted:
38
+ // Parental controls / MDM / Screen Time. The user CANNOT grant it — asking is pointless and
39
+ // waiting is a lie. Distinct from DENIED: "denied" is fixable in Settings, "restricted" is not.
40
+ return { kind: 'reject', code: 'RESTRICTED', message: 'location access is restricted on this device' };
41
+ case CL_AUTH.authorizedAlways:
42
+ case CL_AUTH.authorizedWhenInUse:
43
+ return { kind: 'start' };
44
+ case CL_AUTH.notDetermined:
45
+ default:
46
+ return { kind: 'request' };
47
+ }
48
+ }
@@ -1,6 +1,7 @@
1
1
  import { Application, ApplicationSettings, Connectivity, Device, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { onDeepLink } from './events';
4
+ import { geoAuthAction } from './geo-auth';
4
5
  import { onRemoteMessage } from './handlers-push';
5
6
  import { uiImageToDataUrl } from './ios-image';
6
7
  import { notifIdentity, type NotifIdentity } from './notif-identity';
@@ -24,6 +25,10 @@ function ensureIosDelegates(): void {
24
25
  onResult?: (r: GeoResult) => void;
25
26
  onError?: (e: Error) => void;
26
27
  onCleanup?: () => void;
28
+ /** Prompt answered (or never shown) → wake the throttled renderer so a response can be delivered. */
29
+ onAuthSettled?: () => void;
30
+ /** Authorized → arm the location timer NOW (never while the prompt is up). */
31
+ onLocating?: () => void;
27
32
  static new(): GeoDelegateImpl {
28
33
  return <GeoDelegateImpl>super.new();
29
34
  }
@@ -42,15 +47,24 @@ function ensureIosDelegates(): void {
42
47
  this.onCleanup?.();
43
48
  this.onError?.(Object.assign(new Error(error.localizedDescription), { code: 'DENIED' }));
44
49
  }
50
+ /** Called by CoreLocation on delegate creation AND on every auth change (i.e. when the system
51
+ * prompt is answered). This is the ONLY dismiss signal the permission alert gives us. */
45
52
  locationManagerDidChangeAuthorization(m: CLLocationManager): void {
46
- const st = m.authorizationStatus;
47
- if (st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedWhenInUse ||
48
- st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedAlways) {
53
+ // The prompt is a native surface over the WebView: iOS leaves the WebContent renderer THROTTLED
54
+ // for ~30-45s after it, so any response we post would be undeliverable. Every other surface
55
+ // (OAuth/PHPicker/share/billing) already recovers here; geo never did — and the global
56
+ // UIWindowDidBecomeHidden observer is intentionally disarmed (see main-page.ts), so nothing
57
+ // covered it. That is the root of the 60s `geo.current timed out after 60000ms`.
58
+ this.onAuthSettled?.();
59
+ const action = geoAuthAction(m.authorizationStatus);
60
+ if (action.kind === 'start') {
61
+ this.onLocating?.();
49
62
  m.startUpdatingLocation();
50
- } else if (st === CLAuthorizationStatus.kCLAuthorizationStatusDenied) {
63
+ } else if (action.kind === 'reject') {
51
64
  this.onCleanup?.();
52
- this.onError?.(Object.assign(new Error('location permission denied'), { code: 'DENIED' }));
65
+ this.onError?.(Object.assign(new Error(action.message), { code: action.code }));
53
66
  }
67
+ // 'request' → the prompt is still up; wait for the user's answer (no timer, no surface churn).
54
68
  }
55
69
  }
56
70
  GeoDelegate = GeoDelegateImpl;
@@ -317,22 +331,30 @@ export function registerExtendedHandlers(): void {
317
331
  });
318
332
 
319
333
  // ── geolocation (CLLocationManager) ────────────────────────────────
334
+ // The 15s budget covers ACQUIRING A FIX only — it is armed once we are authorized and locating.
335
+ // It must never cover the permission prompt: the prompt is dismiss-bound (the user may take as long
336
+ // as they like) and it throttles the WebContent renderer, so a timer firing while it is up produces
337
+ // a rejection that CANNOT be delivered — which is precisely how this became a silent 60s hang.
320
338
  bridge.register('geo.current', () => {
321
339
  if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
322
340
  return new Promise((resolve, reject) => {
323
- const timer = setTimeout(() => {
324
- cleanup();
325
- reject(Object.assign(new Error('location timeout'), { code: 'TIMEOUT' }));
326
- }, 15000);
327
-
328
341
  let manager: CLLocationManager | null = null;
329
342
  let delegate: any = null;
343
+ let timer: any = null;
330
344
  const cleanup = () => {
331
- clearTimeout(timer);
345
+ if (timer) clearTimeout(timer);
346
+ timer = null;
332
347
  manager?.stopUpdatingLocation();
333
348
  manager = null;
334
349
  delegate = null;
335
350
  };
351
+ const armLocationTimer = () => {
352
+ if (timer) return;
353
+ timer = setTimeout(() => {
354
+ cleanup();
355
+ reject(Object.assign(new Error('location timeout'), { code: 'TIMEOUT' }));
356
+ }, 15000);
357
+ };
336
358
 
337
359
  Utils.dispatchToMainThread(() => {
338
360
  manager = CLLocationManager.new();
@@ -340,14 +362,18 @@ export function registerExtendedHandlers(): void {
340
362
  delegate.onResult = resolve;
341
363
  delegate.onError = reject;
342
364
  delegate.onCleanup = cleanup;
365
+ delegate.onLocating = armLocationTimer;
366
+ // Reuse the shared primitive every other native surface already uses (handlers-oauth /
367
+ // PHPicker / share / billing / apple-signin). Idempotent + coalesced ⇒ safe to call when no
368
+ // prompt was ever shown.
369
+ delegate.onAuthSettled = () => bridge.getWebView()?.recoverAfterNativeSurface();
343
370
  manager.delegate = delegate;
344
371
  manager.desiredAccuracy = kCLLocationAccuracyHundredMeters;
345
- const st = manager.authorizationStatus;
346
- if (st === CLAuthorizationStatus.kCLAuthorizationStatusNotDetermined) {
347
- manager.requestWhenInUseAuthorization();
348
- } else {
349
- manager.startUpdatingLocation();
350
- }
372
+ // Assigning the delegate makes CoreLocation invoke didChangeAuthorization immediately with the
373
+ // current status, which drives every case: denied/restricted reject NOW, authorized arms the
374
+ // timer + locates, notDetermined waits for the prompt below.
375
+ const action = geoAuthAction(manager.authorizationStatus);
376
+ if (action.kind === 'request') manager.requestWhenInUseAuthorization();
351
377
  });
352
378
  });
353
379
  });
@@ -5,7 +5,13 @@ import android.appwidget.AppWidgetManager
5
5
  import android.appwidget.AppWidgetProvider
6
6
  import android.content.Context
7
7
  import android.content.Intent
8
+ import android.graphics.Bitmap
8
9
  import android.graphics.BitmapFactory
10
+ import android.graphics.BitmapShader
11
+ import android.graphics.Canvas
12
+ import android.graphics.Paint
13
+ import android.graphics.RectF
14
+ import android.graphics.Shader
9
15
  import android.net.Uri
10
16
  import android.view.View
11
17
  import android.widget.RemoteViews
@@ -15,12 +21,14 @@ import java.io.File
15
21
  /**
16
22
  * Home-screen launcher widget (Android parity of the iOS WidgetKit launcher). Reads the payload the
17
23
  * `widget.publish` handler wrote into SharedPreferences ("appwrap.widget" / key "appwrap.widget") and
18
- * renders up to 4 icon-grid tiles; each tile deep-links back into the app via its entry's URL. Icons
19
- * are PNGs the handler saved into the app files dir. Resource ids are looked up by name so the module
20
- * needs no compile-time reference to the host app's R class.
24
+ * renders up to 12 icon-grid tiles across 4 columns; each tile deep-links back into the app via its
25
+ * entry's URL. Icons are PNGs the handler saved into the app files dir. Resource ids are looked up by
26
+ * name so the module needs no compile-time reference to the host app's R class.
21
27
  */
22
28
  class AppwrapWidgetProvider : AppWidgetProvider() {
23
- private val tiles = listOf("tile0", "tile1", "tile2", "tile3")
29
+ private val columns = 4
30
+ private val tiles = (0 until 12).map { "tile$it" }
31
+ private val rows = (0 until 3).map { "row$it" }
24
32
 
25
33
  override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
26
34
  for (id in ids) render(ctx, mgr, id)
@@ -29,6 +37,27 @@ class AppwrapWidgetProvider : AppWidgetProvider() {
29
37
  private fun res(ctx: Context, type: String, name: String) =
30
38
  ctx.resources.getIdentifier(name, type, ctx.packageName)
31
39
 
40
+ /**
41
+ * Round an app icon to look native on the home screen (iOS-parity: iOS clips to a
42
+ * RoundedRectangle ~24% of the icon size). RemoteViews ImageViews can't clip via an outline, so we
43
+ * center-crop the source to a square and redraw it into a rounded bitmap at full source resolution
44
+ * — the widget host then downscales a crisp, antialiased image rather than a hard-edged square.
45
+ */
46
+ private fun rounded(src: Bitmap): Bitmap {
47
+ val size = minOf(src.width, src.height)
48
+ val sq = if (src.width != src.height)
49
+ Bitmap.createBitmap(src, (src.width - size) / 2, (src.height - size) / 2, size, size)
50
+ else src
51
+ val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
52
+ val canvas = Canvas(out)
53
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
54
+ shader = BitmapShader(sq, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
55
+ }
56
+ val r = size * 0.22f
57
+ canvas.drawRoundRect(RectF(0f, 0f, size.toFloat(), size.toFloat()), r, r, paint)
58
+ return out
59
+ }
60
+
32
61
  private fun render(ctx: Context, mgr: AppWidgetManager, id: Int) {
33
62
  val rv = RemoteViews(ctx.packageName, res(ctx, "layout", "appwrap_widget"))
34
63
  val raw = ctx.getSharedPreferences("appwrap.widget", Context.MODE_PRIVATE)
@@ -39,10 +68,20 @@ class AppwrapWidgetProvider : AppWidgetProvider() {
39
68
  rv.setViewVisibility(res(ctx, "id", "appwrap_widget_title"),
40
69
  if (title.isEmpty()) View.GONE else View.VISIBLE)
41
70
  val entries = payload?.optJSONArray("entries")
71
+ val count = entries?.length() ?: 0
72
+
73
+ // A row with no entries at all is GONE so it contributes no height (the grid stays top-packed
74
+ // and leftover widget height falls to the bottom). A row that is only PARTLY filled stays
75
+ // VISIBLE and hides its trailing tiles as INVISIBLE, so a short final row keeps its tiles in
76
+ // the leading columns rather than centring them (iOS Grid's short-final-row behaviour).
77
+ for (r in rows.indices) {
78
+ rv.setViewVisibility(res(ctx, "id", rows[r]),
79
+ if (r * columns < count) View.VISIBLE else View.GONE)
80
+ }
42
81
 
43
82
  for (i in tiles.indices) {
44
83
  val tileId = res(ctx, "id", tiles[i])
45
- val e = if (entries != null && i < entries.length()) entries.optJSONObject(i) else null
84
+ val e = if (entries != null && i < count) entries.optJSONObject(i) else null
46
85
  if (e == null) { rv.setViewVisibility(tileId, View.INVISIBLE); continue }
47
86
  rv.setViewVisibility(tileId, View.VISIBLE)
48
87
  rv.setTextViewText(res(ctx, "id", "${tiles[i]}_label"), e.optString("label"))
@@ -57,7 +96,7 @@ class AppwrapWidgetProvider : AppWidgetProvider() {
57
96
  if (icon.isNotEmpty()) {
58
97
  val f = File(ctx.filesDir, icon)
59
98
  if (f.exists()) BitmapFactory.decodeFile(f.absolutePath)
60
- ?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), it) }
99
+ ?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), rounded(it)) }
61
100
  }
62
101
  val link = e.optString("deepLink", "")
63
102
  if (link.isNotEmpty()) {
@@ -1,7 +1,9 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
- <!-- Red pill background for the widget tile's unread-count badge. -->
2
+ <!-- Red circular/pill background for the widget tile's unread-count badge (iOS-parity Capsule).
3
+ Large corner radius → a true circle for single digits, a pill for 2+. White ring lifts it off
4
+ the icon. -->
3
5
  <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
4
6
  <solid android:color="#FF3B30" />
5
7
  <stroke android:width="1.5dp" android:color="#FFFFFF" />
6
- <corners android:radius="9dp" />
8
+ <corners android:radius="100dp" />
7
9
  </shape>
@@ -1,32 +1,36 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
- <!-- AppWrap launcher widget: title + a 2×2 icon-grid of deep-linked tiles. Populated by
3
- AppwrapWidgetProvider from the published payload; empty tiles are set INVISIBLE. Each icon can
4
- carry an unread-count badge (tileN_badge) overlaid on its top-right corner. -->
2
+ <!-- AppWrap launcher widget: title + a 4-column icon grid of deep-linked tiles (up to 3 rows / 12
3
+ tiles), populated by AppwrapWidgetProvider from the published payload.
4
+
5
+ LAYOUT CONTRACT (iOS parity — see AppwrapWidget.swift `gridView`):
6
+ * Rows are CONTENT-HEIGHT (wrap_content, NO layout_weight). The root is a vertical
7
+ LinearLayout at match_parent, so rows stack from the TOP and any leftover widget height
8
+ falls to the BOTTOM — the "app folder" feel. Giving rows `0dp + weight=1` (as this layout
9
+ once did) makes them greedily split the full height and float each icon to the centre of
10
+ its band, which strands the last row at the bottom behind a large dead gap on a
11
+ vertically-resized widget. Don't reintroduce weights here.
12
+ * A partially-filled row keeps its trailing tiles INVISIBLE (not GONE) so the short final row
13
+ stays aligned to the LEADING columns instead of centring. A fully-empty row is set GONE by
14
+ the provider so it consumes no height. -->
5
15
  <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
6
16
  android:layout_width="match_parent"
7
17
  android:layout_height="match_parent"
8
18
  android:orientation="vertical"
9
- android:background="#F2FFFFFF"
19
+ android:background="@color/appwrap_widget_bg"
10
20
  android:padding="10dp">
11
21
 
12
22
  <TextView
13
23
  android:id="@+id/appwrap_widget_title"
14
24
  android:layout_width="wrap_content"
15
25
  android:layout_height="wrap_content"
16
- android:textColor="#222222"
26
+ android:textColor="@color/appwrap_widget_title"
17
27
  android:textStyle="bold"
18
28
  android:textSize="12sp"
19
- android:layout_marginBottom="6dp"
29
+ android:layout_marginBottom="8dp"
20
30
  android:maxLines="1"
21
31
  android:ellipsize="end" />
22
32
 
23
- <LinearLayout
24
- android:layout_width="match_parent"
25
- android:layout_height="0dp"
26
- android:layout_weight="1"
27
- android:orientation="horizontal"
28
- android:weightSum="2">
29
-
33
+ <LinearLayout android:id="@+id/row0" style="@style/AppwrapWidgetRow">
30
34
  <LinearLayout android:id="@+id/tile0" style="@style/AppwrapWidgetTile">
31
35
  <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
32
36
  <ImageView android:id="@+id/tile0_icon" style="@style/AppwrapWidgetIcon" />
@@ -34,7 +38,6 @@
34
38
  </FrameLayout>
35
39
  <TextView android:id="@+id/tile0_label" style="@style/AppwrapWidgetLabel" />
36
40
  </LinearLayout>
37
-
38
41
  <LinearLayout android:id="@+id/tile1" style="@style/AppwrapWidgetTile">
39
42
  <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
40
43
  <ImageView android:id="@+id/tile1_icon" style="@style/AppwrapWidgetIcon" />
@@ -42,15 +45,6 @@
42
45
  </FrameLayout>
43
46
  <TextView android:id="@+id/tile1_label" style="@style/AppwrapWidgetLabel" />
44
47
  </LinearLayout>
45
- </LinearLayout>
46
-
47
- <LinearLayout
48
- android:layout_width="match_parent"
49
- android:layout_height="0dp"
50
- android:layout_weight="1"
51
- android:orientation="horizontal"
52
- android:weightSum="2">
53
-
54
48
  <LinearLayout android:id="@+id/tile2" style="@style/AppwrapWidgetTile">
55
49
  <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
56
50
  <ImageView android:id="@+id/tile2_icon" style="@style/AppwrapWidgetIcon" />
@@ -58,7 +52,6 @@
58
52
  </FrameLayout>
59
53
  <TextView android:id="@+id/tile2_label" style="@style/AppwrapWidgetLabel" />
60
54
  </LinearLayout>
61
-
62
55
  <LinearLayout android:id="@+id/tile3" style="@style/AppwrapWidgetTile">
63
56
  <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
64
57
  <ImageView android:id="@+id/tile3_icon" style="@style/AppwrapWidgetIcon" />
@@ -67,4 +60,64 @@
67
60
  <TextView android:id="@+id/tile3_label" style="@style/AppwrapWidgetLabel" />
68
61
  </LinearLayout>
69
62
  </LinearLayout>
63
+ <LinearLayout android:id="@+id/row1" style="@style/AppwrapWidgetRow">
64
+ <LinearLayout android:id="@+id/tile4" style="@style/AppwrapWidgetTile">
65
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
66
+ <ImageView android:id="@+id/tile4_icon" style="@style/AppwrapWidgetIcon" />
67
+ <TextView android:id="@+id/tile4_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
68
+ </FrameLayout>
69
+ <TextView android:id="@+id/tile4_label" style="@style/AppwrapWidgetLabel" />
70
+ </LinearLayout>
71
+ <LinearLayout android:id="@+id/tile5" style="@style/AppwrapWidgetTile">
72
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
73
+ <ImageView android:id="@+id/tile5_icon" style="@style/AppwrapWidgetIcon" />
74
+ <TextView android:id="@+id/tile5_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
75
+ </FrameLayout>
76
+ <TextView android:id="@+id/tile5_label" style="@style/AppwrapWidgetLabel" />
77
+ </LinearLayout>
78
+ <LinearLayout android:id="@+id/tile6" style="@style/AppwrapWidgetTile">
79
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
80
+ <ImageView android:id="@+id/tile6_icon" style="@style/AppwrapWidgetIcon" />
81
+ <TextView android:id="@+id/tile6_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
82
+ </FrameLayout>
83
+ <TextView android:id="@+id/tile6_label" style="@style/AppwrapWidgetLabel" />
84
+ </LinearLayout>
85
+ <LinearLayout android:id="@+id/tile7" style="@style/AppwrapWidgetTile">
86
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
87
+ <ImageView android:id="@+id/tile7_icon" style="@style/AppwrapWidgetIcon" />
88
+ <TextView android:id="@+id/tile7_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
89
+ </FrameLayout>
90
+ <TextView android:id="@+id/tile7_label" style="@style/AppwrapWidgetLabel" />
91
+ </LinearLayout>
92
+ </LinearLayout>
93
+ <LinearLayout android:id="@+id/row2" style="@style/AppwrapWidgetRow">
94
+ <LinearLayout android:id="@+id/tile8" style="@style/AppwrapWidgetTile">
95
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
96
+ <ImageView android:id="@+id/tile8_icon" style="@style/AppwrapWidgetIcon" />
97
+ <TextView android:id="@+id/tile8_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
98
+ </FrameLayout>
99
+ <TextView android:id="@+id/tile8_label" style="@style/AppwrapWidgetLabel" />
100
+ </LinearLayout>
101
+ <LinearLayout android:id="@+id/tile9" style="@style/AppwrapWidgetTile">
102
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
103
+ <ImageView android:id="@+id/tile9_icon" style="@style/AppwrapWidgetIcon" />
104
+ <TextView android:id="@+id/tile9_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
105
+ </FrameLayout>
106
+ <TextView android:id="@+id/tile9_label" style="@style/AppwrapWidgetLabel" />
107
+ </LinearLayout>
108
+ <LinearLayout android:id="@+id/tile10" style="@style/AppwrapWidgetTile">
109
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
110
+ <ImageView android:id="@+id/tile10_icon" style="@style/AppwrapWidgetIcon" />
111
+ <TextView android:id="@+id/tile10_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
112
+ </FrameLayout>
113
+ <TextView android:id="@+id/tile10_label" style="@style/AppwrapWidgetLabel" />
114
+ </LinearLayout>
115
+ <LinearLayout android:id="@+id/tile11" style="@style/AppwrapWidgetTile">
116
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
117
+ <ImageView android:id="@+id/tile11_icon" style="@style/AppwrapWidgetIcon" />
118
+ <TextView android:id="@+id/tile11_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
119
+ </FrameLayout>
120
+ <TextView android:id="@+id/tile11_label" style="@style/AppwrapWidgetLabel" />
121
+ </LinearLayout>
122
+ </LinearLayout>
70
123
  </LinearLayout>
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Widget chrome colors. Split into values/ + values-night/ so the tile follows the host's theme
3
+ (iOS parity: containerBackground(.fill.tertiary) + .primary label adapt automatically; a
4
+ hardcoded white slab with #333 text stayed stark-white on a dark home screen). The widget host
5
+ inflates RemoteViews with its own configuration, so -night resolves without any provider code. -->
6
+ <resources>
7
+ <color name="appwrap_widget_bg">#F2FFFFFF</color>
8
+ <color name="appwrap_widget_title">#222222</color>
9
+ <color name="appwrap_widget_label">#333333</color>
10
+ </resources>
@@ -1,27 +1,54 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <resources>
3
- <!-- One icon-grid tile: a centered icon over a single-line label, equal-weight in its row. -->
3
+ <!-- One grid row: 4 equal columns, CONTENT-HEIGHT. The absence of a layout_weight is load-bearing
4
+ (see the layout's contract comment) — a weighted row splits the widget height and floats its
5
+ icons mid-band, leaving a dead gap on a tall widget. -->
6
+ <style name="AppwrapWidgetRow">
7
+ <item name="android:layout_width">match_parent</item>
8
+ <item name="android:layout_height">wrap_content</item>
9
+ <item name="android:orientation">horizontal</item>
10
+ <item name="android:weightSum">4</item>
11
+ <item name="android:layout_marginBottom">10dp</item>
12
+ </style>
13
+ <!-- One icon-grid tile: a centered icon over a single-line label, equal-weight in its row.
14
+ Height is wrap_content so the row measures to content, not to the widget's full height. -->
4
15
  <style name="AppwrapWidgetTile">
5
16
  <item name="android:layout_width">0dp</item>
6
- <item name="android:layout_height">match_parent</item>
17
+ <item name="android:layout_height">wrap_content</item>
7
18
  <item name="android:layout_weight">1</item>
8
19
  <item name="android:orientation">vertical</item>
9
- <item name="android:gravity">center</item>
20
+ <item name="android:gravity">center_horizontal</item>
10
21
  <item name="android:padding">4dp</item>
11
22
  </style>
23
+ <!-- The provider rounds the bitmap itself (RemoteViews ImageView can't clip via outline), so the
24
+ source is drawn crisp at full res and just scaled to fit here — no fitCenter squashing. -->
25
+ <!-- The icon carries a top margin + SYMMETRIC start/end margins so the wrap_content badge-container
26
+ FrameLayout measures LARGER than the 44dp icon. The badge (pinned top|end of that padded
27
+ container) lands in the end-margin region — visually BEYOND the icon's top-right corner (iOS
28
+ offset(x:6,y:-6) parity) — yet stays WITHIN the container bounds, so RemoteViews' default
29
+ child-clipping never cuts it. marginStart mirrors marginEnd so the icon stays HORIZONTALLY
30
+ CENTERED within the FrameLayout (and thus under its label) — an end-only margin shifts the
31
+ icon left, leaving an asymmetric right gap.
32
+ SIZE: 44dp + 14dp of margins = 58dp, + 8dp tile padding = 66dp per column. At 4 columns that
33
+ needs ~284dp of widget width, which is why appwrap_widget_info declares minResizeWidth 280dp
34
+ — shrinking below that would start clipping the icons. -->
12
35
  <style name="AppwrapWidgetIcon">
13
- <item name="android:layout_width">36dp</item>
14
- <item name="android:layout_height">36dp</item>
15
- <item name="android:scaleType">fitCenter</item>
36
+ <item name="android:layout_width">44dp</item>
37
+ <item name="android:layout_height">44dp</item>
38
+ <item name="android:layout_marginTop">7dp</item>
39
+ <item name="android:layout_marginStart">7dp</item>
40
+ <item name="android:layout_marginEnd">7dp</item>
41
+ <item name="android:scaleType">fitXY</item>
16
42
  </style>
17
- <!-- Unread-count bubble overlaid on the icon's top-right corner. -->
43
+ <!-- Unread-count bubble overlapping the icon's top-right corner (iOS-parity: small, circular for
44
+ single digits, red fill, white ring, bold white number). ~40% of the 44dp icon. -->
18
45
  <style name="AppwrapWidgetBadge">
19
46
  <item name="android:layout_width">wrap_content</item>
20
- <item name="android:layout_height">18dp</item>
47
+ <item name="android:layout_height">17dp</item>
21
48
  <item name="android:layout_gravity">top|end</item>
22
- <item name="android:minWidth">18dp</item>
23
- <item name="android:paddingLeft">5dp</item>
24
- <item name="android:paddingRight">5dp</item>
49
+ <item name="android:minWidth">17dp</item>
50
+ <item name="android:paddingLeft">4dp</item>
51
+ <item name="android:paddingRight">4dp</item>
25
52
  <item name="android:background">@drawable/appwrap_badge_bg</item>
26
53
  <item name="android:gravity">center</item>
27
54
  <item name="android:textColor">#FFFFFF</item>
@@ -32,7 +59,7 @@
32
59
  <style name="AppwrapWidgetLabel">
33
60
  <item name="android:layout_width">wrap_content</item>
34
61
  <item name="android:layout_height">wrap_content</item>
35
- <item name="android:textColor">#333333</item>
62
+ <item name="android:textColor">@color/appwrap_widget_label</item>
36
63
  <item name="android:textSize">11sp</item>
37
64
  <item name="android:maxLines">1</item>
38
65
  <item name="android:ellipsize">end</item>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Dark-theme counterpart of values/appwrap_widget_colors.xml (same names, resolved by the host's
3
+ night configuration). Translucent near-black slab + light text, mirroring the light variant's
4
+ alpha so the tile reads as the same material on either theme. -->
5
+ <resources>
6
+ <color name="appwrap_widget_bg">#E61C1C1E</color>
7
+ <color name="appwrap_widget_title">#FFFFFF</color>
8
+ <color name="appwrap_widget_label">#E6FFFFFF</color>
9
+ </resources>
@@ -1,9 +1,13 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <!-- Launcher widget: ~4×2 cells (medium), resizable, initial layout = the icon-grid. updatePeriodMillis
3
- 0 → the app drives refreshes itself via widget.publish (AppWidgetManager broadcast). -->
3
+ 0 → the app drives refreshes itself via widget.publish (AppWidgetManager broadcast).
4
+ minResizeWidth 280dp: the grid is 4 fixed-width columns (~66dp each — see AppwrapWidgetIcon),
5
+ so ~284dp is the narrowest width that renders without clipping the icons. minWidth stays 250dp
6
+ (it only gates where the widget may be PLACED); minResizeWidth is what bounds a user drag. -->
4
7
  <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
5
8
  android:minWidth="250dp"
6
9
  android:minHeight="110dp"
10
+ android:minResizeWidth="280dp"
7
11
  android:targetCellWidth="4"
8
12
  android:targetCellHeight="2"
9
13
  android:updatePeriodMillis="0"
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ // The shell imports NativeScript globals at module load; the bridge only needs isIOS/isAndroid.
4
+ mock.module('@nativescript/core', () => ({ isIOS: false, isAndroid: false, WebView: class {} }));
5
+
6
+ const { Bridge } = await import('../app/shell/bridge');
7
+
8
+ /** Drive the bridge like the real WebView does: feed it a request envelope, capture what it evals. */
9
+ function harness(evalImpl: (js: string) => Promise<unknown>) {
10
+ const b = new Bridge();
11
+ const view: any = { onAppwrapMessage: null };
12
+ b.attach(view);
13
+ b.evalJs = evalImpl;
14
+ const delivered: any[] = [];
15
+ const send = (method: string, id = 'k1') => view.onAppwrapMessage(JSON.stringify({ v: 1, id, kind: 'request', method }));
16
+ /** Parse an envelope back out of the `window.__appwrapDeliver("…")` script the bridge builds. */
17
+ const parse = (js: string) => JSON.parse(JSON.parse(js.slice(js.indexOf('(') + 1, js.lastIndexOf(')'))));
18
+ return { b, send, delivered, parse };
19
+ }
20
+
21
+ /** Comfortably longer than the (shrunken) retry window, so the give-up path has fully run. */
22
+ const settle = () => new Promise((r) => setTimeout(r, 150));
23
+
24
+ describe('Bridge response delivery — a lost response must never become a false TIMEOUT', () => {
25
+ beforeEach(() => {
26
+ Bridge.responseRetryMs = 1;
27
+ Bridge.responseRetryWindowMs = 30;
28
+ Bridge.responseAttemptTimeoutMs = 5;
29
+ });
30
+
31
+ test('REGRESSION (the user bug): a response failing while the renderer is throttled is RETRIED, not swallowed', async () => {
32
+ // iOS freezes the WebContent renderer for ~30-45s under a permission alert: evaluateJavaScript
33
+ // never runs. Old behaviour: one attempt, .catch(console.error) → the caller hung to its 60s watchdog.
34
+ let attempts = 0;
35
+ const { b, send, parse } = harness(async (js) => {
36
+ if (++attempts < 4) throw new Error('renderer throttled');
37
+ return parse(js);
38
+ });
39
+ const seen: any[] = [];
40
+ b.register('geo.current', () => { throw Object.assign(new Error('location timeout'), { code: 'TIMEOUT' }); });
41
+ b.evalJs = async (js: string) => {
42
+ if (attempts++ < 3) throw new Error('renderer throttled');
43
+ seen.push(parse(js));
44
+ };
45
+ send('geo.current');
46
+ await settle();
47
+
48
+ expect(attempts).toBeGreaterThan(3); // it kept trying while frozen
49
+ expect(seen).toHaveLength(1); // and the answer LANDED once the renderer woke
50
+ expect(seen[0].error.code).toBe('TIMEOUT');
51
+ });
52
+
53
+ test('an eval that NEVER completes (frozen renderer) still retries — it must not stall the loop', async () => {
54
+ // WKWebView under a system alert may never invoke the completion handler at all, so the eval
55
+ // promise stays pending forever. A retry loop that only reacts to REJECTION would hang here.
56
+ let attempts = 0;
57
+ const seen: any[] = [];
58
+ const { b, send, parse } = harness(() => new Promise(() => {})); // never settles
59
+ b.register('geo.current', () => { throw Object.assign(new Error('location timeout'), { code: 'TIMEOUT' }); });
60
+ b.evalJs = ((js: string) => {
61
+ if (++attempts < 3) return new Promise(() => {}); // renderer frozen: no callback, ever
62
+ seen.push(parse(js));
63
+ return Promise.resolve(undefined);
64
+ }) as any;
65
+ send('geo.current');
66
+ await settle();
67
+
68
+ expect(attempts).toBeGreaterThanOrEqual(3); // the per-attempt cap broke the stall
69
+ expect(seen).toHaveLength(1);
70
+ expect(seen[0].error.code).toBe('TIMEOUT'); // and the real answer finally landed
71
+ });
72
+
73
+ test('a permanently undeliverable response is reported LOUDLY, never in silence', async () => {
74
+ const errs: string[] = [];
75
+ const orig = console.error;
76
+ console.error = (...a: any[]) => { errs.push(a.map(String).join(' ')); };
77
+ try {
78
+ const { b, send } = harness(async () => { throw new Error('webview gone'); });
79
+ b.register('geo.current', () => { throw Object.assign(new Error('nope'), { code: 'DENIED' }); });
80
+ send('geo.current');
81
+ await settle();
82
+ const line = errs.find((e) => e.includes('UNDELIVERABLE'));
83
+ expect(line).toBeDefined();
84
+ expect(line).toContain('false TIMEOUT'); // names the exact user-visible consequence
85
+ expect(line).toContain('DENIED'); // and the real reason we failed to convey
86
+ } finally { console.error = orig; }
87
+ });
88
+
89
+ test('a rejecting handler still answers with ITS code — a permission never masquerades as a timeout', async () => {
90
+ const seen: any[] = [];
91
+ const { b, send, parse } = harness(async (js) => { seen.push(parse(js)); });
92
+ b.register('geo.current', () => { throw Object.assign(new Error('location permission denied'), { code: 'DENIED' }); });
93
+ send('geo.current');
94
+ await settle();
95
+ expect(seen[0].error).toEqual({ code: 'DENIED', message: 'location permission denied' });
96
+ });
97
+
98
+ test('an unknown method answers UNSUPPORTED fast (no silent drop → no false timeout)', async () => {
99
+ const seen: any[] = [];
100
+ const { b, send, parse } = harness(async (js) => { seen.push(parse(js)); });
101
+ send('geo.nope');
102
+ await settle();
103
+ expect(seen[0].error.code).toBe('UNSUPPORTED');
104
+ });
105
+
106
+ test('retry is response-only: events stay one-shot (they are not idempotent)', async () => {
107
+ let calls = 0;
108
+ const { b } = harness(async () => { calls++; throw new Error('down'); });
109
+ b.emit('geo.position', { lat: 1, lng: 2 });
110
+ await settle();
111
+ expect(calls).toBe(1);
112
+ });
113
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { CL_AUTH, geoAuthAction } from '../app/shell/geo-auth';
3
+
4
+ describe('geoAuthAction', () => {
5
+ test('denied → rejects INSTANTLY with DENIED (never a timeout)', () => {
6
+ expect(geoAuthAction(CL_AUTH.denied)).toEqual({
7
+ kind: 'reject', code: 'DENIED', message: 'location permission denied',
8
+ });
9
+ });
10
+
11
+ test('restricted → rejects with RESTRICTED, distinct from DENIED (was: burned 15s → TIMEOUT)', () => {
12
+ const r = geoAuthAction(CL_AUTH.restricted);
13
+ expect(r.kind).toBe('reject');
14
+ expect((r as any).code).toBe('RESTRICTED');
15
+ });
16
+
17
+ test('a permission state NEVER maps to waiting/starting — the headline invariant', () => {
18
+ for (const st of [CL_AUTH.denied, CL_AUTH.restricted]) {
19
+ expect(geoAuthAction(st).kind).toBe('reject');
20
+ }
21
+ });
22
+
23
+ test('authorized (whenInUse + always) → start locating', () => {
24
+ expect(geoAuthAction(CL_AUTH.authorizedWhenInUse)).toEqual({ kind: 'start' });
25
+ expect(geoAuthAction(CL_AUTH.authorizedAlways)).toEqual({ kind: 'start' });
26
+ });
27
+
28
+ test('notDetermined → request the prompt (and, per contract, arm no timer)', () => {
29
+ expect(geoAuthAction(CL_AUTH.notDetermined)).toEqual({ kind: 'request' });
30
+ });
31
+
32
+ test('unknown/future status → request (safe + self-correcting), never a silent hang', () => {
33
+ expect(geoAuthAction(99).kind).toBe('request');
34
+ });
35
+ });
package/src/cli.ts CHANGED
@@ -8,12 +8,12 @@
8
8
  * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
9
9
  * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
10
10
  */
11
- import { execFileSync, spawn } from 'child_process';
11
+ import { execFileSync, spawn, spawnSync } from 'child_process';
12
12
  import { createHash } from 'crypto';
13
13
  import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
14
14
  import { builtinModules } from 'module';
15
15
  import { networkInterfaces, tmpdir } from 'os';
16
- import { delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
16
+ import { basename, delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
17
17
  import { pathToFileURL } from 'url';
18
18
  // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
19
19
  // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
@@ -1609,6 +1609,16 @@ export function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig):
1609
1609
  // Files the template still provides must survive an override removal (regenerateCore ran first and
1610
1610
  // re-copied them) → protect them from the override prune.
1611
1611
  const templateFiles = existsSync(TEMPLATE_DIR) ? collectRelFiles(TEMPLATE_DIR, templateCopyFilter) : new Set<string>();
1612
+ // ACTIVE modules' native source needs the SAME protection: copyModuleNativeSrc re-copied it into
1613
+ // these exact relative paths, but it lives under runtime/modules-native/<name>/, which
1614
+ // templateCopyFilter deliberately excludes — so it is absent from templateFiles above. Without this,
1615
+ // dropping a consumer override that happened to shadow a module file prunes the MODULE's own copy
1616
+ // too (the prune only sees "was in the overrides manifest, isn't in overrides now"), silently
1617
+ // deleting e.g. res/xml/appwrap_widget_info.xml and failing the build at AAPT.
1618
+ for (const name of nativeReqs(cfg).nativeSrc) {
1619
+ const modDir = join(MODULES_NATIVE_DIR, name);
1620
+ if (existsSync(modDir)) for (const rel of collectRelFiles(modDir)) templateFiles.add(rel);
1621
+ }
1612
1622
  if (!existsSync(dir)) {
1613
1623
  // No overrides now: prune anything a PRIOR overrides run left behind (renamed/removed override files).
1614
1624
  pruneStale(outDir, OVERRIDES_MANIFEST, new Set(), templateFiles);
@@ -2132,6 +2142,122 @@ function runNs(outDir: string, args: string[]): void {
2132
2142
  const env = prepareNsEnv(outDir, args);
2133
2143
  console.log(`▶ ns ${args.join(' ')} (cwd: ${outDir})`);
2134
2144
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
2145
+ if (args.includes('android')) assertSbgParsedCleanly(outDir);
2146
+ }
2147
+
2148
+ /** Fail an Android build when SBG's js_parser ABANDONED a bundled file.
2149
+ *
2150
+ * This is the root check; assertJavaProxyBindings() below is the outcome check. The parser catches errors
2151
+ * PER FILE, drops that file's ENTIRE remaining AST, logs nothing unless JS_PARSER_ENABLE_LOGGING is set
2152
+ * (runSbg.log stays empty), and the gradle task still exits 0 — so every binding declared after the throw
2153
+ * silently vanishes from a green build. Measured blast radius when a spread in a `.extend({...})` literal
2154
+ * threw ~1/3 of the way through bundle.js: bundle-sourced bindings went 24 -> 1. Gone were not just
2155
+ * @JavaProxy('cc.livx.appwrap.AppwrapMessagingService') (a FATAL boot crash) but SensorEventListener x4,
2156
+ * LocationListener, OnCompleteListener x2, RecognitionListener, ContentObserver, Runnable x5 and
2157
+ * ValueCallback — i.e. sensors, geolocation, FCM token, speech and background threads, each failing later
2158
+ * and far from the cause.
2159
+ *
2160
+ * Asserting only "@JavaProxy present" would miss all of that whenever the throw lands after the last
2161
+ * @JavaProxy, so re-run the parser with logging and treat ANY abandoned file as a build failure. This is
2162
+ * read-only w.r.t. the APK (gradle already generated the java); it only rewrites build-tools/sbg-*.txt with
2163
+ * identical content, and those are not gradle inputs.
2164
+ */
2165
+ function assertSbgParsedCleanly(outDir: string): void {
2166
+ const buildTools = join(outDir, 'platforms/android/build-tools');
2167
+ const parser = join(buildTools, 'jsparser/js_parser.js');
2168
+ if (!existsSync(parser)) return assertJavaProxyBindings(outDir); // no parser here → nothing to re-run
2169
+
2170
+ // spawnSync, not execFileSync: the parser logs its fatal "Error processing" lines to STDERR, and
2171
+ // execFileSync returns stdout ONLY — reading just stdout silently sees a clean log and passes.
2172
+ const run = spawnSync('node', [parser, 'enableVerboseLogging'], {
2173
+ cwd: buildTools,
2174
+ encoding: 'utf8',
2175
+ env: { ...process.env, JS_PARSER_ENABLE_LOGGING: 'true' },
2176
+ maxBuffer: 64 * 1024 * 1024,
2177
+ });
2178
+ // Couldn't re-run (no node on PATH, parser layout changed). Don't fail the build on the CHECK's own
2179
+ // failure — fall back to the cheaper outcome assertion, which needs no subprocess.
2180
+ if (run.error || run.status !== 0) return assertJavaProxyBindings(outDir);
2181
+ const log = `${run.stdout ?? ''}\n${run.stderr ?? ''}`;
2182
+
2183
+ // "Error processing '<file>:' <err>" = that file was abandoned mid-AST. (Distinct from the parser's
2184
+ // benign per-node "JSParser Error: Node type is not a call expression" chatter, which is not fatal.)
2185
+ const abandoned = [...log.matchAll(/Error processing '([^']+):?'\s*(.*)/g)].map((m) => ({
2186
+ file: basename(m[1].replace(/:$/, '')),
2187
+ err: (m[2] || '').split('\n')[0].trim(),
2188
+ }));
2189
+ if (abandoned.length) {
2190
+ console.error(
2191
+ `\n✖ Android build is BROKEN: NativeScript's static-binding-generator ABANDONED ${abandoned.length} bundled\n` +
2192
+ ` file(s) mid-parse. Every Java binding declared after the throw was silently dropped — the gradle\n` +
2193
+ ` task still exited 0, so this APK/AAB is missing classes it needs:\n` +
2194
+ abandoned.map((a) => ` • ${a.file}: ${a.err}`).join('\n') +
2195
+ `\n\n Symptoms this causes, all far from the cause: "LookedUpClassNotFound: <class>" FATAL at boot for a\n` +
2196
+ ` @JavaProxy, or sensors/geolocation/speech/FCM-token/background-threads silently doing nothing.\n\n` +
2197
+ ` The known offender is a SPREAD inside an \`X.extend({...})\` literal: the parser reads\n` +
2198
+ ` \`prop.key.name\` for every property and a SpreadElement has no \`key\`. Keep .extend() literals\n` +
2199
+ ` spread-free (select between two whole literals instead), and never pass a variable to .extend().\n\n` +
2200
+ ` Full parse log:\n` +
2201
+ ` cd ${buildTools} && JS_PARSER_ENABLE_LOGGING=true node jsparser/js_parser.js enableVerboseLogging\n`
2202
+ );
2203
+ process.exit(1);
2204
+ }
2205
+ assertJavaProxyBindings(outDir); // parse was clean — still assert the outcome
2206
+ }
2207
+
2208
+ /** Fail an Android build when a `@JavaProxy` class silently lost its generated Java binding.
2209
+ *
2210
+ * NativeScript's static-binding-generator (SBG) reads the BUNDLED js and synthesizes the Java class each
2211
+ * `@JavaProxy('a.b.C')` declares — the same class AndroidManifest entries and `new a.b.C()` reference. Its
2212
+ * js_parser catches errors PER FILE: on an unsupported construct it abandons THE ENTIRE FILE, logs only
2213
+ * under JS_PARSER_ENABLE_LOGGING (runSbg.log stays empty), and the gradle task still exits 0. So every
2214
+ * @JavaProxy declared after that point in the bundle vanishes with a green build, and the app dies at boot
2215
+ * on a device with `LookedUpClassNotFound: a.b.C` — the worst possible place to find out.
2216
+ *
2217
+ * This turns that silent, ship-to-prod failure into a build error. It is deliberately generic (assert the
2218
+ * BINDING exists, not "no spreads"): it catches any future parser quirk, not just the one that bit us —
2219
+ * a spread inside a `X.extend({...})` literal (a SpreadElement has no `.key`, so the parser threw on
2220
+ * `prop.key.name` and dropped cc.livx.appwrap.AppwrapMessagingService, boot-crashing every server-loader
2221
+ * app with push).
2222
+ *
2223
+ * Inputs are SBG's OWN records, so this checks exactly what SBG saw: sbg-js-parsed-files.txt (the files it
2224
+ * parsed) and sbg-bindings.txt (what it decided to generate).
2225
+ */
2226
+ function assertJavaProxyBindings(outDir: string): void {
2227
+ const buildTools = join(outDir, 'platforms/android/build-tools');
2228
+ const bindingsFile = join(buildTools, 'sbg-bindings.txt');
2229
+ const parsedFile = join(buildTools, 'sbg-js-parsed-files.txt');
2230
+ // Absent → SBG never ran for this invocation (e.g. `ns clean`, a prepare-only pass). Nothing to assert.
2231
+ if (!existsSync(bindingsFile) || !existsSync(parsedFile)) return;
2232
+
2233
+ const bindings = readFileSync(bindingsFile, 'utf8');
2234
+ const missing: Array<{ cls: string; file: string }> = [];
2235
+ for (const jsFile of readFileSync(parsedFile, 'utf8').split('\n').map((l) => l.trim())) {
2236
+ if (!jsFile || !existsSync(jsFile)) continue;
2237
+ const js = readFileSync(jsFile, 'utf8');
2238
+ // Only a real decorator call `JavaProxy('a.b.C')` matches — prose mentions like "(JavaProxy)" don't.
2239
+ for (const m of js.matchAll(/JavaProxy\(\s*['"]([\w.$]+)['"]\s*\)/g)) {
2240
+ const cls = m[1];
2241
+ // sbg-bindings.txt is `*`-delimited; the proxy name is one field — a substring test is enough.
2242
+ if (!bindings.includes(cls) && !missing.some((x) => x.cls === cls)) missing.push({ cls, file: basename(jsFile) });
2243
+ }
2244
+ }
2245
+ if (!missing.length) return;
2246
+
2247
+ console.error(
2248
+ `\n✖ Android build is BROKEN: ${missing.length} @JavaProxy class(es) got no Java binding from the\n` +
2249
+ ` NativeScript static-binding-generator, so the APK/AAB is missing them. This build would install\n` +
2250
+ ` and then CRASH AT BOOT with "LookedUpClassNotFound":\n` +
2251
+ missing.map((m) => ` • ${m.cls} (declared in ${m.file})`).join('\n') +
2252
+ `\n\n CAUSE: SBG's js_parser hit an unsupported construct, abandoned the whole file, and still exited 0,\n` +
2253
+ ` dropping every @JavaProxy after that point. It does NOT report this itself.\n\n` +
2254
+ ` The known offender is a SPREAD inside an \`X.extend({...})\` literal — the parser reads\n` +
2255
+ ` \`prop.key.name\` for every property and a SpreadElement has no \`key\`. Keep .extend() literals\n` +
2256
+ ` spread-free (select between two literals instead), and never pass a variable to .extend().\n\n` +
2257
+ ` To see the real parse error:\n` +
2258
+ ` cd ${buildTools} && JS_PARSER_ENABLE_LOGGING=true node jsparser/js_parser.js enableVerboseLogging\n`
2259
+ );
2260
+ process.exit(1);
2135
2261
  }
2136
2262
 
2137
2263
  /** Wipe the generated NativeScript state (`ns clean`: platforms/, hooks/, node_modules/) then restore
@@ -2841,6 +2967,9 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
2841
2967
 
2842
2968
  console.log(`▶ ns ${args.join(' ').replace(/(--key-store-password|--key-store-alias-password) [^ ]*/g, '$1 ****')} (cwd: ${outDir})`);
2843
2969
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit' });
2970
+ // This is the RELEASE path (CI's `build android --release --aab`) — the one whose output actually ships.
2971
+ // It calls `ns` directly rather than via runNs, so it needs its own check. See assertSbgParsedCleanly.
2972
+ if (platform === 'android') assertSbgParsedCleanly(outDir);
2844
2973
  if (platform === 'ios' && release) {
2845
2974
  const ipa = join(outDir, 'platforms/ios/build/Release-iphoneos');
2846
2975
  console.log(`ℹ Signed store .ipa in ${ipa} — upload via \`bunx @livx.cc/mcp-appstores apple-upload\` or the fastlane release lane (native/fastlane).`);