@livx.cc/appwrap 0.51.1 → 0.51.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/app/shell/bridge.ts +61 -2
- package/runtime/app/shell/custom-webview.android.ts +74 -34
- package/runtime/app/shell/custom-webview.ios.ts +6 -0
- package/runtime/app/shell/env-switcher.ts +11 -3
- package/runtime/app/shell/geo-auth.ts +48 -0
- package/runtime/app/shell/handlers-extended.ts +43 -17
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +4 -2
- package/runtime/tests/bridge-response-delivery.test.ts +113 -0
- package/runtime/tests/geo-auth.test.ts +35 -0
- package/src/cli.ts +131 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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). */
|
|
@@ -338,18 +338,18 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
338
338
|
// In server mode (loader:'server') the page loads from a real https `serverUrl`: there are NO
|
|
339
339
|
// https://appwrap.local requests and `backendOrigin` is same-origin, so the asset interceptor does
|
|
340
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
|
|
342
|
-
// interception runs, NS is never crossed into JS off-thread, and the crash is structurally
|
|
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.
|
|
343
345
|
// app/file loaders MUST serve bundled www/ assets, so they keep it (and retain the client for the
|
|
344
346
|
// process lifetime — see CustomWebView.disposeNativeView — so a late in-flight call can't hit a dead peer).
|
|
345
347
|
// NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
request: android.webkit.WebResourceRequest | string
|
|
352
|
-
): 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 => {
|
|
353
353
|
const url: string = typeof request !== 'string' ? String(request.getUrl().toString()) : request;
|
|
354
354
|
if (!url.startsWith(APP_ORIGIN)) return null; // external — let WebView handle it
|
|
355
355
|
|
|
@@ -378,16 +378,16 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
378
378
|
return new android.webkit.WebResourceResponse(
|
|
379
379
|
'text/plain', 'utf-8', 404, 'Not Found', new java.util.HashMap<string, string>(), emptyStream()
|
|
380
380
|
);
|
|
381
|
-
|
|
381
|
+
};
|
|
382
382
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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 => {
|
|
391
391
|
const url: string = typeof request !== 'string' ? String(request.getUrl().toString()) : request;
|
|
392
392
|
const scheme = (url.split(':')[0] || '').toLowerCase();
|
|
393
393
|
if (['http', 'https', 'file', 'about', 'blob', 'data', 'javascript'].includes(scheme)) return false;
|
|
@@ -413,24 +413,23 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
413
413
|
console.warn('[appwrap] no OS handler for external URL: ' + url + ' (' + e + ')');
|
|
414
414
|
}
|
|
415
415
|
return true;
|
|
416
|
-
|
|
416
|
+
};
|
|
417
417
|
|
|
418
|
-
|
|
418
|
+
const pageStarted = (view: android.webkit.WebView): void => {
|
|
419
419
|
// Fallback injection — no-op when the document-start scripts already ran
|
|
420
420
|
view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
|
|
421
|
-
|
|
421
|
+
};
|
|
422
422
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
): 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 => {
|
|
434
433
|
// BUILD-TIME serverUrl host ONLY — never the switchable `effectiveServerUrl()` override (would let a
|
|
435
434
|
// switched host's self-signed cert be trusted → MITM). The dev server is always the build-time URL.
|
|
436
435
|
// Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
|
|
@@ -444,7 +443,48 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
444
443
|
} else {
|
|
445
444
|
handler.cancel();
|
|
446
445
|
}
|
|
447
|
-
|
|
448
|
-
|
|
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
|
+
});
|
|
449
489
|
return new assetClientClass();
|
|
450
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
|
}
|
|
@@ -236,14 +236,22 @@ export async function handleEnvDeepLink(link: string): Promise<void> {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
/** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL.
|
|
239
|
+
/** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL.
|
|
240
|
+
*
|
|
241
|
+
* The field is deliberately EMPTY — do NOT re-add a `defaultText: 'https://'` prefill. A URL is the one
|
|
242
|
+
* input class users always PASTE, and a copied URL already carries its own scheme, so a prefill breaks
|
|
243
|
+
* exactly the case this field exists for: iOS pastes at the caret (which sits AFTER the prefill), giving
|
|
244
|
+
* `https://https://host` → rejected by `allowPattern` as "Not allowed". Typing masks the bug (you type the
|
|
245
|
+
* host after the prefix), which is why it reads as "paste doesn't work". An empty field is also where iOS
|
|
246
|
+
* offers Paste most readily — long-pressing a NON-empty field raises the cursor loupe, not the edit menu.
|
|
247
|
+
* The `https://` hint lives in `message` instead, where it can't contaminate the value.
|
|
248
|
+
*/
|
|
240
249
|
async function promptOther(): Promise<void> {
|
|
241
250
|
const res = await Dialogs.prompt({
|
|
242
251
|
title: 'Custom environment',
|
|
243
|
-
message: 'Enter an allowed URL (https://…).',
|
|
252
|
+
message: 'Enter or paste an allowed URL (https://…).',
|
|
244
253
|
okButtonText: 'Next',
|
|
245
254
|
cancelButtonText: 'Cancel',
|
|
246
|
-
defaultText: 'https://',
|
|
247
255
|
inputType: 'text',
|
|
248
256
|
});
|
|
249
257
|
if (!res?.result || !res.text) return;
|
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
63
|
+
} else if (action.kind === 'reject') {
|
|
51
64
|
this.onCleanup?.();
|
|
52
|
-
this.onError?.(Object.assign(new Error(
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
});
|
|
@@ -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="
|
|
8
|
+
<corners android:radius="100dp" />
|
|
7
9
|
</shape>
|
|
@@ -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).`);
|