@livx.cc/appwrap 0.19.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/LICENSE +21 -0
- package/README.md +20 -0
- package/package.json +46 -0
- package/runtime/App_Resources/Android/app.gradle +34 -0
- package/runtime/App_Resources/Android/before-plugins.gradle +15 -0
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +76 -0
- package/runtime/App_Resources/Android/src/main/res/drawable/ic_launcher_foreground.xml +15 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml +8 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/values/colors.xml +14 -0
- package/runtime/App_Resources/Android/src/main/res/values/ic_launcher_background.xml +4 -0
- package/runtime/App_Resources/Android/src/main/res/values/styles.xml +42 -0
- package/runtime/App_Resources/Android/src/main/res/values-v21/colors.xml +5 -0
- package/runtime/App_Resources/Android/src/main/res/values-v21/styles.xml +32 -0
- package/runtime/App_Resources/Android/src/main/res/values-v29/styles.xml +18 -0
- package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +5 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/Contents.json +6 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/Contents.json +23 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/Contents.json +23 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@3x.png +0 -0
- package/runtime/App_Resources/iOS/Info.plist +60 -0
- package/runtime/App_Resources/iOS/LaunchScreen.storyboard +53 -0
- package/runtime/App_Resources/iOS/Podfile +13 -0
- package/runtime/App_Resources/iOS/PrivacyInfo.xcprivacy +59 -0
- package/runtime/App_Resources/iOS/build.xcconfig +7 -0
- package/runtime/app/app.ts +58 -0
- package/runtime/app/main-page.ts +116 -0
- package/runtime/app/main-page.xml +10 -0
- package/runtime/app/shell/active-modules.generated.ts +9 -0
- package/runtime/app/shell/android-helpers.ts +76 -0
- package/runtime/app/shell/bridge.ts +99 -0
- package/runtime/app/shell/capabilities.manifest.ts +207 -0
- package/runtime/app/shell/config.ts +35 -0
- package/runtime/app/shell/custom-webview.android.ts +270 -0
- package/runtime/app/shell/custom-webview.ios.ts +258 -0
- package/runtime/app/shell/custom-webview.ts +17 -0
- package/runtime/app/shell/env.ts +81 -0
- package/runtime/app/shell/events.ts +100 -0
- package/runtime/app/shell/fcm-bootstrap.generated.ts +1 -0
- package/runtime/app/shell/fcm-firebase.d.ts +16 -0
- package/runtime/app/shell/fcm-service.android.ts +40 -0
- package/runtime/app/shell/fcm-service.ios.ts +3 -0
- package/runtime/app/shell/handlers-android.ts +417 -0
- package/runtime/app/shell/handlers-billing.ts +197 -0
- package/runtime/app/shell/handlers-extended.ts +405 -0
- package/runtime/app/shell/handlers-health.ts +134 -0
- package/runtime/app/shell/handlers-media.ts +126 -0
- package/runtime/app/shell/handlers-oauth.ts +69 -0
- package/runtime/app/shell/handlers-parity.ts +257 -0
- package/runtime/app/shell/handlers-push.ts +189 -0
- package/runtime/app/shell/handlers-system.ts +70 -0
- package/runtime/app/shell/handlers.ts +149 -0
- package/runtime/app/shell/ios-frameworks.d.ts +36 -0
- package/runtime/app/shell/ios-image.ts +15 -0
- package/runtime/app/shell/ios-ui-delegate.ts +131 -0
- package/runtime/app/shell/mime.ts +29 -0
- package/runtime/app/shell/native-log.ts +22 -0
- package/runtime/app/shell/optional-handlers.generated.ts +10 -0
- package/runtime/app/shell/orientation.ts +43 -0
- package/runtime/app/shell/status-bar.ts +129 -0
- package/runtime/app/shell/toast.ts +67 -0
- package/runtime/app/shell/web-quirks.ts +131 -0
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +74 -0
- package/runtime/nativescript.config.ts +15 -0
- package/runtime/package.json +21 -0
- package/runtime/references.d.ts +2 -0
- package/runtime/tsconfig.json +19 -0
- package/runtime/webpack.config.js +27 -0
- package/scripts/stage-assets.mjs +21 -0
- package/src/cli.ts +1328 -0
- package/templates/ci/fastlane/Fastfile +43 -0
- package/templates/ci/fastlane/Matchfile +3 -0
- package/templates/ci/github/workflows/appwrap-pr.yml +28 -0
- package/templates/ci/github/workflows/appwrap-release-android.yml +51 -0
- package/templates/ci/github/workflows/appwrap-release-ios.yml +41 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { WebView, knownFolders, path as nsPath, File } from '@nativescript/core';
|
|
2
|
+
import { SHELL_CONFIG } from './config';
|
|
3
|
+
import { mimeFor } from './mime';
|
|
4
|
+
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, mediaCaptureGuardJs } from './web-quirks';
|
|
5
|
+
import { envGlobalsJs } from './env';
|
|
6
|
+
import { createUiDelegate } from './ios-ui-delegate';
|
|
7
|
+
import { appwrapNativeLog } from './native-log';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* iOS CustomWebView — configures WKWebView before creation (inline media,
|
|
11
|
+
* persistent data store), registers a real WKScriptMessageHandler named
|
|
12
|
+
* `appwrap` (page → `webkit.messageHandlers.appwrap.postMessage(json)`), and
|
|
13
|
+
* serves the bundled PWA via a custom `app://` WKURLSchemeHandler so
|
|
14
|
+
* unmodified ES-module PWAs load with a stable origin (file:// blocks module
|
|
15
|
+
* scripts — origin "null" CORS).
|
|
16
|
+
*/
|
|
17
|
+
// Forwarded WebView console/errors → the shared native-log sink (Documents/appwrap-web.log).
|
|
18
|
+
const appendWebLog = appwrapNativeLog;
|
|
19
|
+
|
|
20
|
+
export class CustomWebView extends WebView {
|
|
21
|
+
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
22
|
+
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
23
|
+
private _scriptHandler: any; // retained — WKUserContentController holds it weakly
|
|
24
|
+
private _logHandler: any; // retained — debug console-forwarding handler
|
|
25
|
+
private _schemeHandler: any;
|
|
26
|
+
// NOT `_uiDelegate`: the base NativeScript WebView uses that exact field name, and
|
|
27
|
+
// TS `private` is compile-time only — sharing it let super.initNativeView() overwrite
|
|
28
|
+
// our media-capture delegate with NS's, so getUserMedia re-prompted every call.
|
|
29
|
+
private _appwrapUiDelegate: any; // retained — WKWebView holds uiDelegate weakly
|
|
30
|
+
|
|
31
|
+
/** No-op on iOS: WKWebView suspends rAF/timers itself when the app leaves the foreground.
|
|
32
|
+
* Kept for parity with the Android impl (wired to suspend/resume in main-page). */
|
|
33
|
+
setRenderingActive(_active: boolean): void {
|
|
34
|
+
/* OS-managed on iOS */
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
createNativeView(): WKWebView {
|
|
38
|
+
const config = WKWebViewConfiguration.new();
|
|
39
|
+
config.allowsInlineMediaPlayback = true;
|
|
40
|
+
config.allowsPictureInPictureMediaPlayback = true;
|
|
41
|
+
config.mediaTypesRequiringUserActionForPlayback = 0; // WKAudiovisualMediaTypeNone
|
|
42
|
+
config.websiteDataStore = WKWebsiteDataStore.defaultDataStore();
|
|
43
|
+
|
|
44
|
+
// Env hints + framework globals (backend origin) + a getUserMedia guard for UNDECLARED capabilities
|
|
45
|
+
// (must reject in JS before WebKit's native capture path — see mediaCaptureGuardJs) + native-feel
|
|
46
|
+
// suppression, all injected before the page's own scripts run. Globals first so the page can read
|
|
47
|
+
// __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
|
|
48
|
+
const hasPlistKey = (k: string) => !!NSBundle.mainBundle.objectForInfoDictionaryKey(k);
|
|
49
|
+
const mediaGuard = mediaCaptureGuardJs(hasPlistKey('NSCameraUsageDescription'), hasPlistKey('NSMicrophoneUsageDescription'));
|
|
50
|
+
for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, NATIVE_FEEL_JS]) {
|
|
51
|
+
const script = WKUserScript.alloc().initWithSourceInjectionTimeForMainFrameOnly(
|
|
52
|
+
src,
|
|
53
|
+
WKUserScriptInjectionTime.AtDocumentStart,
|
|
54
|
+
true
|
|
55
|
+
);
|
|
56
|
+
config.userContentController.addUserScript(script);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const owner = new WeakRef<CustomWebView>(this);
|
|
60
|
+
const HandlerClass = (NSObject as any).extend(
|
|
61
|
+
{
|
|
62
|
+
userContentControllerDidReceiveScriptMessage(
|
|
63
|
+
_controller: WKUserContentController,
|
|
64
|
+
message: WKScriptMessage
|
|
65
|
+
): void {
|
|
66
|
+
const view = owner.get ? owner.get() : (owner as any).deref();
|
|
67
|
+
const body = message.body;
|
|
68
|
+
// Envelopes always travel as JSON strings (protocol v1)
|
|
69
|
+
if (view?.onAppwrapMessage && typeof body === 'string') {
|
|
70
|
+
view.onAppwrapMessage(body);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{ protocols: [WKScriptMessageHandler] }
|
|
75
|
+
);
|
|
76
|
+
this._scriptHandler = HandlerClass.new();
|
|
77
|
+
config.userContentController.addScriptMessageHandlerName(this._scriptHandler, 'appwrap');
|
|
78
|
+
|
|
79
|
+
// Debug console-forwarding: web console/errors → NSLog (via NS console.log) → device console,
|
|
80
|
+
// so `appwrap logs ios` can stream them headlessly. Only wired in debug mode.
|
|
81
|
+
if (SHELL_CONFIG.debug) {
|
|
82
|
+
const LogClass = (NSObject as any).extend(
|
|
83
|
+
{
|
|
84
|
+
userContentControllerDidReceiveScriptMessage(_c: WKUserContentController, message: WKScriptMessage): void {
|
|
85
|
+
// Write to the file sink — NS console.log / NSLog don't surface to devicectl/idevicesyslog
|
|
86
|
+
// on a device build, but a file in the app container is reliably pullable.
|
|
87
|
+
if (typeof message.body === 'string') appendWebLog(message.body);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{ protocols: [WKScriptMessageHandler] }
|
|
91
|
+
);
|
|
92
|
+
this._logHandler = LogClass.new();
|
|
93
|
+
config.userContentController.addScriptMessageHandlerName(this._logHandler, 'appwrapLog');
|
|
94
|
+
appendWebLog('[native] log handler registered'); // boot marker: proves the file sink works
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this._schemeHandler = createAppSchemeHandler();
|
|
98
|
+
config.setURLSchemeHandlerForURLScheme(this._schemeHandler, 'app');
|
|
99
|
+
|
|
100
|
+
const webView = WKWebView.alloc().initWithFrameConfiguration(CGRectZero, config);
|
|
101
|
+
|
|
102
|
+
// Kill the remaining "web page" tells the viewport/CSS can't reach:
|
|
103
|
+
// rubber-band overscroll, long-press link preview, data-detector menus,
|
|
104
|
+
// and the auto content-inset (the page owns safe areas via env()).
|
|
105
|
+
webView.scrollView.bounces = false;
|
|
106
|
+
webView.scrollView.contentInsetAdjustmentBehavior = 2; // .never
|
|
107
|
+
webView.allowsLinkPreview = false;
|
|
108
|
+
// Edge-swipe to go back/forward in WebView history — the iOS native gesture
|
|
109
|
+
// users expect. SPAs that own their own history still get it for free.
|
|
110
|
+
webView.allowsBackForwardNavigationGestures = true;
|
|
111
|
+
if ((webView.configuration as any).dataDetectorTypes !== undefined) {
|
|
112
|
+
(webView.configuration as any).dataDetectorTypes = 0; // WKDataDetectorTypeNone
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Observability (debug mode only): make the WebView debuggable via Safari Web Inspector
|
|
116
|
+
// (Develop → <device> → Para) — full console + network. iOS 16.4+ (guarded). Off in store builds.
|
|
117
|
+
if (SHELL_CONFIG.debug && webView.respondsToSelector('setInspectable:')) (webView as any).inspectable = true;
|
|
118
|
+
|
|
119
|
+
this._appwrapUiDelegate = createUiDelegate();
|
|
120
|
+
webView.UIDelegate = this._appwrapUiDelegate;
|
|
121
|
+
|
|
122
|
+
return webView;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
initNativeView(): void {
|
|
126
|
+
super.initNativeView();
|
|
127
|
+
// NativeScript's WebView assigns its own wkWebView.UIDelegate during init,
|
|
128
|
+
// which clobbers the media-capture grant set in createNativeView — so WebKit
|
|
129
|
+
// falls back to prompting on every getUserMedia call. Reassert ours last so
|
|
130
|
+
// the `.grant` sticks: iOS then prompts once (TCC), like a native app.
|
|
131
|
+
const wk = this.nativeViewProtected as WKWebView;
|
|
132
|
+
if (wk && this._appwrapUiDelegate) wk.UIDelegate = this._appwrapUiDelegate;
|
|
133
|
+
// Lightweight self-check surfaced via debug.webviewInfo (support diagnostics).
|
|
134
|
+
(global as any).__appwrapWebviewDiag =
|
|
135
|
+
`delegateIsMine=${!!wk && wk.UIDelegate === this._appwrapUiDelegate} ` +
|
|
136
|
+
`respondsToMedia=${!!wk && !!wk.UIDelegate && !!(wk.UIDelegate as any).respondsToSelector?.('webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:')}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* app://local/<path> handler:
|
|
142
|
+
* - bundled file exists → serve it (the offline www).
|
|
143
|
+
* - GET navigation, no ext → SPA fallback to the app shell (client-side routes).
|
|
144
|
+
* - else + backendOrigin set → PROXY to the backend via a native URLSession request, returned
|
|
145
|
+
* under the app:// URL so the WebView sees it as SAME-ORIGIN (no CORS needed — this is what
|
|
146
|
+
* makes a same-origin PWA's relative /api & /functions calls work offline). iOS gives us the
|
|
147
|
+
* request body, so POSTs proxy fine.
|
|
148
|
+
* - else → 404 (or SPA fallback when there's no backend).
|
|
149
|
+
*/
|
|
150
|
+
function createAppSchemeHandler(): any {
|
|
151
|
+
const wwwPath = nsPath.join(knownFolders.currentApp().path, 'www');
|
|
152
|
+
|
|
153
|
+
const serveFile = (task: any, url: any, filePath: string, ext: string): void => {
|
|
154
|
+
const data = NSData.dataWithContentsOfFile(filePath);
|
|
155
|
+
const mime = mimeFor(ext, filePath);
|
|
156
|
+
const response = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
|
|
157
|
+
url, 200, 'HTTP/1.1',
|
|
158
|
+
{
|
|
159
|
+
'Content-Type': `${mime}; charset=utf-8`,
|
|
160
|
+
'Content-Length': String(data?.length ?? 0),
|
|
161
|
+
'Access-Control-Allow-Origin': '*',
|
|
162
|
+
'Cache-Control': 'no-cache',
|
|
163
|
+
} as any
|
|
164
|
+
);
|
|
165
|
+
task.didReceiveResponse(response);
|
|
166
|
+
if (data) task.didReceiveData(data);
|
|
167
|
+
task.didFinish();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const proxyToBackend = (task: any, url: any, rel: string): void => {
|
|
171
|
+
const backend = SHELL_CONFIG.backendOrigin.replace(/\/+$/, '');
|
|
172
|
+
const query = url.query ? `?${url.query}` : '';
|
|
173
|
+
const req = NSMutableURLRequest.requestWithURL(NSURL.URLWithString(`${backend}/${rel}${query}`));
|
|
174
|
+
req.HTTPMethod = String(task.request.HTTPMethod ?? 'GET');
|
|
175
|
+
const hdrs = task.request.allHTTPHeaderFields;
|
|
176
|
+
if (hdrs) req.allHTTPHeaderFields = hdrs;
|
|
177
|
+
req.setValueForHTTPHeaderField(null as any, 'Host'); // let URLSession set Host from the target URL
|
|
178
|
+
// WKURLSchemeHandler delivers a POST/PUT body via HTTPBodyStream (not HTTPBody) — read it.
|
|
179
|
+
let body = task.request.HTTPBody;
|
|
180
|
+
if (!body && task.request.HTTPBodyStream) {
|
|
181
|
+
const stream = task.request.HTTPBodyStream;
|
|
182
|
+
const acc = NSMutableData.alloc().init();
|
|
183
|
+
const cap = 16384;
|
|
184
|
+
const buf = (global as any).interop.alloc(cap);
|
|
185
|
+
stream.open();
|
|
186
|
+
while (stream.hasBytesAvailable) {
|
|
187
|
+
const n = stream.readMaxLength(buf, cap);
|
|
188
|
+
if (n <= 0) break;
|
|
189
|
+
acc.appendBytesLength(buf, n);
|
|
190
|
+
}
|
|
191
|
+
stream.close();
|
|
192
|
+
if (acc.length) body = acc;
|
|
193
|
+
}
|
|
194
|
+
if (body) req.HTTPBody = body;
|
|
195
|
+
|
|
196
|
+
const dataTask = NSURLSession.sharedSession.dataTaskWithRequestCompletionHandler(
|
|
197
|
+
req,
|
|
198
|
+
(data: any, resp: any, error: any) => {
|
|
199
|
+
try {
|
|
200
|
+
if (error || !resp) {
|
|
201
|
+
const r = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
|
|
202
|
+
url, 502, 'HTTP/1.1', { 'Content-Length': '0' } as any
|
|
203
|
+
);
|
|
204
|
+
task.didReceiveResponse(r);
|
|
205
|
+
task.didFinish();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Re-emit under the app:// URL (same-origin). Strip Content-Encoding (URLSession already
|
|
209
|
+
// decoded the body) and recompute length; keep the upstream Content-Type + status.
|
|
210
|
+
const ct = resp.valueForHTTPHeaderField?.('Content-Type') ?? 'application/octet-stream';
|
|
211
|
+
const out = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
|
|
212
|
+
url, resp.statusCode ?? 200, 'HTTP/1.1',
|
|
213
|
+
{ 'Content-Type': ct, 'Content-Length': String(data?.length ?? 0), 'Cache-Control': 'no-cache' } as any
|
|
214
|
+
);
|
|
215
|
+
task.didReceiveResponse(out);
|
|
216
|
+
if (data) task.didReceiveData(data);
|
|
217
|
+
task.didFinish();
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// task was stopped/cancelled mid-flight — messaging it throws; ignore.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
dataTask.resume();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const HandlerClass = (NSObject as any).extend(
|
|
227
|
+
{
|
|
228
|
+
webViewStartURLSchemeTask(_webView: WKWebView, task: any): void {
|
|
229
|
+
const url = task.request.URL;
|
|
230
|
+
let rel = decodeURIComponent(String(url.path ?? '/')).replace(/^\/+/, '');
|
|
231
|
+
if (!rel) rel = SHELL_CONFIG.entry;
|
|
232
|
+
const filePath = nsPath.join(wwwPath, rel);
|
|
233
|
+
const ext = rel.includes('.') ? rel.split('.').pop()!.toLowerCase() : '';
|
|
234
|
+
|
|
235
|
+
if (File.exists(filePath)) { serveFile(task, url, filePath, ext); return; }
|
|
236
|
+
|
|
237
|
+
const method = String(task.request.HTTPMethod ?? 'GET').toUpperCase();
|
|
238
|
+
const accept = task.request.valueForHTTPHeaderField?.('Accept') ?? '';
|
|
239
|
+
const isNavigation = method === 'GET' && accept.indexOf('text/html') >= 0;
|
|
240
|
+
|
|
241
|
+
if (isNavigation && !ext) { serveFile(task, url, nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html'); return; }
|
|
242
|
+
if (SHELL_CONFIG.backendOrigin) { proxyToBackend(task, url, rel); return; }
|
|
243
|
+
if (!ext) { serveFile(task, url, nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html'); return; }
|
|
244
|
+
|
|
245
|
+
const resp = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
|
|
246
|
+
url, 404, 'HTTP/1.1', { 'Content-Length': '0' } as any
|
|
247
|
+
);
|
|
248
|
+
task.didReceiveResponse(resp);
|
|
249
|
+
task.didFinish();
|
|
250
|
+
},
|
|
251
|
+
webViewStopURLSchemeTask(_webView: WKWebView, _task: any): void {
|
|
252
|
+
// single-shot responses; in-flight proxy completions are guarded by try/catch.
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{ protocols: [WKURLSchemeHandler] }
|
|
256
|
+
);
|
|
257
|
+
return HandlerClass.new();
|
|
258
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { WebView } from '@nativescript/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Android/common CustomWebView. Web→native transport on Android v0 is the
|
|
5
|
+
* polled queue installed by the bootstrap user script (see bridge.ts);
|
|
6
|
+
* a real addJavascriptInterface upgrade comes later.
|
|
7
|
+
*/
|
|
8
|
+
export class CustomWebView extends WebView {
|
|
9
|
+
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
10
|
+
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
/** Pause/resume the render + JS-timer pipeline on app background/foreground. Platform-specific
|
|
13
|
+
* (see custom-webview.android.ts); no-op on iOS, which suspends rAF on its own. */
|
|
14
|
+
setRenderingActive(_active: boolean): void {
|
|
15
|
+
/* overridden per-platform */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Application, isAndroid } from '@nativescript/core';
|
|
2
|
+
|
|
3
|
+
declare const android: any, NSProcessInfo: any, UIAccessibilityIsReduceMotionEnabled: any;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runtime environment hints the page can't reliably detect itself, surfaced natively so a PWA can
|
|
7
|
+
* scale its rendering down inside the shell. Injected at document-start as `window.__APPWRAP__` (see
|
|
8
|
+
* envGlobalsJs) so it's readable BEFORE the page's own scripts decide what to draw.
|
|
9
|
+
*
|
|
10
|
+
* • isEmulator — running on an emulator/simulator. Its GPU is software-rasterized, so a full-screen
|
|
11
|
+
* CSS/canvas animation that's free on real hardware pegs the host CPU. Apps should
|
|
12
|
+
* cap DPR / disable heavy backgrounds when set.
|
|
13
|
+
* • reduceMotion— the OS "reduce motion" accessibility setting is on. Apps should disable non-essential
|
|
14
|
+
* animation (mirrors the `prefers-reduced-motion` media query, but works pre-paint).
|
|
15
|
+
*/
|
|
16
|
+
export interface AppwrapEnv {
|
|
17
|
+
native: boolean;
|
|
18
|
+
platform: 'android' | 'ios';
|
|
19
|
+
isEmulator: boolean;
|
|
20
|
+
reduceMotion: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function detectEnv(): AppwrapEnv {
|
|
24
|
+
return isAndroid
|
|
25
|
+
? { native: true, platform: 'android', isEmulator: androidIsEmulator(), reduceMotion: androidReduceMotion() }
|
|
26
|
+
: { native: true, platform: 'ios', isEmulator: iosIsSimulator(), reduceMotion: iosReduceMotion() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Document-start snippet: merge the detected hints onto `window.__APPWRAP__` (idempotent). */
|
|
30
|
+
export function envGlobalsJs(): string {
|
|
31
|
+
const env = detectEnv();
|
|
32
|
+
return `;(function(){try{window.__APPWRAP__=Object.assign({},window.__APPWRAP__,${JSON.stringify(env)});}catch(e){}})();`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Android ──────────────────────────────────────────────────────────
|
|
36
|
+
function androidIsEmulator(): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const B = android.os.Build;
|
|
39
|
+
const has = (v: any, s: string) => String(v || '').toLowerCase().includes(s);
|
|
40
|
+
return (
|
|
41
|
+
has(B.FINGERPRINT, 'generic') || has(B.FINGERPRINT, 'emulator') || has(B.FINGERPRINT, 'unknown') ||
|
|
42
|
+
has(B.MODEL, 'google_sdk') || has(B.MODEL, 'emulator') || has(B.MODEL, 'android sdk built for') ||
|
|
43
|
+
has(B.MANUFACTURER, 'genymotion') || has(B.BRAND, 'generic') ||
|
|
44
|
+
has(B.HARDWARE, 'goldfish') || has(B.HARDWARE, 'ranchu') || String(B.PRODUCT || '') === 'google_sdk'
|
|
45
|
+
);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.warn('AppWrap: emulator detect failed', e);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function androidReduceMotion(): boolean {
|
|
53
|
+
try {
|
|
54
|
+
const ctx = Application.android.context || Application.android.foregroundActivity;
|
|
55
|
+
// ANIMATOR_DURATION_SCALE == 0 ⇒ user disabled animations (Settings → Accessibility / Developer).
|
|
56
|
+
const scale = android.provider.Settings.Global.getFloat(
|
|
57
|
+
ctx.getContentResolver(), android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1.0
|
|
58
|
+
);
|
|
59
|
+
return scale === 0;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.warn('AppWrap: reduce-motion detect failed', e);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── iOS ──────────────────────────────────────────────────────────────
|
|
67
|
+
function iosIsSimulator(): boolean {
|
|
68
|
+
try {
|
|
69
|
+
return NSProcessInfo.processInfo.environment.objectForKey('SIMULATOR_DEVICE_NAME') != null;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function iosReduceMotion(): boolean {
|
|
76
|
+
try {
|
|
77
|
+
return !!UIAccessibilityIsReduceMotionEnabled();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Application, Connectivity, isAndroid } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { connectivityStatus } from './handlers-extended';
|
|
4
|
+
|
|
5
|
+
let pendingDeepLink: string | null = null;
|
|
6
|
+
let pendingPushTap: { data: Record<string, string> } | null = null;
|
|
7
|
+
// True only once the PWA's JS has handshaked — i.e. the WebView is actually
|
|
8
|
+
// running our bundle and about to subscribe. Native page-load is too early:
|
|
9
|
+
// a deeplink.open emitted then lands in a WebView with no listener and is lost
|
|
10
|
+
// (the cold-start-from-notification bug). So we gate delivery on the handshake.
|
|
11
|
+
let pwaReady = false;
|
|
12
|
+
|
|
13
|
+
/** Called by the iOS delegate (cold start or while running) and Android intents. */
|
|
14
|
+
export function onDeepLink(url: string): void {
|
|
15
|
+
if (pwaReady) bridge.emit('deeplink.open', { url });
|
|
16
|
+
else pendingDeepLink = url; // buffer until the PWA handshakes
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Android: a tray notification (FCM) was tapped → re-launched the activity with the data payload as
|
|
20
|
+
* intent extras. Buffered until handshake like deep links (cold-start-from-notification). iOS routes
|
|
21
|
+
* taps via the AppDelegate (handlers-push onRemoteMessage). */
|
|
22
|
+
export function onPushTap(payload: { data: Record<string, string> }): void {
|
|
23
|
+
if (pwaReady) bridge.emit('push.tap', payload);
|
|
24
|
+
else pendingPushTap = payload;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The PWA completed app.handshake → its JS is live and registers its
|
|
29
|
+
* lifecycle listeners right after kit.ready() resolves. Flush any deep link
|
|
30
|
+
* that arrived during launch (cold start), after a beat for listener install.
|
|
31
|
+
*/
|
|
32
|
+
export function onPwaHandshake(): void {
|
|
33
|
+
if (pwaReady) return;
|
|
34
|
+
pwaReady = true;
|
|
35
|
+
if (pendingDeepLink) {
|
|
36
|
+
const url = pendingDeepLink;
|
|
37
|
+
pendingDeepLink = null;
|
|
38
|
+
setTimeout(() => bridge.emit('deeplink.open', { url }), 500);
|
|
39
|
+
}
|
|
40
|
+
if (pendingPushTap) {
|
|
41
|
+
const payload = pendingPushTap;
|
|
42
|
+
pendingPushTap = null;
|
|
43
|
+
setTimeout(() => bridge.emit('push.tap', payload), 500);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wire lifecycle + connectivity event forwarding (the PWA subscribes to these). */
|
|
48
|
+
export function startEventForwarding(): void {
|
|
49
|
+
Application.on(Application.suspendEvent, () => bridge.emit('app.pause'));
|
|
50
|
+
Application.on(Application.resumeEvent, () => bridge.emit('app.resume'));
|
|
51
|
+
Application.on(Application.orientationChangedEvent, (args: any) =>
|
|
52
|
+
bridge.emit('screen.orientation.change', args?.newValue === 'landscape' ? 'landscape' : 'portrait')
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
Connectivity.startMonitoring(() => bridge.emit('network.change', connectivityStatus()));
|
|
56
|
+
|
|
57
|
+
if (isAndroid) wireAndroidDeepLinks();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Intents: launch intent (cold start) + onNewIntent (warm, singleTask). Carries both VIEW deep links
|
|
61
|
+
* and FCM notification-tap extras. */
|
|
62
|
+
function wireAndroidDeepLinks(): void {
|
|
63
|
+
const emitFromIntent = (intent: any) => {
|
|
64
|
+
try {
|
|
65
|
+
const data = intent?.getData?.();
|
|
66
|
+
if (data) onDeepLink(String(data.toString()));
|
|
67
|
+
const tap = readFcmTapExtras(intent);
|
|
68
|
+
if (tap) onPushTap(tap);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.warn('AppWrap: intent read failed', e);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
Application.android.on(Application.android.activityNewIntentEvent as any, (args: any) =>
|
|
75
|
+
emitFromIntent(args.intent)
|
|
76
|
+
);
|
|
77
|
+
emitFromIntent(Application.android.startActivity?.getIntent?.());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A tapped FCM notification re-launches the activity with the message's data payload as string
|
|
81
|
+
* extras, alongside FCM's own `google.*`/`gcm.*`/`from`/`collapse_key` bookkeeping keys. Detect via
|
|
82
|
+
* those markers; return the app's data keys only. Null when this isn't an FCM-originated intent. */
|
|
83
|
+
function readFcmTapExtras(intent: any): { data: Record<string, string> } | null {
|
|
84
|
+
try {
|
|
85
|
+
const extras = intent?.getExtras?.();
|
|
86
|
+
if (!extras || (!extras.containsKey('google.message_id') && !extras.containsKey('from'))) return null;
|
|
87
|
+
const data: Record<string, string> = {};
|
|
88
|
+
const it = extras.keySet().iterator();
|
|
89
|
+
while (it.hasNext()) {
|
|
90
|
+
const k = String(it.next());
|
|
91
|
+
if (k.startsWith('google.') || k.startsWith('gcm.') || k === 'from' || k === 'collapse_key') continue;
|
|
92
|
+
const v = extras.get(k);
|
|
93
|
+
if (v != null) data[k] = String(v);
|
|
94
|
+
}
|
|
95
|
+
return { data };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.warn('AppWrap: FCM extras read failed', e);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Generated by `appwrap` — imports the FCM messaging service only when push is wired. Do not edit. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ambient declarations for the Firebase Messaging Android classes the FCM service extends.
|
|
3
|
+
* The full classes are provided at runtime by the `firebase-messaging` AAR (added by the CLI only
|
|
4
|
+
* when push is wired) — the NativeScript Android runtime marshals them. Declare-merges into the
|
|
5
|
+
* `com` namespace from @nativescript/types-android. Mirrors ios-frameworks.d.ts.
|
|
6
|
+
*/
|
|
7
|
+
declare namespace com.google.firebase.messaging {
|
|
8
|
+
class RemoteMessage {
|
|
9
|
+
getData(): any /* java.util.Map<string,string> */;
|
|
10
|
+
getNotification(): { getTitle(): string; getBody(): string } | null;
|
|
11
|
+
}
|
|
12
|
+
class FirebaseMessagingService extends android.app.Service {
|
|
13
|
+
onMessageReceived(message: RemoteMessage): void;
|
|
14
|
+
onNewToken(token: string): void;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core';
|
|
2
|
+
import { onFcmMessage } from './handlers-push';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hand-rolled FirebaseMessagingService (plugin-free) — receives FCM messages while the app process
|
|
6
|
+
* is alive and forwards them to the JS bridge as `push.message`. Covers DATA payloads + foreground
|
|
7
|
+
* delivery of notification payloads (which FCM withholds from the tray while the app is foreground).
|
|
8
|
+
*
|
|
9
|
+
* GATING is load-bearing: this class extends a Firebase base that only exists on the classpath when
|
|
10
|
+
* push is wired. It's bundled solely via `fcm-bootstrap.generated.ts` (the CLI imports it only when
|
|
11
|
+
* google-services.json is present) and the `<service>` is stamped into AndroidManifest only then —
|
|
12
|
+
* so a non-push Android build never compiles a class extending an absent base.
|
|
13
|
+
*
|
|
14
|
+
* Tray-notification taps are routed separately (events.ts reads the launch Intent's FCM extras →
|
|
15
|
+
* `push.tap`); this service is the foreground/data path.
|
|
16
|
+
*/
|
|
17
|
+
@NativeClass()
|
|
18
|
+
@JavaProxy('cc.livx.appwrap.AppwrapMessagingService')
|
|
19
|
+
export class AppwrapMessagingService extends com.google.firebase.messaging.FirebaseMessagingService {
|
|
20
|
+
onMessageReceived(message: com.google.firebase.messaging.RemoteMessage): void {
|
|
21
|
+
super.onMessageReceived(message);
|
|
22
|
+
try {
|
|
23
|
+
const data: Record<string, string> = {};
|
|
24
|
+
const map = message.getData();
|
|
25
|
+
const keys = map.keySet().toArray();
|
|
26
|
+
for (let i = 0; i < keys.length; i++) {
|
|
27
|
+
const k = String(keys[i]);
|
|
28
|
+
data[k] = String(map.get(k));
|
|
29
|
+
}
|
|
30
|
+
const notif = message.getNotification();
|
|
31
|
+
const title = notif ? notif.getTitle() : undefined;
|
|
32
|
+
const body = notif ? notif.getBody() : undefined;
|
|
33
|
+
// bridge.emit evaluates JS on the live WebView (main-thread only); the service runs on a binder
|
|
34
|
+
// thread. No-ops gracefully if the WebView isn't up (process booted headlessly for a data msg).
|
|
35
|
+
Utils.dispatchToMainThread(() => onFcmMessage(data, title || undefined, body || undefined));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.log('[push] onMessageReceived error: ' + e);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|