@livx.cc/appwrap 0.49.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
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",
@@ -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
- const allowedHost = hostOfUrl(SHELL_CONFIG.serverUrl);
408
- const errHost = hostOfUrl(String(error?.getUrl?.() ?? ''));
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
- lp.bottomMargin = Math.round(10 * density);
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
- if (androidView) androidView.setText(bannerText());
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). */