@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,207 @@
1
+ /**
2
+ * Capability manifest — the SINGLE, self-contained declaration of every appwrap capability.
3
+ *
4
+ * PURE DATA — no NativeScript globals — so the build-time CLI (bun) imports it to compose the shell,
5
+ * and the runtime imports it to build the handshake capability map. Each entry fully describes one
6
+ * capability's needs (permissions, background modes, native deps); the shell COLLECTS the union
7
+ * across the active set and dedups (two modules needing camera → one perm stamped once).
8
+ *
9
+ * Tiers:
10
+ * - `core: true` → always compiled in; cheap, no permissions (haptics, storage, toast, …).
11
+ * - opt-in (default) → only active when listed in `appwrap.json.modules` (explicit mode). These
12
+ * carry permissions / native deps / weight, so they're stripped from a build that doesn't ask
13
+ * for them — no bundled handler advertisement, no permission prompt, no native dependency.
14
+ *
15
+ * Back-compat: when `appwrap.json` has NO `modules` key, the CLI treats EVERY capability as active
16
+ * (today's behavior) and permissions still come solely from `appwrap.json.permissions{}`.
17
+ */
18
+
19
+ /** A native permission + a default human usage string (Apple requires app-facing copy).
20
+ * `appwrap.json.permissions{<domain>}` overrides the copy per-app; the key here is the iOS Info.plist
21
+ * key. Android perms need no usage string. */
22
+ export interface IosPermission {
23
+ /** Info.plist key, e.g. 'NSCameraUsageDescription'. */
24
+ key: string;
25
+ /** Override-key into `appwrap.json.permissions{}` (the legacy domain name). */
26
+ domain: string;
27
+ /** Default usage string if the app doesn't override it. */
28
+ defaultUsage: string;
29
+ }
30
+
31
+ export interface ModuleManifest {
32
+ /** Capability name (also the handshake key + `kit.<name>` where 1:1). */
33
+ name: string;
34
+ /** Always-on, cheap, permission-free. Omitted/false = opt-in (gated by appwrap.json.modules). */
35
+ core?: boolean;
36
+ /** Handshake capability keys this module provides. Value 'native', or per-platform gating. */
37
+ capabilities: Record<string, 'native' | { ios?: boolean; android?: boolean }>;
38
+ /** The registration group this capability belongs to. Core caps share legacy groups (bundled
39
+ * together); opt-in NEW modules name their own group → the CLI imports it only when active, so it
40
+ * tree-shakes out of a build that doesn't use it. */
41
+ group: string;
42
+ ios?: {
43
+ permissions?: IosPermission[];
44
+ /** app.entitlements key/values (e.g. `{ 'com.apple.developer.healthkit': true }`). The CLI merges
45
+ * these (across active modules + push) into one App_Resources/iOS/app.entitlements. Values may be
46
+ * boolean, string, or string[]. */
47
+ entitlements?: Record<string, boolean | string | string[]>;
48
+ };
49
+ android?: {
50
+ /** AndroidManifest <uses-permission> names. */
51
+ permissions?: string[];
52
+ /** app.gradle dependency coordinates, e.g. 'androidx.health.connect:connect-client:1.1.0-rc01'. */
53
+ gradleDeps?: string[];
54
+ /** Module ships Kotlin native source → the CLI enables Kotlin in the NS Android build. */
55
+ kotlin?: boolean;
56
+ /** Raw XML injected inside the AndroidManifest `<application>` (activities, providers,
57
+ * intent-filters a capability needs — e.g. Health Connect's permission-rationale activity). */
58
+ manifestApplication?: string;
59
+ };
60
+ /** Module-owned native source: a dir under `runtime/modules-native/<nativeSrc>/` mirroring the
61
+ * App_Resources layout, copied into `native/` ONLY when the module is active (stays strippable).
62
+ * Defaults to the module `name` when the convention dir exists. */
63
+ nativeSrc?: string;
64
+ }
65
+
66
+ export const MODULES: ModuleManifest[] = [
67
+ // ── core (always on, no permissions) ───────────────────────────────────
68
+ { name: 'haptics', core: true, group: 'core', capabilities: { haptics: 'native' } },
69
+ { name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' } },
70
+ { name: 'storage', core: true, group: 'core', capabilities: { storage: 'native', secureStorage: 'native' } },
71
+ { name: 'toast', core: true, group: 'core', capabilities: { toast: 'native' } },
72
+ { name: 'statusBar', core: true, group: 'core', capabilities: { statusBar: 'native', themeColor: 'native' } },
73
+ { name: 'device', core: true, group: 'extended', capabilities: { device: 'native' } },
74
+ { name: 'clipboard', core: true, group: 'extended', capabilities: { clipboard: 'native' } },
75
+ { name: 'network', core: true, group: 'extended', capabilities: { network: 'native' } },
76
+ { name: 'screen', core: true, group: 'extended', capabilities: { screen: 'native', dialogs: 'native', orientation: 'native' } },
77
+ { name: 'app', core: true, group: 'system', capabilities: { app: 'native', browser: 'native' } },
78
+
79
+ // ── opt-in: permission / dependency / weight-bearing ───────────────────
80
+ // POST_NOTIFICATIONS / VIBRATE / USE_BIOMETRIC etc. are in the template's baseline manifest already.
81
+ { name: 'notifications', group: 'extended', capabilities: { notifications: 'native' } },
82
+ {
83
+ name: 'biometrics', group: 'extended',
84
+ capabilities: { biometrics: 'native' },
85
+ ios: { permissions: [{ key: 'NSFaceIDUsageDescription', domain: 'faceid', defaultUsage: 'Authenticate with Face ID.' }] },
86
+ },
87
+ {
88
+ name: 'geo', group: 'extended',
89
+ capabilities: { geo: 'native' },
90
+ ios: { permissions: [{ key: 'NSLocationWhenInUseUsageDescription', domain: 'location', defaultUsage: 'Show your location.' }] },
91
+ android: { permissions: ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'] },
92
+ },
93
+ {
94
+ name: 'photos', group: 'extended',
95
+ capabilities: { photos: 'native' },
96
+ ios: { permissions: [{ key: 'NSPhotoLibraryUsageDescription', domain: 'photos', defaultUsage: 'Pick a photo from your library.' }] },
97
+ },
98
+ {
99
+ name: 'camera', group: 'media',
100
+ capabilities: { camera: 'native' },
101
+ ios: { permissions: [{ key: 'NSCameraUsageDescription', domain: 'camera', defaultUsage: 'Capture a photo.' }] },
102
+ android: { permissions: ['android.permission.CAMERA'] },
103
+ },
104
+ {
105
+ name: 'media', group: 'media',
106
+ capabilities: { media: 'native' },
107
+ ios: { permissions: [
108
+ { key: 'NSCameraUsageDescription', domain: 'camera', defaultUsage: 'Use the camera.' },
109
+ { key: 'NSMicrophoneUsageDescription', domain: 'microphone', defaultUsage: 'Use the microphone.' },
110
+ ] },
111
+ android: { permissions: ['android.permission.CAMERA', 'android.permission.RECORD_AUDIO'] },
112
+ },
113
+ {
114
+ name: 'motion', group: 'parity',
115
+ capabilities: { motion: 'native' },
116
+ ios: { permissions: [{ key: 'NSMotionUsageDescription', domain: 'motion', defaultUsage: 'Read device motion sensors.' }] },
117
+ },
118
+ {
119
+ name: 'contacts', group: 'parity',
120
+ capabilities: { contacts: 'native' },
121
+ android: { permissions: ['android.permission.READ_CONTACTS'] },
122
+ },
123
+ {
124
+ name: 'calendar', group: 'parity',
125
+ capabilities: { calendar: 'native' },
126
+ ios: { permissions: [
127
+ { key: 'NSCalendarsFullAccessUsageDescription', domain: 'calendar', defaultUsage: 'Add events to your calendar.' },
128
+ { key: 'NSCalendarsUsageDescription', domain: 'calendar', defaultUsage: 'Add events to your calendar.' },
129
+ ] },
130
+ android: { permissions: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'] },
131
+ },
132
+ {
133
+ name: 'reviews', group: 'system',
134
+ capabilities: { reviews: { ios: true, android: false } },
135
+ },
136
+ {
137
+ name: 'billing', group: 'billing',
138
+ capabilities: { billing: { ios: true, android: false } },
139
+ },
140
+
141
+ // ── oauth — system-browser OAuth (iOS ASWebAuthenticationSession) ──
142
+ // Opt-in, strippable (own handler file). No permissions/entitlements — ASWebAuthenticationSession
143
+ // needs none; it only relies on the app's urlScheme so the provider redirect returns to the app.
144
+ // Lets Google et al. complete sign-in they reject inside an embedded WebView (disallowed_useragent).
145
+ // Android (Custom Tabs + intent callback) is a future addition.
146
+ {
147
+ name: 'oauth', group: 'oauth',
148
+ capabilities: { oauth: { ios: true, android: false } },
149
+ },
150
+
151
+ // ── health (steps) — opt-in heavy module; FG live + BG via OS step store ──
152
+ {
153
+ name: 'health', group: 'health',
154
+ capabilities: { health: 'native' },
155
+ // iOS reads HealthKit (the Health app's aggregated total incl. Apple Watch): health-share usage
156
+ // string + the healthkit entitlement, both module-owned. No background execution — HealthKit
157
+ // already has the steps the OS recorded while the app was killed.
158
+ ios: {
159
+ permissions: [{ key: 'NSHealthShareUsageDescription', domain: 'health', defaultUsage: 'Read your step count from the Health app.' }],
160
+ entitlements: { 'com.apple.developer.healthkit': true },
161
+ },
162
+ // Android primary = Health Connect (system store, Wear-inclusive, survives kill — matches iOS).
163
+ // Its client is Kotlin-coroutine-only, so the module ships HealthConnectBridge.kt (nativeSrc) and
164
+ // flags kotlin:true. Falls back to SensorManager TYPE_STEP_COUNTER (ACTIVITY_RECOGNITION) when HC absent.
165
+ android: {
166
+ permissions: ['android.permission.health.READ_STEPS', 'android.permission.ACTIVITY_RECOGNITION'],
167
+ gradleDeps: [
168
+ 'androidx.health.connect:connect-client:1.1.0-rc01',
169
+ 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0',
170
+ ],
171
+ kotlin: true,
172
+ // Health Connect refuses to honor permissions unless the app declares a rationale activity
173
+ // handling VIEW_PERMISSION_USAGE / HEALTH_PERMISSIONS (alias onto the NS main activity).
174
+ manifestApplication: `<activity-alias
175
+ android:name="ViewPermissionUsageActivity"
176
+ android:exported="true"
177
+ android:targetActivity="com.tns.NativeScriptActivity"
178
+ android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
179
+ <intent-filter>
180
+ <action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
181
+ <category android:name="android.intent.category.HEALTH_PERMISSIONS" />
182
+ </intent-filter>
183
+ </activity-alias>`,
184
+ },
185
+ nativeSrc: 'health',
186
+ },
187
+ ];
188
+
189
+ /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
190
+ * groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
191
+ export const OPTIONAL_GROUPS = ['health', 'oauth'] as const;
192
+
193
+ /** Resolve the active capability map for the handshake from a set of active capability names. */
194
+ export function buildCapabilityMap(
195
+ activeNames: Set<string>,
196
+ platform: 'ios' | 'android'
197
+ ): Record<string, 'native' | 'none'> {
198
+ const map: Record<string, 'native' | 'none'> = {};
199
+ for (const m of MODULES) {
200
+ if (!m.core && !activeNames.has(m.name)) continue;
201
+ for (const [cap, val] of Object.entries(m.capabilities)) {
202
+ if (val === 'native') map[cap] = 'native';
203
+ else map[cap] = val[platform] ? 'native' : 'none';
204
+ }
205
+ }
206
+ return map;
207
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shell config — stamped by `appwrap init` from appwrap.json.
3
+ */
4
+ export const SHELL_CONFIG = {
5
+ appId: 'cc.livx.hellowrap',
6
+ name: 'Hello AppWrap',
7
+ version: '0.1.0',
8
+ /** Entry file inside the bundled www/ folder. */
9
+ entry: 'index.html',
10
+ /** Page + status bar background while the WebView boots. */
11
+ backgroundColor: '#0b1020',
12
+ /** 'light' = white status bar icons. */
13
+ statusBarStyle: 'light' as 'light' | 'dark',
14
+ /** Android only (experimental). true = WebView draws edge-to-edge under transparent bars +
15
+ * safe-area insets injected as `--saie-*` CSS vars; false = bars show the page backgroundColor. */
16
+ edgeToEdge: false,
17
+ /** Loader: 'app' = app:// scheme handler (stable origin, ES modules, bundled www); 'file' = debug
18
+ * fallback; 'server' = load `serverUrl` live (dev HMR over LAN, or pointing at a deployed URL). */
19
+ loader: 'app' as 'app' | 'file' | 'server',
20
+ /** Live URL loaded when loader === 'server' (e.g. http://192.168.x.x:5173 dev, or a deployed https URL). */
21
+ serverUrl: '',
22
+ /** Absolute backend origin for an offline (app://) PWA whose API/WS calls were originally same-origin.
23
+ * Injected to the page as `window.__APPWRAP_BACKEND_ORIGIN__`; empty = same-origin (browser default). */
24
+ backendOrigin: '',
25
+ /** Debug/dev mode: keep the screen awake (no auto-lock while foreground) + WebView inspectable
26
+ * (Safari Web Inspector / chrome://inspect). Set true by `appwrap deploy`; never in store builds. */
27
+ debug: false,
28
+ /** Value written to `localStorage.DEBUG` in debug mode so the PWA logger goes verbose ('*' = all). */
29
+ debugLog: '*',
30
+ /** Remote push configured, per platform (iOS aps-environment entitlement / Android FCM). Drives the
31
+ * `push` capability flag at runtime by platform — off unless `appwrap.json.push` enables it, so an
32
+ * un-provisioned build honestly reports 'none' (and a personal-team iOS build keeps `pushIos:false`). */
33
+ pushIos: false,
34
+ pushAndroid: false,
35
+ };
@@ -0,0 +1,270 @@
1
+ import { WebView, Utils, 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 } from './web-quirks';
5
+ import { envGlobalsJs } from './env';
6
+ import { requestPermissions } from './android-helpers';
7
+
8
+ declare const android: any, java: any, androidx: any;
9
+
10
+ /** Stable in-app origin — secure context, ES modules work (file:// blocks them). */
11
+ export const APP_ORIGIN = 'https://appwrap.local';
12
+
13
+ /**
14
+ * Web→native transport shim. postMessage tunnels through window.prompt(), which
15
+ * the shell intercepts synchronously in WebChromeClient.onJsPrompt — no compiled
16
+ * @JavascriptInterface class, no polling. Injected at document start (androidx.webkit)
17
+ * with an onPageStarted fallback; the guard makes double-injection a no-op.
18
+ */
19
+ const TRANSPORT_SHIM = `(function(){
20
+ if (window.appwrapNative) return;
21
+ window.appwrapNative = { postMessage: function(json){ window.prompt('__appwrap__:' + json); } };
22
+ })();`;
23
+
24
+ const PROMPT_PREFIX = '__appwrap__:';
25
+
26
+ /** Document-start scripts: env hints + framework globals (backend origin) + bridge transport +
27
+ * native-feel. Globals first so the page can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__ before its
28
+ * own scripts run. Built lazily (not a const) so envGlobalsJs() detects against a live activity context. */
29
+ function buildBootstrapJs(): string {
30
+ return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${NATIVE_FEEL_JS}`;
31
+ }
32
+
33
+ /**
34
+ * Android CustomWebView — replaces NS's default clients after creation:
35
+ * WebChromeClient.onJsPrompt is the appwrap transport, WebViewClient
36
+ * intercepts https://appwrap.local/* and serves the bundled PWA from www/
37
+ * (mirror of the iOS app:// WKURLSchemeHandler).
38
+ */
39
+ export class CustomWebView extends WebView {
40
+ /** Set by the bridge before load; receives raw envelope JSON. */
41
+ onAppwrapMessage: ((json: string) => void) | null = null;
42
+
43
+ initNativeView(): void {
44
+ super.initNativeView();
45
+ const wv = this.android;
46
+ if (!wv) return;
47
+
48
+ // Debug mode only: debuggable via chrome://inspect (full console + network) + keep the screen
49
+ // awake while foreground for continuous troubleshooting. Off in store builds.
50
+ if (SHELL_CONFIG.debug) {
51
+ try { android.webkit.WebView.setWebContentsDebuggingEnabled(true); } catch (e) { /* older API */ }
52
+ try { wv.setKeepScreenOn(true); } catch (e) { /* no-op */ }
53
+ }
54
+
55
+ const settings = wv.getSettings();
56
+ settings.setJavaScriptEnabled(true);
57
+ settings.setDomStorageEnabled(true);
58
+ settings.setAllowFileAccess(true);
59
+ settings.setMediaPlaybackRequiresUserGesture(false); // speaker: autoplay allowed
60
+ // Native feel: no pinch-zoom controls.
61
+ settings.setSupportZoom(false);
62
+ settings.setBuiltInZoomControls(false);
63
+ settings.setDisplayZoomControls(false);
64
+ // No rubber-band overscroll glow.
65
+ wv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER);
66
+
67
+ this.installDocumentStartShim(wv);
68
+ wv.setWebViewClient(createAssetServingClient());
69
+ wv.setWebChromeClient(this.createTransportChromeClient());
70
+ }
71
+
72
+ /**
73
+ * Stop/restart the WebView's render + JS-timer pipeline when the app is backgrounded/foregrounded
74
+ * (wired to Application suspend/resume in main-page). Android does NOT reliably halt rAF/CSS
75
+ * animations when the activity isn't visible, so a page running a continuous animation keeps the
76
+ * compositor — and the CPU — busy in the background. onPause()/pauseTimers() stops that; the latter
77
+ * is process-wide but harmless with our single WebView. iOS suspends rAF on its own (no-op there).
78
+ */
79
+ setRenderingActive(active: boolean): void {
80
+ const wv = this.android;
81
+ if (!wv) return;
82
+ try {
83
+ if (active) { wv.onResume(); wv.resumeTimers(); }
84
+ else { wv.onPause(); wv.pauseTimers(); }
85
+ } catch (e) {
86
+ console.warn('AppWrap: setRenderingActive failed', e);
87
+ }
88
+ }
89
+
90
+ private installDocumentStartShim(wv: any): void {
91
+ try {
92
+ const wkt = androidx.webkit;
93
+ if (wkt.WebViewFeature.isFeatureSupported(wkt.WebViewFeature.DOCUMENT_START_SCRIPT)) {
94
+ const rules = new java.util.HashSet();
95
+ rules.add(APP_ORIGIN);
96
+ wkt.WebViewCompat.addDocumentStartJavaScript(wv, buildBootstrapJs(), rules);
97
+ return;
98
+ }
99
+ console.warn('AppWrap: DOCUMENT_START_SCRIPT unsupported — relying on onPageStarted shim');
100
+ } catch (e) {
101
+ console.warn('AppWrap: addDocumentStartJavaScript failed', e);
102
+ }
103
+ }
104
+
105
+ private createTransportChromeClient(): any {
106
+ const owner = new WeakRef<CustomWebView>(this);
107
+ const ChromeClient = (android.webkit.WebChromeClient as any).extend({
108
+ onJsPrompt(_view: any, _url: string, message: string, _defaultValue: string, result: any): boolean {
109
+ if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
110
+ result.confirm('');
111
+ const view = owner.get ? owner.get() : (owner as any).deref();
112
+ view?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
113
+ return true;
114
+ }
115
+ return false; // genuine page prompt — default handling
116
+ },
117
+
118
+ // getUserMedia: grant the WebView's per-origin capture after ensuring the
119
+ // app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
120
+ onPermissionRequest(request: any): void {
121
+ const PR = android.webkit.PermissionRequest;
122
+ const resources: string[] = Array.from(request.getResources());
123
+ const perms = new Set<string>();
124
+ for (const r of resources) {
125
+ if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
126
+ if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
127
+ }
128
+ if (!perms.size) {
129
+ Utils.dispatchToMainThread(() => request.grant(request.getResources()));
130
+ return;
131
+ }
132
+ requestPermissions(Array.from(perms)).then((ok) =>
133
+ Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
134
+ );
135
+ },
136
+ });
137
+ return new ChromeClient();
138
+ }
139
+ }
140
+
141
+ /** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses. */
142
+ function createAssetServingClient(): any {
143
+ const wwwPath = nsPath.join(knownFolders.currentApp().path, 'www');
144
+
145
+ const respond = (filePath: string, ext: string) => {
146
+ const stream = new java.io.FileInputStream(filePath);
147
+ return new android.webkit.WebResourceResponse(mimeFor(ext, filePath), 'utf-8', stream);
148
+ };
149
+
150
+ const emptyStream = () => new java.io.ByteArrayInputStream((Array as any).create('byte', 0));
151
+
152
+ /**
153
+ * PROXY a non-local path to the backend (mirror of the iOS scheme-handler proxy) so a
154
+ * same-origin PWA's relative /api, /functions and vendor (/_vendor/sdk.js) requests resolve
155
+ * under app://appwrap.local — no CORS, same-origin.
156
+ *
157
+ * THREADING: NativeScript marshals the shouldInterceptRequest JS callback onto the JS/main thread,
158
+ * so a synchronous HttpURLConnection here throws NetworkOnMainThreadException. Instead we return
159
+ * immediately with a PipedInputStream and do the network read on a background java.lang.Thread that
160
+ * pumps the body into the pipe — the WebView drains it lazily. mime/charset are best-effort from the
161
+ * path extension (we can't read upstream headers without first blocking).
162
+ *
163
+ * LIMITATION: Android's shouldInterceptRequest exposes no request body, so only body-less methods
164
+ * (GET/HEAD) are proxied — POST/PUT/PATCH return 501 rather than silently sending an empty body.
165
+ * (BodDB writes ride the WS, not HTTP POST, so this is rarely hit.)
166
+ */
167
+ const proxyToBackend = (rel: string, query: string, ext: string, method: string, headers: any): any => {
168
+ if (method !== 'GET' && method !== 'HEAD') {
169
+ return new android.webkit.WebResourceResponse(
170
+ 'text/plain', 'utf-8', 501, 'Not Implemented', new java.util.HashMap(),
171
+ new java.io.ByteArrayInputStream(new java.lang.String(
172
+ 'appwrap Android shell cannot proxy request bodies (shouldInterceptRequest limitation)'
173
+ ).getBytes('UTF-8'))
174
+ );
175
+ }
176
+ const pin = new java.io.PipedInputStream(1 << 16);
177
+ const pout = new java.io.PipedOutputStream(pin);
178
+ const target = `${SHELL_CONFIG.backendOrigin.replace(/\/+$/, '')}/${rel}${query}`;
179
+ const pump = new java.lang.Runnable({
180
+ run() {
181
+ let is: any = null;
182
+ try {
183
+ const conn = new java.net.URL(target).openConnection();
184
+ conn.setRequestMethod(method);
185
+ conn.setInstanceFollowRedirects(true);
186
+ conn.setConnectTimeout(15000);
187
+ conn.setReadTimeout(20000);
188
+ if (headers) {
189
+ const it = headers.entrySet().iterator();
190
+ while (it.hasNext()) {
191
+ const e = it.next();
192
+ const k = String(e.getKey());
193
+ if (k.toLowerCase() === 'host') continue; // let URLConnection set Host from the target
194
+ conn.setRequestProperty(k, String(e.getValue()));
195
+ }
196
+ }
197
+ conn.connect();
198
+ const status = conn.getResponseCode();
199
+ is = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
200
+ const buf = (Array as any).create('byte', 16384);
201
+ let n: number;
202
+ while (is && (n = is.read(buf)) !== -1) pout.write(buf, 0, n);
203
+ } catch (e) {
204
+ console.log('[appwrap] proxy failed for /' + rel + ': ' + e);
205
+ } finally {
206
+ try { if (is) is.close(); } catch (e) { /* no-op */ }
207
+ try { pout.close(); } catch (e) { /* no-op */ }
208
+ }
209
+ },
210
+ });
211
+ new java.lang.Thread(pump).start();
212
+ const outHeaders = new java.util.HashMap();
213
+ outHeaders.put('Access-Control-Allow-Origin', '*');
214
+ outHeaders.put('Cache-Control', 'no-cache');
215
+ // mime/charset best-effort from the path; status 200 (real status is unknown until the bg read).
216
+ return new android.webkit.WebResourceResponse(mimeFor(ext, rel), 'utf-8', 200, 'OK', outHeaders, pin);
217
+ };
218
+
219
+ const Client = (android.webkit.WebViewClient as any).extend({
220
+ shouldInterceptRequest(_view: any, request: any): any {
221
+ const isObj = typeof request !== 'string';
222
+ const url: string = isObj ? String(request.getUrl().toString()) : request;
223
+ if (!url.startsWith(APP_ORIGIN)) return null; // external — let WebView handle it
224
+
225
+ const tail = url.slice(APP_ORIGIN.length);
226
+ const qIdx = tail.search(/[?#]/);
227
+ const query = qIdx >= 0 && tail[qIdx] === '?' ? tail.slice(qIdx).split('#')[0] : '';
228
+ let rel = decodeURIComponent(tail.split(/[?#]/)[0]).replace(/^\/+/, '');
229
+ if (!rel) rel = SHELL_CONFIG.entry;
230
+ let filePath = nsPath.join(wwwPath, rel);
231
+ const ext = rel.includes('.') ? rel.split('.').pop()!.toLowerCase() : '';
232
+
233
+ if (File.exists(filePath)) return respond(filePath, ext);
234
+
235
+ if (!ext) {
236
+ // extension-less navigation → SPA fallback
237
+ return respond(nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html');
238
+ }
239
+ // Not a local asset → proxy to the backend (mirror of iOS), making relative
240
+ // backend/vendor requests same-origin under app://appwrap.local.
241
+ if (SHELL_CONFIG.backendOrigin) {
242
+ const method = (isObj && request.getMethod && String(request.getMethod())) || 'GET';
243
+ const headers = isObj && request.getRequestHeaders ? request.getRequestHeaders() : null;
244
+ return proxyToBackend(rel, query, ext, method.toUpperCase(), headers);
245
+ }
246
+ return new android.webkit.WebResourceResponse(
247
+ 'text/plain', 'utf-8', 404, 'Not Found', new java.util.HashMap(), emptyStream()
248
+ );
249
+ },
250
+
251
+ onPageStarted(view: any, _url: string, _favicon: any): void {
252
+ // Fallback injection — no-op when the document-start scripts already ran
253
+ view.evaluateJavascript(buildBootstrapJs(), null);
254
+ },
255
+
256
+ // `appwrap dev` points at a LAN dev server that almost always uses a self-signed / mkcert TLS
257
+ // cert the device's trust store doesn't know — the WebView would otherwise hard-fail with
258
+ // ERR_CERT_AUTHORITY_INVALID. Proceed past it ONLY in a debug build AND only when actually in
259
+ // server-loader (dev) mode. Production app:// builds never reach this (local assets, no TLS).
260
+ onReceivedSslError(_view: any, handler: any, error: any): void {
261
+ if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server') {
262
+ console.warn('AppWrap: proceeding past dev-server SSL error (debug+server only):', String(error));
263
+ handler.proceed();
264
+ } else {
265
+ handler.cancel();
266
+ }
267
+ },
268
+ });
269
+ return new Client();
270
+ }