@livx.cc/appwrap 0.48.2 → 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/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-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
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ApplicationSettings, Dialogs, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { SHELL_CONFIG } from './config';
|
|
3
|
+
import { OVERRIDE_KEY, effectiveServerUrl, isUrlAllowed } from './server-url';
|
|
4
|
+
import { bridge } from './bridge';
|
|
5
|
+
import { refreshEnvBanner } from './env-banner';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runtime env-switcher — re-point a `loader:'server'` shell between declared environments (prod / lab /
|
|
9
|
+
* a preview URL) at runtime, surviving a cold start, with NO separate native build. First-party capability
|
|
10
|
+
* (not a plugin), opt-in via `SHELL_CONFIG.envSwitcher`. Inert unless the config block is present AND
|
|
11
|
+
* `enabled` (a prod fork can set `enabled:false` to hard-disable).
|
|
12
|
+
*
|
|
13
|
+
* SECURITY MODEL (distinct from the debug-only dev-server cert trust): this runs in ALL build types when
|
|
14
|
+
* configured. The gate is a REGEX ALLOWLIST + a CONFIRM prompt, not build-type. Declared presets (`envs`)
|
|
15
|
+
* are always trusted; a free-form "Other" URL must match `allowPattern` (anchored, full-string, compiled
|
|
16
|
+
* with try/catch — a throwing/absent pattern is treated as DEFAULT-DENY: "Other" disabled). The chosen URL
|
|
17
|
+
* is persisted through the same native-storage seam the boot loader reads (`kit:serverUrlOverride`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function isEnvSwitcherEnabled(): boolean {
|
|
21
|
+
return SHELL_CONFIG.loader === 'server' && !!SHELL_CONFIG.envSwitcher?.enabled;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Host[:port] of a URL — scheme/path/query/fragment/userinfo stripped. '' if unparseable. */
|
|
25
|
+
export function hostOf(url: string): string {
|
|
26
|
+
const afterScheme = String(url || '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
27
|
+
const authority = afterScheme.split(/[/?#]/)[0];
|
|
28
|
+
return (authority.split('@').pop() || authority).toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The currently persisted override URL, or '' when none / malformed / feature disabled. */
|
|
32
|
+
export function currentOverride(): string {
|
|
33
|
+
if (!isEnvSwitcherEnabled()) return '';
|
|
34
|
+
try {
|
|
35
|
+
const raw = ApplicationSettings.getString(OVERRIDE_KEY, '');
|
|
36
|
+
if (!raw) return '';
|
|
37
|
+
const url = JSON.parse(raw);
|
|
38
|
+
return typeof url === 'string' ? url : '';
|
|
39
|
+
} catch {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when a persisted override is active AND resolves to a host that DIFFERS from the build-time default
|
|
46
|
+
* (`SHELL_CONFIG.serverUrl`). This — not raw `currentOverride()` — is the "you are NOT on the default env"
|
|
47
|
+
* signal the banner keys off: an override whose host equals the build default is not an off-default state
|
|
48
|
+
* (e.g. selecting the preset that equals the build default). Host-normalized via `hostOf` (host[:port],
|
|
49
|
+
* lowercased) — note `hostOf` KEEPS the port (unlike the dev-cert match, which strips it), so two envs that
|
|
50
|
+
* differ only by port compare as distinct; both sides use the same `hostOf`, so the equality test is sound.
|
|
51
|
+
*/
|
|
52
|
+
export function isNonDefaultOverride(): boolean {
|
|
53
|
+
const override = currentOverride();
|
|
54
|
+
if (!override) return false;
|
|
55
|
+
return hostOf(override) !== hostOf(SHELL_CONFIG.serverUrl);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Label for the active env: a matching preset's label, else 'Custom' when an override is set, else ''. */
|
|
59
|
+
export function activeEnvLabel(): string {
|
|
60
|
+
const override = currentOverride();
|
|
61
|
+
if (!override) return '';
|
|
62
|
+
const preset = (SHELL_CONFIG.envSwitcher?.envs ?? []).find((e) => e.url === override);
|
|
63
|
+
return preset ? preset.label : 'Custom';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Persist an override (same encoding as `kit.storage.set` — JSON.stringify under the namespaced key). */
|
|
67
|
+
function writeOverride(url: string): void {
|
|
68
|
+
ApplicationSettings.setString(OVERRIDE_KEY, JSON.stringify(url));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearOverride(): void {
|
|
72
|
+
ApplicationSettings.remove(OVERRIDE_KEY);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load the current effective server URL into the live WebView (immediate switch — no wait for a cold
|
|
76
|
+
* start; the persisted override also makes it stick across relaunch via the boot loader). */
|
|
77
|
+
function reloadToEffective(): void {
|
|
78
|
+
const wv = bridge.getWebView();
|
|
79
|
+
if (!wv) return;
|
|
80
|
+
const url = effectiveServerUrl();
|
|
81
|
+
Utils.dispatchToMainThread(() => {
|
|
82
|
+
if (isIOS && wv.ios) {
|
|
83
|
+
(wv.ios as WKWebView).loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(url)));
|
|
84
|
+
} else if (isAndroid && wv.android) {
|
|
85
|
+
wv.android.clearCache(true);
|
|
86
|
+
wv.src = url;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Apply a switch after user confirmation: persist (or clear) then reload to the new effective URL. */
|
|
92
|
+
async function applySwitch(url: string | null, label: string): Promise<void> {
|
|
93
|
+
const ok = await Dialogs.confirm({
|
|
94
|
+
title: 'Switch environment',
|
|
95
|
+
message: url ? `Load ${label}?\n${hostOf(url)}\n\nThe app will reload.` : 'Reset to the default environment?\nThe app will reload.',
|
|
96
|
+
okButtonText: url ? 'Switch' : 'Reset',
|
|
97
|
+
cancelButtonText: 'Cancel',
|
|
98
|
+
});
|
|
99
|
+
if (!ok) return;
|
|
100
|
+
// Switching to a URL whose host equals the build-time default is really a RESET: persisting it would be
|
|
101
|
+
// redundant state (the boot loader falls back to SHELL_CONFIG.serverUrl anyway) and would leave a
|
|
102
|
+
// relaunch-visible key. Clear instead, so both the banner gate and a cold start land on "default".
|
|
103
|
+
if (url && hostOf(url) !== hostOf(SHELL_CONFIG.serverUrl)) writeOverride(url);
|
|
104
|
+
else clearOverride();
|
|
105
|
+
reloadToEffective();
|
|
106
|
+
refreshEnvBanner(); // in-session: reflect the new env (switch) or hide (reset) — not just on relaunch
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let menuOpen = false;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Show the "Switch Environment" action sheet: pick a declared preset, enter a free-form "Other" URL
|
|
113
|
+
* (validated against `allowPattern`, default-deny), or reset to the build default. Every switch goes
|
|
114
|
+
* through a confirm prompt. No-op when the feature is disabled.
|
|
115
|
+
*/
|
|
116
|
+
export async function showEnvSwitcher(): Promise<void> {
|
|
117
|
+
if (!isEnvSwitcherEnabled() || menuOpen) return;
|
|
118
|
+
menuOpen = true;
|
|
119
|
+
try {
|
|
120
|
+
const envs = SHELL_CONFIG.envSwitcher?.envs ?? [];
|
|
121
|
+
const active = currentOverride();
|
|
122
|
+
const allowOther = !!SHELL_CONFIG.envSwitcher?.allowPattern;
|
|
123
|
+
const actions = envs.map((e) => (e.url === active ? `${e.label} ✓` : e.label));
|
|
124
|
+
if (allowOther) actions.push('Other…');
|
|
125
|
+
actions.push('Reset to default');
|
|
126
|
+
|
|
127
|
+
const choice = await Dialogs.action({
|
|
128
|
+
title: 'Switch Environment',
|
|
129
|
+
message: active ? `Current: ${activeEnvLabel()} (${hostOf(active)})` : 'Current: default',
|
|
130
|
+
cancelButtonText: 'Cancel',
|
|
131
|
+
actions,
|
|
132
|
+
});
|
|
133
|
+
if (!choice || choice === 'Cancel') return;
|
|
134
|
+
|
|
135
|
+
if (choice === 'Reset to default') return void (await applySwitch(null, 'default'));
|
|
136
|
+
if (choice === 'Other…') return void (await promptOther());
|
|
137
|
+
|
|
138
|
+
const label = choice.replace(/ ✓$/, '');
|
|
139
|
+
const env = envs.find((e) => e.label === label);
|
|
140
|
+
if (env) await applySwitch(env.url, env.label);
|
|
141
|
+
} finally {
|
|
142
|
+
menuOpen = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL. */
|
|
147
|
+
async function promptOther(): Promise<void> {
|
|
148
|
+
const res = await Dialogs.prompt({
|
|
149
|
+
title: 'Custom environment',
|
|
150
|
+
message: 'Enter an allowed URL (https://…).',
|
|
151
|
+
okButtonText: 'Next',
|
|
152
|
+
cancelButtonText: 'Cancel',
|
|
153
|
+
defaultText: 'https://',
|
|
154
|
+
inputType: 'text',
|
|
155
|
+
});
|
|
156
|
+
if (!res?.result || !res.text) return;
|
|
157
|
+
const url = res.text.trim();
|
|
158
|
+
if (!isUrlAllowed(url)) {
|
|
159
|
+
await Dialogs.alert({ title: 'Not allowed', message: 'That URL is not in the allowed pattern for this app.', okButtonText: 'OK' });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await applySwitch(url, 'Custom');
|
|
163
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
|
+
import { effectiveServerUrl } from './server-url';
|
|
4
5
|
import { setPendingBackgroundTaskId } from './background-context';
|
|
5
6
|
import { CustomWebView } from './custom-webview';
|
|
6
7
|
// Android-only WorkManager Worker (@JavaProxy + `extends androidx.work.Worker`). Kept in a `.android.ts`
|
|
@@ -100,14 +101,14 @@ function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
|
|
|
100
101
|
const wk = webView.ios as WKWebView;
|
|
101
102
|
if (retry(!wk)) return;
|
|
102
103
|
if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
|
|
103
|
-
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(
|
|
104
|
+
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(effectiveServerUrl())));
|
|
104
105
|
} else {
|
|
105
106
|
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`)));
|
|
106
107
|
}
|
|
107
108
|
} else {
|
|
108
109
|
if (retry(!webView.android)) return;
|
|
109
110
|
webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
|
|
110
|
-
?
|
|
111
|
+
? effectiveServerUrl()
|
|
111
112
|
: `https://appwrap.local/${SHELL_CONFIG.entry}`;
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile plugin host — the in-process analog of the desktop plugin host (`plugin/host.ts`).
|
|
3
|
+
*
|
|
4
|
+
* On desktop a plugin's `WindowCtx` ops marshal over a Unix socket to the Rust shell; that host does
|
|
5
|
+
* NOT apply on mobile — there is one WKWebView/WebView and the NS runtime IS the trusted host. So the
|
|
6
|
+
* mobile "host" is trivial: at boot it takes each configured plugin's def and registers its `handlers`
|
|
7
|
+
* directly onto the same {@link bridge} the built-in `handlers*.ts` groups use. A PWA then reaches a
|
|
8
|
+
* plugin handler exactly like any native method — `kit.invoke('<plugin>.<method>')`.
|
|
9
|
+
*
|
|
10
|
+
* This walking skeleton consumes ONLY `handlers` (the desktop types call a handlers-only plugin the
|
|
11
|
+
* base case). `attachTo`/`onWindow`/`WindowCtx` are desktop-only concepts (multi-window, out-of-webview
|
|
12
|
+
* control) with no mobile analog yet — a plugin that also declares them still works here; those fields
|
|
13
|
+
* are ignored. Deeper hooks (devmenu action, boot/deeplink) are a documented follow-up.
|
|
14
|
+
*/
|
|
15
|
+
import { bridge } from './bridge';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The structural subset of the shared `PluginDef` (`@livx.cc/appwrap/plugin`) that the mobile host
|
|
19
|
+
* consumes. Kept as a local type so the NS runtime never imports the desktop socket/WindowCtx types.
|
|
20
|
+
* A plugin authored with `definePlugin({ name, handlers })` satisfies this by construction.
|
|
21
|
+
*
|
|
22
|
+
* Handler keys are BARE method names (`hello`, not `greeter.hello`); the host registers each on the
|
|
23
|
+
* bridge NAMESPACED under `plugin.name` → the PWA reaches it as `kit.invoke('<name>.<method>')`.
|
|
24
|
+
*/
|
|
25
|
+
export interface MobilePluginDef {
|
|
26
|
+
name: string;
|
|
27
|
+
handlers?: Record<string, (params: any) => unknown | Promise<unknown>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Register one plugin's bridge handlers, each NAMESPACED under `plugin.name` as `<name>.<method>`.
|
|
31
|
+
* This (a) matches the spec's `kit.invoke('<plugin>.<method>')` call shape and (b) confines a plugin
|
|
32
|
+
* to its own namespace so it cannot shadow a core handler (e.g. `app.reload`). As a belt-and-braces
|
|
33
|
+
* guard for the residual case where `name` itself collides with a core prefix, a namespaced key that
|
|
34
|
+
* is ALREADY registered (core handlers register first, at boot) is refused with a warning rather than
|
|
35
|
+
* clobbering the incumbent. */
|
|
36
|
+
export function registerPluginHandlers(plugin: MobilePluginDef): void {
|
|
37
|
+
const name = plugin?.name;
|
|
38
|
+
const handlers = plugin?.handlers ?? {};
|
|
39
|
+
if (!name) {
|
|
40
|
+
console.warn('⚠ mobile plugin has no `name` — cannot namespace its handlers; skipping.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const method of Object.keys(handlers)) {
|
|
44
|
+
const key = `${name}.${method}`;
|
|
45
|
+
if (bridge.has(key)) {
|
|
46
|
+
console.warn(`⚠ mobile plugin "${name}" handler "${key}" collides with an already-registered method — refusing to override; skipping.`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
bridge.register(key, handlers[method]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated by `appwrap init`/`sync` from the appwrap config `plugins`. Do not edit.
|
|
3
|
+
* Committed DEFAULT registers NO plugins (parity with the module barrels): a build without `plugins`
|
|
4
|
+
* imports no plugin bundle and compiles no plugin glue. The CLI (`regenerateMobilePlugins`) rewrites
|
|
5
|
+
* this to import each configured plugin's bundle and register its handlers.
|
|
6
|
+
*/
|
|
7
|
+
export function registerPlugins(): void {
|
|
8
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ApplicationSettings } from '@nativescript/core';
|
|
2
|
+
import { SHELL_CONFIG } from './config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persisted serverUrl override for `loader:'server'` shells.
|
|
6
|
+
*
|
|
7
|
+
* A web debug tool (e.g. an in-app env switcher) can point the WebView at a different origin at
|
|
8
|
+
* runtime. `window.location` only lasts the session — on a cold start the shell reloads its
|
|
9
|
+
* build-time `serverUrl`, and web `localStorage` is partitioned per-origin so it can't carry the
|
|
10
|
+
* choice across the switch. The fix lives in the shell: persist the chosen URL in native storage
|
|
11
|
+
* (survives restarts + origin changes) and read it HERE at every boot/reload site.
|
|
12
|
+
*
|
|
13
|
+
* The web writes it through the existing `kit.storage` seam — `kit.storage.set('serverUrlOverride',
|
|
14
|
+
* url)` — which stores `JSON.stringify(url)` under the namespaced key `kit:serverUrlOverride`
|
|
15
|
+
* (ApplicationSettings → NSUserDefaults / SharedPreferences). Clearing it (`kit.storage.remove`)
|
|
16
|
+
* reverts to the build-time `serverUrl` on the next load.
|
|
17
|
+
*
|
|
18
|
+
* SECURITY: honored ONLY when the env-switcher is configured + enabled (`SHELL_CONFIG.envSwitcher.enabled`
|
|
19
|
+
* — the config block is present and not `enabled:false`). An app that doesn't declare `envSwitcher`, or a
|
|
20
|
+
* prod fork that sets `enabled:false`, ignores the key entirely, so a compromised page can't persistently
|
|
21
|
+
* redirect the shell. The switcher's own gate (menu + allowPattern + confirm) governs what can be written;
|
|
22
|
+
* this is the read side. Runs in ALL build types when enabled — distinct from the debug-only cert trust.
|
|
23
|
+
*/
|
|
24
|
+
export const OVERRIDE_KEY = 'kit:serverUrlOverride';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Full-string allowlist test for a candidate "Other"/override URL. DEFAULT-DENY: an empty or throwing
|
|
28
|
+
* `allowPattern` rejects everything. Enforces a FULL-STRING match (guards a non-anchored pattern that
|
|
29
|
+
* would otherwise match a substring). Bounded input length guards against pathological backtracking on a
|
|
30
|
+
* hostile pattern. Lives here (not env-switcher.ts) so the boot loader can reuse it without an import
|
|
31
|
+
* cycle — env-switcher.ts imports it back for the "Other" prompt.
|
|
32
|
+
*/
|
|
33
|
+
export function isUrlAllowed(url: string): boolean {
|
|
34
|
+
const pattern = SHELL_CONFIG.envSwitcher?.allowPattern;
|
|
35
|
+
if (!pattern) return false; // default-deny: no allowlist configured
|
|
36
|
+
if (!/^https?:\/\//i.test(url) || url.length > 2048) return false;
|
|
37
|
+
try {
|
|
38
|
+
const re = new RegExp(pattern);
|
|
39
|
+
const m = url.match(re);
|
|
40
|
+
return !!m && m[0] === url; // full-string match, regardless of author anchoring
|
|
41
|
+
} catch {
|
|
42
|
+
return false; // throwing pattern → default-deny
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is a stored/entered override URL trustworthy? A declared preset is implicitly allowed; otherwise it
|
|
47
|
+
* must match `allowPattern`. Used by BOTH the boot loader (re-validate the persisted override) and the
|
|
48
|
+
* switcher, so a compromised page that writes an arbitrary `serverUrlOverride` directly through the
|
|
49
|
+
* `kit.storage` bridge seam can't redirect the shell — the write is ignored at boot unless allowlisted. */
|
|
50
|
+
export function isOverrideAllowed(url: string): boolean {
|
|
51
|
+
const presets = SHELL_CONFIG.envSwitcher?.envs ?? [];
|
|
52
|
+
if (presets.some((e) => e.url === url)) return true; // presets are implicitly trusted
|
|
53
|
+
return isUrlAllowed(url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The URL a server-loader shell should load at boot/reload: a valid, ALLOWLISTED persisted override when
|
|
57
|
+
* the env-switcher is enabled, else the build-time `SHELL_CONFIG.serverUrl`. Re-validates the stored
|
|
58
|
+
* override against the same allowlist the switcher uses — the native menu isn't the only writer of the
|
|
59
|
+
* key (any page JS can write it via `kit.storage.set`), so the read side must not trust it blindly. */
|
|
60
|
+
export function effectiveServerUrl(): string {
|
|
61
|
+
if (SHELL_CONFIG.loader !== 'server') return SHELL_CONFIG.serverUrl;
|
|
62
|
+
if (SHELL_CONFIG.envSwitcher?.enabled) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = ApplicationSettings.getString(OVERRIDE_KEY, '');
|
|
65
|
+
if (raw) {
|
|
66
|
+
const url = JSON.parse(raw);
|
|
67
|
+
if (typeof url === 'string' && /^https?:\/\//i.test(url) && isOverrideAllowed(url)) return url;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
/* malformed override — fall back to the build-time serverUrl */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return SHELL_CONFIG.serverUrl;
|
|
74
|
+
}
|
|
@@ -86,6 +86,71 @@ fn rust_port() -> u16 {
|
|
|
86
86
|
std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// ---- Agent-browser-control gate ------------------------------------------------------------------
|
|
90
|
+
// The bridge control channel can eval arbitrary JS in any embedded tab, so it is OPT-IN and OFF by
|
|
91
|
+
// default. It is env-gated at process launch (APPWRAP_BRIDGE_JS), but an app's opt-in typically lives
|
|
92
|
+
// in the FE and isn't known until after launch — a chicken-and-egg. Resolution: a PERSISTENT marker
|
|
93
|
+
// file (survives relaunch, unlike per-launch web storage). The app writes/removes the marker (e.g. via
|
|
94
|
+
// an `app.setAgentBrowserControl` handler) and asks the user to relaunch; on the next launch
|
|
95
|
+
// `resolve_gate()` reads the marker and, if present, populates the bridge env BEFORE `init()` — so the
|
|
96
|
+
// bridge comes up exactly when the user opted in, and stays fully inert otherwise. The marker path is
|
|
97
|
+
// owned by the caller (main.rs, which knows the app identifier) so this module stays app-agnostic.
|
|
98
|
+
|
|
99
|
+
/// Derive the built content bundle path from the running binary (dev + bundled layouts), so callers
|
|
100
|
+
/// don't have to know it. Returns the first candidate that exists.
|
|
101
|
+
fn resolve_content_js() -> Option<String> {
|
|
102
|
+
let exe = std::env::current_exe().ok()?;
|
|
103
|
+
let mut dir = exe.parent();
|
|
104
|
+
while let Some(d) = dir {
|
|
105
|
+
// Dev layout: …/runtime-desktop/… has the built bundle at bridge-shim/dist/content.js.
|
|
106
|
+
let cand = d.join("bridge-shim/dist/content.js");
|
|
107
|
+
if cand.exists() {
|
|
108
|
+
return Some(cand.to_string_lossy().into_owned());
|
|
109
|
+
}
|
|
110
|
+
// Bundled .app: content.js is staged into Contents/Resources.
|
|
111
|
+
let res = d.join("Resources/content.js");
|
|
112
|
+
if res.exists() {
|
|
113
|
+
return Some(res.to_string_lossy().into_owned());
|
|
114
|
+
}
|
|
115
|
+
dir = d.parent();
|
|
116
|
+
}
|
|
117
|
+
None
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Launch-time gate resolution. The opt-in `marker` is the SINGLE source of truth for the
|
|
121
|
+
/// browser-control bridge: it arms iff the marker is present, regardless of any inherited env.
|
|
122
|
+
///
|
|
123
|
+
/// A controlling daemon (mcp-desktop-browser) ALWAYS spawns us with `APPWRAP_BRIDGE_JS` set (it can't
|
|
124
|
+
/// know the FE opt-in), so trusting an inherited env would let a daemon spawn arm the bridge with no
|
|
125
|
+
/// marker — defeating the gate. Therefore: no marker → forcibly CLEAR the bridge env (`init()` then
|
|
126
|
+
/// returns early, fully inert), no matter what we were spawned with. Marker present (opted in) → keep
|
|
127
|
+
/// a daemon-provided env as-is (correct rendezvous ports), or populate it from the resolved content
|
|
128
|
+
/// bundle + defaults when launched standalone. Must run before `init()` and before any embedded tab.
|
|
129
|
+
pub fn resolve_gate(marker: &std::path::Path) {
|
|
130
|
+
if !marker.exists() {
|
|
131
|
+
// Not opted in — the bridge stays inert even if a spawner set the env. The marker, not the
|
|
132
|
+
// inherited env, is authoritative for the browser-control channel.
|
|
133
|
+
std::env::remove_var("APPWRAP_BRIDGE_JS");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if std::env::var("APPWRAP_BRIDGE_JS").ok().filter(|s| !s.is_empty()).is_some() {
|
|
137
|
+
return; // opted in + env already provided (daemon-spawned) — respect its rendezvous ports
|
|
138
|
+
}
|
|
139
|
+
match resolve_content_js() {
|
|
140
|
+
Some(js) => {
|
|
141
|
+
std::env::set_var("APPWRAP_BRIDGE_JS", js);
|
|
142
|
+
if std::env::var("APPWRAP_BRIDGE_RUST_PORT").is_err() {
|
|
143
|
+
std::env::set_var("APPWRAP_BRIDGE_RUST_PORT", "3843");
|
|
144
|
+
}
|
|
145
|
+
if std::env::var("APPWRAP_BRIDGE_PAGE_PORT").is_err() {
|
|
146
|
+
std::env::set_var("APPWRAP_BRIDGE_PAGE_PORT", "3842");
|
|
147
|
+
}
|
|
148
|
+
eprintln!("[bridge-shim] agent browser control ENABLED via opt-in marker");
|
|
149
|
+
}
|
|
150
|
+
None => eprintln!("[bridge-shim] opt-in marker present but content.js not found — bridge stays off"),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
89
154
|
pub fn init(app: AppHandle) {
|
|
90
155
|
let js_path = match std::env::var("APPWRAP_BRIDGE_JS") {
|
|
91
156
|
Ok(p) if !p.is_empty() => p,
|
|
@@ -365,6 +365,25 @@ fn handle(method: &str, params: &Value) -> HandlerResult {
|
|
|
365
365
|
spawn_reaped(cmd)?;
|
|
366
366
|
Ok(json!({ "ok": true }))
|
|
367
367
|
}
|
|
368
|
+
// Persist the agent-browser-control opt-in (the security gate for the bridge control channel).
|
|
369
|
+
// Writes/removes the marker `bridge_mac::resolve_gate` reads at launch; takes effect on the
|
|
370
|
+
// next relaunch (the bridge env is read once at startup), so signal `requiresRelaunch`.
|
|
371
|
+
"app.setAgentBrowserControl" => {
|
|
372
|
+
let enabled = params["enabled"].as_bool().unwrap_or(false);
|
|
373
|
+
#[cfg(target_os = "macos")]
|
|
374
|
+
{
|
|
375
|
+
let path = agent_browser_control_marker();
|
|
376
|
+
if enabled {
|
|
377
|
+
if let Some(parent) = path.parent() {
|
|
378
|
+
std::fs::create_dir_all(parent).map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
379
|
+
}
|
|
380
|
+
std::fs::write(&path, b"1").map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
381
|
+
} else if path.exists() {
|
|
382
|
+
std::fs::remove_file(&path).map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
Ok(json!({ "ok": true, "enabled": enabled, "requiresRelaunch": true }))
|
|
386
|
+
}
|
|
368
387
|
"network.status" => Ok(json!({ "online": true, "type": "wifi" })),
|
|
369
388
|
"ui.safeArea" => Ok(json!({ "top": 0, "right": 0, "bottom": 0, "left": 0 })),
|
|
370
389
|
"ui.alert" | "ui.confirm" => {
|
|
@@ -533,6 +552,17 @@ fn browser_window_handle(method: &str, params: &Value) -> HandlerResult {
|
|
|
533
552
|
}
|
|
534
553
|
}
|
|
535
554
|
|
|
555
|
+
/// Opt-in marker for the agent-browser-control bridge gate: ~/Library/Application
|
|
556
|
+
/// Support/<identifier>/agent-browser-control.enabled. Presence = enabled (read by
|
|
557
|
+
/// bridge_mac::resolve_gate at launch; written/removed by `app.setAgentBrowserControl`).
|
|
558
|
+
fn agent_browser_control_marker() -> std::path::PathBuf {
|
|
559
|
+
let home = std::env::var("HOME").unwrap_or_default();
|
|
560
|
+
std::path::Path::new(&home)
|
|
561
|
+
.join("Library/Application Support")
|
|
562
|
+
.join(&shell().identifier)
|
|
563
|
+
.join("agent-browser-control.enabled")
|
|
564
|
+
}
|
|
565
|
+
|
|
536
566
|
/// Persisted-store file: ~/Library/Application Support/<identifier>/storage.json (macOS app-data dir).
|
|
537
567
|
fn storage_path() -> std::path::PathBuf {
|
|
538
568
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
@@ -965,6 +995,12 @@ fn main() {
|
|
|
965
995
|
});
|
|
966
996
|
}
|
|
967
997
|
// SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
|
|
998
|
+
// resolve_gate() first populates the bridge env from the persisted opt-in marker (an app's
|
|
999
|
+
// "agent browser control" toggle writes it via `app.setAgentBrowserControl`), so the
|
|
1000
|
+
// channel comes up only when the user opted in; no marker → env stays unset → init()
|
|
1001
|
+
// returns early (fully inert).
|
|
1002
|
+
#[cfg(target_os = "macos")]
|
|
1003
|
+
bridge_mac::resolve_gate(&agent_browser_control_marker());
|
|
968
1004
|
#[cfg(target_os = "macos")]
|
|
969
1005
|
bridge_mac::init(app.handle().clone());
|
|
970
1006
|
// Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
|
package/src/cli.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
import { execFileSync, spawn } from 'child_process';
|
|
12
12
|
import { createHash } from 'crypto';
|
|
13
13
|
import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
14
|
+
import { builtinModules } from 'module';
|
|
14
15
|
import { networkInterfaces, tmpdir } from 'os';
|
|
15
|
-
import { delimiter as pathDelimiter, dirname, join, resolve } from 'path';
|
|
16
|
+
import { delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
|
|
16
17
|
import { pathToFileURL } from 'url';
|
|
17
18
|
// PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
|
|
18
19
|
// the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
|
|
@@ -247,6 +248,127 @@ function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
|
|
|
247
248
|
);
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/** Make `cfg.plugins` LIVE for the MOBILE (NativeScript) shell — the in-process analog of the desktop
|
|
252
|
+
* `regeneratePlugins`. There is no separate host on mobile (one WebView, the NS runtime IS the trusted
|
|
253
|
+
* host), so a plugin's `handlers` register directly on the bridge at boot. For each configured plugin:
|
|
254
|
+
* resolve the entry (path or npm), bun-build it to a single ESM bundle under
|
|
255
|
+
* `native/app/shell/plugins/<id>.js` (definePlugin inlined; @nativescript/core externalized), emit a
|
|
256
|
+
* typed `.d.ts` shim so the barrel type-checks, and generate `app/shell/plugins.generated.ts` that
|
|
257
|
+
* imports + registers each. No plugins → rewrite the barrel to the committed no-op default and drop the
|
|
258
|
+
* plugins dir, so a non-plugin build compiles NO plugin glue (parity with the module barrels).
|
|
259
|
+
*
|
|
260
|
+
* SKELETON scope: only `handlers` are consumed on mobile. `attachTo`/`onWindow`/`WindowCtx` are
|
|
261
|
+
* desktop-only; a plugin declaring them still builds here (those fields are ignored).
|
|
262
|
+
*
|
|
263
|
+
* CROSS-LANE SKIP: a plugin that fails to resolve OR that pulls in a Node.js CORE module
|
|
264
|
+
* (`net`/`fs`/`child_process`/…, unavailable in the NativeScript runtime) is skipped with a warning,
|
|
265
|
+
* so a shared `plugins:[]` list genuinely builds on both lanes. The build alone is NOT enough to
|
|
266
|
+
* decide this: `bun build --target=node` treats node builtins as valid passthrough externals, so a
|
|
267
|
+
* desktop-only plugin importing `net` builds successfully and the incompatible `import "net"` lands
|
|
268
|
+
* in the emitted bundle — which then breaks the NS webpack build. We therefore SCAN the plugin's
|
|
269
|
+
* authored SOURCE for node-builtin imports/requires and skip on a hit (deterministic across import
|
|
270
|
+
* styles). We scan the source (not the emitted bundle) on purpose: a raw-text/bundle scan false-
|
|
271
|
+
* positives on (a) builtin specifiers that appear inside STRING LITERALS in a handler body, and (b)
|
|
272
|
+
* bun's own `import { createRequire } from "node:module"` interop shim, which it injects into the
|
|
273
|
+
* bundle for ANY CJS plugin — even one that only `require()`s the documented external
|
|
274
|
+
* `@nativescript/core`. The source reflects the author's real imports; bun's shims do not.
|
|
275
|
+
* KNOWN LIMITATION: only the ENTRY's own imports are scanned — a builtin pulled in TRANSITIVELY via a
|
|
276
|
+
* dependency slips through and fails the NS webpack build later (loud, not silent). Acceptable at this
|
|
277
|
+
* stage: it degrades in the right direction (a rare, self-announcing build error) vs the silent-drop a
|
|
278
|
+
* bundle scan caused, and such a plugin is desktop-only by construction. Revisit with a resolve-graph
|
|
279
|
+
* walk if transitive desktop deps become common. */
|
|
280
|
+
/** Node.js core module names (bare + `node:` forms) — anything here is unavailable in the NS runtime. */
|
|
281
|
+
const NODE_BUILTIN_SET = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
|
|
282
|
+
|
|
283
|
+
/** The distinct Node.js core modules a plugin's SOURCE actually imports/requires. Uses
|
|
284
|
+
* `Bun.Transpiler.scanImports`, which returns only real import/require STATEMENTS (never a specifier
|
|
285
|
+
* that merely appears inside a string literal), so a handler body containing `"x from 'fs'"` is not
|
|
286
|
+
* flagged. Subpaths like `fs/promises` and the `node:` prefix are normalised to the base module
|
|
287
|
+
* before the membership test. `loader` matches the source dialect (ts/tsx/js/jsx). */
|
|
288
|
+
function nodeBuiltinImports(src: string, loader: 'ts' | 'tsx' | 'js' | 'jsx'): string[] {
|
|
289
|
+
const hits = new Set<string>();
|
|
290
|
+
for (const { path } of new Bun.Transpiler({ loader }).scanImports(src)) {
|
|
291
|
+
const base = path.replace(/^node:/, '').split('/')[0];
|
|
292
|
+
if (NODE_BUILTIN_SET.has(path) || NODE_BUILTIN_SET.has(base)) hits.add(path);
|
|
293
|
+
}
|
|
294
|
+
return [...hits];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Map a plugin entrypoint's extension to the Bun.Transpiler loader for its source dialect. */
|
|
298
|
+
function loaderForEntry(entrypoint: string): 'ts' | 'tsx' | 'js' | 'jsx' {
|
|
299
|
+
const ext = extname(entrypoint).toLowerCase();
|
|
300
|
+
if (ext === '.tsx') return 'tsx';
|
|
301
|
+
if (ext === '.jsx') return 'jsx';
|
|
302
|
+
if (ext === '.ts' || ext === '.mts' || ext === '.cts') return 'ts';
|
|
303
|
+
return 'js';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function regenerateMobilePlugins(cwd: string, cfg: AppwrapConfig, outDir: string): void {
|
|
307
|
+
const shellDir = join(outDir, 'app/shell');
|
|
308
|
+
const pluginsDir = join(shellDir, 'plugins');
|
|
309
|
+
// Start clean: stale bundles from a previous config must not linger in the disposable native/.
|
|
310
|
+
rmSync(pluginsDir, { recursive: true, force: true });
|
|
311
|
+
|
|
312
|
+
const barrelPath = join(shellDir, 'plugins.generated.ts');
|
|
313
|
+
const header = `/** Generated by \`appwrap\` from the appwrap config \`plugins\`. Do not edit. */\n`;
|
|
314
|
+
const writeNoop = () => writeFileSync(barrelPath, `${header}export function registerPlugins(): void {\n}\n`);
|
|
315
|
+
|
|
316
|
+
const entries = cfg.plugins ?? [];
|
|
317
|
+
if (entries.length === 0) { writeNoop(); return; }
|
|
318
|
+
|
|
319
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
320
|
+
const bun = process.execPath; // the CLI runs under bun → the exact runtime to build with
|
|
321
|
+
const imports: string[] = [];
|
|
322
|
+
const calls: string[] = [];
|
|
323
|
+
let idx = 0;
|
|
324
|
+
for (const raw of entries) {
|
|
325
|
+
const name = typeof raw === 'string' ? raw : raw.name;
|
|
326
|
+
// Resolve: an existing path (relative to the app root) wins; else treat as an npm package name.
|
|
327
|
+
let entrypoint = resolve(cwd, name);
|
|
328
|
+
if (!existsSync(entrypoint)) {
|
|
329
|
+
try {
|
|
330
|
+
entrypoint = (Bun as unknown as { resolveSync(id: string, parent: string): string }).resolveSync(name, cwd);
|
|
331
|
+
} catch {
|
|
332
|
+
console.warn(`⚠ mobile plugin "${name}" not found (no such path, not resolvable as an npm package) — skipping.`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
337
|
+
const outfile = join(pluginsDir, `${bundleId}.js`);
|
|
338
|
+
// Cross-lane guard: a desktop-only plugin (importing net/fs/child_process/…) BUILDS fine here — node
|
|
339
|
+
// builtins pass through as valid externals — so scan the plugin's SOURCE for real builtin imports and
|
|
340
|
+
// skip on a hit, BEFORE building (no point bundling a plugin we'll drop).
|
|
341
|
+
const nodeBuiltins = nodeBuiltinImports(readFileSync(entrypoint, 'utf8'), loaderForEntry(entrypoint));
|
|
342
|
+
if (nodeBuiltins.length) {
|
|
343
|
+
console.warn(`⚠ mobile plugin "${name}" imports Node.js core module(s) [${nodeBuiltins.join(', ')}] unavailable in the NativeScript runtime (desktop-only plugin on the mobile lane) — skipping.`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
// ESM single-file so NS webpack bundles it; @nativescript/core stays external (provided by the shell).
|
|
348
|
+
execFileSync(bun, ['build', entrypoint, '--format=esm', '--target=node', '--external=@nativescript/core', '--outfile', outfile], { stdio: 'pipe' });
|
|
349
|
+
} catch (e: unknown) {
|
|
350
|
+
console.warn(`⚠ mobile plugin bun-build failed (${name}): ${e instanceof Error ? e.message : String(e)} — skipping.`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// Typed shim so the generated barrel resolves the JS bundle's default export under tsc.
|
|
354
|
+
writeFileSync(
|
|
355
|
+
join(pluginsDir, `${bundleId}.d.ts`),
|
|
356
|
+
`import type { MobilePluginDef } from '../plugin-host';\ndeclare const plugin: MobilePluginDef;\nexport default plugin;\n`
|
|
357
|
+
);
|
|
358
|
+
const ident = `plugin_${idx++}`;
|
|
359
|
+
imports.push(`import ${ident} from './plugins/${bundleId}.js';`);
|
|
360
|
+
calls.push(` registerPluginHandlers(${ident});`);
|
|
361
|
+
console.log(` plugin ← ${name} (mobile: handlers)`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (imports.length === 0) { writeNoop(); return; }
|
|
365
|
+
writeFileSync(
|
|
366
|
+
barrelPath,
|
|
367
|
+
`${header}import { registerPluginHandlers } from './plugin-host';\n${imports.join('\n')}\n\n` +
|
|
368
|
+
`export function registerPlugins(): void {\n${calls.join('\n')}\n}\n`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
250
372
|
/** Stamp the active modules' gradle dependencies into Android app.gradle. Idempotent marker block. */
|
|
251
373
|
function stampAndroidGradleDeps(outDir: string, deps: string[]): void {
|
|
252
374
|
const appGradle = join(outDir, 'App_Resources/Android/app.gradle');
|
|
@@ -482,6 +604,14 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
|
|
|
482
604
|
}
|
|
483
605
|
|
|
484
606
|
function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
607
|
+
// Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
|
|
608
|
+
// (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
|
|
609
|
+
const es = cfg.envSwitcher;
|
|
610
|
+
const envSwitcher = {
|
|
611
|
+
enabled: !!es && es.enabled !== false,
|
|
612
|
+
envs: (es?.envs ?? []).map((e) => ({ label: String(e.label), url: String(e.url) })),
|
|
613
|
+
allowPattern: es?.allowPattern ?? '',
|
|
614
|
+
};
|
|
485
615
|
const content = `/**
|
|
486
616
|
* Shell config — stamped by \`appwrap init\`/\`sync\` from the appwrap config. Do not edit.
|
|
487
617
|
*/
|
|
@@ -508,6 +638,7 @@ export const SHELL_CONFIG = {
|
|
|
508
638
|
pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
|
|
509
639
|
pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
|
|
510
640
|
iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
|
|
641
|
+
envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
|
|
511
642
|
};
|
|
512
643
|
`;
|
|
513
644
|
writeFileSync(join(outDir, 'app/shell/config.ts'), content);
|
|
@@ -1712,6 +1843,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
1712
1843
|
stampAndroidGradleDeps(outDir, req.androidGradleDeps);
|
|
1713
1844
|
stampKotlin(outDir, req.androidKotlin);
|
|
1714
1845
|
generateModuleArtifacts(outDir, req);
|
|
1846
|
+
regenerateMobilePlugins(cwd, cfg, outDir); // config-gated TS plugins → bridge handlers (in-process)
|
|
1715
1847
|
copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
|
|
1716
1848
|
substituteModuleTokens(outDir, req); // stamp __APP_GROUP__ etc. into the copied extension source
|
|
1717
1849
|
stampLaunchScreen(outDir, cfg);
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,29 @@
|
|
|
17
17
|
* JSON still supported as a fallback). See `loadConfig` in cli.ts.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
/** A single named environment shown in the switcher menu. */
|
|
21
|
+
export interface EnvSwitcherEnv {
|
|
22
|
+
/** Human label shown in the menu + banner (e.g. 'Prod', 'Lab', 'PR #123'). */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Absolute origin the WebView loads for this env (e.g. 'https://lab.example.com'). */
|
|
25
|
+
url: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runtime env-switcher config (see `AppwrapConfig.envSwitcher`). */
|
|
29
|
+
export interface EnvSwitcherConfig {
|
|
30
|
+
/** Kill-switch. Omitted → ON when the block is present. A prod config fork can set `false` to
|
|
31
|
+
* HARD-DISABLE the whole feature (menu action + banner + boot override all inert) even if `envs` /
|
|
32
|
+
* `allowPattern` are declared. Absent `envSwitcher` block entirely → also off (default for any app). */
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
/** Presets shown in the switch menu; always implicitly trusted (bypass `allowPattern`). */
|
|
35
|
+
envs?: EnvSwitcherEnv[];
|
|
36
|
+
/** Anchored regex (`^…$`) gating the free-form "Other" URL entry. DEFAULT-DENY: absent/invalid →
|
|
37
|
+
* "Other" is disabled (presets only). Compiled with try/catch; a throwing pattern → default-deny. */
|
|
38
|
+
allowPattern?: string;
|
|
39
|
+
/** Deeplink auto-switch (Phase 2 — not yet implemented). Opt-in. */
|
|
40
|
+
deeplink?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
export interface AppwrapConfig {
|
|
21
44
|
id: string;
|
|
22
45
|
name: string;
|
|
@@ -168,6 +191,13 @@ export interface AppwrapConfig {
|
|
|
168
191
|
loader?: 'app' | 'file' | 'server';
|
|
169
192
|
/** Live URL loaded when loader === 'server'. Set via config or `appwrap dev --url <url>`. */
|
|
170
193
|
serverUrl?: string;
|
|
194
|
+
/** Runtime env-switcher (loader:'server' apps). Lets a build re-point the WebView between declared
|
|
195
|
+
* environments (prod / lab / a preview URL) at runtime — via the native dev-menu "Switch Environment"
|
|
196
|
+
* action + a bottom env-indicator banner — surviving a cold start, with NO separate native build.
|
|
197
|
+
* The chosen URL persists in native storage (`kit:serverUrlOverride`) and is honored by the shell's
|
|
198
|
+
* boot loader. Runs in ALL build types when configured (gate = `allowPattern` + `enabled`, NOT debug —
|
|
199
|
+
* distinct from the debug-only dev-server cert trust). Absent block → the whole feature is inert. */
|
|
200
|
+
envSwitcher?: EnvSwitcherConfig;
|
|
171
201
|
/** Absolute backend origin for an offline (loader:'app') PWA whose API/WebSocket calls were
|
|
172
202
|
* originally same-origin (e.g. "https://api.example.com"). Injected to the page as
|
|
173
203
|
* `window.__APPWRAP_BACKEND_ORIGIN__`; a same-origin PWA reads it to make its calls absolute.
|
|
@@ -337,7 +367,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
|
|
|
337
367
|
export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
|
338
368
|
'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
|
|
339
369
|
'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
|
|
340
|
-
'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
|
|
370
|
+
'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
|
|
341
371
|
'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
|
|
342
372
|
'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
|
|
343
373
|
'usesNonExemptEncryption', 'vendorPaths', 'version',
|