@livx.cc/appwrap 0.21.0 → 0.23.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 +1 -1
- package/runtime/app/main-page.ts +2 -1
- package/runtime/app/shell/capabilities.manifest.ts +3 -0
- package/runtime/app/shell/config.ts +11 -0
- package/runtime/app/shell/custom-webview.android.ts +2 -2
- package/runtime/app/shell/custom-webview.ios.ts +3 -2
- package/runtime/app/shell/devmenu.ts +4 -8
- package/runtime/app/shell/handlers-health.ts +36 -0
- package/runtime/app/shell/handlers.ts +12 -1
- package/runtime/app/shell/orientation.ts +7 -1
- package/runtime/app/shell/status-bar.ts +21 -0
- package/runtime/app/shell/web-quirks.ts +48 -0
- package/src/cli.ts +50 -12
- package/src/config.ts +18 -0
- package/src/derive.ts +99 -0
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -13,7 +13,7 @@ import './shell/fcm-bootstrap.generated'; // side-effect: registers the FCM serv
|
|
|
13
13
|
import { startEventForwarding } from './shell/events';
|
|
14
14
|
import { startDevMenu } from './shell/devmenu';
|
|
15
15
|
import { SHELL_CONFIG } from './shell/config';
|
|
16
|
-
import { bindStatusBarPage, setStatusBarStyle, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
|
|
16
|
+
import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
|
|
17
17
|
import { CustomWebView } from './shell/custom-webview';
|
|
18
18
|
|
|
19
19
|
let initialized = false;
|
|
@@ -23,6 +23,7 @@ export function onPageLoaded(args: EventData): void {
|
|
|
23
23
|
page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor };
|
|
24
24
|
bindStatusBarPage(page);
|
|
25
25
|
if (isAndroid) enableAndroidEdgeToEdge();
|
|
26
|
+
applyThemeColor(SHELL_CONFIG.themeColor); // manifest/config theme_color → native chrome at boot
|
|
26
27
|
setStatusBarStyle(SHELL_CONFIG.statusBarStyle);
|
|
27
28
|
|
|
28
29
|
if (initialized) return;
|
|
@@ -163,6 +163,9 @@ export const MODULES: ModuleManifest[] = [
|
|
|
163
163
|
permissions: [
|
|
164
164
|
{ key: 'NSHealthShareUsageDescription', domain: 'health', defaultUsage: 'Read your step count from the Health app.' },
|
|
165
165
|
{ key: 'NSHealthUpdateUsageDescription', domain: 'health', defaultUsage: 'Read your step count from the Health app.' },
|
|
166
|
+
// Live step stream (health.liveSteps) uses CMPedometer (CoreMotion) → iOS terminates the app
|
|
167
|
+
// on access without this Motion & Fitness usage string. Required for the real-time count.
|
|
168
|
+
{ key: 'NSMotionUsageDescription', domain: 'motion', defaultUsage: 'Count your steps live as you walk.' },
|
|
166
169
|
],
|
|
167
170
|
entitlements: { 'com.apple.developer.healthkit': true },
|
|
168
171
|
},
|
|
@@ -9,8 +9,15 @@ export const SHELL_CONFIG = {
|
|
|
9
9
|
entry: 'index.html',
|
|
10
10
|
/** Page + status bar background while the WebView boots. */
|
|
11
11
|
backgroundColor: '#0b1020',
|
|
12
|
+
/** Boot-time native chrome color (status bar / safe areas), from `appwrap.json.themeColor` or the
|
|
13
|
+
* PWA manifest `theme_color`. Empty = leave the root un-tinted (page backgroundColor shows through).
|
|
14
|
+
* Applied at boot via the same root-view tint `kit.ui.syncThemeColor()` uses at runtime. */
|
|
15
|
+
themeColor: '',
|
|
12
16
|
/** 'light' = white status bar icons. */
|
|
13
17
|
statusBarStyle: 'light' as 'light' | 'dark',
|
|
18
|
+
/** Supported orientation (config > manifest). Drives the iOS AppDelegate orientation mask at boot
|
|
19
|
+
* (which overrides Info.plist) + is stamped to Android `screenOrientation`. '' = free rotation. */
|
|
20
|
+
orientation: '' as '' | 'portrait' | 'landscape' | 'any',
|
|
14
21
|
/** Android only (experimental). true = WebView draws edge-to-edge under transparent bars +
|
|
15
22
|
* safe-area insets injected as `--saie-*` CSS vars; false = bars show the page backgroundColor. */
|
|
16
23
|
edgeToEdge: false,
|
|
@@ -29,6 +36,10 @@ export const SHELL_CONFIG = {
|
|
|
29
36
|
debugLog: '*',
|
|
30
37
|
/** Shake-to-open developer menu (App Info / Reload). On by default, including store builds. */
|
|
31
38
|
devMenu: true,
|
|
39
|
+
/** Neutralize `navigator.serviceWorker.register` in the native shell (a SW serves stale caches and
|
|
40
|
+
* fights the app:// handler / remote-update detection). On by default; set false to opt out and keep
|
|
41
|
+
* the SW (e.g. for in-WebView web-push). See `serviceWorkerGuardJs`. */
|
|
42
|
+
neutralizeServiceWorker: true,
|
|
32
43
|
/** Remote push configured, per platform (iOS aps-environment entitlement / Android FCM). Drives the
|
|
33
44
|
* `push` capability flag at runtime by platform — off unless `appwrap.json.push` enables it, so an
|
|
34
45
|
* un-provisioned build honestly reports 'none' (and a personal-team iOS build keeps `pushIos:false`). */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebView, Utils, knownFolders, path as nsPath, File } from '@nativescript/core';
|
|
2
2
|
import { SHELL_CONFIG } from './config';
|
|
3
3
|
import { mimeFor } from './mime';
|
|
4
|
-
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS } from './web-quirks';
|
|
4
|
+
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs } from './web-quirks';
|
|
5
5
|
import { envGlobalsJs } from './env';
|
|
6
6
|
import { requestPermissions } from './android-helpers';
|
|
7
7
|
|
|
@@ -27,7 +27,7 @@ const PROMPT_PREFIX = '__appwrap__:';
|
|
|
27
27
|
* native-feel. Globals first so the page can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__ before its
|
|
28
28
|
* own scripts run. Built lazily (not a const) so envGlobalsJs() detects against a live activity context. */
|
|
29
29
|
function buildBootstrapJs(): string {
|
|
30
|
-
return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${NATIVE_FEEL_JS}`;
|
|
30
|
+
return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker)}\n${NATIVE_FEEL_JS}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebView, knownFolders, path as nsPath, File } from '@nativescript/core';
|
|
2
2
|
import { SHELL_CONFIG } from './config';
|
|
3
3
|
import { mimeFor } from './mime';
|
|
4
|
-
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, mediaCaptureGuardJs } from './web-quirks';
|
|
4
|
+
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, mediaCaptureGuardJs, serviceWorkerGuardJs } from './web-quirks';
|
|
5
5
|
import { envGlobalsJs } from './env';
|
|
6
6
|
import { createUiDelegate } from './ios-ui-delegate';
|
|
7
7
|
import { appwrapNativeLog } from './native-log';
|
|
@@ -55,7 +55,8 @@ export class CustomWebView extends WebView {
|
|
|
55
55
|
// __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
|
|
56
56
|
const hasPlistKey = (k: string) => !!NSBundle.mainBundle.objectForInfoDictionaryKey(k);
|
|
57
57
|
const mediaGuard = mediaCaptureGuardJs(hasPlistKey('NSCameraUsageDescription'), hasPlistKey('NSMicrophoneUsageDescription'));
|
|
58
|
-
|
|
58
|
+
const swGuard = serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker);
|
|
59
|
+
for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, swGuard, NATIVE_FEEL_JS]) {
|
|
59
60
|
const script = WKUserScript.alloc().initWithSourceInjectionTimeForMainFrameOnly(
|
|
60
61
|
src,
|
|
61
62
|
WKUserScriptInjectionTime.AtDocumentStart,
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
import { Device, Dialogs, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
|
-
import { SHELL_BUILD, reloadWebView } from './handlers';
|
|
4
|
+
import { SHELL_BUILD, reloadWebView, getReportedWebVersion } from './handlers';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Shake-to-open developer menu (enabled in prod too, gated by `SHELL_CONFIG.devMenu`).
|
|
8
8
|
* A shake raises a native action sheet → "App Info" shows non-sensitive diagnostics
|
|
9
9
|
* (ids, versions, loader, remote host) including the running webapp's version vs. the
|
|
10
10
|
* latest deployed — so you can tell at a glance whether the device got the update.
|
|
11
|
+
* The web version status is reported via the always-on `app.reportWebVersion` handler
|
|
12
|
+
* (in handlers.ts) — independent of this menu — and read here when the menu is shown.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
|
-
/** Last version info the web side reported (native-kit's updates module). */
|
|
14
|
-
let webInfo: { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean } = {};
|
|
15
|
-
|
|
16
15
|
export function startDevMenu(): void {
|
|
17
|
-
// The web side (native-kit updates) pushes its version + the latest-deployed version here.
|
|
18
|
-
bridge.register('app.reportWebVersion', (p: any) => {
|
|
19
|
-
webInfo = p || {};
|
|
20
|
-
});
|
|
21
16
|
if (isIOS) startIOSShake();
|
|
22
17
|
else if (isAndroid) startAndroidShake();
|
|
23
18
|
}
|
|
@@ -104,6 +99,7 @@ async function showInfo(): Promise<void> {
|
|
|
104
99
|
// Running web version: prefer what kit.updates reported, else read the page's embedded global
|
|
105
100
|
// directly — so the line shows for any server-loader app exposing __APP_VERSION__, even if its
|
|
106
101
|
// native-kit is too old to ship the updates module.
|
|
102
|
+
const webInfo = getReportedWebVersion();
|
|
107
103
|
const current = webInfo.current || (await readPageVersion());
|
|
108
104
|
const lines = [
|
|
109
105
|
`App: ${SHELL_CONFIG.name}`,
|
|
@@ -4,6 +4,7 @@ import { requestPermissions, startActivityForResult } from './android-helpers';
|
|
|
4
4
|
|
|
5
5
|
declare const android: any;
|
|
6
6
|
declare const cc: any; // generated from the HealthConnectBridge.kt shim (overrides/)
|
|
7
|
+
declare const CMPedometer: any; // CoreMotion (already linked via CMMotionManager in handlers-parity)
|
|
7
8
|
|
|
8
9
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
9
10
|
|
|
@@ -66,6 +67,41 @@ function registerIos(): void {
|
|
|
66
67
|
store.executeQuery(q);
|
|
67
68
|
})
|
|
68
69
|
);
|
|
70
|
+
|
|
71
|
+
// ── Live steps (CMPedometer) — low-latency "as you walk" count, distinct from the laggy HealthKit
|
|
72
|
+
// aggregate. Caller uses HealthKit as the periodic source-of-truth and these live deltas in between.
|
|
73
|
+
// Emits `health.liveStep` { steps } (today's pedometer total since local midnight). iPhone-only
|
|
74
|
+
// (no Watch), which is fine: it's the live driver; HealthKit re-anchors the authoritative total.
|
|
75
|
+
let pedometer: any = null;
|
|
76
|
+
let anchorDay = -1; // local day-of-month the current stream is anchored to (for midnight re-anchor)
|
|
77
|
+
const localDay = () => new Date().getDate();
|
|
78
|
+
const startStream = () => {
|
|
79
|
+
anchorDay = localDay();
|
|
80
|
+
pedometer.startPedometerUpdatesFromDateWithHandler(startOfToday(), (data: any, e: any) => {
|
|
81
|
+
if (e || !data) return;
|
|
82
|
+
// Midnight self-heal: CMPedometer counts since the FIXED anchor date, so after local midnight
|
|
83
|
+
// numberOfSteps still includes yesterday. When the day rolls over, restart from the new
|
|
84
|
+
// midnight (re-anchors to 0) so the live count stays "today only".
|
|
85
|
+
if (localDay() !== anchorDay) { Utils.dispatchToMainThread(() => { pedometer.stopPedometerUpdates(); startStream(); bridge.emit('health.liveStep', { steps: 0 }); }); return; }
|
|
86
|
+
const steps = Math.round(num(data.numberOfSteps));
|
|
87
|
+
Utils.dispatchToMainThread(() => bridge.emit('health.liveStep', { steps }));
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
bridge.register('health.liveSteps.start', () => {
|
|
91
|
+
if (typeof CMPedometer === 'undefined' || !CMPedometer.isStepCountingAvailable())
|
|
92
|
+
throw err('UNSUPPORTED', 'Pedometer (live step counting) unavailable on this device');
|
|
93
|
+
// Motion & Fitness denied/restricted → surface it so the caller can fall back to HealthKit instead
|
|
94
|
+
// of silently receiving no events. (2 = denied, 1 = restricted per CMAuthorizationStatus.)
|
|
95
|
+
const auth = typeof CMPedometer.authorizationStatus === 'function' ? CMPedometer.authorizationStatus() : 0;
|
|
96
|
+
if (auth === 2 || auth === 1) throw err('DENIED', 'Motion & Fitness access is off for live step counting');
|
|
97
|
+
if (pedometer) return; // already streaming
|
|
98
|
+
pedometer = CMPedometer.new();
|
|
99
|
+
startStream();
|
|
100
|
+
});
|
|
101
|
+
bridge.register('health.liveSteps.stop', () => {
|
|
102
|
+
pedometer?.stopPedometerUpdates();
|
|
103
|
+
pedometer = null;
|
|
104
|
+
});
|
|
69
105
|
}
|
|
70
106
|
|
|
71
107
|
function registerAndroid(): void {
|
|
@@ -9,7 +9,13 @@ import { buildCapabilityMap } from './capabilities.manifest';
|
|
|
9
9
|
import { ACTIVE_MODULE_NAMES } from './active-modules.generated';
|
|
10
10
|
|
|
11
11
|
/** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
|
|
12
|
-
export const SHELL_BUILD = 'updates-devmenu-
|
|
12
|
+
export const SHELL_BUILD = 'updates-devmenu-3';
|
|
13
|
+
|
|
14
|
+
/** Version status the web side (native-kit `kit.updates`) reports via `app.reportWebVersion`. */
|
|
15
|
+
export interface WebVersionInfo { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean; }
|
|
16
|
+
let lastWebVersion: WebVersionInfo = {};
|
|
17
|
+
/** Latest version status the web reported — read by the dev-menu App Info screen. */
|
|
18
|
+
export function getReportedWebVersion(): WebVersionInfo { return lastWebVersion; }
|
|
13
19
|
|
|
14
20
|
/** Register all protocol-v1 handlers. */
|
|
15
21
|
export function registerHandlers(): void {
|
|
@@ -138,6 +144,11 @@ export function registerHandlers(): void {
|
|
|
138
144
|
// Hard reload the WebView, bypassing cache — used by the update banner + dev menu.
|
|
139
145
|
bridge.register('app.reload', () => reloadWebView());
|
|
140
146
|
|
|
147
|
+
// Web → native version report (native-kit `kit.updates`). Registered ALWAYS — independent of
|
|
148
|
+
// `devMenu` — so server-loader update polling never invokes an UNSUPPORTED handler (which would
|
|
149
|
+
// warn every poll) when the dev menu is off. The dev-menu App Info screen reads it when shown.
|
|
150
|
+
bridge.register('app.reportWebVersion', (p: WebVersionInfo) => { lastWebVersion = p || {}; });
|
|
151
|
+
|
|
141
152
|
bridge.register('ui.statusBar.setStyle', ({ style }: { style: 'light' | 'dark' }) =>
|
|
142
153
|
setStatusBarStyle(style)
|
|
143
154
|
);
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* `screen.orientation.*` handlers. Raw UIInterfaceOrientationMask bit values
|
|
5
5
|
* (1 << UIInterfaceOrientation) so no ambient UIKit types are needed.
|
|
6
6
|
*/
|
|
7
|
+
import { SHELL_CONFIG } from './config';
|
|
8
|
+
|
|
7
9
|
export const ORIENTATION_MASK = {
|
|
8
10
|
portrait: 1 << 1, // 2
|
|
9
11
|
portraitUpsideDown: 1 << 2, // 4
|
|
@@ -13,7 +15,11 @@ export const ORIENTATION_MASK = {
|
|
|
13
15
|
allButUpsideDown: (1 << 1) | (1 << 3) | (1 << 4), // 26 — default, free rotation sans upside-down
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
// Init from the configured/manifest orientation (config > manifest → stamped into SHELL_CONFIG). The
|
|
19
|
+
// AppDelegate's `supportedInterfaceOrientationsForWindow` returns this mask and OVERRIDES the static
|
|
20
|
+
// Info.plist `UISupportedInterfaceOrientations` at runtime — so locking orientation requires setting
|
|
21
|
+
// the mask here, not just stamping the plist. `kit.screen.orientation.lock()` still overrides at runtime.
|
|
22
|
+
let iosMask = maskForLock((SHELL_CONFIG as { orientation?: string }).orientation || 'any');
|
|
17
23
|
|
|
18
24
|
/** The mask the AppDelegate reports to UIKit. */
|
|
19
25
|
export function iosOrientationMask(): number {
|
|
@@ -108,6 +108,27 @@ export function bindStatusBarPage(page: Page): void {
|
|
|
108
108
|
currentPage = page;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Tint the native root (the chrome behind the page — status bar / safe areas) to a CSS color. This is
|
|
113
|
+
* the SAME surface the `ui.setBackgroundColor` handler / `kit.ui.syncThemeColor()` drive at runtime; we
|
|
114
|
+
* apply the manifest/config `themeColor` through it at boot so the chrome is themed before the WebView
|
|
115
|
+
* paints (no white flash, no un-themed safe areas). No-op for an empty color (leave the page bg).
|
|
116
|
+
*/
|
|
117
|
+
export function applyThemeColor(color: string): void {
|
|
118
|
+
if (!color) return;
|
|
119
|
+
const root = Application.getRootView();
|
|
120
|
+
if (!root) return;
|
|
121
|
+
// `color` is a PWA-manifest `theme_color` (or appwrap.json) — dev free-text, NOT validated upstream.
|
|
122
|
+
// `new Color()` THROWS on a value it can't parse, and this runs at boot (onPageLoaded), so an
|
|
123
|
+
// unsupported/malformed color (e.g. an unrecognized CSS form) would abort the rest of shell init.
|
|
124
|
+
// Degrade gracefully: log + leave the default page background rather than crash the launch.
|
|
125
|
+
try {
|
|
126
|
+
root.backgroundColor = new Color(String(color));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.warn('[appwrap] invalid themeColor, leaving default:', color, (e as Error)?.message ?? e);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
/** 'light' = white icons/text (for dark backgrounds), 'dark' = black. */
|
|
112
133
|
export function setStatusBarStyle(style: 'light' | 'dark'): void {
|
|
113
134
|
if (isIOS) {
|
|
@@ -71,6 +71,54 @@ export function mediaCaptureGuardJs(camera: boolean, microphone: boolean): strin
|
|
|
71
71
|
})();`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Neutralize `navigator.serviceWorker.register` inside the native shell at document-start, so a
|
|
76
|
+
* consumer PWA doesn't have to hand-gate its own SW. Inside the shell a service worker is at best
|
|
77
|
+
* useless and at worst HARMFUL: a cache-first SW serves a stale bundle and fights the native app://
|
|
78
|
+
* scheme handler / loader:'server' remote-update detection.
|
|
79
|
+
*
|
|
80
|
+
* SEMANTICS (and why):
|
|
81
|
+
* - We patch ONLY `register` — leave `navigator.serviceWorker` (and its `.ready`/`.controller`/event
|
|
82
|
+
* surface) in place. Removing `serviceWorker` entirely would lie to feature-detection: `if
|
|
83
|
+
* ('serviceWorker' in navigator)` stays true (the SW *API* exists; this WebView genuinely supports
|
|
84
|
+
* it), but no SW ever activates because every `register` is a no-op. Well-written PWAs treat
|
|
85
|
+
* register() as best-effort.
|
|
86
|
+
* - `register()` returns a PROMISE THAT NEVER RESOLVES (and never rejects). This is the gentlest of
|
|
87
|
+
* the three options: resolving with a fake registration would hand back a lying object whose
|
|
88
|
+
* `.update()`/`.unregister()`/`.active` consumers might call; rejecting fires `.catch()` paths that
|
|
89
|
+
* many apps log as an error or retry in a loop. A pending promise means `.then(...)` simply never
|
|
90
|
+
* runs (no controller, no stale cache — the desired end state) and `.catch(...)` never fires (no
|
|
91
|
+
* uncaught error, no error spam). `navigator.serviceWorker.ready` is likewise a never-settling
|
|
92
|
+
* promise per spec until a SW is ready, so leaving it pending matches normal "no SW yet" behavior.
|
|
93
|
+
* - We ALSO best-effort `getRegistrations().then(rs => rs.forEach(r => r.unregister()))` to tear down
|
|
94
|
+
* any SW a PREVIOUS web/PWA session already installed for this origin (otherwise a pre-existing
|
|
95
|
+
* cache-first SW keeps serving stale content even though we now block new registrations).
|
|
96
|
+
* - Touches `navigator.serviceWorker` ONLY. `Worker` / `SharedWorker` are deliberately untouched —
|
|
97
|
+
* compute-offload workers legitimately run in a WebView.
|
|
98
|
+
*
|
|
99
|
+
* NOTE: neutralizing the SW also disables WEB push (web push needs a SW). That's expected and fine —
|
|
100
|
+
* native push is the `remote-push` lane (APNs/FCM), and the push-prompt UI already gates on
|
|
101
|
+
* `kit.is.native`. No push code needs to change.
|
|
102
|
+
*
|
|
103
|
+
* `enabled` = the resolved neutralize flag (SHELL_CONFIG.neutralizeServiceWorker, default true in
|
|
104
|
+
* native; set false via appwrap config to opt out and leave the SW fully intact). Idempotent via a
|
|
105
|
+
* window guard so double-injection (e.g. Android's onPageStarted fallback) is a no-op.
|
|
106
|
+
*/
|
|
107
|
+
export function serviceWorkerGuardJs(enabled: boolean): string {
|
|
108
|
+
if (!enabled) return '';
|
|
109
|
+
return `(function(){
|
|
110
|
+
var sw = navigator.serviceWorker;
|
|
111
|
+
if (!sw || !sw.register || window.__appwrapSwGuard) return;
|
|
112
|
+
window.__appwrapSwGuard = true;
|
|
113
|
+
// Tear down any SW a prior web session installed for this origin (stops it serving a stale cache).
|
|
114
|
+
try { sw.getRegistrations && sw.getRegistrations().then(function(rs){ rs.forEach(function(r){ try { r.unregister(); } catch (e) {} }); }).catch(function(){}); } catch (e) {}
|
|
115
|
+
// register() → a promise that never settles: .then() never runs (no SW activates) and .catch()
|
|
116
|
+
// never fires (no error spam / retry loops). Feature-detection ('serviceWorker' in navigator) and
|
|
117
|
+
// .ready stay truthful — the API exists, nothing ever becomes ready.
|
|
118
|
+
sw.register = function(){ return new Promise(function(){}); };
|
|
119
|
+
})();`;
|
|
120
|
+
}
|
|
121
|
+
|
|
74
122
|
/**
|
|
75
123
|
* Native-feel injection shared by both CustomWebViews. Suppresses the "it's a
|
|
76
124
|
* web page" tells: pinch / double-tap zoom, long-press callout & selection,
|
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,13 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
|
|
|
20
20
|
// Config shape lives in its own import-safe module so a `appwrap.config.ts` file can import the
|
|
21
21
|
// type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
|
|
22
22
|
import type { AppwrapConfig } from './config';
|
|
23
|
+
import {
|
|
24
|
+
androidScreenOrientation,
|
|
25
|
+
iosOrientations,
|
|
26
|
+
mergeManifest,
|
|
27
|
+
stampAndroidOrientation,
|
|
28
|
+
stampPlistOrientations,
|
|
29
|
+
} from './derive';
|
|
23
30
|
|
|
24
31
|
/** Marketing version → a monotonic integer build (0.2.1 → 201; 1.4.12 → 10412). Stable & increasing
|
|
25
32
|
* across semver bumps so store re-uploads are always accepted without a manual bump. */
|
|
@@ -323,14 +330,8 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
|
|
|
323
330
|
const cfg = await readConfigFile(configPath);
|
|
324
331
|
|
|
325
332
|
// Manifest as source: the appwrap config wins, the PWA manifest fills the gaps, template default last.
|
|
326
|
-
// (DRY single-source — devs don't re-type identity already declared in the manifest.)
|
|
327
|
-
if (cfg.pwaDist)
|
|
328
|
-
const mf = loadManifest(cwd, cfg);
|
|
329
|
-
if (mf) {
|
|
330
|
-
cfg.name ??= mf.name || mf.short_name;
|
|
331
|
-
cfg.backgroundColor ??= mf.background_color;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
333
|
+
// (DRY single-source — devs don't re-type identity already declared in the manifest.) See mergeManifest.
|
|
334
|
+
if (cfg.pwaDist) mergeManifest(cfg, loadManifest(cwd, cfg));
|
|
334
335
|
|
|
335
336
|
for (const key of ['id', 'name', 'version', 'pwaDist'] as const) {
|
|
336
337
|
if (!cfg[key]) {
|
|
@@ -351,7 +352,9 @@ export const SHELL_CONFIG = {
|
|
|
351
352
|
version: ${JSON.stringify(cfg.version)},
|
|
352
353
|
entry: ${JSON.stringify(cfg.entry ?? 'index.html')},
|
|
353
354
|
backgroundColor: ${JSON.stringify(cfg.backgroundColor ?? '#ffffff')},
|
|
355
|
+
themeColor: ${JSON.stringify(cfg.themeColor ?? '')},
|
|
354
356
|
statusBarStyle: ${JSON.stringify(cfg.statusBarStyle ?? 'dark')} as 'light' | 'dark',
|
|
357
|
+
orientation: ${JSON.stringify(cfg.orientation ?? '')} as '' | 'portrait' | 'landscape' | 'any',
|
|
355
358
|
edgeToEdge: ${JSON.stringify(cfg.edgeToEdge ?? false)},
|
|
356
359
|
loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
|
|
357
360
|
serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
|
|
@@ -359,6 +362,7 @@ export const SHELL_CONFIG = {
|
|
|
359
362
|
debug: ${JSON.stringify(cfg.debug ?? false)},
|
|
360
363
|
debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
|
|
361
364
|
devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
|
|
365
|
+
neutralizeServiceWorker: ${JSON.stringify(cfg.neutralizeServiceWorker ?? true)},
|
|
362
366
|
pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
|
|
363
367
|
pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
|
|
364
368
|
};
|
|
@@ -385,6 +389,10 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
385
389
|
stamp('CFBundleShortVersionString', cfg.version); // marketing version (user-facing)
|
|
386
390
|
stamp('CFBundleVersion', String(buildNumberOf(cfg))); // monotonic build — store re-uploads need it higher
|
|
387
391
|
|
|
392
|
+
// Supported orientation (config > manifest) — rewrites both UISupportedInterfaceOrientations
|
|
393
|
+
// arrays (iPhone + ~ipad). Skipped when unset → keep the template's free-rotation default.
|
|
394
|
+
if (cfg.orientation) src = stampPlistOrientations(src, iosOrientations(cfg.orientation));
|
|
395
|
+
|
|
388
396
|
// Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
|
|
389
397
|
src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
|
|
390
398
|
const extras: string[] = [];
|
|
@@ -609,6 +617,9 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
609
617
|
if (existsSync(manifest)) {
|
|
610
618
|
let src = readFileSync(manifest, 'utf8');
|
|
611
619
|
if (cfg.urlScheme) src = src.replace(/android:scheme="[^"]*"/, `android:scheme="${cfg.urlScheme}"`);
|
|
620
|
+
// Supported orientation (config > manifest) on the main <activity>. Skipped when unset → keep
|
|
621
|
+
// the template default (free); 'any' removes the attribute, so re-sync stays idempotent.
|
|
622
|
+
if (cfg.orientation) src = stampAndroidOrientation(src, androidScreenOrientation(cfg.orientation));
|
|
612
623
|
// Permissions — idempotent: rewrite the marker block from the active modules (deduped).
|
|
613
624
|
const perms = req.androidPerms.map((p) => `\t<uses-permission android:name="${p}"/>`);
|
|
614
625
|
src = src.replace(
|
|
@@ -1145,7 +1156,13 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
1145
1156
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
1146
1157
|
try {
|
|
1147
1158
|
// Capture (not inherit) so we can recognize specific failures; echo it for visibility.
|
|
1148
|
-
|
|
1159
|
+
// Wrapped so a LOCKED device waits-and-retries instead of hard-failing (the common annoyance).
|
|
1160
|
+
// --timeout: devicectl has no first-class "wait for unlock", but its overall-timeout lets a single
|
|
1161
|
+
// attempt tolerate a brief locked/unavailable window before erroring; the outer retry covers the
|
|
1162
|
+
// fail-fast case + the unlock prompt. (Verified against `devicectl --help`; community wraps it too.)
|
|
1163
|
+
const out = withUnlockRetry('Install', () =>
|
|
1164
|
+
execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', '25', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
|
|
1165
|
+
);
|
|
1149
1166
|
process.stdout.write(out);
|
|
1150
1167
|
} catch (e: any) {
|
|
1151
1168
|
const log = `${e?.stdout ?? ''}${e?.stderr ?? ''}`;
|
|
@@ -1162,7 +1179,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
1162
1179
|
);
|
|
1163
1180
|
} else {
|
|
1164
1181
|
console.error(
|
|
1165
|
-
'✖ Install failed
|
|
1182
|
+
'✖ Install failed (device still locked after waiting, or only on Wi-Fi).\n' +
|
|
1166
1183
|
' → Unlock the phone (and plug in USB for a reliable connection), then re-run.\n' +
|
|
1167
1184
|
` The built .ipa is ready: ${ipaPath}`
|
|
1168
1185
|
);
|
|
@@ -1173,14 +1190,35 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
1173
1190
|
if (!('no-launch' in flags)) {
|
|
1174
1191
|
console.log(`▶ launching ${cfg.id}`);
|
|
1175
1192
|
try {
|
|
1176
|
-
|
|
1193
|
+
withUnlockRetry('Launch', () =>
|
|
1194
|
+
execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--timeout', '25', '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
|
|
1195
|
+
);
|
|
1177
1196
|
} catch {
|
|
1178
|
-
console.error('⚠ Launch failed (
|
|
1197
|
+
console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
|
|
1179
1198
|
}
|
|
1180
1199
|
}
|
|
1181
1200
|
console.log(`✓ Deployed to ${device.name}.`);
|
|
1182
1201
|
}
|
|
1183
1202
|
|
|
1203
|
+
/** Run a devicectl op; if it fails because the device is LOCKED (or transiently unavailable), prompt
|
|
1204
|
+
* once and poll-retry until it succeeds or the budget runs out — instead of hard-failing. A free-team
|
|
1205
|
+
* 3-app-limit error is NOT a lock, so it's re-thrown immediately for the caller's specific handling.
|
|
1206
|
+
* (We can't auto-unlock — that needs the passcode by design — but we can wait gracefully.) */
|
|
1207
|
+
function withUnlockRetry<T>(label: string, run: () => T, tries = 40, delayMs = 3000): T {
|
|
1208
|
+
for (let i = 0; ; i++) {
|
|
1209
|
+
try {
|
|
1210
|
+
return run();
|
|
1211
|
+
} catch (e: any) {
|
|
1212
|
+
const log = `${e?.stdout ?? ''}${e?.stderr ?? ''}`;
|
|
1213
|
+
if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) throw e;
|
|
1214
|
+
if (i >= tries) throw e;
|
|
1215
|
+
if (i === 0) process.stdout.write(`\n🔒 ${label}: device unavailable — unlock your iPhone. Waiting (auto-retries every ${delayMs / 1000}s, up to ${Math.round((tries * delayMs) / 1000)}s)…\n`);
|
|
1216
|
+
else process.stdout.write(` …waiting for unlock (${i}/${tries})\n`);
|
|
1217
|
+
try { execFileSync('sleep', [String(delayMs / 1000)], { stdio: 'ignore' }); } catch { /* sleep interrupted */ }
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1184
1222
|
/** First connected libimobiledevice UDID (USB, then network). Distinct from devicectl's identifier. */
|
|
1185
1223
|
function libimobiledeviceUdid(): { udid: string; network: boolean } | null {
|
|
1186
1224
|
for (const [args, network] of [[['-l'], false], [['-n'], true]] as const) {
|
package/src/config.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface AppwrapConfig {
|
|
|
24
24
|
entry?: string;
|
|
25
25
|
backgroundColor?: string;
|
|
26
26
|
statusBarStyle?: 'light' | 'dark';
|
|
27
|
+
/** Boot-time native chrome color (status bar / safe areas behind the page). Falls back to the PWA
|
|
28
|
+
* manifest's `theme_color` when absent. The shell tints the native root with it at launch (the
|
|
29
|
+
* same surface `kit.ui.syncThemeColor()` keeps in sync with `<meta name="theme-color">` at runtime)
|
|
30
|
+
* — distinct from `backgroundColor`, which paints the page/splash. CSS color string (e.g. `#0b1020`). */
|
|
31
|
+
themeColor?: string;
|
|
32
|
+
/** Supported device orientation. Falls back to the PWA manifest's `orientation` when absent
|
|
33
|
+
* (`*-primary`/`*-secondary` variants normalize to the axis). `portrait` / `landscape` lock the
|
|
34
|
+
* axis; `any` (default) leaves rotation free (sans upside-down on iOS). Stamped into iOS
|
|
35
|
+
* `UISupportedInterfaceOrientations` (+ `~ipad`) and Android `android:screenOrientation`. */
|
|
36
|
+
orientation?: 'portrait' | 'landscape' | 'any';
|
|
27
37
|
/** Android only (experimental). When true, the WebView draws genuinely edge-to-edge UNDER the
|
|
28
38
|
* transparent system bars (NS `androidOverflowEdge='dont-apply'`) and the real safe-area insets
|
|
29
39
|
* are injected as `--saie-*` CSS vars + native `env(safe-area-inset-*)`, so a multi-theme PWA
|
|
@@ -61,6 +71,14 @@ export interface AppwrapConfig {
|
|
|
61
71
|
* it only exposes non-sensitive diagnostics (ids, versions, loader, remote host). Set `false` to
|
|
62
72
|
* disable. Remote-update detection (native-kit `kit.updates`) is independent of this flag. */
|
|
63
73
|
devMenu?: boolean;
|
|
74
|
+
/** Neutralize `navigator.serviceWorker.register` in the native shell so the consumer PWA doesn't
|
|
75
|
+
* have to gate its own SW. Inside the shell a service worker is useless-to-harmful: a cache-first SW
|
|
76
|
+
* serves a stale bundle and fights the native app:// scheme handler / loader:'server' remote-update
|
|
77
|
+
* detection. Default `true` (only affects the native build — the same web build is untouched).
|
|
78
|
+
* Set `false` to opt out and leave the SW fully intact — e.g. if the PWA intentionally wants its SW
|
|
79
|
+
* for in-WebView web-push as a fallback. NOTE: keeping the SW is the only way to get web push; native
|
|
80
|
+
* push is the separate `push` lane (APNs/FCM) and does NOT need a SW. */
|
|
81
|
+
neutralizeServiceWorker?: boolean;
|
|
64
82
|
/** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
|
|
65
83
|
teamId?: string;
|
|
66
84
|
/** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP
|
package/src/derive.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure manifest-derivation + native-stamp transforms — kept side-effect-free (no fs, no config I/O)
|
|
3
|
+
* so they unit-test against fixture strings, same standard as the urlScheme stamper. The CLI wires
|
|
4
|
+
* these into `loadConfig` (derivation) + the iOS/Android stampers (transforms).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Normalized appwrap orientation — the three lock states the native shells can express. */
|
|
8
|
+
export type Orientation = 'portrait' | 'landscape' | 'any';
|
|
9
|
+
|
|
10
|
+
/** The PWA web-manifest fields appwrap derives from (a subset; everything optional). */
|
|
11
|
+
export interface WebManifest {
|
|
12
|
+
name?: string;
|
|
13
|
+
short_name?: string;
|
|
14
|
+
background_color?: string;
|
|
15
|
+
theme_color?: string;
|
|
16
|
+
orientation?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* "Manifest as source" merge: explicit appwrap-config values WIN, the PWA manifest fills the gaps,
|
|
21
|
+
* the template default lands last (downstream `?? default` in the stampers). Mutates + returns `cfg`
|
|
22
|
+
* — the precedence is `??` (only an `undefined` field is filled), so this is purely additive and
|
|
23
|
+
* never overrides a value the dev wrote. Pure (no fs): the CLI loads the manifest, this merges it.
|
|
24
|
+
*/
|
|
25
|
+
export function mergeManifest<
|
|
26
|
+
T extends {
|
|
27
|
+
name?: string;
|
|
28
|
+
backgroundColor?: string;
|
|
29
|
+
themeColor?: string;
|
|
30
|
+
orientation?: Orientation;
|
|
31
|
+
}
|
|
32
|
+
>(cfg: T, mf: WebManifest | null | undefined): T {
|
|
33
|
+
if (!mf) return cfg;
|
|
34
|
+
cfg.name ??= mf.name || mf.short_name;
|
|
35
|
+
cfg.backgroundColor ??= mf.background_color;
|
|
36
|
+
cfg.themeColor ??= mf.theme_color;
|
|
37
|
+
// normalize the manifest's portrait-primary / landscape-secondary / … to our axis lock.
|
|
38
|
+
if (cfg.orientation === undefined && mf.orientation) cfg.orientation = normalizeOrientation(mf.orientation);
|
|
39
|
+
return cfg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collapse a PWA web-manifest `orientation` value to our three states. The manifest spec allows
|
|
44
|
+
* `portrait`/`landscape` plus the `-primary`/`-secondary`/`*-up`/`natural` variants — we don't model
|
|
45
|
+
* a single-edge lock at the app level (a PWA wrapper either constrains an axis or leaves it free), so
|
|
46
|
+
* any portrait* → portrait, any landscape* → landscape, everything else (incl. `any`/absent) → any.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeOrientation(raw: string | undefined | null): Orientation {
|
|
49
|
+
const v = (raw ?? '').trim().toLowerCase();
|
|
50
|
+
if (v.startsWith('portrait')) return 'portrait';
|
|
51
|
+
if (v.startsWith('landscape')) return 'landscape';
|
|
52
|
+
return 'any'; // 'any' | 'natural' | '' | unknown → don't over-constrain
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** iOS `UISupportedInterfaceOrientations` array members for a normalized orientation. */
|
|
56
|
+
export function iosOrientations(o: Orientation): string[] {
|
|
57
|
+
if (o === 'portrait') return ['UIInterfaceOrientationPortrait'];
|
|
58
|
+
if (o === 'landscape')
|
|
59
|
+
return ['UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'];
|
|
60
|
+
// 'any' → free rotation sans upside-down (matches the orientation.ts ALL_BUT_UPSIDE_DOWN mask).
|
|
61
|
+
return [
|
|
62
|
+
'UIInterfaceOrientationPortrait',
|
|
63
|
+
'UIInterfaceOrientationLandscapeLeft',
|
|
64
|
+
'UIInterfaceOrientationLandscapeRight',
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Android `android:screenOrientation` value for a normalized orientation. */
|
|
69
|
+
export function androidScreenOrientation(o: Orientation): string {
|
|
70
|
+
if (o === 'portrait') return 'portrait';
|
|
71
|
+
if (o === 'landscape') return 'landscape';
|
|
72
|
+
return 'unspecified'; // system free rotation
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rewrite EVERY `UISupportedInterfaceOrientations` (+ the `~ipad` variant the template also sets)
|
|
77
|
+
* array body in an Info.plist string to the given members. Pure string transform — operates on the
|
|
78
|
+
* source it's handed, returns the new source. Keyed by `<key>` name so it touches only those arrays.
|
|
79
|
+
*/
|
|
80
|
+
export function stampPlistOrientations(src: string, orientations: string[]): string {
|
|
81
|
+
const body = orientations.map((o) => `\t\t<string>${o}</string>`).join('\n');
|
|
82
|
+
return src.replace(
|
|
83
|
+
/(<key>UISupportedInterfaceOrientations(?:~ipad)?<\/key>\s*<array>)[\s\S]*?(<\/array>)/g,
|
|
84
|
+
(_m, open, close) => `${open}\n${body}\n\t${close}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Set (or remove) `android:screenOrientation` on the FIRST (main/launcher) `<activity …>` opening tag
|
|
90
|
+
* in an AndroidManifest.xml string. `unspecified` removes the attribute (system default = free).
|
|
91
|
+
* Pure + idempotent: an existing attribute is replaced, otherwise injected after `android:name`.
|
|
92
|
+
*/
|
|
93
|
+
export function stampAndroidOrientation(src: string, value: string): string {
|
|
94
|
+
return src.replace(/<activity\b[^>]*>/, (tag) => {
|
|
95
|
+
const stripped = tag.replace(/\s*android:screenOrientation="[^"]*"/, '');
|
|
96
|
+
if (value === 'unspecified') return stripped;
|
|
97
|
+
return stripped.replace(/(android:name="[^"]*")/, `$1\n\t\t\tandroid:screenOrientation="${value}"`);
|
|
98
|
+
});
|
|
99
|
+
}
|