@livx.cc/appwrap 0.61.18 → 0.61.20
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/config.ts +1 -1
- package/runtime/app/shell/env-switcher.ts +5 -0
- package/runtime/app/shell/handlers-webview.ts +54 -0
- package/runtime/app/shell/server-url.ts +36 -0
- package/runtime/app/shell/url-params.ts +86 -0
- package/src/cli.ts +6 -1
- package/src/config.ts +17 -0
package/package.json
CHANGED
|
@@ -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
|
|
@@ -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);
|
|
@@ -18,6 +18,8 @@ const KVO_KEYS = ['URL', 'title', 'canGoBack', 'canGoForward', 'loading'];
|
|
|
18
18
|
* webview.nav {op:'back'|'forward'|'reload'|'go', url?}
|
|
19
19
|
* webview.hide / webview.show {top?,bottom?} — keep state, reveal/cover the page beneath (show re-insets)
|
|
20
20
|
* webview.close — tear down; emits `webview.closed`
|
|
21
|
+
* webview.cookies {cookies: Cookie[]} — write cookies into the overlay's (persistent) jar; resolves {set}
|
|
22
|
+
* once all are stored (Bare Remote's opt-in "sign in like the Mac")
|
|
21
23
|
* Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change.
|
|
22
24
|
*
|
|
23
25
|
* Swipe back/forward on, target=_blank/window.open loads in place (shared ios-ui-delegate), keyboard +
|
|
@@ -26,6 +28,14 @@ const KVO_KEYS = ['URL', 'title', 'canGoBack', 'canGoForward', 'loading'];
|
|
|
26
28
|
* WebView's box (re-applied when it re-lays out), cookies via the process-wide CookieManager (persistent),
|
|
27
29
|
* getUserMedia via the shared permission handler, _blank in place (no multiple-window support).
|
|
28
30
|
*/
|
|
31
|
+
/** A cookie as data (Bare's desktop `remote.cookies` shape); `expires` = unix seconds, absent = session cookie. */
|
|
32
|
+
type Cookie = { name: string; value: string; domain: string; path?: string; secure?: boolean; httpOnly?: boolean; expires?: number | null; sameSite?: string | null };
|
|
33
|
+
const cookieList = (p: { cookies?: Cookie[] } | null): Cookie[] => {
|
|
34
|
+
const l = p?.cookies;
|
|
35
|
+
if (!Array.isArray(l)) throw err('NATIVE_ERROR', 'webview.cookies: cookies[] required');
|
|
36
|
+
return l.filter((c) => c && c.name && c.domain);
|
|
37
|
+
};
|
|
38
|
+
|
|
29
39
|
export function registerWebViewHandlers(): void {
|
|
30
40
|
if (isIOS) registerIos();
|
|
31
41
|
else if (isAndroid) registerAndroid();
|
|
@@ -160,6 +170,31 @@ function registerIos(): void {
|
|
|
160
170
|
return { open: !!wv };
|
|
161
171
|
}));
|
|
162
172
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
173
|
+
|
|
174
|
+
bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
|
|
175
|
+
const list = cookieList(p);
|
|
176
|
+
return onMain(() => new Promise<{ set: number }>((resolve) => {
|
|
177
|
+
const jar = WKWebsiteDataStore.defaultDataStore().httpCookieStore;
|
|
178
|
+
let left = list.length, set = 0;
|
|
179
|
+
const done = () => { if (--left <= 0) resolve({ set }); };
|
|
180
|
+
if (!left) return resolve({ set });
|
|
181
|
+
for (const c of list) {
|
|
182
|
+
const props = NSMutableDictionary.new<string, any>();
|
|
183
|
+
props.setObjectForKey(c.name, NSHTTPCookieName);
|
|
184
|
+
props.setObjectForKey(c.value ?? '', NSHTTPCookieValue);
|
|
185
|
+
props.setObjectForKey(c.domain, NSHTTPCookieDomain);
|
|
186
|
+
props.setObjectForKey(c.path || '/', NSHTTPCookiePath);
|
|
187
|
+
if (c.secure) props.setObjectForKey('TRUE', NSHTTPCookieSecure);
|
|
188
|
+
if (c.httpOnly) props.setObjectForKey('TRUE', 'HttpOnly'); // no public constant; the key CFNetwork reads
|
|
189
|
+
if (c.expires) props.setObjectForKey(NSDate.dateWithTimeIntervalSince1970(c.expires), NSHTTPCookieExpires);
|
|
190
|
+
if (c.sameSite === 'lax') props.setObjectForKey(NSHTTPCookieSameSiteLax, NSHTTPCookieSameSitePolicy);
|
|
191
|
+
else if (c.sameSite === 'strict') props.setObjectForKey(NSHTTPCookieSameSiteStrict, NSHTTPCookieSameSitePolicy);
|
|
192
|
+
const cookie = NSHTTPCookie.cookieWithProperties(props);
|
|
193
|
+
if (!cookie) { console.warn(`[webview] cookie skipped (invalid): ${c.name} @ ${c.domain}`); done(); continue; }
|
|
194
|
+
jar.setCookieCompletionHandler(cookie, () => { set++; done(); });
|
|
195
|
+
}
|
|
196
|
+
}));
|
|
197
|
+
});
|
|
163
198
|
}
|
|
164
199
|
|
|
165
200
|
function registerAndroid(): void {
|
|
@@ -302,4 +337,23 @@ function registerAndroid(): void {
|
|
|
302
337
|
return { open: !!wv };
|
|
303
338
|
}));
|
|
304
339
|
bridge.register('webview.close', () => onMain(() => { destroy(); }));
|
|
340
|
+
|
|
341
|
+
bridge.register('webview.cookies', (p: { cookies?: Cookie[] } | null) => {
|
|
342
|
+
const list = cookieList(p);
|
|
343
|
+
return onMain(() => new Promise<{ set: number }>((resolve) => {
|
|
344
|
+
const cm = android.webkit.CookieManager.getInstance();
|
|
345
|
+
let left = list.length, set = 0;
|
|
346
|
+
const done = () => { if (--left <= 0) { cm.flush(); resolve({ set }); } };
|
|
347
|
+
if (!left) return resolve({ set });
|
|
348
|
+
for (const c of list) {
|
|
349
|
+
const path = c.path || '/', host = c.domain.replace(/^\./, '');
|
|
350
|
+
const attrs = [`${c.name}=${c.value ?? ''}`, ...(c.domain.startsWith('.') ? [`Domain=${c.domain}`] : []), `Path=${path}`,
|
|
351
|
+
...(c.secure ? ['Secure'] : []), ...(c.httpOnly ? ['HttpOnly'] : []),
|
|
352
|
+
...(c.expires ? [`Expires=${new Date(c.expires * 1000).toUTCString()}`] : []),
|
|
353
|
+
...(c.sameSite ? [`SameSite=${c.sameSite}`] : [])];
|
|
354
|
+
cm.setCookie(`${c.secure ? 'https' : 'http'}://${host}${path}`, attrs.join('; '),
|
|
355
|
+
new android.webkit.ValueCallback<java.lang.Boolean>({ onReceiveValue: (ok) => { if (ok) set++; done(); } }));
|
|
356
|
+
}
|
|
357
|
+
}));
|
|
358
|
+
});
|
|
305
359
|
}
|
|
@@ -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
|
+
}
|
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
|