@livx.cc/appwrap 0.20.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.20.4",
3
+ "version": "0.23.1",
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",
@@ -11,8 +11,9 @@ import { registerAndroidHandlers } from './shell/handlers-android';
11
11
  import { registerOptionalHandlers } from './shell/optional-handlers.generated';
12
12
  import './shell/fcm-bootstrap.generated'; // side-effect: registers the FCM service when push is wired
13
13
  import { startEventForwarding } from './shell/events';
14
+ import { startDevMenu } from './shell/devmenu';
14
15
  import { SHELL_CONFIG } from './shell/config';
15
- import { bindStatusBarPage, setStatusBarStyle, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
16
+ import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
16
17
  import { CustomWebView } from './shell/custom-webview';
17
18
 
18
19
  let initialized = false;
@@ -22,6 +23,7 @@ export function onPageLoaded(args: EventData): void {
22
23
  page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor };
23
24
  bindStatusBarPage(page);
24
25
  if (isAndroid) enableAndroidEdgeToEdge();
26
+ applyThemeColor(SHELL_CONFIG.themeColor); // manifest/config theme_color → native chrome at boot
25
27
  setStatusBarStyle(SHELL_CONFIG.statusBarStyle);
26
28
 
27
29
  if (initialized) return;
@@ -44,6 +46,8 @@ export function onPageLoaded(args: EventData): void {
44
46
  if (isAndroid) registerAndroidHandlers();
45
47
  // Opt-in modules that own their own handler file (health, …) — generated to only the active set.
46
48
  registerOptionalHandlers();
49
+ // Shake-to-open developer menu (App Info / Reload). On by default, incl. prod.
50
+ if (SHELL_CONFIG.devMenu) startDevMenu();
47
51
 
48
52
  const webView = page.getViewById<CustomWebView>('webview');
49
53
  bridge.attach(webView);
@@ -0,0 +1,149 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ /**
5
+ * Persistent bottom banner (vs. the auto-dismiss `toast`). Used for the "new version available —
6
+ * tap to reload" update prompt. **Tap** emits `toast.action` { id } (the web side decides what to
7
+ * do) and dismisses; **swipe down** dismisses without acting.
8
+ */
9
+
10
+ let currentId: string | null = null;
11
+ let iosBanner: UIView | null = null;
12
+ let iosTapHandler: any = null;
13
+ let androidBanner: android.view.View | null = null;
14
+
15
+ function onTap(): void {
16
+ const id = currentId;
17
+ dismissBanner();
18
+ if (id) bridge.emit('toast.action', { id });
19
+ }
20
+
21
+ export function showBanner(opts: { id: string; message: string }): void {
22
+ dismissBanner(); // only one at a time
23
+ currentId = opts.id;
24
+ if (isIOS) Utils.dispatchToMainThread(() => showIOSBanner(opts.message));
25
+ else if (isAndroid) runOnAndroidUi(() => showAndroidBanner(opts.message));
26
+ }
27
+
28
+ export function dismissBanner(): void {
29
+ currentId = null;
30
+ if (isIOS && iosBanner) {
31
+ const b = iosBanner;
32
+ iosBanner = null;
33
+ iosTapHandler = null;
34
+ Utils.dispatchToMainThread(() => b.removeFromSuperview());
35
+ }
36
+ if (isAndroid && androidBanner) {
37
+ const v = androidBanner;
38
+ androidBanner = null;
39
+ runOnAndroidUi(() => (v.getParent() as android.view.ViewGroup)?.removeView(v));
40
+ }
41
+ }
42
+
43
+ // ── iOS ──────────────────────────────────────────────────────────────
44
+ // Built lazily (and only on iOS) — `NSObject`/`interop` don't exist on Android, and this file is
45
+ // imported on both platforms via handlers.ts, so a top-level `.extend` would crash the Android shell.
46
+ let IOSGestureHandler: any = null;
47
+ function iosGestureHandlerClass(): any {
48
+ if (!IOSGestureHandler) {
49
+ IOSGestureHandler = (NSObject as any).extend(
50
+ { bannerTapped() { onTap(); }, bannerDismissed() { dismissBanner(); } },
51
+ {
52
+ exposedMethods: {
53
+ bannerTapped: { returns: interop.types.void },
54
+ bannerDismissed: { returns: interop.types.void },
55
+ },
56
+ }
57
+ );
58
+ }
59
+ return IOSGestureHandler;
60
+ }
61
+
62
+ function showIOSBanner(message: string): void {
63
+ const rootVC = Utils.ios.getRootViewController();
64
+ if (!rootVC?.view) return;
65
+ const screen = UIScreen.mainScreen.bounds;
66
+ const safeBottom = rootVC.view.safeAreaInsets ? rootVC.view.safeAreaInsets.bottom : 0;
67
+ const width = screen.size.width - 24;
68
+
69
+ const container = UIView.alloc().initWithFrame(CGRectMake(12, 0, width, 52));
70
+ container.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0.08, 0.08, 0.1, 0.85);
71
+ container.layer.cornerRadius = 12;
72
+ container.clipsToBounds = true;
73
+ container.userInteractionEnabled = true;
74
+
75
+ const blur = UIVisualEffectView.alloc().initWithEffect(UIBlurEffect.effectWithStyle(UIBlurEffectStyle.Dark));
76
+ blur.frame = container.bounds;
77
+ blur.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
78
+ blur.userInteractionEnabled = false;
79
+ container.addSubview(blur);
80
+
81
+ const label = UILabel.alloc().initWithFrame(CGRectMake(16, 0, width - 32, 52));
82
+ label.text = message;
83
+ label.textColor = UIColor.whiteColor;
84
+ label.textAlignment = NSTextAlignment.Center;
85
+ label.font = UIFont.systemFontOfSizeWeight(15, UIFontWeightSemibold);
86
+ blur.contentView.addSubview(label);
87
+
88
+ iosTapHandler = iosGestureHandlerClass().alloc().init();
89
+ container.addGestureRecognizer(UITapGestureRecognizer.alloc().initWithTargetAction(iosTapHandler, 'bannerTapped'));
90
+ const swipe = UISwipeGestureRecognizer.alloc().initWithTargetAction(iosTapHandler, 'bannerDismissed');
91
+ swipe.direction = UISwipeGestureRecognizerDirection.Down;
92
+ container.addGestureRecognizer(swipe);
93
+
94
+ container.center = CGPointMake(screen.size.width / 2, screen.size.height - safeBottom - 38);
95
+ container.alpha = 0;
96
+ rootVC.view.addSubview(container);
97
+ iosBanner = container;
98
+ UIView.animateWithDurationAnimations(0.3, () => (container.alpha = 1));
99
+ }
100
+
101
+ // ── Android ──────────────────────────────────────────────────────────
102
+ function showAndroidBanner(message: string): void {
103
+ const activity = Application.android.foregroundActivity || Application.android.startActivity;
104
+ if (!activity) return;
105
+ const density = activity.getResources().getDisplayMetrics().density;
106
+ const pad = Math.round(16 * density);
107
+
108
+ const tv = new android.widget.TextView(activity);
109
+ tv.setText(message);
110
+ tv.setTextColor(android.graphics.Color.WHITE);
111
+ tv.setTextSize(15);
112
+ tv.setPadding(pad, pad, pad, pad);
113
+ tv.setGravity(android.view.Gravity.CENTER);
114
+ tv.setBackgroundColor(android.graphics.Color.argb(230, 20, 20, 26));
115
+
116
+ // Distinguish a tap (→ reload) from a downward swipe (→ dismiss) on one touch listener.
117
+ const SWIPE = 40 * density;
118
+ let downX = 0, downY = 0, downT = 0;
119
+ tv.setOnTouchListener(new android.view.View.OnTouchListener({
120
+ onTouch(_v: any, e: any) {
121
+ switch (e.getActionMasked()) {
122
+ case android.view.MotionEvent.ACTION_DOWN:
123
+ downX = e.getRawX(); downY = e.getRawY(); downT = e.getEventTime();
124
+ return true;
125
+ case android.view.MotionEvent.ACTION_UP: {
126
+ const dy = e.getRawY() - downY;
127
+ const dx = Math.abs(e.getRawX() - downX);
128
+ const dt = e.getEventTime() - downT;
129
+ if (dy > SWIPE) dismissBanner(); // swipe down → dismiss
130
+ else if (Math.abs(dy) < SWIPE && dx < SWIPE && dt < 400) onTap(); // tap → reload
131
+ return true;
132
+ }
133
+ default:
134
+ return true;
135
+ }
136
+ },
137
+ }));
138
+
139
+ const lp = new android.widget.FrameLayout.LayoutParams(-1, -2); // MATCH_PARENT × WRAP_CONTENT
140
+ lp.gravity = android.view.Gravity.BOTTOM;
141
+ activity.addContentView(tv, lp);
142
+ androidBanner = tv;
143
+ }
144
+
145
+ function runOnAndroidUi(fn: () => void): void {
146
+ const activity = Application.android.foregroundActivity || Application.android.startActivity;
147
+ if (activity) activity.runOnUiThread(new java.lang.Runnable({ run: fn }));
148
+ else fn();
149
+ }
@@ -39,6 +39,11 @@ export class Bridge {
39
39
  this.webView = null;
40
40
  }
41
41
 
42
+ /** The attached WebView (for handlers that drive it directly, e.g. app.reload). */
43
+ getWebView(): CustomWebView | null {
44
+ return this.webView;
45
+ }
46
+
42
47
  emit(event: string, payload?: unknown): void {
43
48
  this.deliver(JSON.stringify({ v: 1, kind: 'event', event, payload }));
44
49
  }
@@ -75,7 +80,8 @@ export class Bridge {
75
80
  this.evalJs(js).catch((e) => console.error('Bridge: deliver failed', e));
76
81
  }
77
82
 
78
- private evalJs(script: string): Promise<any> {
83
+ /** Evaluate JS in the WebView and resolve its value (used by deliver + the dev-menu version probe). */
84
+ evalJs(script: string): Promise<any> {
79
85
  const wv = this.webView;
80
86
  return new Promise((resolve, reject) => {
81
87
  if (!wv) return reject(new Error('no webview'));
@@ -68,7 +68,7 @@ export const MODULES: ModuleManifest[] = [
68
68
  { name: 'haptics', core: true, group: 'core', capabilities: { haptics: 'native' } },
69
69
  { name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' } },
70
70
  { name: 'storage', core: true, group: 'core', capabilities: { storage: 'native', secureStorage: 'native' } },
71
- { name: 'toast', core: true, group: 'core', capabilities: { toast: 'native' } },
71
+ { name: 'toast', core: true, group: 'core', capabilities: { toast: 'native', banner: 'native', updates: 'native' } },
72
72
  { name: 'statusBar', core: true, group: 'core', capabilities: { statusBar: 'native', themeColor: 'native' } },
73
73
  { name: 'device', core: true, group: 'extended', capabilities: { device: 'native' } },
74
74
  { name: 'clipboard', core: true, group: 'extended', capabilities: { clipboard: 'native' } },
@@ -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,
@@ -27,6 +34,12 @@ export const SHELL_CONFIG = {
27
34
  debug: false,
28
35
  /** Value written to `localStorage.DEBUG` in debug mode so the PWA logger goes verbose ('*' = all). */
29
36
  debugLog: '*',
37
+ /** Shake-to-open developer menu (App Info / Reload). On by default, including store builds. */
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,
30
43
  /** Remote push configured, per platform (iOS aps-environment entitlement / Android FCM). Drives the
31
44
  * `push` capability flag at runtime by platform — off unless `appwrap.json.push` enables it, so an
32
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
- for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, NATIVE_FEEL_JS]) {
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,
@@ -0,0 +1,140 @@
1
+ import { Device, Dialogs, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { SHELL_CONFIG } from './config';
4
+ import { SHELL_BUILD, reloadWebView, getReportedWebVersion } from './handlers';
5
+
6
+ /**
7
+ * Shake-to-open developer menu (enabled in prod too, gated by `SHELL_CONFIG.devMenu`).
8
+ * A shake raises a native action sheet → "App Info" shows non-sensitive diagnostics
9
+ * (ids, versions, loader, remote host) including the running webapp's version vs. the
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.
13
+ */
14
+
15
+ export function startDevMenu(): void {
16
+ if (isIOS) startIOSShake();
17
+ else if (isAndroid) startAndroidShake();
18
+ }
19
+
20
+ // ── shake detection (total acceleration magnitude in g incl. gravity, debounced) ──
21
+ const SHAKE_G = 1.8; // spike vs. ~1g rest
22
+ let shakeMm: any = null; // hold a ref so CoreMotion isn't GC'd
23
+ let firstSpikeAt = 0;
24
+ let spikes = 0;
25
+ let lastMenuAt = 0;
26
+
27
+ function onAccelMagnitude(g: number): void {
28
+ if (g < SHAKE_G) return;
29
+ const now = Date.now();
30
+ if (now - lastMenuAt < 1500) return; // don't re-open right after a menu
31
+ if (now - firstSpikeAt > 800) {
32
+ firstSpikeAt = now; // start a fresh window
33
+ spikes = 1;
34
+ return;
35
+ }
36
+ if (++spikes >= 2) {
37
+ spikes = 0;
38
+ firstSpikeAt = 0;
39
+ lastMenuAt = now;
40
+ void showDevMenu();
41
+ }
42
+ }
43
+
44
+ function startIOSShake(): void {
45
+ // Poll `mm.deviceMotion` on a JS timer — CoreMotion's queue-handler block is fragile under
46
+ // NativeScript (can silently stop firing on-device); mirrors the motion.start handler.
47
+ const mm = CMMotionManager.new();
48
+ if (!mm.deviceMotionAvailable) return; // no sensors (simulator)
49
+ shakeMm = mm;
50
+ mm.deviceMotionUpdateInterval = 0.1;
51
+ mm.startDeviceMotionUpdates();
52
+ setInterval(() => {
53
+ const m = mm.deviceMotion;
54
+ if (!m) return; // first sample not ready
55
+ const x = m.userAcceleration.x + m.gravity.x; // total accel in g (rest ≈ 1)
56
+ const y = m.userAcceleration.y + m.gravity.y;
57
+ const z = m.userAcceleration.z + m.gravity.z;
58
+ onAccelMagnitude(Math.sqrt(x * x + y * y + z * z));
59
+ }, 100);
60
+ }
61
+
62
+ function startAndroidShake(): void {
63
+ const sm = Utils.android
64
+ .getApplicationContext()
65
+ .getSystemService(android.content.Context.SENSOR_SERVICE) as android.hardware.SensorManager;
66
+ const accel = sm.getDefaultSensor(android.hardware.Sensor.TYPE_ACCELEROMETER);
67
+ if (!accel) return;
68
+ const G = 9.80665;
69
+ const listener = new android.hardware.SensorEventListener({
70
+ onAccuracyChanged() {},
71
+ onSensorChanged(e: any) {
72
+ const v = e.values;
73
+ onAccelMagnitude(Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) / G);
74
+ },
75
+ });
76
+ sm.registerListener(listener, accel, android.hardware.SensorManager.SENSOR_DELAY_UI);
77
+ }
78
+
79
+ // ── menu + info ──────────────────────────────────────────────────────
80
+ let menuOpen = false;
81
+ async function showDevMenu(): Promise<void> {
82
+ if (menuOpen) return; // sensor keeps firing while the sheet is up — don't stack dialogs
83
+ menuOpen = true;
84
+ try {
85
+ const action = await Dialogs.action({
86
+ title: SHELL_CONFIG.name,
87
+ message: 'Developer menu',
88
+ cancelButtonText: 'Cancel',
89
+ actions: ['App Info', 'Reload'],
90
+ });
91
+ if (action === 'App Info') await showInfo();
92
+ else if (action === 'Reload') reloadWebView();
93
+ } finally {
94
+ menuOpen = false;
95
+ }
96
+ }
97
+
98
+ async function showInfo(): Promise<void> {
99
+ // Running web version: prefer what kit.updates reported, else read the page's embedded global
100
+ // directly — so the line shows for any server-loader app exposing __APP_VERSION__, even if its
101
+ // native-kit is too old to ship the updates module.
102
+ const webInfo = getReportedWebVersion();
103
+ const current = webInfo.current || (await readPageVersion());
104
+ const lines = [
105
+ `App: ${SHELL_CONFIG.name}`,
106
+ `ID: ${SHELL_CONFIG.appId}`,
107
+ `Shell: ${SHELL_CONFIG.version} (${SHELL_BUILD})`,
108
+ `Platform: ${isIOS ? 'iOS' : 'Android'} ${Device.osVersion}`,
109
+ `Loader: ${SHELL_CONFIG.loader}`,
110
+ ];
111
+ if (SHELL_CONFIG.loader === 'server') lines.push(`Remote: ${safeHost(SHELL_CONFIG.serverUrl)}`);
112
+
113
+ const build = webInfo.build ? ` · build ${webInfo.build}` : '';
114
+ lines.push(`Web version: ${current || 'unknown'}${build}`);
115
+ if (webInfo.latest && current && webInfo.latest !== current) lines.push(`⚠️ Update available: ${webInfo.latest} — Reload to apply`);
116
+ else if (webInfo.latest) lines.push('✓ Up to date');
117
+
118
+ Dialogs.alert({ title: 'App Info', message: lines.join('\n'), okButtonText: 'Close' });
119
+ }
120
+
121
+ /** Read the running page's embedded version directly from the WebView: `window.__APP_VERSION__`,
122
+ * then a `<meta name="app-version">` fallback. '' if neither is present (or eval fails). */
123
+ async function readPageVersion(): Promise<string> {
124
+ try {
125
+ const v = await bridge.evalJs(
126
+ 'window.__APP_VERSION__ || document.querySelector(\'meta[name="app-version"]\')?.getAttribute("content") || ""'
127
+ );
128
+ return v ? String(v) : '';
129
+ } catch {
130
+ return '';
131
+ }
132
+ }
133
+
134
+ /** Host[:port] only — strips scheme, path, query, fragment AND any `user:pass@` userinfo, so no
135
+ * credentials or tokens embedded in the URL ever surface in diagnostics. */
136
+ function safeHost(url: string): string {
137
+ const afterScheme = String(url || '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
138
+ const authority = afterScheme.split(/[/?#]/)[0]; // drop path/query/fragment
139
+ return authority.split('@').pop() || authority; // drop userinfo
140
+ }
@@ -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 {
@@ -3,12 +3,19 @@ import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
4
  import { onPwaHandshake } from './events';
5
5
  import { showToast } from './toast';
6
+ import { showBanner, dismissBanner } from './banner';
6
7
  import { setStatusBarStyle } from './status-bar';
7
8
  import { buildCapabilityMap } from './capabilities.manifest';
8
9
  import { ACTIVE_MODULE_NAMES } from './active-modules.generated';
9
10
 
10
11
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
11
- export const SHELL_BUILD = 'oauth-media-speaker-1';
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; }
12
19
 
13
20
  /** Register all protocol-v1 handlers. */
14
21
  export function registerHandlers(): void {
@@ -23,7 +30,7 @@ export function registerHandlers(): void {
23
30
  return {
24
31
  protocol: 1,
25
32
  platform: isIOS ? 'ios' : 'android',
26
- app: { id: SHELL_CONFIG.appId, name: SHELL_CONFIG.name, version: SHELL_CONFIG.version, build: SHELL_BUILD },
33
+ app: { id: SHELL_CONFIG.appId, name: SHELL_CONFIG.name, version: SHELL_CONFIG.version, build: SHELL_BUILD, loader: SHELL_CONFIG.loader },
27
34
  debug: { lastNotifTap: safeJson(ApplicationSettings.getString('kit:__notifTap', '')) },
28
35
  capabilities,
29
36
  };
@@ -127,11 +134,41 @@ export function registerHandlers(): void {
127
134
  showToast(String(message ?? ''), duration ?? 'short')
128
135
  );
129
136
 
137
+ // Persistent, tappable banner (e.g. the remote-update "tap to reload" prompt). Tap emits
138
+ // `toast.action` { id } back to the web side.
139
+ bridge.register('toast.banner', ({ id, message }: { id: string; message: string }) =>
140
+ showBanner({ id: String(id ?? 'banner'), message: String(message ?? '') })
141
+ );
142
+ bridge.register('toast.dismissBanner', () => dismissBanner());
143
+
144
+ // Hard reload the WebView, bypassing cache — used by the update banner + dev menu.
145
+ bridge.register('app.reload', () => reloadWebView());
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
+
130
152
  bridge.register('ui.statusBar.setStyle', ({ style }: { style: 'light' | 'dark' }) =>
131
153
  setStatusBarStyle(style)
132
154
  );
133
155
  }
134
156
 
157
+ /** Reload the attached WebView from origin, bypassing the HTTP cache (iOS `reloadFromOrigin`,
158
+ * Android `clearCache` + `reload`). No-op if no WebView is attached yet. */
159
+ export function reloadWebView(): void {
160
+ const wv = bridge.getWebView();
161
+ if (!wv) return;
162
+ Utils.dispatchToMainThread(() => {
163
+ if (isIOS && wv.ios) {
164
+ (wv.ios as WKWebView).reloadFromOrigin();
165
+ } else if (isAndroid && wv.android) {
166
+ wv.android.clearCache(true);
167
+ wv.android.reload();
168
+ }
169
+ });
170
+ }
171
+
135
172
  /** Parse a stored JSON breadcrumb; null if absent/unparseable (diagnostic, never throws). */
136
173
  function safeJson(s: string): unknown {
137
174
  if (!s) return null;
@@ -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
- let iosMask = ORIENTATION_MASK.allButUpsideDown;
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,13 +352,17 @@ 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 ?? '')},
358
361
  backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
359
362
  debug: ${JSON.stringify(cfg.debug ?? false)},
360
363
  debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
364
+ devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
365
+ neutralizeServiceWorker: ${JSON.stringify(cfg.neutralizeServiceWorker ?? true)},
361
366
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
362
367
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
363
368
  };
@@ -384,6 +389,10 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
384
389
  stamp('CFBundleShortVersionString', cfg.version); // marketing version (user-facing)
385
390
  stamp('CFBundleVersion', String(buildNumberOf(cfg))); // monotonic build — store re-uploads need it higher
386
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
+
387
396
  // Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
388
397
  src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
389
398
  const extras: string[] = [];
@@ -608,6 +617,9 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
608
617
  if (existsSync(manifest)) {
609
618
  let src = readFileSync(manifest, 'utf8');
610
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));
611
623
  // Permissions — idempotent: rewrite the marker block from the active modules (deduped).
612
624
  const perms = req.androidPerms.map((p) => `\t<uses-permission android:name="${p}"/>`);
613
625
  src = src.replace(
@@ -1144,7 +1156,13 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1144
1156
  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
1145
1157
  try {
1146
1158
  // Capture (not inherit) so we can recognize specific failures; echo it for visibility.
1147
- const out = execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
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
+ );
1148
1166
  process.stdout.write(out);
1149
1167
  } catch (e: any) {
1150
1168
  const log = `${e?.stdout ?? ''}${e?.stderr ?? ''}`;
@@ -1161,7 +1179,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1161
1179
  );
1162
1180
  } else {
1163
1181
  console.error(
1164
- '✖ Install failed. Usually the device is LOCKED or only on Wi-Fi.\n' +
1182
+ '✖ Install failed (device still locked after waiting, or only on Wi-Fi).\n' +
1165
1183
  ' → Unlock the phone (and plug in USB for a reliable connection), then re-run.\n' +
1166
1184
  ` The built .ipa is ready: ${ipaPath}`
1167
1185
  );
@@ -1172,14 +1190,35 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1172
1190
  if (!('no-launch' in flags)) {
1173
1191
  console.log(`▶ launching ${cfg.id}`);
1174
1192
  try {
1175
- execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', device.id, cfg.id], { stdio: 'inherit' });
1193
+ withUnlockRetry('Launch', () =>
1194
+ execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--timeout', '25', '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
1195
+ );
1176
1196
  } catch {
1177
- console.error('⚠ Launch failed (device locked?). The app is installed — unlock and tap it, or re-run.');
1197
+ console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
1178
1198
  }
1179
1199
  }
1180
1200
  console.log(`✓ Deployed to ${device.name}.`);
1181
1201
  }
1182
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
+
1183
1222
  /** First connected libimobiledevice UDID (USB, then network). Distinct from devicectl's identifier. */
1184
1223
  function libimobiledeviceUdid(): { udid: string; network: boolean } | null {
1185
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
@@ -57,6 +67,18 @@ export interface AppwrapConfig {
57
67
  /** In debug mode, the value written to `localStorage.DEBUG` at startup so the PWA's logger goes
58
68
  * verbose (common convention — `'*'` = all, or comma-separated module names). Default `'*'`. */
59
69
  debugLog?: string;
70
+ /** Shake-to-open developer menu (App Info / Reload). Default `true` — ON in store builds too, since
71
+ * it only exposes non-sensitive diagnostics (ids, versions, loader, remote host). Set `false` to
72
+ * disable. Remote-update detection (native-kit `kit.updates`) is independent of this flag. */
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;
60
82
  /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
61
83
  teamId?: string;
62
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
+ }
@@ -11,7 +11,7 @@ jobs:
11
11
  - uses: oven-sh/setup-bun@v2
12
12
  - run: bun install
13
13
  - run: bun test || echo "no tests"
14
- - run: bun run build.ts # → dist/ (the PWA bundle the wrapper ships)
14
+ - run: bun run build # → dist/ (the PWA bundle the wrapper ships)
15
15
 
16
16
  ios-build:
17
17
  runs-on: macos-15
@@ -21,7 +21,7 @@ jobs:
21
21
  - uses: oven-sh/setup-bun@v2
22
22
  - uses: actions/setup-node@v4
23
23
  with: { node-version: 22 }
24
- - run: bun install && bun run build.ts
24
+ - run: bun install && bun run build
25
25
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
26
26
  - run: npm i -g nativescript@9.0.6
27
27
  - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
@@ -29,7 +29,7 @@ jobs:
29
29
  - uses: actions/setup-java@v4
30
30
  with: { distribution: temurin, java-version: 17 }
31
31
  - uses: android-actions/setup-android@v3
32
- - run: bun install && bun run build.ts # → dist/ (the PWA bundle the wrapper ships)
32
+ - run: bun install && bun run build # → dist/ (the PWA bundle the wrapper ships)
33
33
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
34
34
  - run: npm i -g nativescript@9.0.6
35
35
  - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate fresh in CI
@@ -31,7 +31,7 @@ jobs:
31
31
  - uses: oven-sh/setup-bun@v2
32
32
  - uses: actions/setup-node@v4
33
33
  with: { node-version: 22 }
34
- - run: bun install && bun run build.ts
34
+ - run: bun install && bun run build
35
35
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
36
36
  - run: npm i -g nativescript@9.0.6
37
37
  - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI