@livx.cc/appwrap 0.49.0 → 0.50.1
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
|
@@ -4,13 +4,7 @@ import { mimeFor } from './mime';
|
|
|
4
4
|
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
|
|
5
5
|
import { envGlobalsJs } from './env';
|
|
6
6
|
import { requestPermissions } from './android-helpers';
|
|
7
|
-
|
|
8
|
-
/** Host[:port] of a URL, lowercased; '' if unparseable (scheme/path/userinfo stripped). */
|
|
9
|
-
function hostOfUrl(url: string): string {
|
|
10
|
-
const afterScheme = String(url || '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
11
|
-
const authority = afterScheme.split(/[/?#]/)[0];
|
|
12
|
-
return (authority.split('@').pop() || authority).toLowerCase();
|
|
13
|
-
}
|
|
7
|
+
import { hostOf } from './env-switcher';
|
|
14
8
|
|
|
15
9
|
// `android` + `java` resolve to the real types-android namespaces (no declare needed).
|
|
16
10
|
declare const androidx: any; // no NS types: androidx.webkit.* (WebViewFeature/WebViewCompat) not in the android-32 platform typings
|
|
@@ -404,8 +398,11 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
404
398
|
): void {
|
|
405
399
|
// BUILD-TIME serverUrl host ONLY — never the switchable `effectiveServerUrl()` override (would let a
|
|
406
400
|
// switched host's self-signed cert be trusted → MITM). The dev server is always the build-time URL.
|
|
407
|
-
|
|
408
|
-
|
|
401
|
+
// Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
|
|
402
|
+
// resources on an alt port share one self-signed cert) — same `hostOf` normalization + port strip.
|
|
403
|
+
const stripPort = (h: string) => h.replace(/:\d+$/, '');
|
|
404
|
+
const allowedHost = stripPort(hostOf(SHELL_CONFIG.serverUrl));
|
|
405
|
+
const errHost = stripPort(hostOf(String(error?.getUrl?.() ?? '')));
|
|
409
406
|
if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server' && !!allowedHost && errHost === allowedHost) {
|
|
410
407
|
console.warn('AppWrap: trusting self-signed dev-server cert (debug + host-scoped):', errHost);
|
|
411
408
|
handler.proceed();
|
|
@@ -24,6 +24,7 @@ let iosPanRecognizer: any = null; // read inside the (no-arg) pan handler to avo
|
|
|
24
24
|
|
|
25
25
|
// Android refs.
|
|
26
26
|
let androidView: android.widget.TextView | null = null;
|
|
27
|
+
let androidDraggedPos: { x: number; y: number } | null = null; // parked translation (mirror of iosDraggedCenter)
|
|
27
28
|
|
|
28
29
|
/** Show the banner if the switcher is enabled AND a NON-DEFAULT override is active. Idempotent. No-op
|
|
29
30
|
* otherwise (incl. when the override resolves to the build-time default host). */
|
|
@@ -61,6 +62,7 @@ export function hideEnvBanner(): void {
|
|
|
61
62
|
*/
|
|
62
63
|
export function resetEnvBannerPosition(): void {
|
|
63
64
|
iosDraggedCenter = null;
|
|
65
|
+
androidDraggedPos = null;
|
|
64
66
|
if (isIOS) { if (iosContainer) render(); }
|
|
65
67
|
else if (isAndroid) { if (androidView) render(); }
|
|
66
68
|
}
|
|
@@ -207,19 +209,68 @@ function showAndroidBanner(): void {
|
|
|
207
209
|
bg.setColor(android.graphics.Color.argb(235, 184, 115, 5)); // amber
|
|
208
210
|
bg.setCornerRadius(15 * density);
|
|
209
211
|
tv.setBackground(bg);
|
|
212
|
+
// Tap semantics (via performClick from the touch listener below): pill → expand; expanded → switch menu.
|
|
210
213
|
tv.setOnClickListener(new android.view.View.OnClickListener({ onClick() { onTap(); } }));
|
|
211
214
|
|
|
215
|
+
// Drag ANYWHERE via translationX/Y (mirror of the iOS pan — no clamp; recall via shake →
|
|
216
|
+
// resetEnvBannerPosition). Consume the gesture stream (return true on DOWN) so we get MOVE/UP; on a
|
|
217
|
+
// no-move UP, performClick() routes to the tap handler above (preserves tap + a11y). The parked
|
|
218
|
+
// translation is remembered in androidDraggedPos so re-renders don't snap it home.
|
|
219
|
+
const slop = android.view.ViewConfiguration.get(activity).getScaledTouchSlop();
|
|
220
|
+
let downX = 0, downY = 0, startTX = 0, startTY = 0, moved = false;
|
|
221
|
+
tv.setOnTouchListener(new android.view.View.OnTouchListener({
|
|
222
|
+
onTouch(v: android.view.View, event: android.view.MotionEvent): boolean {
|
|
223
|
+
switch (event.getActionMasked()) {
|
|
224
|
+
case android.view.MotionEvent.ACTION_DOWN:
|
|
225
|
+
downX = event.getRawX(); downY = event.getRawY();
|
|
226
|
+
startTX = v.getTranslationX(); startTY = v.getTranslationY();
|
|
227
|
+
moved = false;
|
|
228
|
+
return true;
|
|
229
|
+
case android.view.MotionEvent.ACTION_MOVE: {
|
|
230
|
+
const dx = event.getRawX() - downX, dy = event.getRawY() - downY;
|
|
231
|
+
if (Math.abs(dx) > slop || Math.abs(dy) > slop) moved = true;
|
|
232
|
+
v.setTranslationX(startTX + dx);
|
|
233
|
+
v.setTranslationY(startTY + dy);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
case android.view.MotionEvent.ACTION_UP:
|
|
237
|
+
if (moved) androidDraggedPos = { x: v.getTranslationX(), y: v.getTranslationY() };
|
|
238
|
+
else v.performClick();
|
|
239
|
+
return true;
|
|
240
|
+
default:
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
245
|
+
|
|
212
246
|
const lp = new android.widget.FrameLayout.LayoutParams(-2, -2); // WRAP_CONTENT × WRAP_CONTENT
|
|
213
247
|
lp.gravity = android.view.Gravity.BOTTOM | android.view.Gravity.CENTER_HORIZONTAL;
|
|
214
|
-
|
|
248
|
+
// Raise above the gesture/nav area (mirror of the iOS home-indicator raise), respecting insets where
|
|
249
|
+
// available so the default pill never sits under the system nav bar (best-effort — falls back cleanly).
|
|
250
|
+
lp.bottomMargin = androidBottomInsetPx(activity) + Math.round(16 * density);
|
|
215
251
|
activity.addContentView(tv, lp);
|
|
216
252
|
androidView = tv;
|
|
217
253
|
renderAndroid();
|
|
218
254
|
armShrink();
|
|
219
255
|
}
|
|
220
256
|
|
|
257
|
+
/** Bottom system-inset (gesture/nav bar) in px; 0 best-effort if unavailable. */
|
|
258
|
+
function androidBottomInsetPx(activity: android.app.Activity): number {
|
|
259
|
+
try {
|
|
260
|
+
const wi = activity.getWindow().getDecorView().getRootWindowInsets();
|
|
261
|
+
if (wi) return wi.getSystemWindowInsetBottom();
|
|
262
|
+
} catch (e) { /* pre-attach / older API — fall back to the base margin */ }
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
221
266
|
function renderAndroid(): void {
|
|
222
|
-
|
|
267
|
+
const tv = androidView;
|
|
268
|
+
if (!tv) return;
|
|
269
|
+
tv.setText(bannerText());
|
|
270
|
+
tv.setAlpha(state === 'pill' ? 0.6 : 1.0); // shrunk pill is semi-transparent but still readable
|
|
271
|
+
// Re-apply the parked translation (or default home) so a re-render doesn't snap a dragged pill back.
|
|
272
|
+
tv.setTranslationX(androidDraggedPos ? androidDraggedPos.x : 0);
|
|
273
|
+
tv.setTranslationY(androidDraggedPos ? androidDraggedPos.y : 0);
|
|
223
274
|
}
|
|
224
275
|
|
|
225
276
|
function hideAndroidBanner(): void {
|
|
@@ -143,6 +143,72 @@ export async function showEnvSwitcher(): Promise<void> {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
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
|
+
|
|
146
212
|
/** Free-form URL entry, gated by `allowPattern` (default-deny). Rejects a non-matching URL. */
|
|
147
213
|
async function promptOther(): Promise<void> {
|
|
148
214
|
const res = await Dialogs.prompt({
|
|
@@ -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). */
|
|
@@ -40,6 +40,10 @@ window.__APPWRAP_DEBUG__=${SHELL_CONFIG.debug ? 'true' : 'false'};
|
|
|
40
40
|
// Debug builds only: a loud, non-interactive DEV strip pinned to the top edge showing the server
|
|
41
41
|
// this shell points at. A dev-pointing ipa (debug:true) must never be mistaken for a store/prod
|
|
42
42
|
// build — the host in the strip makes "which backend am I on?" answerable at a glance.
|
|
43
|
+
// SUPPRESSED when the env-switcher is enabled: its bottom env-banner (shown only on a NON-default
|
|
44
|
+
// env) is the better, less-intrusive "which env am I on" indicator and supersedes this strip. Apps
|
|
45
|
+
// without envSwitcher keep the strip. (Store builds never reach here — debug is false.)
|
|
46
|
+
var __appwrapDevStrip = ${SHELL_CONFIG.envSwitcher?.enabled ? 'false' : 'true'};
|
|
43
47
|
var srv = ${JSON.stringify(SHELL_CONFIG.serverUrl || '')};
|
|
44
48
|
var host = ''; try { host = srv ? new URL(srv).host : (location && location.host) || ''; } catch (e) { host = srv || ''; }
|
|
45
49
|
function mountDevStrip(){
|
|
@@ -53,8 +57,10 @@ window.__APPWRAP_DEBUG__=${SHELL_CONFIG.debug ? 'true' : 'false'};
|
|
|
53
57
|
+ 'padding:calc(env(safe-area-inset-top,0px) + 2px) 6px 2px;';
|
|
54
58
|
document.body.appendChild(el);
|
|
55
59
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
60
|
+
if (__appwrapDevStrip) {
|
|
61
|
+
if (document.body) mountDevStrip();
|
|
62
|
+
else document.addEventListener('DOMContentLoaded', mountDevStrip);
|
|
63
|
+
}
|
|
58
64
|
})();`;
|
|
59
65
|
|
|
60
66
|
/**
|