@livx.cc/appwrap 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/package.json +46 -0
- package/runtime/App_Resources/Android/app.gradle +34 -0
- package/runtime/App_Resources/Android/before-plugins.gradle +15 -0
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +76 -0
- package/runtime/App_Resources/Android/src/main/res/drawable/ic_launcher_foreground.xml +15 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml +8 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/values/colors.xml +14 -0
- package/runtime/App_Resources/Android/src/main/res/values/ic_launcher_background.xml +4 -0
- package/runtime/App_Resources/Android/src/main/res/values/styles.xml +42 -0
- package/runtime/App_Resources/Android/src/main/res/values-v21/colors.xml +5 -0
- package/runtime/App_Resources/Android/src/main/res/values-v21/styles.xml +32 -0
- package/runtime/App_Resources/Android/src/main/res/values-v29/styles.xml +18 -0
- package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +5 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/Contents.json +6 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/Contents.json +23 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/Contents.json +23 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@3x.png +0 -0
- package/runtime/App_Resources/iOS/Info.plist +60 -0
- package/runtime/App_Resources/iOS/LaunchScreen.storyboard +53 -0
- package/runtime/App_Resources/iOS/Podfile +13 -0
- package/runtime/App_Resources/iOS/PrivacyInfo.xcprivacy +59 -0
- package/runtime/App_Resources/iOS/build.xcconfig +7 -0
- package/runtime/app/app.ts +58 -0
- package/runtime/app/main-page.ts +116 -0
- package/runtime/app/main-page.xml +10 -0
- package/runtime/app/shell/active-modules.generated.ts +9 -0
- package/runtime/app/shell/android-helpers.ts +76 -0
- package/runtime/app/shell/bridge.ts +99 -0
- package/runtime/app/shell/capabilities.manifest.ts +207 -0
- package/runtime/app/shell/config.ts +35 -0
- package/runtime/app/shell/custom-webview.android.ts +270 -0
- package/runtime/app/shell/custom-webview.ios.ts +258 -0
- package/runtime/app/shell/custom-webview.ts +17 -0
- package/runtime/app/shell/env.ts +81 -0
- package/runtime/app/shell/events.ts +100 -0
- package/runtime/app/shell/fcm-bootstrap.generated.ts +1 -0
- package/runtime/app/shell/fcm-firebase.d.ts +16 -0
- package/runtime/app/shell/fcm-service.android.ts +40 -0
- package/runtime/app/shell/fcm-service.ios.ts +3 -0
- package/runtime/app/shell/handlers-android.ts +417 -0
- package/runtime/app/shell/handlers-billing.ts +197 -0
- package/runtime/app/shell/handlers-extended.ts +405 -0
- package/runtime/app/shell/handlers-health.ts +134 -0
- package/runtime/app/shell/handlers-media.ts +126 -0
- package/runtime/app/shell/handlers-oauth.ts +69 -0
- package/runtime/app/shell/handlers-parity.ts +257 -0
- package/runtime/app/shell/handlers-push.ts +189 -0
- package/runtime/app/shell/handlers-system.ts +70 -0
- package/runtime/app/shell/handlers.ts +149 -0
- package/runtime/app/shell/ios-frameworks.d.ts +36 -0
- package/runtime/app/shell/ios-image.ts +15 -0
- package/runtime/app/shell/ios-ui-delegate.ts +131 -0
- package/runtime/app/shell/mime.ts +29 -0
- package/runtime/app/shell/native-log.ts +22 -0
- package/runtime/app/shell/optional-handlers.generated.ts +10 -0
- package/runtime/app/shell/orientation.ts +43 -0
- package/runtime/app/shell/status-bar.ts +129 -0
- package/runtime/app/shell/toast.ts +67 -0
- package/runtime/app/shell/web-quirks.ts +131 -0
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +74 -0
- package/runtime/nativescript.config.ts +15 -0
- package/runtime/package.json +21 -0
- package/runtime/references.d.ts +2 -0
- package/runtime/tsconfig.json +19 -0
- package/runtime/webpack.config.js +27 -0
- package/scripts/stage-assets.mjs +21 -0
- package/src/cli.ts +1328 -0
- package/templates/ci/fastlane/Fastfile +43 -0
- package/templates/ci/fastlane/Matchfile +3 -0
- package/templates/ci/github/workflows/appwrap-pr.yml +28 -0
- package/templates/ci/github/workflows/appwrap-release-android.yml +51 -0
- package/templates/ci/github/workflows/appwrap-release-ios.yml +41 -0
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
|
+
}
|