@livx.cc/appwrap 0.61.12 → 0.61.14
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/main-page.ts +83 -2
- package/runtime/app/shell/config.ts +4 -0
- package/src/cli.ts +56 -13
- package/src/config.ts +12 -4
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -94,6 +94,7 @@ export function onPageLoaded(args: EventData): void {
|
|
|
94
94
|
|
|
95
95
|
if (initialized) return;
|
|
96
96
|
initialized = true;
|
|
97
|
+
const hideSplash = wireSplashHold(page); // first: the overlay must be up before the first frame
|
|
97
98
|
|
|
98
99
|
registerHandlers();
|
|
99
100
|
registerExtendedHandlers();
|
|
@@ -126,7 +127,7 @@ export function onPageLoaded(args: EventData): void {
|
|
|
126
127
|
// if a future surface orphans a window with no dismiss callback — gate it on a "surface presented" flag.
|
|
127
128
|
void armNativeSurfaceRecovery; // referenced to keep the helper (still callable if a scoped need arises)
|
|
128
129
|
if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
|
|
129
|
-
wireLoadFallback(page, webView); // loader:'server' failure → branded retry view (no white screen)
|
|
130
|
+
wireLoadFallback(page, webView, hideSplash); // loader:'server' failure → branded retry view (no white screen)
|
|
130
131
|
startEventForwarding();
|
|
131
132
|
loadBundle(webView);
|
|
132
133
|
// Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
|
|
@@ -179,7 +180,70 @@ export function onPageLoaded(args: EventData): void {
|
|
|
179
180
|
* NOTE: web-process termination has no NS seam in a prod build (the didTerminate forwarder lives on
|
|
180
181
|
* the debug-only DevCertNavDelegate) — out of scope here.
|
|
181
182
|
*/
|
|
182
|
-
|
|
183
|
+
/**
|
|
184
|
+
* `splashHold`: keep the launch splash up until the PAGE has painted. Without it the OS splash drops
|
|
185
|
+
* the moment this native page draws, and a loader:'server' WebView shows its blank canvas for the
|
|
186
|
+
* whole index.html + bundle download. The page ends the hold with `kit.invoke('ui.splash.hide')`;
|
|
187
|
+
* a safety timeout and the load-failure view end it too, so a page that never calls it (or an old
|
|
188
|
+
* web build behind a new shell) costs at most `timeoutMs`. Returns the idempotent hide.
|
|
189
|
+
*
|
|
190
|
+
* PIXEL-IDENTICAL BY CONSTRUCTION: the overlay IS the OS launch splash, re-instantiated natively and
|
|
191
|
+
* pinned full-bleed over the whole window — iOS instantiates LaunchScreen.storyboard itself, Android
|
|
192
|
+
* paints the window-background drawable (splash_screen.xml). An NS-layout copy sat inside the safe
|
|
193
|
+
* area, so its logo landed lower than the storyboard's and visibly jumped at the hand-off.
|
|
194
|
+
*/
|
|
195
|
+
function wireSplashHold(page: Page): (why: string) => void {
|
|
196
|
+
const noop = () => {};
|
|
197
|
+
const remove = SHELL_CONFIG.splash?.hold ? mountNativeSplash(page) : null;
|
|
198
|
+
if (!remove) {
|
|
199
|
+
bridge.register('ui.splash.hide', noop); // a page may always call it; nothing is held here
|
|
200
|
+
return noop;
|
|
201
|
+
}
|
|
202
|
+
const shownAt = Date.now();
|
|
203
|
+
let done = false;
|
|
204
|
+
const hide = (why: string): void => {
|
|
205
|
+
if (done) return;
|
|
206
|
+
done = true;
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
appwrapNativeLog(`[native:splash] hide (${why}) after ${Date.now() - shownAt}ms`);
|
|
209
|
+
remove();
|
|
210
|
+
};
|
|
211
|
+
const timer = setTimeout(() => hide('timeout'), SHELL_CONFIG.splash.timeoutMs);
|
|
212
|
+
bridge.register('ui.splash.hide', () => { hide('page'); });
|
|
213
|
+
return hide;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Put the OS launch splash back on screen as a native full-window view; returns its fade-out+remove. */
|
|
217
|
+
function mountNativeSplash(page: Page): (() => void) | null {
|
|
218
|
+
const FADE_MS = 180;
|
|
219
|
+
try {
|
|
220
|
+
if (isIOS) {
|
|
221
|
+
const host = (page.viewController as UIViewController | undefined)?.view;
|
|
222
|
+
const vc = UIStoryboard.storyboardWithNameBundle('LaunchScreen', null).instantiateInitialViewController();
|
|
223
|
+
if (!host || !vc) return null;
|
|
224
|
+
const v = vc.view;
|
|
225
|
+
v.frame = UIScreen.mainScreen.bounds;
|
|
226
|
+
v.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
|
|
227
|
+
host.addSubview(v);
|
|
228
|
+
return () => UIView.animateWithDurationAnimationsCompletion(FADE_MS / 1000, () => { v.alpha = 0; }, () => v.removeFromSuperview());
|
|
229
|
+
}
|
|
230
|
+
const act = (Application.android.foregroundActivity || Application.android.startActivity) as android.app.Activity | null;
|
|
231
|
+
const decor = act?.getWindow()?.getDecorView() as android.view.ViewGroup | undefined;
|
|
232
|
+
if (!act || !decor) return null;
|
|
233
|
+
const v = new android.view.View(act);
|
|
234
|
+
const resId = act.getResources().getIdentifier('splash_screen', 'drawable', act.getPackageName());
|
|
235
|
+
if (resId) v.setBackground(act.getResources().getDrawable(resId, act.getTheme()));
|
|
236
|
+
else v.setBackgroundColor(android.graphics.Color.parseColor(SHELL_CONFIG.backgroundColor));
|
|
237
|
+
v.setClickable(true); // swallow touches while the page boots, like the OS splash
|
|
238
|
+
decor.addView(v, new android.view.ViewGroup.LayoutParams(-1, -1));
|
|
239
|
+
return () => v.animate().alpha(0).setDuration(FADE_MS).withEndAction(new java.lang.Runnable({ run: () => decor.removeView(v) })).start();
|
|
240
|
+
} catch (e) {
|
|
241
|
+
appwrapNativeLog(`[native:splash] overlay unavailable: ${e}`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function wireLoadFallback(page: Page, webView: CustomWebView, hideSplash: (why: string) => void = () => {}): void {
|
|
183
247
|
if (SHELL_CONFIG.loader !== 'server') return; // bundled loaders can't fail on network
|
|
184
248
|
const fallback = page.getViewById<StackLayout>('loadFallback');
|
|
185
249
|
const retryBtn = page.getViewById<Button>('loadRetryBtn');
|
|
@@ -187,6 +251,7 @@ function wireLoadFallback(page: Page, webView: CustomWebView): void {
|
|
|
187
251
|
|
|
188
252
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
189
253
|
let backoffMs = 3000;
|
|
254
|
+
let triedCache = false; // iOS: one cache-only replay per failure run (see below)
|
|
190
255
|
const clearTimer = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } };
|
|
191
256
|
const retry = () => {
|
|
192
257
|
clearTimer();
|
|
@@ -202,6 +267,7 @@ function wireLoadFallback(page: Page, webView: CustomWebView): void {
|
|
|
202
267
|
const err = String(args.error ?? '');
|
|
203
268
|
if (!err) {
|
|
204
269
|
// successful load → hide + reset (idempotent; covers the auto-retry that finally lands)
|
|
270
|
+
triedCache = false;
|
|
205
271
|
clearTimer();
|
|
206
272
|
backoffMs = 3000;
|
|
207
273
|
if (fallback.visibility !== 'collapse') {
|
|
@@ -211,7 +277,22 @@ function wireLoadFallback(page: Page, webView: CustomWebView): void {
|
|
|
211
277
|
return;
|
|
212
278
|
}
|
|
213
279
|
if (/cancel/i.test(err)) return; // NSURLErrorCancelled: navigation superseded, not a failure
|
|
280
|
+
// iOS OFFLINE LAUNCH: WKWebView gets no service worker without app-bound domains, but its HTTP
|
|
281
|
+
// cache already holds the last index.html (no-cache = revalidate, still stored) and the immutable
|
|
282
|
+
// hashed assets. Before showing the error, replay the launch URL cache-only; online launches never
|
|
283
|
+
// take this path, so they still revalidate and get the newest deploy (OTA).
|
|
284
|
+
if (isIOS && !triedCache) {
|
|
285
|
+
triedCache = true;
|
|
286
|
+
const wk = webView.ios as WKWebView | null;
|
|
287
|
+
if (wk) {
|
|
288
|
+
appwrapNativeLog(`[native:fallback] load failed (${err}) — replaying from the HTTP cache`);
|
|
289
|
+
wk.loadRequest(NSURLRequest.requestWithURLCachePolicyTimeoutInterval(
|
|
290
|
+
NSURL.URLWithString(effectiveServerUrl()), NSURLRequestCachePolicy.ReturnCacheDataDontLoad, 10));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
214
294
|
appwrapNativeLog(`[native:fallback] load failed: ${err}`);
|
|
295
|
+
hideSplash('load-failed');
|
|
215
296
|
fallback.visibility = 'visible';
|
|
216
297
|
if (!retryTimer) {
|
|
217
298
|
retryTimer = setTimeout(() => { backoffMs = Math.min(backoffMs * 2, 30_000); retry(); }, backoffMs);
|
|
@@ -73,6 +73,10 @@ export const SHELL_CONFIG = {
|
|
|
73
73
|
* Lifting extra makes the page cover that strip (the keyboard hides the thin bottom row of content).
|
|
74
74
|
* Default 82. Set 0 for NO extra lift (input sits flush at the reported height; the strip may show). */
|
|
75
75
|
iosKeyboardExtraLift: 82,
|
|
76
|
+
/** `splashHold` config: hold the launch splash over the WebView until the page calls
|
|
77
|
+
* `ui.splash.hide` (or `timeoutMs` passes / the load fails). `logo` = a `splashIcon` was stamped
|
|
78
|
+
* (iOS `LaunchScreen.Center` imageset, Android `splash_logo` drawable). See wireSplashHold. */
|
|
79
|
+
splash: { hold: false, timeoutMs: 4000, logo: false } as { hold: boolean; timeoutMs: number; logo: boolean },
|
|
76
80
|
/** Runtime env-switcher (loader:'server'). `enabled` is the resolved kill-switch (config block
|
|
77
81
|
* present AND not `enabled:false`); when false the menu action, banner, and boot override are all
|
|
78
82
|
* inert. `envs` = declared presets; `allowPattern` = anchored regex gating "Other" (default-deny
|
package/src/cli.ts
CHANGED
|
@@ -974,6 +974,12 @@ export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
|
974
974
|
envs: (es?.envs ?? []).map((e) => ({ label: String(e.label), url: String(e.url) })),
|
|
975
975
|
allowPattern: es?.allowPattern ?? '',
|
|
976
976
|
};
|
|
977
|
+
const hold = cfg.splashHold;
|
|
978
|
+
const splash = {
|
|
979
|
+
hold: !!hold,
|
|
980
|
+
timeoutMs: (typeof hold === 'object' && hold.timeoutMs) || 4000,
|
|
981
|
+
logo: !!cfg.splashIcon,
|
|
982
|
+
};
|
|
977
983
|
const content = `/**
|
|
978
984
|
* Shell config — stamped by \`appwrap init\`/\`sync\` from the appwrap config. Do not edit.
|
|
979
985
|
*/
|
|
@@ -1002,6 +1008,7 @@ export const SHELL_CONFIG = {
|
|
|
1002
1008
|
pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
|
|
1003
1009
|
pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
|
|
1004
1010
|
iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
|
|
1011
|
+
splash: ${JSON.stringify(splash)} as { hold: boolean; timeoutMs: number; logo: boolean },
|
|
1005
1012
|
envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
|
|
1006
1013
|
webCaps: ${JSON.stringify(webCaps)} as { camera: boolean; microphone: boolean; geolocation: boolean },
|
|
1007
1014
|
};
|
|
@@ -1895,6 +1902,14 @@ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
|
|
|
1895
1902
|
ras.resize(splashSrc, Math.round(220 * parseFloat(img.scale)), join(centerSet, img.filename));
|
|
1896
1903
|
}
|
|
1897
1904
|
}
|
|
1905
|
+
// Android: the same logo on the 288dp splash canvas (xxxhdpi-sharp; nodpi so the layer-list's
|
|
1906
|
+
// explicit dp size does the scaling), then re-stamp the splash XML + Android 12+ theme to use it.
|
|
1907
|
+
const droidRes = join(outDir, 'App_Resources/Android/src/main/res');
|
|
1908
|
+
const hex = (cfg.backgroundColor ?? '#ffffff').replace('#', '');
|
|
1909
|
+
if (existsSync(splashSrc) && existsSync(droidRes) && /^[0-9a-fA-F]{6}$/.test(hex)) {
|
|
1910
|
+
ras.resize(splashSrc, ANDROID_SPLASH_DP * 4, join(droidRes, 'drawable-nodpi/splash_logo.png'));
|
|
1911
|
+
writeAndroidSplash(outDir, hex, true);
|
|
1912
|
+
}
|
|
1898
1913
|
}
|
|
1899
1914
|
|
|
1900
1915
|
const ANDROID_DENSITIES: Record<string, number> = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
|
|
@@ -1967,19 +1982,47 @@ function stampLaunchScreen(outDir: string, cfg: AppwrapConfig): void {
|
|
|
1967
1982
|
|
|
1968
1983
|
// Android: replace the default NativeScript splash (background bitmap + NS logo) with a solid fill
|
|
1969
1984
|
// of the app's backgroundColor — parity with the iOS solid launch screen, and no NS branding flash.
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
}
|
|
1985
|
+
writeAndroidSplash(outDir, hex, false);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/** Android launch splash, both phases, on one `ANDROID_SPLASH_DP` canvas so the logo never jumps:
|
|
1989
|
+
* the activity window background (`splash_screen.xml`, every API level) and — with a logo — the
|
|
1990
|
+
* Android 12+ system SplashScreen (`values-v31` LaunchScreenTheme), whose icon is drawn at 288dp when
|
|
1991
|
+
* it has no icon background. The shell's splash-hold overlay (main-page.ts) uses the same size. */
|
|
1992
|
+
const ANDROID_SPLASH_DP = 288;
|
|
1993
|
+
function writeAndroidSplash(outDir: string, hex: string, logo: boolean): void {
|
|
1994
|
+
const res = join(outDir, 'App_Resources/Android/src/main/res');
|
|
1995
|
+
const splash = join(res, 'drawable-nodpi/splash_screen.xml');
|
|
1996
|
+
if (!existsSync(splash)) return;
|
|
1997
|
+
const color = `#${hex.toUpperCase()}`;
|
|
1998
|
+
writeFileSync(splash,
|
|
1999
|
+
`<?xml version="1.0" encoding="utf-8"?>\n` +
|
|
2000
|
+
`<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:gravity="fill">\n` +
|
|
2001
|
+
` <item>\n` +
|
|
2002
|
+
` <shape android:shape="rectangle">\n` +
|
|
2003
|
+
` <solid android:color="${color}" />\n` +
|
|
2004
|
+
` </shape>\n` +
|
|
2005
|
+
` </item>\n` +
|
|
2006
|
+
(logo
|
|
2007
|
+
? ` <item android:width="${ANDROID_SPLASH_DP}dp" android:height="${ANDROID_SPLASH_DP}dp" android:gravity="center">\n` +
|
|
2008
|
+
` <bitmap android:src="@drawable/splash_logo" android:gravity="fill" />\n` +
|
|
2009
|
+
` </item>\n`
|
|
2010
|
+
: '') +
|
|
2011
|
+
`</layer-list>\n`
|
|
2012
|
+
);
|
|
2013
|
+
const v31 = join(res, 'values-v31/styles.xml');
|
|
2014
|
+
if (!logo) { if (existsSync(v31) && readFileSync(v31, 'utf8').includes('appwrap:splash')) rmSync(v31); return; }
|
|
2015
|
+
mkdirSync(dirname(v31), { recursive: true });
|
|
2016
|
+
writeFileSync(v31,
|
|
2017
|
+
`<?xml version="1.0" encoding="utf-8"?>\n` +
|
|
2018
|
+
`<!-- appwrap:splash — generated from splashIcon/backgroundColor; regenerated every sync. -->\n` +
|
|
2019
|
+
`<resources xmlns:android="http://schemas.android.com/apk/res/android">\n` +
|
|
2020
|
+
` <style name="LaunchScreenTheme" parent="LaunchScreenThemeBase">\n` +
|
|
2021
|
+
` <item name="android:windowSplashScreenBackground">${color}</item>\n` +
|
|
2022
|
+
` <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_logo</item>\n` +
|
|
2023
|
+
` </style>\n` +
|
|
2024
|
+
`</resources>\n`
|
|
2025
|
+
);
|
|
1983
2026
|
}
|
|
1984
2027
|
|
|
1985
2028
|
const VERSION_FILE = '.appwrap-version';
|
package/src/config.ts
CHANGED
|
@@ -169,10 +169,18 @@ export interface AppwrapConfig {
|
|
|
169
169
|
oauthRedirectSchemes?: string[];
|
|
170
170
|
/** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
|
|
171
171
|
icon?: string;
|
|
172
|
-
/** Optional centered logo for the
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
/** Optional centered logo for the launch splash (iOS LaunchScreen + Android splash, incl. the
|
|
173
|
+
* Android 12+ system splash) — a TRANSPARENT-background square png (a wordmark or glyph, NOT the
|
|
174
|
+
* app icon, whose opaque background would show as a box). Rendered on a 220pt (iOS) / 288dp
|
|
175
|
+
* (Android) canvas, so bake the glyph's padding into the png (~45% of the canvas keeps a square
|
|
176
|
+
* inside Android 12's 192dp circle mask). When absent the splash is a solid `backgroundColor`. */
|
|
175
177
|
splashIcon?: string;
|
|
178
|
+
/** Hold the launch splash (backgroundColor + `splashIcon`) OVER the WebView until the page says it
|
|
179
|
+
* has painted — `kit.invoke('ui.splash.hide')` — instead of dropping it the moment the native page
|
|
180
|
+
* draws (which shows the WebView's blank canvas while a loader:'server' page downloads). Safety
|
|
181
|
+
* net: it hides anyway after `timeoutMs` (default 4000) or when the load-failure view shows.
|
|
182
|
+
* `true` = defaults. Off by default: a page that never calls hide would sit behind the splash. */
|
|
183
|
+
splashHold?: boolean | { timeoutMs?: number };
|
|
176
184
|
/** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
|
|
177
185
|
* (load `serverUrl` live — dev HMR over LAN or a deployed URL). `appwrap dev` sets this. */
|
|
178
186
|
loader?: 'app' | 'file' | 'server';
|
|
@@ -401,7 +409,7 @@ export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
|
|
401
409
|
'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
|
|
402
410
|
'androidLockTextZoom', 'envSwitcher', 'iosEntitlements', 'iosInfoPlist', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
|
|
403
411
|
'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle', 'store',
|
|
404
|
-
'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
|
|
412
|
+
'splashHold', 'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
|
|
405
413
|
'usesNonExemptEncryption', 'vendorPaths', 'version',
|
|
406
414
|
]);
|
|
407
415
|
|