@livx.cc/appwrap 0.42.3 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.42.3",
3
+ "version": "0.43.0",
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",
@@ -30,17 +30,19 @@
30
30
  "exports": {
31
31
  ".": "./src/config.ts",
32
32
  "./config": "./src/config.ts",
33
+ "./handlers": "./src/handlers.ts",
33
34
  "./cli": "./src/cli.ts"
34
35
  },
35
36
  "files": [
36
37
  "src",
37
38
  "runtime",
39
+ "runtime-desktop",
38
40
  "templates",
39
41
  "scripts/stage-assets.mjs"
40
42
  ],
41
43
  "scripts": {
42
44
  "prepack": "node scripts/stage-assets.mjs",
43
- "postpack": "rm -rf runtime templates"
45
+ "postpack": "rm -rf runtime runtime-desktop templates"
44
46
  },
45
47
  "publishConfig": {
46
48
  "access": "public"
@@ -48,6 +48,21 @@ if (isIOS) {
48
48
  onDeepLink(url.absoluteString);
49
49
  return true;
50
50
  }
51
+ // Universal links (https://<associated-domain>/...) arrive as a browsing user
52
+ // activity, NOT via openURL. Without this, tapping an https app link opens Safari
53
+ // instead of the app. Requires the associated-domains entitlement (iosEntitlements).
54
+ applicationContinueUserActivityRestorationHandler(
55
+ _app: UIApplication,
56
+ userActivity: NSUserActivity,
57
+ _restorationHandler: (restorableObjects: NSArray<any>) => void
58
+ ): boolean {
59
+ if (userActivity.activityType === NSUserActivityTypeBrowsingWeb && userActivity.webpageURL) {
60
+ console.log('AppWrap: universalLink', userActivity.webpageURL.absoluteString);
61
+ onDeepLink(userActivity.webpageURL.absoluteString);
62
+ return true;
63
+ }
64
+ return false;
65
+ }
51
66
  // Drives `kit.screen.orientation.lock` — UIKit reads this on every rotation.
52
67
  applicationSupportedInterfaceOrientationsForWindow(_app: UIApplication, _window: UIWindow): UIInterfaceOrientationMask {
53
68
  // iosOrientationMask() returns the raw bitmask as a number; the protocol types it as the enum.
@@ -116,7 +116,16 @@ export function onPageLoaded(args: EventData): void {
116
116
 
117
117
  const webView = page.getViewById<CustomWebView>('webview');
118
118
  bridge.attach(webView);
119
- if (isIOS) armNativeSurfaceRecovery(webView); // global freeze-recovery on any native modal dismiss
119
+ // NOTE: the GLOBAL UIWindowDidBecomeHidden freeze-recovery observer is intentionally NOT armed.
120
+ // It over-fired: the iOS edit menu / text-selection callout / AutoFill bar present in their own
121
+ // floating UIWindows, and their normal appear/disappear churn hides a transient window → the observer
122
+ // ran neutralizeStrayWindows() and DETACHED the just-shown edit-menu window (a plain UIWindow, so no
123
+ // class filter can distinguish it from a real orphan) → Copy/Paste + AutoFill silently dead on EVERY
124
+ // text field (device-verified via the [native:recover] log). Freeze recovery still runs via the
125
+ // explicit per-surface dismiss callbacks (billing/oauth/share/PHPicker/SafariVC → recoverAfterNativeSurface),
126
+ // which are the proven baseline and only fire for actual native surfaces. Re-add a scoped observer only
127
+ // if a future surface orphans a window with no dismiss callback — gate it on a "surface presented" flag.
128
+ void armNativeSurfaceRecovery; // referenced to keep the helper (still callable if a scoped need arises)
120
129
  if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
121
130
  startEventForwarding();
122
131
  loadBundle(webView);
@@ -171,7 +180,7 @@ function loadBundle(webView: CustomWebView): void {
171
180
  const readAccessURL = NSURL.fileURLWithPathIsDirectory(wwwPath, true);
172
181
  wk.loadFileURLAllowingReadAccessToURL(entryURL, readAccessURL);
173
182
  } else {
174
- const url = NSURL.URLWithString(`app://local/${SHELL_CONFIG.entry}`);
183
+ const url = NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`);
175
184
  wk.loadRequest(NSURLRequest.requestWithURL(url));
176
185
  }
177
186
  };
@@ -31,6 +31,58 @@ function buildBootstrapJs(): string {
31
31
  return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker)}\n${externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser)}\n${NATIVE_FEEL_JS}`;
32
32
  }
33
33
 
34
+ /**
35
+ * The extended WebChromeClient MUST be generated ONCE. NativeScript caches the Java proxy
36
+ * class it synthesizes for `extend({...})` keyed by the methods-object shape, so calling
37
+ * extend() again per-webview REUSES the first invocation's class — including its captured
38
+ * closure. A per-instance `owner` WeakRef baked into the closure therefore cross-wires every
39
+ * later webview's prompt() transport to the FIRST instance (device-proven in the feedox
40
+ * mini-app spike: webview-2's bridge request was answered as webview-1). Route by the native
41
+ * view the callback is given instead, resolved against a per-view registry.
42
+ */
43
+ let chromeClientClass: any;
44
+ function getChromeClientClass(): any {
45
+ if (chromeClientClass) return chromeClientClass;
46
+ // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
47
+ chromeClientClass = (android.webkit.WebChromeClient as any).extend({
48
+ onJsPrompt(
49
+ view: android.webkit.WebView,
50
+ _url: string,
51
+ message: string,
52
+ _defaultValue: string,
53
+ result: android.webkit.JsPromptResult
54
+ ): boolean {
55
+ if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
56
+ result.confirm('');
57
+ CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
58
+ return true;
59
+ }
60
+ return false; // genuine page prompt — default handling
61
+ },
62
+
63
+ // getUserMedia: grant the WebView's per-origin capture after ensuring the
64
+ // app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
65
+ // No instance state — safe on the shared class.
66
+ onPermissionRequest(request: android.webkit.PermissionRequest): void {
67
+ const PR = android.webkit.PermissionRequest;
68
+ const resources: string[] = Array.from(request.getResources());
69
+ const perms = new Set<string>();
70
+ for (const r of resources) {
71
+ if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
72
+ if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
73
+ }
74
+ if (!perms.size) {
75
+ Utils.dispatchToMainThread(() => request.grant(request.getResources()));
76
+ return;
77
+ }
78
+ requestPermissions(Array.from(perms)).then((ok) =>
79
+ Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
80
+ );
81
+ },
82
+ });
83
+ return chromeClientClass;
84
+ }
85
+
34
86
  /**
35
87
  * Android CustomWebView — replaces NS's default clients after creation:
36
88
  * WebChromeClient.onJsPrompt is the appwrap transport, WebViewClient
@@ -41,6 +93,18 @@ export class CustomWebView extends WebView {
41
93
  /** Set by the bridge before load; receives raw envelope JSON. */
42
94
  onAppwrapMessage: ((json: string) => void) | null = null;
43
95
 
96
+ // Native-view → owning CustomWebView, keyed by the Java identity hash (stable per native
97
+ // object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
98
+ // so a destroyed view can be GC'd; entry pruned on dispose.
99
+ private static registry = new Map<number, WeakRef<CustomWebView>>();
100
+ static forNative(view: android.webkit.WebView): CustomWebView | undefined {
101
+ const owner = CustomWebView.registry.get(view.hashCode())?.deref();
102
+ // identityHashCode is not unique: on a (rare) collision the map holds the LAST writer —
103
+ // re-check native identity so a colliding view drops rather than cross-wires.
104
+ if (owner && owner.android !== view) return undefined;
105
+ return owner;
106
+ }
107
+
44
108
  initNativeView(): void {
45
109
  super.initNativeView();
46
110
  // NS core types the `.android` getter as `any`; narrow to the real WebView for this scope.
@@ -68,7 +132,15 @@ export class CustomWebView extends WebView {
68
132
 
69
133
  this.installDocumentStartShim(wv);
70
134
  wv.setWebViewClient(createAssetServingClient());
71
- wv.setWebChromeClient(this.createTransportChromeClient());
135
+ // Bind THIS native view to THIS instance before wiring the (shared, extend-once) client.
136
+ CustomWebView.registry.set(wv.hashCode(), new WeakRef<CustomWebView>(this));
137
+ wv.setWebChromeClient(new (getChromeClientClass())());
138
+ }
139
+
140
+ disposeNativeView(): void {
141
+ const wv = this.android as android.webkit.WebView | undefined;
142
+ if (wv) CustomWebView.registry.delete(wv.hashCode());
143
+ super.disposeNativeView();
72
144
  }
73
145
 
74
146
  /**
@@ -115,52 +187,15 @@ export class CustomWebView extends WebView {
115
187
  console.warn('AppWrap: addDocumentStartJavaScript failed', e);
116
188
  }
117
189
  }
118
-
119
- private createTransportChromeClient(): android.webkit.WebChromeClient {
120
- const owner = new WeakRef<CustomWebView>(this);
121
- // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
122
- const ChromeClient = (android.webkit.WebChromeClient as any).extend({
123
- onJsPrompt(
124
- _view: android.webkit.WebView,
125
- _url: string,
126
- message: string,
127
- _defaultValue: string,
128
- result: android.webkit.JsPromptResult
129
- ): boolean {
130
- if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
131
- result.confirm('');
132
- const view = owner.get ? owner.get() : (owner as { deref(): CustomWebView }).deref();
133
- view?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
134
- return true;
135
- }
136
- return false; // genuine page prompt — default handling
137
- },
138
-
139
- // getUserMedia: grant the WebView's per-origin capture after ensuring the
140
- // app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
141
- onPermissionRequest(request: android.webkit.PermissionRequest): void {
142
- const PR = android.webkit.PermissionRequest;
143
- const resources: string[] = Array.from(request.getResources());
144
- const perms = new Set<string>();
145
- for (const r of resources) {
146
- if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
147
- if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
148
- }
149
- if (!perms.size) {
150
- Utils.dispatchToMainThread(() => request.grant(request.getResources()));
151
- return;
152
- }
153
- requestPermissions(Array.from(perms)).then((ok) =>
154
- Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
155
- );
156
- },
157
- });
158
- return new ChromeClient();
159
- }
160
190
  }
161
191
 
162
- /** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses. */
192
+ /** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses.
193
+ * Extend-once (same NS shape-caching pitfall as the ChromeClient above): the closure here is
194
+ * process-constant, but generating the class per webview would silently pin the FIRST call's
195
+ * closure anyway — build it once and reuse. */
196
+ let assetClientClass: any;
163
197
  function createAssetServingClient(): android.webkit.WebViewClient {
198
+ if (assetClientClass) return new assetClientClass();
164
199
  const wwwPath = nsPath.join(knownFolders.currentApp().path, 'www');
165
200
 
166
201
  const respond = (filePath: string, ext: string): android.webkit.WebResourceResponse => {
@@ -246,7 +281,7 @@ function createAssetServingClient(): android.webkit.WebViewClient {
246
281
  };
247
282
 
248
283
  // NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
249
- const Client = (android.webkit.WebViewClient as any).extend({
284
+ assetClientClass = (android.webkit.WebViewClient as any).extend({
250
285
  // Modern (API 21+) overload passes a WebResourceRequest; the legacy overload passed a string URL.
251
286
  // We defensively handle both, so the param is the union.
252
287
  shouldInterceptRequest(
@@ -305,5 +340,5 @@ function createAssetServingClient(): android.webkit.WebViewClient {
305
340
  }
306
341
  },
307
342
  });
308
- return new Client();
343
+ return new assetClientClass();
309
344
  }
@@ -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, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
4
+ import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, capabilityGuardJs, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
5
5
  import { envGlobalsJs } from './env';
6
6
  import { createUiDelegate } from './ios-ui-delegate';
7
7
  import { appwrapNativeLog } from './native-log';
@@ -157,7 +157,7 @@ export class CustomWebView extends WebView {
157
157
  for (let j = 0; j < windows.count; j++) {
158
158
  const w = windows.objectAtIndex(j);
159
159
  if (main && w !== main && w.windowLevel > 0 && !w.hidden) {
160
- if (dbg) appendWebLog(`[native:recover] stray win[${j}] lvl=${w.windowLevel} uie=${w.userInteractionEnabled} key=${w.keyWindow}`);
160
+ if (dbg) appendWebLog(`[native:recover] stray win[${j}] ${NSStringFromClass(w.class())} lvl=${w.windowLevel} uie=${w.userInteractionEnabled} key=${w.keyWindow}`);
161
161
  w.userInteractionEnabled = false;
162
162
  w.hidden = true;
163
163
  w.windowScene = null; // detach from the scene — strongest removal
@@ -179,21 +179,31 @@ export class CustomWebView extends WebView {
179
179
  config.allowsInlineMediaPlayback = true;
180
180
  config.allowsPictureInPictureMediaPlayback = true;
181
181
  config.mediaTypesRequiringUserActionForPlayback = 0; // WKAudiovisualMediaTypeNone
182
+ // Element Fullscreen API (iOS 15.4+): the JS Fullscreen API on arbitrary elements is gated behind
183
+ // this preference (native <video> fullscreen works without it). respondsToSelector-guarded so it's a
184
+ // no-op on older iOS (mirrors the setInspectable: probe below).
185
+ if (config.preferences.respondsToSelector('setElementFullscreenEnabled:')) {
186
+ (config.preferences as WKPreferences & { elementFullscreenEnabled: boolean }).elementFullscreenEnabled = true;
187
+ }
182
188
  config.websiteDataStore = WKWebsiteDataStore.defaultDataStore();
183
189
  // Apple's hard requirement for a service worker in a WKWebView: restrict navigation to the
184
190
  // declared app-bound domains (also stamped to Info.plist WKAppBoundDomains). Only set when
185
191
  // configured — otherwise the WebView stays unrestricted (default behavior).
186
192
  if (SHELL_CONFIG.appBoundDomains?.length) config.limitsNavigationsToAppBoundDomains = true;
187
193
 
188
- // Env hints + framework globals (backend origin) + a getUserMedia guard for UNDECLARED capabilities
189
- // (must reject in JS before WebKit's native capture path — see mediaCaptureGuardJs) + native-feel
190
- // suppression, all injected before the page's own scripts run. Globals first so the page can read
191
- // __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
194
+ // Env hints + framework globals (backend origin) + the TCC capability guard for UNDECLARED web APIs
195
+ // (reject camera/mic/geolocation in JS before WebKit's native path — see capabilityGuardJs) +
196
+ // native-feel suppression, all injected before the page's own scripts run. Globals first so the page
197
+ // can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
192
198
  const hasPlistKey = (k: string) => !!NSBundle.mainBundle.objectForInfoDictionaryKey(k);
193
- const mediaGuard = mediaCaptureGuardJs(hasPlistKey('NSCameraUsageDescription'), hasPlistKey('NSMicrophoneUsageDescription'));
199
+ const capabilityGuard = capabilityGuardJs({
200
+ camera: hasPlistKey('NSCameraUsageDescription'),
201
+ microphone: hasPlistKey('NSMicrophoneUsageDescription'),
202
+ geolocation: hasPlistKey('NSLocationWhenInUseUsageDescription'),
203
+ });
194
204
  const swGuard = serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker);
195
205
  const extNavGuard = externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser);
196
- for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, swGuard, extNavGuard, NATIVE_FEEL_JS]) {
206
+ for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, capabilityGuard, swGuard, extNavGuard, NATIVE_FEEL_JS]) {
197
207
  const script = WKUserScript.alloc().initWithSourceInjectionTimeForMainFrameOnly(
198
208
  src,
199
209
  WKUserScriptInjectionTime.AtDocumentStart,
@@ -97,18 +97,36 @@ export function registerAndroidHandlers(): void {
97
97
  }
98
98
  });
99
99
 
100
+ // NS caches the Java proxy class synthesized by .extend({...}) by the methods-object SHAPE, so
101
+ // re-extending per call would permanently bake in the FIRST call's resolve/reject closure — every
102
+ // later authenticate would settle the first (already-settled) promise and hang its own. Extend once
103
+ // and route through a mutable slot (only one biometric prompt can be active at a time).
104
+ let biometricCallbackClass: any;
105
+ let biometricPending: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | null = null;
106
+ const getBiometricCallbackClass = () => {
107
+ if (biometricCallbackClass) return biometricCallbackClass;
108
+ biometricCallbackClass = (androidx.biometric.BiometricPrompt.AuthenticationCallback as any).extend({
109
+ onAuthenticationSucceeded(_result: any) {
110
+ biometricPending?.resolve({ success: true });
111
+ biometricPending = null;
112
+ },
113
+ onAuthenticationError(_code: number, message: any) {
114
+ biometricPending?.reject(err('DENIED', String(message ?? 'authentication error')));
115
+ biometricPending = null;
116
+ },
117
+ });
118
+ return biometricCallbackClass;
119
+ };
120
+
100
121
  bridge.register('biometrics.authenticate', ({ reason }: { reason?: string }) => {
101
122
  return new Promise((resolve, reject) => {
102
123
  Utils.dispatchToMainThread(() => {
103
124
  try {
104
125
  const act = activity(); // NativeScriptActivity extends AppCompatActivity → FragmentActivity
105
126
  const executor = androidx.core.content.ContextCompat.getMainExecutor(act);
106
- const Callback = (androidx.biometric.BiometricPrompt.AuthenticationCallback as any).extend({
107
- onAuthenticationSucceeded(_result: any) { resolve({ success: true }); },
108
- onAuthenticationError(_code: number, message: any) {
109
- reject(err('DENIED', String(message ?? 'authentication error')));
110
- },
111
- });
127
+ biometricPending?.reject(err('DENIED', 'superseded by a new biometric prompt'));
128
+ biometricPending = { resolve, reject };
129
+ const Callback = getBiometricCallbackClass();
112
130
  const prompt = new androidx.biometric.BiometricPrompt(act, executor, new Callback());
113
131
  const info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
114
132
  .setTitle(String(reason ?? 'Authenticate'))
@@ -117,6 +135,7 @@ export function registerAndroidHandlers(): void {
117
135
  .build();
118
136
  prompt.authenticate(info);
119
137
  } catch (e: any) {
138
+ if (biometricPending?.reject === reject) biometricPending = null;
120
139
  reject(err('UNSUPPORTED', e?.message ?? String(e)));
121
140
  }
122
141
  });
@@ -102,7 +102,7 @@ function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
102
102
  if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
103
103
  wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(SHELL_CONFIG.serverUrl)));
104
104
  } else {
105
- wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://local/${SHELL_CONFIG.entry}`)));
105
+ wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`)));
106
106
  }
107
107
  } else {
108
108
  if (retry(!webView.android)) return;
@@ -71,6 +71,65 @@ export function mediaCaptureGuardJs(camera: boolean, microphone: boolean): strin
71
71
  })();`;
72
72
  }
73
73
 
74
+ /**
75
+ * Reject `navigator.geolocation` in JS at document-start when this build did NOT declare location
76
+ * (no NSLocationWhenInUseUsageDescription). Unlike camera/mic — where an undeclared request is a HARD
77
+ * TCC process-kill (see mediaCaptureGuardJs) — a WKWebView geolocation request with no usage string
78
+ * does NOT crash: WebKit's CoreLocation provider silently denies it. But the un-guarded failure mode is
79
+ * poor (the promise/callback can hang, and some iOS builds surface a confusing WebKit prompt for a
80
+ * permission the app can never grant). So when location is undeclared we fail FAST and deterministically
81
+ * — invoke the error callback with PERMISSION_DENIED so the page's own fallback runs immediately. When
82
+ * location IS declared, we no-op: WebKit + CoreLocation own the real prompt and the API works natively.
83
+ *
84
+ * `declared` = presence of NSLocationWhenInUseUsageDescription (the `geo` module). Patches BOTH
85
+ * getCurrentPosition and watchPosition on the live `navigator.geolocation`. Idempotent via a window guard.
86
+ */
87
+ export function geolocationGuardJs(declared: boolean): string {
88
+ if (declared) return '';
89
+ return `(function(){
90
+ var geo = navigator.geolocation;
91
+ if (!geo || window.__appwrapGeoGuard) return;
92
+ window.__appwrapGeoGuard = true;
93
+ function deny(_pos, err){
94
+ if (typeof err === 'function') {
95
+ // GeolocationPositionError.PERMISSION_DENIED === 1.
96
+ setTimeout(function(){ try { err({ code: 1, message: 'location not available (capability not enabled for this app)', PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 }); } catch (e) {} }, 0);
97
+ }
98
+ }
99
+ geo.getCurrentPosition = function(_ok, err){ deny(_ok, err); };
100
+ geo.watchPosition = function(_ok, err){ deny(_ok, err); return 0; };
101
+ geo.clearWatch = function(){};
102
+ })();`;
103
+ }
104
+
105
+ /** A snapshot of which TCC-gated capabilities THIS build declared (Info.plist usage strings present),
106
+ * derived by the shell from `NSBundle.mainBundle.objectForInfoDictionaryKey`. Drives capabilityGuardJs. */
107
+ export interface DeclaredCaps {
108
+ /** NSCameraUsageDescription present. */
109
+ camera: boolean;
110
+ /** NSMicrophoneUsageDescription present. */
111
+ microphone: boolean;
112
+ /** NSLocationWhenInUseUsageDescription present (the `geo` module). */
113
+ geolocation: boolean;
114
+ }
115
+
116
+ /**
117
+ * The single crash-proofing / graceful-degradation guard for ALL JS-reachable, TCC-gated web APIs in a
118
+ * WKWebView, injected at document-start. Takes the build's declared-capability snapshot and, for every
119
+ * capability the build did NOT declare, prevents the app-killing (camera/mic) or hanging (geolocation)
120
+ * failure while letting DECLARED capabilities pass straight through to their native prompt. Composes the
121
+ * individual guards so every consumer's foreign webview (shell + feedox mini-app views) is covered from
122
+ * one call site. Other TCC-gated surfaces (getDisplayMedia, SpeechRecognition, WebMIDI/BT/NFC/USB) are
123
+ * simply UNIMPLEMENTED by WKWebView — they resolve to `undefined`, so feature-detection degrades cleanly
124
+ * and there is nothing to guard.
125
+ */
126
+ export function capabilityGuardJs(declared: DeclaredCaps): string {
127
+ return [
128
+ mediaCaptureGuardJs(declared.camera, declared.microphone),
129
+ geolocationGuardJs(declared.geolocation),
130
+ ].filter(Boolean).join('\n');
131
+ }
132
+
74
133
  /**
75
134
  * Neutralize `navigator.serviceWorker.register` inside the native shell at document-start, so a
76
135
  * consumer PWA doesn't have to hand-gate its own SW. Inside the shell a service worker is at best
@@ -195,6 +254,10 @@ const NATIVE_FEEL_CSS = [
195
254
  // touch-callout:none suppresses long-press menus, but on inputs/textareas/contenteditable that also
196
255
  // kills the iOS Copy/Paste callout — so exclude editable fields (same set as the user-select rule).
197
256
  ':where(:not(input,textarea,[contenteditable],[contenteditable] *)){-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}',
257
+ // Both -webkit-touch-callout and user-select INHERIT, so a :not(input) exclusion isn't enough — the
258
+ // field still inherits `none` from body/form and loses its paste callout + AutoFill selection. Must
259
+ // EXPLICITLY re-enable editable fields (not merely exempt them).
260
+ 'input,textarea,[contenteditable],[contenteditable] *{-webkit-touch-callout:default;-webkit-user-select:text;user-select:text}',
198
261
  ].join('');
199
262
 
200
263
  /** Accessibility-standard reduced-motion reset — collapse animation/transition durations to ~0 and
@@ -14,6 +14,26 @@ module.exports = (env) => {
14
14
  webpack.init(env);
15
15
 
16
16
  webpack.chainWebpack((config) => {
17
+ // node:-scheme imports: pure-JS deps (e.g. @noble/hashes) conditionally probe Node builtins via
18
+ // the `node:` scheme, which webpack cannot even READ ("UnhandledSchemeError: Reading from
19
+ // 'node:crypto' is not handled by plugins") — the whole NS build fails. Strip the scheme so the
20
+ // request becomes a bare builtin name…
21
+ const { NormalModuleReplacementPlugin } = require('webpack');
22
+ config.plugin('AppwrapStripNodeScheme').use(NormalModuleReplacementPlugin, [
23
+ /^node:/,
24
+ (resource) => {
25
+ resource.request = resource.request.replace(/^node:/, '');
26
+ },
27
+ ]);
28
+ // …then stub every Node builtin the NS runtime doesn't have to an empty module (fallback: false),
29
+ // so conditional probes resolve gracefully instead of "Module not found". `module` is excluded:
30
+ // @nativescript/webpack maps it to its own polyfill and that must keep winning.
31
+ config.resolve.merge({
32
+ fallback: Object.fromEntries(
33
+ require('module').builtinModules.filter((m) => m !== 'module').map((m) => [m, false])
34
+ ),
35
+ });
36
+
17
37
  // Ship the PWA bundle inside the app package
18
38
  config.plugin('CopyWww').use(CopyWebpackPlugin, [
19
39
  {
@@ -0,0 +1,77 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>appwrap desktop autotest</title>
4
+ <body style="font-family:monospace;background:#111;color:#0f0"><pre id="log">autotest</pre>
5
+ <canvas id="cv" width="800" height="600"></canvas>
6
+ <script>
7
+ const logEl = document.getElementById('log');
8
+ const say = (m) => { logEl.textContent += '\n' + m; };
9
+ // Phase markers travel over the real bridge so they interleave with [bridge] eprintln
10
+ // lines next to the watchdog's memory samples. UNSUPPORTED responses are irrelevant.
11
+ const mark = (name) => window.appwrapNative?.postMessage(JSON.stringify({ v: 1, id: 'ph-' + name, kind: 'request', method: 'phase.' + name, params: null }));
12
+ window.__appwrapDeliver = () => {}; // swallow responses
13
+
14
+ const cleanups = [];
15
+ const phases = [
16
+ ['idle', () => {}],
17
+ ['motion-listeners', () => {
18
+ const h1 = () => {}; const h2 = () => {};
19
+ window.addEventListener('devicemotion', h1);
20
+ window.addEventListener('deviceorientation', h2);
21
+ cleanups.push(() => { window.removeEventListener('devicemotion', h1); window.removeEventListener('deviceorientation', h2); });
22
+ }],
23
+ ['raf-canvas', () => {
24
+ const ctx = document.getElementById('cv').getContext('2d');
25
+ let run = true, t = 0;
26
+ const loop = () => { if (!run) return; t++; ctx.fillStyle = `hsl(${t % 360},80%,50%)`; ctx.fillRect(0, 0, 800, 600); ctx.getImageData(0, 0, 100, 100); requestAnimationFrame(loop); };
27
+ loop();
28
+ cleanups.push(() => { run = false; });
29
+ }],
30
+ ['speech-tts', () => {
31
+ let run = true;
32
+ const speak = () => { if (!run) return; const u = new SpeechSynthesisUtterance('test'); u.volume = 0; u.onend = speak; speechSynthesis.speak(u); };
33
+ speak();
34
+ cleanups.push(() => { run = false; speechSynthesis.cancel(); });
35
+ }],
36
+ ['geo-watch', () => {
37
+ if (!navigator.geolocation) return say('no geolocation');
38
+ const id = navigator.geolocation.watchPosition(() => {}, () => {}, { enableHighAccuracy: true });
39
+ cleanups.push(() => navigator.geolocation.clearWatch(id));
40
+ }],
41
+ ['bridge-storage-spam', () => {
42
+ let run = true, i = 0;
43
+ const big = 'x'.repeat(100_000);
44
+ const tick = () => {
45
+ if (!run) return;
46
+ for (let j = 0; j < 20; j++) window.appwrapNative.postMessage(JSON.stringify({ v: 1, id: 's' + (++i), kind: 'request', method: 'storage.set', params: { key: 'k' + (i % 50), value: big } }));
47
+ setTimeout(tick, 100);
48
+ };
49
+ tick();
50
+ cleanups.push(() => { run = false; });
51
+ }],
52
+ ['toast-spam', () => {
53
+ let run = true, i = 0;
54
+ const tick = () => { if (!run || i > 40) return; i++; window.appwrapNative.postMessage(JSON.stringify({ v: 1, id: 't' + i, kind: 'request', method: 'toast.show', params: { message: 'spam ' + i } })); setTimeout(tick, 500); };
55
+ tick();
56
+ cleanups.push(() => { run = false; });
57
+ }],
58
+ ['getusermedia', () => {
59
+ navigator.mediaDevices?.getUserMedia({ video: true, audio: true })
60
+ .then((s) => { say('gum ok'); cleanups.push(() => s.getTracks().forEach((t) => t.stop())); })
61
+ .catch((e) => say('gum err ' + e.name));
62
+ }],
63
+ ['idle-end', () => {}],
64
+ ];
65
+
66
+ let idx = 0;
67
+ const next = () => {
68
+ while (cleanups.length) { try { cleanups.pop()(); } catch {} }
69
+ if (idx >= phases.length) { mark('DONE'); say('DONE'); return; }
70
+ const [name, fn] = phases[idx++];
71
+ mark(name); say('phase: ' + name);
72
+ try { fn(); } catch (e) { say('threw: ' + e.message); }
73
+ setTimeout(next, 30_000);
74
+ };
75
+ next();
76
+ </script>
77
+ </body>