@livx.cc/appwrap 0.48.1 → 0.49.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/capabilities.manifest.ts +15 -33
- package/runtime/app/shell/config.ts +5 -0
- package/runtime/app/shell/custom-webview.android.ts +19 -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 +236 -0
- package/runtime/app/shell/env-switcher.ts +163 -0
- package/runtime/app/shell/handlers-android.ts +38 -0
- package/runtime/app/shell/handlers-background.ts +3 -2
- package/runtime/app/shell/handlers-parity.ts +55 -1
- 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
|
|
@@ -98,6 +98,16 @@ export const MODULES: ModuleManifest[] = [
|
|
|
98
98
|
ios: { permissions: [{ key: 'NSLocationWhenInUseUsageDescription', domain: 'location', defaultUsage: 'Show your location.' }] },
|
|
99
99
|
android: { permissions: ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'] },
|
|
100
100
|
},
|
|
101
|
+
// heading (compass) — handlers live in the always-bundled parity/android groups (registerParity/
|
|
102
|
+
// AndroidHandlers). iOS CLHeading via CLLocationManager: trueHeading needs location auth, so it rides
|
|
103
|
+
// geo's SAME NSLocationWhenInUseUsageDescription (the CLI dedups when geo is also active; declared here
|
|
104
|
+
// for self-containment so a heading-only app still stamps it). Android uses TYPE_ROTATION_VECTOR — a
|
|
105
|
+
// motion sensor that needs NO runtime permission, so no android block (avoid an unused-permission flag).
|
|
106
|
+
{
|
|
107
|
+
name: 'heading', group: 'parity',
|
|
108
|
+
capabilities: { heading: 'native' },
|
|
109
|
+
ios: { permissions: [{ key: 'NSLocationWhenInUseUsageDescription', domain: 'location', defaultUsage: 'Show your compass heading.' }] },
|
|
110
|
+
},
|
|
101
111
|
{
|
|
102
112
|
name: 'photos', group: 'extended',
|
|
103
113
|
capabilities: { photos: 'native' },
|
|
@@ -308,39 +318,11 @@ export const MODULES: ModuleManifest[] = [
|
|
|
308
318
|
},
|
|
309
319
|
|
|
310
320
|
// ── widget — home-screen presence via a native extension (iOS WidgetKit / Android AppWidget) ──
|
|
311
|
-
// Opt-in; ships its own
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
// `
|
|
315
|
-
//
|
|
316
|
-
{
|
|
317
|
-
name: 'widget', group: 'widget',
|
|
318
|
-
capabilities: { widget: { ios: true, android: true } },
|
|
319
|
-
// Android launcher widget = an AppWidgetProvider receiver (icon-grid) fed by the widget.publish
|
|
320
|
-
// handler via SharedPreferences; Kotlin source + res ship as nativeSrc. iOS side = the WidgetKit
|
|
321
|
-
// extension under nativeSrc's App_Resources/iOS/extensions/.
|
|
322
|
-
android: {
|
|
323
|
-
kotlin: true,
|
|
324
|
-
manifestApplication: `<receiver
|
|
325
|
-
android:name="cc.livx.appwrap.AppwrapWidgetProvider"
|
|
326
|
-
android:exported="true">
|
|
327
|
-
<intent-filter>
|
|
328
|
-
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
|
329
|
-
</intent-filter>
|
|
330
|
-
<meta-data
|
|
331
|
-
android:name="android.appwidget.provider"
|
|
332
|
-
android:resource="@xml/appwrap_widget_info" />
|
|
333
|
-
</receiver>`,
|
|
334
|
-
},
|
|
335
|
-
nativeSrc: 'widget',
|
|
336
|
-
},
|
|
337
|
-
|
|
338
|
-
// ── widget — home-screen presence via a native extension (iOS WidgetKit / Android AppWidget) ──
|
|
339
|
-
// Opt-in; ships its own extension target under modules-native/widget/. The host publishes tiles into
|
|
340
|
-
// an App Group container the extension reads at render; each tile deep-links back on tap. The App
|
|
341
|
-
// Group entitlement is app-specific (group.<appId>), so consumers supply it via appwrap.config
|
|
342
|
-
// `iosEntitlements` for now (Phase-2 templates `group.$APPID` in the CLI). Phase-1 spike ships a
|
|
343
|
-
// single static tile to prove the extension builds, provisions the group, renders, and deep-links.
|
|
321
|
+
// Opt-in; ships its own native surfaces under modules-native/widget/. The app publishes tiles/stats
|
|
322
|
+
// into a shared container (App Group on iOS, SharedPreferences on Android) via the `widget.publish`
|
|
323
|
+
// handler; the extension/provider reads it at render and each tile deep-links back on tap. Two kinds:
|
|
324
|
+
// a `launcher` icon-grid (the host's pinned apps) and a `data` widget (a mini-app's live stat/list).
|
|
325
|
+
// The App Group id is derived (`group.<appId>`) and templated into both targets by the CLI.
|
|
344
326
|
{
|
|
345
327
|
name: 'widget', group: 'widget',
|
|
346
328
|
capabilities: { widget: { ios: true, android: true } },
|
|
@@ -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
|
};
|
|
@@ -5,6 +5,13 @@ import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs, externalNavGu
|
|
|
5
5
|
import { envGlobalsJs } from './env';
|
|
6
6
|
import { requestPermissions } from './android-helpers';
|
|
7
7
|
|
|
8
|
+
/** Host[:port] of a URL, lowercased; '' if unparseable (scheme/path/userinfo stripped). */
|
|
9
|
+
function hostOfUrl(url: string): string {
|
|
10
|
+
const afterScheme = String(url || '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
11
|
+
const authority = afterScheme.split(/[/?#]/)[0];
|
|
12
|
+
return (authority.split('@').pop() || authority).toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
// `android` + `java` resolve to the real types-android namespaces (no declare needed).
|
|
9
16
|
declare const androidx: any; // no NS types: androidx.webkit.* (WebViewFeature/WebViewCompat) not in the android-32 platform typings
|
|
10
17
|
|
|
@@ -384,17 +391,23 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
384
391
|
view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
|
|
385
392
|
},
|
|
386
393
|
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
394
|
+
// DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
|
|
395
|
+
// points at a LAN dev server that almost always uses a self-signed / mkcert TLS cert the device's
|
|
396
|
+
// trust store doesn't know — the WebView would otherwise hard-fail with ERR_CERT_AUTHORITY_INVALID.
|
|
397
|
+
// Proceed past it ONLY in a debug build, only in server-loader mode, AND only for the ONE host the
|
|
398
|
+
// build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) — never blanket-trust every host. Production app:// builds
|
|
399
|
+
// never reach this (local assets, no TLS). NEVER active in a store build (SHELL_CONFIG.debug false).
|
|
391
400
|
onReceivedSslError(
|
|
392
401
|
_view: android.webkit.WebView,
|
|
393
402
|
handler: android.webkit.SslErrorHandler,
|
|
394
403
|
error: android.net.http.SslError
|
|
395
404
|
): void {
|
|
396
|
-
|
|
397
|
-
|
|
405
|
+
// BUILD-TIME serverUrl host ONLY — never the switchable `effectiveServerUrl()` override (would let a
|
|
406
|
+
// switched host's self-signed cert be trusted → MITM). The dev server is always the build-time URL.
|
|
407
|
+
const allowedHost = hostOfUrl(SHELL_CONFIG.serverUrl);
|
|
408
|
+
const errHost = hostOfUrl(String(error?.getUrl?.() ?? ''));
|
|
409
|
+
if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server' && !!allowedHost && errHost === allowedHost) {
|
|
410
|
+
console.warn('AppWrap: trusting self-signed dev-server cert (debug + host-scoped):', errHost);
|
|
398
411
|
handler.proceed();
|
|
399
412
|
} else {
|
|
400
413
|
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,236 @@
|
|
|
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
|
+
|
|
28
|
+
/** Show the banner if the switcher is enabled AND a NON-DEFAULT override is active. Idempotent. No-op
|
|
29
|
+
* otherwise (incl. when the override resolves to the build-time default host). */
|
|
30
|
+
export function showEnvBannerIfActive(): void {
|
|
31
|
+
if (!isEnvSwitcherEnabled() || !isNonDefaultOverride()) return;
|
|
32
|
+
state = 'expanded';
|
|
33
|
+
if (isIOS) Utils.dispatchToMainThread(showIOSBanner);
|
|
34
|
+
else if (isAndroid) runOnAndroidUi(showAndroidBanner);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reconcile the banner with the CURRENT override after an in-session switch/reset (the WebView reloads but
|
|
39
|
+
* this native overlay doesn't re-render on its own). Override active → ensure shown, reset to EXPANDED,
|
|
40
|
+
* re-render the new label/host, and re-arm the single shrink timer. Override cleared (reset) → hide it.
|
|
41
|
+
* Called from `applySwitch`; complements `showEnvBannerIfActive` (relaunch path).
|
|
42
|
+
*/
|
|
43
|
+
export function refreshEnvBanner(): void {
|
|
44
|
+
if (!isEnvSwitcherEnabled() || !isNonDefaultOverride()) { hideEnvBanner(); return; }
|
|
45
|
+
state = 'expanded';
|
|
46
|
+
if (isIOS) Utils.dispatchToMainThread(() => { if (iosContainer) { renderIOS(); armShrink(); } else showIOSBanner(); });
|
|
47
|
+
else if (isAndroid) runOnAndroidUi(() => { if (androidView) { renderAndroid(); armShrink(); } else showAndroidBanner(); });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove the banner and cancel its shrink timer. Idempotent. */
|
|
51
|
+
export function hideEnvBanner(): void {
|
|
52
|
+
if (shrinkTimer) { clearTimeout(shrinkTimer); shrinkTimer = null; }
|
|
53
|
+
if (isIOS) Utils.dispatchToMainThread(hideIOSBanner);
|
|
54
|
+
else if (isAndroid) runOnAndroidUi(hideAndroidBanner);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recall a stranded pill to its default spot: clear the parked/dragged position and re-render. No-op
|
|
59
|
+
* cleanly if the banner isn't shown. Wired to the shake handler (devmenu.ts) so a shake both opens the
|
|
60
|
+
* dev menu AND brings a dragged-away pill home — the recovery mechanism for the now-unclamped drag.
|
|
61
|
+
*/
|
|
62
|
+
export function resetEnvBannerPosition(): void {
|
|
63
|
+
iosDraggedCenter = null;
|
|
64
|
+
if (isIOS) { if (iosContainer) render(); }
|
|
65
|
+
else if (isAndroid) { if (androidView) render(); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bannerText(): string {
|
|
69
|
+
const label = activeEnvLabel() || 'Custom';
|
|
70
|
+
return state === 'pill' ? `⇄ ${label}` : `⇄ ${label} · ${hostOf(currentOverride())}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function armShrink(): void {
|
|
74
|
+
if (shrinkTimer) clearTimeout(shrinkTimer);
|
|
75
|
+
shrinkTimer = setTimeout(() => {
|
|
76
|
+
state = 'pill';
|
|
77
|
+
render();
|
|
78
|
+
}, SHRINK_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onTap(): void {
|
|
82
|
+
if (state === 'expanded') {
|
|
83
|
+
void showEnvSwitcher(); // tap expanded → open the switch menu
|
|
84
|
+
} else {
|
|
85
|
+
state = 'expanded'; // tap pill → re-expand, then re-arm the auto-shrink
|
|
86
|
+
render();
|
|
87
|
+
armShrink();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function render(): void {
|
|
92
|
+
if (isIOS) Utils.dispatchToMainThread(renderIOS);
|
|
93
|
+
else if (isAndroid) runOnAndroidUi(renderAndroid);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── iOS ──────────────────────────────────────────────────────────────
|
|
97
|
+
function iosGestureHandlerClass(): any {
|
|
98
|
+
if (!IOSGestureHandler) {
|
|
99
|
+
IOSGestureHandler = (NSObject as any).extend(
|
|
100
|
+
{
|
|
101
|
+
bannerTapped() { onTap(); },
|
|
102
|
+
// Drag to reposition; remember where the user parked it so re-renders don't snap it back.
|
|
103
|
+
// No-arg (reads the module-level recognizer) — a param-selector target crashes with
|
|
104
|
+
// "unrecognized selector" on a runtime .extend, so we mirror the no-arg tap pattern.
|
|
105
|
+
bannerPanned() {
|
|
106
|
+
const gr = iosPanRecognizer, c = iosContainer;
|
|
107
|
+
if (!gr || !c || !c.superview) return;
|
|
108
|
+
const t = gr.translationInView(c.superview);
|
|
109
|
+
// FREELY draggable ANYWHERE — including the top safe-area / status bar / screen edges. No clamp:
|
|
110
|
+
// recovery is via shake (see resetEnvBannerPosition), not by fencing the drag.
|
|
111
|
+
const nx = c.center.x + t.x, ny = c.center.y + t.y;
|
|
112
|
+
c.center = CGPointMake(nx, ny);
|
|
113
|
+
gr.setTranslationInView(CGPointMake(0, 0), c.superview);
|
|
114
|
+
if (gr.state === 3 /* UIGestureRecognizerState.Ended */) iosDraggedCenter = { x: nx, y: ny };
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
exposedMethods: {
|
|
119
|
+
bannerTapped: { returns: interop.types.void },
|
|
120
|
+
bannerPanned: { returns: interop.types.void },
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return IOSGestureHandler;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function showIOSBanner(): void {
|
|
129
|
+
if (iosContainer) return; // already shown
|
|
130
|
+
const rootVC = Utils.ios.getRootViewController();
|
|
131
|
+
if (!rootVC?.view) return;
|
|
132
|
+
|
|
133
|
+
const container = UIView.alloc().initWithFrame(CGRectMake(0, 0, 10, 10));
|
|
134
|
+
container.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0.72, 0.45, 0.02, 0.92); // amber = "not default"
|
|
135
|
+
container.layer.cornerRadius = 15;
|
|
136
|
+
container.clipsToBounds = true;
|
|
137
|
+
container.userInteractionEnabled = true;
|
|
138
|
+
|
|
139
|
+
const label = UILabel.alloc().initWithFrame(CGRectZero);
|
|
140
|
+
label.textColor = UIColor.whiteColor;
|
|
141
|
+
label.textAlignment = NSTextAlignment.Center;
|
|
142
|
+
label.font = UIFont.systemFontOfSizeWeight(13, UIFontWeightSemibold);
|
|
143
|
+
container.addSubview(label);
|
|
144
|
+
|
|
145
|
+
iosTapHandler = iosGestureHandlerClass().alloc().init();
|
|
146
|
+
container.addGestureRecognizer(UITapGestureRecognizer.alloc().initWithTargetAction(iosTapHandler, 'bannerTapped'));
|
|
147
|
+
iosPanRecognizer = UIPanGestureRecognizer.alloc().initWithTargetAction(iosTapHandler, 'bannerPanned');
|
|
148
|
+
container.addGestureRecognizer(iosPanRecognizer);
|
|
149
|
+
|
|
150
|
+
rootVC.view.addSubview(container);
|
|
151
|
+
iosContainer = container;
|
|
152
|
+
iosLabel = label;
|
|
153
|
+
renderIOS();
|
|
154
|
+
armShrink();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderIOS(): void {
|
|
158
|
+
const container = iosContainer, label = iosLabel;
|
|
159
|
+
if (!container || !label) return;
|
|
160
|
+
// Position in the SUPERVIEW's coordinate space (where container.center lives), NOT UIScreen: the root
|
|
161
|
+
// view can be inset/offset from the screen, which made the default land at the TOP. Fall back to the
|
|
162
|
+
// screen only if the container isn't in a hierarchy yet.
|
|
163
|
+
const sv = container.superview;
|
|
164
|
+
const bounds = sv ? sv.bounds : UIScreen.mainScreen.bounds;
|
|
165
|
+
const insets = sv ? sv.safeAreaInsets : null;
|
|
166
|
+
const safeBottom = insets ? insets.bottom : 0;
|
|
167
|
+
label.text = bannerText();
|
|
168
|
+
const height = 30;
|
|
169
|
+
const width = state === 'pill' ? 96 : Math.min(bounds.size.width - 24, 320);
|
|
170
|
+
container.alpha = state === 'pill' ? 0.6 : 1.0; // shrunk pill is semi-transparent but still readable
|
|
171
|
+
UIView.animateWithDurationAnimations(0.25, () => {
|
|
172
|
+
label.frame = CGRectMake(10, 0, width - 20, height);
|
|
173
|
+
container.frame = CGRectMake(0, 0, width, height);
|
|
174
|
+
if (state === 'pill' && iosDraggedCenter) {
|
|
175
|
+
container.center = CGPointMake(iosDraggedCenter.x, iosDraggedCenter.y); // where the user parked it
|
|
176
|
+
} else if (state === 'pill') {
|
|
177
|
+
// Parked default: inset from the left edge and RAISED well above the home-indicator zone, so a drag
|
|
178
|
+
// isn't stolen by iOS system edge gestures and it never sits under a bottom CTA. Draggable from there.
|
|
179
|
+
container.center = CGPointMake(20 + width / 2, bounds.size.height - safeBottom - height / 2 - 64);
|
|
180
|
+
} else {
|
|
181
|
+
// Expanded (transient): centered just above the safe area.
|
|
182
|
+
container.center = CGPointMake(bounds.size.width / 2, bounds.size.height - safeBottom - height / 2 - 6);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function hideIOSBanner(): void {
|
|
188
|
+
if (!iosContainer) return;
|
|
189
|
+
iosContainer.removeFromSuperview();
|
|
190
|
+
iosContainer = null;
|
|
191
|
+
iosLabel = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Android ──────────────────────────────────────────────────────────
|
|
195
|
+
function showAndroidBanner(): void {
|
|
196
|
+
if (androidView) return;
|
|
197
|
+
const activity = Application.android.foregroundActivity || Application.android.startActivity;
|
|
198
|
+
if (!activity) return;
|
|
199
|
+
const density = activity.getResources().getDisplayMetrics().density;
|
|
200
|
+
const padH = Math.round(14 * density), padV = Math.round(6 * density);
|
|
201
|
+
|
|
202
|
+
const tv = new android.widget.TextView(activity);
|
|
203
|
+
tv.setTextColor(android.graphics.Color.WHITE);
|
|
204
|
+
tv.setTextSize(13);
|
|
205
|
+
tv.setPadding(padH, padV, padH, padV);
|
|
206
|
+
const bg = new android.graphics.drawable.GradientDrawable();
|
|
207
|
+
bg.setColor(android.graphics.Color.argb(235, 184, 115, 5)); // amber
|
|
208
|
+
bg.setCornerRadius(15 * density);
|
|
209
|
+
tv.setBackground(bg);
|
|
210
|
+
tv.setOnClickListener(new android.view.View.OnClickListener({ onClick() { onTap(); } }));
|
|
211
|
+
|
|
212
|
+
const lp = new android.widget.FrameLayout.LayoutParams(-2, -2); // WRAP_CONTENT × WRAP_CONTENT
|
|
213
|
+
lp.gravity = android.view.Gravity.BOTTOM | android.view.Gravity.CENTER_HORIZONTAL;
|
|
214
|
+
lp.bottomMargin = Math.round(10 * density);
|
|
215
|
+
activity.addContentView(tv, lp);
|
|
216
|
+
androidView = tv;
|
|
217
|
+
renderAndroid();
|
|
218
|
+
armShrink();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderAndroid(): void {
|
|
222
|
+
if (androidView) androidView.setText(bannerText());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hideAndroidBanner(): void {
|
|
226
|
+
if (!androidView) return;
|
|
227
|
+
const parent = androidView.getParent();
|
|
228
|
+
if (parent) (parent as android.view.ViewGroup).removeView(androidView);
|
|
229
|
+
androidView = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function runOnAndroidUi(fn: () => void): void {
|
|
233
|
+
const activity = Application.android.foregroundActivity || Application.android.startActivity;
|
|
234
|
+
if (activity) activity.runOnUiThread(new java.lang.Runnable({ run: fn }));
|
|
235
|
+
else fn();
|
|
236
|
+
}
|