@livx.cc/appwrap 0.48.2 → 0.50.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.48.2",
3
+ "version": "0.50.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",
@@ -1,5 +1,6 @@
1
1
  import { Application, AndroidApplication, EventData, Page, isAndroid, isIOS, knownFolders, path } from '@nativescript/core';
2
2
  import { bridge } from './shell/bridge';
3
+ import { effectiveServerUrl } from './shell/server-url';
3
4
  import { registerHandlers } from './shell/handlers';
4
5
  import { registerExtendedHandlers } from './shell/handlers-extended';
5
6
  import { registerParityHandlers } from './shell/handlers-parity';
@@ -11,9 +12,11 @@ import { registerFsHandlers } from './shell/handlers-fs';
11
12
  import { registerPushHandlers } from './shell/handlers-push';
12
13
  import { registerAndroidHandlers } from './shell/handlers-android';
13
14
  import { registerOptionalHandlers } from './shell/optional-handlers.generated';
15
+ import { registerPlugins } from './shell/plugins.generated';
14
16
  import './shell/fcm-bootstrap.generated'; // side-effect: registers the FCM service when push is wired
15
17
  import { startEventForwarding } from './shell/events';
16
18
  import { startDevMenu } from './shell/devmenu';
19
+ import { showEnvBannerIfActive } from './shell/env-banner';
17
20
  import { SHELL_CONFIG } from './shell/config';
18
21
  import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
19
22
  import { CustomWebView } from './shell/custom-webview';
@@ -111,6 +114,9 @@ export function onPageLoaded(args: EventData): void {
111
114
  if (isAndroid) registerAndroidHandlers();
112
115
  // Opt-in modules that own their own handler file (health, …) — generated to only the active set.
113
116
  registerOptionalHandlers();
117
+ // Config-gated TS plugins (appwrap.config `plugins`) — registers each plugin's bridge handlers.
118
+ // No-op default when no plugins are configured (generated barrel is empty → no plugin glue compiled).
119
+ registerPlugins();
114
120
  // Shake-to-open developer menu (App Info / Reload). On by default, incl. prod.
115
121
  if (SHELL_CONFIG.devMenu) startDevMenu();
116
122
 
@@ -129,6 +135,9 @@ export function onPageLoaded(args: EventData): void {
129
135
  if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
130
136
  startEventForwarding();
131
137
  loadBundle(webView);
138
+ // Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
139
+ // active (env-switcher only). Bottom-safe-area, auto-shrinks to a pill after 3s. No-op otherwise.
140
+ showEnvBannerIfActive();
132
141
 
133
142
  // Halt the WebView render + JS-timer pipeline while backgrounded so a page running a continuous
134
143
  // animation (Android doesn't auto-pause rAF off-screen) stops burning CPU/battery. No-op on iOS.
@@ -172,8 +181,8 @@ function loadBundle(webView: CustomWebView): void {
172
181
  return;
173
182
  }
174
183
  if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
175
- // Live URL (dev HMR / deployed). Bridge (WKUserScript + message handler) still injects.
176
- const url = NSURL.URLWithString(SHELL_CONFIG.serverUrl);
184
+ // Live URL (dev HMR / deployed), or a persisted debug override. Bridge still injects.
185
+ const url = NSURL.URLWithString(effectiveServerUrl());
177
186
  wk.loadRequest(NSURLRequest.requestWithURL(url));
178
187
  } else if (SHELL_CONFIG.loader === 'file') {
179
188
  const entryURL = NSURL.fileURLWithPath(entryPath);
@@ -194,7 +203,7 @@ function loadBundle(webView: CustomWebView): void {
194
203
  return;
195
204
  }
196
205
  webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
197
- ? SHELL_CONFIG.serverUrl
206
+ ? effectiveServerUrl()
198
207
  : SHELL_CONFIG.loader === 'file'
199
208
  ? `file://${entryPath}`
200
209
  : `https://appwrap.local/${SHELL_CONFIG.entry}`;
@@ -26,6 +26,12 @@ export class Bridge {
26
26
  this.handlers.set(method, handler);
27
27
  }
28
28
 
29
+ /** Whether a handler is already registered for `method` (used by the plugin host to refuse a
30
+ * namespaced key that would clobber a core handler). */
31
+ has(method: string): boolean {
32
+ return this.handlers.has(method);
33
+ }
34
+
29
35
  /**
30
36
  * Both platforms push envelopes in: iOS via WKScriptMessageHandler 'appwrap',
31
37
  * Android via the prompt() tunnel intercepted in WebChromeClient.onJsPrompt
@@ -65,4 +65,9 @@ export const SHELL_CONFIG = {
65
65
  * Lifting extra makes the page cover that strip (the keyboard hides the thin bottom row of content).
66
66
  * Default 82. Set 0 for NO extra lift (input sits flush at the reported height; the strip may show). */
67
67
  iosKeyboardExtraLift: 82,
68
+ /** Runtime env-switcher (loader:'server'). `enabled` is the resolved kill-switch (config block
69
+ * present AND not `enabled:false`); when false the menu action, banner, and boot override are all
70
+ * inert. `envs` = declared presets; `allowPattern` = anchored regex gating "Other" (default-deny
71
+ * when ''). Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
72
+ envSwitcher: { enabled: false, envs: [] as { label: string; url: string }[], allowPattern: '' },
68
73
  };
@@ -4,6 +4,7 @@ import { mimeFor } from './mime';
4
4
  import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
5
5
  import { envGlobalsJs } from './env';
6
6
  import { requestPermissions } from './android-helpers';
7
+ import { hostOf } from './env-switcher';
7
8
 
8
9
  // `android` + `java` resolve to the real types-android namespaces (no declare needed).
9
10
  declare const androidx: any; // no NS types: androidx.webkit.* (WebViewFeature/WebViewCompat) not in the android-32 platform typings
@@ -384,17 +385,26 @@ function createAssetServingClient(): android.webkit.WebViewClient {
384
385
  view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
385
386
  },
386
387
 
387
- // `appwrap dev` points at a LAN dev server that almost always uses a self-signed / mkcert TLS
388
- // cert the device's trust store doesn't know — the WebView would otherwise hard-fail with
389
- // ERR_CERT_AUTHORITY_INVALID. Proceed past it ONLY in a debug build AND only when actually in
390
- // server-loader (dev) mode. Production app:// builds never reach this (local assets, no TLS).
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).
391
394
  onReceivedSslError(
392
395
  _view: android.webkit.WebView,
393
396
  handler: android.webkit.SslErrorHandler,
394
397
  error: android.net.http.SslError
395
398
  ): void {
396
- if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server') {
397
- console.warn('AppWrap: proceeding past dev-server SSL error (debug+server only):', String(error));
399
+ // BUILD-TIME serverUrl host ONLY — never the switchable `effectiveServerUrl()` override (would let a
400
+ // switched host's self-signed cert be trusted → MITM). The dev server is always the build-time URL.
401
+ // Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
402
+ // resources on an alt port share one self-signed cert) — same `hostOf` normalization + port strip.
403
+ const stripPort = (h: string) => h.replace(/:\d+$/, '');
404
+ const allowedHost = stripPort(hostOf(SHELL_CONFIG.serverUrl));
405
+ const errHost = stripPort(hostOf(String(error?.getUrl?.() ?? '')));
406
+ if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server' && !!allowedHost && errHost === allowedHost) {
407
+ console.warn('AppWrap: trusting self-signed dev-server cert (debug + host-scoped):', errHost);
398
408
  handler.proceed();
399
409
  } else {
400
410
  handler.cancel();
@@ -57,6 +57,103 @@ class AppwrapLogHandler extends NSObject implements WKScriptMessageHandler {
57
57
  }
58
58
  }
59
59
 
60
+ /** Host[:port] of a URL, lowercased; '' if unparseable (scheme/path/userinfo stripped). */
61
+ function hostOfUrl(url: string): string {
62
+ const afterScheme = String(url || '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
63
+ const authority = afterScheme.split(/[/?#]/)[0];
64
+ return (authority.split('@').pop() || authority).toLowerCase();
65
+ }
66
+
67
+ /**
68
+ * DEBUG-ONLY dev-server cert trust. A `WKNavigationDelegate` that accepts the server-trust challenge for
69
+ * the ONE build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) and ONLY in a debug build — so `appwrap dev`
70
+ * against a local vite HTTPS server with a self-signed cert loads on-device with NO per-device CA install
71
+ * (and getUserMedia/mic works — a secure context). NEVER in a store build (gated on `SHELL_CONFIG.debug`,
72
+ * which `appwrap build` never sets), and scoped to exactly one host so it can't blanket-trust the internet.
73
+ *
74
+ * It WRAPS NativeScript's own navigation delegate: every other selector is forwarded to the original via
75
+ * ObjC message forwarding (`forwardingTargetForSelector:` + a `respondsToSelector:` that also consults the
76
+ * fallback), so NS's load-lifecycle events keep firing. This is a DISTINCT gate from the env-switcher
77
+ * (which is allowlist+enabled-gated and runs in all builds); cert trust stays debug-only + host-scoped.
78
+ */
79
+ @NativeClass()
80
+ class DevCertNavDelegate extends NSObject implements WKNavigationDelegate {
81
+ static ObjCProtocols = [WKNavigationDelegate];
82
+ fallback: WKNavigationDelegate | null = null;
83
+ static withFallback(fb: WKNavigationDelegate | null): DevCertNavDelegate {
84
+ const d = <DevCertNavDelegate>DevCertNavDelegate.new();
85
+ d.fallback = fb;
86
+ return d;
87
+ }
88
+ private fbResponds(sel: string): boolean {
89
+ const f = this.fallback as any;
90
+ return !!(f && f.respondsToSelector && f.respondsToSelector(sel));
91
+ }
92
+
93
+ // The reason this delegate exists: trust a self-signed cert for the build-time serverUrl host, debug
94
+ // only. Host WITHOUT port (protectionSpace.host has no port; serverUrl may, e.g. a :3000 dev server).
95
+ // Scoped to the BUILD-TIME host, never the switchable override (would enable MITM on a switched host).
96
+ webViewDidReceiveAuthenticationChallengeCompletionHandler(
97
+ webView: WKWebView,
98
+ challenge: NSURLAuthenticationChallenge,
99
+ completionHandler: (d: NSURLSessionAuthChallengeDisposition, c: NSURLCredential | null) => void
100
+ ): void {
101
+ const space = challenge.protectionSpace;
102
+ const stripPort = (h: string) => h.replace(/:\d+$/, '');
103
+ const allowedHost = stripPort(hostOfUrl(SHELL_CONFIG.serverUrl));
104
+ if (
105
+ SHELL_CONFIG.debug && !!allowedHost &&
106
+ space.authenticationMethod === NSURLAuthenticationMethodServerTrust &&
107
+ stripPort(hostOfUrl(space.host)) === allowedHost && space.serverTrust
108
+ ) {
109
+ appendWebLog(`[native:devcert] trusting self-signed cert for ${space.host} (debug + host-scoped)`);
110
+ completionHandler(NSURLSessionAuthChallengeDisposition.UseCredential, NSURLCredential.credentialForTrust(space.serverTrust));
111
+ return;
112
+ }
113
+ if (this.fbResponds('webView:didReceiveAuthenticationChallenge:completionHandler:')) {
114
+ (this.fallback as any).webViewDidReceiveAuthenticationChallengeCompletionHandler(webView, challenge, completionHandler);
115
+ return;
116
+ }
117
+ completionHandler(NSURLSessionAuthChallengeDisposition.PerformDefaultHandling, null);
118
+ }
119
+
120
+ // We REPLACE NS's navigationDelegate, so we MUST re-implement every method NS relied on and forward it
121
+ // EXPLICITLY — ObjC message forwarding (forwardingTargetForSelector) is unreliable for a @NativeClass,
122
+ // and a decision method (below) whose `decisionHandler` is never called STALLS the navigation → blank.
123
+ webViewDecidePolicyForNavigationActionDecisionHandler(webView: WKWebView, navigationAction: any, decisionHandler: (p: number) => void): void {
124
+ if (this.fbResponds('webView:decidePolicyForNavigationAction:decisionHandler:')) {
125
+ (this.fallback as any).webViewDecidePolicyForNavigationActionDecisionHandler(webView, navigationAction, decisionHandler);
126
+ } else {
127
+ decisionHandler(1 /* WKNavigationActionPolicy.Allow */);
128
+ }
129
+ }
130
+ webViewDecidePolicyForNavigationResponseDecisionHandler(webView: WKWebView, navigationResponse: any, decisionHandler: (p: number) => void): void {
131
+ if (this.fbResponds('webView:decidePolicyForNavigationResponse:decisionHandler:')) {
132
+ (this.fallback as any).webViewDecidePolicyForNavigationResponseDecisionHandler(webView, navigationResponse, decisionHandler);
133
+ } else {
134
+ decisionHandler(1 /* WKNavigationResponsePolicy.Allow */);
135
+ }
136
+ }
137
+ webViewDidStartProvisionalNavigation(webView: WKWebView, navigation: any): void {
138
+ if (this.fbResponds('webView:didStartProvisionalNavigation:')) (this.fallback as any).webViewDidStartProvisionalNavigation(webView, navigation);
139
+ }
140
+ webViewDidCommitNavigation(webView: WKWebView, navigation: any): void {
141
+ if (this.fbResponds('webView:didCommitNavigation:')) (this.fallback as any).webViewDidCommitNavigation(webView, navigation);
142
+ }
143
+ webViewDidFinishNavigation(webView: WKWebView, navigation: any): void {
144
+ if (this.fbResponds('webView:didFinishNavigation:')) (this.fallback as any).webViewDidFinishNavigation(webView, navigation);
145
+ }
146
+ webViewDidFailNavigationWithError(webView: WKWebView, navigation: any, error: NSError): void {
147
+ if (this.fbResponds('webView:didFailNavigation:withError:')) (this.fallback as any).webViewDidFailNavigationWithError(webView, navigation, error);
148
+ }
149
+ webViewDidFailProvisionalNavigationWithError(webView: WKWebView, navigation: any, error: NSError): void {
150
+ if (this.fbResponds('webView:didFailProvisionalNavigation:withError:')) (this.fallback as any).webViewDidFailProvisionalNavigationWithError(webView, navigation, error);
151
+ }
152
+ webViewWebContentProcessDidTerminate(webView: WKWebView): void {
153
+ if (this.fbResponds('webViewWebContentProcessDidTerminate:')) (this.fallback as any).webViewWebContentProcessDidTerminate(webView);
154
+ }
155
+ }
156
+
60
157
  export class CustomWebView extends WebView {
61
158
  /** Set by the bridge before load; receives raw envelope JSON. */
62
159
  onAppwrapMessage: ((json: string) => void) | null = null;
@@ -67,6 +164,7 @@ export class CustomWebView extends WebView {
67
164
  // TS `private` is compile-time only — sharing it let super.initNativeView() overwrite
68
165
  // our media-capture delegate with NS's, so getUserMedia re-prompted every call.
69
166
  private _appwrapUiDelegate!: WKUIDelegate; // retained — WKWebView holds uiDelegate weakly
167
+ private _navDelegate: DevCertNavDelegate | null = null; // retained — WKWebView holds navigationDelegate weakly
70
168
 
71
169
  /** No-op on iOS: WKWebView suspends rAF/timers itself when the app leaves the foreground.
72
170
  * Kept for parity with the Android impl (wired to suspend/resume in main-page). */
@@ -267,6 +365,14 @@ export class CustomWebView extends WebView {
267
365
  // the `.grant` sticks: iOS then prompts once (TCC), like a native app.
268
366
  const wk = this.nativeViewProtected as WKWebView;
269
367
  if (wk && this._appwrapUiDelegate) wk.UIDelegate = this._appwrapUiDelegate;
368
+ // DEBUG-ONLY dev-server cert trust: wrap NS's navigation delegate so a self-signed local dev server
369
+ // loads without a per-device CA install. Only in a debug server-loader build; forwards all other
370
+ // nav-delegate selectors to NS's original so load events keep firing. Never compiled-active in a
371
+ // store build (SHELL_CONFIG.debug is false there).
372
+ if (wk && SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server') {
373
+ this._navDelegate = DevCertNavDelegate.withFallback(wk.navigationDelegate);
374
+ wk.navigationDelegate = this._navDelegate;
375
+ }
270
376
  // Lightweight self-check surfaced via debug.webviewInfo (support diagnostics).
271
377
  (global as { __appwrapWebviewDiag?: string }).__appwrapWebviewDiag =
272
378
  `delegateIsMine=${!!wk && wk.UIDelegate === this._appwrapUiDelegate} ` +
@@ -2,6 +2,8 @@ import { Device, Dialogs, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
4
  import { SHELL_BUILD, reloadWebView, getReportedWebVersion } from './handlers';
5
+ import { isEnvSwitcherEnabled, showEnvSwitcher } from './env-switcher';
6
+ import { resetEnvBannerPosition } from './env-banner';
5
7
 
6
8
  /**
7
9
  * Shake-to-open developer menu (enabled in prod too, gated by `SHELL_CONFIG.devMenu`).
@@ -55,6 +57,7 @@ function onAccelMagnitude(g: number, dir: number): void {
55
57
  lastSpikeAt = 0;
56
58
  lastSpikeDir = 0;
57
59
  lastMenuAt = now;
60
+ resetEnvBannerPosition(); // a shake also recalls a stranded (freely-dragged) env pill to its default spot
58
61
  void showDevMenu();
59
62
  }
60
63
  }
@@ -117,15 +120,20 @@ async function showDevMenu(): Promise<void> {
117
120
  if (menuOpen) return; // sensor keeps firing while the sheet is up — don't stack dialogs
118
121
  menuOpen = true;
119
122
  try {
123
+ const actions = ['App Info', 'Reload', 'Toggle Debug'];
124
+ // "Switch Environment" — only when the env-switcher is configured + enabled (runs in ALL build types,
125
+ // not just debug; the gate is the config allowlist + confirm prompt, see env-switcher.ts).
126
+ if (isEnvSwitcherEnabled()) actions.push('Switch Environment');
120
127
  const action = await Dialogs.action({
121
128
  title: SHELL_CONFIG.name,
122
129
  message: 'Developer menu',
123
130
  cancelButtonText: 'Cancel',
124
- actions: ['App Info', 'Reload', 'Toggle Debug'],
131
+ actions,
125
132
  });
126
133
  if (action === 'App Info') await showInfo();
127
134
  else if (action === 'Reload') reloadWebView();
128
135
  else if (action === 'Toggle Debug') toggleWebDebug();
136
+ else if (action === 'Switch Environment') await showEnvSwitcher();
129
137
  } finally {
130
138
  menuOpen = false;
131
139
  }
@@ -0,0 +1,287 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { activeEnvLabel, currentOverride, hostOf, isEnvSwitcherEnabled, isNonDefaultOverride, showEnvSwitcher } from './env-switcher';
3
+
4
+ /**
5
+ * Env indicator banner — a non-invasive marker pinned to the BOTTOM safe area, shown on every relaunch
6
+ * while a NON-DEFAULT env override is active (see `currentOverride`). It starts EXPANDED (label + host),
7
+ * auto-shrinks to a small PILL after 3s to stay out of the way; tapping the pill re-expands it, tapping
8
+ * the expanded banner opens the "Switch Environment" menu (`showEnvSwitcher`). Moved here from the AGF web
9
+ * app since env-switching is now a framework concern. Distinct from `banner.ts` (the update-prompt banner).
10
+ */
11
+
12
+ const SHRINK_MS = 3000;
13
+ type State = 'expanded' | 'pill';
14
+ let state: State = 'expanded';
15
+ let shrinkTimer: any = null;
16
+
17
+ // iOS refs (built lazily inside the iOS path — `interop`/`NSObject` don't exist on Android).
18
+ let iosContainer: UIView | null = null;
19
+ let iosLabel: UILabel | null = null;
20
+ let iosTapHandler: any = null;
21
+ let IOSGestureHandler: any = null;
22
+ let iosDraggedCenter: { x: number; y: number } | null = null; // where the user parked the pill (drag)
23
+ let iosPanRecognizer: any = null; // read inside the (no-arg) pan handler to avoid a param-selector crash
24
+
25
+ // Android refs.
26
+ let androidView: android.widget.TextView | null = null;
27
+ let androidDraggedPos: { x: number; y: number } | null = null; // parked translation (mirror of iosDraggedCenter)
28
+
29
+ /** Show the banner if the switcher is enabled AND a NON-DEFAULT override is active. Idempotent. No-op
30
+ * otherwise (incl. when the override resolves to the build-time default host). */
31
+ export function showEnvBannerIfActive(): void {
32
+ if (!isEnvSwitcherEnabled() || !isNonDefaultOverride()) return;
33
+ state = 'expanded';
34
+ if (isIOS) Utils.dispatchToMainThread(showIOSBanner);
35
+ else if (isAndroid) runOnAndroidUi(showAndroidBanner);
36
+ }
37
+
38
+ /**
39
+ * Reconcile the banner with the CURRENT override after an in-session switch/reset (the WebView reloads but
40
+ * this native overlay doesn't re-render on its own). Override active → ensure shown, reset to EXPANDED,
41
+ * re-render the new label/host, and re-arm the single shrink timer. Override cleared (reset) → hide it.
42
+ * Called from `applySwitch`; complements `showEnvBannerIfActive` (relaunch path).
43
+ */
44
+ export function refreshEnvBanner(): void {
45
+ if (!isEnvSwitcherEnabled() || !isNonDefaultOverride()) { hideEnvBanner(); return; }
46
+ state = 'expanded';
47
+ if (isIOS) Utils.dispatchToMainThread(() => { if (iosContainer) { renderIOS(); armShrink(); } else showIOSBanner(); });
48
+ else if (isAndroid) runOnAndroidUi(() => { if (androidView) { renderAndroid(); armShrink(); } else showAndroidBanner(); });
49
+ }
50
+
51
+ /** Remove the banner and cancel its shrink timer. Idempotent. */
52
+ export function hideEnvBanner(): void {
53
+ if (shrinkTimer) { clearTimeout(shrinkTimer); shrinkTimer = null; }
54
+ if (isIOS) Utils.dispatchToMainThread(hideIOSBanner);
55
+ else if (isAndroid) runOnAndroidUi(hideAndroidBanner);
56
+ }
57
+
58
+ /**
59
+ * Recall a stranded pill to its default spot: clear the parked/dragged position and re-render. No-op
60
+ * cleanly if the banner isn't shown. Wired to the shake handler (devmenu.ts) so a shake both opens the
61
+ * dev menu AND brings a dragged-away pill home — the recovery mechanism for the now-unclamped drag.
62
+ */
63
+ export function resetEnvBannerPosition(): void {
64
+ iosDraggedCenter = null;
65
+ androidDraggedPos = null;
66
+ if (isIOS) { if (iosContainer) render(); }
67
+ else if (isAndroid) { if (androidView) render(); }
68
+ }
69
+
70
+ function bannerText(): string {
71
+ const label = activeEnvLabel() || 'Custom';
72
+ return state === 'pill' ? `⇄ ${label}` : `⇄ ${label} · ${hostOf(currentOverride())}`;
73
+ }
74
+
75
+ function armShrink(): void {
76
+ if (shrinkTimer) clearTimeout(shrinkTimer);
77
+ shrinkTimer = setTimeout(() => {
78
+ state = 'pill';
79
+ render();
80
+ }, SHRINK_MS);
81
+ }
82
+
83
+ function onTap(): void {
84
+ if (state === 'expanded') {
85
+ void showEnvSwitcher(); // tap expanded → open the switch menu
86
+ } else {
87
+ state = 'expanded'; // tap pill → re-expand, then re-arm the auto-shrink
88
+ render();
89
+ armShrink();
90
+ }
91
+ }
92
+
93
+ function render(): void {
94
+ if (isIOS) Utils.dispatchToMainThread(renderIOS);
95
+ else if (isAndroid) runOnAndroidUi(renderAndroid);
96
+ }
97
+
98
+ // ── iOS ──────────────────────────────────────────────────────────────
99
+ function iosGestureHandlerClass(): any {
100
+ if (!IOSGestureHandler) {
101
+ IOSGestureHandler = (NSObject as any).extend(
102
+ {
103
+ bannerTapped() { onTap(); },
104
+ // Drag to reposition; remember where the user parked it so re-renders don't snap it back.
105
+ // No-arg (reads the module-level recognizer) — a param-selector target crashes with
106
+ // "unrecognized selector" on a runtime .extend, so we mirror the no-arg tap pattern.
107
+ bannerPanned() {
108
+ const gr = iosPanRecognizer, c = iosContainer;
109
+ if (!gr || !c || !c.superview) return;
110
+ const t = gr.translationInView(c.superview);
111
+ // FREELY draggable ANYWHERE — including the top safe-area / status bar / screen edges. No clamp:
112
+ // recovery is via shake (see resetEnvBannerPosition), not by fencing the drag.
113
+ const nx = c.center.x + t.x, ny = c.center.y + t.y;
114
+ c.center = CGPointMake(nx, ny);
115
+ gr.setTranslationInView(CGPointMake(0, 0), c.superview);
116
+ if (gr.state === 3 /* UIGestureRecognizerState.Ended */) iosDraggedCenter = { x: nx, y: ny };
117
+ },
118
+ },
119
+ {
120
+ exposedMethods: {
121
+ bannerTapped: { returns: interop.types.void },
122
+ bannerPanned: { returns: interop.types.void },
123
+ },
124
+ }
125
+ );
126
+ }
127
+ return IOSGestureHandler;
128
+ }
129
+
130
+ function showIOSBanner(): void {
131
+ if (iosContainer) return; // already shown
132
+ const rootVC = Utils.ios.getRootViewController();
133
+ if (!rootVC?.view) return;
134
+
135
+ const container = UIView.alloc().initWithFrame(CGRectMake(0, 0, 10, 10));
136
+ container.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0.72, 0.45, 0.02, 0.92); // amber = "not default"
137
+ container.layer.cornerRadius = 15;
138
+ container.clipsToBounds = true;
139
+ container.userInteractionEnabled = true;
140
+
141
+ const label = UILabel.alloc().initWithFrame(CGRectZero);
142
+ label.textColor = UIColor.whiteColor;
143
+ label.textAlignment = NSTextAlignment.Center;
144
+ label.font = UIFont.systemFontOfSizeWeight(13, UIFontWeightSemibold);
145
+ container.addSubview(label);
146
+
147
+ iosTapHandler = iosGestureHandlerClass().alloc().init();
148
+ container.addGestureRecognizer(UITapGestureRecognizer.alloc().initWithTargetAction(iosTapHandler, 'bannerTapped'));
149
+ iosPanRecognizer = UIPanGestureRecognizer.alloc().initWithTargetAction(iosTapHandler, 'bannerPanned');
150
+ container.addGestureRecognizer(iosPanRecognizer);
151
+
152
+ rootVC.view.addSubview(container);
153
+ iosContainer = container;
154
+ iosLabel = label;
155
+ renderIOS();
156
+ armShrink();
157
+ }
158
+
159
+ function renderIOS(): void {
160
+ const container = iosContainer, label = iosLabel;
161
+ if (!container || !label) return;
162
+ // Position in the SUPERVIEW's coordinate space (where container.center lives), NOT UIScreen: the root
163
+ // view can be inset/offset from the screen, which made the default land at the TOP. Fall back to the
164
+ // screen only if the container isn't in a hierarchy yet.
165
+ const sv = container.superview;
166
+ const bounds = sv ? sv.bounds : UIScreen.mainScreen.bounds;
167
+ const insets = sv ? sv.safeAreaInsets : null;
168
+ const safeBottom = insets ? insets.bottom : 0;
169
+ label.text = bannerText();
170
+ const height = 30;
171
+ const width = state === 'pill' ? 96 : Math.min(bounds.size.width - 24, 320);
172
+ container.alpha = state === 'pill' ? 0.6 : 1.0; // shrunk pill is semi-transparent but still readable
173
+ UIView.animateWithDurationAnimations(0.25, () => {
174
+ label.frame = CGRectMake(10, 0, width - 20, height);
175
+ container.frame = CGRectMake(0, 0, width, height);
176
+ if (state === 'pill' && iosDraggedCenter) {
177
+ container.center = CGPointMake(iosDraggedCenter.x, iosDraggedCenter.y); // where the user parked it
178
+ } else if (state === 'pill') {
179
+ // Parked default: inset from the left edge and RAISED well above the home-indicator zone, so a drag
180
+ // isn't stolen by iOS system edge gestures and it never sits under a bottom CTA. Draggable from there.
181
+ container.center = CGPointMake(20 + width / 2, bounds.size.height - safeBottom - height / 2 - 64);
182
+ } else {
183
+ // Expanded (transient): centered just above the safe area.
184
+ container.center = CGPointMake(bounds.size.width / 2, bounds.size.height - safeBottom - height / 2 - 6);
185
+ }
186
+ });
187
+ }
188
+
189
+ function hideIOSBanner(): void {
190
+ if (!iosContainer) return;
191
+ iosContainer.removeFromSuperview();
192
+ iosContainer = null;
193
+ iosLabel = null;
194
+ }
195
+
196
+ // ── Android ──────────────────────────────────────────────────────────
197
+ function showAndroidBanner(): void {
198
+ if (androidView) return;
199
+ const activity = Application.android.foregroundActivity || Application.android.startActivity;
200
+ if (!activity) return;
201
+ const density = activity.getResources().getDisplayMetrics().density;
202
+ const padH = Math.round(14 * density), padV = Math.round(6 * density);
203
+
204
+ const tv = new android.widget.TextView(activity);
205
+ tv.setTextColor(android.graphics.Color.WHITE);
206
+ tv.setTextSize(13);
207
+ tv.setPadding(padH, padV, padH, padV);
208
+ const bg = new android.graphics.drawable.GradientDrawable();
209
+ bg.setColor(android.graphics.Color.argb(235, 184, 115, 5)); // amber
210
+ bg.setCornerRadius(15 * density);
211
+ tv.setBackground(bg);
212
+ // Tap semantics (via performClick from the touch listener below): pill → expand; expanded → switch menu.
213
+ tv.setOnClickListener(new android.view.View.OnClickListener({ onClick() { onTap(); } }));
214
+
215
+ // Drag ANYWHERE via translationX/Y (mirror of the iOS pan — no clamp; recall via shake →
216
+ // resetEnvBannerPosition). Consume the gesture stream (return true on DOWN) so we get MOVE/UP; on a
217
+ // no-move UP, performClick() routes to the tap handler above (preserves tap + a11y). The parked
218
+ // translation is remembered in androidDraggedPos so re-renders don't snap it home.
219
+ const slop = android.view.ViewConfiguration.get(activity).getScaledTouchSlop();
220
+ let downX = 0, downY = 0, startTX = 0, startTY = 0, moved = false;
221
+ tv.setOnTouchListener(new android.view.View.OnTouchListener({
222
+ onTouch(v: android.view.View, event: android.view.MotionEvent): boolean {
223
+ switch (event.getActionMasked()) {
224
+ case android.view.MotionEvent.ACTION_DOWN:
225
+ downX = event.getRawX(); downY = event.getRawY();
226
+ startTX = v.getTranslationX(); startTY = v.getTranslationY();
227
+ moved = false;
228
+ return true;
229
+ case android.view.MotionEvent.ACTION_MOVE: {
230
+ const dx = event.getRawX() - downX, dy = event.getRawY() - downY;
231
+ if (Math.abs(dx) > slop || Math.abs(dy) > slop) moved = true;
232
+ v.setTranslationX(startTX + dx);
233
+ v.setTranslationY(startTY + dy);
234
+ return true;
235
+ }
236
+ case android.view.MotionEvent.ACTION_UP:
237
+ if (moved) androidDraggedPos = { x: v.getTranslationX(), y: v.getTranslationY() };
238
+ else v.performClick();
239
+ return true;
240
+ default:
241
+ return false;
242
+ }
243
+ },
244
+ }));
245
+
246
+ const lp = new android.widget.FrameLayout.LayoutParams(-2, -2); // WRAP_CONTENT × WRAP_CONTENT
247
+ lp.gravity = android.view.Gravity.BOTTOM | android.view.Gravity.CENTER_HORIZONTAL;
248
+ // Raise above the gesture/nav area (mirror of the iOS home-indicator raise), respecting insets where
249
+ // available so the default pill never sits under the system nav bar (best-effort — falls back cleanly).
250
+ lp.bottomMargin = androidBottomInsetPx(activity) + Math.round(16 * density);
251
+ activity.addContentView(tv, lp);
252
+ androidView = tv;
253
+ renderAndroid();
254
+ armShrink();
255
+ }
256
+
257
+ /** Bottom system-inset (gesture/nav bar) in px; 0 best-effort if unavailable. */
258
+ function androidBottomInsetPx(activity: android.app.Activity): number {
259
+ try {
260
+ const wi = activity.getWindow().getDecorView().getRootWindowInsets();
261
+ if (wi) return wi.getSystemWindowInsetBottom();
262
+ } catch (e) { /* pre-attach / older API — fall back to the base margin */ }
263
+ return 0;
264
+ }
265
+
266
+ function renderAndroid(): void {
267
+ const tv = androidView;
268
+ if (!tv) return;
269
+ tv.setText(bannerText());
270
+ tv.setAlpha(state === 'pill' ? 0.6 : 1.0); // shrunk pill is semi-transparent but still readable
271
+ // Re-apply the parked translation (or default home) so a re-render doesn't snap a dragged pill back.
272
+ tv.setTranslationX(androidDraggedPos ? androidDraggedPos.x : 0);
273
+ tv.setTranslationY(androidDraggedPos ? androidDraggedPos.y : 0);
274
+ }
275
+
276
+ function hideAndroidBanner(): void {
277
+ if (!androidView) return;
278
+ const parent = androidView.getParent();
279
+ if (parent) (parent as android.view.ViewGroup).removeView(androidView);
280
+ androidView = null;
281
+ }
282
+
283
+ function runOnAndroidUi(fn: () => void): void {
284
+ const activity = Application.android.foregroundActivity || Application.android.startActivity;
285
+ if (activity) activity.runOnUiThread(new java.lang.Runnable({ run: fn }));
286
+ else fn();
287
+ }