@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 +1 -1
- package/runtime/App_Resources/iOS/src/AppwrapKeyboardAccessory.m +25 -0
- package/runtime/App_Resources/iOS/src/AppwrapKeyboardFocus.m +30 -0
- package/runtime/app/shell/android-helpers.ts +20 -0
- package/runtime/app/shell/capabilities.manifest.ts +10 -1
- package/runtime/app/shell/config.ts +3 -0
- package/runtime/app/shell/custom-webview.android.ts +2 -15
- package/runtime/app/shell/handlers-webview.ts +305 -0
- package/runtime/app/shell/handlers.ts +1 -1
- package/runtime/app/shell/keyboard.ts +5 -3
- package/src/cli.ts +10 -0
- package/src/config.ts +12 -1
- package/src/derive.ts +20 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
//
|
|
40
|
-
(
|
|
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
|