@livx.cc/appwrap 0.48.1 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -365,6 +365,44 @@ export function registerAndroidHandlers(): void {
365
365
  motionListener = null;
366
366
  });
367
367
 
368
+ // ── heading (SensorManager: rotation vector → compass azimuth) ─────
369
+ // TYPE_ROTATION_VECTOR fuses accelerometer+magnetometer(+gyro) into an orientation; getOrientation
370
+ // gives azimuth (rad, counter-clockwise from north), which we convert to a 0–360 compass heading.
371
+ // UNVERIFIED-ON-DEVICE.
372
+ let headingListener: android.hardware.SensorEventListener | null = null;
373
+
374
+ bridge.register('heading.start', (p: { hz?: number } = {}) => {
375
+ if (headingListener) return; // already streaming
376
+ const hz = Math.max(1, Math.min(60, p.hz || 10));
377
+ const minMs = 1000 / hz;
378
+ const sm = sensorManager();
379
+ const rot = sm.getDefaultSensor(android.hardware.Sensor.TYPE_ROTATION_VECTOR);
380
+ if (!rot) throw err('UNSUPPORTED', 'No rotation-vector sensor (compass) on this device');
381
+ const R = Array.create('float', 9);
382
+ const orientation = Array.create('float', 3);
383
+ let last = 0;
384
+ headingListener = new android.hardware.SensorEventListener({
385
+ onAccuracyChanged() {},
386
+ onSensorChanged(event: android.hardware.SensorEvent) {
387
+ const now = java.lang.System.currentTimeMillis();
388
+ if (now - last < minMs) return; // throttle emit to the requested Hz
389
+ last = now;
390
+ android.hardware.SensorManager.getRotationMatrixFromVector(R, event.values);
391
+ android.hardware.SensorManager.getOrientation(R, orientation);
392
+ // orientation[0] = azimuth in rad (−π..π), CCW from north. Convert to 0–360 compass degrees.
393
+ const deg = ((orientation[0] * 180) / Math.PI + 360) % 360;
394
+ bridge.emit('heading.data', { deg });
395
+ },
396
+ });
397
+ const periodUs = Math.max(5000, Math.round(1_000_000 / hz));
398
+ sm.registerListener(headingListener, rot, periodUs);
399
+ });
400
+
401
+ bridge.register('heading.stop', () => {
402
+ if (headingListener) sensorManager().unregisterListener(headingListener);
403
+ headingListener = null;
404
+ });
405
+
368
406
  // ── contacts (ACTION_PICK + ContactsContract query) ────────────────
369
407
  bridge.register('contacts.pick', async () => {
370
408
  if (!(await requestPermissions(['android.permission.READ_CONTACTS']))) {
@@ -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
  }
@@ -24,7 +24,7 @@ function readContact(contact: CNContact): { name: string; phones: string[]; emai
24
24
  // shared module can instantiate on Android — NSObject/CL*/CN*/UIImagePicker* are iOS globals, and a
25
25
  // top-level `extends NSObject` would evaluate at ES-module load and crash the Android shell.
26
26
  // any: module-level holders for runtime-built ObjC subclasses; each class BODY stays fully typed.
27
- let GeoWatchDelegate: any, ContactPickerDelegate: any, CameraCaptureDelegate: any;
27
+ let GeoWatchDelegate: any, HeadingDelegate: any, ContactPickerDelegate: any, CameraCaptureDelegate: any;
28
28
  function ensureIosDelegates(): void {
29
29
  if (!isIOS || GeoWatchDelegate) return;
30
30
 
@@ -58,6 +58,30 @@ function ensureIosDelegates(): void {
58
58
  }
59
59
  GeoWatchDelegate = GeoWatchDelegateImpl;
60
60
 
61
+ // CLLocationManager delegate for the compass heading stream (heading.watch). Emits a 0–360
62
+ // compass heading; prefers trueHeading (needs location auth) and falls back to magneticHeading.
63
+ // UNVERIFIED-ON-DEVICE.
64
+ @NativeClass()
65
+ class HeadingDelegateImpl extends NSObject implements CLLocationManagerDelegate {
66
+ static ObjCProtocols = [CLLocationManagerDelegate];
67
+ static new(): HeadingDelegateImpl {
68
+ return <HeadingDelegateImpl>super.new();
69
+ }
70
+ locationManagerDidUpdateHeading(_m: CLLocationManager, heading: CLHeading): void {
71
+ // trueHeading is negative when unavailable (no location auth / no recent fix) — magneticHeading
72
+ // is always valid once the magnetometer is calibrated.
73
+ const deg = heading.trueHeading >= 0 ? heading.trueHeading : heading.magneticHeading;
74
+ bridge.emit('heading.data', {
75
+ deg: ((deg % 360) + 360) % 360,
76
+ accuracy: heading.headingAccuracy >= 0 ? heading.headingAccuracy : undefined,
77
+ });
78
+ }
79
+ locationManagerDidFailWithError(_m: CLLocationManager, error: NSError): void {
80
+ console.warn('AppWrap: heading.watch error', error.localizedDescription);
81
+ }
82
+ }
83
+ HeadingDelegate = HeadingDelegateImpl;
84
+
61
85
  // CNContactPickerViewController delegate. The picker resolution callbacks are set as instance fields
62
86
  // after construction (replacing the old closure-captured `resolve`).
63
87
  @NativeClass()
@@ -220,6 +244,36 @@ export function registerParityHandlers(): void {
220
244
  motionManager = null;
221
245
  });
222
246
 
247
+ // ── heading (CLLocationManager compass) ────────────────────────────
248
+ // Compass heading via CLLocationManager.startUpdatingHeading (delegate-pushed, not polled — the
249
+ // heading callback is reliable, unlike CoreMotion's block). UNVERIFIED-ON-DEVICE.
250
+ let headingManager: CLLocationManager | null = null;
251
+ let headingDelegate: any = null;
252
+
253
+ bridge.register('heading.start', () => {
254
+ if (!isIOS) throw iosOnly();
255
+ if (headingManager) return; // already streaming
256
+ Utils.dispatchToMainThread(() => {
257
+ const mm = CLLocationManager.new();
258
+ if (!CLLocationManager.headingAvailable()) throw err('UNSUPPORTED', 'No compass on this device');
259
+ headingManager = mm;
260
+ headingDelegate = HeadingDelegate.new();
261
+ mm.delegate = headingDelegate;
262
+ mm.headingFilter = 1; // emit on ≥1° change
263
+ // trueHeading requires location auth; request it so we can report true (not just magnetic) north.
264
+ if (mm.authorizationStatus === CLAuthorizationStatus.kCLAuthorizationStatusNotDetermined) {
265
+ mm.requestWhenInUseAuthorization();
266
+ }
267
+ mm.startUpdatingHeading();
268
+ });
269
+ });
270
+
271
+ bridge.register('heading.stop', () => {
272
+ headingManager?.stopUpdatingHeading();
273
+ headingManager = null;
274
+ headingDelegate = null;
275
+ });
276
+
223
277
  // ── contacts (CNContactPickerViewController — no permission needed) ──
224
278
  bridge.register('contacts.pick', () => {
225
279
  if (!isIOS) throw iosOnly();
@@ -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