@livx.cc/appwrap 0.61.19 → 0.61.21
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/shell/bridge-origin.ts +39 -0
- package/runtime/app/shell/bridge.ts +9 -2
- package/runtime/app/shell/config.ts +1 -1
- package/runtime/app/shell/custom-webview.android.ts +4 -3
- package/runtime/app/shell/custom-webview.ios.ts +4 -2
- package/runtime/app/shell/custom-webview.ts +1 -1
- package/runtime/app/shell/env-switcher.ts +5 -0
- package/runtime/app/shell/server-url.ts +36 -0
- package/runtime/app/shell/url-params.ts +86 -0
- package/runtime/tests/bridge-origin.test.ts +68 -0
- package/runtime/tests/bridge-response-delivery.test.ts +1 -1
- package/src/cli.ts +6 -1
- package/src/config.ts +17 -0
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SHELL_CONFIG } from './config';
|
|
2
|
+
import { effectiveServerUrl } from './server-url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge origin gate. The shell's WebView can show ANY page (a link, a redirect, a page the app opens), and
|
|
6
|
+
* every page in it can post to the `appwrap` channel — so without a gate a foreign page reads the app's
|
|
7
|
+
* keychain (`storage.secure`), files, cookies, contacts… The APP's own origin keeps the full bridge:
|
|
8
|
+
* the bundled `app://localhost`, the server loader's origin (incl. an allowlisted env override), and
|
|
9
|
+
* `appBoundDomains`. Any other origin (or an unknown one) gets only FOREIGN_ALLOWED: UI feedback and
|
|
10
|
+
* permission-prompted features that expose no stored app data.
|
|
11
|
+
*/
|
|
12
|
+
const FOREIGN_ALLOWED = [
|
|
13
|
+
'app.handshake', 'app.environment', 'device.info', 'network.status',
|
|
14
|
+
'haptics.', 'toast.', 'keyboard.', 'ui.', 'screen.', 'scanner.', 'push.', 'share.share',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** `scheme://host[:port]` of a URL, lowercased, default ports dropped; '' if unparseable. */
|
|
18
|
+
export function originOf(url: string | null | undefined): string {
|
|
19
|
+
const m = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i.exec(String(url || ''));
|
|
20
|
+
if (!m) return '';
|
|
21
|
+
const scheme = m[1].toLowerCase();
|
|
22
|
+
const host = (m[2].split('@').pop() || '').toLowerCase();
|
|
23
|
+
const dflt = scheme === 'https' ? ':443' : scheme === 'http' ? ':80' : '';
|
|
24
|
+
return `${scheme}://${dflt && host.endsWith(dflt) ? host.slice(0, -dflt.length) : host}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Is `origin` the app's own (full bridge)? Fail-closed: an empty/unknown origin is foreign. */
|
|
28
|
+
export function isTrustedOrigin(origin: string | null | undefined): boolean {
|
|
29
|
+
const o = originOf(origin);
|
|
30
|
+
if (!o) return false;
|
|
31
|
+
if (o === 'app://localhost' || (SHELL_CONFIG.loader === 'file' && o === 'file://')) return true;
|
|
32
|
+
if (SHELL_CONFIG.loader === 'server' && o === originOf(effectiveServerUrl())) return true;
|
|
33
|
+
return (SHELL_CONFIG.appBoundDomains ?? []).some((h) => o === `https://${String(h).toLowerCase()}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** May a page of `origin` call `method`? */
|
|
37
|
+
export function bridgeAllows(origin: string | null | undefined, method: string): boolean {
|
|
38
|
+
return isTrustedOrigin(origin) || FOREIGN_ALLOWED.some((a) => (a.endsWith('.') ? method.startsWith(a) : method === a));
|
|
39
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { CustomWebView } from './custom-webview';
|
|
3
|
+
import { bridgeAllows } from './bridge-origin';
|
|
3
4
|
|
|
4
5
|
// params: any — the bridge payload is an untyped JSON object decoded from the WebView; each handler
|
|
5
6
|
// narrows it to its own param shape at the call site.
|
|
@@ -39,7 +40,7 @@ export class Bridge {
|
|
|
39
40
|
*/
|
|
40
41
|
attach(webView: CustomWebView): void {
|
|
41
42
|
this.webView = webView;
|
|
42
|
-
webView.onAppwrapMessage = (json) => this.onMessage(json);
|
|
43
|
+
webView.onAppwrapMessage = (json, origin) => this.onMessage(json, origin);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
detach(): void {
|
|
@@ -68,7 +69,8 @@ export class Bridge {
|
|
|
68
69
|
* renderer would silently stall the retry loop and we would be back to the 60s lie. */
|
|
69
70
|
static responseAttemptTimeoutMs = 3_000;
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
/** `origin` = the calling frame's origin, from the transport (never from the page's own payload). */
|
|
73
|
+
private async onMessage(json: string, origin: string): Promise<void> {
|
|
72
74
|
let req: RequestEnvelope;
|
|
73
75
|
try {
|
|
74
76
|
req = JSON.parse(json);
|
|
@@ -78,6 +80,11 @@ export class Bridge {
|
|
|
78
80
|
}
|
|
79
81
|
if (req.kind !== 'request' || !req.id || !req.method) return;
|
|
80
82
|
|
|
83
|
+
if (!bridgeAllows(origin, req.method)) {
|
|
84
|
+
console.warn(`Bridge: ${req.method} denied for foreign origin ${origin || '(unknown)'}`);
|
|
85
|
+
this.respond(req.id, undefined, { code: 'FORBIDDEN', message: `${req.method} is not available to ${origin || 'this page'}` });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
81
88
|
const handler = this.handlers.get(req.method);
|
|
82
89
|
if (!handler) {
|
|
83
90
|
this.respond(req.id, undefined, { code: 'UNSUPPORTED', message: `No handler for ${req.method}` });
|
|
@@ -84,7 +84,7 @@ export const SHELL_CONFIG = {
|
|
|
84
84
|
* present AND not `enabled:false`); when false the menu action, banner, and boot override are all
|
|
85
85
|
* inert. `envs` = declared presets; `allowPattern` = anchored regex gating "Other" (default-deny
|
|
86
86
|
* when ''). Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
|
|
87
|
-
envSwitcher: { enabled: false, envs: [] as { label: string; url: string }[], allowPattern: '' },
|
|
87
|
+
envSwitcher: { enabled: false, envs: [] as { label: string; url: string }[], allowPattern: '', params: [] as { key: string; label: string; options: string[]; optionsUrl: string; optionsPath: string; defaultValue?: string }[] },
|
|
88
88
|
/** TCC-gated web APIs this build DECLARED (active modules + the config's `permissions{}`) — what the
|
|
89
89
|
* document-start capability guard exposes to the page. NOT the same question as "is the Info.plist
|
|
90
90
|
* usage string present": the plist also carries the webview baseline (NSCameraUsageDescription is
|
|
@@ -49,14 +49,15 @@ function getChromeClientClass(): any {
|
|
|
49
49
|
chromeClientClass = (android.webkit.WebChromeClient as any).extend({
|
|
50
50
|
onJsPrompt(
|
|
51
51
|
view: android.webkit.WebView,
|
|
52
|
-
|
|
52
|
+
url: string,
|
|
53
53
|
message: string,
|
|
54
54
|
_defaultValue: string,
|
|
55
55
|
result: android.webkit.JsPromptResult
|
|
56
56
|
): boolean {
|
|
57
57
|
if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
|
|
58
58
|
result.confirm('');
|
|
59
|
-
|
|
59
|
+
// `url` = the page that called prompt() (the bridge gates on its origin).
|
|
60
|
+
CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length), url);
|
|
60
61
|
return true;
|
|
61
62
|
}
|
|
62
63
|
return false; // genuine page prompt — default handling
|
|
@@ -91,7 +92,7 @@ function getChromeClientClass(): any {
|
|
|
91
92
|
*/
|
|
92
93
|
export class CustomWebView extends WebView {
|
|
93
94
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
94
|
-
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
95
|
+
onAppwrapMessage: ((json: string, origin: string) => void) | null = null;
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
98
|
* A TLS handshake this view REFUSED (onReceivedSslError → handler.cancel()), pending attribution to the
|
|
@@ -38,7 +38,9 @@ class AppwrapScriptHandler extends NSObject implements WKScriptMessageHandler {
|
|
|
38
38
|
const body = message.body;
|
|
39
39
|
// Envelopes always travel as JSON strings (protocol v1)
|
|
40
40
|
if (view?.onAppwrapMessage && typeof body === 'string') {
|
|
41
|
-
|
|
41
|
+
// The CALLING frame's origin (WebKit-reported, not page-controlled) — the bridge gates on it.
|
|
42
|
+
const so = message.frameInfo?.securityOrigin;
|
|
43
|
+
view.onAppwrapMessage(body, so ? `${so.protocol}://${so.host}${so.port ? `:${so.port}` : ''}` : '');
|
|
42
44
|
} else {
|
|
43
45
|
// Nothing to dispatch to (bridge detached / view gone) or a non-string body ⇒ this request is
|
|
44
46
|
// DROPPED and can never be answered — the caller's watchdog will report a false TIMEOUT for it.
|
|
@@ -195,7 +197,7 @@ export class CustomWebView extends WebView {
|
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
198
|
-
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
200
|
+
onAppwrapMessage: ((json: string, origin: string) => void) | null = null;
|
|
199
201
|
private _scriptHandler!: AppwrapScriptHandler; // retained — WKUserContentController holds it weakly
|
|
200
202
|
private _logHandler!: AppwrapLogHandler; // retained — debug console-forwarding handler
|
|
201
203
|
private _schemeHandler!: WKURLSchemeHandler;
|
|
@@ -7,7 +7,7 @@ import { WebView } from '@nativescript/core';
|
|
|
7
7
|
*/
|
|
8
8
|
export class CustomWebView extends WebView {
|
|
9
9
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
10
|
-
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
10
|
+
onAppwrapMessage: ((json: string, origin: string) => void) | null = null;
|
|
11
11
|
|
|
12
12
|
/** Pause/resume the render + JS-timer pipeline on app background/foreground. Platform-specific
|
|
13
13
|
* (see custom-webview.android.ts); no-op on iOS, which suspends rAF on its own. */
|
|
@@ -5,6 +5,7 @@ import { OVERRIDE_KEY, effectiveServerUrl, isUrlAllowed } from './server-url';
|
|
|
5
5
|
import { bridge } from './bridge';
|
|
6
6
|
import { refreshEnvBanner } from './env-banner';
|
|
7
7
|
import { refreshEnvKeepAwake } from './env-keepawake';
|
|
8
|
+
import { paramMenuLabel, showParamPicker, urlParamDefs } from './url-params';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Runtime env-switcher — re-point a `loader:'server'` shell between declared environments (prod / lab /
|
|
@@ -164,6 +165,8 @@ export async function showEnvSwitcher(): Promise<void> {
|
|
|
164
165
|
const allowOther = !!SHELL_CONFIG.envSwitcher?.allowPattern;
|
|
165
166
|
const actions = envs.map((e) => (e.url === active ? `${e.label} ✓` : e.label));
|
|
166
167
|
if (allowOther) actions.push('Other…');
|
|
168
|
+
const params = urlParamDefs().map((p) => ({ p, label: paramMenuLabel(p) }));
|
|
169
|
+
actions.push(...params.map((x) => x.label));
|
|
167
170
|
actions.push('Reset to default');
|
|
168
171
|
|
|
169
172
|
const choice = await Dialogs.action({
|
|
@@ -176,6 +179,8 @@ export async function showEnvSwitcher(): Promise<void> {
|
|
|
176
179
|
|
|
177
180
|
if (choice === 'Reset to default') return void (await applySwitch(null, 'default'));
|
|
178
181
|
if (choice === 'Other…') return void (await promptOther());
|
|
182
|
+
const param = params.find((x) => x.label === choice);
|
|
183
|
+
if (param) return void (await showParamPicker(param.p, reloadToEffective));
|
|
179
184
|
|
|
180
185
|
const label = choice.replace(/ ✓$/, '');
|
|
181
186
|
const env = envs.find((e) => e.label === label);
|
|
@@ -58,6 +58,42 @@ export function isOverrideAllowed(url: string): boolean {
|
|
|
58
58
|
* override against the same allowlist the switcher uses — the native menu isn't the only writer of the
|
|
59
59
|
* key (any page JS can write it via `kit.storage.set`), so the read side must not trust it blindly. */
|
|
60
60
|
export function effectiveServerUrl(): string {
|
|
61
|
+
return withUrlParams(effectiveBaseUrl());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Persisted values for the config-declared URL params (`envSwitcher.params`), `{ key: value }`. */
|
|
65
|
+
export const URL_PARAMS_KEY = 'kit:urlParams';
|
|
66
|
+
|
|
67
|
+
/** The persisted URL-param selections, filtered to keys DECLARED in `envSwitcher.params` (a page can write
|
|
68
|
+
* the key via `kit.storage`, so undeclared keys / non-string values are dropped). {} when disabled. */
|
|
69
|
+
export function storedUrlParams(): Record<string, string> {
|
|
70
|
+
const declared = new Set((SHELL_CONFIG.envSwitcher?.params ?? []).map((p) => p.key));
|
|
71
|
+
if (SHELL_CONFIG.loader !== 'server' || !SHELL_CONFIG.envSwitcher?.enabled || !declared.size) return {};
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(ApplicationSettings.getString(URL_PARAMS_KEY, '') || '{}');
|
|
74
|
+
const out: Record<string, string> = {};
|
|
75
|
+
for (const [k, v] of Object.entries(raw ?? {})) if (declared.has(k) && typeof v === 'string' && v) out[k] = v;
|
|
76
|
+
return out;
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Apply the stored URL params to a load URL (a key already in the URL is REPLACED, not duplicated). */
|
|
83
|
+
export function withUrlParams(url: string): string {
|
|
84
|
+
const params = Object.entries(storedUrlParams());
|
|
85
|
+
if (!params.length) return url;
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(url);
|
|
88
|
+
for (const [k, v] of params) u.searchParams.set(k, v);
|
|
89
|
+
return u.toString();
|
|
90
|
+
} catch {
|
|
91
|
+
return url; // unparseable base — load it untouched rather than corrupt it
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The env base URL (override or build default) WITHOUT the URL params — what option sources resolve against. */
|
|
96
|
+
export function effectiveBaseUrl(): string {
|
|
61
97
|
if (SHELL_CONFIG.loader !== 'server') return SHELL_CONFIG.serverUrl;
|
|
62
98
|
if (SHELL_CONFIG.envSwitcher?.enabled) {
|
|
63
99
|
try {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ApplicationSettings, Dialogs, Http } from '@nativescript/core';
|
|
2
|
+
import { SHELL_CONFIG } from './config';
|
|
3
|
+
import { URL_PARAMS_KEY, effectiveBaseUrl, storedUrlParams } from './server-url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Config-driven URL-param menu for the env-switcher (`envSwitcher.params`). Each declared param adds a
|
|
7
|
+
* "<Label>: <current>" entry to the Switch Environment sheet; picking a value persists it (`kit:urlParams`)
|
|
8
|
+
* and reloads the WebView with `?<key>=<value>` appended (see `withUrlParams` in server-url.ts). The first
|
|
9
|
+
* option, "default", means "no param" — or `?<key>=<defaultValue>` when the param declares one. Same gate as the env-switcher itself — no separate trust surface.
|
|
10
|
+
*
|
|
11
|
+
* Options come from a static `options` list and/or `optionsUrl` — a JSON GET resolved against the ACTIVE
|
|
12
|
+
* env's base URL (so a relative path follows an env switch). `optionsPath` (dot path) selects the value
|
|
13
|
+
* inside the response; an array yields its string items, an object yields its keys.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type UrlParamDef = (typeof SHELL_CONFIG.envSwitcher.params)[number];
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_OPTION = 'default';
|
|
19
|
+
|
|
20
|
+
export function urlParamDefs(): UrlParamDef[] {
|
|
21
|
+
return SHELL_CONFIG.envSwitcher?.params ?? [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Menu entry label for a param, e.g. "Segment: senior". */
|
|
25
|
+
export function paramMenuLabel(p: UrlParamDef): string {
|
|
26
|
+
return `${p.label || p.key}: ${currentOption(p)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The menu option currently selected — a stored `defaultValue` reads back as "default". */
|
|
30
|
+
export function currentOption(p: UrlParamDef): string {
|
|
31
|
+
const v = storedUrlParams()[p.key];
|
|
32
|
+
return !v || v === p.defaultValue ? DEFAULT_OPTION : v;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Extract string options from a JSON response: walk `path`, then array → strings, object → keys. */
|
|
36
|
+
export function extractOptions(json: unknown, path = ''): string[] {
|
|
37
|
+
let v: any = json;
|
|
38
|
+
for (const k of path.split('.').filter(Boolean)) v = v?.[k];
|
|
39
|
+
const list = Array.isArray(v) ? v : v && typeof v === 'object' ? Object.keys(v) : [];
|
|
40
|
+
return list.filter((s): s is string => typeof s === 'string' && !!s);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve the param's options: "default" + static options + fetched options (deduped). A failed fetch
|
|
44
|
+
* is logged and falls back to the static list — the menu still opens. */
|
|
45
|
+
export async function loadParamOptions(p: UrlParamDef): Promise<string[]> {
|
|
46
|
+
const opts = (p.options ?? []).filter((o) => o !== p.defaultValue);
|
|
47
|
+
if (p.optionsUrl) {
|
|
48
|
+
const url = new URL(p.optionsUrl, effectiveBaseUrl()).toString();
|
|
49
|
+
try {
|
|
50
|
+
const res = await Http.request({ url, method: 'GET', timeout: 8000 });
|
|
51
|
+
if (res.statusCode < 200 || res.statusCode >= 300) throw new Error(`HTTP ${res.statusCode}`);
|
|
52
|
+
opts.push(...extractOptions(res.content?.toJSON(), p.optionsPath));
|
|
53
|
+
} catch (e: any) {
|
|
54
|
+
console.warn(`AppWrap: url-param "${p.key}" options fetch failed (${url}): ${e?.message ?? e}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...new Set([DEFAULT_OPTION, ...opts])];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Persist one param pick. "default" (or '') stores the param's `defaultValue` when declared — an explicit
|
|
61
|
+
* reset sent to the page (e.g. `?segment=default`) — else clears it so the param is omitted. Only an
|
|
62
|
+
* explicit pick is ever sent; an untouched app never gets the defaultValue appended. */
|
|
63
|
+
export function setUrlParam(p: UrlParamDef, value: string): void {
|
|
64
|
+
const next = { ...storedUrlParams() };
|
|
65
|
+
const v = !value || value === DEFAULT_OPTION ? p.defaultValue ?? '' : value;
|
|
66
|
+
if (v) next[p.key] = v;
|
|
67
|
+
else delete next[p.key];
|
|
68
|
+
ApplicationSettings.setString(URL_PARAMS_KEY, JSON.stringify(next));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Action sheet for one param; on a changed pick, persist + `reload()`. */
|
|
72
|
+
export async function showParamPicker(p: UrlParamDef, reload: () => void): Promise<void> {
|
|
73
|
+
const current = currentOption(p);
|
|
74
|
+
const options = await loadParamOptions(p);
|
|
75
|
+
const choice = await Dialogs.action({
|
|
76
|
+
title: p.label || p.key,
|
|
77
|
+
message: `Current: ${current}`,
|
|
78
|
+
cancelButtonText: 'Cancel',
|
|
79
|
+
actions: options.map((o) => (o === current ? `${o} ✓` : o)),
|
|
80
|
+
});
|
|
81
|
+
if (!choice || choice === 'Cancel') return;
|
|
82
|
+
const value = choice.replace(/ ✓$/, '');
|
|
83
|
+
if (value === current) return;
|
|
84
|
+
setUrlParam(p, value);
|
|
85
|
+
reload();
|
|
86
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// Same superset @nativescript/core mock as the other runtime tests (shared module cache).
|
|
4
|
+
mock.module('@nativescript/core', () => ({
|
|
5
|
+
Http: { request: async () => ({ content: null }) },
|
|
6
|
+
isIOS: false, isAndroid: false, WebView: class {},
|
|
7
|
+
ApplicationSettings: { getString: (_k: string, d = '') => d, setString: () => {}, remove: () => {} },
|
|
8
|
+
Dialogs: { confirm: async () => true, action: async () => '', alert: async () => undefined, prompt: async () => ({ result: false, text: '' }) },
|
|
9
|
+
Utils: { dispatchToMainThread: (fn: () => void) => fn() },
|
|
10
|
+
Application: { on: () => {}, suspendEvent: 's', resumeEvent: 'r', orientationChangedEvent: 'o', android: {} },
|
|
11
|
+
Connectivity: { startMonitoring: () => {} },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const { SHELL_CONFIG } = await import('../app/shell/config');
|
|
15
|
+
const { Bridge } = await import('../app/shell/bridge');
|
|
16
|
+
const { bridgeAllows, originOf } = await import('../app/shell/bridge-origin');
|
|
17
|
+
|
|
18
|
+
/** A request from a page of `origin`; resolves with the response envelope the bridge delivers. */
|
|
19
|
+
async function call(method: string, origin: string) {
|
|
20
|
+
const b = new Bridge();
|
|
21
|
+
const view: any = { onAppwrapMessage: null };
|
|
22
|
+
b.attach(view);
|
|
23
|
+
b.register(method, () => 'secret');
|
|
24
|
+
let out: any;
|
|
25
|
+
b.evalJs = async (js: string) => { out = JSON.parse(JSON.parse(js.slice(js.indexOf('(') + 1, js.lastIndexOf(')')))); };
|
|
26
|
+
view.onAppwrapMessage(JSON.stringify({ v: 1, id: 'k', kind: 'request', method }), origin);
|
|
27
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('Bridge origin gate — a foreign page must not reach the app’s stored data', () => {
|
|
32
|
+
test('the app origin keeps the full bridge', async () => {
|
|
33
|
+
expect((await call('storage.secure.get', 'app://localhost')).result).toBe('secret');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a foreign page (a LAN/Tailscale server) is FORBIDDEN storage.secure, fs, webview cookies', async () => {
|
|
37
|
+
for (const m of ['storage.secure.get', 'storage.get', 'fs.read', 'webview.getCookies', 'clipboard.read', 'share.files']) {
|
|
38
|
+
const r = await call(m, 'http://192.168.1.5:7707');
|
|
39
|
+
expect(r.result).toBeUndefined();
|
|
40
|
+
expect(r.error.code).toBe('FORBIDDEN');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an unknown origin fails closed', async () => {
|
|
45
|
+
expect((await call('storage.secure.get', '')).error.code).toBe('FORBIDDEN');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a foreign page keeps UI feedback + permission-prompted features', () => {
|
|
49
|
+
for (const m of ['haptics.impact', 'keyboard.hide', 'share.share', 'ui.statusBar.setStyle', 'push.register', 'app.handshake'])
|
|
50
|
+
expect(bridgeAllows('https://evil.example', m)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('server loader: its own origin (default port, any path) is trusted; a lookalike is not', () => {
|
|
54
|
+
const prev = { loader: SHELL_CONFIG.loader, serverUrl: SHELL_CONFIG.serverUrl };
|
|
55
|
+
Object.assign(SHELL_CONFIG, { loader: 'server', serverUrl: 'https://app.example.com/start?x=1' });
|
|
56
|
+
try {
|
|
57
|
+
expect(bridgeAllows('https://app.example.com:443', 'storage.secure.get')).toBe(true);
|
|
58
|
+
expect(bridgeAllows('https://app.example.com.evil.io', 'storage.secure.get')).toBe(false);
|
|
59
|
+
expect(bridgeAllows('http://app.example.com', 'storage.secure.get')).toBe(false);
|
|
60
|
+
} finally { Object.assign(SHELL_CONFIG, prev); }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('originOf normalises', () => {
|
|
64
|
+
expect(originOf('HTTP://U:p@Host:80/a?b#c')).toBe('http://host');
|
|
65
|
+
expect(originOf('app://localhost/index.html')).toBe('app://localhost');
|
|
66
|
+
expect(originOf('not a url')).toBe('');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -22,7 +22,7 @@ function harness(evalImpl: (js: string) => Promise<unknown>) {
|
|
|
22
22
|
b.attach(view);
|
|
23
23
|
b.evalJs = evalImpl;
|
|
24
24
|
const delivered: any[] = [];
|
|
25
|
-
const send = (method: string, id = 'k1') => view.onAppwrapMessage(JSON.stringify({ v: 1, id, kind: 'request', method }));
|
|
25
|
+
const send = (method: string, id = 'k1') => view.onAppwrapMessage(JSON.stringify({ v: 1, id, kind: 'request', method }), 'app://localhost');
|
|
26
26
|
/** Parse an envelope back out of the `window.__appwrapDeliver("…")` script the bridge builds. */
|
|
27
27
|
const parse = (js: string) => JSON.parse(JSON.parse(js.slice(js.indexOf('(') + 1, js.lastIndexOf(')'))));
|
|
28
28
|
return { b, send, delivered, parse };
|
package/src/cli.ts
CHANGED
|
@@ -975,6 +975,11 @@ export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
|
975
975
|
enabled: !!es && es.enabled !== false,
|
|
976
976
|
envs: (es?.envs ?? []).map((e) => ({ label: String(e.label), url: String(e.url) })),
|
|
977
977
|
allowPattern: es?.allowPattern ?? '',
|
|
978
|
+
params: (es?.params ?? []).map((p) => ({
|
|
979
|
+
key: String(p.key), label: String(p.label ?? p.key), options: (p.options ?? []).map(String),
|
|
980
|
+
optionsUrl: String(p.optionsUrl ?? ''), optionsPath: String(p.optionsPath ?? ''),
|
|
981
|
+
...(p.defaultValue ? { defaultValue: String(p.defaultValue) } : {}),
|
|
982
|
+
})),
|
|
978
983
|
};
|
|
979
984
|
const hold = cfg.splashHold;
|
|
980
985
|
const splash = {
|
|
@@ -1012,7 +1017,7 @@ export const SHELL_CONFIG = {
|
|
|
1012
1017
|
iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
|
|
1013
1018
|
iosHideKeyboardAccessory: ${JSON.stringify(cfg.iosHideKeyboardAccessory ?? false)},
|
|
1014
1019
|
splash: ${JSON.stringify(splash)} as { hold: boolean; timeoutMs: number; logo: boolean },
|
|
1015
|
-
envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
|
|
1020
|
+
envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string; params: { key: string; label: string; options: string[]; optionsUrl: string; optionsPath: string; defaultValue?: string }[] },
|
|
1016
1021
|
webCaps: ${JSON.stringify(webCaps)} as { camera: boolean; microphone: boolean; geolocation: boolean },
|
|
1017
1022
|
};
|
|
1018
1023
|
`;
|
package/src/config.ts
CHANGED
|
@@ -38,6 +38,23 @@ export interface EnvSwitcherConfig {
|
|
|
38
38
|
allowPattern?: string;
|
|
39
39
|
/** Deeplink auto-switch (Phase 2 — not yet implemented). Opt-in. */
|
|
40
40
|
deeplink?: boolean;
|
|
41
|
+
/** Extra URL query params pickable from the switch menu (same gate). Each adds "<label>: <value>";
|
|
42
|
+
* a pick persists and reloads with `?<key>=<value>` ("default" = param omitted). */
|
|
43
|
+
params?: EnvSwitcherParam[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One switchable URL param. Options = static `options` + `optionsUrl` (JSON GET, resolved against the
|
|
47
|
+
* ACTIVE env's URL, so a relative path follows env switches); `optionsPath` dot-path picks the value —
|
|
48
|
+
* an array gives its strings, an object gives its keys. */
|
|
49
|
+
export interface EnvSwitcherParam {
|
|
50
|
+
key: string;
|
|
51
|
+
label?: string;
|
|
52
|
+
options?: string[];
|
|
53
|
+
optionsUrl?: string;
|
|
54
|
+
optionsPath?: string;
|
|
55
|
+
/** Value sent when "default" is picked (e.g. 'default' → `?segment=default`, an explicit reset). Absent →
|
|
56
|
+
* the param is omitted. Only sent after an explicit pick, never on an untouched app. */
|
|
57
|
+
defaultValue?: string;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
/** iOS share-extension direct sync (`shareTarget.directSync`). When configured, the generated
|