@livx.cc/appwrap 0.30.2 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.30.2",
3
+ "version": "0.32.0",
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",
@@ -19,10 +19,24 @@ import { SHELL_CONFIG } from './config';
19
19
  let pendingRegister: { resolve: (t: PushToken) => void; reject: (e: Error) => void } | null = null;
20
20
  let cachedToken: string | null = null;
21
21
 
22
- interface PushToken { platform: 'apns' | 'fcm'; token: string; }
22
+ interface PushToken { platform: 'apns' | 'fcm'; token: string; topic?: string; }
23
+
24
+ /** The app's bundle/package id — the APNs `apns-topic` MUST equal it or APNs rejects with
25
+ * `DeviceTokenNotForTopic`. iOS: NSBundle bundle id; Android: the application package id (FCM
26
+ * ignores apns-topic, so it's informational there). Resolved natively so consumers never hardcode it. */
27
+ function appTopic(): string | undefined {
28
+ try {
29
+ if (isIOS) return NSBundle.mainBundle.bundleIdentifier;
30
+ if (isAndroid) return Utils.android.getApplicationContext().getPackageName();
31
+ } catch (e: any) {
32
+ console.log('[push] bundle-id resolve failed: ' + (e?.message ?? e));
33
+ }
34
+ return undefined;
35
+ }
23
36
 
24
37
  /** POST the device token to the app's configured backend NATIVELY (no WKWebView fetch → no app://
25
- * cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured. */
38
+ * cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured.
39
+ * Includes `topic` (the bundle/package id) so the backend can set the correct apns-topic header. */
26
40
  function registerTokenWithBackend(platform: 'ios' | 'android', token: string): void {
27
41
  const url = SHELL_CONFIG.pushRegistrationUrl;
28
42
  if (!url) return;
@@ -31,7 +45,7 @@ function registerTokenWithBackend(platform: 'ios' | 'android', token: string): v
31
45
  try {
32
46
  Http.request({
33
47
  url, method: 'POST', headers: { 'Content-Type': 'application/json' },
34
- content: JSON.stringify({ token, platform }),
48
+ content: JSON.stringify({ token, platform, topic: appTopic() }),
35
49
  })
36
50
  .then((res) => console.log('[push] token registered with backend → ' + res.statusCode))
37
51
  .catch((e: any) => console.log('[push] backend register failed: ' + (e?.message ?? e)));
@@ -48,7 +62,7 @@ export function onApnsToken(hexToken: string): void {
48
62
  console.log('[push] APNs token: ' + hexToken);
49
63
  registerTokenWithBackend('ios', hexToken);
50
64
  if (pendingRegister) {
51
- pendingRegister.resolve({ platform: 'apns', token: hexToken });
65
+ pendingRegister.resolve({ platform: 'apns', token: hexToken, topic: appTopic() });
52
66
  pendingRegister = null;
53
67
  }
54
68
  }
@@ -143,7 +157,7 @@ export function registerPushHandlers(): void {
143
157
  bridge.register('push.register', () => {
144
158
  if (isIOS) {
145
159
  return new Promise<PushToken>((resolve, reject) => {
146
- if (cachedToken) return resolve({ platform: 'apns', token: cachedToken });
160
+ if (cachedToken) return resolve({ platform: 'apns', token: cachedToken, topic: appTopic() });
147
161
  pendingRegister = { resolve, reject };
148
162
  Utils.dispatchToMainThread(() => UIApplication.sharedApplication.registerForRemoteNotifications());
149
163
  setTimeout(() => {
@@ -235,7 +249,7 @@ function androidGetToken(): Promise<PushToken> {
235
249
  if (t.isSuccessful()) {
236
250
  const tok = String(t.getResult());
237
251
  registerTokenWithBackend('android', tok); // parity with iOS onApnsToken
238
- resolve({ platform: 'fcm', token: tok });
252
+ resolve({ platform: 'fcm', token: tok, topic: appTopic() });
239
253
  } else reject(Object.assign(new Error('FCM token fetch failed'), { code: 'NATIVE_ERROR' }));
240
254
  },
241
255
  }));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrap-shell",
3
3
  "main": "app/app.ts",
4
- "version": "0.29.0",
4
+ "version": "0.30.0",
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "dev:ios": "ns run ios",
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
10
10
  */
11
11
  import { execFileSync } from 'child_process';
12
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
12
+ import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
13
13
  import { networkInterfaces, tmpdir } from 'os';
14
14
  import { dirname, join, resolve } from 'path';
15
15
  import { pathToFileURL } from 'url';
@@ -336,11 +336,17 @@ async function readConfigFile(configPath: string): Promise<AppwrapConfig> {
336
336
  return cfg as AppwrapConfig;
337
337
  }
338
338
 
339
- async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
340
- // Explicit --config wins; otherwise probe ts → js → json (TS preferred).
341
- const configPath = flags.config
339
+ /** Resolve the user's appwrap config file path the CLI loads from: explicit --config wins;
340
+ * otherwise probe ts → js → json (TS preferred). Single source of discovery (reused by the
341
+ * team-id pin-to-config writer so it never re-invents the probe). */
342
+ function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
343
+ return flags.config
342
344
  ? resolve(cwd, flags.config)
343
345
  : (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
346
+ }
347
+
348
+ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
349
+ const configPath = resolveConfigPath(cwd, flags);
344
350
  if (!existsSync(configPath)) {
345
351
  console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
346
352
  process.exit(1);
@@ -463,15 +469,31 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
463
469
  writeFileSync(plist, src);
464
470
  }
465
471
 
466
- function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
472
+ function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; configPath: string }): void {
467
473
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
468
- // A placeholder/empty teamId silently produces an unsignable build (DEVELOPMENT_TEAM = YOUR_APPLE_TEAM_ID
469
- // → xcodebuild "No Account for Team"). Warn loudly rather than let a long build fail cryptically.
470
- if (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId)) {
471
- console.warn(`⚠ teamId is unset/placeholder ("${cfg.teamId ?? ''}") — device builds won't sign.\n` +
472
- ' Set it to your Apple Team ID in appwrap.config (Xcode → Settings → Accounts shows it; ' +
473
- 'Individual = paid, Personal Team = free).');
474
- return;
474
+ // Resolution order: a real (non-placeholder) cfg.teamId wins; else $APPWRAP_TEAM_ID (headless/CI);
475
+ // else the enriched interactive picker (which then offers to pin its choice to the config). This
476
+ // intercepts BEFORE `ns build` runs so NS never shows its plain, unenriched prompt.
477
+ const isPlaceholder = !cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId);
478
+ if (isPlaceholder) {
479
+ const envTeam = process.env.APPWRAP_TEAM_ID?.trim();
480
+ if (envTeam) {
481
+ cfg.teamId = envTeam;
482
+ console.log(` team ← ${envTeam} (from $APPWRAP_TEAM_ID)`);
483
+ } else if (!process.stdout.isTTY) {
484
+ console.warn(`⚠ teamId is unset — set it in appwrap.config, set $APPWRAP_TEAM_ID, or run interactively to pick from your teams.`);
485
+ return;
486
+ } else {
487
+ const picked = pickTeamIdInteractively();
488
+ cfg.teamId = picked.teamId;
489
+ // Offer to persist the choice so the user isn't re-prompted on every deploy. No-TTY/headless is
490
+ // already handled above; promptYesNo additionally guards against a non-interactive stdin.
491
+ if (ctx && promptYesNo(` Pin "${picked.name} (${picked.teamId})" to appwrap.config so you're not asked again?`, true)) {
492
+ pinTeamIdToConfig(ctx.configPath, picked.teamId);
493
+ } else {
494
+ console.log(` ⓘ To skip this prompt: set teamId: "${picked.teamId}" in appwrap.config (or set $APPWRAP_TEAM_ID).`);
495
+ }
496
+ }
475
497
  }
476
498
  if (!existsSync(xcconfig)) return;
477
499
  let src = readFileSync(xcconfig, 'utf8');
@@ -948,7 +970,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
948
970
  * Excludes the first-time scaffold (managed-guard, CI, .gitignore) + overrides/version-manifest, which the
949
971
  * callers sequence around this so overrides win LAST and the marker writes after.
950
972
  */
951
- function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean } = {}): void {
973
+ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean; flags?: Record<string, string> } = {}): void {
952
974
  const req = nativeReqs(cfg);
953
975
  if (opts.firstRun && !req.explicit) {
954
976
  console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
@@ -964,7 +986,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
964
986
  stampShellConfig(outDir, cfg);
965
987
  stampNativeScriptConfig(outDir, cfg);
966
988
  stampIOSDisplayName(outDir, cfg, req);
967
- stampTeamId(outDir, cfg);
989
+ stampTeamId(outDir, cfg, { cwd, configPath: resolveConfigPath(cwd, opts.flags ?? {}) });
968
990
  stampAndroidAppName(outDir, cfg, req);
969
991
  stampAndroidVersion(outDir, cfg);
970
992
  stampAndroidGradleDeps(outDir, req.androidGradleDeps);
@@ -1004,7 +1026,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1004
1026
 
1005
1027
  console.log(`🎁 appwrap init → ${outDir}`);
1006
1028
  mkdirSync(outDir, { recursive: true });
1007
- regenerateCore(cwd, outDir, cfg, { firstRun: true });
1029
+ regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1008
1030
  copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
1009
1031
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1010
1032
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
@@ -1022,7 +1044,7 @@ async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
1022
1044
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1023
1045
  process.exit(1);
1024
1046
  }
1025
- regenerateCore(cwd, outDir, cfg);
1047
+ regenerateCore(cwd, outDir, cfg, { flags });
1026
1048
  applyOverrides(cwd, outDir, cfg); // overrides win last
1027
1049
  stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
1028
1050
  console.log('✓ Synced.');
@@ -1121,6 +1143,7 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
1121
1143
  }
1122
1144
  }
1123
1145
 
1146
+ interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
1124
1147
  interface DeviceInfo { id: string; name: string; model: string; transport: string }
1125
1148
 
1126
1149
  /** The subset of a `xcrun devicectl list devices --json-output` device entry appwrap reads. */
@@ -1131,6 +1154,217 @@ interface DevicectlDevice {
1131
1154
  connectionProperties?: { tunnelState?: string; transportType?: string };
1132
1155
  }
1133
1156
 
1157
+ /** Read Apple team metadata from provisioning profiles + distribution certs in the keychain.
1158
+ * Provisioning profiles give us the reliable teamId↔teamName mapping; distribution certs
1159
+ * often embed the account email in the display name. */
1160
+ function detectAppleTeams(): AppleTeam[] {
1161
+ const teams = new Map<string, AppleTeam>();
1162
+
1163
+ // 1. Provisioning profiles → teamId + teamName (most reliable)
1164
+ const profilesDir = join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles');
1165
+ if (existsSync(profilesDir)) {
1166
+ try {
1167
+ const files = readdirSync(profilesDir).filter((f) => f.endsWith('.mobileprovision'));
1168
+ for (const f of files) {
1169
+ try {
1170
+ const raw = execFileSync('security', ['cms', '-D', '-i', join(profilesDir, f)],
1171
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1172
+ const idMatch = raw.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/);
1173
+ const nameMatch = raw.match(/<key>TeamName<\/key>\s*<string>([^<]+)<\/string>/);
1174
+ if (idMatch && nameMatch) {
1175
+ const teamId = idMatch[1];
1176
+ const name = nameMatch[1];
1177
+ const free = /personal team/i.test(name);
1178
+ if (!teams.has(teamId)) teams.set(teamId, { teamId, name, paid: !free });
1179
+ }
1180
+ } catch { /* skip unreadable profile */ }
1181
+ }
1182
+ } catch { /* skip if dir unreadable */ }
1183
+ }
1184
+
1185
+ // 2. Keychain distribution/Developer-ID certs → teamId + possible email in name
1186
+ try {
1187
+ const out = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'],
1188
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1189
+ for (const line of out.split('\n')) {
1190
+ const m = line.match(/"(?:Apple Distribution|Developer ID Application): (.+?) \(([A-Z0-9]{10})\)"/);
1191
+ if (!m) continue;
1192
+ const [, label, teamId] = m;
1193
+ const email = label.includes('@') ? label.trim() : undefined;
1194
+ const existing = teams.get(teamId);
1195
+ if (existing) {
1196
+ if (email && !existing.email) existing.email = email;
1197
+ } else {
1198
+ teams.set(teamId, { teamId, name: label.trim(), email, paid: !/personal team/i.test(label) });
1199
+ }
1200
+ }
1201
+ } catch { /* keychain unavailable */ }
1202
+
1203
+ return [...teams.values()];
1204
+ }
1205
+
1206
+ /** Arrow-key interactive selector. Returns the index of the chosen item. */
1207
+ function arrowSelect(prompt: string, items: string[]): number {
1208
+ const tty = openSync('/dev/tty', 'r+');
1209
+ const write = (s: string) => writeSync(tty, s);
1210
+ const ESC = '\x1b';
1211
+
1212
+ write(`${prompt}\n`);
1213
+ let idx = 0;
1214
+ const HINT = '\x1b[2m ↑↓ / j k to move · Enter to confirm\x1b[0m';
1215
+ const render = (clear: boolean) => {
1216
+ if (clear) write(`\x1b[${items.length + 1}A`); // +1 for the hint line
1217
+ for (let i = 0; i < items.length; i++)
1218
+ write(`\r\x1b[K${i === idx ? '❯ ' : ' '}${items[i]}\n`);
1219
+ write(`\r\x1b[K${HINT}\n`);
1220
+ };
1221
+ render(false);
1222
+
1223
+ // raw mode via stty
1224
+ execFileSync('stty', ['-icanon', '-echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
1225
+ const buf = Buffer.alloc(6);
1226
+ try {
1227
+ for (;;) {
1228
+ const n = readSync(tty, buf, 0, 6, null);
1229
+ const key = buf.slice(0, n).toString();
1230
+ if (key === `${ESC}[A` || key === 'k') { idx = (idx - 1 + items.length) % items.length; render(true); }
1231
+ else if (key === `${ESC}[B` || key === 'j') { idx = (idx + 1) % items.length; render(true); }
1232
+ else if (key === '\r' || key === '\n') break;
1233
+ else if (key === '\x03') { write('\n'); process.exit(1); } // Ctrl-C
1234
+ }
1235
+ } finally {
1236
+ execFileSync('stty', ['icanon', 'echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
1237
+ // Erase the hint line so the selected value prints cleanly after
1238
+ write(`\x1b[1A\r\x1b[K`);
1239
+ write('\n');
1240
+ closeSync(tty);
1241
+ }
1242
+ return idx;
1243
+ }
1244
+
1245
+ /** Interactively prompt for an Apple team when teamId is unset. Shows enriched metadata
1246
+ * (email, paid/free) sourced from local keychain + provisioning profiles. */
1247
+ function pickTeamIdInteractively(): { teamId: string; name: string } {
1248
+ const teams = detectAppleTeams();
1249
+ if (teams.length === 0) {
1250
+ console.error('✖ No Apple signing teams found in keychain/provisioning profiles.\n' +
1251
+ ' Sign into Xcode → Settings → Accounts, then re-run.');
1252
+ process.exit(1);
1253
+ }
1254
+ if (teams.length === 1) {
1255
+ const t = teams[0];
1256
+ console.log(` team ← ${t.name} (${t.teamId})${t.email ? ` <${t.email}>` : ''} [${t.paid ? 'paid' : 'free'}] (only option)`);
1257
+ return { teamId: t.teamId, name: t.name };
1258
+ }
1259
+ const items = teams.map((t) => {
1260
+ const badge = t.paid ? '✓ paid' : '○ free';
1261
+ const email = t.email ? ` <${t.email}>` : '';
1262
+ return `${t.name} (${t.teamId})${email} [${badge}]`;
1263
+ });
1264
+ const idx = arrowSelect('Found multiple Apple teams — pick one to use for signing:', items);
1265
+ return { teamId: teams[idx].teamId, name: teams[idx].name };
1266
+ }
1267
+
1268
+ /** Y/n confirmation on the TTY (default-yes here). Reuses the global `prompt` primitive. A
1269
+ * non-interactive / piped stdin returns null → falls back to `def` ONLY when there's a real TTY;
1270
+ * a fully headless run never reaches here (callers gate on `process.stdout.isTTY` first), but be
1271
+ * defensive: if stdin can't be read, do NOT pin (safer to re-ask than to silently mutate config). */
1272
+ function promptYesNo(message: string, def: boolean): boolean {
1273
+ if (!process.stdin.isTTY) return false;
1274
+ const suffix = def ? ' [Y/n] ' : ' [y/N] ';
1275
+ const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(message + suffix);
1276
+ if (ans == null) return false;
1277
+ const a = ans.trim().toLowerCase();
1278
+ if (a === '') return def;
1279
+ return a === 'y' || a === 'yes';
1280
+ }
1281
+
1282
+ /** Persist `teamId` into the user's appwrap config so the interactive picker isn't re-run every
1283
+ * deploy. Pure string surgery (returns the new file content) so it's unit-testable across both
1284
+ * supported formats:
1285
+ * - `.json` — set/replace the top-level `"teamId"` property (preserves 2-space indent).
1286
+ * - `.ts`/`.js` — replace an existing `teamId:` field value (incl. the `YOUR_APPLE_TEAM_ID`
1287
+ * placeholder), else insert a new `teamId: '<id>',` line near the other top-level fields
1288
+ * (after `id:`, matching its indentation/quote style). If the shape is unexpected, returns
1289
+ * `null` so the caller skips the write rather than corrupting the file. */
1290
+ export function pinTeamIdInConfigSource(src: string, teamId: string, isJson: boolean): string | null {
1291
+ if (isJson) {
1292
+ let obj: Record<string, unknown>;
1293
+ try { obj = JSON.parse(src) as Record<string, unknown>; } catch { return null; }
1294
+ if (typeof obj !== 'object' || obj == null) return null;
1295
+ obj.teamId = teamId;
1296
+ return JSON.stringify(obj, null, 2) + (src.endsWith('\n') ? '\n' : '');
1297
+ }
1298
+ // TS/JS: replace an existing teamId field value, preserving its quote style.
1299
+ const existing = /(\bteamId\s*:\s*)(['"`])[^'"`]*\2/;
1300
+ if (existing.test(src)) {
1301
+ return src.replace(existing, (_m, lead: string, q: string) => `${lead}${q}${teamId}${q}`);
1302
+ }
1303
+ // No teamId field — insert after the `id:` field (mirroring its indentation + quote style).
1304
+ const idLine = /^([ \t]*)id\s*:\s*(['"`])[^'"`]*\2\s*,?[ \t]*$/m;
1305
+ const m = idLine.exec(src);
1306
+ if (!m) return null; // unfamiliar shape — don't risk corrupting it
1307
+ const indent = m[1];
1308
+ const quote = m[2];
1309
+ return src.slice(0, m.index + m[0].length)
1310
+ + `\n${indent}teamId: ${quote}${teamId}${quote},`
1311
+ + src.slice(m.index + m[0].length);
1312
+ }
1313
+
1314
+ /** Write the pinned teamId to the resolved config file (thin IO wrapper over the pure helper). */
1315
+ function pinTeamIdToConfig(configPath: string, teamId: string): void {
1316
+ if (!existsSync(configPath)) {
1317
+ console.warn(` ⚠ could not pin teamId — config not found at ${configPath}`);
1318
+ return;
1319
+ }
1320
+ const src = readFileSync(configPath, 'utf8');
1321
+ const next = pinTeamIdInConfigSource(src, teamId, configPath.endsWith('.json'));
1322
+ if (next == null) {
1323
+ console.warn(` ⚠ couldn't safely edit ${configPath} (unexpected shape) — leaving it untouched. Set teamId: '${teamId}' manually.`);
1324
+ return;
1325
+ }
1326
+ writeFileSync(configPath, next);
1327
+ console.log(` ✓ pinned teamId: '${teamId}' to ${configPath}`);
1328
+ }
1329
+
1330
+ // ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────
1331
+
1332
+ /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
1333
+ * App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
1334
+ * changes and would make the fingerprint permanently stale.
1335
+ * Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
1336
+ function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: string): string {
1337
+ const mtime = (p: string): number => {
1338
+ if (!existsSync(p)) return 0;
1339
+ try {
1340
+ const s = statSync(p);
1341
+ if (s.isDirectory()) {
1342
+ let sum = 0;
1343
+ for (const e of readdirSync(p, { withFileTypes: true }))
1344
+ sum += mtime(join(p, e.name));
1345
+ return sum;
1346
+ }
1347
+ return s.mtimeMs;
1348
+ } catch { return 0; }
1349
+ };
1350
+ const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
1351
+ const parts = [mtime(distDir), mtime(join(cwd, 'appwrap.config.ts'))];
1352
+ // Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
1353
+ let h = 5381;
1354
+ for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
1355
+ return h.toString(36);
1356
+ }
1357
+
1358
+ const BUILD_CACHE_FILE = '.appwrap-build-cache.json';
1359
+ interface BuildCache { fingerprint: string; ipaPath: string; builtAt: string }
1360
+
1361
+ function readBuildCache(outDir: string): BuildCache | null {
1362
+ try { return JSON.parse(readFileSync(join(outDir, BUILD_CACHE_FILE), 'utf8')); } catch { return null; }
1363
+ }
1364
+ function writeBuildCache(outDir: string, cache: BuildCache): void {
1365
+ try { writeFileSync(join(outDir, BUILD_CACHE_FILE), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
1366
+ }
1367
+
1134
1368
  /** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
1135
1369
  * tunnels and non-iOS (watch). Returns [] if none. */
1136
1370
  function listIosDevices(): DeviceInfo[] {
@@ -1199,27 +1433,53 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1199
1433
  await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1200
1434
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1201
1435
  stampShellConfig(outDir, { ...cfg, debug: true });
1202
- console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
1203
- try {
1204
- execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1205
- } catch (e) {
1206
- // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
1207
- console.error(
1208
- '\n✖ Device build failed — if the errors above mention signing:\n' +
1209
- ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
1210
- ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
1211
- ' unique string and re-deploy.\n' +
1212
- ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
1213
- ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
1214
- ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
1215
- );
1216
- process.exit(1);
1217
- }
1218
1436
 
1219
1437
  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
1220
- const ipa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
1438
+
1439
+ // Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
1440
+ // --resume (-r): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
1441
+ // the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
1442
+ // Auto: always checks fingerprint; never skips if sources/deps changed.
1443
+ const resume = 'resume' in flags || 'r' in flags;
1444
+ const force = 'force' in flags || 'f' in flags;
1445
+ const fp = buildFingerprint(cwd, cfg, outDir);
1446
+ const cache = readBuildCache(outDir);
1447
+ const existingIpa = existsSync(ipaDir)
1448
+ ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
1449
+ : undefined;
1450
+ const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.ipaPath === join(ipaDir, existingIpa);
1451
+ // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
1452
+ // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
1453
+ const noCache = existingIpa && !cache;
1454
+ const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));
1455
+
1456
+ if (canSkipBuild) {
1457
+ const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
1458
+ console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
1459
+ } else {
1460
+ console.log(`▶ ns build ios --for-device (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
1461
+ try {
1462
+ execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1463
+ } catch (e) {
1464
+ // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
1465
+ console.error(
1466
+ '\n✖ Device build failed — if the errors above mention signing:\n' +
1467
+ ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
1468
+ ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
1469
+ ' unique string and re-deploy.\n' +
1470
+ ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
1471
+ ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
1472
+ ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
1473
+ );
1474
+ process.exit(1);
1475
+ }
1476
+ }
1477
+
1478
+ const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
1479
+ const ipa = builtIpa;
1221
1480
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
1222
1481
  const ipaPath = join(ipaDir, ipa);
1482
+ if (!canSkipBuild) writeBuildCache(outDir, { fingerprint: fp, ipaPath, builtAt: new Date().toISOString() });
1223
1483
 
1224
1484
  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
1225
1485
  let installedViaUsbmux = false;