@livx.cc/appwrap 0.40.1 → 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.1",
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",
@@ -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
@@ -1268,23 +1268,26 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
1268
1268
  /** `appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]` — the
1269
1269
  * live-dev loop. Subsumes the old `run`/`debug` verbs AND the old `dev` (loader:server stamp).
1270
1270
  *
1271
- * Default target = the physical DEVICE: clean deploy (== `deploy`, the shared path — NOT reimplemented)
1272
- * → stay ATTACHED streaming the WebView console + watch project sources → rebuild+reinstall on save.
1273
- * We MUST NOT use `ns run` livesync on a device — it throws `Invalid version … Got type "object"`, an
1274
- * 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).
1275
1278
  *
1276
1279
  * Flags:
1277
1280
  * --sim → emulator/simulator via `ns run` (HMR is reliable there); `--debug` → `ns debug`.
1278
1281
  * --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 → 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.
1281
1284
  */
1282
1285
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1283
1286
  const platform = positionals[0];
1284
1287
  const sim = 'sim' in flags || positionals[1] === 'sim';
1285
1288
  const wantDebug = 'debug' in flags;
1286
1289
  if (platform !== 'ios' && platform !== 'android') {
1287
- 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>]');
1288
1291
  process.exit(1);
1289
1292
  }
1290
1293
  const cfg = await loadConfig(cwd, flags);
@@ -1326,39 +1329,62 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
1326
1329
  return;
1327
1330
  }
1328
1331
 
1329
- // ── 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 ──
1330
1362
  await deploy(cwd, flags, [platform], devUrl ? effectiveCfg : undefined);
1331
1363
  // Follow-ups reuse the just-deployed device from last-device memory — drop an interactive `-d`.
1332
1364
  const followFlags = { ...flags }; delete followFlags.d;
1333
-
1334
1365
  if (wantDebug) openInspector(effectiveCfg, followFlags, platform, outDir);
1335
-
1336
1366
  if ('detached' in flags) {
1337
1367
  console.log('\n✓ --detached — installed & launched; not attaching/watching.');
1338
1368
  return;
1339
1369
  }
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
1370
  if (devUrl) {
1371
+ // --url: the web hot-reloads from the dev server INSIDE the WebView; just stream the console.
1345
1372
  console.log('\n▶ dev: web hot-reloads from the dev server inside the WebView; streaming the console. Ctrl-C to stop.');
1346
1373
  await logs(cwd, followFlags, [platform]);
1347
1374
  return;
1348
1375
  }
1349
- 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.');
1350
1379
  const logArgs = [import.meta.path, 'logs', platform];
1351
1380
  if (followFlags.device) logArgs.push('--device', followFlags.device);
1352
1381
  if (followFlags.out) logArgs.push('--out', followFlags.out);
1353
1382
  if (followFlags.config) logArgs.push('--config', followFlags.config);
1354
- // `detached: true` puts the log child in its OWN process group so we can kill the WHOLE group —
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.
1383
+ // `detached: true` → own process group so we reap the whole tree (bun → adb/idevicesyslog) on Ctrl-C.
1358
1384
  const logChild = spawn('bun', logArgs, { stdio: 'inherit', detached: true });
1359
- const stop = () => { try { if (logChild.pid) process.kill(-logChild.pid); } catch { /* already gone */ } };
1360
- process.on('exit', stop);
1361
- 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); });
1362
1388
  await watchAndRedeploy(cwd, followFlags, platform);
1363
1389
  }
1364
1390
 
@@ -1386,7 +1412,9 @@ function resolveAndroidSdk(): string | undefined {
1386
1412
  return candidates.find((d) => existsSync(join(d, 'platform-tools')) || existsSync(join(d, 'platforms')));
1387
1413
  }
1388
1414
 
1389
- 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 {
1390
1418
  const env: NodeJS.ProcessEnv = { ...process.env };
1391
1419
  // Android: inject a discovered SDK so `ns` finds it even when the user's shell never exported
1392
1420
  // ANDROID_HOME (new terminal not sourced, conda base shell, etc.) — the common deploy blocker.
@@ -1405,6 +1433,11 @@ function runNs(outDir: string, args: string[]): void {
1405
1433
  console.log(`▶ bun install (cwd: ${outDir})`);
1406
1434
  execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env });
1407
1435
  }
1436
+ return env;
1437
+ }
1438
+
1439
+ function runNs(outDir: string, args: string[]): void {
1440
+ const env = prepareNsEnv(outDir, args);
1408
1441
  console.log(`▶ ns ${args.join(' ')} (cwd: ${outDir})`);
1409
1442
  execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
1410
1443
  }
@@ -1427,22 +1460,58 @@ function readStampedLoader(outDir: string): { loader: string; serverUrl: string;
1427
1460
  }
1428
1461
  }
1429
1462
 
1430
- /** Lean watch loop for `dev <platform>`: re-run the clean deploy path whenever a project
1431
- * source file changes (debounced). Skips generated/output dirs. NOT ns livesync — a full rebuild+
1432
- * 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.) */
1433
1466
  async function watchAndRedeploy(cwd: string, flags: Record<string, string>, platform: 'ios' | 'android'): Promise<void> {
1434
1467
  const { watch } = await import('fs');
1435
- const ignore = /(^|\/)(native|node_modules|dist|\.git|\.appwrap)(\/|$)/;
1468
+ const ignore = /(^|\/)(native|node_modules|dist|public|\.git|\.appwrap)(\/|$)/;
1436
1469
  console.log(`\n👀 watching ${cwd} for changes → rebuild+reinstall on save (Ctrl-C to stop).`);
1437
1470
  let timer: ReturnType<typeof setTimeout> | undefined;
1438
1471
  let busy = false;
1472
+ let quietUntil = 0;
1439
1473
  watch(cwd, { recursive: true }, (_evt, file) => {
1440
- if (!file || ignore.test(String(file)) || busy) return;
1474
+ if (!file || ignore.test(String(file)) || busy || Date.now() < quietUntil) return;
1441
1475
  clearTimeout(timer);
1442
1476
  timer = setTimeout(async () => {
1443
1477
  busy = true;
1444
1478
  console.log(`\n🔁 change: ${file} → redeploying…`);
1445
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;
1446
1515
  busy = false;
1447
1516
  }, 600);
1448
1517
  });
@@ -1871,7 +1940,8 @@ function listDevices(platform: 'ios' | 'android'): DeviceInfo[] {
1871
1940
  return listAndroidDevices(adb).map((serial) => {
1872
1941
  let model = '';
1873
1942
  try { model = execFileSync(adb, ['-s', serial, 'shell', 'getprop', 'ro.product.model'], { encoding: 'utf8' }).trim(); } catch { /* offline */ }
1874
- 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' };
1875
1945
  });
1876
1946
  }
1877
1947
 
@@ -1893,10 +1963,21 @@ function pickInteractively(devices: DeviceInfo[]): DeviceInfo {
1893
1963
  /** Resolve the target device for a platform command (the reusable core). Persists the choice under
1894
1964
  * `outDir` so the next command (e.g. `run`→`logs`) reuses it. */
1895
1965
  function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Record<string, string>): DeviceInfo {
1896
- 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);
1897
1978
  const noneMsg = platform === 'ios'
1898
1979
  ? '✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.'
1899
- : '✖ 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`)';
1900
1981
 
1901
1982
  // --device <id|name> — exact (or unambiguous prefix) match against connected devices.
1902
1983
  if (flags.device) {
@@ -1905,6 +1986,22 @@ function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Recor
1905
1986
  writeLastDevice(outDir, platform, m.id);
1906
1987
  return m;
1907
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
+
1908
2005
  if (devices.length === 0) { console.error(noneMsg); process.exit(1); }
1909
2006
 
1910
2007
  // -d → always prompt. Otherwise prefer the remembered device, then the sole device.
@@ -1968,6 +2065,86 @@ function androidAdb(): string {
1968
2065
  return 'adb';
1969
2066
  }
1970
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
+
1971
2148
  /** Authorized (`device` state) adb serials. Skips `unauthorized`/`offline`. */
1972
2149
  function listAndroidDevices(adb: string): string[] {
1973
2150
  try {
@@ -2408,11 +2585,14 @@ async function main(): Promise<void> {
2408
2585
  default:
2409
2586
  console.log('Usage: appwrap <init|sync|dev|build|deploy|publish|logs> [--config <path>] [--out native]\n' +
2410
2587
  ' 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\n' +
2412
- ' dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]\n' +
2413
- ' live-dev: DEVICE → clean deploy + stream console + watch sources (rebuild on save).\n' +
2414
- ' --sim = ns run/HMR on emulator; --url/--port = web HMR from a dev server inside the WebView;\n' +
2415
- ' --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' +
2416
2596
  ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
2417
2597
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
2418
2598
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +