@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 +4 -2
- package/runtime/app/app.ts +15 -0
- package/runtime/app/main-page.ts +11 -2
- package/runtime/app/shell/custom-webview.android.ts +81 -46
- package/runtime/app/shell/custom-webview.ios.ts +18 -8
- package/runtime/app/shell/handlers-android.ts +25 -6
- package/runtime/app/shell/handlers-background.ts +1 -1
- package/runtime/app/shell/web-quirks.ts +63 -0
- package/runtime/webpack.config.js +20 -0
- package/runtime-desktop/autotest/index.html +77 -0
- package/runtime-desktop/src-tauri/Cargo.lock +5459 -0
- package/runtime-desktop/src-tauri/Cargo.toml +38 -0
- package/runtime-desktop/src-tauri/build.rs +3 -0
- package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +10 -0
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
- package/runtime-desktop/src-tauri/src/main.rs +683 -0
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
- package/runtime-desktop/src-tauri/src/popup_mac.rs +466 -0
- package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
- package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
- package/scripts/stage-assets.mjs +4 -2
- package/src/cli.ts +621 -48
- package/src/config.ts +40 -1
- package/src/desktop.ts +246 -0
- package/src/handlers.ts +71 -0
- package/src/icon.ts +207 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livx.cc/appwrap",
|
|
3
|
-
"version": "0.
|
|
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"
|
package/runtime/app/app.ts
CHANGED
|
@@ -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.
|
package/runtime/app/main-page.ts
CHANGED
|
@@ -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
|
-
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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) +
|
|
189
|
-
// (
|
|
190
|
-
// suppression, all injected before the page's own scripts run. Globals first so the page
|
|
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
|
|
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,
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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://
|
|
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>
|