@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.
- package/package.json +1 -1
- package/runtime/app/main-page.ts +12 -3
- package/runtime/app/shell/bridge.ts +6 -0
- package/runtime/app/shell/config.ts +5 -0
- package/runtime/app/shell/custom-webview.android.ts +16 -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 +287 -0
- package/runtime/app/shell/env-switcher.ts +229 -0
- package/runtime/app/shell/events.ts +19 -0
- package/runtime/app/shell/handlers-background.ts +3 -2
- package/runtime/app/shell/plugin-host.ts +51 -0
- package/runtime/app/shell/plugins.generated.ts +8 -0
- package/runtime/app/shell/server-url.ts +74 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +65 -0
- package/runtime-desktop/src-tauri/src/main.rs +36 -0
- package/src/cli.ts +133 -1
- package/src/config.ts +31 -1
|
@@ -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(
|
|
104
|
+
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(effectiveServerUrl())));
|
|
104
105
|
} else {
|
|
105
106
|
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`)));
|
|
106
107
|
}
|
|
107
108
|
} else {
|
|
108
109
|
if (retry(!webView.android)) return;
|
|
109
110
|
webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
|
|
110
|
-
?
|
|
111
|
+
? effectiveServerUrl()
|
|
111
112
|
: `https://appwrap.local/${SHELL_CONFIG.entry}`;
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile plugin host — the in-process analog of the desktop plugin host (`plugin/host.ts`).
|
|
3
|
+
*
|
|
4
|
+
* On desktop a plugin's `WindowCtx` ops marshal over a Unix socket to the Rust shell; that host does
|
|
5
|
+
* NOT apply on mobile — there is one WKWebView/WebView and the NS runtime IS the trusted host. So the
|
|
6
|
+
* mobile "host" is trivial: at boot it takes each configured plugin's def and registers its `handlers`
|
|
7
|
+
* directly onto the same {@link bridge} the built-in `handlers*.ts` groups use. A PWA then reaches a
|
|
8
|
+
* plugin handler exactly like any native method — `kit.invoke('<plugin>.<method>')`.
|
|
9
|
+
*
|
|
10
|
+
* This walking skeleton consumes ONLY `handlers` (the desktop types call a handlers-only plugin the
|
|
11
|
+
* base case). `attachTo`/`onWindow`/`WindowCtx` are desktop-only concepts (multi-window, out-of-webview
|
|
12
|
+
* control) with no mobile analog yet — a plugin that also declares them still works here; those fields
|
|
13
|
+
* are ignored. Deeper hooks (devmenu action, boot/deeplink) are a documented follow-up.
|
|
14
|
+
*/
|
|
15
|
+
import { bridge } from './bridge';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The structural subset of the shared `PluginDef` (`@livx.cc/appwrap/plugin`) that the mobile host
|
|
19
|
+
* consumes. Kept as a local type so the NS runtime never imports the desktop socket/WindowCtx types.
|
|
20
|
+
* A plugin authored with `definePlugin({ name, handlers })` satisfies this by construction.
|
|
21
|
+
*
|
|
22
|
+
* Handler keys are BARE method names (`hello`, not `greeter.hello`); the host registers each on the
|
|
23
|
+
* bridge NAMESPACED under `plugin.name` → the PWA reaches it as `kit.invoke('<name>.<method>')`.
|
|
24
|
+
*/
|
|
25
|
+
export interface MobilePluginDef {
|
|
26
|
+
name: string;
|
|
27
|
+
handlers?: Record<string, (params: any) => unknown | Promise<unknown>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Register one plugin's bridge handlers, each NAMESPACED under `plugin.name` as `<name>.<method>`.
|
|
31
|
+
* This (a) matches the spec's `kit.invoke('<plugin>.<method>')` call shape and (b) confines a plugin
|
|
32
|
+
* to its own namespace so it cannot shadow a core handler (e.g. `app.reload`). As a belt-and-braces
|
|
33
|
+
* guard for the residual case where `name` itself collides with a core prefix, a namespaced key that
|
|
34
|
+
* is ALREADY registered (core handlers register first, at boot) is refused with a warning rather than
|
|
35
|
+
* clobbering the incumbent. */
|
|
36
|
+
export function registerPluginHandlers(plugin: MobilePluginDef): void {
|
|
37
|
+
const name = plugin?.name;
|
|
38
|
+
const handlers = plugin?.handlers ?? {};
|
|
39
|
+
if (!name) {
|
|
40
|
+
console.warn('⚠ mobile plugin has no `name` — cannot namespace its handlers; skipping.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const method of Object.keys(handlers)) {
|
|
44
|
+
const key = `${name}.${method}`;
|
|
45
|
+
if (bridge.has(key)) {
|
|
46
|
+
console.warn(`⚠ mobile plugin "${name}" handler "${key}" collides with an already-registered method — refusing to override; skipping.`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
bridge.register(key, handlers[method]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated by `appwrap init`/`sync` from the appwrap config `plugins`. Do not edit.
|
|
3
|
+
* Committed DEFAULT registers NO plugins (parity with the module barrels): a build without `plugins`
|
|
4
|
+
* imports no plugin bundle and compiles no plugin glue. The CLI (`regenerateMobilePlugins`) rewrites
|
|
5
|
+
* this to import each configured plugin's bundle and register its handlers.
|
|
6
|
+
*/
|
|
7
|
+
export function registerPlugins(): void {
|
|
8
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ApplicationSettings } from '@nativescript/core';
|
|
2
|
+
import { SHELL_CONFIG } from './config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persisted serverUrl override for `loader:'server'` shells.
|
|
6
|
+
*
|
|
7
|
+
* A web debug tool (e.g. an in-app env switcher) can point the WebView at a different origin at
|
|
8
|
+
* runtime. `window.location` only lasts the session — on a cold start the shell reloads its
|
|
9
|
+
* build-time `serverUrl`, and web `localStorage` is partitioned per-origin so it can't carry the
|
|
10
|
+
* choice across the switch. The fix lives in the shell: persist the chosen URL in native storage
|
|
11
|
+
* (survives restarts + origin changes) and read it HERE at every boot/reload site.
|
|
12
|
+
*
|
|
13
|
+
* The web writes it through the existing `kit.storage` seam — `kit.storage.set('serverUrlOverride',
|
|
14
|
+
* url)` — which stores `JSON.stringify(url)` under the namespaced key `kit:serverUrlOverride`
|
|
15
|
+
* (ApplicationSettings → NSUserDefaults / SharedPreferences). Clearing it (`kit.storage.remove`)
|
|
16
|
+
* reverts to the build-time `serverUrl` on the next load.
|
|
17
|
+
*
|
|
18
|
+
* SECURITY: honored ONLY when the env-switcher is configured + enabled (`SHELL_CONFIG.envSwitcher.enabled`
|
|
19
|
+
* — the config block is present and not `enabled:false`). An app that doesn't declare `envSwitcher`, or a
|
|
20
|
+
* prod fork that sets `enabled:false`, ignores the key entirely, so a compromised page can't persistently
|
|
21
|
+
* redirect the shell. The switcher's own gate (menu + allowPattern + confirm) governs what can be written;
|
|
22
|
+
* this is the read side. Runs in ALL build types when enabled — distinct from the debug-only cert trust.
|
|
23
|
+
*/
|
|
24
|
+
export const OVERRIDE_KEY = 'kit:serverUrlOverride';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Full-string allowlist test for a candidate "Other"/override URL. DEFAULT-DENY: an empty or throwing
|
|
28
|
+
* `allowPattern` rejects everything. Enforces a FULL-STRING match (guards a non-anchored pattern that
|
|
29
|
+
* would otherwise match a substring). Bounded input length guards against pathological backtracking on a
|
|
30
|
+
* hostile pattern. Lives here (not env-switcher.ts) so the boot loader can reuse it without an import
|
|
31
|
+
* cycle — env-switcher.ts imports it back for the "Other" prompt.
|
|
32
|
+
*/
|
|
33
|
+
export function isUrlAllowed(url: string): boolean {
|
|
34
|
+
const pattern = SHELL_CONFIG.envSwitcher?.allowPattern;
|
|
35
|
+
if (!pattern) return false; // default-deny: no allowlist configured
|
|
36
|
+
if (!/^https?:\/\//i.test(url) || url.length > 2048) return false;
|
|
37
|
+
try {
|
|
38
|
+
const re = new RegExp(pattern);
|
|
39
|
+
const m = url.match(re);
|
|
40
|
+
return !!m && m[0] === url; // full-string match, regardless of author anchoring
|
|
41
|
+
} catch {
|
|
42
|
+
return false; // throwing pattern → default-deny
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is a stored/entered override URL trustworthy? A declared preset is implicitly allowed; otherwise it
|
|
47
|
+
* must match `allowPattern`. Used by BOTH the boot loader (re-validate the persisted override) and the
|
|
48
|
+
* switcher, so a compromised page that writes an arbitrary `serverUrlOverride` directly through the
|
|
49
|
+
* `kit.storage` bridge seam can't redirect the shell — the write is ignored at boot unless allowlisted. */
|
|
50
|
+
export function isOverrideAllowed(url: string): boolean {
|
|
51
|
+
const presets = SHELL_CONFIG.envSwitcher?.envs ?? [];
|
|
52
|
+
if (presets.some((e) => e.url === url)) return true; // presets are implicitly trusted
|
|
53
|
+
return isUrlAllowed(url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The URL a server-loader shell should load at boot/reload: a valid, ALLOWLISTED persisted override when
|
|
57
|
+
* the env-switcher is enabled, else the build-time `SHELL_CONFIG.serverUrl`. Re-validates the stored
|
|
58
|
+
* override against the same allowlist the switcher uses — the native menu isn't the only writer of the
|
|
59
|
+
* key (any page JS can write it via `kit.storage.set`), so the read side must not trust it blindly. */
|
|
60
|
+
export function effectiveServerUrl(): string {
|
|
61
|
+
if (SHELL_CONFIG.loader !== 'server') return SHELL_CONFIG.serverUrl;
|
|
62
|
+
if (SHELL_CONFIG.envSwitcher?.enabled) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = ApplicationSettings.getString(OVERRIDE_KEY, '');
|
|
65
|
+
if (raw) {
|
|
66
|
+
const url = JSON.parse(raw);
|
|
67
|
+
if (typeof url === 'string' && /^https?:\/\//i.test(url) && isOverrideAllowed(url)) return url;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
/* malformed override — fall back to the build-time serverUrl */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return SHELL_CONFIG.serverUrl;
|
|
74
|
+
}
|
|
@@ -86,6 +86,71 @@ fn rust_port() -> u16 {
|
|
|
86
86
|
std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// ---- Agent-browser-control gate ------------------------------------------------------------------
|
|
90
|
+
// The bridge control channel can eval arbitrary JS in any embedded tab, so it is OPT-IN and OFF by
|
|
91
|
+
// default. It is env-gated at process launch (APPWRAP_BRIDGE_JS), but an app's opt-in typically lives
|
|
92
|
+
// in the FE and isn't known until after launch — a chicken-and-egg. Resolution: a PERSISTENT marker
|
|
93
|
+
// file (survives relaunch, unlike per-launch web storage). The app writes/removes the marker (e.g. via
|
|
94
|
+
// an `app.setAgentBrowserControl` handler) and asks the user to relaunch; on the next launch
|
|
95
|
+
// `resolve_gate()` reads the marker and, if present, populates the bridge env BEFORE `init()` — so the
|
|
96
|
+
// bridge comes up exactly when the user opted in, and stays fully inert otherwise. The marker path is
|
|
97
|
+
// owned by the caller (main.rs, which knows the app identifier) so this module stays app-agnostic.
|
|
98
|
+
|
|
99
|
+
/// Derive the built content bundle path from the running binary (dev + bundled layouts), so callers
|
|
100
|
+
/// don't have to know it. Returns the first candidate that exists.
|
|
101
|
+
fn resolve_content_js() -> Option<String> {
|
|
102
|
+
let exe = std::env::current_exe().ok()?;
|
|
103
|
+
let mut dir = exe.parent();
|
|
104
|
+
while let Some(d) = dir {
|
|
105
|
+
// Dev layout: …/runtime-desktop/… has the built bundle at bridge-shim/dist/content.js.
|
|
106
|
+
let cand = d.join("bridge-shim/dist/content.js");
|
|
107
|
+
if cand.exists() {
|
|
108
|
+
return Some(cand.to_string_lossy().into_owned());
|
|
109
|
+
}
|
|
110
|
+
// Bundled .app: content.js is staged into Contents/Resources.
|
|
111
|
+
let res = d.join("Resources/content.js");
|
|
112
|
+
if res.exists() {
|
|
113
|
+
return Some(res.to_string_lossy().into_owned());
|
|
114
|
+
}
|
|
115
|
+
dir = d.parent();
|
|
116
|
+
}
|
|
117
|
+
None
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Launch-time gate resolution. The opt-in `marker` is the SINGLE source of truth for the
|
|
121
|
+
/// browser-control bridge: it arms iff the marker is present, regardless of any inherited env.
|
|
122
|
+
///
|
|
123
|
+
/// A controlling daemon (mcp-desktop-browser) ALWAYS spawns us with `APPWRAP_BRIDGE_JS` set (it can't
|
|
124
|
+
/// know the FE opt-in), so trusting an inherited env would let a daemon spawn arm the bridge with no
|
|
125
|
+
/// marker — defeating the gate. Therefore: no marker → forcibly CLEAR the bridge env (`init()` then
|
|
126
|
+
/// returns early, fully inert), no matter what we were spawned with. Marker present (opted in) → keep
|
|
127
|
+
/// a daemon-provided env as-is (correct rendezvous ports), or populate it from the resolved content
|
|
128
|
+
/// bundle + defaults when launched standalone. Must run before `init()` and before any embedded tab.
|
|
129
|
+
pub fn resolve_gate(marker: &std::path::Path) {
|
|
130
|
+
if !marker.exists() {
|
|
131
|
+
// Not opted in — the bridge stays inert even if a spawner set the env. The marker, not the
|
|
132
|
+
// inherited env, is authoritative for the browser-control channel.
|
|
133
|
+
std::env::remove_var("APPWRAP_BRIDGE_JS");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if std::env::var("APPWRAP_BRIDGE_JS").ok().filter(|s| !s.is_empty()).is_some() {
|
|
137
|
+
return; // opted in + env already provided (daemon-spawned) — respect its rendezvous ports
|
|
138
|
+
}
|
|
139
|
+
match resolve_content_js() {
|
|
140
|
+
Some(js) => {
|
|
141
|
+
std::env::set_var("APPWRAP_BRIDGE_JS", js);
|
|
142
|
+
if std::env::var("APPWRAP_BRIDGE_RUST_PORT").is_err() {
|
|
143
|
+
std::env::set_var("APPWRAP_BRIDGE_RUST_PORT", "3843");
|
|
144
|
+
}
|
|
145
|
+
if std::env::var("APPWRAP_BRIDGE_PAGE_PORT").is_err() {
|
|
146
|
+
std::env::set_var("APPWRAP_BRIDGE_PAGE_PORT", "3842");
|
|
147
|
+
}
|
|
148
|
+
eprintln!("[bridge-shim] agent browser control ENABLED via opt-in marker");
|
|
149
|
+
}
|
|
150
|
+
None => eprintln!("[bridge-shim] opt-in marker present but content.js not found — bridge stays off"),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
89
154
|
pub fn init(app: AppHandle) {
|
|
90
155
|
let js_path = match std::env::var("APPWRAP_BRIDGE_JS") {
|
|
91
156
|
Ok(p) if !p.is_empty() => p,
|
|
@@ -365,6 +365,25 @@ fn handle(method: &str, params: &Value) -> HandlerResult {
|
|
|
365
365
|
spawn_reaped(cmd)?;
|
|
366
366
|
Ok(json!({ "ok": true }))
|
|
367
367
|
}
|
|
368
|
+
// Persist the agent-browser-control opt-in (the security gate for the bridge control channel).
|
|
369
|
+
// Writes/removes the marker `bridge_mac::resolve_gate` reads at launch; takes effect on the
|
|
370
|
+
// next relaunch (the bridge env is read once at startup), so signal `requiresRelaunch`.
|
|
371
|
+
"app.setAgentBrowserControl" => {
|
|
372
|
+
let enabled = params["enabled"].as_bool().unwrap_or(false);
|
|
373
|
+
#[cfg(target_os = "macos")]
|
|
374
|
+
{
|
|
375
|
+
let path = agent_browser_control_marker();
|
|
376
|
+
if enabled {
|
|
377
|
+
if let Some(parent) = path.parent() {
|
|
378
|
+
std::fs::create_dir_all(parent).map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
379
|
+
}
|
|
380
|
+
std::fs::write(&path, b"1").map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
381
|
+
} else if path.exists() {
|
|
382
|
+
std::fs::remove_file(&path).map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
Ok(json!({ "ok": true, "enabled": enabled, "requiresRelaunch": true }))
|
|
386
|
+
}
|
|
368
387
|
"network.status" => Ok(json!({ "online": true, "type": "wifi" })),
|
|
369
388
|
"ui.safeArea" => Ok(json!({ "top": 0, "right": 0, "bottom": 0, "left": 0 })),
|
|
370
389
|
"ui.alert" | "ui.confirm" => {
|
|
@@ -533,6 +552,17 @@ fn browser_window_handle(method: &str, params: &Value) -> HandlerResult {
|
|
|
533
552
|
}
|
|
534
553
|
}
|
|
535
554
|
|
|
555
|
+
/// Opt-in marker for the agent-browser-control bridge gate: ~/Library/Application
|
|
556
|
+
/// Support/<identifier>/agent-browser-control.enabled. Presence = enabled (read by
|
|
557
|
+
/// bridge_mac::resolve_gate at launch; written/removed by `app.setAgentBrowserControl`).
|
|
558
|
+
fn agent_browser_control_marker() -> std::path::PathBuf {
|
|
559
|
+
let home = std::env::var("HOME").unwrap_or_default();
|
|
560
|
+
std::path::Path::new(&home)
|
|
561
|
+
.join("Library/Application Support")
|
|
562
|
+
.join(&shell().identifier)
|
|
563
|
+
.join("agent-browser-control.enabled")
|
|
564
|
+
}
|
|
565
|
+
|
|
536
566
|
/// Persisted-store file: ~/Library/Application Support/<identifier>/storage.json (macOS app-data dir).
|
|
537
567
|
fn storage_path() -> std::path::PathBuf {
|
|
538
568
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
@@ -965,6 +995,12 @@ fn main() {
|
|
|
965
995
|
});
|
|
966
996
|
}
|
|
967
997
|
// SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
|
|
998
|
+
// resolve_gate() first populates the bridge env from the persisted opt-in marker (an app's
|
|
999
|
+
// "agent browser control" toggle writes it via `app.setAgentBrowserControl`), so the
|
|
1000
|
+
// channel comes up only when the user opted in; no marker → env stays unset → init()
|
|
1001
|
+
// returns early (fully inert).
|
|
1002
|
+
#[cfg(target_os = "macos")]
|
|
1003
|
+
bridge_mac::resolve_gate(&agent_browser_control_marker());
|
|
968
1004
|
#[cfg(target_os = "macos")]
|
|
969
1005
|
bridge_mac::init(app.handle().clone());
|
|
970
1006
|
// Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
|