@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/package.json +46 -0
  4. package/runtime/App_Resources/Android/app.gradle +34 -0
  5. package/runtime/App_Resources/Android/before-plugins.gradle +15 -0
  6. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +76 -0
  7. package/runtime/App_Resources/Android/src/main/res/drawable/ic_launcher_foreground.xml +15 -0
  8. package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/background.png +0 -0
  9. package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/logo.png +0 -0
  10. package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/background.png +0 -0
  11. package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/logo.png +0 -0
  12. package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/background.png +0 -0
  13. package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/logo.png +0 -0
  14. package/runtime/App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml +8 -0
  15. package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/background.png +0 -0
  16. package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/logo.png +0 -0
  17. package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/background.png +0 -0
  18. package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/logo.png +0 -0
  19. package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/background.png +0 -0
  20. package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/logo.png +0 -0
  21. package/runtime/App_Resources/Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  22. package/runtime/App_Resources/Android/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  23. package/runtime/App_Resources/Android/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  24. package/runtime/App_Resources/Android/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  25. package/runtime/App_Resources/Android/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  26. package/runtime/App_Resources/Android/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  27. package/runtime/App_Resources/Android/src/main/res/values/colors.xml +14 -0
  28. package/runtime/App_Resources/Android/src/main/res/values/ic_launcher_background.xml +4 -0
  29. package/runtime/App_Resources/Android/src/main/res/values/styles.xml +42 -0
  30. package/runtime/App_Resources/Android/src/main/res/values-v21/colors.xml +5 -0
  31. package/runtime/App_Resources/Android/src/main/res/values-v21/styles.xml +32 -0
  32. package/runtime/App_Resources/Android/src/main/res/values-v29/styles.xml +18 -0
  33. package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +5 -0
  34. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
  35. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
  36. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20.png +0 -0
  37. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png +0 -0
  38. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png +0 -0
  39. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29.png +0 -0
  40. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png +0 -0
  41. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png +0 -0
  42. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40.png +0 -0
  43. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png +0 -0
  44. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png +0 -0
  45. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png +0 -0
  46. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png +0 -0
  47. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76.png +0 -0
  48. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png +0 -0
  49. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png +0 -0
  50. package/runtime/App_Resources/iOS/Assets.xcassets/Contents.json +6 -0
  51. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/Contents.json +23 -0
  52. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill.png +0 -0
  53. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@2x.png +0 -0
  54. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@3x.png +0 -0
  55. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/Contents.json +23 -0
  56. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center.png +0 -0
  57. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@2x.png +0 -0
  58. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@3x.png +0 -0
  59. package/runtime/App_Resources/iOS/Info.plist +60 -0
  60. package/runtime/App_Resources/iOS/LaunchScreen.storyboard +53 -0
  61. package/runtime/App_Resources/iOS/Podfile +13 -0
  62. package/runtime/App_Resources/iOS/PrivacyInfo.xcprivacy +59 -0
  63. package/runtime/App_Resources/iOS/build.xcconfig +7 -0
  64. package/runtime/app/app.ts +58 -0
  65. package/runtime/app/main-page.ts +116 -0
  66. package/runtime/app/main-page.xml +10 -0
  67. package/runtime/app/shell/active-modules.generated.ts +9 -0
  68. package/runtime/app/shell/android-helpers.ts +76 -0
  69. package/runtime/app/shell/bridge.ts +99 -0
  70. package/runtime/app/shell/capabilities.manifest.ts +207 -0
  71. package/runtime/app/shell/config.ts +35 -0
  72. package/runtime/app/shell/custom-webview.android.ts +270 -0
  73. package/runtime/app/shell/custom-webview.ios.ts +258 -0
  74. package/runtime/app/shell/custom-webview.ts +17 -0
  75. package/runtime/app/shell/env.ts +81 -0
  76. package/runtime/app/shell/events.ts +100 -0
  77. package/runtime/app/shell/fcm-bootstrap.generated.ts +1 -0
  78. package/runtime/app/shell/fcm-firebase.d.ts +16 -0
  79. package/runtime/app/shell/fcm-service.android.ts +40 -0
  80. package/runtime/app/shell/fcm-service.ios.ts +3 -0
  81. package/runtime/app/shell/handlers-android.ts +417 -0
  82. package/runtime/app/shell/handlers-billing.ts +197 -0
  83. package/runtime/app/shell/handlers-extended.ts +405 -0
  84. package/runtime/app/shell/handlers-health.ts +134 -0
  85. package/runtime/app/shell/handlers-media.ts +126 -0
  86. package/runtime/app/shell/handlers-oauth.ts +69 -0
  87. package/runtime/app/shell/handlers-parity.ts +257 -0
  88. package/runtime/app/shell/handlers-push.ts +189 -0
  89. package/runtime/app/shell/handlers-system.ts +70 -0
  90. package/runtime/app/shell/handlers.ts +149 -0
  91. package/runtime/app/shell/ios-frameworks.d.ts +36 -0
  92. package/runtime/app/shell/ios-image.ts +15 -0
  93. package/runtime/app/shell/ios-ui-delegate.ts +131 -0
  94. package/runtime/app/shell/mime.ts +29 -0
  95. package/runtime/app/shell/native-log.ts +22 -0
  96. package/runtime/app/shell/optional-handlers.generated.ts +10 -0
  97. package/runtime/app/shell/orientation.ts +43 -0
  98. package/runtime/app/shell/status-bar.ts +129 -0
  99. package/runtime/app/shell/toast.ts +67 -0
  100. package/runtime/app/shell/web-quirks.ts +131 -0
  101. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +74 -0
  102. package/runtime/nativescript.config.ts +15 -0
  103. package/runtime/package.json +21 -0
  104. package/runtime/references.d.ts +2 -0
  105. package/runtime/tsconfig.json +19 -0
  106. package/runtime/webpack.config.js +27 -0
  107. package/scripts/stage-assets.mjs +21 -0
  108. package/src/cli.ts +1328 -0
  109. package/templates/ci/fastlane/Fastfile +43 -0
  110. package/templates/ci/fastlane/Matchfile +3 -0
  111. package/templates/ci/github/workflows/appwrap-pr.yml +28 -0
  112. package/templates/ci/github/workflows/appwrap-release-android.yml +51 -0
  113. 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
+ }
@@ -0,0 +1,3 @@
1
+ // iOS has no FirebaseMessagingService — inbound APNs is handled by the AppDelegate (handlers-push.ts).
2
+ // This stub lets `import './fcm-service'` resolve on both platforms (NS platform-suffix resolution).
3
+ export {};