@livx.cc/appwrap 0.48.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
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
+ // ── Deep-link entry (`<scheme>://env?url=<encoded-url>`) ──────────────────────────────────────────
147
+ // A PR-preview link can be SHARED and auto-open the app on that env. Same security model as "Other":
148
+ // the decoded target must pass `isUrlAllowed` (anchored allowPattern, default-deny) and a confirm
149
+ // prompt, then applies via the EXACT SAME `applySwitch` path as a manual switch. events.ts owns the
150
+ // WebView-ready timing (warm: now; cold launch: buffered, replayed after the PWA handshake).
151
+
152
+ export type EnvDeepLinkDecision =
153
+ | { action: 'switch'; url: string }
154
+ | { action: 'rejected'; url: string }
155
+ | { action: 'ignored' };
156
+
157
+ /** Decode the `url` query param from a `<scheme>://env?url=<encoded>` link. Null if absent/undecodable. */
158
+ function parseEnvTargetUrl(link: string): string | null {
159
+ const q = String(link || '').indexOf('?');
160
+ if (q < 0) return null;
161
+ for (const part of link.slice(q + 1).split('&')) {
162
+ const eq = part.indexOf('=');
163
+ if ((eq < 0 ? part : part.slice(0, eq)) !== 'url') continue;
164
+ try {
165
+ const decoded = decodeURIComponent((eq < 0 ? '' : part.slice(eq + 1)).replace(/\+/g, '%20')).trim();
166
+ return decoded || null;
167
+ } catch {
168
+ return null; // malformed percent-encoding
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /** Structurally an env-switch deep link (`<scheme>://env…`) AND the switcher is enabled — the signal
175
+ * events.ts uses to CONSUME the link (never forward an env link to the PWA). Inert (false) when the
176
+ * feature is disabled/absent, so such a link simply falls through to the normal PWA deep-link path. */
177
+ export function isEnvDeepLink(link: string): boolean {
178
+ return isEnvSwitcherEnabled() && hostOf(link) === 'env';
179
+ }
180
+
181
+ /**
182
+ * PURE decision for an inbound `<scheme>://env?url=…` deep link — no dialogs, no side effects (unit-
183
+ * testable, mirrors env-switcher-security.test.ts). SECURITY: the decoded target MUST pass the SAME
184
+ * `isUrlAllowed` gate as the "Other" custom URL (anchored allowPattern, default-deny). Disabled feature
185
+ * / non-env link / malformed (no decodable `url`) → `ignored` (silently dropped); target present but not
186
+ * allowlisted → `rejected` (NEVER switch); allowlisted → `switch`.
187
+ */
188
+ export function decideEnvDeepLink(link: string): EnvDeepLinkDecision {
189
+ if (!isEnvSwitcherEnabled() || hostOf(link) !== 'env') return { action: 'ignored' };
190
+ const target = parseEnvTargetUrl(link);
191
+ if (!target) return { action: 'ignored' };
192
+ if (!isUrlAllowed(target)) return { action: 'rejected', url: target };
193
+ return { action: 'switch', url: target };
194
+ }
195
+
196
+ /**
197
+ * Handle an env-switch deep link end-to-end: confirm + apply via the SAME `applySwitch` path as a manual
198
+ * switch (persist override → reload the WebView → refresh the banner, incl. the host-equals-default →
199
+ * RESET semantics). A disallowed target shows a brief alert and NEVER switches; a malformed/disabled link
200
+ * is ignored silently. Callers gate the WebView-ready timing (warm now / cold at handshake).
201
+ */
202
+ export async function handleEnvDeepLink(link: string): Promise<void> {
203
+ const decision = decideEnvDeepLink(link);
204
+ if (decision.action === 'switch') {
205
+ const preset = (SHELL_CONFIG.envSwitcher?.envs ?? []).find((e) => e.url === decision.url);
206
+ await applySwitch(decision.url, preset ? preset.label : 'Shared link');
207
+ } else if (decision.action === 'rejected') {
208
+ await Dialogs.alert({ title: 'Not allowed', message: 'That environment link is not allowed for this app.', okButtonText: 'OK' });
209
+ }
210
+ }
211
+
212
+ /** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL. */
213
+ async function promptOther(): Promise<void> {
214
+ const res = await Dialogs.prompt({
215
+ title: 'Custom environment',
216
+ message: 'Enter an allowed URL (https://…).',
217
+ okButtonText: 'Next',
218
+ cancelButtonText: 'Cancel',
219
+ defaultText: 'https://',
220
+ inputType: 'text',
221
+ });
222
+ if (!res?.result || !res.text) return;
223
+ const url = res.text.trim();
224
+ if (!isUrlAllowed(url)) {
225
+ await Dialogs.alert({ title: 'Not allowed', message: 'That URL is not in the allowed pattern for this app.', okButtonText: 'OK' });
226
+ return;
227
+ }
228
+ await applySwitch(url, 'Custom');
229
+ }
@@ -2,8 +2,13 @@ import { Application, Connectivity, isAndroid } from '@nativescript/core';
2
2
  import type { AndroidActivityNewIntentEventData, OrientationChangedEventData } from '@nativescript/core';
3
3
  import { bridge } from './bridge';
4
4
  import { connectivityStatus } from './handlers-extended';
5
+ import { isEnvDeepLink, handleEnvDeepLink } from './env-switcher';
5
6
 
6
7
  let pendingDeepLink: string | null = null;
8
+ // A cold-launch env-switch deep link (`<scheme>://env?url=…`). Buffered like `pendingDeepLink` until the
9
+ // PWA handshake, so the confirm dialog + WebView reload run when the shell is actually ready (not during
10
+ // iOS didFinishLaunching / the Android launch-intent read, when no WebView exists yet).
11
+ let pendingEnvDeepLink: string | null = null;
7
12
  let pendingPushTap: { data: Record<string, string> } | null = null;
8
13
  let pendingShortcut: string | null = null;
9
14
  // True only once the PWA's JS has handshaked — i.e. the WebView is actually
@@ -25,6 +30,14 @@ export function onDeepLink(url: string): void {
25
30
  // Give a native consumer (OAuth callback) first refusal — a matched OAuth redirect is internal
26
31
  // plumbing, not an app deep link, so it's swallowed here and never forwarded to the PWA.
27
32
  if (deepLinkInterceptor?.(url)) return;
33
+ // Env-switch deep link (config-gated): a shared PR-preview link that re-points the shell. CONSUMED
34
+ // here — never forwarded to the PWA (it's a shell command, not an app route). The allowlist + confirm
35
+ // gate lives in handleEnvDeepLink; disabled/absent switcher → isEnvDeepLink is false → normal path.
36
+ if (isEnvDeepLink(url)) {
37
+ if (pwaReady) void handleEnvDeepLink(url);
38
+ else pendingEnvDeepLink = url; // cold launch — replay after the handshake, when the WebView is ready
39
+ return;
40
+ }
28
41
  if (pwaReady) bridge.emit('deeplink.open', { url });
29
42
  else pendingDeepLink = url; // buffer until the PWA handshakes — delivered IN the handshake response
30
43
  }
@@ -77,6 +90,12 @@ export function onPwaHandshake(): void {
77
90
  pendingShortcut = null;
78
91
  setTimeout(() => bridge.emit('app.shortcut', { id }), 500);
79
92
  }
93
+ if (pendingEnvDeepLink) {
94
+ const link = pendingEnvDeepLink;
95
+ pendingEnvDeepLink = null;
96
+ // Small beat so the just-handshaked WebView is fully attached before the confirm/reload.
97
+ setTimeout(() => void handleEnvDeepLink(link), 500);
98
+ }
80
99
  }
81
100
 
82
101
  /** Wire lifecycle + connectivity event forwarding (the PWA subscribes to these). */
@@ -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