@livx.cc/appwrap 0.48.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/app/main-page.ts +12 -3
- package/runtime/app/shell/bridge.ts +6 -0
- package/runtime/app/shell/capabilities.manifest.ts +15 -33
- package/runtime/app/shell/config.ts +5 -0
- package/runtime/app/shell/custom-webview.android.ts +19 -6
- package/runtime/app/shell/custom-webview.ios.ts +106 -0
- package/runtime/app/shell/devmenu.ts +9 -1
- package/runtime/app/shell/env-banner.ts +236 -0
- package/runtime/app/shell/env-switcher.ts +163 -0
- package/runtime/app/shell/handlers-android.ts +38 -0
- package/runtime/app/shell/handlers-background.ts +3 -2
- package/runtime/app/shell/handlers-parity.ts +55 -1
- package/runtime/app/shell/plugin-host.ts +51 -0
- package/runtime/app/shell/plugins.generated.ts +8 -0
- package/runtime/app/shell/server-url.ts +74 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +65 -0
- package/runtime-desktop/src-tauri/src/main.rs +36 -0
- package/src/cli.ts +133 -1
- package/src/config.ts +31 -1
|
@@ -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(
|
|
104
|
+
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(effectiveServerUrl())));
|
|
104
105
|
} else {
|
|
105
106
|
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`)));
|
|
106
107
|
}
|
|
107
108
|
} else {
|
|
108
109
|
if (retry(!webView.android)) return;
|
|
109
110
|
webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
|
|
110
|
-
?
|
|
111
|
+
? effectiveServerUrl()
|
|
111
112
|
: `https://appwrap.local/${SHELL_CONFIG.entry}`;
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -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
|