@livx.cc/appwrap 0.40.1 → 0.42.1
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 +1 -1
- package/runtime/app/shell/config.ts +6 -0
- package/runtime/app/shell/keyboard.ts +217 -5
- package/runtime/app/shell/web-quirks.ts +4 -2
- package/runtime/package.json +1 -0
- package/src/cli.ts +603 -63
- package/src/config.ts +31 -5
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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-
|
|
195
|
-
|
|
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/runtime/package.json
CHANGED
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
|
-
/**
|
|
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;
|
|
@@ -1198,6 +1492,8 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1198
1492
|
copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
|
|
1199
1493
|
writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
|
|
1200
1494
|
applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
|
|
1495
|
+
stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
|
|
1496
|
+
stampIOSExtensionVersions(outDir, cfg); // AFTER overrides: extension Info.plist ships from overrides; version must match the app's or App Store validation fails
|
|
1201
1497
|
stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
|
|
1202
1498
|
console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n Run it: appwrap dev ios (or: appwrap dev android)`);
|
|
1203
1499
|
}
|
|
@@ -1214,6 +1510,8 @@ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: Ap
|
|
|
1214
1510
|
}
|
|
1215
1511
|
regenerateCore(cwd, outDir, cfg, { flags });
|
|
1216
1512
|
applyOverrides(cwd, outDir, cfg); // overrides win last
|
|
1513
|
+
stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
|
|
1514
|
+
stampIOSExtensionVersions(outDir, cfg); // AFTER overrides: extension Info.plist ships from overrides; version must match the app's or App Store validation fails
|
|
1217
1515
|
stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
|
|
1218
1516
|
console.log('✓ Synced.');
|
|
1219
1517
|
}
|
|
@@ -1268,23 +1566,26 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
|
|
|
1268
1566
|
/** `appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]` — the
|
|
1269
1567
|
* live-dev loop. Subsumes the old `run`/`debug` verbs AND the old `dev` (loader:server stamp).
|
|
1270
1568
|
*
|
|
1271
|
-
* Default target = the physical DEVICE
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1569
|
+
* Default target = the physical DEVICE.
|
|
1570
|
+
* • ANDROID: `ns run` livesync for true on-device HMR (incremental, no full reinstall) + a source
|
|
1571
|
+
* watcher that rebuilds the web & re-stages www on save so PWA edits flow into the livesync. The old
|
|
1572
|
+
* "Invalid version … Got type object" crash that made this look unfixable was just the shell
|
|
1573
|
+
* package.json failing to declare @nativescript/android → ns read the runtime version as null.
|
|
1574
|
+
* • iOS: the proven deploy + redeploy-on-save loop (NOT ns run — `ns run ios --device` hits the
|
|
1575
|
+
* personal-team signing/registration path `deploy ios` handles bespokely; pending device-verify).
|
|
1275
1576
|
*
|
|
1276
1577
|
* Flags:
|
|
1277
1578
|
* --sim → emulator/simulator via `ns run` (HMR is reliable there); `--debug` → `ns debug`.
|
|
1278
1579
|
* --url/--port→ stamp loader:'server' at that dev-server URL (web hot-reloads inside the WebView), deploy + attach.
|
|
1279
|
-
* --detached → deploy + exit (install & launch only; don't attach/watch).
|
|
1280
|
-
* --debug →
|
|
1580
|
+
* --detached → deploy + exit (install & launch only; don't attach/watch — the MIUI-safe `--user 0` install).
|
|
1581
|
+
* --debug → android: `ns debug` (inspector); iOS/url: open the WebView inspector then attach.
|
|
1281
1582
|
*/
|
|
1282
1583
|
async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
1283
1584
|
const platform = positionals[0];
|
|
1284
1585
|
const sim = 'sim' in flags || positionals[1] === 'sim';
|
|
1285
1586
|
const wantDebug = 'debug' in flags;
|
|
1286
1587
|
if (platform !== 'ios' && platform !== 'android') {
|
|
1287
|
-
console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]');
|
|
1588
|
+
console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
|
|
1288
1589
|
process.exit(1);
|
|
1289
1590
|
}
|
|
1290
1591
|
const cfg = await loadConfig(cwd, flags);
|
|
@@ -1320,45 +1621,69 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
|
|
|
1320
1621
|
: cfg;
|
|
1321
1622
|
regenerateCore(cwd, outDir, simCfg, { flags });
|
|
1322
1623
|
applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
|
|
1624
|
+
stampIOSExtensionVersions(outDir, simCfg); // AFTER overrides: keep extension version matching the app's (Xcode warns on mismatch)
|
|
1323
1625
|
stampVersionManifest(outDir, simCfg);
|
|
1324
1626
|
console.log(simCfg.loader === 'server' ? `✓ Refreshed wrapper (dev loader → ${simCfg.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
|
|
1325
1627
|
runNs(outDir, [wantDebug ? 'debug' : 'run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
|
|
1326
1628
|
return;
|
|
1327
1629
|
}
|
|
1328
1630
|
|
|
1329
|
-
// ── device:
|
|
1631
|
+
// ── ANDROID device + bundled loader: ns run livesync = true on-device HMR (incremental, no reinstall) ──
|
|
1632
|
+
// Unblocked by declaring @nativescript/android in the shell package.json: ns reads the android runtime
|
|
1633
|
+
// version on the livesync path; when it's undeclared that lookup returns null → `semver.gt(null, …)` →
|
|
1634
|
+
// the "Invalid version … Got type object" crash that long made on-device livesync look unfixable.
|
|
1635
|
+
// ns watches native/app + native/www-src; since the PWA SOURCE lives OUTSIDE native/, we run a source
|
|
1636
|
+
// watcher alongside that rebuilds the web + re-stages www on save — ns's livesync then pushes it.
|
|
1637
|
+
// (Must spawn ns async, not execFileSync: a sync exec freezes the fs.watch loop.)
|
|
1638
|
+
// iOS is intentionally NOT on ns run here: `ns run ios --device` hits the personal-team device
|
|
1639
|
+
// registration/signing path that `deploy ios` handles bespokely — unverified, so iOS keeps the proven
|
|
1640
|
+
// deploy + redeploy-on-save loop below until it's device-verified.
|
|
1641
|
+
if (platform === 'android' && !devUrl && !('detached' in flags)) {
|
|
1642
|
+
const device = resolveDevice(outDir, 'android', flags);
|
|
1643
|
+
// MIUI/Xiaomi auto-denies a bare `adb install`; only `--user 0` works (deploy uses it). Pre-installing
|
|
1644
|
+
// via deploy establishes the package so `ns run`'s subsequent install lands as an allowed UPDATE
|
|
1645
|
+
// rather than a blocked fresh bare-install — the device-verified path. (~one extra fast install.)
|
|
1646
|
+
await deploy(cwd, { ...flags, 'no-launch': '' }, ['android'], cfg);
|
|
1647
|
+
const nsArgs = [wantDebug ? 'debug' : 'run', 'android', '--device', device.id];
|
|
1648
|
+
const env = prepareNsEnv(outDir, nsArgs);
|
|
1649
|
+
console.log(`\n▶ dev: ns ${nsArgs.join(' ')} (on-device HMR) + watching sources → re-stage on save. Ctrl-C to stop.`);
|
|
1650
|
+
// Own process group so Ctrl-C / kill reaps ns AND its grandchildren (gradle, adb logcat, webpack).
|
|
1651
|
+
const nsChild = spawn('ns', nsArgs, { cwd: outDir, stdio: 'inherit', detached: true, env });
|
|
1652
|
+
const stop = () => { try { if (nsChild.pid) process.kill(-nsChild.pid); } catch { /* already gone */ } };
|
|
1653
|
+
process.on('exit', stop);
|
|
1654
|
+
process.on('SIGINT', () => { stop(); process.exit(0); });
|
|
1655
|
+
nsChild.on('exit', (code) => process.exit(code ?? 0));
|
|
1656
|
+
await watchAndSync(cwd, flags, outDir, cfg); // rebuild + re-stage on save; ns livesync pushes it
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ── iOS device (bundled), --url, or --detached: the proven one-shot deploy path ──
|
|
1330
1661
|
await deploy(cwd, flags, [platform], devUrl ? effectiveCfg : undefined);
|
|
1331
1662
|
// Follow-ups reuse the just-deployed device from last-device memory — drop an interactive `-d`.
|
|
1332
1663
|
const followFlags = { ...flags }; delete followFlags.d;
|
|
1333
|
-
|
|
1334
1664
|
if (wantDebug) openInspector(effectiveCfg, followFlags, platform, outDir);
|
|
1335
|
-
|
|
1336
1665
|
if ('detached' in flags) {
|
|
1337
1666
|
console.log('\n✓ --detached — installed & launched; not attaching/watching.');
|
|
1338
1667
|
return;
|
|
1339
1668
|
}
|
|
1340
|
-
|
|
1341
|
-
// Attach: stream the WebView console. With a bundled loader we ALSO watch sources → rebuild+reinstall
|
|
1342
|
-
// on save. With a dev-server loader (--url) the web hot-reloads from the server INSIDE the WebView, so
|
|
1343
|
-
// a native rebuild is pointless (and would re-stamp the bundled loader) — just stream the console.
|
|
1344
1669
|
if (devUrl) {
|
|
1670
|
+
// --url: the web hot-reloads from the dev server INSIDE the WebView; just stream the console.
|
|
1345
1671
|
console.log('\n▶ dev: web hot-reloads from the dev server inside the WebView; streaming the console. Ctrl-C to stop.');
|
|
1346
1672
|
await logs(cwd, followFlags, [platform]);
|
|
1347
1673
|
return;
|
|
1348
1674
|
}
|
|
1349
|
-
|
|
1675
|
+
// iOS bundled device: stream the WebView console + redeploy (rebuild+reinstall) on save. No ns livesync
|
|
1676
|
+
// on an iOS device yet (see above), so a full redeploy is the device-safe refresh path.
|
|
1677
|
+
console.log('\n▶ dev: streaming WebView console + watching sources (edit a file → rebuild+reinstall). Ctrl-C to stop.');
|
|
1350
1678
|
const logArgs = [import.meta.path, 'logs', platform];
|
|
1351
1679
|
if (followFlags.device) logArgs.push('--device', followFlags.device);
|
|
1352
1680
|
if (followFlags.out) logArgs.push('--out', followFlags.out);
|
|
1353
1681
|
if (followFlags.config) logArgs.push('--config', followFlags.config);
|
|
1354
|
-
// `detached: true`
|
|
1355
|
-
// the child is `bun … logs`, which itself spawns `adb logcat`; `logChild.kill()` would only reap the
|
|
1356
|
-
// `bun` and orphan the `adb logcat` grandchild when the signal hits the leader pid (e.g. `kill <pid>`
|
|
1357
|
-
// / a supervisor, not interactive Ctrl-C which signals the group). `process.kill(-pid)` reaps both.
|
|
1682
|
+
// `detached: true` → own process group so we reap the whole tree (bun → adb/idevicesyslog) on Ctrl-C.
|
|
1358
1683
|
const logChild = spawn('bun', logArgs, { stdio: 'inherit', detached: true });
|
|
1359
|
-
const
|
|
1360
|
-
process.on('exit',
|
|
1361
|
-
process.on('SIGINT', () => {
|
|
1684
|
+
const stopLog = () => { try { if (logChild.pid) process.kill(-logChild.pid); } catch { /* already gone */ } };
|
|
1685
|
+
process.on('exit', stopLog);
|
|
1686
|
+
process.on('SIGINT', () => { stopLog(); process.exit(0); });
|
|
1362
1687
|
await watchAndRedeploy(cwd, followFlags, platform);
|
|
1363
1688
|
}
|
|
1364
1689
|
|
|
@@ -1386,7 +1711,9 @@ function resolveAndroidSdk(): string | undefined {
|
|
|
1386
1711
|
return candidates.find((d) => existsSync(join(d, 'platform-tools')) || existsSync(join(d, 'platforms')));
|
|
1387
1712
|
}
|
|
1388
1713
|
|
|
1389
|
-
|
|
1714
|
+
/** Build the env for an `ns` invocation in `outDir` (auto-detect Android SDK, ensure bun-installed deps).
|
|
1715
|
+
* Shared by the blocking `runNs` (sim) and the non-blocking spawn in `dev` (device livesync). */
|
|
1716
|
+
function prepareNsEnv(outDir: string, args: string[]): NodeJS.ProcessEnv {
|
|
1390
1717
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
1391
1718
|
// Android: inject a discovered SDK so `ns` finds it even when the user's shell never exported
|
|
1392
1719
|
// ANDROID_HOME (new terminal not sourced, conda base shell, etc.) — the common deploy blocker.
|
|
@@ -1405,10 +1732,34 @@ function runNs(outDir: string, args: string[]): void {
|
|
|
1405
1732
|
console.log(`▶ bun install (cwd: ${outDir})`);
|
|
1406
1733
|
execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env });
|
|
1407
1734
|
}
|
|
1735
|
+
return env;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function runNs(outDir: string, args: string[]): void {
|
|
1739
|
+
const env = prepareNsEnv(outDir, args);
|
|
1408
1740
|
console.log(`▶ ns ${args.join(' ')} (cwd: ${outDir})`);
|
|
1409
1741
|
execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
|
|
1410
1742
|
}
|
|
1411
1743
|
|
|
1744
|
+
/** Wipe the generated NativeScript state (`ns clean`: platforms/, hooks/, node_modules/) then restore
|
|
1745
|
+
* deps. The escape hatch when the generated tree drifts from the current project — e.g. a moved/renamed
|
|
1746
|
+
* repo leaves a STALE platforms/ios/Podfile that ns keeps appending to (corrupt merge, orphaned
|
|
1747
|
+
* post_install, mixed old/new paths) → CocoaPods "unexpected end" and the build never recovers on its own. */
|
|
1748
|
+
async function clean(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
1749
|
+
const outDir = resolve(cwd, flags.out ?? 'native');
|
|
1750
|
+
if (!existsSync(outDir)) {
|
|
1751
|
+
console.error(`✖ Wrapper not found at ${outDir} — nothing to clean (run \`appwrap init\` first)`);
|
|
1752
|
+
process.exit(1);
|
|
1753
|
+
}
|
|
1754
|
+
console.log(`▶ ns clean (cwd: ${outDir}) — wiping platforms/, hooks/, node_modules/`);
|
|
1755
|
+
execFileSync('ns', ['clean'], { cwd: outDir, stdio: 'inherit', env: { ...process.env } });
|
|
1756
|
+
// ns clean also removes node_modules; restore it so the tree is usable and the next deploy doesn't
|
|
1757
|
+
// pay the reinstall inline (prepareNsEnv would otherwise lazily bun-install on the following build).
|
|
1758
|
+
console.log(`▶ bun install (cwd: ${outDir}) — restoring deps`);
|
|
1759
|
+
execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env: { ...process.env } });
|
|
1760
|
+
console.log('✓ Cleaned. Next `appwrap deploy` regenerates platforms/ from App_Resources.');
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1412
1763
|
/** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
|
|
1413
1764
|
* preserve an ACTIVE dev loader across a `dev --sim` refresh. Returns null if the
|
|
1414
1765
|
* generated config is absent/unreadable — the caller then falls back to the appwrap config. */
|
|
@@ -1427,22 +1778,58 @@ function readStampedLoader(outDir: string): { loader: string; serverUrl: string;
|
|
|
1427
1778
|
}
|
|
1428
1779
|
}
|
|
1429
1780
|
|
|
1430
|
-
/**
|
|
1431
|
-
* source
|
|
1432
|
-
*
|
|
1781
|
+
/** Watch loop for iOS `dev` (no on-device ns livesync yet): re-run the clean deploy path on a project
|
|
1782
|
+
* source change (debounced) — a full rebuild+reinstall, the device-safe refresh for iOS. Skips
|
|
1783
|
+
* generated/output dirs. macOS recursive fs.watch. (Android uses watchAndSync + ns livesync instead.) */
|
|
1433
1784
|
async function watchAndRedeploy(cwd: string, flags: Record<string, string>, platform: 'ios' | 'android'): Promise<void> {
|
|
1434
1785
|
const { watch } = await import('fs');
|
|
1435
|
-
const ignore = /(^|\/)(native|node_modules|dist|\.git|\.appwrap)(\/|$)/;
|
|
1786
|
+
const ignore = /(^|\/)(native|node_modules|dist|public|\.git|\.appwrap)(\/|$)/;
|
|
1436
1787
|
console.log(`\n👀 watching ${cwd} for changes → rebuild+reinstall on save (Ctrl-C to stop).`);
|
|
1437
1788
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1438
1789
|
let busy = false;
|
|
1790
|
+
let quietUntil = 0;
|
|
1439
1791
|
watch(cwd, { recursive: true }, (_evt, file) => {
|
|
1440
|
-
if (!file || ignore.test(String(file)) || busy) return;
|
|
1792
|
+
if (!file || ignore.test(String(file)) || busy || Date.now() < quietUntil) return;
|
|
1441
1793
|
clearTimeout(timer);
|
|
1442
1794
|
timer = setTimeout(async () => {
|
|
1443
1795
|
busy = true;
|
|
1444
1796
|
console.log(`\n🔁 change: ${file} → redeploying…`);
|
|
1445
1797
|
try { await deploy(cwd, flags, [platform]); } catch (e) { console.error(`⚠ redeploy failed: ${(e as Error).message}`); }
|
|
1798
|
+
quietUntil = Date.now() + 1500;
|
|
1799
|
+
busy = false;
|
|
1800
|
+
}, 600);
|
|
1801
|
+
});
|
|
1802
|
+
await new Promise<void>(() => { /* run until Ctrl-C */ });
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/** Lean watch loop for Android `dev`: on a project source change, rebuild the web + RE-STAGE only
|
|
1806
|
+
* the web bundle (dist → native/www-src) — debounced. Deliberately NOT a full `regenerateCore`: that
|
|
1807
|
+
* re-copies App_Resources/package.json and makes ns do a full native rebuild+reinstall (defeating HMR
|
|
1808
|
+
* and tripping MIUI). Staging www-src only keeps the `ns run` livesync on the JS hot-push path. The
|
|
1809
|
+
* watcher sees the PROJECT dir, so it only catches PWA source edits (the shell template lives elsewhere).
|
|
1810
|
+
* Skips generated/output dirs. macOS recursive fs.watch. */
|
|
1811
|
+
async function watchAndSync(cwd: string, flags: Record<string, string>, outDir: string, cfg: AppwrapConfig): Promise<void> {
|
|
1812
|
+
const { watch } = await import('fs');
|
|
1813
|
+
// Skip generated/output trees. `dist` is the web build output; `public` is where many build steps
|
|
1814
|
+
// ALSO emit (e.g. a copied bundle / stamped index) — both must be ignored or the rebuild's own writes
|
|
1815
|
+
// re-trigger the watch in a loop. A post-rebuild cooldown is the generic backstop for any other
|
|
1816
|
+
// output dir we don't know about (the project's build target is project-specific).
|
|
1817
|
+
const ignore = /(^|\/)(native|node_modules|dist|public|\.git|\.appwrap)(\/|$)/;
|
|
1818
|
+
console.log(`👀 watching ${cwd} → rebuild + re-stage on save (ns livesync pushes it).`);
|
|
1819
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1820
|
+
let busy = false;
|
|
1821
|
+
let quietUntil = 0; // ignore events for a beat after a rebuild — its own file writes aren't user edits
|
|
1822
|
+
watch(cwd, { recursive: true }, (_evt, file) => {
|
|
1823
|
+
if (!file || ignore.test(String(file)) || busy || Date.now() < quietUntil) return;
|
|
1824
|
+
clearTimeout(timer);
|
|
1825
|
+
timer = setTimeout(() => {
|
|
1826
|
+
busy = true;
|
|
1827
|
+
console.log(`\n🔁 change: ${file} → rebuild web + re-stage www…`);
|
|
1828
|
+
try {
|
|
1829
|
+
buildWebIfBundled(cwd, cfg, flags);
|
|
1830
|
+
copyPwa(cwd, outDir, cfg); // stage dist → www-src only; ns livesync hot-pushes it (no reinstall)
|
|
1831
|
+
} catch (e) { console.error(`⚠ re-stage failed: ${(e as Error).message}`); }
|
|
1832
|
+
quietUntil = Date.now() + 1500;
|
|
1446
1833
|
busy = false;
|
|
1447
1834
|
}, 600);
|
|
1448
1835
|
});
|
|
@@ -1871,7 +2258,8 @@ function listDevices(platform: 'ios' | 'android'): DeviceInfo[] {
|
|
|
1871
2258
|
return listAndroidDevices(adb).map((serial) => {
|
|
1872
2259
|
let model = '';
|
|
1873
2260
|
try { model = execFileSync(adb, ['-s', serial, 'shell', 'getprop', 'ro.product.model'], { encoding: 'utf8' }).trim(); } catch { /* offline */ }
|
|
1874
|
-
|
|
2261
|
+
// A network adb serial is `host:port` (USB serials never contain ':') → label it wifi.
|
|
2262
|
+
return { id: serial, name: model || serial, model, transport: serial.includes(':') ? 'wifi' : 'usb' };
|
|
1875
2263
|
});
|
|
1876
2264
|
}
|
|
1877
2265
|
|
|
@@ -1893,10 +2281,21 @@ function pickInteractively(devices: DeviceInfo[]): DeviceInfo {
|
|
|
1893
2281
|
/** Resolve the target device for a platform command (the reusable core). Persists the choice under
|
|
1894
2282
|
* `outDir` so the next command (e.g. `run`→`logs`) reuses it. */
|
|
1895
2283
|
function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Record<string, string>): DeviceInfo {
|
|
1896
|
-
const
|
|
2284
|
+
const adb = platform === 'android' ? androidAdb() : '';
|
|
2285
|
+
|
|
2286
|
+
// --wifi (android): flip a USB device to wireless adb (or reconnect a remembered one), then target it.
|
|
2287
|
+
if (platform === 'android' && 'wifi' in flags) enableWifiAdb(adb, outDir, flags);
|
|
2288
|
+
|
|
2289
|
+
// --device <ip[:port]> (android): if it's a network target that isn't attached yet, `adb connect` it.
|
|
2290
|
+
if (platform === 'android' && flags.device && looksLikeAdbHost(flags.device)) {
|
|
2291
|
+
const target = withAdbPort(flags.device);
|
|
2292
|
+
if (!listAndroidDevices(adb).includes(target)) { adbConnect(adb, target); flags.device = target; }
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
let devices = listDevices(platform);
|
|
1897
2296
|
const noneMsg = platform === 'ios'
|
|
1898
2297
|
? '✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.'
|
|
1899
|
-
: '✖ No authorized Android device
|
|
2298
|
+
: '✖ No authorized Android device.\n USB: plug in + accept "Allow USB debugging".\n Wireless: `appwrap dev android --wifi` (flip a USB device to wireless), enable the phone\'s "Wireless debugging" (auto-discovered via mDNS), or `--device <ip[:port]>` (an already-paired device).\n (check with `adb devices`)';
|
|
1900
2299
|
|
|
1901
2300
|
// --device <id|name> — exact (or unambiguous prefix) match against connected devices.
|
|
1902
2301
|
if (flags.device) {
|
|
@@ -1905,6 +2304,22 @@ function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Recor
|
|
|
1905
2304
|
writeLastDevice(outDir, platform, m.id);
|
|
1906
2305
|
return m;
|
|
1907
2306
|
}
|
|
2307
|
+
|
|
2308
|
+
// Android: nothing attached but a wireless device was remembered → auto-reconnect it (survives sleep /
|
|
2309
|
+
// USB-unplug, so plain `appwrap dev android` keeps working cordless after the first `--wifi`).
|
|
2310
|
+
if (platform === 'android' && !('d' in flags)) {
|
|
2311
|
+
const last = readLastDevice(outDir, 'android');
|
|
2312
|
+
if (last && last.includes(':') && !devices.find((d) => d.id === last) && adbConnect(adb, last)) devices = listDevices(platform);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// Android passive discovery (iOS parity): still nothing → pick up any mDNS-advertised wireless device
|
|
2316
|
+
// (tcpip / "Wireless debugging" on) and adb-connect it, so plain `dev android` finds it with no flag —
|
|
2317
|
+
// the same zero-config a network-paired iPhone gets from devicectl.
|
|
2318
|
+
if (platform === 'android' && devices.length === 0 && !flags.device) {
|
|
2319
|
+
const found = androidMdnsTargets(adb).filter((t) => !listAndroidDevices(adb).includes(t) && adbConnect(adb, t));
|
|
2320
|
+
if (found.length) devices = listDevices(platform);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
1908
2323
|
if (devices.length === 0) { console.error(noneMsg); process.exit(1); }
|
|
1909
2324
|
|
|
1910
2325
|
// -d → always prompt. Otherwise prefer the remembered device, then the sole device.
|
|
@@ -1968,6 +2383,86 @@ function androidAdb(): string {
|
|
|
1968
2383
|
return 'adb';
|
|
1969
2384
|
}
|
|
1970
2385
|
|
|
2386
|
+
/** Synchronous sleep — the device-resolution path is all sync execFileSync, so we can't await. */
|
|
2387
|
+
function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
2388
|
+
|
|
2389
|
+
/** A `--device` value that looks like a network target (ip / hostname[:port]) vs a USB serial — USB
|
|
2390
|
+
* adb serials are bare alphanumerics, never containing a '.' (ip/host) or ':' (host:port). */
|
|
2391
|
+
function looksLikeAdbHost(s: string): boolean { return s.includes('.') || s.includes(':'); }
|
|
2392
|
+
/** Normalize a wireless target to host:port (adb's default tcpip port is 5555). */
|
|
2393
|
+
function withAdbPort(host: string): string { return /:\d+$/.test(host) ? host : `${host}:5555`; }
|
|
2394
|
+
|
|
2395
|
+
/** `adb connect <target>` — true if connected (or already was). Prints the outcome. */
|
|
2396
|
+
function adbConnect(adb: string, target: string): boolean {
|
|
2397
|
+
try {
|
|
2398
|
+
const out = execFileSync(adb, ['connect', target], { encoding: 'utf8' }).trim();
|
|
2399
|
+
const ok = /connected to|already connected/i.test(out);
|
|
2400
|
+
console.log(ok ? `🔗 ${out}` : `⚠ adb connect ${target}: ${out}`);
|
|
2401
|
+
return ok;
|
|
2402
|
+
} catch (e) {
|
|
2403
|
+
console.error(`⚠ adb connect ${target} failed: ${execErrText(e).trim()}`);
|
|
2404
|
+
return false;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/** mDNS-discovered wireless adb targets (`ip:port`) — the passive path that matches iOS devicectl's
|
|
2409
|
+
* network listing. The device advertises once tcpip is on (`--wifi`) or Android-11+ "Wireless debugging"
|
|
2410
|
+
* is enabled, so `appwrap dev android` finds it with NO flag (parity with `dev ios` over the network). */
|
|
2411
|
+
function androidMdnsTargets(adb: string): string[] {
|
|
2412
|
+
try {
|
|
2413
|
+
return execFileSync(adb, ['mdns', 'services'], { encoding: 'utf8', timeout: 8000 })
|
|
2414
|
+
.split('\n')
|
|
2415
|
+
.map((l) => l.match(/(\d+\.\d+\.\d+\.\d+:\d+)\s*$/)?.[1])
|
|
2416
|
+
.filter((x): x is string => !!x);
|
|
2417
|
+
} catch { return []; }
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/** Read a USB-connected device's Wi-Fi (wlan0) IPv4, or null if it isn't on Wi-Fi. */
|
|
2421
|
+
function androidWifiIp(adb: string, serial: string): string | null {
|
|
2422
|
+
for (const args of [
|
|
2423
|
+
['-s', serial, 'shell', 'ip', '-o', 'route', 'get', '1.1.1.1'], // "... src 192.168.1.50"
|
|
2424
|
+
['-s', serial, 'shell', 'ip', '-o', '-f', 'inet', 'addr', 'show', 'wlan0'], // "inet 192.168.1.50/24"
|
|
2425
|
+
]) {
|
|
2426
|
+
try {
|
|
2427
|
+
const m = execFileSync(adb, args, { encoding: 'utf8' }).match(/(?:src|inet)\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
2428
|
+
if (m && !m[1].startsWith('127.')) return m[1];
|
|
2429
|
+
} catch { /* try next */ }
|
|
2430
|
+
}
|
|
2431
|
+
return null;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/** `--wifi`: flip a USB-connected device into TCP/IP mode and `adb connect` it over the LAN, so the user
|
|
2435
|
+
* can unplug and keep iterating cordless. If nothing's on USB but a wireless device was remembered, just
|
|
2436
|
+
* reconnect that. Sets `flags.device` to the wireless target (and clears `wifi`) so the rest of the
|
|
2437
|
+
* resolve/deploy path — and any later resolveDevice call — targets it without re-flipping. */
|
|
2438
|
+
function enableWifiAdb(adb: string, outDir: string, flags: Record<string, string>): void {
|
|
2439
|
+
const usb = listAndroidDevices(adb).filter((s) => !s.includes(':')); // USB-attached serials only
|
|
2440
|
+
if (usb.length === 0) {
|
|
2441
|
+
const last = readLastDevice(outDir, 'android');
|
|
2442
|
+
if (last && last.includes(':') && adbConnect(adb, last)) { flags.device = last; delete flags.wifi; return; }
|
|
2443
|
+
console.error('✖ --wifi needs a USB-connected device to flip to wireless (none found). Plug in once + accept "Allow USB debugging", or pass --device <ip[:port]> for an already-paired device.');
|
|
2444
|
+
process.exit(1);
|
|
2445
|
+
}
|
|
2446
|
+
const serial = flags.device && usb.includes(flags.device) ? flags.device : usb[0];
|
|
2447
|
+
if (usb.length > 1 && serial === usb[0] && !(flags.device && usb.includes(flags.device))) {
|
|
2448
|
+
console.log(` (multiple USB devices; flipping ${serial} — pass --device <serial> to choose another)`);
|
|
2449
|
+
}
|
|
2450
|
+
const ip = androidWifiIp(adb, serial);
|
|
2451
|
+
if (!ip) { console.error(`✖ Couldn't read ${serial}'s Wi-Fi IP — is it on Wi-Fi? (try: adb -s ${serial} shell ip route)`); process.exit(1); }
|
|
2452
|
+
console.log(`📶 ${serial}: enabling wireless adb on :5555…`);
|
|
2453
|
+
try { execFileSync(adb, ['-s', serial, 'tcpip', '5555'], { stdio: 'pipe' }); }
|
|
2454
|
+
catch (e) { console.error(`✖ adb tcpip failed: ${execErrText(e).trim()}`); process.exit(1); }
|
|
2455
|
+
const target = `${ip}:5555`;
|
|
2456
|
+
// tcpip restarts adbd on the device — connect with a few retries while it comes back up.
|
|
2457
|
+
let connected = false;
|
|
2458
|
+
for (let i = 0; i < 6 && !connected; i++) { sleepSync(700); connected = adbConnect(adb, target); }
|
|
2459
|
+
if (!connected) { console.error(`✖ Couldn't connect to ${target} after tcpip — same Wi-Fi network? firewall blocking :5555?`); process.exit(1); }
|
|
2460
|
+
writeLastDevice(outDir, 'android', target);
|
|
2461
|
+
console.log(`✓ Wireless adb ready → ${target}. You can unplug USB now.`);
|
|
2462
|
+
flags.device = target;
|
|
2463
|
+
delete flags.wifi;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
1971
2466
|
/** Authorized (`device` state) adb serials. Skips `unauthorized`/`offline`. */
|
|
1972
2467
|
function listAndroidDevices(adb: string): string[] {
|
|
1973
2468
|
try {
|
|
@@ -2097,9 +2592,16 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
2097
2592
|
const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
|
|
2098
2593
|
console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
|
|
2099
2594
|
} else {
|
|
2100
|
-
|
|
2595
|
+
// Manual signing (signing:'manual') → pass the main-app profile as --provision so NS emits a
|
|
2596
|
+
// manual exportOptions.plist covering the app + its extensions (see stampManualSigning sidecar).
|
|
2597
|
+
const nsArgs = ['build', 'ios', '--for-device'];
|
|
2101
2598
|
try {
|
|
2102
|
-
|
|
2599
|
+
const sc = JSON.parse(readFileSync(join(outDir, '.appwrap-signing.json'), 'utf8')) as { provision?: string };
|
|
2600
|
+
if (sc.provision) nsArgs.push('--provision', sc.provision);
|
|
2601
|
+
} catch { /* no sidecar → automatic signing */ }
|
|
2602
|
+
console.log(`▶ ns ${nsArgs.join(' ')} (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
|
|
2603
|
+
try {
|
|
2604
|
+
execFileSync('ns', nsArgs, { cwd: outDir, stdio: 'inherit' });
|
|
2103
2605
|
} catch (e) {
|
|
2104
2606
|
// The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
|
|
2105
2607
|
console.error(
|
|
@@ -2123,16 +2625,19 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
2123
2625
|
|
|
2124
2626
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
2125
2627
|
let installedViaUsbmux = false;
|
|
2628
|
+
let installed = false;
|
|
2629
|
+
// A wireless (localNetwork) install of a multi-MB .ipa legitimately takes longer than a wired one —
|
|
2630
|
+
// 25s is a wired number; too tight for Wi-Fi, tripping the fallback on a healthy-but-slow install.
|
|
2631
|
+
const wireless = /localNetwork|wifi/i.test(device.transport);
|
|
2632
|
+
const installTimeout = wireless ? '120' : '25';
|
|
2633
|
+
// Capture (not inherit) so we can recognize specific failures; echo it for visibility. Wrapped so a
|
|
2634
|
+
// LOCKED device waits-and-retries. --timeout lets one attempt tolerate a brief locked/unavailable window.
|
|
2635
|
+
const runDevicectlInstall = (): string => withUnlockRetry('Install', () =>
|
|
2636
|
+
execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', installTimeout, '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
|
|
2637
|
+
);
|
|
2126
2638
|
try {
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
// --timeout: devicectl has no first-class "wait for unlock", but its overall-timeout lets a single
|
|
2130
|
-
// attempt tolerate a brief locked/unavailable window before erroring; the outer retry covers the
|
|
2131
|
-
// fail-fast case + the unlock prompt. (Verified against `devicectl --help`; community wraps it too.)
|
|
2132
|
-
const out = withUnlockRetry('Install', () =>
|
|
2133
|
-
execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', '25', '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
|
|
2134
|
-
);
|
|
2135
|
-
process.stdout.write(out);
|
|
2639
|
+
process.stdout.write(runDevicectlInstall());
|
|
2640
|
+
installed = true;
|
|
2136
2641
|
} catch (e: unknown) {
|
|
2137
2642
|
const log = execErrText(e);
|
|
2138
2643
|
process.stderr.write(log);
|
|
@@ -2148,19 +2653,33 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
2148
2653
|
);
|
|
2149
2654
|
process.exit(1);
|
|
2150
2655
|
} else if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
|
|
2151
|
-
// devicectl/CoreDevice is stuck (
|
|
2152
|
-
//
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
);
|
|
2163
|
-
|
|
2656
|
+
// devicectl/CoreDevice is stuck (connection hang, NOT a lock). Usual cause: a pegged
|
|
2657
|
+
// CoreDeviceService daemon. SELF-HEAL: kill it (auto-respawns clean) and retry devicectl once —
|
|
2658
|
+
// this is the fix a human otherwise applies by hand, and it's the ONLY path that works for a
|
|
2659
|
+
// wireless-only device (usbmux is USB-only).
|
|
2660
|
+
if (killStuckCoreDeviceService()) {
|
|
2661
|
+
console.error('\n↻ CoreDevice tunnel was stuck (likely a pegged CoreDeviceService) — restarted it, retrying install…');
|
|
2662
|
+
try { process.stdout.write(runDevicectlInstall()); installed = true; }
|
|
2663
|
+
catch (e2) { process.stderr.write(execErrText(e2)); }
|
|
2664
|
+
}
|
|
2665
|
+
// Still stuck → usbmux (ideviceinstaller), which only helps a USB-reachable device.
|
|
2666
|
+
if (!installed) {
|
|
2667
|
+
console.error('\n⚠ devicectl still stuck — trying ideviceinstaller (usbmux, USB only)…');
|
|
2668
|
+
if (usbmuxInstall(ipaPath)) {
|
|
2669
|
+
installedViaUsbmux = true; installed = true;
|
|
2670
|
+
} else {
|
|
2671
|
+
console.error(
|
|
2672
|
+
'✖ Install failed: the CoreDevice tunnel stayed stuck and there is no usbmux (USB) path.\n' +
|
|
2673
|
+
(wireless
|
|
2674
|
+
? ' This device is on Wi-Fi only — CoreDeviceService was just restarted, so simply RE-RUN\n' +
|
|
2675
|
+
' `appwrap deploy ios` (the .ipa is cached; it will only re-install). For the most reliable\n' +
|
|
2676
|
+
' path, plug in the USB cable.\n'
|
|
2677
|
+
: ' → Re-plug the USB cable (re-establishes the CoreDevice tunnel) and re-run, OR\n' +
|
|
2678
|
+
' `brew install ideviceinstaller` for a usbmux install path.\n') +
|
|
2679
|
+
` The built .ipa is ready: ${ipaPath}`
|
|
2680
|
+
);
|
|
2681
|
+
process.exit(1);
|
|
2682
|
+
}
|
|
2164
2683
|
}
|
|
2165
2684
|
} else {
|
|
2166
2685
|
console.error(
|
|
@@ -2181,7 +2700,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
2181
2700
|
// old serverUrl) despite a correct new build — masquerading as a build/cache bug. (A reinstall
|
|
2182
2701
|
// over the top does NOT replace a running process; this avoids the manual uninstall dance.)
|
|
2183
2702
|
withUnlockRetry('Launch', () =>
|
|
2184
|
-
execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout',
|
|
2703
|
+
execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', installTimeout, '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
|
|
2185
2704
|
);
|
|
2186
2705
|
} catch {
|
|
2187
2706
|
console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
|
|
@@ -2206,6 +2725,20 @@ function usbmuxInstall(ipaPath: string): boolean {
|
|
|
2206
2725
|
}
|
|
2207
2726
|
}
|
|
2208
2727
|
|
|
2728
|
+
/** Apple's `CoreDeviceService` daemon intermittently pegs a CPU core at 100% and wedges the device
|
|
2729
|
+
* tunnel — `devicectl` then hangs until its timeout (worse on a wireless/localNetwork target). Killing
|
|
2730
|
+
* it is the documented remedy: launchd auto-respawns it clean. Best-effort; returns true if we
|
|
2731
|
+
* signalled a matching process (pkill exits non-zero → throws → false when nothing matched). */
|
|
2732
|
+
function killStuckCoreDeviceService(): boolean {
|
|
2733
|
+
try {
|
|
2734
|
+
execFileSync('pkill', ['-9', '-f', 'CoreDeviceService.xpc'], { stdio: 'ignore' });
|
|
2735
|
+
sleepSync(4000); // let launchd respawn it + re-establish the tunnel before we retry
|
|
2736
|
+
return true;
|
|
2737
|
+
} catch {
|
|
2738
|
+
return false;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2209
2742
|
/** Run a devicectl op; if it fails because the device is LOCKED (or transiently unavailable), prompt
|
|
2210
2743
|
* once and poll-retry until it succeeds or the budget runs out — instead of hard-failing. A free-team
|
|
2211
2744
|
* 3-app-limit error is NOT a lock, so it's re-thrown immediately for the caller's specific handling.
|
|
@@ -2378,6 +2911,9 @@ async function main(): Promise<void> {
|
|
|
2378
2911
|
case 'sync':
|
|
2379
2912
|
await sync(cwd, flags);
|
|
2380
2913
|
break;
|
|
2914
|
+
case 'clean':
|
|
2915
|
+
await clean(cwd, flags);
|
|
2916
|
+
break;
|
|
2381
2917
|
case 'dev':
|
|
2382
2918
|
await dev(cwd, flags, positionals);
|
|
2383
2919
|
break;
|
|
@@ -2408,15 +2944,19 @@ async function main(): Promise<void> {
|
|
|
2408
2944
|
default:
|
|
2409
2945
|
console.log('Usage: appwrap <init|sync|dev|build|deploy|publish|logs> [--config <path>] [--out native]\n' +
|
|
2410
2946
|
' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
|
|
2411
|
-
' Device selection (dev/deploy/logs/publish): --device <id|name> | -d (pick from a list) | else last-used / sole device.\n
|
|
2412
|
-
'
|
|
2413
|
-
'
|
|
2414
|
-
' --
|
|
2415
|
-
'
|
|
2947
|
+
' Device selection (dev/deploy/logs/publish): --device <id|name|ip[:port]> | -d (pick from a list) | else last-used / sole device.\n' +
|
|
2948
|
+
' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
|
|
2949
|
+
' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
|
|
2950
|
+
' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
|
|
2951
|
+
' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
|
|
2952
|
+
' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
|
|
2953
|
+
' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
|
|
2954
|
+
' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
|
|
2416
2955
|
' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
|
|
2417
2956
|
' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
|
|
2418
2957
|
' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
|
|
2419
2958
|
' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
|
|
2959
|
+
' clean (ns clean: wipe generated platforms/hooks/node_modules, restore deps — fixes stale/corrupt Podfile after a repo move)\n' +
|
|
2420
2960
|
' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
|
|
2421
2961
|
process.exit(command ? 1 : 0);
|
|
2422
2962
|
}
|
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
|
|