@livx.cc/appwrap 0.41.0 → 0.42.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.41.0",
3
+ "version": "0.42.2",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -8,13 +8,16 @@
8
8
  - FileTimestamp (C617.1) — NativeScript reads bundle/app file metadata at runtime
9
9
  - SystemBootTime (35F9.1) — NativeScript timers/uptime
10
10
  - DiskSpace (E174.1) — NativeScript file I/O
11
- NSPrivacyTracking is false (no IDFA, no cross-app/third-party linkage). The shell surfaces install
12
- context for FIRST-PARTY analytics via kit.context():
13
- - DeviceID (CRC0) — identifierForVendor (IDFV), app-scoped, reset on uninstall; NOT IDFA.
14
- - ProductInteraction (385F.1) — usage/app-environment props (platform, version, install source).
15
- Both are declared NOT linked to identity and NOT used for tracking. If your PWA collects MORE
16
- (account, location, contacts) or DOES track (ads/3rd-party linkage), add the matching
17
- NSPrivacyCollectedDataTypes / set NSPrivacyTracking=true here — see tracking-analytics + store-readiness.
11
+ NSPrivacyTracking is false (no IDFA, no cross-app/third-party linkage) and NSPrivacyCollectedDataTypes
12
+ is EMPTY by default: the stock runtime does NOT collect or transmit any data. kit.context() merely
13
+ RETURNS install context (IDFV, platform, version) to the app on request — it never sends it anywhere —
14
+ so the default binary matches a "Data Not Collected" App Store privacy label.
15
+
16
+ If YOUR app wires analytics that actually transmits data (DeviceID/IDFV, ProductInteraction, account,
17
+ location, …) you MUST declare it here — replace this whole file via your app's overrides dir
18
+ (appwrap.overrides/App_Resources/iOS/PrivacyInfo.xcprivacy). `applyOverrides` copies overrides over
19
+ the generated native/ last, so your file wins. Add the matching NSPrivacyCollectedDataTypes entries
20
+ (and set NSPrivacyTracking=true if you track) — see tracking-analytics + store-readiness.
18
21
  -->
19
22
  <plist version="1.0">
20
23
  <dict>
@@ -23,32 +26,7 @@
23
26
  <key>NSPrivacyTrackingDomains</key>
24
27
  <array/>
25
28
  <key>NSPrivacyCollectedDataTypes</key>
26
- <array>
27
- <dict>
28
- <key>NSPrivacyCollectedDataType</key>
29
- <string>NSPrivacyCollectedDataTypeDeviceID</string>
30
- <key>NSPrivacyCollectedDataTypeLinked</key>
31
- <false/>
32
- <key>NSPrivacyCollectedDataTypeTracking</key>
33
- <false/>
34
- <key>NSPrivacyCollectedDataTypePurposes</key>
35
- <array>
36
- <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
37
- </array>
38
- </dict>
39
- <dict>
40
- <key>NSPrivacyCollectedDataType</key>
41
- <string>NSPrivacyCollectedDataTypeProductInteraction</string>
42
- <key>NSPrivacyCollectedDataTypeLinked</key>
43
- <false/>
44
- <key>NSPrivacyCollectedDataTypeTracking</key>
45
- <false/>
46
- <key>NSPrivacyCollectedDataTypePurposes</key>
47
- <array>
48
- <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
49
- </array>
50
- </dict>
51
- </array>
29
+ <array/>
52
30
  <key>NSPrivacyAccessedAPITypes</key>
53
31
  <array>
54
32
  <dict>
@@ -59,4 +59,10 @@ export const SHELL_CONFIG = {
59
59
  * `{ token, platform: 'ios'|'android' }`. Native HTTP avoids the WKWebView `app://` cross-origin/CORS
60
60
  * wall, so a token reaches your server without any WebView fetch. Empty = the app handles sending. */
61
61
  pushRegistrationUrl: '',
62
+ /** iOS only. Extra points to lift the WebView ABOVE the reported keyboard height on focus. iOS 26
63
+ * reports a keyboard ~this-many points TALLER than it draws on warm re-focus (a phantom accessory
64
+ * reservation), which would leave a black strip between the resized WebView and the real keys.
65
+ * Lifting extra makes the page cover that strip (the keyboard hides the thin bottom row of content).
66
+ * Default 82. Set 0 for NO extra lift (input sits flush at the reported height; the strip may show). */
67
+ iosKeyboardExtraLift: 82,
62
68
  };
@@ -1,12 +1,24 @@
1
1
  import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
+ import { SHELL_CONFIG } from './config';
4
+ import { appwrapNativeLog } from './native-log';
3
5
 
4
6
  declare const android: any;
5
7
  // iOS keyboard-notification globals (marshalled from UIKit at runtime).
6
8
  declare const NSNotificationCenter: any;
7
9
  declare const UIKeyboardWillShowNotification: string;
10
+ declare const UIKeyboardDidShowNotification: string;
8
11
  declare const UIKeyboardWillHideNotification: string;
12
+ declare const UIKeyboardDidHideNotification: string;
13
+ declare const UIKeyboardWillChangeFrameNotification: string;
14
+ declare const UIKeyboardDidChangeFrameNotification: string;
9
15
  declare const UIKeyboardFrameEndUserInfoKey: string;
16
+ declare const UIKeyboardAnimationDurationUserInfoKey: string;
17
+ declare const UIEdgeInsetsZero: any;
18
+ declare const NSObject: any;
19
+ declare const UIScrollViewDelegate: any;
20
+ declare function CGPointMake(x: number, y: number): any;
21
+ declare const UIColor: any;
10
22
 
11
23
  let observersArmed = false;
12
24
 
@@ -36,17 +48,217 @@ export function registerKeyboardHandlers(): void {
36
48
  else if (isAndroid) armAndroidKeyboardObserver();
37
49
  }
38
50
 
39
- /** iOS: NSNotificationCenter observers → emit keyboard.show {height} / keyboard.hide. */
51
+ /**
52
+ * iOS: native keyboard avoidance — a faithful port of Capacitor's `resize: native` mode
53
+ * (capacitor-keyboard Keyboard.m), which is the battle-tested way to do this in a WKWebView shell:
54
+ *
55
+ * 1. DETACH the WKWebView from UIKit keyboard notifications. WKWebView internally observes them
56
+ * and applies its own scrollView insets/auto-scroll; with two keyboard-handling authorities the
57
+ * layouts stack and fling content. Capacitor removes the webview as an observer so the shell is
58
+ * the ONLY authority.
59
+ * 2. RESIZE the webview frame to (window height − keyboard height) AFTER the keyboard animation
60
+ * finishes (animationDuration + 0.2s, debounced) — never mid-animation. Restore on hide with a
61
+ * near-zero delay. `marginBottom` is kept in sync so NativeScript layout passes agree with the
62
+ * frame instead of clobbering it.
63
+ * 3. ZERO the scrollView contentInset on every keyboard event — WebKit's leftover keyboard inset
64
+ * is what double-compensates and leaves black bands.
65
+ *
66
+ * Also emits keyboard.show {height} / keyboard.hide to the page (heights in points ≈ CSS px).
67
+ */
40
68
  function armIosKeyboardObservers(): void {
41
69
  const center = NSNotificationCenter.defaultCenter;
42
- center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillShowNotification, null, null, (note: any) => {
70
+ let paddingBottom = 0;
71
+ let pendingUpdate: any = null;
72
+ let webkitDetached = false;
73
+ // Extra lift applied by the CURRENT show, decided by whether the ▲▼✓ accessory bar is drawn:
74
+ // a COLD focus (fires UIKeyboardWillShow) draws the bar → reported height is accurate → 0 extra;
75
+ // a WARM re-focus (arrives as didShow-only, no willShow) drops the bar but still reports the taller
76
+ // height → apply the configured extra lift to close the gap. Reset when the keyboard fully hides.
77
+ let sawWillShow = false;
78
+ let activeExtraLift = 0;
79
+
80
+ const getWk = (): WKWebView | undefined => bridge.getWebView()?.ios as WKWebView | undefined;
81
+
82
+ // (1) Make the shell the only keyboard-handling authority (Capacitor does exactly this in load()).
83
+ const detachWebKitKeyboardHandling = (): void => {
84
+ if (webkitDetached) return;
85
+ const wk = getWk();
86
+ if (!wk) return;
87
+ for (const name of [
88
+ UIKeyboardWillShowNotification,
89
+ UIKeyboardWillHideNotification,
90
+ UIKeyboardWillChangeFrameNotification,
91
+ UIKeyboardDidChangeFrameNotification,
92
+ ]) {
93
+ center.removeObserverNameObject(wk, name, null);
94
+ }
95
+ webkitDetached = true;
96
+ if (SHELL_CONFIG.debug) appwrapNativeLog('[native:keyboard] detached WKWebView keyboard observers');
97
+ };
98
+
99
+ // (3) Kill WebKit's keyboard contentInset so it can't double-compensate our resize.
100
+ const resetScrollView = (): void => {
101
+ const wk = getWk();
102
+ if (wk) wk.scrollView.contentInset = UIEdgeInsetsZero;
103
+ };
104
+
105
+ // (2) The Capacitor _updateFrame: webview frame = window bounds minus the keyboard band.
106
+ // Never skippable: if the webview is momentarily detached from the window (page navigation,
107
+ // instance switch — which is also when the keyboard dismisses), RETRY until it reattaches.
108
+ // A bailed restore that's assumed applied leaves the frame shrunk forever (black band).
109
+ const updateFrame = (attempt = 0): void => {
110
+ const wv = bridge.getWebView();
111
+ const wk = getWk();
112
+ const win = wk?.window;
113
+ if (!wv || !wk || !win) {
114
+ if (attempt < 20) pendingUpdate = setTimeout(() => Utils.dispatchToMainThread(() => updateFrame(attempt + 1)), 100);
115
+ else if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] frame update gave up (webview detached), pad=${paddingBottom}`);
116
+ return;
117
+ }
118
+ // SINGLE layout authority: NativeScript. Only the margin changes — never wk.frame directly.
119
+ // A manual frame write disagrees with NS layout (safe-area math) and the two ping-pong,
120
+ // which is what produced gaps, content under the status bar, and residual scroll state.
121
+ // Subtract the bottom safe-area inset: NS layout already excludes it, and the keyboard COVERS
122
+ // it — margining the full keyboard height double-counts those 34pt as a gap above the keyboard.
123
+ const safeBottom = win.safeAreaInsets?.bottom ?? 0;
124
+ // On a WARM re-focus iOS 26 reports a keyboard TALLER than it draws (it reserves the ▲▼✓ accessory
125
+ // row but doesn't draw it), leaving bare black native space between the shrunk webview and the
126
+ // real keys. `activeExtraLift` (see onShow) is the configured extra lift on those warm shows and 0
127
+ // on cold shows where the bar IS drawn — so the webview covers the strip only when it's needed.
128
+ wv.marginBottom = paddingBottom > 0 ? Math.max(0, paddingBottom - safeBottom - activeExtraLift) : 0;
129
+ // Property change alone doesn't reliably trigger a layout pass outside NS's own flow
130
+ // (restore path: margin=0 was set but the view stayed shrunk until the next layout).
131
+ wv.requestLayout();
132
+ resetScrollView();
133
+ // The page scrolls in inner containers; the outer scrollView must stay at origin. Clears
134
+ // WebKit's focus auto-scroll leftovers and the "whole app scrollable" residue after hide.
135
+ wk.scrollView.setContentOffsetAnimated(CGPointMake(0, 0), false);
136
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] marginBottom=${wv.marginBottom} (kb=${paddingBottom}, extraLift=${activeExtraLift}, ${sawWillShow ? 'cold' : 'warm'})`);
137
+ };
138
+
139
+ // No same-value skip: re-applying an identical frame is free, and unconditional application
140
+ // makes every keyboard event self-healing after a missed/bailed update.
141
+ const setKeyboardHeight = (height: number, delayMs: number): void => {
142
+ paddingBottom = height;
143
+ if (pendingUpdate) clearTimeout(pendingUpdate); // debounce like cancelPreviousPerformRequests
144
+ pendingUpdate = setTimeout(() => Utils.dispatchToMainThread(() => updateFrame()), delayMs);
145
+ };
146
+
147
+ // Re-focus does NOT re-fire willShow — capture-verified: a second tap arrives ONLY as
148
+ // didShow/willChangeFrame. All three feed the same idempotent handler; whichever iOS sends, the
149
+ // shrink lands. Height-0 frame events do nothing (hide is owned by the willHide/didHide pair).
150
+ const onShow = (tag: string, settleMs: number | null) => (note: any): void => {
151
+ detachWebKitKeyboardHandling();
152
+ if (tag === 'willShow') sawWillShow = true; // cold acquire → the accessory bar will be drawn
153
+ // Warm re-focus (didShow/willChangeFrame with no willShow this cycle) → bar dropped → lift extra.
154
+ activeExtraLift = sawWillShow ? 0 : (SHELL_CONFIG.iosKeyboardExtraLift ?? 82);
43
155
  const value = note?.userInfo?.objectForKey?.(UIKeyboardFrameEndUserInfoKey);
44
- const height = value ? value.CGRectValue.size.height : 0; // points ≈ CSS px in WKWebView
45
- bridge.emit('keyboard.show', { height: Math.round(height) });
46
- });
156
+ // Overlap with the window, NOT frame.size.height: iOS delivers keyboard-sized but off-screen
157
+ // end-frames during dismissal — size.height would shrink a keyboard-less screen.
158
+ let height = 0;
159
+ let winH = 0;
160
+ if (value) {
161
+ const end = value.CGRectValue;
162
+ const win = getWk()?.window;
163
+ winH = win ? win.bounds.size.height : 0;
164
+ height = winH ? Math.max(0, Math.round(winH - end.origin.y)) : Math.round(end.size.height);
165
+ }
166
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] ${tag} height=${height}`);
167
+ resetScrollView();
168
+ if (height <= 0) return;
169
+ // Guard against bogus full-screen keyboard frames. iOS occasionally delivers a transitional frame
170
+ // with origin.y≈0 (seen during SMS-OTP autofill) → height ≈ the whole window → the resize would
171
+ // shrink the webview to a sliver (huge black gap). A real software keyboard is never >85% of the
172
+ // screen; ignore the event and wait for the real frame (which follows and self-heals).
173
+ if (winH && height > winH * 0.85) {
174
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] ignore bogus height=${height} (win=${Math.round(winH)})`);
175
+ return;
176
+ }
177
+ // Paint the webview/window backdrop the page color so the resize shows no white flash. (The
178
+ // iOS-26 phantom keyboard-height gap is closed geometrically by the extra lift in updateFrame.)
179
+ syncBackdropColor();
180
+ const duration = note?.userInfo?.objectForKey?.(UIKeyboardAnimationDurationUserInfoKey)?.doubleValue ?? 0.25;
181
+ // willShow/willChangeFrame: resize after the animation settles. didShow: already settled.
182
+ setKeyboardHeight(height, settleMs ?? (duration + 0.2) * 1000);
183
+ bridge.emit('keyboard.show', { height });
184
+ };
185
+ center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillShowNotification, null, null, onShow('willShow', null));
186
+ center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillChangeFrameNotification, null, null, onShow('willChangeFrame', null));
187
+ center.addObserverForNameObjectQueueUsingBlock(UIKeyboardDidShowNotification, null, null, onShow('didShow', 50));
47
188
  center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillHideNotification, null, null, () => {
189
+ if (SHELL_CONFIG.debug) appwrapNativeLog('[native:keyboard] willHide → restore');
190
+ setKeyboardHeight(0, 10);
191
+ resetScrollView();
48
192
  bridge.emit('keyboard.hide');
49
193
  });
194
+ center.addObserverForNameObjectQueueUsingBlock(UIKeyboardDidHideNotification, null, null, () => {
195
+ setKeyboardHeight(0, 10); // enforcement pass — a hidden keyboard must always end at full height
196
+ resetScrollView();
197
+ sawWillShow = false; // keyboard fully gone → next show re-decides cold (bar) vs warm (no bar)
198
+ });
199
+
200
+ armScrollClamp();
201
+ }
202
+
203
+ /**
204
+ * Sample the page's background color once per keyboard-show and paint the webview + its window with
205
+ * it (Capacitor's autoBackdropColor) so there are no white flashes during the resize.
206
+ */
207
+ function syncBackdropColor(): void {
208
+ try {
209
+ const wk = bridge.getWebView()?.ios as WKWebView | undefined;
210
+ if (!wk) return;
211
+ wk.evaluateJavaScriptCompletionHandler('window.getComputedStyle(document.body).backgroundColor', (result: any) => {
212
+ const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(String(result ?? ''));
213
+ if (!m) return;
214
+ Utils.dispatchToMainThread(() => {
215
+ const w = bridge.getWebView()?.ios as WKWebView | undefined;
216
+ if (!w) return;
217
+ const color = UIColor.colorWithRedGreenBlueAlpha(+m[1] / 255, +m[2] / 255, +m[3] / 255, 1);
218
+ w.backgroundColor = color;
219
+ if (w.window) w.window.backgroundColor = color;
220
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] backdrop ← rgb(${m[1]},${m[2]},${m[3]})`);
221
+ });
222
+ });
223
+ } catch (e) {
224
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] backdrop sync failed: ${e}`);
225
+ }
226
+ }
227
+
228
+ let clampDelegate: any; // retained — a bare local would be GC'd and the native delegate dies with it
229
+
230
+ /**
231
+ * Continuous outer-scroll clamp. WebKit's keyboard machinery scrolls the WKWebView's outer
232
+ * scrollView at arbitrary times (focus auto-scroll while the keyboard is up, late "restore scroll"
233
+ * compensation ~1s after hide — probe-verified offset=386 with content == bounds). Timed pins lose
234
+ * that race by definition; a scrollViewDidScroll delegate wins every time: on EVERY scroll event
235
+ * clamp the offset to the legitimate range. Pages that fit the viewport (app shells — inner divs
236
+ * scroll, not the page) have exactly one legal offset: 0. Genuinely scrollable pages keep normal
237
+ * in-range scrolling untouched.
238
+ */
239
+ function armScrollClamp(): void {
240
+ const attach = (attempt = 0): void => {
241
+ const wk = bridge.getWebView()?.ios as WKWebView | undefined;
242
+ if (!wk) {
243
+ if (attempt < 50) setTimeout(() => attach(attempt + 1), 200);
244
+ return;
245
+ }
246
+ const Delegate = (NSObject as any).extend(
247
+ {
248
+ scrollViewDidScroll(sv: any): void {
249
+ const maxY = Math.max(0, sv.contentSize.height - sv.bounds.size.height + sv.contentInset.bottom);
250
+ const y = sv.contentOffset.y;
251
+ const clamped = Math.min(Math.max(y, 0), maxY);
252
+ if (Math.abs(clamped - y) > 0.5) sv.setContentOffsetAnimated(CGPointMake(sv.contentOffset.x, clamped), false);
253
+ },
254
+ },
255
+ { protocols: [UIScrollViewDelegate] },
256
+ );
257
+ clampDelegate = Delegate.new();
258
+ wk.scrollView.delegate = clampDelegate;
259
+ if (SHELL_CONFIG.debug) appwrapNativeLog('[native:keyboard] scroll clamp armed');
260
+ };
261
+ attach();
50
262
  }
51
263
 
52
264
  /** Android: a global-layout listener compares the decor view's visible frame to its full height. */
@@ -191,8 +191,10 @@ export function externalNavGuardJs(enabled: boolean): string {
191
191
  const NATIVE_FEEL_CSS = [
192
192
  'html{-webkit-text-size-adjust:100%;text-size-adjust:100%}',
193
193
  'html,body{overscroll-behavior:none;touch-action:manipulation}',
194
- '*{-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent}',
195
- ':where(:not(input,textarea,[contenteditable],[contenteditable] *)){-webkit-user-select:none;user-select:none}',
194
+ '*{-webkit-tap-highlight-color:transparent}',
195
+ // touch-callout:none suppresses long-press menus, but on inputs/textareas/contenteditable that also
196
+ // kills the iOS Copy/Paste callout — so exclude editable fields (same set as the user-select rule).
197
+ ':where(:not(input,textarea,[contenteditable],[contenteditable] *)){-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}',
196
198
  ].join('');
197
199
 
198
200
  /** Accessibility-standard reduced-motion reset — collapse animation/transition durations to ~0 and
package/src/cli.ts CHANGED
@@ -248,7 +248,7 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
248
248
  const iosDir = join(outDir, 'App_Resources/iOS');
249
249
  if (!existsSync(iosDir)) return;
250
250
  const file = join(iosDir, 'app.entitlements');
251
- const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements };
251
+ const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements, ...cfg.iosEntitlements };
252
252
  if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
253
253
  const keys = Object.keys(ent);
254
254
  if (keys.length === 0) { rmSync(file, { force: true }); return; }
@@ -445,6 +445,7 @@ export const SHELL_CONFIG = {
445
445
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
446
446
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
447
447
  pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
448
+ iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
448
449
  };
449
450
  `;
450
451
  writeFileSync(join(outDir, 'app/shell/config.ts'), content);
@@ -534,6 +535,29 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
534
535
  writeFileSync(plist, src);
535
536
  }
536
537
 
538
+ /** Stamp each app extension's Info.plist CFBundleShortVersionString + CFBundleVersion to match the
539
+ * main app (marketing version + monotonic build). App Store validation HARD-FAILS on a mismatch.
540
+ * Extension Info.plists ship verbatim from overrides (NS never stamps them), so this MUST run AFTER
541
+ * applyOverrides — same seam as stampManualSigning (an override would otherwise clobber the stamp). */
542
+ function stampIOSExtensionVersions(outDir: string, cfg: AppwrapConfig): void {
543
+ const extRoot = join(outDir, 'App_Resources/iOS/extensions');
544
+ if (!existsSync(extRoot)) return;
545
+ const build = String(buildNumberOf(cfg));
546
+ for (const ext of readdirSync(extRoot, { withFileTypes: true })) {
547
+ if (!ext.isDirectory()) continue;
548
+ const plist = join(extRoot, ext.name, 'Info.plist');
549
+ if (!existsSync(plist)) continue;
550
+ let src = readFileSync(plist, 'utf8');
551
+ const stamp = (key: string, value: string) => {
552
+ const re = new RegExp(`(<key>${key}</key>\\s*<string>)[^<]*(</string>)`);
553
+ src = re.test(src) ? src.replace(re, `$1${value}$2`) : src;
554
+ };
555
+ stamp('CFBundleShortVersionString', cfg.version);
556
+ stamp('CFBundleVersion', build);
557
+ writeFileSync(plist, src);
558
+ }
559
+ }
560
+
537
561
  function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; configPath: string }): void {
538
562
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
539
563
  // Resolution order: a real (non-placeholder) cfg.teamId wins; else $APPWRAP_TEAM_ID (headless/CI);
@@ -568,6 +592,237 @@ function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; co
568
592
  writeFileSync(xcconfig, src);
569
593
  }
570
594
 
595
+ interface ProvProfile { name: string; uuid: string; teamId: string; bundleId: string; exp: number }
596
+
597
+ /** Discover provisioning profiles installed on this machine (both the legacy MobileDevice dir and
598
+ * the modern Xcode UserData dir). Each is decoded with `security cms` and reduced to the fields we
599
+ * match on: profile Name, its TeamIdentifier, the bundle id (application-identifier minus the team
600
+ * prefix), and expiration (ms epoch). Unreadable/legacy profiles are skipped. */
601
+ function findProvisioningProfiles(): ProvProfile[] {
602
+ const dirs = [
603
+ join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
604
+ join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
605
+ ];
606
+ // Dedup by UUID: the SAME profile commonly lives in BOTH dirs (Xcode copies from MobileDevice into
607
+ // its UserData store) — without this, selectSigningProfile would see one profile as two and
608
+ // spuriously prompt / warn of "ambiguity".
609
+ const byUuid = new Map<string, ProvProfile>();
610
+ for (const dir of dirs) {
611
+ if (!existsSync(dir)) continue;
612
+ let files: string[] = [];
613
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.mobileprovision')); } catch { continue; }
614
+ for (const f of files) {
615
+ try {
616
+ const raw = execFileSync('security', ['cms', '-D', '-i', join(dir, f)],
617
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
618
+ const name = raw.match(/<key>Name<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
619
+ const uuid = raw.match(/<key>UUID<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
620
+ const team = raw.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/)?.[1];
621
+ const appId = raw.match(/<key>application-identifier<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
622
+ const expStr = raw.match(/<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/)?.[1];
623
+ if (!name || !uuid || !team || !appId || byUuid.has(uuid)) continue;
624
+ // application-identifier is `<TEAM>.<bundle.id>`; strip the team prefix to get the bundle id.
625
+ const bundleId = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
626
+ byUuid.set(uuid, { name, uuid, teamId: team, bundleId, exp: expStr ? Date.parse(expStr) : 0 });
627
+ } catch { /* skip unreadable profile */ }
628
+ }
629
+ }
630
+ return [...byUuid.values()];
631
+ }
632
+
633
+ /** Pick the provisioning-profile NAME to sign `bundleId` with, from the installed profiles for `teamId`.
634
+ * Order: a valid pinned choice (cfg.signingProfiles) → the sole match → interactive pick (TTY, then
635
+ * pinned) → newest-expiry (headless). Returns null when nothing matches (caller warns + skips). */
636
+ function selectSigningProfile(
637
+ profiles: ProvProfile[], teamId: string, bundleId: string, cfg: AppwrapConfig,
638
+ ctx?: { configPath: string },
639
+ ): string | null {
640
+ const now = Date.now();
641
+ const matches = profiles.filter((p) => p.teamId === teamId && p.bundleId === bundleId && p.exp > now);
642
+ if (matches.length === 0) return null;
643
+ const pinned = cfg.signingProfiles?.[bundleId];
644
+ if (pinned && matches.some((p) => p.name === pinned)) return pinned;
645
+ if (matches.length === 1) return matches[0].name;
646
+ // Ambiguous: prompt on a real TTY (and pin the choice), else deterministically take newest expiry.
647
+ if (process.stdout.isTTY) {
648
+ const sorted = [...matches].sort((a, b) => b.exp - a.exp);
649
+ const idx = arrowSelect(`Multiple profiles for ${bundleId} — pick one to sign with:`,
650
+ sorted.map((p) => `${p.name} (expires ${new Date(p.exp).toISOString().slice(0, 10)})`));
651
+ const chosen = sorted[idx].name;
652
+ if (ctx) pinSigningProfileToConfig(ctx.configPath, bundleId, chosen);
653
+ return chosen;
654
+ }
655
+ const newest = [...matches].sort((a, b) => b.exp - a.exp)[0];
656
+ console.warn(` ⚠ ${matches.length} profiles match ${bundleId}; using newest "${newest.name}" (pin one via signingProfiles to silence).`);
657
+ return newest.name;
658
+ }
659
+
660
+ /** Manual code-signing for device builds (`signing: 'manual'`). Resolves the app + each extension
661
+ * target to an installed provisioning profile (by `<teamId>.<bundleId>`) and stamps Manual signing
662
+ * into build.xcconfig (main app) + each extension's extension.json targetBuildConfigurationProperties.
663
+ * This is what makes `appwrap deploy ios` provision extensions + special entitlements without an Xcode
664
+ * GUI login. No-op unless signing==='manual'. iOS-only files; harmless on other platforms. */
665
+ function stampManualSigning(outDir: string, cfg: AppwrapConfig, ctx?: { configPath: string }): void {
666
+ const mode = process.env.APPWRAP_SIGNING?.trim() || cfg.signing;
667
+ if (mode !== 'manual') {
668
+ // Not manual → tear down any artifacts a PREVIOUS manual run left in native/ (it isn't fully
669
+ // wiped on sync), so `deploy` doesn't keep passing a now-stale --provision / export map.
670
+ for (const p of ['.appwrap-signing.json', 'hooks/after-prepare/appwrap-signing.js', 'App_Resources/iOS/extensions/provisioning.json']) {
671
+ const f = join(outDir, p);
672
+ if (existsSync(f)) rmSync(f, { force: true });
673
+ }
674
+ return;
675
+ }
676
+ const teamId = cfg.teamId;
677
+ if (!teamId || /YOUR_APPLE_TEAM_ID|^$/.test(teamId)) {
678
+ console.warn(' ⚠ signing: manual but teamId is unset — skipping manual signing.');
679
+ return;
680
+ }
681
+ const profiles = findProvisioningProfiles();
682
+ let mainProvision: string | null = null;
683
+
684
+ // Main app target → build.xcconfig.
685
+ const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
686
+ if (existsSync(xcconfig)) {
687
+ const name = selectSigningProfile(profiles, teamId, cfg.id, cfg, ctx);
688
+ if (!name) {
689
+ console.warn(` ⚠ no valid profile for ${cfg.id} (team ${teamId}) — automatic signing will run. Install one or seed via Xcode.`);
690
+ } else {
691
+ mainProvision = name;
692
+ let src = readFileSync(xcconfig, 'utf8');
693
+ const setKey = (s: string, key: string, val: string): string =>
694
+ // Anchored + multiline so we never rewrite the commented `// CODE_SIGN_IDENTITY = …` template line.
695
+ new RegExp(`^\\s*${key}\\s*=`, 'm').test(s)
696
+ ? s.replace(new RegExp(`^\\s*${key}\\s*=\\s*[^;\\n]*;?`, 'm'), `${key} = ${val};`)
697
+ : s + `\n${key} = ${val};`;
698
+ src = setKey(src, 'CODE_SIGN_STYLE', 'Manual');
699
+ src = setKey(src, 'CODE_SIGN_IDENTITY', 'Apple Development');
700
+ src = setKey(src, 'PROVISIONING_PROFILE_SPECIFIER', name);
701
+ writeFileSync(xcconfig, src.endsWith('\n') ? src : src + '\n');
702
+ console.log(` sign ← ${cfg.id} → "${name}" (manual)`);
703
+ }
704
+ }
705
+
706
+ // Extension targets (bundle id = <appId>.<Name>). extension.json CAN'T carry the signing: NS's
707
+ // prepareSigning runs AFTER it and force-propagates the MAIN app's signing onto every extension
708
+ // (clobbering the team/profile — and an extension needs a DIFFERENT profile than the app). So we
709
+ // install an after-prepare hook that stamps each extension target's signing into project.pbxproj
710
+ // LAST, after NS is done. Same seam StoreKit wiring uses.
711
+ const extRoot = join(outDir, 'App_Resources/iOS/extensions');
712
+ const extEntries: Array<{ bundleId: string; profile: string }> = [];
713
+ if (existsSync(extRoot)) {
714
+ for (const ext of readdirSync(extRoot, { withFileTypes: true })) {
715
+ if (!ext.isDirectory()) continue;
716
+ if (!existsSync(join(extRoot, ext.name, 'extension.json'))) continue;
717
+ const extBundle = `${cfg.id}.${ext.name}`;
718
+ const name = selectSigningProfile(profiles, teamId, extBundle, cfg, ctx);
719
+ if (!name) { console.warn(` ⚠ no valid profile for extension ${extBundle} — it may fail to sign.`); continue; }
720
+ extEntries.push({ bundleId: extBundle, profile: name });
721
+ console.log(` sign ← ${extBundle} → "${name}" (manual, via hook)`);
722
+ }
723
+ }
724
+ const hookDir = join(outDir, 'hooks/after-prepare');
725
+ const hookFile = join(hookDir, 'appwrap-signing.js');
726
+ if (extEntries.length) {
727
+ mkdirSync(hookDir, { recursive: true });
728
+ writeFileSync(hookFile, SIGNING_HOOK(teamId, extEntries));
729
+ // NS's exportOptions reads extension profiles from this file (extensions/provisioning.json) —
730
+ // it maps each extension bundle id → profile name so `xcodebuild -exportArchive` can build the .ipa.
731
+ writeFileSync(join(extRoot, 'provisioning.json'),
732
+ JSON.stringify(Object.fromEntries(extEntries.map((e) => [e.bundleId, e.profile])), null, 2) + '\n');
733
+ } else {
734
+ // No extensions to sign → clear any hook / provisioning map a prior run left behind.
735
+ if (existsSync(hookFile)) rmSync(hookFile, { force: true });
736
+ const provJson = join(extRoot, 'provisioning.json');
737
+ if (existsSync(provJson)) rmSync(provJson, { force: true });
738
+ }
739
+
740
+ // Sidecar the resolved main-app profile so the device-build step can pass `ns build … --provision
741
+ // <name>` — that's the switch that makes NS emit a manual-signing exportOptions.plist (with the app
742
+ // AND each extension's provisioningProfiles) for `xcodebuild -exportArchive`. Without it the export
743
+ // has no profile map and fails even though the archive signed fine.
744
+ const sidecar = join(outDir, '.appwrap-signing.json');
745
+ if (mainProvision) writeFileSync(sidecar, JSON.stringify({ provision: mainProvision }) + '\n');
746
+ else if (existsSync(sidecar)) rmSync(sidecar, { force: true });
747
+ }
748
+
749
+ /** After-prepare hook source: stamps manual signing into each extension target's build settings in
750
+ * project.pbxproj — run AFTER `ns prepare` (hence after NS's prepareSigning), so it's the final word.
751
+ * Self-contained (no deps), zero-arg (NS DI won't choke). Per extension it upserts DEVELOPMENT_TEAM,
752
+ * PROVISIONING_PROFILE_SPECIFIER, CODE_SIGN_STYLE=Manual, CODE_SIGN_IDENTITY in every buildSettings
753
+ * block whose PRODUCT_BUNDLE_IDENTIFIER matches the extension's bundle id. */
754
+ const SIGNING_HOOK = (team: string, entries: Array<{ bundleId: string; profile: string }>) => `// Generated by \`appwrap\` — stamps manual signing onto extension targets in project.pbxproj.
755
+ const fs = require('fs');
756
+ const path = require('path');
757
+ const TEAM = ${JSON.stringify(team)};
758
+ const ENTRIES = ${JSON.stringify(entries)};
759
+ module.exports = function () {
760
+ const iosDir = path.join(__dirname, '..', '..', 'platforms', 'ios');
761
+ if (!fs.existsSync(iosDir)) return;
762
+ const projDir = fs.readdirSync(iosDir).find((d) => d.endsWith('.xcodeproj') && d !== 'Pods.xcodeproj');
763
+ if (!projDir) return;
764
+ const pbx = path.join(iosDir, projDir, 'project.pbxproj');
765
+ if (!fs.existsSync(pbx)) return;
766
+ let src = fs.readFileSync(pbx, 'utf8');
767
+ const q = (v) => (/\\s/.test(v) ? '"' + v + '"' : v);
768
+ // Upsert a key inside one buildSettings block: replace its line if present, else insert after
769
+ // PRODUCT_BUNDLE_IDENTIFIER. Also strips PROVISIONING_PROFILE (UUID form) to avoid a stale mismatch.
770
+ const upsert = (block, key, val) => {
771
+ const re = new RegExp('(^\\\\s*)' + key + '\\\\s*=\\\\s*[^;\\\\n]*;', 'm');
772
+ if (re.test(block)) return block.replace(re, (_m, indent) => indent + key + ' = ' + val + ';');
773
+ return block.replace(/(^(\\s*)PRODUCT_BUNDLE_IDENTIFIER\\s*=\\s*[^;\\n]*;)/m,
774
+ (m, _l, indent) => m + '\\n' + indent + key + ' = ' + val + ';');
775
+ };
776
+ for (const { bundleId, profile } of ENTRIES) {
777
+ // Each XCBuildConfiguration's buildSettings block (Debug + Release) for this extension target.
778
+ src = src.replace(/buildSettings = \\{[\\s\\S]*?\\n(\\t*)\\};/g, (block) => {
779
+ const bidRe = new RegExp('PRODUCT_BUNDLE_IDENTIFIER\\\\s*=\\\\s*"?' + bundleId.replace(/[.]/g, '\\\\.') + '"?;');
780
+ if (!bidRe.test(block)) return block;
781
+ block = block.replace(/^\\s*PROVISIONING_PROFILE\\s*=\\s*[^;\\n]*;\\n?/m, '');
782
+ block = upsert(block, 'CODE_SIGN_STYLE', 'Manual');
783
+ block = upsert(block, 'DEVELOPMENT_TEAM', TEAM);
784
+ block = upsert(block, 'CODE_SIGN_IDENTITY', q('Apple Development'));
785
+ block = upsert(block, 'PROVISIONING_PROFILE_SPECIFIER', q(profile));
786
+ return block;
787
+ });
788
+ }
789
+ fs.writeFileSync(pbx, src);
790
+ console.log(' appwrap: manual signing stamped for ' + ENTRIES.map((e) => e.bundleId).join(', '));
791
+ };
792
+ `;
793
+
794
+ /** Persist a manual-signing profile choice (bundle id → profile name) so an ambiguous match isn't
795
+ * re-prompted. JSON configs are updated structurally; TS/JS get a best-effort inserted line (a hint
796
+ * is printed if the shape is unfamiliar — the deterministic newest-expiry pick still works meanwhile). */
797
+ function pinSigningProfileToConfig(configPath: string, bundleId: string, profileName: string): void {
798
+ if (!existsSync(configPath)) return;
799
+ const src = readFileSync(configPath, 'utf8');
800
+ if (configPath.endsWith('.json')) {
801
+ try {
802
+ const obj = JSON.parse(src) as Record<string, unknown>;
803
+ const map = (obj.signingProfiles as Record<string, string>) ?? {};
804
+ map[bundleId] = profileName;
805
+ obj.signingProfiles = map;
806
+ writeFileSync(configPath, JSON.stringify(obj, null, 2) + (src.endsWith('\n') ? '\n' : ''));
807
+ console.log(` ✓ pinned signingProfiles['${bundleId}'] = '${profileName}'`);
808
+ } catch { /* leave untouched */ }
809
+ return;
810
+ }
811
+ if (/\bsigningProfiles\s*:/.test(src)) {
812
+ console.log(` ⓘ add '${bundleId}': '${profileName}' to signingProfiles in ${configPath} to silence this prompt.`);
813
+ return;
814
+ }
815
+ const anchor = /^([ \t]*)(signing|id)\s*:\s*(['"`])[^'"`]*\3\s*,?[ \t]*$/m;
816
+ const m = anchor.exec(src);
817
+ if (!m) { console.log(` ⓘ set signingProfiles: { '${bundleId}': '${profileName}' } in ${configPath} to silence this prompt.`); return; }
818
+ const indent = m[1];
819
+ const next = src.slice(0, m.index + m[0].length)
820
+ + `\n${indent}signingProfiles: { '${bundleId}': '${profileName}' },`
821
+ + src.slice(m.index + m[0].length);
822
+ writeFileSync(configPath, next);
823
+ console.log(` ✓ pinned signingProfiles['${bundleId}'] = '${profileName}' in ${configPath}`);
824
+ }
825
+
571
826
  /** Stamp TARGETED_DEVICE_FAMILY into build.xcconfig from cfg.targetedDevices. `'iphone'` → `1`
572
827
  * (iPhone-only → UIDeviceFamily=[1], so the App Store doesn't require iPad screenshots);
573
828
  * `'universal'`/unset → `1,2` (NativeScript's default). Idempotent: replaces any prior value. */
@@ -891,6 +1146,23 @@ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
891
1146
  }
892
1147
  }
893
1148
 
1149
+ // iOS launch screen: stamp the centered splash logo — ONLY from an explicit `splashIcon`
1150
+ // (a transparent-bg wordmark/glyph). NOT the app icon: an icon carries its own opaque background,
1151
+ // which would render as an ugly box floating on the solid splash. Without `splashIcon` the Center
1152
+ // layer is blanked in stampLaunchScreen → clean solid-backgroundColor splash, no logo. Square, ~220pt.
1153
+ if (cfg.splashIcon) {
1154
+ const splashSrc = resolve(cwd, cfg.splashIcon);
1155
+ const centerSet = join(outDir, 'App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset');
1156
+ if (!existsSync(splashSrc)) {
1157
+ console.warn(`⚠ config \`splashIcon\` not found: ${splashSrc} — splash will be a plain background`);
1158
+ } else if (existsSync(join(centerSet, 'Contents.json'))) {
1159
+ const c = JSON.parse(readFileSync(join(centerSet, 'Contents.json'), 'utf8'));
1160
+ for (const img of c.images as Array<{ scale: string; filename: string }>) {
1161
+ ras.resize(splashSrc, Math.round(220 * parseFloat(img.scale)), join(centerSet, img.filename));
1162
+ }
1163
+ }
1164
+ }
1165
+
894
1166
  const ANDROID_DENSITIES: Record<string, number> = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
895
1167
  const res = join(outDir, 'App_Resources/Android/src/main/res');
896
1168
  for (const [density, px] of Object.entries(ANDROID_DENSITIES)) {
@@ -920,8 +1192,30 @@ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
920
1192
  console.log(` icon ← ${source} (${w}px)`);
921
1193
  }
922
1194
 
923
- /** Tint the iOS launch screen to the configured background color. */
1195
+ /** De-NativeScript the launch screen: blank the iOS NS-blue AspectFill layer (UNCONDITIONAL — the
1196
+ * default must never ship), then tint the iOS storyboard + Android splash to the app's backgroundColor. */
924
1197
  function stampLaunchScreen(outDir: string, cfg: AppwrapConfig): void {
1198
+ // iOS: blank the full-screen AspectFill layer FIRST, regardless of backgroundColor. The template
1199
+ // ships the default NativeScript-blue background bitmap there, which paints OVER the storyboard
1200
+ // background → the tint (and any brand color) is invisible and every app shows the NS splash.
1201
+ // Overwrite those rasters with a 1×1 transparent pixel so the storyboard's solid background shows
1202
+ // through; the centered app-icon logo (branded in generateIcons) sits on top. Tool-free (no
1203
+ // sips/magick → works on any CI).
1204
+ const TRANSPARENT_PNG = Buffer.from(
1205
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
1206
+ 'base64'
1207
+ );
1208
+ const blankImageset = (name: string): void => {
1209
+ const set = join(outDir, `App_Resources/iOS/Assets.xcassets/${name}.imageset`);
1210
+ if (!existsSync(join(set, 'Contents.json'))) return;
1211
+ const c = JSON.parse(readFileSync(join(set, 'Contents.json'), 'utf8'));
1212
+ for (const img of c.images as Array<{ filename: string }>) writeFileSync(join(set, img.filename), TRANSPARENT_PNG);
1213
+ };
1214
+ blankImageset('LaunchScreen.AspectFill');
1215
+ // Blank the centered NS wordmark too → clean solid-color splash. generateIcons repaints Center from
1216
+ // `splashIcon` AFTER this (it runs later in regenerateCore), so an opted-in logo survives.
1217
+ blankImageset('LaunchScreen.Center');
1218
+
925
1219
  if (!cfg.backgroundColor) return;
926
1220
  const hex = cfg.backgroundColor.replace('#', '');
927
1221
  if (!/^[0-9a-fA-F]{6}$/.test(hex)) return;
@@ -981,10 +1275,18 @@ function stampVersionManifest(outDir: string, cfg: AppwrapConfig): void {
981
1275
 
982
1276
  /** Pure-native escape hatch: copy the consumer's overrides dir OVER the generated wrapper, last,
983
1277
  * so it wins. For legacy/custom native code the declarative config can't express. */
984
- function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig): void {
1278
+ export function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig): void {
985
1279
  const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
986
- if (!existsSync(dir)) return;
1280
+ // Files the template still provides must survive an override removal (regenerateCore ran first and
1281
+ // re-copied them) → protect them from the override prune.
1282
+ const templateFiles = existsSync(TEMPLATE_DIR) ? collectRelFiles(TEMPLATE_DIR, templateCopyFilter) : new Set<string>();
1283
+ if (!existsSync(dir)) {
1284
+ // No overrides now: prune anything a PRIOR overrides run left behind (renamed/removed override files).
1285
+ pruneStale(outDir, OVERRIDES_MANIFEST, new Set(), templateFiles);
1286
+ return;
1287
+ }
987
1288
  cpSync(dir, outDir, { recursive: true, force: true });
1289
+ pruneStale(outDir, OVERRIDES_MANIFEST, collectRelFiles(dir), templateFiles);
988
1290
  console.log(` over ← ${dir} (native overrides applied)`);
989
1291
  }
990
1292
 
@@ -1128,6 +1430,54 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1128
1430
  console.log(ci);
1129
1431
  }
1130
1432
 
1433
+ // Manifests of what the two sync-owned sources (runtime template, consumer overrides) generated LAST run.
1434
+ // Used to prune files that were REMOVED/renamed in the source — cpSync(force) overwrites but never deletes,
1435
+ // so without this a relocated source lingers in native/ and gets re-bundled.
1436
+ const TEMPLATE_MANIFEST = '.appwrap-template-manifest.json';
1437
+ const OVERRIDES_MANIFEST = '.appwrap-overrides-manifest.json';
1438
+
1439
+ /** Files under TEMPLATE_DIR to skip when copying/walking it (deps, build output, PWA staging, per-module
1440
+ * native — those are handled selectively elsewhere). Shared by the cpSync filter and the prune walk so
1441
+ * the copied set and the pruned set are defined identically. */
1442
+ const templateCopyFilter = (src: string): boolean =>
1443
+ !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(TEMPLATE_DIR.length));
1444
+
1445
+ /** File (not dir) paths under `root`, relative to it, skipping entries `accept` rejects. */
1446
+ function collectRelFiles(root: string, accept: (abs: string) => boolean = () => true): Set<string> {
1447
+ const out = new Set<string>();
1448
+ const rec = (dir: string, rel: string): void => {
1449
+ let entries;
1450
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
1451
+ for (const e of entries) {
1452
+ const abs = join(dir, e.name);
1453
+ if (!accept(abs)) continue;
1454
+ const r = rel ? `${rel}/${e.name}` : e.name;
1455
+ if (e.isDirectory()) rec(abs, r);
1456
+ else out.add(r);
1457
+ }
1458
+ };
1459
+ rec(root, '');
1460
+ return out;
1461
+ }
1462
+
1463
+ /** Prune generated files sync OWNS that vanished from their source. Deletes only paths recorded in the
1464
+ * PRIOR manifest that are absent from the `current` source set — so it never touches user data or any
1465
+ * region absent from a previous run's manifest. `protect` keeps paths another owned source still ships
1466
+ * (e.g. an override was removed but the template still provides that file). */
1467
+ function pruneStale(outDir: string, manifest: string, current: Set<string>, protect?: Set<string>): void {
1468
+ const mf = join(outDir, manifest);
1469
+ let prev: string[] = [];
1470
+ try { prev = JSON.parse(readFileSync(mf, 'utf8')) as string[]; } catch { /* first run — nothing to prune */ }
1471
+ for (const rel of prev) {
1472
+ if (current.has(rel) || protect?.has(rel)) continue;
1473
+ const abs = join(outDir, rel);
1474
+ try {
1475
+ if (existsSync(abs)) { rmSync(abs, { force: true }); console.log(` prune ✕ ${rel} (removed from source)`); }
1476
+ } catch { /* non-fatal */ }
1477
+ }
1478
+ try { writeFileSync(mf, JSON.stringify([...current])); } catch { /* non-fatal */ }
1479
+ }
1480
+
1131
1481
  /**
1132
1482
  * Reproduce native/ from source — the shared core of `init` and `sync`. Copies the runtime shell
1133
1483
  * template, re-stamps EVERY config artifact, and re-copies the built PWA. `native/` is disposable, so a
@@ -1147,8 +1497,10 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1147
1497
  // modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
1148
1498
  // Match RELATIVE to TEMPLATE_DIR — when installed from npm, TEMPLATE_DIR itself sits under
1149
1499
  // node_modules/, so testing the absolute path would wrongly exclude the entire template.
1150
- filter: (src) => !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(TEMPLATE_DIR.length)),
1500
+ filter: templateCopyFilter,
1151
1501
  });
1502
+ // Delete template files removed/renamed since the last regenerate (cpSync only overwrites, never deletes).
1503
+ pruneStale(outDir, TEMPLATE_MANIFEST, collectRelFiles(TEMPLATE_DIR, templateCopyFilter));
1152
1504
  stampShellConfig(outDir, cfg);
1153
1505
  stampNativeScriptConfig(outDir, cfg);
1154
1506
  stampIOSDisplayName(outDir, cfg, req);
@@ -1198,6 +1550,8 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1198
1550
  copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
1199
1551
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1200
1552
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1553
+ stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
1554
+ stampIOSExtensionVersions(outDir, cfg); // AFTER overrides: extension Info.plist ships from overrides; version must match the app's or App Store validation fails
1201
1555
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
1202
1556
  console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n Run it: appwrap dev ios (or: appwrap dev android)`);
1203
1557
  }
@@ -1214,6 +1568,8 @@ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: Ap
1214
1568
  }
1215
1569
  regenerateCore(cwd, outDir, cfg, { flags });
1216
1570
  applyOverrides(cwd, outDir, cfg); // overrides win last
1571
+ stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
1572
+ stampIOSExtensionVersions(outDir, cfg); // AFTER overrides: extension Info.plist ships from overrides; version must match the app's or App Store validation fails
1217
1573
  stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
1218
1574
  console.log('✓ Synced.');
1219
1575
  }
@@ -1323,6 +1679,7 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
1323
1679
  : cfg;
1324
1680
  regenerateCore(cwd, outDir, simCfg, { flags });
1325
1681
  applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
1682
+ stampIOSExtensionVersions(outDir, simCfg); // AFTER overrides: keep extension version matching the app's (Xcode warns on mismatch)
1326
1683
  stampVersionManifest(outDir, simCfg);
1327
1684
  console.log(simCfg.loader === 'server' ? `✓ Refreshed wrapper (dev loader → ${simCfg.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
1328
1685
  runNs(outDir, [wantDebug ? 'debug' : 'run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
@@ -1442,6 +1799,25 @@ function runNs(outDir: string, args: string[]): void {
1442
1799
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
1443
1800
  }
1444
1801
 
1802
+ /** Wipe the generated NativeScript state (`ns clean`: platforms/, hooks/, node_modules/) then restore
1803
+ * deps. The escape hatch when the generated tree drifts from the current project — e.g. a moved/renamed
1804
+ * repo leaves a STALE platforms/ios/Podfile that ns keeps appending to (corrupt merge, orphaned
1805
+ * post_install, mixed old/new paths) → CocoaPods "unexpected end" and the build never recovers on its own. */
1806
+ async function clean(cwd: string, flags: Record<string, string>): Promise<void> {
1807
+ const outDir = resolve(cwd, flags.out ?? 'native');
1808
+ if (!existsSync(outDir)) {
1809
+ console.error(`✖ Wrapper not found at ${outDir} — nothing to clean (run \`appwrap init\` first)`);
1810
+ process.exit(1);
1811
+ }
1812
+ console.log(`▶ ns clean (cwd: ${outDir}) — wiping platforms/, hooks/, node_modules/`);
1813
+ execFileSync('ns', ['clean'], { cwd: outDir, stdio: 'inherit', env: { ...process.env } });
1814
+ // ns clean also removes node_modules; restore it so the tree is usable and the next deploy doesn't
1815
+ // pay the reinstall inline (prepareNsEnv would otherwise lazily bun-install on the following build).
1816
+ console.log(`▶ bun install (cwd: ${outDir}) — restoring deps`);
1817
+ execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env: { ...process.env } });
1818
+ console.log('✓ Cleaned. Next `appwrap deploy` regenerates platforms/ from App_Resources.');
1819
+ }
1820
+
1445
1821
  /** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
1446
1822
  * preserve an ACTIVE dev loader across a `dev --sim` refresh. Returns null if the
1447
1823
  * generated config is absent/unreadable — the caller then falls back to the appwrap config. */
@@ -1858,7 +2234,7 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
1858
2234
  * App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
1859
2235
  * changes and would make the fingerprint permanently stale.
1860
2236
  * Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
1861
- function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: string): string {
2237
+ export function buildFingerprint(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): string {
1862
2238
  const mtime = (p: string): number => {
1863
2239
  if (!existsSync(p)) return 0;
1864
2240
  try {
@@ -1872,8 +2248,12 @@ function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: strin
1872
2248
  return s.mtimeMs;
1873
2249
  } catch { return 0; }
1874
2250
  };
2251
+ // Fingerprint EVERY app source the build actually consumes — not just dist. Missing the overrides dir
2252
+ // (native escape hatch) or a `.js`/`.json`/`--config` config file made edits there produce a stale
2253
+ // "inputs unchanged" skip. Resolve the real config path (ts→js→json / --config) instead of hardcoding.
1875
2254
  const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
1876
- const parts = [mtime(distDir), mtime(join(cwd, 'appwrap.config.ts'))];
2255
+ const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
2256
+ const parts = [mtime(distDir), mtime(overridesDir), mtime(resolveConfigPath(cwd, flags))];
1877
2257
  // Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
1878
2258
  let h = 5381;
1879
2259
  for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
@@ -2181,7 +2561,7 @@ async function deployAndroid(cwd: string, flags: Record<string, string>, cfgOver
2181
2561
  // unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
2182
2562
  // bumps the fingerprint; pair with --no-web-build to actually hit this fast-path.)
2183
2563
  const force = 'force' in flags || 'f' in flags;
2184
- const fp = buildFingerprint(cwd, cfg, outDir);
2564
+ const fp = buildFingerprint(cwd, cfg, flags);
2185
2565
  const cache = readBuildCache(outDir, 'android');
2186
2566
  if (!force && existsSync(apk) && cache?.fingerprint === fp && cache?.artifactPath === apk) {
2187
2567
  console.log(`⚡ Skipping build — inputs unchanged since last build (${apk.split('/').pop()})`);
@@ -2259,7 +2639,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2259
2639
  // Auto: always checks fingerprint; never skips if sources/deps changed.
2260
2640
  const resume = 'resume' in flags || 'r' in flags;
2261
2641
  const force = 'force' in flags || 'f' in flags;
2262
- const fp = buildFingerprint(cwd, cfg, outDir);
2642
+ const fp = buildFingerprint(cwd, cfg, flags);
2263
2643
  const cache = readBuildCache(outDir, 'ios');
2264
2644
  const existingIpa = existsSync(ipaDir)
2265
2645
  ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
@@ -2274,9 +2654,16 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2274
2654
  const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
2275
2655
  console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
2276
2656
  } else {
2277
- console.log(`▶ ns build ios --for-device (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
2657
+ // Manual signing (signing:'manual') → pass the main-app profile as --provision so NS emits a
2658
+ // manual exportOptions.plist covering the app + its extensions (see stampManualSigning sidecar).
2659
+ const nsArgs = ['build', 'ios', '--for-device'];
2660
+ try {
2661
+ const sc = JSON.parse(readFileSync(join(outDir, '.appwrap-signing.json'), 'utf8')) as { provision?: string };
2662
+ if (sc.provision) nsArgs.push('--provision', sc.provision);
2663
+ } catch { /* no sidecar → automatic signing */ }
2664
+ console.log(`▶ ns ${nsArgs.join(' ')} (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
2278
2665
  try {
2279
- execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
2666
+ execFileSync('ns', nsArgs, { cwd: outDir, stdio: 'inherit' });
2280
2667
  } catch (e) {
2281
2668
  // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
2282
2669
  console.error(
@@ -2300,16 +2687,19 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2300
2687
 
2301
2688
  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
2302
2689
  let installedViaUsbmux = false;
2690
+ let installed = false;
2691
+ // A wireless (localNetwork) install of a multi-MB .ipa legitimately takes longer than a wired one —
2692
+ // 25s is a wired number; too tight for Wi-Fi, tripping the fallback on a healthy-but-slow install.
2693
+ const wireless = /localNetwork|wifi/i.test(device.transport);
2694
+ const installTimeout = wireless ? '120' : '25';
2695
+ // Capture (not inherit) so we can recognize specific failures; echo it for visibility. Wrapped so a
2696
+ // LOCKED device waits-and-retries. --timeout lets one attempt tolerate a brief locked/unavailable window.
2697
+ const runDevicectlInstall = (): string => withUnlockRetry('Install', () =>
2698
+ execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', installTimeout, '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
2699
+ );
2303
2700
  try {
2304
- // Capture (not inherit) so we can recognize specific failures; echo it for visibility.
2305
- // Wrapped so a LOCKED device waits-and-retries instead of hard-failing (the common annoyance).
2306
- // --timeout: devicectl has no first-class "wait for unlock", but its overall-timeout lets a single
2307
- // attempt tolerate a brief locked/unavailable window before erroring; the outer retry covers the
2308
- // fail-fast case + the unlock prompt. (Verified against `devicectl --help`; community wraps it too.)
2309
- const out = withUnlockRetry('Install', () =>
2310
- execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', '25', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
2311
- );
2312
- process.stdout.write(out);
2701
+ process.stdout.write(runDevicectlInstall());
2702
+ installed = true;
2313
2703
  } catch (e: unknown) {
2314
2704
  const log = execErrText(e);
2315
2705
  process.stderr.write(log);
@@ -2325,19 +2715,33 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2325
2715
  );
2326
2716
  process.exit(1);
2327
2717
  } else if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
2328
- // devicectl/CoreDevice is stuck (a connection hang, NOT a lock). usbmux (ideviceinstaller) is a
2329
- // separate stack that usually still works — auto-fall-back instead of chasing a phantom "unlock".
2330
- console.error('\n⚠ devicectl/CoreDevice is stuck (connection hang, not a lock) — falling back to ideviceinstaller (usbmux)…');
2331
- if (usbmuxInstall(ipaPath)) {
2332
- installedViaUsbmux = true;
2333
- } else {
2334
- console.error(
2335
- '✖ usbmux fallback unavailable.\n' +
2336
- ' → Re-plug the USB cable (re-establishes the CoreDevice tunnel) and re-run, OR\n' +
2337
- ' `brew install ideviceinstaller` for a usbmux install path.\n' +
2338
- ` The built .ipa is ready: ${ipaPath}`
2339
- );
2340
- process.exit(1);
2718
+ // devicectl/CoreDevice is stuck (connection hang, NOT a lock). Usual cause: a pegged
2719
+ // CoreDeviceService daemon. SELF-HEAL: kill it (auto-respawns clean) and retry devicectl once —
2720
+ // this is the fix a human otherwise applies by hand, and it's the ONLY path that works for a
2721
+ // wireless-only device (usbmux is USB-only).
2722
+ if (killStuckCoreDeviceService()) {
2723
+ console.error('\n↻ CoreDevice tunnel was stuck (likely a pegged CoreDeviceService) — restarted it, retrying install…');
2724
+ try { process.stdout.write(runDevicectlInstall()); installed = true; }
2725
+ catch (e2) { process.stderr.write(execErrText(e2)); }
2726
+ }
2727
+ // Still stuck → usbmux (ideviceinstaller), which only helps a USB-reachable device.
2728
+ if (!installed) {
2729
+ console.error('\n⚠ devicectl still stuck — trying ideviceinstaller (usbmux, USB only)…');
2730
+ if (usbmuxInstall(ipaPath)) {
2731
+ installedViaUsbmux = true; installed = true;
2732
+ } else {
2733
+ console.error(
2734
+ '✖ Install failed: the CoreDevice tunnel stayed stuck and there is no usbmux (USB) path.\n' +
2735
+ (wireless
2736
+ ? ' This device is on Wi-Fi only — CoreDeviceService was just restarted, so simply RE-RUN\n' +
2737
+ ' `appwrap deploy ios` (the .ipa is cached; it will only re-install). For the most reliable\n' +
2738
+ ' path, plug in the USB cable.\n'
2739
+ : ' → Re-plug the USB cable (re-establishes the CoreDevice tunnel) and re-run, OR\n' +
2740
+ ' `brew install ideviceinstaller` for a usbmux install path.\n') +
2741
+ ` The built .ipa is ready: ${ipaPath}`
2742
+ );
2743
+ process.exit(1);
2744
+ }
2341
2745
  }
2342
2746
  } else {
2343
2747
  console.error(
@@ -2358,7 +2762,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2358
2762
  // old serverUrl) despite a correct new build — masquerading as a build/cache bug. (A reinstall
2359
2763
  // over the top does NOT replace a running process; this avoids the manual uninstall dance.)
2360
2764
  withUnlockRetry('Launch', () =>
2361
- execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', '25', '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
2765
+ execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', installTimeout, '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
2362
2766
  );
2363
2767
  } catch {
2364
2768
  console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
@@ -2383,6 +2787,20 @@ function usbmuxInstall(ipaPath: string): boolean {
2383
2787
  }
2384
2788
  }
2385
2789
 
2790
+ /** Apple's `CoreDeviceService` daemon intermittently pegs a CPU core at 100% and wedges the device
2791
+ * tunnel — `devicectl` then hangs until its timeout (worse on a wireless/localNetwork target). Killing
2792
+ * it is the documented remedy: launchd auto-respawns it clean. Best-effort; returns true if we
2793
+ * signalled a matching process (pkill exits non-zero → throws → false when nothing matched). */
2794
+ function killStuckCoreDeviceService(): boolean {
2795
+ try {
2796
+ execFileSync('pkill', ['-9', '-f', 'CoreDeviceService.xpc'], { stdio: 'ignore' });
2797
+ sleepSync(4000); // let launchd respawn it + re-establish the tunnel before we retry
2798
+ return true;
2799
+ } catch {
2800
+ return false;
2801
+ }
2802
+ }
2803
+
2386
2804
  /** Run a devicectl op; if it fails because the device is LOCKED (or transiently unavailable), prompt
2387
2805
  * once and poll-retry until it succeeds or the budget runs out — instead of hard-failing. A free-team
2388
2806
  * 3-app-limit error is NOT a lock, so it's re-thrown immediately for the caller's specific handling.
@@ -2555,6 +2973,9 @@ async function main(): Promise<void> {
2555
2973
  case 'sync':
2556
2974
  await sync(cwd, flags);
2557
2975
  break;
2976
+ case 'clean':
2977
+ await clean(cwd, flags);
2978
+ break;
2558
2979
  case 'dev':
2559
2980
  await dev(cwd, flags, positionals);
2560
2981
  break;
@@ -2597,6 +3018,7 @@ async function main(): Promise<void> {
2597
3018
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
2598
3019
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
2599
3020
  ' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
3021
+ ' clean (ns clean: wipe generated platforms/hooks/node_modules, restore deps — fixes stale/corrupt Podfile after a repo move)\n' +
2600
3022
  ' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
2601
3023
  process.exit(command ? 1 : 0);
2602
3024
  }
package/src/config.ts CHANGED
@@ -45,6 +45,11 @@ export interface AppwrapConfig {
45
45
  * paints the bar regions itself. Default false = bars show the page `backgroundColor` (works, but
46
46
  * can't match a multi-theme app). iOS is always genuinely edge-to-edge. */
47
47
  edgeToEdge?: boolean;
48
+ /** iOS only. Extra points to lift the WebView above the reported keyboard height on focus, to hide
49
+ * the iOS-26 phantom keyboard-height gap (the reported keyboard is taller than it draws on warm
50
+ * re-focus, leaving a black strip). Default 82; the keyboard covers the thin bottom content row.
51
+ * Set 0 for no extra lift (input flush at the reported height; the strip may show). */
52
+ iosKeyboardExtraLift?: number;
48
53
  pwaDist: string;
49
54
  /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
50
55
  urlScheme?: string;
@@ -63,6 +68,10 @@ export interface AppwrapConfig {
63
68
  queryPackages?: string[];
64
69
  /** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
65
70
  icon?: string;
71
+ /** Optional centered logo for the iOS launch splash — a TRANSPARENT-background png (a wordmark or
72
+ * glyph, NOT the app icon, whose opaque background would show as a box on the splash). When absent
73
+ * the splash is a clean solid `backgroundColor` fill with no logo. */
74
+ splashIcon?: string;
66
75
  /** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
67
76
  * (load `serverUrl` live — dev HMR over LAN or a deployed URL). `appwrap dev` sets this. */
68
77
  loader?: 'app' | 'file' | 'server';
@@ -143,6 +152,23 @@ export interface AppwrapConfig {
143
152
  /** iOS export-compliance. `ITSAppUsesNonExemptEncryption` — stamped `false` by default (skips the
144
153
  * per-upload prompt). Set `true` only if the app uses non-exempt encryption. */
145
154
  usesNonExemptEncryption?: boolean;
155
+ /** Extra iOS entitlements merged into the generated `app.entitlements` (on top of the ones active
156
+ * modules + push contribute). For capabilities the module system doesn't (yet) model — e.g.
157
+ * `{ 'com.apple.developer.declared-age-range': true }`. Key = entitlement string, value = boolean /
158
+ * string / string[]. NOTE: the entitlement must also be enabled on the App ID / provisioning profile
159
+ * (some need Apple approval) or a distribution build won't sign. Absent → no change. */
160
+ iosEntitlements?: Record<string, boolean | string | string[]>;
161
+ /** iOS code-signing style for device / `deploy` builds. Default `'auto'` (Xcode automatic signing —
162
+ * requires the team's Apple ID signed into Xcode's GUI to mint profiles). Set `'manual'` to sign
163
+ * device builds with provisioning profiles ALREADY installed on this machine (matched by
164
+ * `<teamId>.<bundleId>`, incl. app-extension targets). This is what lets `appwrap deploy ios`
165
+ * provision app extensions + special-access entitlements (e.g. declared-age-range) headlessly,
166
+ * without an Xcode GUI login. Store/TestFlight builds still go through `appwrap release` (fastlane+match). */
167
+ signing?: 'auto' | 'manual';
168
+ /** Manual-signing profile pins (bundle id → provisioning-profile NAME). Auto-filled when a `'manual'`
169
+ * build finds MULTIPLE candidate profiles for an id and you pick one interactively — so you're not
170
+ * re-prompted. Usually unset: a single matching profile is selected automatically. */
171
+ signingProfiles?: Record<string, string>;
146
172
  /** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
147
173
  * OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
148
174
  * can't express. Default `'appwrap.overrides'`; applied only if it exists. */
@@ -214,11 +240,11 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
214
240
  * with no error, then an App Store rejection). A loud warning turns that silent no-op into a signal.
215
241
  */
216
242
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
217
- 'appBoundDomains', 'backendOrigin', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
218
- 'debugLog', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'loader', 'modules', 'name',
219
- 'neutralizeServiceWorker', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
220
- 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'statusBarStyle',
221
- 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
243
+ 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
244
+ 'debugLog', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
245
+ 'iosEntitlements', 'neutralizeServiceWorker', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
246
+ 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
247
+ 'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
222
248
  'usesNonExemptEncryption', 'vendorPaths', 'version',
223
249
  ]);
224
250