@livx.cc/appwrap 0.50.0 → 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
|
@@ -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)
|
|
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
|
|
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
|
|
263
|
-
return
|
|
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
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
|
158
|
-
function
|
|
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)) !==
|
|
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
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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 };
|
|
@@ -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
|
/**
|