@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.61.19",
3
+ "version": "0.61.21",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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
- private async onMessage(json: string): Promise<void> {
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
- _url: string,
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
- CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
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
- view.onAppwrapMessage(body);
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