@livx.cc/appwrap 0.50.1 → 0.51.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.50.1",
3
+ "version": "0.51.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",
@@ -194,10 +194,32 @@ function hideIOSBanner(): void {
194
194
  }
195
195
 
196
196
  // ── Android ──────────────────────────────────────────────────────────
197
+ /**
198
+ * Cold-relaunch safe entry: on a relaunch (main-page onPageLoaded) the Activity's window is not yet
199
+ * attached, so `foregroundActivity` can be null AND `getRootWindowInsets()` returns null — mounting
200
+ * inline then lands the pill INSIDE the nav-bar zone (bottomMargin computed off a 0 inset) where it's
201
+ * occluded by the home affordance and reads as "the banner disappeared". So: wait for an activity, then
202
+ * defer the actual mount to `decorView.post` (runs after attach + first layout on the UI thread), when
203
+ * insets are real. In-session callers (refreshEnvBanner) hit the same path — a one-frame post is harmless.
204
+ */
205
+ let androidMountRetries = 0; // bounds the boot "activity not ready" retry so it can't spin forever
206
+
197
207
  function showAndroidBanner(): void {
198
208
  if (androidView) return;
199
209
  const activity = Application.android.foregroundActivity || Application.android.startActivity;
200
- if (!activity) return;
210
+ if (!activity) {
211
+ if (androidMountRetries++ < 20) setTimeout(() => runOnAndroidUi(showAndroidBanner), 100); // boot: activity not ready — retry, bounded (~2s)
212
+ return;
213
+ }
214
+ androidMountRetries = 0;
215
+ activity.getWindow().getDecorView().post(new java.lang.Runnable({ run() { mountAndroidBanner(activity); } }));
216
+ }
217
+
218
+ function mountAndroidBanner(activity: android.app.Activity): void {
219
+ if (androidView) return; // a second deferred mount raced in — keep the first
220
+ // Re-gate at the final step: the override may have been cleared (reset / switch to default / hideEnvBanner)
221
+ // during the deferred post or the boot retry window — don't mount a stale banner the caller no longer wants.
222
+ if (!isEnvSwitcherEnabled() || !isNonDefaultOverride()) return;
201
223
  const density = activity.getResources().getDisplayMetrics().density;
202
224
  const padH = Math.round(14 * density), padV = Math.round(6 * density);
203
225
 
@@ -254,13 +276,15 @@ function showAndroidBanner(): void {
254
276
  armShrink();
255
277
  }
256
278
 
257
- /** Bottom system-inset (gesture/nav bar) in px; 0 best-effort if unavailable. */
279
+ /** Bottom system-inset (gesture/nav bar) in px. Falls back to ~48dp (a typical nav-bar height) rather
280
+ * than 0 if the read fails, so the pill never lands INSIDE the nav bar on a device/timing where insets
281
+ * are unavailable — being a touch too high is harmless; being occluded reads as "missing". */
258
282
  function androidBottomInsetPx(activity: android.app.Activity): number {
259
283
  try {
260
284
  const wi = activity.getWindow().getDecorView().getRootWindowInsets();
261
285
  if (wi) return wi.getSystemWindowInsetBottom();
262
- } catch (e) { /* pre-attach / older API — fall back to the base margin */ }
263
- return 0;
286
+ } catch (e) { /* pre-attach / older API — fall back below */ }
287
+ return Math.round(48 * activity.getResources().getDisplayMetrics().density);
264
288
  }
265
289
 
266
290
  function renderAndroid(): void {
@@ -143,24 +143,26 @@ 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).
146
+ // ── Deep-link entry (`<scheme>://env?to=<name>` | `?url=<encoded-url>`) ────────────────────────────
147
+ // A shared link can auto-open the app on a given env. Two additive forms: `?to=<name>` switches to a
148
+ // CONFIGURED preset by label (TRUSTED — bypasses the allowlist, like the manual switcher's presets);
149
+ // `?url=<encoded>` is a free-form target still gated by `isUrlAllowed` (anchored allowPattern, default-
150
+ // deny). Both go through a confirm prompt and apply via the EXACT SAME `applySwitch` path as a manual
151
+ // switch. events.ts owns the WebView-ready timing (warm: now; cold launch: buffered, replayed after
152
+ // the PWA handshake).
151
153
 
152
154
  export type EnvDeepLinkDecision =
153
155
  | { action: 'switch'; url: string }
154
156
  | { action: 'rejected'; url: string }
155
157
  | { action: 'ignored' };
156
158
 
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
+ /** Decode a query param by name from a `<scheme>://env?…` link. Null if absent/empty/undecodable. */
160
+ function parseQueryParam(link: string, name: string): string | null {
159
161
  const q = String(link || '').indexOf('?');
160
162
  if (q < 0) return null;
161
163
  for (const part of link.slice(q + 1).split('&')) {
162
164
  const eq = part.indexOf('=');
163
- if ((eq < 0 ? part : part.slice(0, eq)) !== 'url') continue;
165
+ if ((eq < 0 ? part : part.slice(0, eq)) !== name) continue;
164
166
  try {
165
167
  const decoded = decodeURIComponent((eq < 0 ? '' : part.slice(eq + 1)).replace(/\+/g, '%20')).trim();
166
168
  return decoded || null;
@@ -171,6 +173,19 @@ function parseEnvTargetUrl(link: string): string | null {
171
173
  return null;
172
174
  }
173
175
 
176
+ /** Decode the `url` query param from a `<scheme>://env?url=<encoded>` link. Null if absent/undecodable. */
177
+ function parseEnvTargetUrl(link: string): string | null {
178
+ return parseQueryParam(link, 'url');
179
+ }
180
+
181
+ /** Resolve a `?to=`/`?env=` preset NAME against `envs` by label (case-insensitive). Null if no name given. */
182
+ function resolvePresetByName(link: string): { name: string; preset: { label: string; url: string } | undefined } | null {
183
+ const name = parseQueryParam(link, 'to') ?? parseQueryParam(link, 'env');
184
+ if (name === null) return null;
185
+ const preset = (SHELL_CONFIG.envSwitcher?.envs ?? []).find((e) => e.label.toLowerCase() === name.toLowerCase());
186
+ return { name, preset };
187
+ }
188
+
174
189
  /** Structurally an env-switch deep link (`<scheme>://env…`) AND the switcher is enabled — the signal
175
190
  * events.ts uses to CONSUME the link (never forward an env link to the PWA). Inert (false) when the
176
191
  * feature is disabled/absent, so such a link simply falls through to the normal PWA deep-link path. */
@@ -179,14 +194,26 @@ export function isEnvDeepLink(link: string): boolean {
179
194
  }
180
195
 
181
196
  /**
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`.
197
+ * PURE decision for an inbound `<scheme>://env?…` deep link — no dialogs, no side effects (unit-testable,
198
+ * mirrors env-deeplink-security.test.ts). Two additive forms:
199
+ *
200
+ * • `?to=<name>` (alias `?env=<name>`) — switch to a CONFIGURED preset by label, case-insensitive. A
201
+ * resolved preset is TRUSTED and BYPASSES the allowlist (it's a declared env, exactly like the manual
202
+ * switcher trusts its presets and only allowlists free-form "Other"). A name matching NO preset →
203
+ * `rejected` (NEVER switch, NEVER fall through to `?url=`). `to` WINS when both `to`/`env` present, and
204
+ * takes PRECEDENCE over `?url=` when both are present.
205
+ * • `?url=<encoded>` — free-form target, allowlist-GATED: MUST pass the SAME `isUrlAllowed` gate as the
206
+ * "Other" custom URL (anchored allowPattern, default-deny). Allowlisted → `switch`; else → `rejected`.
207
+ *
208
+ * Disabled feature / non-env link / neither param present (or malformed/undecodable) → `ignored`.
187
209
  */
188
210
  export function decideEnvDeepLink(link: string): EnvDeepLinkDecision {
189
211
  if (!isEnvSwitcherEnabled() || hostOf(link) !== 'env') return { action: 'ignored' };
212
+ // Preset-by-name takes precedence and bypasses the allowlist (trusted, configured env).
213
+ const named = resolvePresetByName(link);
214
+ if (named) {
215
+ return named.preset ? { action: 'switch', url: named.preset.url } : { action: 'rejected', url: named.name };
216
+ }
190
217
  const target = parseEnvTargetUrl(link);
191
218
  if (!target) return { action: 'ignored' };
192
219
  if (!isUrlAllowed(target)) return { action: 'rejected', url: target };