@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 +1 -1
- package/runtime/App_Resources/iOS/Info.plist +4 -6
- package/runtime/package.json +1 -0
- package/src/cli.ts +235 -48
- package/src/config.ts +5 -0
- package/src/derive.ts +23 -11
package/package.json
CHANGED
|
@@ -50,11 +50,9 @@
|
|
|
50
50
|
<key>NSAllowsLocalNetworking</key>
|
|
51
51
|
<true/>
|
|
52
52
|
</dict>
|
|
53
|
-
<!--
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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>
|
package/runtime/package.json
CHANGED
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
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// (
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
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 →
|
|
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:
|
|
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
|
-
|
|
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`
|
|
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
|
|
1353
|
-
process.on('exit',
|
|
1354
|
-
process.on('SIGINT', () => {
|
|
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
|
-
|
|
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
|
-
/**
|
|
1424
|
-
* source
|
|
1425
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
2405
|
-
'
|
|
2406
|
-
'
|
|
2407
|
-
' --
|
|
2408
|
-
'
|
|
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)
|
|
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
|
|
211
|
-
//
|
|
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 +
|