@livx.cc/appwrap 0.51.2 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.51.2",
3
+ "version": "0.52.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -17,6 +17,7 @@ import './shell/fcm-bootstrap.generated'; // side-effect: registers the FCM serv
17
17
  import { startEventForwarding } from './shell/events';
18
18
  import { startDevMenu } from './shell/devmenu';
19
19
  import { showEnvBannerIfActive } from './shell/env-banner';
20
+ import { reassertEnvKeepAwake } from './shell/env-keepawake';
20
21
  import { SHELL_CONFIG } from './shell/config';
21
22
  import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
22
23
  import { CustomWebView } from './shell/custom-webview';
@@ -95,12 +96,6 @@ export function onPageLoaded(args: EventData): void {
95
96
  if (initialized) return;
96
97
  initialized = true;
97
98
 
98
- // Debug mode: keep the screen awake (no auto-lock while foreground) so the dev/inspect session
99
- // and the iterate loop stay alive. iOS via the idle timer; Android via WebView keepScreenOn.
100
- if (isIOS && SHELL_CONFIG.debug) {
101
- try { UIApplication.sharedApplication.idleTimerDisabled = true; } catch (e) { /* no-op */ }
102
- }
103
-
104
99
  registerHandlers();
105
100
  registerExtendedHandlers();
106
101
  registerParityHandlers();
@@ -138,6 +133,9 @@ export function onPageLoaded(args: EventData): void {
138
133
  // Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
139
134
  // active (env-switcher only). Bottom-safe-area, auto-shrinks to a pill after 3s. No-op otherwise.
140
135
  showEnvBannerIfActive();
136
+ // Keep the screen awake while pointed at a NON-DEFAULT backend (or in a debug build) — same signal as the
137
+ // banner above, so "banner showing" ⟺ "no auto-lock". Subsumes the old iOS-only debug idle-timer block.
138
+ reassertEnvKeepAwake();
141
139
 
142
140
  // Halt the WebView render + JS-timer pipeline while backgrounded so a page running a continuous
143
141
  // animation (Android doesn't auto-pause rAF off-screen) stops burning CPU/battery. No-op on iOS.
@@ -147,6 +145,10 @@ export function onPageLoaded(args: EventData): void {
147
145
  // iOS: wake the WebContent renderer NOW (it stays THROTTLED for ~30-45s after a full-window system
148
146
  // surface — StoreKit manage-subscriptions sheet, itms-apps deep link, backgrounding — froze it).
149
147
  webView.wakeWebContent();
148
+ // Neither platform guarantees the wake lock survives a background trip (Android drops the window flag
149
+ // outright if the Activity was recreated). Assert-only: never write `false`, so an app driving the
150
+ // public `ui.keepAwake` bridge API itself (e.g. during video) isn't clobbered on resume.
151
+ reassertEnvKeepAwake();
150
152
  });
151
153
 
152
154
  // DEBUG: trace the iOS app-lifecycle event sequence to the pullable log sink so we can see WHICH
@@ -420,6 +420,40 @@ function createAssetServingClient(): android.webkit.WebViewClient {
420
420
  view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
421
421
  };
422
422
 
423
+ // Main-frame load FAILURE → NativeScript's `loadFinished` event (its `error` arg). We replace NS's own
424
+ // WebViewClientImpl wholesale (see initNativeView), and NS only raises that event from ITS client — so
425
+ // without this forward the event NEVER fires on Android and a failed load (server down, DNS, refused)
426
+ // is invisible to JS. iOS keeps the event because DevCertNavDelegate forwards didFail*Navigation to
427
+ // NS's delegate; this restores the SAME seam here rather than adding a parallel Android-only one
428
+ // (consumer: env-switcher's reloadToEffective).
429
+ // MAIN FRAME ONLY: API 23+ also reports sub-resource failures here, and a dead favicon/image must not
430
+ // be mistaken for "the page didn't load". minSdk is 26, so only this modern overload is ever dispatched
431
+ // (the legacy string-arg overload is API < 23).
432
+ const receivedError = (
433
+ view: android.webkit.WebView,
434
+ request: android.webkit.WebResourceRequest,
435
+ error: android.webkit.WebResourceError
436
+ ): void => {
437
+ if (!request?.isForMainFrame?.()) return;
438
+ // `_onLoadFinished` is NS's internal event-raiser (the `_` API index.android.js itself calls) — not
439
+ // in the public typings, hence the cast.
440
+ (CustomWebView.forNative(view) as any)?._onLoadFinished(
441
+ String(request.getUrl?.() ?? ''),
442
+ `${error?.getDescription?.() ?? 'Load failed'} (${error?.getErrorCode?.() ?? '?'})`
443
+ );
444
+ };
445
+
446
+ // Main-frame load SUCCESS → NativeScript's `loadFinished` event with NO `error` arg. The counterpart of
447
+ // `receivedError` above, and NOT optional: NS raises this from ITS WebViewClientImpl.onPageFinished,
448
+ // which we replace wholesale — so forwarding only the FAILURE half would leave `loadFinished` never
449
+ // firing on a SUCCESSFUL load. A one-shot `loadFinished` listener (env-switcher's reloadToEffective)
450
+ // would then never unhook on success and would leak onto the NEXT navigation, blaming a later error on
451
+ // an earlier switch (and naming the earlier switch's host). Both halves or neither.
452
+ const pageFinished = (view: android.webkit.WebView, url: string): void => {
453
+ // Mirrors NS's own `owner._onLoadFinished(url, undefined)` — absent `error` is what marks success.
454
+ (CustomWebView.forNative(view) as any)?._onLoadFinished(String(url ?? ''));
455
+ };
456
+
423
457
  // DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
424
458
  // points at a LAN dev server that almost always uses a self-signed / mkcert TLS cert the device's
425
459
  // trust store doesn't know — the WebView would otherwise hard-fail with ERR_CERT_AUTHORITY_INVALID.
@@ -468,9 +502,15 @@ function createAssetServingClient(): android.webkit.WebViewClient {
468
502
  onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
469
503
  pageStarted(view);
470
504
  },
505
+ onPageFinished(view: android.webkit.WebView, url: string): void {
506
+ pageFinished(view, url);
507
+ },
471
508
  onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
472
509
  receivedSslError(handler, error);
473
510
  },
511
+ onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
512
+ receivedError(view, request, error);
513
+ },
474
514
  })
475
515
  : (android.webkit.WebViewClient as any).extend({
476
516
  shouldInterceptRequest(_view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): android.webkit.WebResourceResponse | null {
@@ -482,9 +522,15 @@ function createAssetServingClient(): android.webkit.WebViewClient {
482
522
  onPageStarted(view: android.webkit.WebView, _url: string, _favicon: android.graphics.Bitmap): void {
483
523
  pageStarted(view);
484
524
  },
525
+ onPageFinished(view: android.webkit.WebView, url: string): void {
526
+ pageFinished(view, url);
527
+ },
485
528
  onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
486
529
  receivedSslError(handler, error);
487
530
  },
531
+ onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
532
+ receivedError(view, request, error);
533
+ },
488
534
  });
489
535
  return new assetClientClass();
490
536
  }
@@ -0,0 +1,94 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { SHELL_CONFIG } from './config';
3
+ import { isNonDefaultOverride } from './env-switcher';
4
+
5
+ /**
6
+ * Keep the screen awake while the shell is pointed at a NON-PRODUCTION backend, so a long on-device test
7
+ * session isn't killed by auto-lock. Companion to `env-banner.ts`: same trigger, same reconcile points.
8
+ *
9
+ * WHY THE BACKEND, NOT THE BUILD: `SHELL_CONFIG.debug` is a BUILD-TIME fact, and it misses the case that
10
+ * actually matters — a RELEASE/TestFlight binary (`debug:false`) whose env-switcher is pointed at lab or a
11
+ * local dev server is a test session in every way that counts, and it auto-locks mid-test. Conversely the
12
+ * signal must never fire for a real user. `isNonDefaultOverride()` is exactly that line: it is true only
13
+ * when a persisted `kit:serverUrlOverride` resolves to a host DIFFERENT from the build-time default
14
+ * (`SHELL_CONFIG.serverUrl`). A real user never sets an override, so this cannot leak into production —
15
+ * and it is the SAME predicate the amber env banner keys off, so "banner visible" ⟺ "screen stays awake",
16
+ * by construction rather than by two rules kept in sync by hand.
17
+ *
18
+ * `debug` is retained as an OR, not a replacement: a debug build has its own reason to stay awake (the
19
+ * inspect/iterate loop) even on the default env. This subsumes the iOS-only `idleTimerDisabled` block that
20
+ * used to sit inline in main-page.ts; the debug-gated Android half still lives in `custom-webview.android.ts`
21
+ * (`wv.setKeepScreenOn(true)`), which is harmlessly redundant with this module's Android path in a debug
22
+ * build — both are idempotent "keep on" assertions, and only this one also covers the env-override case.
23
+ *
24
+ * Inert for `loader !== 'server'` / no envSwitcher config: `isNonDefaultOverride()` returns false via
25
+ * `isEnvSwitcherEnabled()`, so a non-switcher app reduces to the plain `debug` behaviour (no crash).
26
+ */
27
+
28
+ /** Desired wake-lock state: a non-default backend override (the banner's signal), or a debug build. */
29
+ export function shouldKeepAwake(): boolean {
30
+ return !!SHELL_CONFIG.debug || isNonDefaultOverride();
31
+ }
32
+
33
+ let androidRetries = 0; // bounds the boot "activity not ready" retry so it can't spin forever
34
+
35
+ /** Apply the wake lock natively. iOS: the app-wide idle timer. Android: the window's KEEP_SCREEN_ON flag.
36
+ *
37
+ * The Android boot retry mirrors `env-banner.ts`: at `onPageLoaded` on a relaunch the Activity is not
38
+ * necessarily attached yet, so `foregroundActivity` can be null. Returning silently there would drop the
39
+ * wake lock in exactly the common case (relaunch straight into lab) — so retry, bounded (~2s), and
40
+ * re-read the DESIRED state at each attempt so a switch/reset landing mid-window wins over a stale one. */
41
+ function applyKeepAwake(on: boolean): void {
42
+ if (isIOS) {
43
+ Utils.dispatchToMainThread(() => {
44
+ try { UIApplication.sharedApplication.idleTimerDisabled = on; } catch (e) { /* no-op */ }
45
+ });
46
+ } else if (isAndroid) {
47
+ const activity = Application.android?.foregroundActivity || Application.android?.startActivity;
48
+ if (!activity) {
49
+ if (androidRetries++ < 20) setTimeout(() => applyKeepAwake(shouldKeepAwake()), 100);
50
+ return;
51
+ }
52
+ androidRetries = 0;
53
+ activity.runOnUiThread(new java.lang.Runnable({
54
+ run() {
55
+ try {
56
+ const window = activity.getWindow();
57
+ if (!window) return;
58
+ const flag = android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
59
+ on ? window.addFlags(flag) : window.clearFlags(flag);
60
+ } catch (e) { /* no-op */ }
61
+ },
62
+ }));
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Reconcile the wake lock to the EXACT desired state — including turning it OFF. For explicit env
68
+ * transitions only (`applySwitch`, alongside `refreshEnvBanner`), where clearing on a reset-to-default is
69
+ * the whole point. Idempotent.
70
+ */
71
+ export function refreshEnvKeepAwake(): void {
72
+ applyKeepAwake(shouldKeepAwake());
73
+ }
74
+
75
+ /**
76
+ * Assert the wake lock if this env wants it — but NEVER write `false`. For the boot and resume paths.
77
+ *
78
+ * WHY ASSERT-ONLY, not a full reconcile: the hosted app may hold the screen on ITSELF — either via the
79
+ * PUBLIC `ui.keepAwake` bridge API (handlers-extended / handlers-android), or via the web Screen Wake Lock
80
+ * API (`navigator.wakeLock`), which Android WebView honours by keeping the same KEEP_SCREEN_ON state. That
81
+ * is not hypothetical: the AGF app holds a `navigator.wakeLock` for the duration of a live room, and the
82
+ * flag is observable on its window (`dumpsys window` → `fl=KEEP_SCREEN_ON`) with no override set at all.
83
+ * A full reconcile on resume would write `false` on the default env and silently CLOBBER that app's own
84
+ * lock after any background trip. Writing `false` here would also buy nothing: a fresh process
85
+ * starts with the idle timer enabled and a fresh Activity window has no KEEP_SCREEN_ON flag, so "off" is
86
+ * already the state at boot. Only the ON direction needs re-asserting.
87
+ *
88
+ * ON DOES need re-asserting: neither platform guarantees the lock survives a background trip. Android
89
+ * drops the window flag outright if the Activity is recreated (config change / process-death restore) —
90
+ * a NEW window has no flag, and `initialized` in main-page.ts would not re-run the boot path.
91
+ */
92
+ export function reassertEnvKeepAwake(): void {
93
+ if (shouldKeepAwake()) applyKeepAwake(true);
94
+ }
@@ -1,8 +1,10 @@
1
1
  import { ApplicationSettings, Dialogs, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import type { EventData, LoadEventData } from '@nativescript/core';
2
3
  import { SHELL_CONFIG } from './config';
3
4
  import { OVERRIDE_KEY, effectiveServerUrl, isUrlAllowed } from './server-url';
4
5
  import { bridge } from './bridge';
5
6
  import { refreshEnvBanner } from './env-banner';
7
+ import { refreshEnvKeepAwake } from './env-keepawake';
6
8
 
7
9
  /**
8
10
  * Runtime env-switcher — re-point a `loader:'server'` shell between declared environments (prod / lab /
@@ -72,12 +74,51 @@ function clearOverride(): void {
72
74
  ApplicationSettings.remove(OVERRIDE_KEY);
73
75
  }
74
76
 
77
+ /**
78
+ * The switch is PERSISTED but the visible page did NOT change — the exact gap the confirm prompt's "The
79
+ * app will reload" leaves the user staring at. Silence here reads as "the switch didn't work" (it did:
80
+ * the override is already written, so a cold start WILL land on it), which is the actionable half and the
81
+ * half that was missing. Name the host and the reason so an unreachable target (dev server down, DNS,
82
+ * TLS/ATS refusal) is distinguishable from a broken switcher.
83
+ */
84
+ function notifyNotReloaded(url: string, reason: string): void {
85
+ console.warn(`AppWrap: env switch persisted but the in-session reload did not happen (${hostOf(url)}): ${reason}`);
86
+ void Dialogs.alert({
87
+ title: 'Still on the old environment',
88
+ message: `Couldn't load ${hostOf(url)}.\n${reason}\n\nThe environment change IS saved — relaunch the app to use it.`,
89
+ okButtonText: 'OK',
90
+ });
91
+ }
92
+
75
93
  /** Load the current effective server URL into the live WebView (immediate switch — no wait for a cold
76
- * start; the persisted override also makes it stick across relaunch via the boot loader). */
94
+ * start; the persisted override also makes it stick across relaunch via the boot loader).
95
+ *
96
+ * NEVER fail silently: both the no-WebView path and a FAILED load report through `notifyNotReloaded`.
97
+ * The failure seam is NativeScript's own `loadFinished` event, whose `error` arg both platforms already
98
+ * populate for a main-frame failure (iOS: WKNavigationDelegate didFail[Provisional]Navigation — a refused
99
+ * connection is PROVISIONAL, which is exactly the case that bit; Android: WebViewClient.onReceivedError,
100
+ * re-wired to NS in custom-webview.android.ts because our client replaces NS's). Reusing it keeps ONE
101
+ * cross-platform seam — do NOT install a second WKNavigationDelegate here: custom-webview.ios.ts already
102
+ * owns one (DevCertNavDelegate) and a competing delegate would silently unhook its cert trust.
103
+ *
104
+ * The handler is ONE-SHOT and armed immediately before the load, so it observes this switch's outcome and
105
+ * cannot leak or mis-attribute a later navigation's error to the switch.
106
+ */
77
107
  function reloadToEffective(): void {
78
- const wv = bridge.getWebView();
79
- if (!wv) return;
80
108
  const url = effectiveServerUrl();
109
+ const wv = bridge.getWebView();
110
+ if (!wv) {
111
+ notifyNotReloaded(url, 'The app view is not available.');
112
+ return;
113
+ }
114
+ // `on` resolves to Observable's generic overload here (CustomWebView widens the WebView-specific one),
115
+ // so take EventData and narrow — `loadFinished` always carries LoadEventData.
116
+ const onLoadFinished = (args: EventData) => {
117
+ wv.off('loadFinished', onLoadFinished);
118
+ const error = (args as LoadEventData).error;
119
+ if (error) notifyNotReloaded(url, String(error));
120
+ };
121
+ wv.on('loadFinished', onLoadFinished);
81
122
  Utils.dispatchToMainThread(() => {
82
123
  if (isIOS && wv.ios) {
83
124
  (wv.ios as WKWebView).loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(url)));
@@ -104,6 +145,7 @@ async function applySwitch(url: string | null, label: string): Promise<void> {
104
145
  else clearOverride();
105
146
  reloadToEffective();
106
147
  refreshEnvBanner(); // in-session: reflect the new env (switch) or hide (reset) — not just on relaunch
148
+ refreshEnvKeepAwake(); // same signal as the banner: awake off-default, normal on a reset — never disagree
107
149
  }
108
150
 
109
151
  let menuOpen = false;
@@ -236,14 +278,22 @@ export async function handleEnvDeepLink(link: string): Promise<void> {
236
278
  }
237
279
  }
238
280
 
239
- /** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL. */
281
+ /** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL.
282
+ *
283
+ * The field is deliberately EMPTY — do NOT re-add a `defaultText: 'https://'` prefill. A URL is the one
284
+ * input class users always PASTE, and a copied URL already carries its own scheme, so a prefill breaks
285
+ * exactly the case this field exists for: iOS pastes at the caret (which sits AFTER the prefill), giving
286
+ * `https://https://host` → rejected by `allowPattern` as "Not allowed". Typing masks the bug (you type the
287
+ * host after the prefix), which is why it reads as "paste doesn't work". An empty field is also where iOS
288
+ * offers Paste most readily — long-pressing a NON-empty field raises the cursor loupe, not the edit menu.
289
+ * The `https://` hint lives in `message` instead, where it can't contaminate the value.
290
+ */
240
291
  async function promptOther(): Promise<void> {
241
292
  const res = await Dialogs.prompt({
242
293
  title: 'Custom environment',
243
- message: 'Enter an allowed URL (https://…).',
294
+ message: 'Enter or paste an allowed URL (https://…).',
244
295
  okButtonText: 'Next',
245
296
  cancelButtonText: 'Cancel',
246
- defaultText: 'https://',
247
297
  inputType: 'text',
248
298
  });
249
299
  if (!res?.result || !res.text) return;