@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
package/src/cli.ts ADDED
@@ -0,0 +1,1328 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * appwrap CLI v0 — scaffold a native wrapper around a built PWA.
4
+ *
5
+ * appwrap init [--config appwrap.json] [--out native] # from the PWA project dir
6
+ * appwrap sync [--config appwrap.json] [--out native] # re-copy PWA dist into the wrapper
7
+ *
8
+ * appwrap.json: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }
9
+ */
10
+ import { execFileSync } from 'child_process';
11
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
12
+ import { networkInterfaces, tmpdir } from 'os';
13
+ import { dirname, join, resolve } from 'path';
14
+ // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
15
+ // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
16
+ // monorepo and from a published tarball (where runtime/ is bundled at the package root).
17
+ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manifest';
18
+
19
+ interface AppwrapConfig {
20
+ id: string;
21
+ name: string;
22
+ version: string;
23
+ entry?: string;
24
+ backgroundColor?: string;
25
+ statusBarStyle?: 'light' | 'dark';
26
+ /** Android only (experimental). When true, the WebView draws genuinely edge-to-edge UNDER the
27
+ * transparent system bars (NS `androidOverflowEdge='dont-apply'`) and the real safe-area insets
28
+ * are injected as `--saie-*` CSS vars + native `env(safe-area-inset-*)`, so a multi-theme PWA
29
+ * paints the bar regions itself. Default false = bars show the page `backgroundColor` (works, but
30
+ * can't match a multi-theme app). iOS is always genuinely edge-to-edge. */
31
+ edgeToEdge?: boolean;
32
+ pwaDist: string;
33
+ /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
34
+ urlScheme?: string;
35
+ /** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
36
+ icon?: string;
37
+ /** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
38
+ * (load `serverUrl` live — dev HMR over LAN or a deployed URL). `appwrap dev` sets this. */
39
+ loader?: 'app' | 'file' | 'server';
40
+ /** Live URL loaded when loader === 'server'. Set via appwrap.json or `appwrap dev --url <url>`. */
41
+ serverUrl?: string;
42
+ /** Absolute backend origin for an offline (loader:'app') PWA whose API/WebSocket calls were
43
+ * originally same-origin (e.g. "https://api.example.com"). Injected to the page as
44
+ * `window.__APPWRAP_BACKEND_ORIGIN__`; a same-origin PWA reads it to make its calls absolute.
45
+ * Empty/unset = same-origin (browser default), so the same web build is unaffected. */
46
+ backendOrigin?: string;
47
+ /** Backend-served STATIC assets the PWA loads via a *relative, hardcoded* URL (e.g. an SDK
48
+ * `<script src="/_vendor/sdk.js">` that can't be made absolute without breaking script order).
49
+ * Fetched from `backendOrigin` at build time and bundled into www/, so they resolve offline at
50
+ * app://. The asset is pinned to the build (correct for a no-OTA native app). Needs `backendOrigin`. */
51
+ vendorPaths?: string[];
52
+ /** Debug/dev mode: keeps the screen awake (no auto-lock while foreground) + makes the WebView
53
+ * inspectable (Safari Web Inspector / chrome://inspect) for continuous troubleshooting. `appwrap
54
+ * deploy` forces this on; `appwrap build` leaves it off. NEVER ship a store build with debug on. */
55
+ debug?: boolean;
56
+ /** In debug mode, the value written to `localStorage.DEBUG` at startup so the PWA's logger goes
57
+ * verbose (common convention — `'*'` = all, or comma-separated module names). Default `'*'`. */
58
+ debugLog?: string;
59
+ /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
60
+ teamId?: string;
61
+ /** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP
62
+ * testing — products resolve without App Store Connect. Only applies when launched from
63
+ * Xcode (simulator or device-from-Xcode), not a standalone devicectl sideload. */
64
+ storekitConfig?: string;
65
+ /** Permission usage strings, keyed by domain. Only listed ones are stamped
66
+ * (iOS: Info.plist usage string; Android: <uses-permission>). 'contacts' has no
67
+ * iOS key (CNContactPicker needs none) — it only stamps Android READ_CONTACTS. */
68
+ permissions?: Partial<
69
+ Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion', string>
70
+ >;
71
+ /** Monotonic build identifier. Stores reject a re-upload unless this is HIGHER than the last:
72
+ * iOS `CFBundleVersion`, Android `versionCode` (the marketing `version` stays the user-facing
73
+ * string). Default: an integer derived from `version` (0.2.1 → 201). Set explicitly from a CI
74
+ * run number for fleet builds of the same marketing version. */
75
+ buildNumber?: string | number;
76
+ /** iOS export-compliance. `ITSAppUsesNonExemptEncryption` — stamped `false` by default (skips the
77
+ * per-upload prompt). Set `true` only if the app uses non-exempt encryption. */
78
+ usesNonExemptEncryption?: boolean;
79
+ /** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
80
+ * OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
81
+ * can't express. Default `'appwrap.overrides'`; applied only if it exists. */
82
+ overrides?: string;
83
+ /** Reserved — appwrap plugins (npm packages contributing a kit module + native handlers + config).
84
+ * Parsed today; full native composition lands with the plugin contract (see framework-extensibility). */
85
+ plugins?: string[];
86
+ /** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
87
+ * only the listed capabilities (plus always-on core) are advertised, permissioned, and — for
88
+ * modules that own their handler file (e.g. health) — compiled into the shell. Their permissions,
89
+ * background modes and native deps are collected from each module's self-contained manifest entry
90
+ * (the per-app `permissions{}` map only OVERRIDES the default usage copy). When ABSENT, every
91
+ * capability is active and permissions come solely from `permissions{}` (pre-modules behavior). */
92
+ modules?: string[];
93
+ /** Remote push (APNs/FCM). Off unless set — gating matters: an `aps-environment` entitlement on a
94
+ * team that can't hold the Push capability (e.g. a personal team) BREAKS code signing, and the
95
+ * handshake should honestly report `push: 'none'` on an un-provisioned build. The kit returns a raw
96
+ * token; SENDING is your backend's job (provider-agnostic). */
97
+ push?: {
98
+ /** Master switch for the push lane. */
99
+ enabled?: boolean;
100
+ /** Per-platform gates (default true when `enabled`). Split because the two platforms have
101
+ * independent prerequisites: iOS needs the `aps-environment` entitlement (a PAID Apple team —
102
+ * a personal team can't hold it, and stamping it would break signing), Android needs FCM +
103
+ * google-services.json. e.g. `{ enabled:true, ios:false, android:true }` ships Android push
104
+ * while keeping a personal-team iOS build signable. */
105
+ ios?: boolean;
106
+ android?: boolean;
107
+ /** iOS APNs environment in the entitlement: 'development' (debug/TestFlight builds) or
108
+ * 'production' (App Store). Default 'development'. */
109
+ apsEnvironment?: 'development' | 'production';
110
+ /** Path (relative to the PWA project) to the Firebase `google-services.json` for Android FCM. */
111
+ googleServicesJson?: string;
112
+ };
113
+ }
114
+
115
+ /** Marketing version → a monotonic integer build (0.2.1 → 201; 1.4.12 → 10412). Stable & increasing
116
+ * across semver bumps so store re-uploads are always accepted without a manual bump. */
117
+ function deriveBuild(version: string): number {
118
+ const [maj = 0, min = 0, patch = 0] = version.split('.').map((n) => parseInt(n, 10) || 0);
119
+ return maj * 10000 + min * 100 + patch;
120
+ }
121
+
122
+ /** Resolved monotonic build number: explicit `buildNumber` (e.g. CI run #) wins, else derived. */
123
+ function buildNumberOf(cfg: AppwrapConfig): number {
124
+ if (cfg.buildNumber != null) {
125
+ const n = parseInt(String(cfg.buildNumber), 10);
126
+ if (!Number.isNaN(n)) return n;
127
+ }
128
+ return deriveBuild(cfg.version);
129
+ }
130
+
131
+ const IOS_PERMISSION_KEYS: Record<string, string[]> = {
132
+ location: ['NSLocationWhenInUseUsageDescription'],
133
+ photos: ['NSPhotoLibraryUsageDescription'],
134
+ camera: ['NSCameraUsageDescription'],
135
+ microphone: ['NSMicrophoneUsageDescription'],
136
+ faceid: ['NSFaceIDUsageDescription'],
137
+ // iOS 17 key + pre-17 fallback key, same usage string
138
+ calendar: ['NSCalendarsFullAccessUsageDescription', 'NSCalendarsUsageDescription'],
139
+ };
140
+
141
+ /** Runtime permissions stamped into AndroidManifest.xml per declared domain.
142
+ * photos/faceid need none: system picker / USE_BIOMETRIC is baseline. */
143
+ const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
144
+ location: ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'],
145
+ camera: ['android.permission.CAMERA'],
146
+ microphone: ['android.permission.RECORD_AUDIO'],
147
+ calendar: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
148
+ contacts: ['android.permission.READ_CONTACTS'],
149
+ };
150
+
151
+ /** Resolve a bundled asset dir. A published tarball ships runtime/ + templates/ at the package root
152
+ * (one level above src/); the monorepo resolves them at the repo root (three levels up). */
153
+ function resolveAssetRoot(rel: string): string {
154
+ const local = resolve(import.meta.dir, '..', rel);
155
+ return existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
156
+ }
157
+ const TEMPLATE_DIR = resolveAssetRoot('runtime');
158
+ const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
159
+
160
+ // Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
161
+ // Top-level await resolves before any command dispatches at the bottom of this file.
162
+ const { MODULES, OPTIONAL_GROUPS } = (await import(
163
+ resolve(TEMPLATE_DIR, 'app/shell/capabilities.manifest')
164
+ )) as typeof CapManifest;
165
+
166
+ /** Native requirements composed for a build: the union (deduped) of the active modules' self-contained
167
+ * manifest declarations. Two modes:
168
+ * - `modules` ABSENT (legacy): every capability active; permissions come ONLY from `permissions{}`
169
+ * (the iOS/Android key maps above) — unchanged pre-modules behavior.
170
+ * - `modules` PRESENT (explicit): core + listed; perms/bg-modes/deps derived from manifests, with
171
+ * `permissions{}` overriding the default usage copy. Capabilities not listed are stripped.
172
+ */
173
+ interface NativeReqs {
174
+ explicit: boolean;
175
+ activeOptIn: string[]; // opt-in capability names that are active (for the handshake map)
176
+ activeOptionalGroups: string[]; // strippable handler groups (own file) that are active
177
+ iosPlist: Array<{ key: string; usage: string }>;
178
+ iosEntitlements: Record<string, boolean | string | string[]>;
179
+ androidPerms: string[];
180
+ androidGradleDeps: string[];
181
+ androidKotlin: boolean; // any active module ships Kotlin native source
182
+ androidManifestApp: string[]; // raw XML injected inside AndroidManifest <application>
183
+ nativeSrc: string[]; // active modules' nativeSrc dir names (under runtime/modules-native/)
184
+ }
185
+
186
+ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
187
+ const optIn = MODULES.filter((m) => !m.core);
188
+ // Legacy default (no `modules` key) = every opt-in capability EXCEPT strictly-opt-in own-file
189
+ // modules (OPTIONAL_GROUPS, e.g. health): those carry deps/perms legacy won't stamp, so they must
190
+ // be explicitly requested. Explicit mode = exactly what `modules` lists.
191
+ const active = cfg.modules
192
+ ? new Set(cfg.modules)
193
+ : new Set(optIn.filter((m) => !OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number])).map((m) => m.name));
194
+ const activeMods = MODULES.filter((m) => m.core || active.has(m.name));
195
+
196
+ const iosPlist: Array<{ key: string; usage: string }> = [];
197
+ const seenKeys = new Set<string>();
198
+ const androidPerms = new Set<string>();
199
+ const gradle = new Set<string>();
200
+ const iosEntitlements: Record<string, boolean | string | string[]> = {};
201
+ const nativeSrc: string[] = [];
202
+ const androidManifestApp: string[] = [];
203
+ let androidKotlin = false;
204
+
205
+ if (cfg.modules) {
206
+ // explicit: self-contained module declarations win
207
+ for (const m of activeMods) {
208
+ for (const p of m.ios?.permissions ?? []) {
209
+ if (seenKeys.has(p.key)) continue;
210
+ seenKeys.add(p.key);
211
+ iosPlist.push({ key: p.key, usage: cfg.permissions?.[p.domain as keyof typeof cfg.permissions] ?? p.defaultUsage });
212
+ }
213
+ for (const ap of m.android?.permissions ?? []) androidPerms.add(ap);
214
+ for (const g of m.android?.gradleDeps ?? []) gradle.add(g);
215
+ Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
216
+ if (m.android?.kotlin) androidKotlin = true;
217
+ if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
218
+ if (m.nativeSrc) nativeSrc.push(m.nativeSrc);
219
+ }
220
+ } else {
221
+ // legacy: only what `permissions{}` declares (via the key maps) — no behavior change
222
+ for (const [domain, text] of Object.entries(cfg.permissions ?? {})) {
223
+ for (const key of IOS_PERMISSION_KEYS[domain] ?? []) {
224
+ if (text && !seenKeys.has(key)) { seenKeys.add(key); iosPlist.push({ key, usage: text }); }
225
+ }
226
+ for (const p of ANDROID_PERMISSION_KEYS[domain] ?? []) androidPerms.add(p);
227
+ }
228
+ }
229
+
230
+ return {
231
+ explicit: !!cfg.modules,
232
+ activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
233
+ activeOptionalGroups: OPTIONAL_GROUPS.filter((g) => activeMods.some((m) => m.group === g)),
234
+ iosPlist,
235
+ iosEntitlements,
236
+ androidPerms: [...androidPerms],
237
+ androidGradleDeps: [...gradle],
238
+ androidKotlin,
239
+ androidManifestApp,
240
+ nativeSrc,
241
+ };
242
+ }
243
+
244
+ /** Map a strippable optional group → its handler file + register fn (for the generated barrel). */
245
+ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
246
+ health: { file: './handlers-health', fn: 'registerHealthHandlers' },
247
+ oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
248
+ };
249
+
250
+ /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
251
+ * handshake map) and the optional-handler barrel (imports only active strippable groups). */
252
+ function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
253
+ const shell = join(outDir, 'app/shell');
254
+ writeFileSync(
255
+ join(shell, 'active-modules.generated.ts'),
256
+ `/** Generated by \`appwrap\` from appwrap.json \`modules\`. Do not edit. */\n` +
257
+ `export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n`
258
+ );
259
+
260
+ const groups = req.activeOptionalGroups.filter((g) => OPTIONAL_GROUP_HANDLERS[g]);
261
+ const imports = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`).join('\n');
262
+ const calls = groups.map((g) => ` ${OPTIONAL_GROUP_HANDLERS[g].fn}();`).join('\n');
263
+ writeFileSync(
264
+ join(shell, 'optional-handlers.generated.ts'),
265
+ `/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
266
+ `${imports}${imports ? '\n' : ''}\nexport function registerOptionalHandlers(): void {\n${calls}\n}\n`
267
+ );
268
+ }
269
+
270
+ /** Stamp the active modules' gradle dependencies into Android app.gradle. Idempotent marker block. */
271
+ function stampAndroidGradleDeps(outDir: string, deps: string[]): void {
272
+ const appGradle = join(outDir, 'App_Resources/Android/app.gradle');
273
+ if (!existsSync(appGradle)) return;
274
+ const strip = (s: string) => s.replace(/\n*\/\/ appwrap-modules:begin[\s\S]*?\/\/ appwrap-modules:end\n*/g, '\n');
275
+ let s = strip(readFileSync(appGradle, 'utf8')).trimEnd() + '\n';
276
+ if (deps.length) {
277
+ const lines = deps.map((d) => ` implementation "${d}"`).join('\n');
278
+ s += `\n// appwrap-modules:begin (native deps from active modules)\ndependencies {\n${lines}\n}\n// appwrap-modules:end\n`;
279
+ }
280
+ writeFileSync(appGradle, s);
281
+ }
282
+
283
+ /** Module-owned native source lives here (mirroring App_Resources); copied into native/ when active. */
284
+ const MODULES_NATIVE_DIR = resolve(TEMPLATE_DIR, 'modules-native');
285
+ const MODULE_KOTLIN_VERSION = '2.1.0';
286
+
287
+ /** Merge iOS entitlements from active modules + remote push into ONE app.entitlements (NS auto-detects
288
+ * + signs it). Removes the file when empty so a no-entitlement build (personal team, push off) signs. */
289
+ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
290
+ const iosDir = join(outDir, 'App_Resources/iOS');
291
+ if (!existsSync(iosDir)) return;
292
+ const file = join(iosDir, 'app.entitlements');
293
+ const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements };
294
+ if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
295
+ const keys = Object.keys(ent);
296
+ if (keys.length === 0) { rmSync(file, { force: true }); return; }
297
+ const val = (v: boolean | string | string[]): string =>
298
+ typeof v === 'boolean' ? `<${v}/>`
299
+ : Array.isArray(v) ? `<array>\n${v.map((s) => ` <string>${s}</string>`).join('\n')}\n </array>`
300
+ : `<string>${v}</string>`;
301
+ const body = keys.map((k) => ` <key>${k}</key>\n ${val(ent[k])}`).join('\n');
302
+ writeFileSync(
303
+ file,
304
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
305
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
306
+ `<plist version="1.0">\n<dict>\n${body}\n</dict>\n</plist>\n`
307
+ );
308
+ console.log(` entl ← ${keys.join(', ')}`);
309
+ }
310
+
311
+ /** Copy active modules' native source (runtime/modules-native/<name>/) into native/ — only when the
312
+ * module is active, so module native code stays stripped from builds that don't use it. */
313
+ function copyModuleNativeSrc(outDir: string, req: NativeReqs): void {
314
+ for (const name of req.nativeSrc) {
315
+ const src = join(MODULES_NATIVE_DIR, name);
316
+ if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); continue; }
317
+ cpSync(src, outDir, { recursive: true, force: true });
318
+ console.log(` natv ← module '${name}' native source`);
319
+ }
320
+ }
321
+
322
+ /** Enable Kotlin in the NS Android build when an active module ships Kotlin native source. Injects
323
+ * useKotlin/kotlinVersion into before-plugins.gradle's project.ext (re-stamped from template each run). */
324
+ function stampKotlin(outDir: string, enable: boolean): void {
325
+ const file = join(outDir, 'App_Resources/Android/before-plugins.gradle');
326
+ if (!enable || !existsSync(file)) return;
327
+ let src = readFileSync(file, 'utf8');
328
+ if (!/^\s*useKotlin\s*=/m.test(src)) {
329
+ src = src.replace(/(project\.ext\s*\{)/, `$1\n useKotlin = true\n kotlinVersion = "${MODULE_KOTLIN_VERSION}"`);
330
+ }
331
+ writeFileSync(file, src);
332
+ console.log(` ktln ← Kotlin enabled (${MODULE_KOTLIN_VERSION})`);
333
+ }
334
+
335
+ function parseArgs(argv: string[]) {
336
+ const [command, ...rest] = argv;
337
+ const flags: Record<string, string> = {};
338
+ const positionals: string[] = [];
339
+ for (let i = 0; i < rest.length; i++) {
340
+ const t = rest[i];
341
+ if (t?.startsWith('-')) {
342
+ const key = t.replace(/^-+/, ''); // accept both --long and -short (e.g. -r)
343
+ const next = rest[i + 1];
344
+ // value flag (`--out native`) vs boolean flag (`--aab`, `-r`) → presence as ''
345
+ if (next !== undefined && !next.startsWith('-')) {
346
+ flags[key] = next;
347
+ i++;
348
+ } else {
349
+ flags[key] = '';
350
+ }
351
+ } else if (t !== undefined) {
352
+ positionals.push(t);
353
+ }
354
+ }
355
+ return { command, flags, positionals };
356
+ }
357
+
358
+ /** Parse the PWA's web manifest (manifest.json / .webmanifest) from the dist dir, or null. */
359
+ function loadManifest(cwd: string, cfg: AppwrapConfig): Record<string, any> | null {
360
+ const dist = resolve(cwd, cfg.pwaDist);
361
+ for (const name of ['manifest.json', 'manifest.webmanifest']) {
362
+ const mf = join(dist, name);
363
+ if (!existsSync(mf)) continue;
364
+ try {
365
+ return JSON.parse(readFileSync(mf, 'utf8'));
366
+ } catch (e: any) {
367
+ console.warn(`⚠ Could not parse ${name}: ${e.message}`);
368
+ }
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function loadConfig(cwd: string, flags: Record<string, string>): AppwrapConfig {
374
+ const configPath = resolve(cwd, flags.config ?? 'appwrap.json');
375
+ if (!existsSync(configPath)) {
376
+ console.error(`✖ Config not found: ${configPath}`);
377
+ process.exit(1);
378
+ }
379
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8')) as AppwrapConfig;
380
+
381
+ // Manifest as source: appwrap.json wins, the PWA manifest fills the gaps, template default last.
382
+ // (DRY single-source — devs don't re-type identity already declared in the manifest.)
383
+ if (cfg.pwaDist) {
384
+ const mf = loadManifest(cwd, cfg);
385
+ if (mf) {
386
+ cfg.name ??= mf.name || mf.short_name;
387
+ cfg.backgroundColor ??= mf.background_color;
388
+ }
389
+ }
390
+
391
+ for (const key of ['id', 'name', 'version', 'pwaDist'] as const) {
392
+ if (!cfg[key]) {
393
+ console.error(`✖ appwrap.json missing required field: ${key}` + (key === 'name' ? ' (and no name/short_name in the PWA manifest)' : ''));
394
+ process.exit(1);
395
+ }
396
+ }
397
+ return cfg;
398
+ }
399
+
400
+ function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
401
+ const content = `/**
402
+ * Shell config — stamped by \`appwrap init\`/\`sync\` from appwrap.json. Do not edit.
403
+ */
404
+ export const SHELL_CONFIG = {
405
+ appId: ${JSON.stringify(cfg.id)},
406
+ name: ${JSON.stringify(cfg.name)},
407
+ version: ${JSON.stringify(cfg.version)},
408
+ entry: ${JSON.stringify(cfg.entry ?? 'index.html')},
409
+ backgroundColor: ${JSON.stringify(cfg.backgroundColor ?? '#ffffff')},
410
+ statusBarStyle: ${JSON.stringify(cfg.statusBarStyle ?? 'dark')} as 'light' | 'dark',
411
+ edgeToEdge: ${JSON.stringify(cfg.edgeToEdge ?? false)},
412
+ loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
413
+ serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
414
+ backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
415
+ debug: ${JSON.stringify(cfg.debug ?? false)},
416
+ debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
417
+ pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
418
+ pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
419
+ };
420
+ `;
421
+ writeFileSync(join(outDir, 'app/shell/config.ts'), content);
422
+ }
423
+
424
+ function stampNativeScriptConfig(outDir: string, cfg: AppwrapConfig): void {
425
+ const file = join(outDir, 'nativescript.config.ts');
426
+ const src = readFileSync(file, 'utf8').replace(/id: '[^']*'/, `id: '${cfg.id}'`);
427
+ writeFileSync(file, src);
428
+ }
429
+
430
+ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
431
+ const plist = join(outDir, 'App_Resources/iOS/Info.plist');
432
+ if (!existsSync(plist)) return;
433
+ let src = readFileSync(plist, 'utf8');
434
+ const stamp = (key: string, value: string) => {
435
+ const re = new RegExp(`(<key>${key}</key>\\s*<string>)[^<]*(</string>)`);
436
+ src = re.test(src) ? src.replace(re, `$1${value}$2`) : src;
437
+ };
438
+ stamp('CFBundleDisplayName', cfg.name);
439
+ stamp('CFBundleName', cfg.name);
440
+ stamp('CFBundleShortVersionString', cfg.version); // marketing version (user-facing)
441
+ stamp('CFBundleVersion', String(buildNumberOf(cfg))); // monotonic build — store re-uploads need it higher
442
+
443
+ // Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
444
+ src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
445
+ const extras: string[] = [];
446
+ // Export compliance: skips the per-upload encryption prompt. Default false; override in config.
447
+ extras.push(` <key>ITSAppUsesNonExemptEncryption</key>\n <${cfg.usesNonExemptEncryption ? 'true' : 'false'}/>`);
448
+ // Permissions: composed (deduped) from the active modules — legacy mode falls back to permissions{}.
449
+ for (const { key, usage } of req.iosPlist) {
450
+ extras.push(` <key>${key}</key>\n <string>${usage}</string>`);
451
+ }
452
+ if (cfg.urlScheme) {
453
+ extras.push(
454
+ ` <key>CFBundleURLTypes</key>\n <array>\n <dict>\n <key>CFBundleURLName</key>\n <string>${cfg.id}</string>\n <key>CFBundleURLSchemes</key>\n <array>\n <string>${cfg.urlScheme}</string>\n </array>\n </dict>\n </array>`
455
+ );
456
+ }
457
+ if (extras.length) {
458
+ src = src.replace(
459
+ /<\/dict>\s*<\/plist>\s*$/,
460
+ ` <!-- appwrap:begin -->\n${extras.join('\n')}\n <!-- appwrap:end -->\n</dict>\n</plist>\n`
461
+ );
462
+ }
463
+
464
+ // Remote push needs the `remote-notification` background mode. The template already ships a
465
+ // UIBackgroundModes array (for `audio`), so MERGE in-place — a second <key> would be a duplicate
466
+ // (invalid plist). Idempotent both ways: add when enabled+missing, strip when disabled.
467
+ const iosPush = !!cfg.push?.enabled && cfg.push?.ios !== false;
468
+ const bgArray = /(<key>UIBackgroundModes<\/key>\s*<array>)([\s\S]*?)(<\/array>)/;
469
+ const hasRN = /<string>remote-notification<\/string>/.test(src);
470
+ if (iosPush && !hasRN) {
471
+ src = bgArray.test(src)
472
+ ? src.replace(bgArray, (_m, open, inner, close) => `${open}${inner}\t<string>remote-notification</string>\n\t${close}`)
473
+ : src.replace(/<\/dict>\s*<\/plist>\s*$/, ` <key>UIBackgroundModes</key>\n <array>\n <string>remote-notification</string>\n </array>\n</dict>\n</plist>\n`);
474
+ } else if (!iosPush && hasRN) {
475
+ src = src.replace(/\s*<string>remote-notification<\/string>/, '');
476
+ }
477
+
478
+ writeFileSync(plist, src);
479
+ }
480
+
481
+ function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
482
+ const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
483
+ if (!existsSync(xcconfig) || !cfg.teamId) return;
484
+ let src = readFileSync(xcconfig, 'utf8');
485
+ src = /DEVELOPMENT_TEAM\s*=/.test(src)
486
+ ? src.replace(/DEVELOPMENT_TEAM\s*=\s*[^;\n]*;?/, `DEVELOPMENT_TEAM = ${cfg.teamId};`)
487
+ : src + `\nDEVELOPMENT_TEAM = ${cfg.teamId};\n`;
488
+ writeFileSync(xcconfig, src);
489
+ }
490
+
491
+ /** Wire a StoreKit config file for LOCAL iOS IAP testing (no App Store Connect needed).
492
+ * NativeScript copies App_Resources/iOS/* into the generated project and adds it as a file
493
+ * reference — but it never points the scheme at it, so StoreKit has no catalog. We (1) drop the
494
+ * .storekit into App_Resources/iOS so it's bundled + referenced, and (2) install an after-prepare
495
+ * hook that injects `<StoreKitConfigurationFileReference>` into the scheme's LaunchAction (the
496
+ * scheme is regenerated on every `ns prepare`, so a one-time edit won't stick). Only takes effect
497
+ * when launched from Xcode (sim or device-from-Xcode), not a standalone devicectl sideload. */
498
+ function stampStoreKit(cwd: string, outDir: string, cfg: AppwrapConfig): void {
499
+ if (!cfg.storekitConfig) return;
500
+ const source = resolve(cwd, cfg.storekitConfig);
501
+ if (!existsSync(source)) {
502
+ console.warn(`⚠ appwrap.json.storekitConfig not found: ${source} — skipping StoreKit wiring`);
503
+ return;
504
+ }
505
+ const base = source.split('/').pop()!;
506
+ const iosRes = join(outDir, 'App_Resources/iOS');
507
+ if (!existsSync(iosRes)) return;
508
+ cpSync(source, join(iosRes, base));
509
+
510
+ // after-prepare hook: resolve the .storekit's real location under platforms/ios at run time and
511
+ // point each app scheme's LaunchAction at it via a path relative to the scheme file (Xcode's rule).
512
+ const hookDir = join(outDir, 'hooks/after-prepare');
513
+ mkdirSync(hookDir, { recursive: true });
514
+ writeFileSync(join(hookDir, 'appwrap-storekit.js'), STOREKIT_HOOK(base));
515
+ console.log(` iap ← StoreKit config (${base}) wired for local testing`);
516
+ }
517
+
518
+ /** The after-prepare hook source. Self-contained (no deps); zero-arg so NS's DI never chokes. */
519
+ const STOREKIT_HOOK = (base: string) => `// Generated by \`appwrap\` — wires ${base} into the iOS scheme for local StoreKit testing.
520
+ const fs = require('fs');
521
+ const path = require('path');
522
+ module.exports = function () {
523
+ const iosDir = path.join(__dirname, '..', '..', 'platforms', 'ios');
524
+ if (!fs.existsSync(iosDir)) return;
525
+ const find = (dir, name) => {
526
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
527
+ const p = path.join(dir, e.name);
528
+ if (e.isDirectory()) { if (e.name === 'Pods') continue; const r = find(p, name); if (r) return r; }
529
+ else if (e.name === name) return p;
530
+ }
531
+ return null;
532
+ };
533
+ const storekit = find(iosDir, ${JSON.stringify(base)});
534
+ if (!storekit) return;
535
+ for (const proj of fs.readdirSync(iosDir).filter((d) => d.endsWith('.xcodeproj') && d !== 'Pods.xcodeproj')) {
536
+ const schemesDir = path.join(iosDir, proj, 'xcshareddata', 'xcschemes');
537
+ if (!fs.existsSync(schemesDir)) continue;
538
+ for (const s of fs.readdirSync(schemesDir).filter((f) => f.endsWith('.xcscheme'))) {
539
+ const file = path.join(schemesDir, s);
540
+ let xml = fs.readFileSync(file, 'utf8');
541
+ if (xml.includes('StoreKitConfigurationFileReference')) continue;
542
+ const id = path.relative(schemesDir, storekit);
543
+ const ref = ' <StoreKitConfigurationFileReference\\n identifier = "' + id + '">\\n </StoreKitConfigurationFileReference>';
544
+ xml = xml.replace(/(\\s*)<\\/LaunchAction>/, '\\n' + ref + '$1</LaunchAction>');
545
+ fs.writeFileSync(file, xml);
546
+ console.log(' appwrap: StoreKit config wired into ' + s + ' (' + id + ')');
547
+ }
548
+ }
549
+ };
550
+ `;
551
+
552
+ /** Remote-push native wiring (gated on `cfg.push.enabled`). iOS: the `aps-environment` entitlement —
553
+ * NativeScript auto-detects `App_Resources/iOS/app.entitlements` and signs with it. Idempotent:
554
+ * removes the file when push is disabled so a personal-team (no-push) build still signs. Android FCM
555
+ * gradle plumbing is staged separately (needs google-services.json) — only the file is copied here. */
556
+ function stampPush(cwd: string, outDir: string, cfg: AppwrapConfig): void {
557
+ const androidPush = !!cfg.push?.enabled && cfg.push?.android !== false;
558
+ // iOS aps-environment entitlement is emitted by stampEntitlements (unified with module entitlements).
559
+
560
+ // Android FCM. We deliberately AVOID the `com.google.gms.google-services` gradle plugin: injecting
561
+ // its buildscript classpath via NS's `apply from:` scripts doesn't reach the module's plugin
562
+ // resolver (Gradle scoping → "plugin not found"). The plugin only generates string resources from
563
+ // google-services.json that Firebase auto-init (FirebaseInitProvider) reads — so we generate those
564
+ // resources directly + add the firebase-messaging dep. Same result, no plugin, no classpath fight.
565
+ // Token-only register() works on auto-init; inbound onMessage/onTap to JS needs a
566
+ // FirebaseMessagingService (the @nativescript/firebase-messaging plugin) — 1b.
567
+ let fcmVals: Record<string, string> | null = null;
568
+ if (androidPush && cfg.push?.googleServicesJson) {
569
+ const src = resolve(cwd, cfg.push.googleServicesJson);
570
+ if (existsSync(src)) {
571
+ fcmVals = readGoogleServices(src);
572
+ if (fcmVals) console.log(` push ← Android FCM wired (firebase resources for ${fcmVals.project_id}, no plugin)`);
573
+ else console.warn(`⚠ Could not parse ${src} — skipping Android FCM`);
574
+ } else {
575
+ console.warn(`⚠ appwrap.json.push.googleServicesJson not found: ${src} — skipping Android FCM`);
576
+ }
577
+ }
578
+ stampAndroidFcm(outDir, fcmVals);
579
+ }
580
+
581
+ /** Extract the values Firebase auto-init needs from a google-services.json (the subset the
582
+ * google-services plugin would otherwise codegen). Returns null if the shape is unexpected. */
583
+ function readGoogleServices(src: string): Record<string, string> | null {
584
+ try {
585
+ const j = JSON.parse(readFileSync(src, 'utf8'));
586
+ const client = (j.client ?? [])[0];
587
+ const vals: Record<string, string> = {
588
+ google_app_id: client?.client_info?.mobilesdk_app_id ?? '',
589
+ gcm_defaultSenderId: j.project_info?.project_number ?? '',
590
+ google_api_key: (client?.api_key ?? [])[0]?.current_key ?? '',
591
+ project_id: j.project_info?.project_id ?? '',
592
+ google_storage_bucket: j.project_info?.storage_bucket ?? '',
593
+ };
594
+ return vals.google_app_id && vals.gcm_defaultSenderId ? vals : null;
595
+ } catch {
596
+ return null;
597
+ }
598
+ }
599
+
600
+ /** Wire (or strip) Android FCM without the google-services plugin: write the firebase string
601
+ * resources Firebase auto-init reads + add the firebase-messaging dependency. Idempotent. */
602
+ function stampAndroidFcm(outDir: string, vals: Record<string, string> | null): void {
603
+ const resXml = join(outDir, 'App_Resources/Android/src/main/res/values/appwrap-firebase.xml');
604
+ const appGradle = join(outDir, 'App_Resources/Android/app.gradle');
605
+ const beforePlugins = join(outDir, 'App_Resources/Android/before-plugins.gradle');
606
+ const stripBlock = (s: string) => s.replace(/\n*\/\/ appwrap-fcm:begin[\s\S]*?\/\/ appwrap-fcm:end\n*/g, '\n');
607
+
608
+ // before-plugins: ensure any prior plugin-classpath block is gone (we no longer use it).
609
+ if (existsSync(beforePlugins)) writeFileSync(beforePlugins, stripBlock(readFileSync(beforePlugins, 'utf8')).trimEnd() + '\n');
610
+
611
+ // Inbound delivery wiring (gated by `vals` = FCM actually wired): the FirebaseMessagingService for
612
+ // foreground/data onMessage. Declaring the <service> + importing the (Firebase-extending) shell
613
+ // class only when FCM is present keeps a non-push build from compiling a class with an absent base.
614
+ stampFcmService(outDir, !!vals);
615
+
616
+ if (vals) {
617
+ const strings = Object.entries(vals)
618
+ .filter(([, v]) => v)
619
+ .map(([k, v]) => ` <string name="${k}" translatable="false">${v}</string>`)
620
+ .join('\n');
621
+ writeFileSync(resXml, `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n${strings}\n</resources>\n`);
622
+ if (existsSync(appGradle)) {
623
+ let s = stripBlock(readFileSync(appGradle, 'utf8')).trimEnd() + '\n';
624
+ s += `\n// appwrap-fcm:begin (remote push — gated by appwrap.json.push + google-services.json)\ndependencies {\n implementation platform("com.google.firebase:firebase-bom:33.7.0")\n implementation "com.google.firebase:firebase-messaging"\n}\n// appwrap-fcm:end\n`;
625
+ writeFileSync(appGradle, s);
626
+ }
627
+ } else {
628
+ rmSync(resXml, { force: true });
629
+ rmSync(join(outDir, 'App_Resources/Android/google-services.json'), { force: true });
630
+ if (existsSync(appGradle)) writeFileSync(appGradle, stripBlock(readFileSync(appGradle, 'utf8')).trimEnd() + '\n');
631
+ }
632
+ }
633
+
634
+ /** Wire (or strip) the inbound FCM FirebaseMessagingService: import the shell service via the
635
+ * generated bootstrap + declare the <service> in AndroidManifest — both ONLY when FCM is wired
636
+ * (`on`), so non-push builds never compile/declare a Firebase-extending class. Idempotent. */
637
+ function stampFcmService(outDir: string, on: boolean): void {
638
+ writeFileSync(
639
+ join(outDir, 'app/shell/fcm-bootstrap.generated.ts'),
640
+ `/** Generated by \`appwrap\` — imports the FCM messaging service only when push is wired. Do not edit. */\n` +
641
+ (on ? `import './fcm-service'; // side-effect: registers AppwrapMessagingService (JavaProxy)\n` : ``)
642
+ );
643
+
644
+ const manifest = join(outDir, 'App_Resources/Android/src/main/AndroidManifest.xml');
645
+ if (!existsSync(manifest)) return;
646
+ const service = on
647
+ ? `\n\t\t<service\n\t\t\tandroid:name="cc.livx.appwrap.AppwrapMessagingService"\n\t\t\tandroid:exported="false">\n\t\t\t<intent-filter>\n\t\t\t\t<action android:name="com.google.firebase.MESSAGING_EVENT" />\n\t\t\t</intent-filter>\n\t\t</service>\n\t\t`
648
+ : '';
649
+ const src = readFileSync(manifest, 'utf8').replace(
650
+ /<!-- appwrap:fcm -->[\s\S]*?<!-- \/appwrap:fcm -->/,
651
+ `<!-- appwrap:fcm -->${service}<!-- /appwrap:fcm -->`
652
+ );
653
+ writeFileSync(manifest, src);
654
+ }
655
+
656
+ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
657
+ const strings = join(outDir, 'App_Resources/Android/src/main/res/values/strings.xml');
658
+ if (existsSync(strings)) {
659
+ let src = readFileSync(strings, 'utf8');
660
+ src = src.replace(/(<string name="app_name">)[^<]*(<\/string>)/, `$1${cfg.name}$2`);
661
+ writeFileSync(strings, src);
662
+ }
663
+ const manifest = join(outDir, 'App_Resources/Android/src/main/AndroidManifest.xml');
664
+ if (existsSync(manifest)) {
665
+ let src = readFileSync(manifest, 'utf8');
666
+ if (cfg.urlScheme) src = src.replace(/android:scheme="[^"]*"/, `android:scheme="${cfg.urlScheme}"`);
667
+ // Permissions — idempotent: rewrite the marker block from the active modules (deduped).
668
+ const perms = req.androidPerms.map((p) => `\t<uses-permission android:name="${p}"/>`);
669
+ src = src.replace(
670
+ /<!-- appwrap:permissions -->[\s\S]*?<!-- \/appwrap:permissions -->/,
671
+ `<!-- appwrap:permissions -->\n${perms.join('\n')}\n\t<!-- /appwrap:permissions -->`
672
+ );
673
+ // <application> XML from active modules (activities/providers/intent-filters) — idempotent marker.
674
+ src = src.replace(
675
+ /<!-- appwrap:application -->[\s\S]*?<!-- \/appwrap:application -->/,
676
+ `<!-- appwrap:application -->\n\t\t${req.androidManifestApp.join('\n\t\t')}\n\t\t<!-- /appwrap:application -->`
677
+ );
678
+ writeFileSync(manifest, src);
679
+ }
680
+ }
681
+
682
+ /** Stamp Android marketing version (versionName) + monotonic build (versionCode) into app.gradle. */
683
+ function stampAndroidVersion(outDir: string, cfg: AppwrapConfig): void {
684
+ const gradle = join(outDir, 'App_Resources/Android/app.gradle');
685
+ if (!existsSync(gradle)) return;
686
+ let src = readFileSync(gradle, 'utf8');
687
+ src = src.replace(/versionCode\s+\d+/, `versionCode ${buildNumberOf(cfg)}`);
688
+ src = src.replace(/versionName\s+"[^"]*"/, `versionName "${cfg.version}"`);
689
+ writeFileSync(gradle, src);
690
+ }
691
+
692
+ /** Locate the icon source: explicit cfg.icon, else the largest icon in the PWA manifest. */
693
+ function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
694
+ if (cfg.icon) {
695
+ const p = resolve(cwd, cfg.icon);
696
+ if (existsSync(p)) return p;
697
+ console.warn(`⚠ appwrap.json.icon not found: ${p}`);
698
+ return null;
699
+ }
700
+ const dist = resolve(cwd, cfg.pwaDist);
701
+ const icons: Array<{ src: string; sizes?: string }> = loadManifest(cwd, cfg)?.icons ?? [];
702
+ const best = icons
703
+ .map((i) => ({ src: i.src, px: parseInt(i.sizes ?? '0', 10) || 0 }))
704
+ .sort((a, b) => b.px - a.px)[0];
705
+ if (best) {
706
+ const p = join(dist, best.src);
707
+ if (existsSync(p)) return p;
708
+ }
709
+ return null;
710
+ }
711
+
712
+ /**
713
+ * Locate the maskable icon source for the Android adaptive-icon foreground (full-bleed, content in
714
+ * the safe zone). Prefers a manifest icon with purpose "maskable"; falls back to the main source so
715
+ * non-maskable icons still get a real foreground (just edge-cropped by the launcher mask).
716
+ */
717
+ function findMaskableSource(cwd: string, cfg: AppwrapConfig): string | null {
718
+ const dist = resolve(cwd, cfg.pwaDist);
719
+ const icons: Array<{ src: string; sizes?: string; purpose?: string }> = loadManifest(cwd, cfg)?.icons ?? [];
720
+ const maskable = icons
721
+ .filter((i) => (i.purpose ?? '').split(/\s+/).includes('maskable'))
722
+ .map((i) => ({ src: i.src, px: parseInt(i.sizes ?? '0', 10) || 0 }))
723
+ .sort((a, b) => b.px - a.px)[0];
724
+ if (maskable) {
725
+ const p = join(dist, maskable.src);
726
+ if (existsSync(p)) return p;
727
+ }
728
+ return findIconSource(cwd, cfg);
729
+ }
730
+
731
+ /** Generate iOS appiconset + Android mipmaps from the PWA's icon via sips (macOS). */
732
+ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
733
+ const source = findIconSource(cwd, cfg);
734
+ if (!source) {
735
+ console.warn('⚠ No app icon source found (manifest icons or appwrap.json.icon) — keeping template icons');
736
+ return;
737
+ }
738
+ const probe = (prop: string) =>
739
+ parseInt(execFileSync('sips', ['-g', prop, source]).toString().match(/(\d+)\s*$/)?.[1] ?? '0', 10);
740
+ const w = probe('pixelWidth');
741
+ if (w < 512) console.warn(`⚠ Icon source is ${w}px — below the 512px App Store minimum (using it anyway)`);
742
+
743
+ const resize = (px: number, dest: string) =>
744
+ execFileSync('sips', ['-z', String(px), String(px), source, '--out', dest], { stdio: 'ignore' });
745
+
746
+ const iconset = join(outDir, 'App_Resources/iOS/Assets.xcassets/AppIcon.appiconset');
747
+ if (existsSync(iconset)) {
748
+ const contents = JSON.parse(readFileSync(join(iconset, 'Contents.json'), 'utf8'));
749
+ for (const img of contents.images as Array<{ size: string; scale: string; filename: string }>) {
750
+ const px = Math.round(parseFloat(img.size) * parseFloat(img.scale));
751
+ resize(px, join(iconset, img.filename));
752
+ }
753
+ }
754
+
755
+ const ANDROID_DENSITIES: Record<string, number> = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
756
+ const res = join(outDir, 'App_Resources/Android/src/main/res');
757
+ for (const [density, px] of Object.entries(ANDROID_DENSITIES)) {
758
+ const dir = join(res, `mipmap-${density}`);
759
+ if (existsSync(dir)) resize(px, join(dir, 'ic_launcher.png'));
760
+ }
761
+
762
+ // Android 8+ (API 26+) renders the ADAPTIVE icon — mipmap-anydpi-v26/ic_launcher.xml's
763
+ // <foreground>, NOT ic_launcher.png. The NS template ships a vector foreground (the default "N"),
764
+ // so without this the launcher icon stays the template's. Generate full-bleed foreground rasters
765
+ // (108dp per density) from the maskable icon and repoint the adaptive XML at them.
766
+ const fgSource = findMaskableSource(cwd, cfg);
767
+ const ADAPTIVE_DP = 108;
768
+ const adaptiveXml = join(res, 'mipmap-anydpi-v26/ic_launcher.xml');
769
+ if (fgSource && existsSync(adaptiveXml)) {
770
+ const resizeFrom = (src: string, px: number, dest: string) =>
771
+ execFileSync('sips', ['-z', String(px), String(px), src, '--out', dest], { stdio: 'ignore' });
772
+ for (const [density, baseline] of Object.entries(ANDROID_DENSITIES)) {
773
+ const dir = join(res, `mipmap-${density}`);
774
+ if (!existsSync(dir)) continue;
775
+ const px = Math.round((baseline / 48) * ADAPTIVE_DP); // scale 48dp baseline → 108dp foreground
776
+ resizeFrom(fgSource, px, join(dir, 'ic_launcher_foreground.png'));
777
+ }
778
+ let xml = readFileSync(adaptiveXml, 'utf8');
779
+ xml = xml.replace(/(<foreground[^>]*android:drawable=")[^"]*(")/, '$1@mipmap/ic_launcher_foreground$2');
780
+ writeFileSync(adaptiveXml, xml);
781
+ }
782
+ console.log(` icon ← ${source} (${w}px)`);
783
+ }
784
+
785
+ /** Tint the iOS launch screen to the configured background color. */
786
+ function stampLaunchScreen(outDir: string, cfg: AppwrapConfig): void {
787
+ const storyboard = join(outDir, 'App_Resources/iOS/LaunchScreen.storyboard');
788
+ if (!existsSync(storyboard) || !cfg.backgroundColor) return;
789
+ const hex = cfg.backgroundColor.replace('#', '');
790
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) return;
791
+ const ch = (i: number) => (parseInt(hex.slice(i, i + 2), 16) / 255).toFixed(4);
792
+ const src = readFileSync(storyboard, 'utf8').replace(
793
+ /<color key="backgroundColor"[^/]*\/>/g,
794
+ `<color key="backgroundColor" red="${ch(0)}" green="${ch(2)}" blue="${ch(4)}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>`
795
+ );
796
+ writeFileSync(storyboard, src);
797
+ }
798
+
799
+ const VERSION_FILE = '.appwrap-version';
800
+
801
+ /** Read a package.json version, or '?' if unreadable. */
802
+ function pkgVersion(pkgPath: string): string {
803
+ try {
804
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '?';
805
+ } catch {
806
+ return '?';
807
+ }
808
+ }
809
+
810
+ /** Stamp `.appwrap-version` into the wrapper — the provenance record that makes `native/`
811
+ * a disposable, regenerable artifact: which CLI/shell/protocol generated it, from which app.
812
+ * Its presence also marks the dir as appwrap-managed (so re-`init` regenerates it safely). */
813
+ function stampVersionManifest(outDir: string, cfg: AppwrapConfig): void {
814
+ const manifest = {
815
+ cli: pkgVersion(resolve(import.meta.dir, '../package.json')),
816
+ shell: pkgVersion(join(TEMPLATE_DIR, 'package.json')),
817
+ protocol: 1,
818
+ app: { id: cfg.id, version: cfg.version, build: buildNumberOf(cfg) },
819
+ note: 'Generated by `appwrap init` — this directory is DISPOSABLE. Gitignore it; regenerate with `appwrap init`. Custom native code goes in your `overrides/` dir, not here.',
820
+ };
821
+ writeFileSync(join(outDir, VERSION_FILE), JSON.stringify(manifest, null, 2) + '\n');
822
+ }
823
+
824
+ /** Pure-native escape hatch: copy the consumer's overrides dir OVER the generated wrapper, last,
825
+ * so it wins. For legacy/custom native code the declarative config can't express. */
826
+ function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig): void {
827
+ const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
828
+ if (!existsSync(dir)) return;
829
+ cpSync(dir, outDir, { recursive: true, force: true });
830
+ console.log(` over ← ${dir} (native overrides applied)`);
831
+ }
832
+
833
+ function copyPwa(cwd: string, outDir: string, cfg: AppwrapConfig): void {
834
+ // Stage the PWA OUTSIDE appPath ('app') — in a sibling `www-src/` — so NativeScript's webpack
835
+ // never runs its loaders (css2json etc.) over real web CSS/assets. webpack.config.js copies
836
+ // `www-src` → the bundle's `www` verbatim; the app:// scheme handler serves it at runtime.
837
+ const www = join(outDir, 'www-src');
838
+ const legacyWww = join(outDir, 'app/www'); // clear any pre-isolation staging
839
+ rmSync(legacyWww, { recursive: true, force: true });
840
+ // server loader loads `serverUrl` live — the bundle is unused. Don't copy it; and clear any stale
841
+ // www so it isn't shipped.
842
+ if (cfg.loader === 'server') {
843
+ rmSync(www, { recursive: true, force: true });
844
+ console.log(' www ← skipped (loader:server loads serverUrl)');
845
+ return;
846
+ }
847
+ const dist = resolve(cwd, cfg.pwaDist);
848
+ const entry = join(dist, cfg.entry ?? 'index.html');
849
+ if (!existsSync(entry)) {
850
+ console.error(`✖ PWA entry not found: ${entry} — build your PWA first`);
851
+ process.exit(1);
852
+ }
853
+ rmSync(www, { recursive: true, force: true });
854
+ mkdirSync(www, { recursive: true });
855
+ cpSync(dist, www, { recursive: true });
856
+ console.log(` www ← ${dist}`);
857
+ vendorBackendAssets(www, cfg);
858
+ }
859
+
860
+ /** Fetch backend-served static assets (cfg.vendorPaths) into the bundle so they resolve offline at
861
+ * app://. Pins them to the build — re-fetched on every init/sync. Synchronous via curl. */
862
+ function vendorBackendAssets(www: string, cfg: AppwrapConfig): void {
863
+ if (!cfg.vendorPaths?.length) return;
864
+ if (!cfg.backendOrigin) {
865
+ console.error('✖ vendorPaths requires backendOrigin in appwrap.json');
866
+ process.exit(1);
867
+ }
868
+ const origin = cfg.backendOrigin.replace(/\/+$/, '');
869
+ for (const p of cfg.vendorPaths) {
870
+ const rel = p.replace(/^\/+/, '');
871
+ const url = `${origin}/${rel}`;
872
+ const dest = join(www, rel);
873
+ mkdirSync(dirname(dest), { recursive: true });
874
+ const tmp = `${dest}.tmp`;
875
+ // Fetch to a temp file so a transient failure never truncates a previously-vendored asset.
876
+ // Retry a couple times (the backend can briefly reset under deploy/cold-start), and on total
877
+ // failure fall back to the cached copy if one exists rather than breaking the whole sync.
878
+ let ok = false;
879
+ let lastErr: any;
880
+ for (let attempt = 0; attempt < 3 && !ok; attempt++) {
881
+ try {
882
+ execFileSync('curl', ['-fsSL', '--retry', '2', url, '-o', tmp], { stdio: 'pipe' });
883
+ cpSync(tmp, dest);
884
+ ok = true;
885
+ } catch (e: any) {
886
+ lastErr = e;
887
+ }
888
+ }
889
+ rmSync(tmp, { force: true });
890
+ if (ok) {
891
+ console.log(` vendor ← ${url}`);
892
+ } else if (existsSync(dest) && readFileSync(dest).length > 0) {
893
+ console.warn(`⚠ vendor fetch failed: ${url} — using cached copy (backend unreachable).`);
894
+ if (lastErr?.stderr) console.warn(` ${String(lastErr.stderr).trim()}`);
895
+ } else {
896
+ console.error(`✖ vendor fetch failed: ${url} (backend reachable? path correct?) — no cached copy to fall back to`);
897
+ if (lastErr?.stderr) console.error(String(lastErr.stderr).trim());
898
+ process.exit(1);
899
+ }
900
+ }
901
+ }
902
+
903
+ /** Walk up from `start` to the git repo root (dir containing `.git`); fall back to `start`. */
904
+ function gitRoot(start: string): string {
905
+ let dir = start;
906
+ while (true) {
907
+ if (existsSync(join(dir, '.git'))) return dir;
908
+ const parent = dirname(dir);
909
+ if (parent === dir) return start; // reached filesystem root, no .git found
910
+ dir = parent;
911
+ }
912
+ }
913
+
914
+ /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
915
+ function copyCiTemplates(cwd: string, outDir: string): void {
916
+ if (!existsSync(CI_TEMPLATE_DIR)) return;
917
+ // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
918
+ // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
919
+ const targets: Array<[string, string]> = [
920
+ [join(CI_TEMPLATE_DIR, 'github/workflows'), join(gitRoot(cwd), '.github/workflows')],
921
+ [join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')],
922
+ ];
923
+ for (const [from, to] of targets) {
924
+ mkdirSync(to, { recursive: true });
925
+ cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
926
+ }
927
+ console.log(' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane) — see secrets contract in workflow headers');
928
+ }
929
+
930
+ /**
931
+ * Reproduce native/ from source — the shared core of `init` and `sync`. Copies the runtime shell
932
+ * template, re-stamps EVERY config artifact, and re-copies the built PWA. `native/` is disposable, so a
933
+ * full copy every time is correct — and is what keeps `sync` from silently shipping stale runtime/config
934
+ * (the old split made `sync` skip the template + nsconfig id + version manifest → three drift footguns).
935
+ * Excludes the first-time scaffold (managed-guard, CI, .gitignore) + overrides/version-manifest, which the
936
+ * callers sequence around this so overrides win LAST and the marker writes after.
937
+ */
938
+ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean } = {}): void {
939
+ const req = nativeReqs(cfg);
940
+ if (opts.firstRun && !req.explicit) {
941
+ console.log(' ℹ no `modules` in appwrap.json → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
942
+ }
943
+ cpSync(TEMPLATE_DIR, outDir, {
944
+ recursive: true,
945
+ force: true, // explicit: Bun's cpSync does not overwrite existing files by default
946
+ // modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
947
+ // Match RELATIVE to TEMPLATE_DIR — when installed from npm, TEMPLATE_DIR itself sits under
948
+ // node_modules/, so testing the absolute path would wrongly exclude the entire template.
949
+ filter: (src) => !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(TEMPLATE_DIR.length)),
950
+ });
951
+ stampShellConfig(outDir, cfg);
952
+ stampNativeScriptConfig(outDir, cfg);
953
+ stampIOSDisplayName(outDir, cfg, req);
954
+ stampTeamId(outDir, cfg);
955
+ stampAndroidAppName(outDir, cfg, req);
956
+ stampAndroidVersion(outDir, cfg);
957
+ stampAndroidGradleDeps(outDir, req.androidGradleDeps);
958
+ stampKotlin(outDir, req.androidKotlin);
959
+ generateModuleArtifacts(outDir, req);
960
+ copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
961
+ stampLaunchScreen(outDir, cfg);
962
+ stampStoreKit(cwd, outDir, cfg);
963
+ stampPush(cwd, outDir, cfg);
964
+ stampEntitlements(outDir, cfg, req); // unified app.entitlements: module entitlements + push aps-environment
965
+ generateIcons(cwd, outDir, cfg);
966
+ copyPwa(cwd, outDir, cfg);
967
+ }
968
+
969
+ function init(cwd: string, flags: Record<string, string>): void {
970
+ const cfg = loadConfig(cwd, flags);
971
+ const outDir = resolve(cwd, flags.out ?? 'native');
972
+
973
+ if (!existsSync(TEMPLATE_DIR)) {
974
+ console.error(`✖ Runtime template not found at ${TEMPLATE_DIR}`);
975
+ process.exit(1);
976
+ }
977
+
978
+ // Managed-model guard: re-`init` regenerates an appwrap-managed wrapper freely (it's disposable),
979
+ // but refuse to clobber a directory we didn't generate unless --force is passed.
980
+ if (existsSync(outDir)) {
981
+ const managed = existsSync(join(outDir, VERSION_FILE));
982
+ const nonEmpty = readdirSync(outDir).length > 0;
983
+ if (nonEmpty && !managed && !('force' in flags)) {
984
+ console.error(
985
+ `✖ ${outDir} exists and is not an appwrap-managed wrapper (no ${VERSION_FILE}).\n` +
986
+ ` Re-run with --force to overwrite it, or choose a different --out.`
987
+ );
988
+ process.exit(1);
989
+ }
990
+ }
991
+
992
+ console.log(`🎁 appwrap init → ${outDir}`);
993
+ mkdirSync(outDir, { recursive: true });
994
+ regenerateCore(cwd, outDir, cfg, { firstRun: true });
995
+ copyCiTemplates(cwd, outDir); // first-time scaffold (never overwrites)
996
+ writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
997
+ applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
998
+ stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
999
+ console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n cd ${flags.out ?? 'native'} && npm install && ns run ios`);
1000
+ }
1001
+
1002
+ // `sync` = the same regenerate as `init`, minus the first-time guard/scaffold. It is a TRUE refresh from
1003
+ // source (shell + config + PWA), so runtime/config edits never silently lag behind. `native/` is
1004
+ // disposable; re-copying the shell costs ~ms (the real cost is the later `ns build`, which both share).
1005
+ function sync(cwd: string, flags: Record<string, string>): void {
1006
+ const cfg = loadConfig(cwd, flags);
1007
+ const outDir = resolve(cwd, flags.out ?? 'native');
1008
+ if (!existsSync(outDir)) {
1009
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1010
+ process.exit(1);
1011
+ }
1012
+ regenerateCore(cwd, outDir, cfg);
1013
+ applyOverrides(cwd, outDir, cfg); // overrides win last
1014
+ stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
1015
+ console.log('✓ Synced.');
1016
+ }
1017
+
1018
+ /** First non-internal IPv4 — so a physical device on the LAN can reach the dev server (localhost won't). */
1019
+ function lanIp(): string | null {
1020
+ for (const addrs of Object.values(networkInterfaces())) {
1021
+ for (const a of addrs ?? []) {
1022
+ if (a.family === 'IPv4' && !a.internal) return a.address;
1023
+ }
1024
+ }
1025
+ return null;
1026
+ }
1027
+
1028
+ /** `appwrap dev` — point the existing wrapper at a LIVE url (loader 'server') instead of bundled www.
1029
+ * Dev runs their own web server (vite host:true) or a deployed URL; this just stamps the shell config.
1030
+ * `--url <url>` explicit; else http://<lan-ip>:<port> (default 5173). Re-run `appwrap sync`/`init` to revert. */
1031
+ function dev(cwd: string, flags: Record<string, string>): void {
1032
+ const cfg = loadConfig(cwd, flags);
1033
+ const outDir = resolve(cwd, flags.out ?? 'native');
1034
+ if (!existsSync(outDir)) {
1035
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1036
+ process.exit(1);
1037
+ }
1038
+ let url = flags.url;
1039
+ if (!url) {
1040
+ const ip = lanIp();
1041
+ if (!ip) {
1042
+ console.error('✖ Could not detect a LAN IP — pass --url http://<host>:<port> explicitly');
1043
+ process.exit(1);
1044
+ }
1045
+ url = `http://${ip}:${flags.port ?? '5173'}`;
1046
+ }
1047
+ // Dev is inherently a debug workflow: enables the WebView inspector, keep-awake, and the
1048
+ // debug-only dev-server SSL bypass (LAN dev servers use self-signed/mkcert certs the device
1049
+ // doesn't trust). Revert to a non-debug, bundled build with `appwrap sync`.
1050
+ stampShellConfig(outDir, { ...cfg, loader: 'server', serverUrl: url, debug: true });
1051
+ console.log(`✓ Dev loader → ${url} (debug)`);
1052
+ console.log(` Web server must bind 0.0.0.0 (vite: \`server.host: true\` / \`--host\`) so the device can reach it.`);
1053
+ if (url.startsWith('https:')) {
1054
+ console.log(` ⚠ Android: serve the dev server over HTTP, not HTTPS — the WebView can't bypass wss TLS`);
1055
+ console.log(` errors, so HMR won't live-reload on-device (the page still loads). iOS is fine with HTTPS.`);
1056
+ }
1057
+ console.log(` Then: cd ${flags.out ?? 'native'} && ns run ios (revert with \`appwrap sync\`)`);
1058
+ }
1059
+
1060
+ /** `appwrap build <ios|android> [--release] [--aab]` — store-readiness build path. Re-stamps config,
1061
+ * re-copies the PWA, then delegates the actual compile to NativeScript with the right flags. Release
1062
+ * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
1063
+ * never live in appwrap.json. iOS distribution signing/upload is the fastlane release lane's job (the
1064
+ * cicd templates); `--release` here just builds the Release config for the device. */
1065
+ function build(cwd: string, flags: Record<string, string>, positionals: string[]): void {
1066
+ const platform = positionals[0];
1067
+ if (platform !== 'ios' && platform !== 'android') {
1068
+ console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config appwrap.json] [--out native]');
1069
+ process.exit(1);
1070
+ }
1071
+ const outDir = resolve(cwd, flags.out ?? 'native');
1072
+ if (!existsSync(outDir)) {
1073
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1074
+ process.exit(1);
1075
+ }
1076
+ // Make sure the wrapper reflects the latest config + PWA before compiling (also validates appwrap.json).
1077
+ sync(cwd, flags);
1078
+
1079
+ const release = 'release' in flags;
1080
+ const args = ['build', platform];
1081
+ if (release) args.push('--release');
1082
+ if (platform === 'ios' && release) args.push('--for-device');
1083
+ if (platform === 'android' && 'aab' in flags) args.push('--aab');
1084
+
1085
+ if (platform === 'android' && release) {
1086
+ const ks = process.env.APPWRAP_ANDROID_KEYSTORE;
1087
+ if (!ks) {
1088
+ console.error(
1089
+ '✖ Release Android build needs a signing keystore. Set:\n' +
1090
+ ' APPWRAP_ANDROID_KEYSTORE=/abs/path/to.keystore\n' +
1091
+ ' APPWRAP_ANDROID_KEYSTORE_PASSWORD=… APPWRAP_ANDROID_KEYSTORE_ALIAS=… APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD=…\n' +
1092
+ ' (generate a throwaway one with `keytool -genkeypair -keystore upload.keystore -alias upload -keyalg RSA -keysize 2048 -validity 10000`).'
1093
+ );
1094
+ process.exit(1);
1095
+ }
1096
+ args.push(
1097
+ '--key-store-path', ks,
1098
+ '--key-store-password', process.env.APPWRAP_ANDROID_KEYSTORE_PASSWORD ?? '',
1099
+ '--key-store-alias', process.env.APPWRAP_ANDROID_KEYSTORE_ALIAS ?? '',
1100
+ '--key-store-alias-password', process.env.APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD ?? ''
1101
+ );
1102
+ }
1103
+
1104
+ console.log(`▶ ns ${args.join(' ').replace(/(--key-store-password|--key-store-alias-password) [^ ]*/g, '$1 ****')} (cwd: ${outDir})`);
1105
+ execFileSync('ns', args, { cwd: outDir, stdio: 'inherit' });
1106
+ if (platform === 'ios' && release) {
1107
+ console.log('ℹ App Store distribution (archive + upload) goes through the fastlane release lane (native/fastlane) — needs a paid team + ASC API key.');
1108
+ }
1109
+ }
1110
+
1111
+ interface DeviceInfo { id: string; name: string; model: string; transport: string }
1112
+
1113
+ /** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
1114
+ * tunnels and non-iOS (watch). Returns [] if none. */
1115
+ function listIosDevices(): DeviceInfo[] {
1116
+ const out = join(tmpdir(), `appwrap-devices-${process.pid}.json`);
1117
+ try {
1118
+ execFileSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', out], { stdio: 'pipe' });
1119
+ const j = JSON.parse(readFileSync(out, 'utf8'));
1120
+ rmSync(out, { force: true });
1121
+ return (j?.result?.devices ?? [])
1122
+ .filter((d: any) => d?.hardwareProperties?.platform === 'iOS'
1123
+ && d?.connectionProperties?.tunnelState !== 'unavailable')
1124
+ .map((d: any) => ({
1125
+ id: d.identifier,
1126
+ name: d?.deviceProperties?.name ?? '(unknown)',
1127
+ model: d?.hardwareProperties?.marketingName ?? d?.hardwareProperties?.productType ?? '',
1128
+ transport: d?.connectionProperties?.transportType ?? '',
1129
+ }));
1130
+ } catch {
1131
+ return [];
1132
+ }
1133
+ }
1134
+
1135
+ /** Pick a device: explicit --device wins; else auto-select the only one; else list + prompt. */
1136
+ function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1137
+ if (explicitId) {
1138
+ const m = devices.find((d) => d.id === explicitId || d.name === explicitId);
1139
+ if (!m) { console.error(`✖ --device "${explicitId}" not found among connected devices.`); process.exit(1); }
1140
+ return m;
1141
+ }
1142
+ if (devices.length === 0) {
1143
+ console.error('✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.');
1144
+ process.exit(1);
1145
+ }
1146
+ if (devices.length === 1) {
1147
+ console.log(`📱 Using ${devices[0].name} (${devices[0].model || devices[0].transport})`);
1148
+ return devices[0];
1149
+ }
1150
+ console.log('Multiple devices connected:');
1151
+ devices.forEach((d, i) => console.log(` ${i + 1}) ${d.name} — ${d.model || 'iPhone'} [${d.transport}]`));
1152
+ const ans = (globalThis as any).prompt(`Select device [1-${devices.length}]: `);
1153
+ const idx = Number(ans) - 1;
1154
+ if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) {
1155
+ console.error('✖ Invalid selection.'); process.exit(1);
1156
+ }
1157
+ return devices[idx];
1158
+ }
1159
+
1160
+ /** `appwrap deploy ios [--device <id|name>] [--no-launch]` — build for device, auto-pick the
1161
+ * connected phone (USB or network; prompts if several), install + launch. Debug build (no
1162
+ * distribution signing) — for testing on your own device. Run the PWA build first (or via the script). */
1163
+ function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): void {
1164
+ const platform = positionals[0];
1165
+ if (platform !== 'ios') {
1166
+ console.error('Usage: appwrap deploy ios [--device <id|name>] [--no-launch] (android: use `ns run android`)');
1167
+ process.exit(1);
1168
+ }
1169
+ const cfg = loadConfig(cwd, flags);
1170
+ const outDir = resolve(cwd, flags.out ?? 'native');
1171
+ if (!existsSync(outDir)) {
1172
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1173
+ process.exit(1);
1174
+ }
1175
+ // Pick the device up front so we fail fast before a long build if nothing's connected.
1176
+ const device = pickDevice(listIosDevices(), flags.device || undefined);
1177
+
1178
+ sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1179
+ // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1180
+ stampShellConfig(outDir, { ...loadConfig(cwd, flags), debug: true });
1181
+ console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
1182
+ execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1183
+
1184
+ const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
1185
+ const ipa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
1186
+ if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
1187
+ const ipaPath = join(ipaDir, ipa);
1188
+
1189
+ console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
1190
+ try {
1191
+ // Capture (not inherit) so we can recognize specific failures; echo it for visibility.
1192
+ const out = execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
1193
+ process.stdout.write(out);
1194
+ } catch (e: any) {
1195
+ const log = `${e?.stdout ?? ''}${e?.stderr ?? ''}`;
1196
+ process.stderr.write(log);
1197
+ if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) {
1198
+ // Free Apple developer profile caps a device at 3 app IDs — a sibling appwrap/WDA build often eats a slot.
1199
+ const ids = [...log.matchAll(/"([A-Z0-9]{10}\.[^"]+)"/g)].map((m) => m[1]);
1200
+ console.error(
1201
+ "\n✖ Install blocked: this device hit the FREE developer profile's 3-app limit (not a lock).\n" +
1202
+ (ids.length ? ` Installed under this team: ${ids.join(', ')}\n` : '') +
1203
+ ' → Uninstall one you don\'t need, then re-run:\n' +
1204
+ ` xcrun devicectl device uninstall app --device ${device.id} <bundleId>\n` +
1205
+ ' (A paid Apple Developer account removes this limit.)'
1206
+ );
1207
+ } else {
1208
+ console.error(
1209
+ '✖ Install failed. Usually the device is LOCKED or only on Wi-Fi.\n' +
1210
+ ' → Unlock the phone (and plug in USB for a reliable connection), then re-run.\n' +
1211
+ ` The built .ipa is ready: ${ipaPath}`
1212
+ );
1213
+ }
1214
+ process.exit(1);
1215
+ }
1216
+
1217
+ if (!('no-launch' in flags)) {
1218
+ console.log(`▶ launching ${cfg.id}`);
1219
+ try {
1220
+ execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', device.id, cfg.id], { stdio: 'inherit' });
1221
+ } catch {
1222
+ console.error('⚠ Launch failed (device locked?). The app is installed — unlock and tap it, or re-run.');
1223
+ }
1224
+ }
1225
+ console.log(`✓ Deployed to ${device.name}.`);
1226
+ }
1227
+
1228
+ /** First connected libimobiledevice UDID (USB, then network). Distinct from devicectl's identifier. */
1229
+ function libimobiledeviceUdid(): { udid: string; network: boolean } | null {
1230
+ for (const [args, network] of [[['-l'], false], [['-n'], true]] as const) {
1231
+ try {
1232
+ const out = execFileSync('idevice_id', args, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
1233
+ const first = out.split('\n').map((s) => s.trim()).filter(Boolean)[0];
1234
+ if (first) return { udid: first.split(/\s+/)[0], network };
1235
+ } catch { /* idevice_id missing or no device */ }
1236
+ }
1237
+ return null;
1238
+ }
1239
+
1240
+ /** `appwrap logs ios` — read the WebView's forwarded console + errors. In debug builds the shell
1241
+ * forwards them to a file in the app container (NS `console.log`/`NSLog` do NOT surface to devicectl
1242
+ * or idevicesyslog on a device build — a file is the reliable channel), which this pulls via
1243
+ * `devicectl device copy`. DEFAULT: watch (poll the file ~every 3s, print new lines). `--once`:
1244
+ * one snapshot. `--native`: the OS-level app syslog firehose via idevicesyslog (native crashes; USB).
1245
+ * Headless-friendly: redirect to a file and read it. */
1246
+ function logs(cwd: string, flags: Record<string, string>, positionals: string[]): void {
1247
+ const platform = positionals[0] ?? 'ios';
1248
+ if (platform !== 'ios') {
1249
+ console.error('Usage: appwrap logs ios [--once] [--native] [--device <id|name>]');
1250
+ process.exit(1);
1251
+ }
1252
+ const cfg = loadConfig(cwd, flags);
1253
+
1254
+ if ('native' in flags) {
1255
+ const li = libimobiledeviceUdid();
1256
+ if (!li) {
1257
+ console.error('✖ No device via libimobiledevice (need USB, or `brew install libimobiledevice`).');
1258
+ process.exit(1);
1259
+ }
1260
+ console.log(`▶ native OS syslog for ${cfg.id} (idevicesyslog -p native) — Ctrl-C to stop.`);
1261
+ try {
1262
+ execFileSync('idevicesyslog', ['-u', li.udid, ...(li.network ? ['-n'] : []), '-p', 'native'], { stdio: 'inherit' });
1263
+ } catch {
1264
+ process.exit(1);
1265
+ }
1266
+ return;
1267
+ }
1268
+
1269
+ const device = pickDevice(listIosDevices(), flags.device || undefined);
1270
+ const dest = join(tmpdir(), `appwrap-weblog-${process.pid}.log`);
1271
+ const pull = (): string => {
1272
+ try {
1273
+ execFileSync(
1274
+ 'xcrun',
1275
+ ['devicectl', 'device', 'copy', 'from', '--device', device.id, '--domain-type', 'appDataContainer',
1276
+ '--domain-identifier', cfg.id, '--source', 'Documents/appwrap-web.log', '--destination', dest],
1277
+ { stdio: ['ignore', 'ignore', 'ignore'] }
1278
+ );
1279
+ return readFileSync(dest, 'utf8');
1280
+ } catch {
1281
+ return ''; // not yet created (app hasn't logged) or not a debug build
1282
+ }
1283
+ };
1284
+
1285
+ if ('once' in flags) {
1286
+ process.stdout.write(pull() || '(no web log yet — debug build? has the app logged anything?)\n');
1287
+ return;
1288
+ }
1289
+
1290
+ console.log(`▶ watching web logs from ${cfg.id} on ${device.name} (pull every 3s) — Ctrl-C to stop.`);
1291
+ console.log(' [appwrap-web] = forwarded WebView console/errors. (--once = snapshot, --native = OS firehose.)');
1292
+ let shown = 0;
1293
+ for (;;) {
1294
+ const all = pull();
1295
+ if (all.length < shown) shown = 0; // app relaunched → file reset; reprint
1296
+ if (all.length > shown) { process.stdout.write(all.slice(shown)); shown = all.length; }
1297
+ try { execFileSync('sleep', ['3']); } catch { break; }
1298
+ }
1299
+ }
1300
+
1301
+ const { command, flags, positionals } = parseArgs(process.argv.slice(2));
1302
+ const cwd = process.cwd();
1303
+
1304
+ switch (command) {
1305
+ case 'init':
1306
+ init(cwd, flags);
1307
+ break;
1308
+ case 'sync':
1309
+ sync(cwd, flags);
1310
+ break;
1311
+ case 'dev':
1312
+ dev(cwd, flags);
1313
+ break;
1314
+ case 'build':
1315
+ build(cwd, flags, positionals);
1316
+ break;
1317
+ case 'deploy':
1318
+ deploy(cwd, flags, positionals);
1319
+ break;
1320
+ case 'logs':
1321
+ logs(cwd, flags, positionals);
1322
+ break;
1323
+ default:
1324
+ console.log('Usage: appwrap <init|sync|dev|build|deploy|logs> [--config appwrap.json] [--out native]\n' +
1325
+ ' build <ios|android> [--release] [--aab] deploy ios [--device <id|name>] [--no-launch]\n' +
1326
+ ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
1327
+ process.exit(command ? 1 : 0);
1328
+ }