@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 +1 -1
- package/runtime/app/main-page.ts +5 -1
- package/runtime/app/shell/banner.ts +149 -0
- package/runtime/app/shell/bridge.ts +7 -1
- package/runtime/app/shell/capabilities.manifest.ts +4 -1
- package/runtime/app/shell/config.ts +13 -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 +140 -0
- package/runtime/app/shell/handlers-health.ts +36 -0
- package/runtime/app/shell/handlers.ts +39 -2
- 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 +51 -12
- package/src/config.ts +22 -0
- package/src/derive.ts +99 -0
- package/templates/ci/github/workflows/appwrap-pr.yml +2 -2
- package/templates/ci/github/workflows/appwrap-release-android.yml +1 -1
- package/templates/ci/github/workflows/appwrap-release-ios.yml +1 -1
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|