@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 +1 -1
- package/runtime/app/main-page.ts +12 -3
- package/runtime/app/shell/bridge.ts +6 -0
- package/runtime/app/shell/config.ts +5 -0
- package/runtime/app/shell/custom-webview.android.ts +16 -6
- package/runtime/app/shell/custom-webview.ios.ts +106 -0
- package/runtime/app/shell/devmenu.ts +9 -1
- package/runtime/app/shell/env-banner.ts +287 -0
- package/runtime/app/shell/env-switcher.ts +229 -0
- package/runtime/app/shell/events.ts +19 -0
- package/runtime/app/shell/handlers-background.ts +3 -2
- package/runtime/app/shell/plugin-host.ts +51 -0
- package/runtime/app/shell/plugins.generated.ts +8 -0
- package/runtime/app/shell/server-url.ts +74 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +65 -0
- package/runtime-desktop/src-tauri/src/main.rs +36 -0
- package/src/cli.ts +133 -1
- package/src/config.ts +31 -1
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -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)
|
|
176
|
-
const url = NSURL.URLWithString(
|
|
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
|
-
?
|
|
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
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|
-
|
|
397
|
-
|
|
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
|
|
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
|
+
}
|