@livx.cc/appwrap 0.61.15 → 0.61.17

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.61.15",
3
+ "version": "0.61.17",
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",
@@ -0,0 +1,25 @@
1
+ // appwrap: config `iosHideKeyboardAccessory: true` stamps Info.plist `AppwrapHideKeyboardAccessory` → this
2
+ // drops WKWebView's ▲▼✓ keyboard input-accessory bar. The bar is the `inputAccessoryView` of the private
3
+ // WKContentView (the first responder while a web field is focused); we give THAT class a nil-returning
4
+ // override. Native on purpose: doing it from JS (NativeScript extend / objc runtime interop on a
5
+ // metadata-less private class) SIGABRTs on device. Absent/false key → no-op.
6
+ #import <UIKit/UIKit.h>
7
+ #import <objc/runtime.h>
8
+ #import <dlfcn.h>
9
+
10
+ @interface AppwrapKeyboardAccessory : NSObject
11
+ @end
12
+
13
+ @implementation AppwrapKeyboardAccessory
14
+ + (void)load {
15
+ if (![[[NSBundle mainBundle] objectForInfoDictionaryKey:@"AppwrapHideKeyboardAccessory"] boolValue]) return;
16
+ // WebKit may not be loaded yet at +load (the shell reaches it via NativeScript metadata, not a link) — load it.
17
+ dlopen("/System/Library/Frameworks/WebKit.framework/WebKit", RTLD_LAZY);
18
+ Class cls = NSClassFromString(@"WKContentView");
19
+ Method m = cls ? class_getInstanceMethod(cls, @selector(inputAccessoryView)) : NULL;
20
+ if (!m) { NSLog(@"[appwrap] keyboard accessory: WKContentView not found — bar kept"); return; }
21
+ IMP nilImp = imp_implementationWithBlock(^id(id _self) { return nil; });
22
+ // Add on WKContentView itself (not the inherited UIResponder method) so other responders are untouched.
23
+ if (!class_addMethod(cls, @selector(inputAccessoryView), nilImp, method_getTypeEncoding(m))) method_setImplementation(m, nilImp);
24
+ }
25
+ @end
@@ -0,0 +1,30 @@
1
+ // appwrap: config `iosKeyboardWithoutUserAction: true` stamps Info.plist `AppwrapKeyboardWithoutUserAction` →
2
+ // programmatic `el.focus()` raises the keyboard (WKWebView's missing `keyboardDisplayRequiresUserAction = NO`).
3
+ // WebKit only shows the keyboard when the private WKContentView focus hook sees `userIsInteracting`; we wrap
4
+ // that hook and force it YES (the approach Cordova/Capacitor ship). Native for the same reason as
5
+ // AppwrapKeyboardAccessory.m (JS swizzles of private WebKit classes SIGABRT on device). Absent key → no-op;
6
+ // selector not found (future iOS renames it) → logged no-op, never a crash.
7
+ #import <UIKit/UIKit.h>
8
+ #import <objc/runtime.h>
9
+ #import <dlfcn.h>
10
+
11
+ @interface AppwrapKeyboardFocus : NSObject
12
+ @end
13
+
14
+ @implementation AppwrapKeyboardFocus
15
+ + (void)load {
16
+ if (![[[NSBundle mainBundle] objectForInfoDictionaryKey:@"AppwrapKeyboardWithoutUserAction"] boolValue]) return;
17
+ dlopen("/System/Library/Frameworks/WebKit.framework/WebKit", RTLD_LAZY);
18
+ Class cls = NSClassFromString(@"WKContentView");
19
+ // iOS 13+ signature; args after userIsInteracting are passed through untouched. Scalars are taken as
20
+ // 64-bit words (arm64/x86_64 pass each in its own register) so no bits of `activityStateChanges` are lost.
21
+ SEL sel = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:activityStateChanges:userObject:");
22
+ Method m = cls ? class_getInstanceMethod(cls, sel) : NULL;
23
+ if (!m) { NSLog(@"[appwrap] keyboard focus: WKContentView focus hook not found — focus() needs a tap"); return; }
24
+ typedef void (*Orig)(id, SEL, void *, BOOL, uintptr_t, uintptr_t, id);
25
+ Orig orig = (Orig)method_getImplementation(m);
26
+ method_setImplementation(m, imp_implementationWithBlock(^void(id me, void *info, BOOL interacting, uintptr_t blur, uintptr_t changes, id userObject) {
27
+ orig(me, sel, info, YES, blur, changes, userObject);
28
+ }));
29
+ }
30
+ @end
@@ -30,6 +30,26 @@ export function requestPermissions(permissions: string[]): Promise<boolean> {
30
30
  });
31
31
  }
32
32
 
33
+ /** WebChromeClient.onPermissionRequest (getUserMedia): grant the WebView's per-origin capture after
34
+ * ensuring the app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO). Stateless —
35
+ * shared by the app WebView and the `webview` overlay. */
36
+ export function handleWebPermissionRequest(request: android.webkit.PermissionRequest): void {
37
+ const PR = android.webkit.PermissionRequest;
38
+ const resources: string[] = Array.from(request.getResources());
39
+ const perms = new Set<string>();
40
+ for (const r of resources) {
41
+ if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
42
+ if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
43
+ }
44
+ if (!perms.size) {
45
+ Utils.dispatchToMainThread(() => request.grant(request.getResources()));
46
+ return;
47
+ }
48
+ requestPermissions(Array.from(perms)).then((ok) =>
49
+ Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
50
+ );
51
+ }
52
+
33
53
  /** Decode a content Uri (downscaled to `maxSize` longest edge) into a JPEG data URL. */
34
54
  export function uriToDataUrl(uri: android.net.Uri, maxSize: number): string | null {
35
55
  const cr = Utils.android.getApplicationContext().getContentResolver();
@@ -212,6 +212,15 @@ export const MODULES: ModuleManifest[] = [
212
212
  },
213
213
  },
214
214
 
215
+ // ── webview — in-app WebView OVERLAY (a second chrome-less WKWebView over the app, page-driven) ──
216
+ // vs browser.open (SFSafariViewController: modal, own chrome, separate cookie jar): this one shares the
217
+ // persistent default data store and is steered by the page (open/nav/hide/show/close + webview.state
218
+ // events). iOS: WKWebView; Android: android.webkit.WebView in the content frame. No permission, no native dep. Strippable.
219
+ {
220
+ name: 'webview', group: 'webview',
221
+ capabilities: { webview: { ios: true, android: true } },
222
+ },
223
+
215
224
  // ── speech (TTS + STT) — opt-in module; ONE coherent kit.speech, TWO honest capabilities ──
216
225
  // ONE module (not split TTS-core / STT-opt-in): TTS-only apps are rare and a split fractures
217
226
  // `kit.speech` across tiers. The module declares the STT perms (mic + speech-recognition); the
@@ -341,7 +350,7 @@ export const MODULES: ModuleManifest[] = [
341
350
 
342
351
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
343
352
  * groups (core/extended/parity/system/media) are always bundled; only these are CLI-gated. */
344
- export const OPTIONAL_GROUPS = ['oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask', 'shareTarget'] as const;
353
+ export const OPTIONAL_GROUPS = ['oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask', 'shareTarget', 'webview'] as const;
345
354
 
346
355
  /** CLI-gated groups (strippable in explicit mode) that legacy mode (no `modules` key) STILL auto-bundles
347
356
  * for back-compat. Empty now that billing (its only member) moved to a host-provided pack — kept as the
@@ -73,6 +73,9 @@ export const SHELL_CONFIG = {
73
73
  * Lifting extra makes the page cover that strip (the keyboard hides the thin bottom row of content).
74
74
  * Default 82. Set 0 for NO extra lift (input sits flush at the reported height; the strip may show). */
75
75
  iosKeyboardExtraLift: 82,
76
+ /** iOS only. Remove WKWebView's ▲▼✓ keyboard accessory bar (native: App_Resources/iOS/src/AppwrapKeyboardAccessory.m, gated on Info.plist AppwrapHideKeyboardAccessory). With the bar
77
+ * gone the extra lift above is not applied — it only compensates the bar's phantom reservation. */
78
+ iosHideKeyboardAccessory: false,
76
79
  /** `splashHold` config: hold the launch splash over the WebView until the page calls
77
80
  * `ui.splash.hide` (or `timeoutMs` passes / the load fails). `logo` = a `splashIcon` was stamped
78
81
  * (iOS `LaunchScreen.Center` imageset, Android `splash_logo` drawable). See wireSplashHold. */
@@ -3,7 +3,7 @@ import { SHELL_CONFIG } from './config';
3
3
  import { mimeFor } from './mime';
4
4
  import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
5
5
  import { envGlobalsJs } from './env';
6
- import { requestPermissions } from './android-helpers';
6
+ import { handleWebPermissionRequest } from './android-helpers';
7
7
  import { showFileChooser } from './file-chooser.android';
8
8
  import { hostOf } from './env-switcher';
9
9
 
@@ -66,20 +66,7 @@ function getChromeClientClass(): any {
66
66
  // app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
67
67
  // No instance state — safe on the shared class.
68
68
  onPermissionRequest(request: android.webkit.PermissionRequest): void {
69
- const PR = android.webkit.PermissionRequest;
70
- const resources: string[] = Array.from(request.getResources());
71
- const perms = new Set<string>();
72
- for (const r of resources) {
73
- if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
74
- if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
75
- }
76
- if (!perms.size) {
77
- Utils.dispatchToMainThread(() => request.grant(request.getResources()));
78
- return;
79
- }
80
- requestPermissions(Array.from(perms)).then((ok) =>
81
- Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
82
- );
69
+ handleWebPermissionRequest(request);
83
70
  },
84
71
 
85
72
  // <input type="file"> — NOT optional here: replacing NS's WebChromeClient removed the
@@ -0,0 +1,305 @@
1
+ import { Utils, isIOS, isAndroid, Application } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { createUiDelegate } from './ios-ui-delegate';
4
+ import { handleWebPermissionRequest } from './android-helpers';
5
+
6
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
7
+
8
+ /** WKWebView properties whose change is a navigation-state change (KVO-observable). */
9
+ const KVO_KEYS = ['URL', 'title', 'canGoBack', 'canGoForward', 'loading'];
10
+
11
+ /**
12
+ * In-app WebView OVERLAY (iOS) — a second, full WKWebView layered over the app's own WebView, below
13
+ * `top` CSS px and above `bottom` CSS px (so the page keeps its own top bar / bottom dock visible). Unlike `browser.open`
14
+ * (SFSafariViewController: modal, its own chrome, isolated cookies), this is chrome-less, page-driven
15
+ * and shares the PERSISTENT default data store (cookies/logins survive relaunch).
16
+ *
17
+ * webview.open {url, top?, bottom?} — show (or, if open, navigate + show / re-inset)
18
+ * webview.nav {op:'back'|'forward'|'reload'|'go', url?}
19
+ * webview.hide / webview.show {top?,bottom?} — keep state, reveal/cover the page beneath (show re-insets)
20
+ * webview.close — tear down; emits `webview.closed`
21
+ * Events: `webview.state` {url,title,canGoBack,canGoForward,loading} on every navigation change.
22
+ *
23
+ * Swipe back/forward on, target=_blank/window.open loads in place (shared ios-ui-delegate), keyboard +
24
+ * safe areas handled by WKWebView's own scroll-view insets (contentInsetAdjustment automatic).
25
+ * Android: an android.webkit.WebView added to the activity's content frame, margins derived from the app
26
+ * WebView's box (re-applied when it re-lays out), cookies via the process-wide CookieManager (persistent),
27
+ * getUserMedia via the shared permission handler, _blank in place (no multiple-window support).
28
+ */
29
+ export function registerWebViewHandlers(): void {
30
+ if (isIOS) registerIos();
31
+ else if (isAndroid) registerAndroid();
32
+ }
33
+
34
+
35
+
36
+ function registerIos(): void {
37
+ // Strong refs — WKWebView holds its delegates weakly and KVO observers aren't retained.
38
+ let wv: WKWebView | null = null;
39
+ let observer: NSObject | null = null;
40
+ let uiDelegate: WKUIDelegate | null = null;
41
+ let topConstraint: NSLayoutConstraint | null = null;
42
+ let bottomConstraint: NSLayoutConstraint | null = null;
43
+ let emitQueued = false;
44
+ let ObserverClass: any; // KVO sink, built once (ObjC class names are global)
45
+
46
+ const state = () => ({
47
+ url: wv?.URL?.absoluteString ?? '',
48
+ title: wv?.title ?? '',
49
+ canGoBack: !!wv?.canGoBack,
50
+ canGoForward: !!wv?.canGoForward,
51
+ loading: !!wv?.loading,
52
+ });
53
+
54
+ // Several KVO keys flip together on one navigation — coalesce into one event per runloop turn.
55
+ const emitState = () => {
56
+ if (emitQueued) return;
57
+ emitQueued = true;
58
+ setTimeout(() => { emitQueued = false; if (wv) bridge.emit('webview.state', state()); }, 0);
59
+ };
60
+
61
+ const load = (url: string) => {
62
+ const nsUrl = NSURL.URLWithString(url);
63
+ if (!nsUrl) throw err('NATIVE_ERROR', `webview: invalid url ${url}`);
64
+ wv!.loadRequest(NSURLRequest.requestWithURL(nsUrl));
65
+ };
66
+
67
+ const create = (top: number, bottom: number) => {
68
+ const host = bridge.getWebView()?.ios as WKWebView | undefined;
69
+ const container = host?.superview ?? Utils.ios.getRootViewController()?.view;
70
+ if (!container) throw err('NATIVE_ERROR', 'webview.open: no host view');
71
+
72
+ const config = WKWebViewConfiguration.new();
73
+ config.websiteDataStore = WKWebsiteDataStore.defaultDataStore();
74
+ config.allowsInlineMediaPlayback = true;
75
+ const view = WKWebView.alloc().initWithFrameConfiguration(CGRectZero, config);
76
+ view.allowsBackForwardNavigationGestures = true;
77
+ uiDelegate = createUiDelegate();
78
+ view.UIDelegate = uiDelegate;
79
+ if (host?.backgroundColor) { view.backgroundColor = host.backgroundColor; view.opaque = false; }
80
+ if ((view as any).inspectable !== undefined) (view as any).inspectable = true; // iOS 16.4+ Safari inspector
81
+
82
+ view.translatesAutoresizingMaskIntoConstraints = false;
83
+ container.addSubview(view);
84
+ // Anchor to the app WebView's box so `top`/`bottom` are in the page's own CSS px (== points).
85
+ const ref: any = host ?? container;
86
+ topConstraint = view.topAnchor.constraintEqualToAnchorConstant(ref.topAnchor, top);
87
+ bottomConstraint = view.bottomAnchor.constraintEqualToAnchorConstant(ref.bottomAnchor, -bottom);
88
+ NSLayoutConstraint.activateConstraints([
89
+ topConstraint,
90
+ bottomConstraint,
91
+ view.leadingAnchor.constraintEqualToAnchor(ref.leadingAnchor),
92
+ view.trailingAnchor.constraintEqualToAnchor(ref.trailingAnchor),
93
+ ] as any);
94
+
95
+ ObserverClass ??= (NSObject as any).extend(
96
+ {
97
+ observeValueForKeyPathOfObjectChangeContext() {
98
+ // Shell-coloured + non-opaque only to avoid a white flash before the first load; after it, WebKit
99
+ // must paint the page's own canvas, or a page with no background shows the (dark) shell through.
100
+ if (wv && !wv.opaque && !wv.loading && wv.URL) wv.opaque = true;
101
+ emitState();
102
+ },
103
+ },
104
+ { name: 'AppwrapWebViewOverlayObserver' }
105
+ );
106
+ observer = ObserverClass.new();
107
+ for (const k of KVO_KEYS) view.addObserverForKeyPathOptionsContext(observer!, k, NSKeyValueObservingOptions.New, null);
108
+ wv = view;
109
+ };
110
+
111
+ const destroy = () => {
112
+ if (!wv) return;
113
+ for (const k of KVO_KEYS) { try { wv.removeObserverForKeyPath(observer!, k); } catch { /* not observed */ } }
114
+ wv.stopLoading();
115
+ wv.removeFromSuperview();
116
+ wv = null; observer = null; uiDelegate = null; topConstraint = null; bottomConstraint = null;
117
+ bridge.emit('webview.closed', {});
118
+ };
119
+
120
+ const onMain = <T>(fn: () => T): Promise<T> =>
121
+ new Promise((resolve, reject) => Utils.dispatchToMainThread(() => {
122
+ try { resolve(fn()); } catch (e) { reject(e); }
123
+ }));
124
+
125
+ const inset = (v?: number) => Math.max(0, Number(v) || 0);
126
+ /** Re-apply insets on a live overlay; an omitted side keeps its current value. */
127
+ const setInsets = (top?: number, bottom?: number) => {
128
+ if (top !== undefined && topConstraint) topConstraint.constant = inset(top);
129
+ if (bottom !== undefined && bottomConstraint) bottomConstraint.constant = -inset(bottom);
130
+ };
131
+
132
+ bridge.register('webview.open', ({ url, top, bottom }: { url: string; top?: number; bottom?: number }) => {
133
+ const target = String(url ?? '');
134
+ if (!target) throw err('NATIVE_ERROR', 'webview.open: empty url');
135
+ return onMain(() => {
136
+ if (!wv) create(inset(top), inset(bottom));
137
+ else setInsets(top, bottom);
138
+ wv!.hidden = false;
139
+ wv!.superview?.bringSubviewToFront(wv!);
140
+ load(target);
141
+ return state();
142
+ });
143
+ });
144
+
145
+ bridge.register('webview.nav', ({ op, url }: { op: string; url?: string }) =>
146
+ onMain(() => {
147
+ if (!wv) throw err('NATIVE_ERROR', 'webview.nav: not open');
148
+ if (op === 'back') { if (wv.canGoBack) wv.goBack(); }
149
+ else if (op === 'forward') { if (wv.canGoForward) wv.goForward(); }
150
+ else if (op === 'reload') wv.reload();
151
+ else if (op === 'go') load(String(url ?? ''));
152
+ else throw err('NATIVE_ERROR', `webview.nav: unknown op ${op}`);
153
+ return state();
154
+ })
155
+ );
156
+
157
+ bridge.register('webview.hide', () => onMain(() => { if (wv) wv.hidden = true; return { open: !!wv }; }));
158
+ bridge.register('webview.show', (p?: { top?: number; bottom?: number } | null) => onMain(() => {
159
+ if (wv) { setInsets(p?.top, p?.bottom); wv.hidden = false; wv.superview?.bringSubviewToFront(wv); }
160
+ return { open: !!wv };
161
+ }));
162
+ bridge.register('webview.close', () => onMain(() => { destroy(); }));
163
+ }
164
+
165
+ function registerAndroid(): void {
166
+ let wv: any = null; // android.webkit.WebView
167
+ let insets = { top: 0, bottom: 0 };
168
+ let layoutListener: any = null;
169
+ let host: any = null;
170
+ let emitQueued = false;
171
+ // NS caches extend() proxies by shape — build each client class ONCE; route to the single live overlay.
172
+ let ViewClient: any, ChromeClient: any;
173
+
174
+ const state = () => ({
175
+ url: wv?.getUrl() ?? '',
176
+ title: wv?.getTitle() ?? '',
177
+ canGoBack: !!wv?.canGoBack(),
178
+ canGoForward: !!wv?.canGoForward(),
179
+ loading: !!wv && wv.getProgress() < 100,
180
+ });
181
+ const emitState = () => {
182
+ if (emitQueued) return;
183
+ emitQueued = true;
184
+ setTimeout(() => { emitQueued = false; if (wv) bridge.emit('webview.state', state()); }, 0);
185
+ };
186
+
187
+ const activity = () => Application.android.foregroundActivity ?? Application.android.startActivity;
188
+ const density = () => Utils.android.getApplicationContext().getResources().getDisplayMetrics().density || 1;
189
+ const content = (): any => activity()?.findViewById(android.R.id.content); // FrameLayout
190
+
191
+ /** Place the overlay over the app WebView's box, `top`/`bottom` CSS px inside it. */
192
+ const layout = () => {
193
+ const frame = content();
194
+ if (!wv || !frame) return;
195
+ const d = density();
196
+ const fl = [0, 0], hl = [0, 0];
197
+ frame.getLocationInWindow(fl);
198
+ let boxTop = 0, boxBottom = 0;
199
+ if (host) {
200
+ host.getLocationInWindow(hl);
201
+ boxTop = hl[1] - fl[1];
202
+ boxBottom = frame.getHeight() - (boxTop + host.getHeight());
203
+ }
204
+ const lp = new android.widget.FrameLayout.LayoutParams(-1, -1);
205
+ lp.topMargin = Math.max(0, Math.round(boxTop + insets.top * d));
206
+ lp.bottomMargin = Math.max(0, Math.round(boxBottom + insets.bottom * d));
207
+ wv.setLayoutParams(lp);
208
+ };
209
+
210
+ const load = (url: string) => {
211
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) throw err('NATIVE_ERROR', `webview: invalid url ${url}`);
212
+ wv.loadUrl(url);
213
+ };
214
+
215
+ const create = () => {
216
+ const frame = content();
217
+ if (!frame) throw err('NATIVE_ERROR', 'webview.open: no host view');
218
+ host = bridge.getWebView()?.android ?? null;
219
+ ViewClient ??= (android.webkit.WebViewClient as any).extend({
220
+ onPageStarted() { emitState(); },
221
+ onPageFinished() { emitState(); },
222
+ doUpdateVisitedHistory() { emitState(); },
223
+ });
224
+ ChromeClient ??= (android.webkit.WebChromeClient as any).extend({
225
+ onReceivedTitle() { emitState(); },
226
+ onProgressChanged() { emitState(); },
227
+ onPermissionRequest(request: any) { handleWebPermissionRequest(request); },
228
+ });
229
+ const view = new android.webkit.WebView(activity());
230
+ const st = view.getSettings();
231
+ st.setJavaScriptEnabled(true);
232
+ st.setDomStorageEnabled(true);
233
+ st.setMediaPlaybackRequiresUserGesture(false);
234
+ st.setSupportMultipleWindows(false); // target=_blank / window.open load in place
235
+ android.webkit.CookieManager.getInstance().setAcceptThirdPartyCookies(view, true);
236
+ const client = new ViewClient(), chrome = new ChromeClient();
237
+ view.setWebViewClient(client);
238
+ view.setWebChromeClient(chrome);
239
+ (view as any)._appwrapClients = [client, chrome]; // keep JS peers alive (NS GC doesn't see the native hold)
240
+ const bg = host?.getBackground?.();
241
+ if (bg instanceof android.graphics.drawable.ColorDrawable) view.setBackgroundColor(bg.getColor());
242
+ android.webkit.WebView.setWebContentsDebuggingEnabled(true);
243
+ wv = view;
244
+ frame.addView(view);
245
+ layout();
246
+ if (host) {
247
+ layoutListener = new android.view.View.OnLayoutChangeListener({ onLayoutChange() { layout(); } });
248
+ host.addOnLayoutChangeListener(layoutListener);
249
+ }
250
+ };
251
+
252
+ const destroy = () => {
253
+ if (!wv) return;
254
+ if (host && layoutListener) host.removeOnLayoutChangeListener(layoutListener);
255
+ wv.stopLoading();
256
+ wv.getParent()?.removeView(wv);
257
+ wv.destroy();
258
+ wv = null; host = null; layoutListener = null;
259
+ android.webkit.CookieManager.getInstance().flush();
260
+ bridge.emit('webview.closed', {});
261
+ };
262
+
263
+ const onMain = <T>(fn: () => T): Promise<T> =>
264
+ new Promise((resolve, reject) => Utils.dispatchToMainThread(() => {
265
+ try { resolve(fn()); } catch (e) { reject(e); }
266
+ }));
267
+ const inset = (v?: number) => Math.max(0, Number(v) || 0);
268
+ const setInsets = (top?: number, bottom?: number) => {
269
+ if (top !== undefined) insets.top = inset(top);
270
+ if (bottom !== undefined) insets.bottom = inset(bottom);
271
+ layout();
272
+ };
273
+ const reveal = () => { wv.setVisibility(android.view.View.VISIBLE); wv.bringToFront(); };
274
+
275
+ bridge.register('webview.open', ({ url, top, bottom }: { url: string; top?: number; bottom?: number }) => {
276
+ const target = String(url ?? '');
277
+ if (!target) throw err('NATIVE_ERROR', 'webview.open: empty url');
278
+ return onMain(() => {
279
+ if (!wv) { insets = { top: inset(top), bottom: inset(bottom) }; create(); }
280
+ else setInsets(top, bottom);
281
+ reveal();
282
+ load(target);
283
+ return state();
284
+ });
285
+ });
286
+
287
+ bridge.register('webview.nav', ({ op, url }: { op: string; url?: string }) =>
288
+ onMain(() => {
289
+ if (!wv) throw err('NATIVE_ERROR', 'webview.nav: not open');
290
+ if (op === 'back') { if (wv.canGoBack()) wv.goBack(); }
291
+ else if (op === 'forward') { if (wv.canGoForward()) wv.goForward(); }
292
+ else if (op === 'reload') wv.reload();
293
+ else if (op === 'go') load(String(url ?? ''));
294
+ else throw err('NATIVE_ERROR', `webview.nav: unknown op ${op}`);
295
+ return state();
296
+ })
297
+ );
298
+
299
+ bridge.register('webview.hide', () => onMain(() => { wv?.setVisibility(android.view.View.GONE); return { open: !!wv }; }));
300
+ bridge.register('webview.show', (p?: { top?: number; bottom?: number } | null) => onMain(() => {
301
+ if (wv) { setInsets(p?.top, p?.bottom); reveal(); }
302
+ return { open: !!wv };
303
+ }));
304
+ bridge.register('webview.close', () => onMain(() => { destroy(); }));
305
+ }
@@ -15,7 +15,7 @@ import { appwrapNativeLog } from './native-log';
15
15
  const dlog = (line: string) => { if (SHELL_CONFIG.debug) appwrapNativeLog(line); };
16
16
 
17
17
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
18
- export const SHELL_BUILD = 'save-to-photos-1';
18
+ export const SHELL_BUILD = 'webview-overlay-1';
19
19
 
20
20
  /** Version status the web side (native-kit `kit.updates`) reports via `app.reportWebVersion`. */
21
21
  export interface WebVersionInfo { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean; }
@@ -36,8 +36,10 @@ export function registerKeyboardHandlers(): void {
36
36
  bridge.register('keyboard.hide', () => {
37
37
  Utils.dispatchToMainThread(() => {
38
38
  if (isIOS) {
39
- // WKWebView is a UIView → endEditing resigns the active field's first responder.
40
- (bridge.getWebView()?.ios as any)?.endEditing?.(true);
39
+ // endEditing on the WINDOW resigns whichever field is first responder — incl. one inside a
40
+ // sibling view (the `webview` overlay), which the app WebView's own endEditing can't reach.
41
+ const wv = bridge.getWebView()?.ios as any;
42
+ (wv?.window ?? UIApplication.sharedApplication.keyWindow ?? wv)?.endEditing?.(true);
41
43
  } else if (isAndroid) {
42
44
  hideAndroidKeyboard();
43
45
  }
@@ -159,7 +161,7 @@ function armIosKeyboardObservers(): void {
159
161
  detachWebKitKeyboardHandling();
160
162
  if (tag === 'willShow') sawWillShow = true; // cold acquire → the accessory bar will be drawn
161
163
  // Warm re-focus (didShow/willChangeFrame with no willShow this cycle) → bar dropped → lift extra.
162
- activeExtraLift = sawWillShow ? 0 : (SHELL_CONFIG.iosKeyboardExtraLift ?? 82);
164
+ activeExtraLift = sawWillShow || SHELL_CONFIG.iosHideKeyboardAccessory ? 0 : (SHELL_CONFIG.iosKeyboardExtraLift ?? 82);
163
165
  const value = note?.userInfo?.objectForKey?.(UIKeyboardFrameEndUserInfoKey);
164
166
  const rect = value?.CGRectValue;
165
167
  // Keyboard frames are in screen coordinates: a webview momentarily out of its window (screen
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  stampAndroidOrientation,
37
37
  stampAndroidQueries,
38
38
  stampAppBoundDomains,
39
+ stampInsecureHosts,
39
40
  stampPlistBackgroundTasks,
40
41
  stampPlistOrientations,
41
42
  stampPrivacyTracking,
@@ -401,6 +402,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
401
402
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
402
403
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
403
404
  shareTarget: { file: './handlers-share-target', fn: 'registerShareTargetHandlers' },
405
+ webview: { file: './handlers-webview', fn: 'registerWebViewHandlers' },
404
406
  // billing/health/widget live in host-provided module packs — a consumer opts in via modulePacks.
405
407
  };
406
408
 
@@ -1008,6 +1010,7 @@ export const SHELL_CONFIG = {
1008
1010
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
1009
1011
  pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
1010
1012
  iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
1013
+ iosHideKeyboardAccessory: ${JSON.stringify(cfg.iosHideKeyboardAccessory ?? false)},
1011
1014
  splash: ${JSON.stringify(splash)} as { hold: boolean; timeoutMs: number; logo: boolean },
1012
1015
  envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
1013
1016
  webCaps: ${JSON.stringify(webCaps)} as { camera: boolean; microphone: boolean; geolocation: boolean },
@@ -1068,6 +1071,10 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1068
1071
  // strips the WKAppBoundDomains key, so it no-ops when the field is absent.
1069
1072
  src = stampAppBoundDomains(src, cfg.appBoundDomains);
1070
1073
 
1074
+ // ATS per-domain exceptions for plain-http hosts (e.g. a Tailscale `ts.net` box). Merged into the
1075
+ // template's NSAppTransportSecurity dict; idempotent both ways.
1076
+ src = stampInsecureHosts(src, cfg.insecureHosts);
1077
+
1071
1078
  // Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
1072
1079
  src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
1073
1080
  const extras: string[] = [];
@@ -1077,6 +1084,9 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1077
1084
  for (const { key, usage } of req.iosPlist) {
1078
1085
  extras.push(` <key>${key}</key>\n <string>${usage}</string>`);
1079
1086
  }
1087
+ // Read by the native AppwrapKeyboardAccessory +load (runtime App_Resources/iOS/src) — no JS involved.
1088
+ if (cfg.iosHideKeyboardAccessory) extras.push(` <key>AppwrapHideKeyboardAccessory</key>\n <true/>`);
1089
+ if (cfg.iosKeyboardWithoutUserAction) extras.push(` <key>AppwrapKeyboardWithoutUserAction</key>\n <true/>`);
1080
1090
  if (cfg.urlScheme) {
1081
1091
  extras.push(
1082
1092
  ` <key>CFBundleURLTypes</key>\n <array>\n <dict>\n <key>CFBundleTypeRole</key>\n <string>Editor</string>\n <key>CFBundleURLName</key>\n <string>${cfg.id}</string>\n <key>CFBundleURLSchemes</key>\n <array>\n <string>${cfg.urlScheme}</string>\n </array>\n </dict>\n </array>`
package/src/config.ts CHANGED
@@ -131,6 +131,17 @@ export interface AppwrapConfig {
131
131
  * re-focus, leaving a black strip). Default 82; the keyboard covers the thin bottom content row.
132
132
  * Set 0 for no extra lift (input flush at the reported height; the strip may show). */
133
133
  iosKeyboardExtraLift?: number;
134
+ /** iOS only. Remove WKWebView's ▲▼✓ keyboard input-accessory bar (terminal/chat UIs that own their
135
+ * own input chrome). Also drops the extra keyboard lift, which only compensates that bar. Default false. */
136
+ iosHideKeyboardAccessory?: boolean;
137
+ /** iOS only. Let programmatic `el.focus()` raise the keyboard without a user tap (the UIWebView-era
138
+ * `keyboardDisplayRequiresUserAction = false`; WKWebView has no public switch, so a native +load
139
+ * forces WebKit's focus path to `userIsInteracting`). Applies to every WKWebView in the app. Default false. */
140
+ iosKeyboardWithoutUserAction?: boolean;
141
+ /** iOS only. Hosts allowed over plain http (ATS `NSExceptionDomains`, subdomains included) — e.g.
142
+ * `['ts.net']` for a Tailscale box. Per-domain, never NSAllowsArbitraryLoads. Local-network/IP
143
+ * hosts are already allowed (`NSAllowsLocalNetworking`). */
144
+ insecureHosts?: string[];
134
145
  pwaDist: string;
135
146
  /** Desktop-shell block, consumed by an external desktop host (a module pack) that registers a
136
147
  * `'desktop'` platform handler via `runCli({ platforms })`. Carried OPAQUE — nothing here reads
@@ -406,7 +417,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
406
417
  */
407
418
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
408
419
  'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'ci', 'debug',
409
- 'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
420
+ 'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosHideKeyboardAccessory', 'iosKeyboardExtraLift', 'iosKeyboardWithoutUserAction', 'insecureHosts', 'loader', 'modules', 'modulePacks', 'name',
410
421
  'androidLockTextZoom', 'envSwitcher', 'iosEntitlements', 'iosInfoPlist', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
411
422
  'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle', 'store',
412
423
  'splashHold', 'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
package/src/derive.ts CHANGED
@@ -261,6 +261,26 @@ export function stampAppBoundDomains(src: string, domains: string[] | undefined)
261
261
  return src;
262
262
  }
263
263
 
264
+ /**
265
+ * Stamp ATS exceptions for plain-http hosts (config `insecureHosts`) into the template's EXISTING
266
+ * `NSAppTransportSecurity` dict as `NSExceptionDomains` — each host with IncludesSubdomains +
267
+ * InsecureHTTPLoads. Scoped per domain on purpose (never NSAllowsArbitraryLoads, an App Review flag).
268
+ * e.g. `['ts.net']` lets the WebView load `http://box.tailnet.ts.net:7707`. Idempotent both ways:
269
+ * the marked block is stripped first, so an empty/absent list removes it.
270
+ */
271
+ export function stampInsecureHosts(src: string, hosts: string[] | undefined): string {
272
+ src = src.replace(/\s*<!-- appwrap:insecure -->[\s\S]*?<!-- \/appwrap:insecure -->/g, '');
273
+ const list = (hosts ?? []).map((h) => h.trim().replace(/^\*?\./, '')).filter(Boolean);
274
+ if (!list.length) return src;
275
+ const ats = /(<key>NSAppTransportSecurity<\/key>\s*<dict>)/;
276
+ const entries = list.map((h) =>
277
+ `\t\t\t<key>${h}</key>\n\t\t\t<dict>\n\t\t\t\t<key>NSIncludesSubdomains</key>\n\t\t\t\t<true/>\n` +
278
+ `\t\t\t\t<key>NSExceptionAllowsInsecureHTTPLoads</key>\n\t\t\t\t<true/>\n\t\t\t</dict>`).join('\n');
279
+ const block = `\n\t\t<!-- appwrap:insecure -->\n\t\t<key>NSExceptionDomains</key>\n\t\t<dict>\n${entries}\n\t\t</dict>\n\t\t<!-- /appwrap:insecure -->`;
280
+ if (ats.test(src)) return src.replace(ats, `$1${block}`);
281
+ return src.replace(/<\/dict>\s*<\/plist>\s*$/, `\t<key>NSAppTransportSecurity</key>\n\t<dict>${block}\n\t</dict>\n</dict>\n</plist>\n`);
282
+ }
283
+
264
284
  /**
265
285
  * Stamp the App Tracking Transparency declarations into PrivacyInfo.xcprivacy. Rewrites the two
266
286
  * tracking keys IN PLACE (the template ships them, so we never restructure the doc — we only flip