@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.48.2",
3
+ "version": "0.49.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -1,5 +1,6 @@
1
1
  import { Application, AndroidApplication, EventData, Page, isAndroid, isIOS, knownFolders, path } from '@nativescript/core';
2
2
  import { bridge } from './shell/bridge';
3
+ import { effectiveServerUrl } from './shell/server-url';
3
4
  import { registerHandlers } from './shell/handlers';
4
5
  import { registerExtendedHandlers } from './shell/handlers-extended';
5
6
  import { registerParityHandlers } from './shell/handlers-parity';
@@ -11,9 +12,11 @@ import { registerFsHandlers } from './shell/handlers-fs';
11
12
  import { registerPushHandlers } from './shell/handlers-push';
12
13
  import { registerAndroidHandlers } from './shell/handlers-android';
13
14
  import { registerOptionalHandlers } from './shell/optional-handlers.generated';
15
+ import { registerPlugins } from './shell/plugins.generated';
14
16
  import './shell/fcm-bootstrap.generated'; // side-effect: registers the FCM service when push is wired
15
17
  import { startEventForwarding } from './shell/events';
16
18
  import { startDevMenu } from './shell/devmenu';
19
+ import { showEnvBannerIfActive } from './shell/env-banner';
17
20
  import { SHELL_CONFIG } from './shell/config';
18
21
  import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
19
22
  import { CustomWebView } from './shell/custom-webview';
@@ -111,6 +114,9 @@ export function onPageLoaded(args: EventData): void {
111
114
  if (isAndroid) registerAndroidHandlers();
112
115
  // Opt-in modules that own their own handler file (health, …) — generated to only the active set.
113
116
  registerOptionalHandlers();
117
+ // Config-gated TS plugins (appwrap.config `plugins`) — registers each plugin's bridge handlers.
118
+ // No-op default when no plugins are configured (generated barrel is empty → no plugin glue compiled).
119
+ registerPlugins();
114
120
  // Shake-to-open developer menu (App Info / Reload). On by default, incl. prod.
115
121
  if (SHELL_CONFIG.devMenu) startDevMenu();
116
122
 
@@ -129,6 +135,9 @@ export function onPageLoaded(args: EventData): void {
129
135
  if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
130
136
  startEventForwarding();
131
137
  loadBundle(webView);
138
+ // Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
139
+ // active (env-switcher only). Bottom-safe-area, auto-shrinks to a pill after 3s. No-op otherwise.
140
+ showEnvBannerIfActive();
132
141
 
133
142
  // Halt the WebView render + JS-timer pipeline while backgrounded so a page running a continuous
134
143
  // animation (Android doesn't auto-pause rAF off-screen) stops burning CPU/battery. No-op on iOS.
@@ -172,8 +181,8 @@ function loadBundle(webView: CustomWebView): void {
172
181
  return;
173
182
  }
174
183
  if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
175
- // Live URL (dev HMR / deployed). Bridge (WKUserScript + message handler) still injects.
176
- const url = NSURL.URLWithString(SHELL_CONFIG.serverUrl);
184
+ // Live URL (dev HMR / deployed), or a persisted debug override. Bridge still injects.
185
+ const url = NSURL.URLWithString(effectiveServerUrl());
177
186
  wk.loadRequest(NSURLRequest.requestWithURL(url));
178
187
  } else if (SHELL_CONFIG.loader === 'file') {
179
188
  const entryURL = NSURL.fileURLWithPath(entryPath);
@@ -194,7 +203,7 @@ function loadBundle(webView: CustomWebView): void {
194
203
  return;
195
204
  }
196
205
  webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
197
- ? SHELL_CONFIG.serverUrl
206
+ ? effectiveServerUrl()
198
207
  : SHELL_CONFIG.loader === 'file'
199
208
  ? `file://${entryPath}`
200
209
  : `https://appwrap.local/${SHELL_CONFIG.entry}`;
@@ -26,6 +26,12 @@ export class Bridge {
26
26
  this.handlers.set(method, handler);
27
27
  }
28
28
 
29
+ /** Whether a handler is already registered for `method` (used by the plugin host to refuse a
30
+ * namespaced key that would clobber a core handler). */
31
+ has(method: string): boolean {
32
+ return this.handlers.has(method);
33
+ }
34
+
29
35
  /**
30
36
  * Both platforms push envelopes in: iOS via WKScriptMessageHandler 'appwrap',
31
37
  * Android via the prompt() tunnel intercepted in WebChromeClient.onJsPrompt
@@ -65,4 +65,9 @@ export const SHELL_CONFIG = {
65
65
  * Lifting extra makes the page cover that strip (the keyboard hides the thin bottom row of content).
66
66
  * Default 82. Set 0 for NO extra lift (input sits flush at the reported height; the strip may show). */
67
67
  iosKeyboardExtraLift: 82,
68
+ /** Runtime env-switcher (loader:'server'). `enabled` is the resolved kill-switch (config block
69
+ * present AND not `enabled:false`); when false the menu action, banner, and boot override are all
70
+ * inert. `envs` = declared presets; `allowPattern` = anchored regex gating "Other" (default-deny
71
+ * when ''). Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
72
+ envSwitcher: { enabled: false, envs: [] as { label: string; url: string }[], allowPattern: '' },
68
73
  };
@@ -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
- // `appwrap dev` points at a LAN dev server that almost always uses a self-signed / mkcert TLS
388
- // cert the device's trust store doesn't know — the WebView would otherwise hard-fail with
389
- // ERR_CERT_AUTHORITY_INVALID. Proceed past it ONLY in a debug build AND only when actually in
390
- // server-loader (dev) mode. Production app:// builds never reach this (local assets, no TLS).
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
- if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server') {
397
- console.warn('AppWrap: proceeding past dev-server SSL error (debug+server only):', String(error));
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: ['App Info', 'Reload', 'Toggle Debug'],
131
+ actions,
125
132
  });
126
133
  if (action === 'App Info') await showInfo();
127
134
  else if (action === 'Reload') reloadWebView();
128
135
  else if (action === 'Toggle Debug') toggleWebDebug();
136
+ else if (action === 'Switch Environment') await showEnvSwitcher();
129
137
  } finally {
130
138
  menuOpen = false;
131
139
  }
@@ -0,0 +1,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(SHELL_CONFIG.serverUrl)));
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
- ? SHELL_CONFIG.serverUrl
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',