@livx.cc/appwrap 0.40.0 → 0.41.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.40.0",
3
+ "version": "0.41.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",
@@ -50,11 +50,9 @@
50
50
  <key>NSAllowsLocalNetworking</key>
51
51
  <true/>
52
52
  </dict>
53
- <!-- Background audio so music-player PWAs keep playing when backgrounded /
54
- screen-locked. Remove for apps that never play audio in the background. -->
55
- <key>UIBackgroundModes</key>
56
- <array>
57
- <string>audio</string>
58
- </array>
53
+ <!-- No UIBackgroundModes by default. Declaring a mode with no matching feature is an App Store
54
+ 2.5.4 rejection, so modules stamp only what they need: push → `remote-notification`,
55
+ backgroundTask → `fetch`+`processing`, config `backgroundAudio:true` → `audio` (each creates
56
+ the key if absent). Keep the default clean; apps opt into capabilities in appwrap.config. -->
59
57
  </dict>
60
58
  </plist>
@@ -12,6 +12,7 @@
12
12
  "@nativescript/secure-storage": "^4.0.1"
13
13
  },
14
14
  "devDependencies": {
15
+ "@nativescript/android": "9.0.4",
15
16
  "@nativescript/ios": "9.0.2",
16
17
  "@nativescript/types": "~9.0.0",
17
18
  "@nativescript/webpack": "~5.0.25",
package/src/cli.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  stampPlistBackgroundTasks,
34
34
  stampPlistOrientations,
35
35
  stampPrivacyTracking,
36
+ stripEmptyBackgroundModes,
36
37
  } from './derive';
37
38
  import type { WebManifest } from './derive';
38
39
 
@@ -510,19 +511,25 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
510
511
  );
511
512
  }
512
513
 
513
- // Remote push needs the `remote-notification` background mode. The template already ships a
514
- // UIBackgroundModes array (for `audio`), so MERGE in-place — a second <key> would be a duplicate
515
- // (invalid plist). Idempotent both ways: add when enabled+missing, strip when disabled.
516
- const iosPush = !!cfg.push?.enabled && cfg.push?.ios !== false;
514
+ // UIBackgroundModes is opt-in per module/config (the template ships none). Toggle each mode in the
515
+ // shared array — a second <key> would be a duplicate (invalid plist). Idempotent both ways: MERGE
516
+ // in-place (creating the key when absent) if wanted, strip when not.
517
517
  const bgArray = /(<key>UIBackgroundModes<\/key>\s*<array>)([\s\S]*?)(<\/array>)/;
518
- const hasRN = /<string>remote-notification<\/string>/.test(src);
519
- if (iosPush && !hasRN) {
520
- src = bgArray.test(src)
521
- ? src.replace(bgArray, (_m, open, inner, close) => `${open}${inner}\t<string>remote-notification</string>\n\t${close}`)
522
- : src.replace(/<\/dict>\s*<\/plist>\s*$/, ` <key>UIBackgroundModes</key>\n <array>\n <string>remote-notification</string>\n </array>\n</dict>\n</plist>\n`);
523
- } else if (!iosPush && hasRN) {
524
- src = src.replace(/\s*<string>remote-notification<\/string>/, '');
525
- }
518
+ const toggleBgMode = (s: string, mode: string, want: boolean): string => {
519
+ const has = new RegExp(`<string>${mode}</string>`).test(s);
520
+ if (want && !has) {
521
+ return bgArray.test(s)
522
+ ? s.replace(bgArray, (_m, open, inner, close) => `${open}${inner}\t<string>${mode}</string>\n\t${close}`)
523
+ : s.replace(/<\/dict>\s*<\/plist>\s*$/, ` <key>UIBackgroundModes</key>\n <array>\n <string>${mode}</string>\n </array>\n</dict>\n</plist>\n`);
524
+ }
525
+ if (!want && has) return s.replace(new RegExp(`\\s*<string>${mode}</string>`), '');
526
+ return s;
527
+ };
528
+ // Remote push needs `remote-notification`; apps that genuinely play audio in the background opt in
529
+ // via `backgroundAudio: true` (Apple 2.5.4 rejects `audio` without a real background-audio feature).
530
+ src = toggleBgMode(src, 'remote-notification', !!cfg.push?.enabled && cfg.push?.ios !== false);
531
+ src = toggleBgMode(src, 'audio', !!cfg.backgroundAudio);
532
+ src = stripEmptyBackgroundModes(src);
526
533
 
527
534
  writeFileSync(plist, src);
528
535
  }
@@ -1261,23 +1268,26 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
1261
1268
  /** `appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]` — the
1262
1269
  * live-dev loop. Subsumes the old `run`/`debug` verbs AND the old `dev` (loader:server stamp).
1263
1270
  *
1264
- * Default target = the physical DEVICE: clean deploy (== `deploy`, the shared path — NOT reimplemented)
1265
- * → stay ATTACHED streaming the WebView console + watch project sources → rebuild+reinstall on save.
1266
- * We MUST NOT use `ns run` livesync on a device — it throws `Invalid version … Got type "object"`, an
1267
- * ns-internal semver bug we can't fix; so device-dev is deploy + logs + a plain rebuild watch loop.
1271
+ * Default target = the physical DEVICE.
1272
+ * • ANDROID: `ns run` livesync for true on-device HMR (incremental, no full reinstall) + a source
1273
+ * watcher that rebuilds the web & re-stages www on save so PWA edits flow into the livesync. The old
1274
+ * "Invalid version … Got type object" crash that made this look unfixable was just the shell
1275
+ * package.json failing to declare @nativescript/android → ns read the runtime version as null.
1276
+ * • iOS: the proven deploy + redeploy-on-save loop (NOT ns run — `ns run ios --device` hits the
1277
+ * personal-team signing/registration path `deploy ios` handles bespokely; pending device-verify).
1268
1278
  *
1269
1279
  * Flags:
1270
1280
  * --sim → emulator/simulator via `ns run` (HMR is reliable there); `--debug` → `ns debug`.
1271
1281
  * --url/--port→ stamp loader:'server' at that dev-server URL (web hot-reloads inside the WebView), deploy + attach.
1272
- * --detached → deploy + exit (install & launch only; don't attach/watch).
1273
- * --debug → also open the WebView inspector (chrome://inspect / Safari), then attach.
1282
+ * --detached → deploy + exit (install & launch only; don't attach/watch — the MIUI-safe `--user 0` install).
1283
+ * --debug → android: `ns debug` (inspector); iOS/url: open the WebView inspector then attach.
1274
1284
  */
1275
1285
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1276
1286
  const platform = positionals[0];
1277
1287
  const sim = 'sim' in flags || positionals[1] === 'sim';
1278
1288
  const wantDebug = 'debug' in flags;
1279
1289
  if (platform !== 'ios' && platform !== 'android') {
1280
- console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]');
1290
+ console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
1281
1291
  process.exit(1);
1282
1292
  }
1283
1293
  const cfg = await loadConfig(cwd, flags);
@@ -1319,39 +1329,62 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
1319
1329
  return;
1320
1330
  }
1321
1331
 
1322
- // ── device: clean deploy (the shared `deploy` path — NO ns livesync) ──
1332
+ // ── ANDROID device + bundled loader: ns run livesync = true on-device HMR (incremental, no reinstall) ──
1333
+ // Unblocked by declaring @nativescript/android in the shell package.json: ns reads the android runtime
1334
+ // version on the livesync path; when it's undeclared that lookup returns null → `semver.gt(null, …)` →
1335
+ // the "Invalid version … Got type object" crash that long made on-device livesync look unfixable.
1336
+ // ns watches native/app + native/www-src; since the PWA SOURCE lives OUTSIDE native/, we run a source
1337
+ // watcher alongside that rebuilds the web + re-stages www on save — ns's livesync then pushes it.
1338
+ // (Must spawn ns async, not execFileSync: a sync exec freezes the fs.watch loop.)
1339
+ // iOS is intentionally NOT on ns run here: `ns run ios --device` hits the personal-team device
1340
+ // registration/signing path that `deploy ios` handles bespokely — unverified, so iOS keeps the proven
1341
+ // deploy + redeploy-on-save loop below until it's device-verified.
1342
+ if (platform === 'android' && !devUrl && !('detached' in flags)) {
1343
+ const device = resolveDevice(outDir, 'android', flags);
1344
+ // MIUI/Xiaomi auto-denies a bare `adb install`; only `--user 0` works (deploy uses it). Pre-installing
1345
+ // via deploy establishes the package so `ns run`'s subsequent install lands as an allowed UPDATE
1346
+ // rather than a blocked fresh bare-install — the device-verified path. (~one extra fast install.)
1347
+ await deploy(cwd, { ...flags, 'no-launch': '' }, ['android'], cfg);
1348
+ const nsArgs = [wantDebug ? 'debug' : 'run', 'android', '--device', device.id];
1349
+ const env = prepareNsEnv(outDir, nsArgs);
1350
+ console.log(`\n▶ dev: ns ${nsArgs.join(' ')} (on-device HMR) + watching sources → re-stage on save. Ctrl-C to stop.`);
1351
+ // Own process group so Ctrl-C / kill reaps ns AND its grandchildren (gradle, adb logcat, webpack).
1352
+ const nsChild = spawn('ns', nsArgs, { cwd: outDir, stdio: 'inherit', detached: true, env });
1353
+ const stop = () => { try { if (nsChild.pid) process.kill(-nsChild.pid); } catch { /* already gone */ } };
1354
+ process.on('exit', stop);
1355
+ process.on('SIGINT', () => { stop(); process.exit(0); });
1356
+ nsChild.on('exit', (code) => process.exit(code ?? 0));
1357
+ await watchAndSync(cwd, flags, outDir, cfg); // rebuild + re-stage on save; ns livesync pushes it
1358
+ return;
1359
+ }
1360
+
1361
+ // ── iOS device (bundled), --url, or --detached: the proven one-shot deploy path ──
1323
1362
  await deploy(cwd, flags, [platform], devUrl ? effectiveCfg : undefined);
1324
1363
  // Follow-ups reuse the just-deployed device from last-device memory — drop an interactive `-d`.
1325
1364
  const followFlags = { ...flags }; delete followFlags.d;
1326
-
1327
1365
  if (wantDebug) openInspector(effectiveCfg, followFlags, platform, outDir);
1328
-
1329
1366
  if ('detached' in flags) {
1330
1367
  console.log('\n✓ --detached — installed & launched; not attaching/watching.');
1331
1368
  return;
1332
1369
  }
1333
-
1334
- // Attach: stream the WebView console. With a bundled loader we ALSO watch sources → rebuild+reinstall
1335
- // on save. With a dev-server loader (--url) the web hot-reloads from the server INSIDE the WebView, so
1336
- // a native rebuild is pointless (and would re-stamp the bundled loader) — just stream the console.
1337
1370
  if (devUrl) {
1371
+ // --url: the web hot-reloads from the dev server INSIDE the WebView; just stream the console.
1338
1372
  console.log('\n▶ dev: web hot-reloads from the dev server inside the WebView; streaming the console. Ctrl-C to stop.');
1339
1373
  await logs(cwd, followFlags, [platform]);
1340
1374
  return;
1341
1375
  }
1342
- console.log(`\n▶ dev: streaming WebView console + watching sources (edit a file → rebuild+reinstall). Ctrl-C to stop.`);
1376
+ // iOS bundled device: stream the WebView console + redeploy (rebuild+reinstall) on save. No ns livesync
1377
+ // on an iOS device yet (see above), so a full redeploy is the device-safe refresh path.
1378
+ console.log('\n▶ dev: streaming WebView console + watching sources (edit a file → rebuild+reinstall). Ctrl-C to stop.');
1343
1379
  const logArgs = [import.meta.path, 'logs', platform];
1344
1380
  if (followFlags.device) logArgs.push('--device', followFlags.device);
1345
1381
  if (followFlags.out) logArgs.push('--out', followFlags.out);
1346
1382
  if (followFlags.config) logArgs.push('--config', followFlags.config);
1347
- // `detached: true` puts the log child in its OWN process group so we can kill the WHOLE group —
1348
- // the child is `bun … logs`, which itself spawns `adb logcat`; `logChild.kill()` would only reap the
1349
- // `bun` and orphan the `adb logcat` grandchild when the signal hits the leader pid (e.g. `kill <pid>`
1350
- // / a supervisor, not interactive Ctrl-C which signals the group). `process.kill(-pid)` reaps both.
1383
+ // `detached: true` → own process group so we reap the whole tree (bun → adb/idevicesyslog) on Ctrl-C.
1351
1384
  const logChild = spawn('bun', logArgs, { stdio: 'inherit', detached: true });
1352
- const stop = () => { try { if (logChild.pid) process.kill(-logChild.pid); } catch { /* already gone */ } };
1353
- process.on('exit', stop);
1354
- process.on('SIGINT', () => { stop(); process.exit(0); });
1385
+ const stopLog = () => { try { if (logChild.pid) process.kill(-logChild.pid); } catch { /* already gone */ } };
1386
+ process.on('exit', stopLog);
1387
+ process.on('SIGINT', () => { stopLog(); process.exit(0); });
1355
1388
  await watchAndRedeploy(cwd, followFlags, platform);
1356
1389
  }
1357
1390
 
@@ -1379,7 +1412,9 @@ function resolveAndroidSdk(): string | undefined {
1379
1412
  return candidates.find((d) => existsSync(join(d, 'platform-tools')) || existsSync(join(d, 'platforms')));
1380
1413
  }
1381
1414
 
1382
- function runNs(outDir: string, args: string[]): void {
1415
+ /** Build the env for an `ns` invocation in `outDir` (auto-detect Android SDK, ensure bun-installed deps).
1416
+ * Shared by the blocking `runNs` (sim) and the non-blocking spawn in `dev` (device livesync). */
1417
+ function prepareNsEnv(outDir: string, args: string[]): NodeJS.ProcessEnv {
1383
1418
  const env: NodeJS.ProcessEnv = { ...process.env };
1384
1419
  // Android: inject a discovered SDK so `ns` finds it even when the user's shell never exported
1385
1420
  // ANDROID_HOME (new terminal not sourced, conda base shell, etc.) — the common deploy blocker.
@@ -1398,6 +1433,11 @@ function runNs(outDir: string, args: string[]): void {
1398
1433
  console.log(`▶ bun install (cwd: ${outDir})`);
1399
1434
  execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env });
1400
1435
  }
1436
+ return env;
1437
+ }
1438
+
1439
+ function runNs(outDir: string, args: string[]): void {
1440
+ const env = prepareNsEnv(outDir, args);
1401
1441
  console.log(`▶ ns ${args.join(' ')} (cwd: ${outDir})`);
1402
1442
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
1403
1443
  }
@@ -1420,22 +1460,58 @@ function readStampedLoader(outDir: string): { loader: string; serverUrl: string;
1420
1460
  }
1421
1461
  }
1422
1462
 
1423
- /** Lean watch loop for `dev <platform>`: re-run the clean deploy path whenever a project
1424
- * source file changes (debounced). Skips generated/output dirs. NOT ns livesync — a full rebuild+
1425
- * reinstall, which is the only device-safe path (see `run`'s note). macOS recursive fs.watch. */
1463
+ /** Watch loop for iOS `dev` (no on-device ns livesync yet): re-run the clean deploy path on a project
1464
+ * source change (debounced) — a full rebuild+reinstall, the device-safe refresh for iOS. Skips
1465
+ * generated/output dirs. macOS recursive fs.watch. (Android uses watchAndSync + ns livesync instead.) */
1426
1466
  async function watchAndRedeploy(cwd: string, flags: Record<string, string>, platform: 'ios' | 'android'): Promise<void> {
1427
1467
  const { watch } = await import('fs');
1428
- const ignore = /(^|\/)(native|node_modules|dist|\.git|\.appwrap)(\/|$)/;
1468
+ const ignore = /(^|\/)(native|node_modules|dist|public|\.git|\.appwrap)(\/|$)/;
1429
1469
  console.log(`\n👀 watching ${cwd} for changes → rebuild+reinstall on save (Ctrl-C to stop).`);
1430
1470
  let timer: ReturnType<typeof setTimeout> | undefined;
1431
1471
  let busy = false;
1472
+ let quietUntil = 0;
1432
1473
  watch(cwd, { recursive: true }, (_evt, file) => {
1433
- if (!file || ignore.test(String(file)) || busy) return;
1474
+ if (!file || ignore.test(String(file)) || busy || Date.now() < quietUntil) return;
1434
1475
  clearTimeout(timer);
1435
1476
  timer = setTimeout(async () => {
1436
1477
  busy = true;
1437
1478
  console.log(`\n🔁 change: ${file} → redeploying…`);
1438
1479
  try { await deploy(cwd, flags, [platform]); } catch (e) { console.error(`⚠ redeploy failed: ${(e as Error).message}`); }
1480
+ quietUntil = Date.now() + 1500;
1481
+ busy = false;
1482
+ }, 600);
1483
+ });
1484
+ await new Promise<void>(() => { /* run until Ctrl-C */ });
1485
+ }
1486
+
1487
+ /** Lean watch loop for Android `dev`: on a project source change, rebuild the web + RE-STAGE only
1488
+ * the web bundle (dist → native/www-src) — debounced. Deliberately NOT a full `regenerateCore`: that
1489
+ * re-copies App_Resources/package.json and makes ns do a full native rebuild+reinstall (defeating HMR
1490
+ * and tripping MIUI). Staging www-src only keeps the `ns run` livesync on the JS hot-push path. The
1491
+ * watcher sees the PROJECT dir, so it only catches PWA source edits (the shell template lives elsewhere).
1492
+ * Skips generated/output dirs. macOS recursive fs.watch. */
1493
+ async function watchAndSync(cwd: string, flags: Record<string, string>, outDir: string, cfg: AppwrapConfig): Promise<void> {
1494
+ const { watch } = await import('fs');
1495
+ // Skip generated/output trees. `dist` is the web build output; `public` is where many build steps
1496
+ // ALSO emit (e.g. a copied bundle / stamped index) — both must be ignored or the rebuild's own writes
1497
+ // re-trigger the watch in a loop. A post-rebuild cooldown is the generic backstop for any other
1498
+ // output dir we don't know about (the project's build target is project-specific).
1499
+ const ignore = /(^|\/)(native|node_modules|dist|public|\.git|\.appwrap)(\/|$)/;
1500
+ console.log(`👀 watching ${cwd} → rebuild + re-stage on save (ns livesync pushes it).`);
1501
+ let timer: ReturnType<typeof setTimeout> | undefined;
1502
+ let busy = false;
1503
+ let quietUntil = 0; // ignore events for a beat after a rebuild — its own file writes aren't user edits
1504
+ watch(cwd, { recursive: true }, (_evt, file) => {
1505
+ if (!file || ignore.test(String(file)) || busy || Date.now() < quietUntil) return;
1506
+ clearTimeout(timer);
1507
+ timer = setTimeout(() => {
1508
+ busy = true;
1509
+ console.log(`\n🔁 change: ${file} → rebuild web + re-stage www…`);
1510
+ try {
1511
+ buildWebIfBundled(cwd, cfg, flags);
1512
+ copyPwa(cwd, outDir, cfg); // stage dist → www-src only; ns livesync hot-pushes it (no reinstall)
1513
+ } catch (e) { console.error(`⚠ re-stage failed: ${(e as Error).message}`); }
1514
+ quietUntil = Date.now() + 1500;
1439
1515
  busy = false;
1440
1516
  }, 600);
1441
1517
  });
@@ -1864,7 +1940,8 @@ function listDevices(platform: 'ios' | 'android'): DeviceInfo[] {
1864
1940
  return listAndroidDevices(adb).map((serial) => {
1865
1941
  let model = '';
1866
1942
  try { model = execFileSync(adb, ['-s', serial, 'shell', 'getprop', 'ro.product.model'], { encoding: 'utf8' }).trim(); } catch { /* offline */ }
1867
- return { id: serial, name: model || serial, model, transport: 'usb' };
1943
+ // A network adb serial is `host:port` (USB serials never contain ':') → label it wifi.
1944
+ return { id: serial, name: model || serial, model, transport: serial.includes(':') ? 'wifi' : 'usb' };
1868
1945
  });
1869
1946
  }
1870
1947
 
@@ -1886,10 +1963,21 @@ function pickInteractively(devices: DeviceInfo[]): DeviceInfo {
1886
1963
  /** Resolve the target device for a platform command (the reusable core). Persists the choice under
1887
1964
  * `outDir` so the next command (e.g. `run`→`logs`) reuses it. */
1888
1965
  function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Record<string, string>): DeviceInfo {
1889
- const devices = listDevices(platform);
1966
+ const adb = platform === 'android' ? androidAdb() : '';
1967
+
1968
+ // --wifi (android): flip a USB device to wireless adb (or reconnect a remembered one), then target it.
1969
+ if (platform === 'android' && 'wifi' in flags) enableWifiAdb(adb, outDir, flags);
1970
+
1971
+ // --device <ip[:port]> (android): if it's a network target that isn't attached yet, `adb connect` it.
1972
+ if (platform === 'android' && flags.device && looksLikeAdbHost(flags.device)) {
1973
+ const target = withAdbPort(flags.device);
1974
+ if (!listAndroidDevices(adb).includes(target)) { adbConnect(adb, target); flags.device = target; }
1975
+ }
1976
+
1977
+ let devices = listDevices(platform);
1890
1978
  const noneMsg = platform === 'ios'
1891
1979
  ? '✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.'
1892
- : '✖ No authorized Android device. Connect via USB + accept the "Allow USB debugging" prompt (check with `adb devices`).';
1980
+ : '✖ 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`)';
1893
1981
 
1894
1982
  // --device <id|name> — exact (or unambiguous prefix) match against connected devices.
1895
1983
  if (flags.device) {
@@ -1898,6 +1986,22 @@ function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Recor
1898
1986
  writeLastDevice(outDir, platform, m.id);
1899
1987
  return m;
1900
1988
  }
1989
+
1990
+ // Android: nothing attached but a wireless device was remembered → auto-reconnect it (survives sleep /
1991
+ // USB-unplug, so plain `appwrap dev android` keeps working cordless after the first `--wifi`).
1992
+ if (platform === 'android' && !('d' in flags)) {
1993
+ const last = readLastDevice(outDir, 'android');
1994
+ if (last && last.includes(':') && !devices.find((d) => d.id === last) && adbConnect(adb, last)) devices = listDevices(platform);
1995
+ }
1996
+
1997
+ // Android passive discovery (iOS parity): still nothing → pick up any mDNS-advertised wireless device
1998
+ // (tcpip / "Wireless debugging" on) and adb-connect it, so plain `dev android` finds it with no flag —
1999
+ // the same zero-config a network-paired iPhone gets from devicectl.
2000
+ if (platform === 'android' && devices.length === 0 && !flags.device) {
2001
+ const found = androidMdnsTargets(adb).filter((t) => !listAndroidDevices(adb).includes(t) && adbConnect(adb, t));
2002
+ if (found.length) devices = listDevices(platform);
2003
+ }
2004
+
1901
2005
  if (devices.length === 0) { console.error(noneMsg); process.exit(1); }
1902
2006
 
1903
2007
  // -d → always prompt. Otherwise prefer the remembered device, then the sole device.
@@ -1961,6 +2065,86 @@ function androidAdb(): string {
1961
2065
  return 'adb';
1962
2066
  }
1963
2067
 
2068
+ /** Synchronous sleep — the device-resolution path is all sync execFileSync, so we can't await. */
2069
+ function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
2070
+
2071
+ /** A `--device` value that looks like a network target (ip / hostname[:port]) vs a USB serial — USB
2072
+ * adb serials are bare alphanumerics, never containing a '.' (ip/host) or ':' (host:port). */
2073
+ function looksLikeAdbHost(s: string): boolean { return s.includes('.') || s.includes(':'); }
2074
+ /** Normalize a wireless target to host:port (adb's default tcpip port is 5555). */
2075
+ function withAdbPort(host: string): string { return /:\d+$/.test(host) ? host : `${host}:5555`; }
2076
+
2077
+ /** `adb connect <target>` — true if connected (or already was). Prints the outcome. */
2078
+ function adbConnect(adb: string, target: string): boolean {
2079
+ try {
2080
+ const out = execFileSync(adb, ['connect', target], { encoding: 'utf8' }).trim();
2081
+ const ok = /connected to|already connected/i.test(out);
2082
+ console.log(ok ? `🔗 ${out}` : `⚠ adb connect ${target}: ${out}`);
2083
+ return ok;
2084
+ } catch (e) {
2085
+ console.error(`⚠ adb connect ${target} failed: ${execErrText(e).trim()}`);
2086
+ return false;
2087
+ }
2088
+ }
2089
+
2090
+ /** mDNS-discovered wireless adb targets (`ip:port`) — the passive path that matches iOS devicectl's
2091
+ * network listing. The device advertises once tcpip is on (`--wifi`) or Android-11+ "Wireless debugging"
2092
+ * is enabled, so `appwrap dev android` finds it with NO flag (parity with `dev ios` over the network). */
2093
+ function androidMdnsTargets(adb: string): string[] {
2094
+ try {
2095
+ return execFileSync(adb, ['mdns', 'services'], { encoding: 'utf8', timeout: 8000 })
2096
+ .split('\n')
2097
+ .map((l) => l.match(/(\d+\.\d+\.\d+\.\d+:\d+)\s*$/)?.[1])
2098
+ .filter((x): x is string => !!x);
2099
+ } catch { return []; }
2100
+ }
2101
+
2102
+ /** Read a USB-connected device's Wi-Fi (wlan0) IPv4, or null if it isn't on Wi-Fi. */
2103
+ function androidWifiIp(adb: string, serial: string): string | null {
2104
+ for (const args of [
2105
+ ['-s', serial, 'shell', 'ip', '-o', 'route', 'get', '1.1.1.1'], // "... src 192.168.1.50"
2106
+ ['-s', serial, 'shell', 'ip', '-o', '-f', 'inet', 'addr', 'show', 'wlan0'], // "inet 192.168.1.50/24"
2107
+ ]) {
2108
+ try {
2109
+ const m = execFileSync(adb, args, { encoding: 'utf8' }).match(/(?:src|inet)\s+(\d+\.\d+\.\d+\.\d+)/);
2110
+ if (m && !m[1].startsWith('127.')) return m[1];
2111
+ } catch { /* try next */ }
2112
+ }
2113
+ return null;
2114
+ }
2115
+
2116
+ /** `--wifi`: flip a USB-connected device into TCP/IP mode and `adb connect` it over the LAN, so the user
2117
+ * can unplug and keep iterating cordless. If nothing's on USB but a wireless device was remembered, just
2118
+ * reconnect that. Sets `flags.device` to the wireless target (and clears `wifi`) so the rest of the
2119
+ * resolve/deploy path — and any later resolveDevice call — targets it without re-flipping. */
2120
+ function enableWifiAdb(adb: string, outDir: string, flags: Record<string, string>): void {
2121
+ const usb = listAndroidDevices(adb).filter((s) => !s.includes(':')); // USB-attached serials only
2122
+ if (usb.length === 0) {
2123
+ const last = readLastDevice(outDir, 'android');
2124
+ if (last && last.includes(':') && adbConnect(adb, last)) { flags.device = last; delete flags.wifi; return; }
2125
+ 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.');
2126
+ process.exit(1);
2127
+ }
2128
+ const serial = flags.device && usb.includes(flags.device) ? flags.device : usb[0];
2129
+ if (usb.length > 1 && serial === usb[0] && !(flags.device && usb.includes(flags.device))) {
2130
+ console.log(` (multiple USB devices; flipping ${serial} — pass --device <serial> to choose another)`);
2131
+ }
2132
+ const ip = androidWifiIp(adb, serial);
2133
+ 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); }
2134
+ console.log(`📶 ${serial}: enabling wireless adb on :5555…`);
2135
+ try { execFileSync(adb, ['-s', serial, 'tcpip', '5555'], { stdio: 'pipe' }); }
2136
+ catch (e) { console.error(`✖ adb tcpip failed: ${execErrText(e).trim()}`); process.exit(1); }
2137
+ const target = `${ip}:5555`;
2138
+ // tcpip restarts adbd on the device — connect with a few retries while it comes back up.
2139
+ let connected = false;
2140
+ for (let i = 0; i < 6 && !connected; i++) { sleepSync(700); connected = adbConnect(adb, target); }
2141
+ if (!connected) { console.error(`✖ Couldn't connect to ${target} after tcpip — same Wi-Fi network? firewall blocking :5555?`); process.exit(1); }
2142
+ writeLastDevice(outDir, 'android', target);
2143
+ console.log(`✓ Wireless adb ready → ${target}. You can unplug USB now.`);
2144
+ flags.device = target;
2145
+ delete flags.wifi;
2146
+ }
2147
+
1964
2148
  /** Authorized (`device` state) adb serials. Skips `unauthorized`/`offline`. */
1965
2149
  function listAndroidDevices(adb: string): string[] {
1966
2150
  try {
@@ -2401,11 +2585,14 @@ async function main(): Promise<void> {
2401
2585
  default:
2402
2586
  console.log('Usage: appwrap <init|sync|dev|build|deploy|publish|logs> [--config <path>] [--out native]\n' +
2403
2587
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
2404
- ' Device selection (dev/deploy/logs/publish): --device <id|name> | -d (pick from a list) | else last-used / sole device.\n\n' +
2405
- ' dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]\n' +
2406
- ' live-dev: DEVICE → clean deploy + stream console + watch sources (rebuild on save).\n' +
2407
- ' --sim = ns run/HMR on emulator; --url/--port = web HMR from a dev server inside the WebView;\n' +
2408
- ' --detached = install & launch then exit; --debug = also open the WebView inspector.\n' +
2588
+ ' Device selection (dev/deploy/logs/publish): --device <id|name|ip[:port]> | -d (pick from a list) | else last-used / sole device.\n' +
2589
+ ' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
2590
+ ' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
2591
+ ' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
2592
+ ' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
2593
+ ' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
2594
+ ' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
2595
+ ' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
2409
2596
  ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
2410
2597
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
2411
2598
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
package/src/config.ts CHANGED
@@ -164,6 +164,11 @@ export interface AppwrapConfig {
164
164
  * same ids are what `kit.backgroundTask.register(id, …)` / `.schedule({id})` use. No-op when absent
165
165
  * or the module is inactive. */
166
166
  backgroundTasks?: string[];
167
+ /** Opt in to the `audio` UIBackgroundMode — ONLY for apps that genuinely keep playing audio while
168
+ * backgrounded/screen-locked (music/streaming/podcast players). Off by default: declaring `audio`
169
+ * without a real background-audio feature is an App Store 2.5.4 rejection. Stamps `audio` into
170
+ * Info.plist UIBackgroundModes when true; no-op/stripped when absent. */
171
+ backgroundAudio?: boolean;
167
172
  /** Remote push (APNs/FCM). Off unless set — gating matters: an `aps-environment` entitlement on a
168
173
  * team that can't hold the Push capability (e.g. a personal team) BREAKS code signing, and the
169
174
  * handshake should honestly report `push: 'none'` on an un-provisioned build. The kit returns a raw
package/src/derive.ts CHANGED
@@ -195,20 +195,15 @@ export function stampAndroidOrientation(src: string, value: string): string {
195
195
  * `remote-notification`). `ids` empty/undefined → strips the block + removes the two modes it added.
196
196
  */
197
197
  export function stampPlistBackgroundTasks(src: string, ids: string[] | undefined): string {
198
- // 1) Always rewrite the marker block (permitted identifiers). Strip first → idempotent.
198
+ // 1) Strip the marker block first (idempotent). It is re-added LAST (step 3) so its position is
199
+ // stable regardless of whether the UIBackgroundModes array below was pre-existing or freshly
200
+ // created — otherwise the marker and a created array flip order between runs (non-idempotent).
199
201
  src = src.replace(/\s*<!-- appwrap:bgtask -->[\s\S]*?<!-- \/appwrap:bgtask -->/g, '');
200
202
  const list = (ids ?? []).filter(Boolean);
201
- if (list.length) {
202
- const items = list.map((s) => ` <string>${s}</string>`).join('\n');
203
- const block =
204
- ` <!-- appwrap:bgtask -->\n` +
205
- ` <key>BGTaskSchedulerPermittedIdentifiers</key>\n <array>\n${items}\n </array>\n` +
206
- ` <!-- /appwrap:bgtask -->`;
207
- src = src.replace(/<\/dict>\s*<\/plist>\s*$/, `${block}\n</dict>\n</plist>\n`);
208
- }
209
203
 
210
- // 2) Merge/remove the fetch + processing background modes (separate from the marker — they live in
211
- // the shared UIBackgroundModes array, which may also hold audio/remote-notification).
204
+ // 2) Merge/remove the fetch + processing background modes — they live in the shared
205
+ // UIBackgroundModes array (which may also hold audio/remote-notification). Create the key when
206
+ // absent; strip the two modes (and, via stripEmptyBackgroundModes, the emptied key) when off.
212
207
  const modes = ['fetch', 'processing'];
213
208
  const bgArray = /(<key>UIBackgroundModes<\/key>\s*<array>)([\s\S]*?)(<\/array>)/;
214
209
  if (list.length) {
@@ -225,9 +220,26 @@ export function stampPlistBackgroundTasks(src: string, ids: string[] | undefined
225
220
  } else {
226
221
  for (const m of modes) src = src.replace(new RegExp(`\\s*<string>${m}</string>`), '');
227
222
  }
223
+ src = stripEmptyBackgroundModes(src);
224
+
225
+ // 3) Re-add the permitted-identifiers marker block LAST, right before </dict> (stable position).
226
+ if (list.length) {
227
+ const items = list.map((s) => ` <string>${s}</string>`).join('\n');
228
+ const block =
229
+ ` <!-- appwrap:bgtask -->\n` +
230
+ ` <key>BGTaskSchedulerPermittedIdentifiers</key>\n <array>\n${items}\n </array>\n` +
231
+ ` <!-- /appwrap:bgtask -->`;
232
+ src = src.replace(/<\/dict>\s*<\/plist>\s*$/, `${block}\n</dict>\n</plist>\n`);
233
+ }
228
234
  return src;
229
235
  }
230
236
 
237
+ /** Remove a now-empty `UIBackgroundModes` key+array so a plist that opts into no modes carries no
238
+ * dangling empty key (keeps the default tidy). Pure + idempotent; no-op when the array has members. */
239
+ export function stripEmptyBackgroundModes(src: string): string {
240
+ return src.replace(/\s*<key>UIBackgroundModes<\/key>\s*<array>\s*<\/array>/g, '');
241
+ }
242
+
231
243
  /**
232
244
  * Stamp (or strip) `WKAppBoundDomains` in an Info.plist string — Apple's gate for running a service
233
245
  * worker inside a WKWebView (paired with `limitsNavigationsToAppBoundDomains` on the config). Pure +