@livx.cc/appwrap 0.39.16 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.39.16",
3
+ "version": "0.40.1",
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>
@@ -131,6 +131,21 @@ async function showDevMenu(): Promise<void> {
131
131
  }
132
132
  }
133
133
 
134
+ /** Native build id — iOS `CFBundleVersion` / Android `versionCode`. This is the number the store shows
135
+ * (TestFlight/Play), so it lets a tester confirm exactly which uploaded build is running. */
136
+ function nativeBuild(): string {
137
+ try {
138
+ if (isIOS) return String(NSBundle.mainBundle.objectForInfoDictionaryKey('CFBundleVersion'));
139
+ if (isAndroid) {
140
+ const ctx = Utils.android.getApplicationContext();
141
+ return String(ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0).versionCode);
142
+ }
143
+ } catch (e) {
144
+ console.warn('AppWrap: devmenu nativeBuild read failed', e);
145
+ }
146
+ return '?';
147
+ }
148
+
134
149
  async function showInfo(): Promise<void> {
135
150
  // Running web version: prefer what kit.updates reported, else read the page's embedded global
136
151
  // directly — so the line shows for any server-loader app exposing __APP_VERSION__, even if its
@@ -140,7 +155,8 @@ async function showInfo(): Promise<void> {
140
155
  const lines = [
141
156
  `App: ${SHELL_CONFIG.name}`,
142
157
  `ID: ${SHELL_CONFIG.appId}`,
143
- `Shell: ${SHELL_CONFIG.version} (${SHELL_BUILD})`,
158
+ `Version: ${SHELL_CONFIG.version} (build ${nativeBuild()})`, // native CFBundleVersion/versionCode = the store build id
159
+ `Shell: ${SHELL_BUILD}`,
144
160
  `Platform: ${isIOS ? 'iOS' : 'Android'} ${Device.osVersion}`,
145
161
  `Loader: ${SHELL_CONFIG.loader}`,
146
162
  ];
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
9
9
  * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
10
10
  */
11
- import { execFileSync } from 'child_process';
11
+ import { execFileSync, spawn } from 'child_process';
12
12
  import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
13
13
  import { networkInterfaces, tmpdir } from 'os';
14
14
  import { dirname, join, resolve } from 'path';
@@ -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
  }
@@ -1192,14 +1199,14 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1192
1199
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1193
1200
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1194
1201
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
1195
- console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n Run it: appwrap run ios (or: appwrap run android)`);
1202
+ console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n Run it: appwrap dev ios (or: appwrap dev android)`);
1196
1203
  }
1197
1204
 
1198
1205
  // `sync` = the same regenerate as `init`, minus the first-time guard/scaffold. It is a TRUE refresh from
1199
1206
  // source (shell + config + PWA), so runtime/config edits never silently lag behind. `native/` is
1200
1207
  // disposable; re-copying the shell costs ~ms (the real cost is the later `ns build`, which both share).
1201
- async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
1202
- const cfg = await loadConfig(cwd, flags);
1208
+ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: AppwrapConfig): Promise<void> {
1209
+ const cfg = cfgOverride ?? await loadConfig(cwd, flags);
1203
1210
  const outDir = resolve(cwd, flags.out ?? 'native');
1204
1211
  if (!existsSync(outDir)) {
1205
1212
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
@@ -1221,36 +1228,138 @@ function lanIp(): string | null {
1221
1228
  return null;
1222
1229
  }
1223
1230
 
1224
- /** `appwrap dev` — point the existing wrapper at a LIVE url (loader 'server') instead of bundled www.
1225
- * Dev runs their own web server (vite host:true) or a deployed URL; this just stamps the shell config.
1226
- * `--url <url>` explicit; else http://<lan-ip>:<port> (default 5173). Re-run `appwrap sync`/`init` to revert. */
1227
- async function dev(cwd: string, flags: Record<string, string>): Promise<void> {
1231
+ /** Resolve the `--url <devserver>` / `--port <p>` dev-server URL, or null when neither is given.
1232
+ * Explicit `--url` wins; else `http://<lan-ip>:<port>` (port default 5173). Exits if no LAN IP. */
1233
+ function resolveDevUrl(flags: Record<string, string>): string | null {
1234
+ if (!('url' in flags) && !('port' in flags)) return null;
1235
+ if (flags.url) return flags.url;
1236
+ const ip = lanIp();
1237
+ if (!ip) {
1238
+ console.error('✖ Could not detect a LAN IP — pass --url http://<host>:<port> explicitly');
1239
+ process.exit(1);
1240
+ }
1241
+ return `http://${ip}:${flags.port ?? '5173'}`;
1242
+ }
1243
+
1244
+ /** `--debug` fold-in: open the on-device WebView inspector. Android adb-forwards the devtools socket →
1245
+ * chrome://inspect; iOS prints the Safari Web Inspector path. Best-effort (a non-debug build / not-running
1246
+ * app just gets a hint). Shared by `dev --debug` and the `debug` back-compat alias. */
1247
+ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platform: 'ios' | 'android', outDir: string): void {
1248
+ if (platform === 'android') {
1249
+ const adb = androidAdb();
1250
+ const device = resolveDevice(outDir, 'android', flags).id;
1251
+ const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
1252
+ if (pid) {
1253
+ try {
1254
+ execFileSync(adb, ['-s', device, 'forward', 'tcp:9222', `localabstract:webview_devtools_remote_${pid}`], { stdio: 'pipe' });
1255
+ console.log('✓ WebView devtools forwarded → open chrome://inspect (or http://localhost:9222) in desktop Chrome to inspect the page.');
1256
+ } catch {
1257
+ console.log('⚠ Could not forward the devtools socket — open chrome://inspect and look for the device there.');
1258
+ }
1259
+ } else {
1260
+ console.log(`⚠ ${cfg.id} not running yet — open chrome://inspect once it launches.`);
1261
+ }
1262
+ console.log(' (Needs a DEBUG build — `appwrap dev`/`deploy android` installs one with the inspector enabled.)\n');
1263
+ } else {
1264
+ console.log('▶ iOS WebView inspector: Safari → Develop → [your iPhone] → [the app]. Enable it first in iOS Settings → Safari → Advanced → Web Inspector.\n');
1265
+ }
1266
+ }
1267
+
1268
+ /** `appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]` — the
1269
+ * live-dev loop. Subsumes the old `run`/`debug` verbs AND the old `dev` (loader:server stamp).
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.
1275
+ *
1276
+ * Flags:
1277
+ * --sim → emulator/simulator via `ns run` (HMR is reliable there); `--debug` → `ns debug`.
1278
+ * --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.
1281
+ */
1282
+ async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1283
+ const platform = positionals[0];
1284
+ const sim = 'sim' in flags || positionals[1] === 'sim';
1285
+ const wantDebug = 'debug' in flags;
1286
+ if (platform !== 'ios' && platform !== 'android') {
1287
+ console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]');
1288
+ process.exit(1);
1289
+ }
1228
1290
  const cfg = await loadConfig(cwd, flags);
1229
1291
  const outDir = resolve(cwd, flags.out ?? 'native');
1230
1292
  if (!existsSync(outDir)) {
1231
1293
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1232
1294
  process.exit(1);
1233
1295
  }
1234
- let url = flags.url;
1235
- if (!url) {
1236
- const ip = lanIp();
1237
- if (!ip) {
1238
- console.error('✖ Could not detect a LAN IP — pass --url http://<host>:<port> explicitly');
1239
- process.exit(1);
1296
+
1297
+ // `--url`/`--port` → point the wrapper at a live dev server (loader:'server', web HMR inside the WebView).
1298
+ const devUrl = resolveDevUrl(flags);
1299
+ // The cfg the deploy/sim path stamps: server-loader when a dev URL is given, else the bundled config.
1300
+ // debug:true here = keep-awake + WebView inspector + the dev-server SSL bypass (LAN self-signed certs).
1301
+ const effectiveCfg: AppwrapConfig = devUrl
1302
+ ? { ...cfg, loader: 'server', serverUrl: devUrl, debug: true }
1303
+ : cfg;
1304
+ if (devUrl) {
1305
+ console.log(`✓ Dev loader → ${devUrl} (web hot-reloads from the dev server inside the WebView)`);
1306
+ console.log(' Dev server must bind 0.0.0.0 (vite: `server.host: true` / `--host`) so the device can reach it.');
1307
+ if (devUrl.startsWith('https:') && platform === 'android') {
1308
+ console.log(" ⚠ Android: serve the dev server over HTTP, not HTTPS — the WebView can't bypass wss TLS errors (page loads, HMR won't).");
1240
1309
  }
1241
- url = `http://${ip}:${flags.port ?? '5173'}`;
1242
1310
  }
1243
- // Dev is inherently a debug workflow: enables the WebView inspector, keep-awake, and the
1244
- // debug-only dev-server SSL bypass (LAN dev servers use self-signed/mkcert certs the device
1245
- // doesn't trust). Revert to a non-debug, bundled build with `appwrap sync`.
1246
- stampShellConfig(outDir, { ...cfg, loader: 'server', serverUrl: url, debug: true });
1247
- console.log(`✓ Dev loader → ${url} (debug)`);
1248
- console.log(` Web server must bind 0.0.0.0 (vite: \`server.host: true\` / \`--host\`) so the device can reach it.`);
1249
- if (url.startsWith('https:')) {
1250
- console.log(` ⚠ Android: serve the dev server over HTTP, not HTTPS — the WebView can't bypass wss TLS`);
1251
- console.log(` errors, so HMR won't live-reload on-device (the page still loads). iOS is fine with HTTPS.`);
1311
+
1312
+ // ── --sim: emulator/simulator → ns run (HMR) / ns debug. Reliable there; refresh the wrapper first. ──
1313
+ if (sim) {
1314
+ // Preserve an already-active dev loader (stamped by a prior `dev --url`) if no URL was passed now.
1315
+ const stamped = !devUrl ? readStampedLoader(outDir) : null;
1316
+ const simCfg: AppwrapConfig = devUrl
1317
+ ? effectiveCfg
1318
+ : stamped?.loader === 'server'
1319
+ ? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
1320
+ : cfg;
1321
+ regenerateCore(cwd, outDir, simCfg, { flags });
1322
+ applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
1323
+ stampVersionManifest(outDir, simCfg);
1324
+ console.log(simCfg.loader === 'server' ? `✓ Refreshed wrapper (dev loader → ${simCfg.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
1325
+ runNs(outDir, [wantDebug ? 'debug' : 'run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
1326
+ return;
1327
+ }
1328
+
1329
+ // ── device: clean deploy (the shared `deploy` path — NO ns livesync) ──
1330
+ await deploy(cwd, flags, [platform], devUrl ? effectiveCfg : undefined);
1331
+ // Follow-ups reuse the just-deployed device from last-device memory — drop an interactive `-d`.
1332
+ const followFlags = { ...flags }; delete followFlags.d;
1333
+
1334
+ if (wantDebug) openInspector(effectiveCfg, followFlags, platform, outDir);
1335
+
1336
+ if ('detached' in flags) {
1337
+ console.log('\n✓ --detached — installed & launched; not attaching/watching.');
1338
+ return;
1339
+ }
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
+ if (devUrl) {
1345
+ console.log('\n▶ dev: web hot-reloads from the dev server inside the WebView; streaming the console. Ctrl-C to stop.');
1346
+ await logs(cwd, followFlags, [platform]);
1347
+ return;
1252
1348
  }
1253
- console.log(` Then: appwrap run ios (revert with \`appwrap sync\`)`);
1349
+ console.log(`\n▶ dev: streaming WebView console + watching sources (edit a file → rebuild+reinstall). Ctrl-C to stop.`);
1350
+ const logArgs = [import.meta.path, 'logs', platform];
1351
+ if (followFlags.device) logArgs.push('--device', followFlags.device);
1352
+ if (followFlags.out) logArgs.push('--out', followFlags.out);
1353
+ 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.
1358
+ 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); });
1362
+ await watchAndRedeploy(cwd, followFlags, platform);
1254
1363
  }
1255
1364
 
1256
1365
  /** Ensure the wrapper's deps are installed (bun — honoring the repo's package manager, so callers
@@ -1301,7 +1410,7 @@ function runNs(outDir: string, args: string[]): void {
1301
1410
  }
1302
1411
 
1303
1412
  /** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
1304
- * preserve an ACTIVE `dev` loader across `run` (see run()'s footgun note). Returns null if the
1413
+ * preserve an ACTIVE dev loader across a `dev --sim` refresh. Returns null if the
1305
1414
  * generated config is absent/unreadable — the caller then falls back to the appwrap config. */
1306
1415
  function readStampedLoader(outDir: string): { loader: string; serverUrl: string; debug: boolean } | null {
1307
1416
  try {
@@ -1318,38 +1427,26 @@ function readStampedLoader(outDir: string): { loader: string; serverUrl: string;
1318
1427
  }
1319
1428
  }
1320
1429
 
1321
- /** `appwrap run <ios|android> [--device <id|name>]` — compile + boot the wrapper in a
1322
- * simulator/emulator (or named device) with live reload: the appwrap-managed replacement for raw
1323
- * `ns run`, driven from the PWA project root with deps auto-installed.
1324
- *
1325
- * Refreshes the generated wrapper from the source template + PWA first (same regenerateCore as
1326
- * `sync`/`build`) so framework `runtime/` edits and PWA rebuilds actually reach the device — `run`
1327
- * used to skip this and silently ship the STALE generated shell (the run-without-sync footgun). An
1328
- * ACTIVE `dev` loader (loader:'server', stamped by `appwrap dev`) is preserved so dev→run
1329
- * live-reload isn't clobbered back to the bundled loader. */
1330
- async function run(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1331
- const platform = positionals[0];
1332
- if (platform !== 'ios' && platform !== 'android') {
1333
- console.error('Usage: appwrap run <ios|android> [--device <id|name>] [--out native]');
1334
- process.exit(1);
1335
- }
1336
- const outDir = resolve(cwd, flags.out ?? 'native');
1337
- if (!existsSync(outDir)) {
1338
- console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1339
- process.exit(1);
1340
- }
1341
- const cfg = await loadConfig(cwd, flags);
1342
- const stamped = readStampedLoader(outDir);
1343
- const devActive = stamped?.loader === 'server';
1344
- // Preserve a live dev loader; otherwise regenerate from the appwrap config (bundled loader).
1345
- const effectiveCfg = devActive
1346
- ? { ...cfg, loader: 'server' as const, serverUrl: stamped!.serverUrl, debug: stamped!.debug }
1347
- : cfg;
1348
- regenerateCore(cwd, outDir, effectiveCfg, { flags });
1349
- applyOverrides(cwd, outDir, effectiveCfg); // overrides win last — same order as sync (else run wipes them)
1350
- stampVersionManifest(outDir, effectiveCfg); // keep the managed-marker / provenance current
1351
- console.log(devActive ? `✓ Refreshed wrapper (preserved dev loader → ${stamped!.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
1352
- runNs(outDir, ['run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
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. */
1433
+ async function watchAndRedeploy(cwd: string, flags: Record<string, string>, platform: 'ios' | 'android'): Promise<void> {
1434
+ const { watch } = await import('fs');
1435
+ const ignore = /(^|\/)(native|node_modules|dist|\.git|\.appwrap)(\/|$)/;
1436
+ console.log(`\n👀 watching ${cwd} for changes → rebuild+reinstall on save (Ctrl-C to stop).`);
1437
+ let timer: ReturnType<typeof setTimeout> | undefined;
1438
+ let busy = false;
1439
+ watch(cwd, { recursive: true }, (_evt, file) => {
1440
+ if (!file || ignore.test(String(file)) || busy) return;
1441
+ clearTimeout(timer);
1442
+ timer = setTimeout(async () => {
1443
+ busy = true;
1444
+ console.log(`\n🔁 change: ${file} → redeploying…`);
1445
+ try { await deploy(cwd, flags, [platform]); } catch (e) { console.error(`⚠ redeploy failed: ${(e as Error).message}`); }
1446
+ busy = false;
1447
+ }, 600);
1448
+ });
1449
+ await new Promise<void>(() => { /* run until Ctrl-C */ });
1353
1450
  }
1354
1451
 
1355
1452
  /** `appwrap build <ios|android> [--release] [--aab]` — store-readiness build path. Re-stamps config,
@@ -1748,36 +1845,85 @@ function listIosDevices(): DeviceInfo[] {
1748
1845
  }
1749
1846
  }
1750
1847
 
1751
- /** Pick a device: explicit --device wins; else auto-select the only one; else list + prompt. */
1752
- function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1753
- if (explicitId) {
1754
- const m = devices.find((d) => d.id === explicitId || d.name === explicitId);
1755
- if (!m) { console.error(`✖ --device "${explicitId}" not found among connected devices.`); process.exit(1); }
1756
- return m;
1757
- }
1758
- if (devices.length === 0) {
1759
- console.error('✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.');
1848
+ // ─── Shared device resolver ───────────────────────────────────────────────────────────────────────
1849
+ // One helper used by deploy/run/debug/logs/publish so every command shares the SAME device-selection
1850
+ // UX + last-device memory. Resolution order:
1851
+ // --device <id|name> → exact match (error if not connected)
1852
+ // -d → always show the interactive list + number prompt
1853
+ // else → the LAST chosen device (persisted) if still connected; else the only one;
1854
+ // else the interactive list; none → clear error. The choice is persisted on
1855
+ // success so the next command (e.g. `run`→`logs`) reuses it without re-asking.
1856
+ // Persist the last device under the wrapper outDir (already gitignored by consumers, like the build
1857
+ // cache) — not the consumer root, so it never shows up as a stray untracked file.
1858
+ const lastDeviceFile = (outDir: string, platform: string) => join(outDir, `.appwrap-last-device-${platform}`);
1859
+ function readLastDevice(outDir: string, platform: string): string | null {
1860
+ try { return readFileSync(lastDeviceFile(outDir, platform), 'utf8').trim() || null; } catch { return null; }
1861
+ }
1862
+ function writeLastDevice(outDir: string, platform: string, id: string): void {
1863
+ try { writeFileSync(lastDeviceFile(outDir, platform), id); } catch { /* non-fatal */ }
1864
+ }
1865
+
1866
+ /** Enumerate connected devices for a platform as a uniform DeviceInfo[] (iOS via devicectl, Android
1867
+ * via adb — adb serials enriched with the product model for a readable picker). */
1868
+ function listDevices(platform: 'ios' | 'android'): DeviceInfo[] {
1869
+ if (platform === 'ios') return listIosDevices();
1870
+ const adb = androidAdb();
1871
+ return listAndroidDevices(adb).map((serial) => {
1872
+ let model = '';
1873
+ 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' };
1875
+ });
1876
+ }
1877
+
1878
+ /** Interactive number-prompt picker over a device list. */
1879
+ function pickInteractively(devices: DeviceInfo[]): DeviceInfo {
1880
+ // No TTY (CI / piped) → Bun's prompt() returns null → "Invalid selection". Give a clear directive instead.
1881
+ if (!process.stdout.isTTY) {
1882
+ console.error(`✖ ${devices.length} devices connected and no TTY to prompt — pass --device <id>. Connected: ${devices.map((d) => d.id).join(', ')}`);
1760
1883
  process.exit(1);
1761
1884
  }
1762
- if (devices.length === 1) {
1763
- console.log(`📱 Using ${devices[0].name} (${devices[0].model || devices[0].transport})`);
1764
- return devices[0];
1765
- }
1766
- console.log('Multiple devices connected:');
1767
- devices.forEach((d, i) => console.log(` ${i + 1}) ${d.name} — ${d.model || 'iPhone'} [${d.transport}]`));
1885
+ console.log('Connected devices:');
1886
+ devices.forEach((d, i) => console.log(` ${i + 1}) ${d.name}${d.model && d.model !== d.name ? ` — ${d.model}` : ''}${d.transport ? ` [${d.transport}]` : ''} (${d.id})`));
1768
1887
  const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(`Select device [1-${devices.length}]: `);
1769
1888
  const idx = Number(ans) - 1;
1770
- if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) {
1771
- console.error('✖ Invalid selection.'); process.exit(1);
1772
- }
1889
+ if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) { console.error('✖ Invalid selection.'); process.exit(1); }
1773
1890
  return devices[idx];
1774
1891
  }
1775
1892
 
1893
+ /** Resolve the target device for a platform command (the reusable core). Persists the choice under
1894
+ * `outDir` so the next command (e.g. `run`→`logs`) reuses it. */
1895
+ function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Record<string, string>): DeviceInfo {
1896
+ const devices = listDevices(platform);
1897
+ const noneMsg = platform === 'ios'
1898
+ ? '✖ 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`).';
1900
+
1901
+ // --device <id|name> — exact (or unambiguous prefix) match against connected devices.
1902
+ if (flags.device) {
1903
+ const m = devices.find((d) => d.id === flags.device || d.name === flags.device) ?? devices.find((d) => d.id.startsWith(flags.device));
1904
+ if (!m) { console.error(`✖ --device "${flags.device}" not connected/authorized. Connected: ${devices.map((d) => d.id).join(', ') || '(none)'}`); process.exit(1); }
1905
+ writeLastDevice(outDir, platform, m.id);
1906
+ return m;
1907
+ }
1908
+ if (devices.length === 0) { console.error(noneMsg); process.exit(1); }
1909
+
1910
+ // -d → always prompt. Otherwise prefer the remembered device, then the sole device.
1911
+ if (!('d' in flags)) {
1912
+ const last = readLastDevice(outDir, platform);
1913
+ const remembered = last ? devices.find((d) => d.id === last) : undefined;
1914
+ if (remembered) { console.log(`📱 Using ${remembered.name} (${remembered.id}) — last used.`); return remembered; }
1915
+ if (devices.length === 1) { console.log(`📱 Using ${devices[0].name} (${devices[0].id}) — only device connected.`); writeLastDevice(outDir, platform, devices[0].id); return devices[0]; }
1916
+ }
1917
+ const picked = pickInteractively(devices);
1918
+ writeLastDevice(outDir, platform, picked.id);
1919
+ return picked;
1920
+ }
1921
+
1776
1922
  /** `appwrap deploy <ios|android> [--device <id|name>] [--no-launch]` — build for a device, auto-pick
1777
1923
  * the connected phone (USB or network; prompts if several), install + launch. Debug build (no
1778
1924
  * distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
1779
- * iOS has a bespoke Debug-IPA path (below); Android delegates to `run` (NativeScript builds + installs
1780
- * + launches), so the `deploy <platform>` surface is symmetric across both. */
1925
+ * iOS has a bespoke Debug-IPA path (below); Android uses the adb toolchain (build → install → launch).
1926
+ * `dev` calls THIS shared path for its clean device deploy — deploy is the one-shot ship primitive. */
1781
1927
  /** The project's web-build command: explicit `webBuild` in config, else `bun run build` if the
1782
1928
  * project's package.json has a "build" script. null when there's nothing to run. */
1783
1929
  function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
@@ -1835,42 +1981,22 @@ function listAndroidDevices(adb: string): string[] {
1835
1981
  }
1836
1982
  }
1837
1983
 
1838
- /** Pick the target Android device (fail fast, like iOS's pickDevice): explicit --device, else the
1839
- * single connected one; clear errors for none / multiple / unauthorized. */
1840
- function pickAndroidDevice(adb: string, flag?: string): string {
1841
- const devices = listAndroidDevices(adb);
1842
- if (flag) {
1843
- if (devices.includes(flag)) return flag;
1844
- console.error(`✖ Device "${flag}" not connected/authorized. Authorized: ${devices.join(', ') || '(none)'}`);
1845
- process.exit(1);
1846
- }
1847
- if (devices.length === 0) {
1848
- console.error('✖ No authorized Android device. Connect via USB + accept the "Allow USB debugging" prompt (check with `adb devices`).');
1849
- process.exit(1);
1850
- }
1851
- if (devices.length > 1) {
1852
- console.error(`✖ Multiple devices — pass --device <id>: ${devices.join(', ')}`);
1853
- process.exit(1);
1854
- }
1855
- return devices[0];
1856
- }
1857
-
1858
1984
  /** `appwrap deploy android` — ONE-SHOT device deploy, the Android twin of `deploy ios`: sync + debug
1859
1985
  * config → `ns build android` (debug APK) → `adb install -r` to the device → launch (unless --no-launch)
1860
1986
  * → exit. Unlike `run android` (ns watch-mode), it doesn't stay attached. NOTE: like `deploy ios`, it
1861
1987
  * ships the CURRENT `dist/` — build the web first (`bun run build`, or use the `bun run android` script). */
1862
- async function deployAndroid(cwd: string, flags: Record<string, string>): Promise<void> {
1863
- const cfg = await loadConfig(cwd, flags);
1988
+ async function deployAndroid(cwd: string, flags: Record<string, string>, cfgOverride?: AppwrapConfig): Promise<void> {
1989
+ const cfg = cfgOverride ?? await loadConfig(cwd, flags);
1864
1990
  const outDir = resolve(cwd, flags.out ?? 'native');
1865
1991
  if (!existsSync(outDir)) {
1866
1992
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1867
1993
  process.exit(1);
1868
1994
  }
1869
1995
  const adb = androidAdb();
1870
- const device = pickAndroidDevice(adb, flags.device || undefined); // fail fast before the build
1996
+ const device = resolveDevice(outDir, 'android', flags).id; // shared resolver (fail fast before the build)
1871
1997
 
1872
1998
  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle; server → skip (both printed)
1873
- await sync(cwd, flags); // re-stamp config + copy latest PWA dist
1999
+ await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist
1874
2000
  stampShellConfig(outDir, { ...cfg, debug: true }); // debug: keep-awake + WebView inspector (parity with deploy ios)
1875
2001
 
1876
2002
  const apk = join(outDir, 'platforms/android/app/build/outputs/apk/debug/app-debug.apk');
@@ -1923,28 +2049,28 @@ async function deployAndroid(cwd: string, flags: Record<string, string>): Promis
1923
2049
  console.log(`✓ Deployed to ${device}.`);
1924
2050
  }
1925
2051
 
1926
- async function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2052
+ async function deploy(cwd: string, flags: Record<string, string>, positionals: string[], cfgOverride?: AppwrapConfig): Promise<void> {
1927
2053
  const platform = positionals[0];
1928
2054
  if (platform === 'android') {
1929
2055
  // Parity with `deploy ios`: a clean ONE-SHOT build → install-to-device → launch → exit (NOT `ns run`,
1930
2056
  // which is watch-mode + hangs on some devices). Mirrors the iOS path with the adb toolchain.
1931
- return deployAndroid(cwd, flags);
2057
+ return deployAndroid(cwd, flags, cfgOverride);
1932
2058
  }
1933
2059
  if (platform !== 'ios') {
1934
2060
  console.error('Usage: appwrap deploy <ios|android> [--device <id|name>] [--no-launch]');
1935
2061
  process.exit(1);
1936
2062
  }
1937
- const cfg = await loadConfig(cwd, flags);
2063
+ const cfg = cfgOverride ?? await loadConfig(cwd, flags);
1938
2064
  const outDir = resolve(cwd, flags.out ?? 'native');
1939
2065
  if (!existsSync(outDir)) {
1940
2066
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1941
2067
  process.exit(1);
1942
2068
  }
1943
2069
  // Pick the device up front so we fail fast before a long build if nothing's connected.
1944
- const device = pickDevice(listIosDevices(), flags.device || undefined);
2070
+ const device = resolveDevice(outDir, 'ios', flags);
1945
2071
 
1946
2072
  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle (no stale dist); server → skip (printed)
1947
- await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
2073
+ await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1948
2074
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1949
2075
  stampShellConfig(outDir, { ...cfg, debug: true });
1950
2076
 
@@ -2127,11 +2253,12 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2127
2253
  process.exit(1);
2128
2254
  }
2129
2255
  const cfg = await loadConfig(cwd, flags);
2256
+ const outDir = resolve(cwd, flags.out ?? 'native'); // for last-device memory (shared resolver)
2130
2257
 
2131
2258
  // ── Android: adb logcat — WebView console (chromium tag) by default; --native = full app logcat ──
2132
2259
  if (platform === 'android') {
2133
2260
  const adb = androidAdb();
2134
- const device = pickAndroidDevice(adb, flags.device || undefined);
2261
+ const device = resolveDevice(outDir, 'android', flags).id;
2135
2262
  const once = 'once' in flags;
2136
2263
  if ('native' in flags) {
2137
2264
  const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
@@ -2159,7 +2286,7 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2159
2286
  return;
2160
2287
  }
2161
2288
 
2162
- const device = pickDevice(listIosDevices(), flags.device || undefined);
2289
+ const device = resolveDevice(outDir, 'ios', flags);
2163
2290
  const dest = join(tmpdir(), `appwrap-weblog-${process.pid}.log`);
2164
2291
  const pull = (): string => {
2165
2292
  try {
@@ -2191,40 +2318,53 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2191
2318
  }
2192
2319
  }
2193
2320
 
2194
- /** CLI dispatch. Guarded by `import.meta.main` so importing this module (e.g. for the `AppwrapConfig`
2195
- * type via the package entry) doesn't run a command. */
2196
- /** `appwrap debug <ios|android>` — point at the WebView inspector for the (debug-built) app, then
2197
- * stream its console. Debug builds enable the inspector (deploy installs one); this just opens the
2198
- * door + tails logs — no rebuild. Android: adb-forwards the WebView devtools socket → chrome://inspect.
2199
- * iOS: prints the Safari Web Inspector path. Both then fall through to `logs` for the live console. */
2200
- async function debug(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2321
+ /** `appwrap publish <ios|android> [prod]` — distribution. DEFAULT = BETA (iOS TestFlight via the
2322
+ * proven `release` lane; Android → Play internal track via the mcp-appstores `android-upload` CLI).
2323
+ * `prod` → store (iOS App Store via `submit`; Android Play production track). Consolidates the existing
2324
+ * `release`/`submit` (kept as aliases). Android upload reuses the same contract as the CI release
2325
+ * workflow: a signed AAB from `build android --release --aab` + `APPSTORES_REGISTRY` env for the Play
2326
+ * service-account/package mapping (see the emitted appwrap-release-android.yml). */
2327
+ async function publish(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2201
2328
  const platform = positionals[0];
2329
+ const prod = positionals[1] === 'prod';
2202
2330
  if (platform !== 'ios' && platform !== 'android') {
2203
- console.error('Usage: appwrap debug <ios|android> [--device <id|name>]');
2331
+ console.error('Usage: appwrap publish <ios|android> [prod] (default: beta — TestFlight / Play internal)');
2204
2332
  process.exit(1);
2205
2333
  }
2206
- const cfg = await loadConfig(cwd, flags);
2207
- if (platform === 'android') {
2208
- const adb = androidAdb();
2209
- const device = pickAndroidDevice(adb, flags.device || undefined);
2210
- const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
2211
- if (pid) {
2212
- try {
2213
- execFileSync(adb, ['-s', device, 'forward', 'tcp:9222', `localabstract:webview_devtools_remote_${pid}`], { stdio: 'pipe' });
2214
- console.log('✓ WebView devtools forwarded → open chrome://inspect (or http://localhost:9222) in desktop Chrome to inspect the page.');
2215
- } catch {
2216
- console.log('⚠ Could not forward the devtools socket — open chrome://inspect and look for the device there.');
2217
- }
2218
- } else {
2219
- console.log(`⚠ ${cfg.id} not running — launch it (or \`appwrap deploy android\`), then open chrome://inspect.`);
2220
- }
2221
- console.log(' (Needs a DEBUG build — `appwrap deploy android` installs one with the inspector enabled.)\n');
2222
- } else {
2223
- console.log('▶ iOS WebView inspector: Safari → Develop → [your iPhone] → [the app]. Enable it first in iOS Settings → Safari → Advanced → Web Inspector.');
2224
- console.log(' (Needs a DEBUG build — `appwrap deploy ios` installs one.)\n');
2334
+ if (platform === 'ios') {
2335
+ // iOS rides the proven fastlane path unchanged: beta → TestFlight, prod → App Store promote.
2336
+ return release(cwd, flags, ['ios'], prod ? 'release' : 'beta');
2337
+ }
2338
+
2339
+ // ── Android: build a signed AAB, then upload via the mcp-appstores CLI (Play Developer API). ──
2340
+ const track = flags.track || (prod ? 'production' : 'internal');
2341
+ console.log(`▶ appwrap build android --release --aab (for Play ${track} track)`);
2342
+ await build(cwd, { ...flags, release: '', aab: '' }, ['android']);
2343
+ const aab = join(resolve(cwd, flags.out ?? 'native'), 'platforms/android/app/build/outputs/bundle/release/app-release.aab');
2344
+ if (!existsSync(aab)) { console.error(`✖ No AAB produced at ${aab}`); process.exit(1); }
2345
+
2346
+ if (!process.env.APPSTORES_REGISTRY) {
2347
+ console.error(
2348
+ '\n✖ Android publish needs the Play upload contract (same as the CI release workflow):\n' +
2349
+ ' • APPSTORES_REGISTRY env — JSON mapping org→serviceAccountPath + app→packageName.\n' +
2350
+ ' • A Play service-account JSON + the app already created in the Play Console (one prior manual release).\n' +
2351
+ ` The signed AAB is ready: ${aab}\n` +
2352
+ ' Then: APPSTORES_ALLOW_WRITES=true bunx @livx.cc/mcp-appstores android-upload \\\n' +
2353
+ ` --org <org> --app <app> --file "${aab}" --track ${track} --status completed`
2354
+ );
2355
+ process.exit(1);
2356
+ }
2357
+ const org = flags.org || (await loadConfig(cwd, flags)).id;
2358
+ const app = flags.app || 'app';
2359
+ console.log(`▶ bunx @livx.cc/mcp-appstores android-upload --org ${org} --app ${app} --track ${track}`);
2360
+ try {
2361
+ execFileSync('bunx', ['@livx.cc/mcp-appstores', 'android-upload', '--org', org, '--app', app, '--file', aab, '--track', track, '--status', 'completed'],
2362
+ { cwd, stdio: 'inherit', env: { ...process.env, APPSTORES_ALLOW_WRITES: process.env.APPSTORES_ALLOW_WRITES ?? 'true' } });
2363
+ } catch {
2364
+ console.error(`\n✖ Play upload failed. Check APPSTORES_REGISTRY (org "${org}", app "${app}") + the service-account permissions. The AAB is ready: ${aab}`);
2365
+ process.exit(1);
2225
2366
  }
2226
- // Tail the live console (reuses logs' per-platform streaming).
2227
- await logs(cwd, flags, positionals);
2367
+ console.log(`✓ Uploaded to Play ${track} track.`);
2228
2368
  }
2229
2369
 
2230
2370
  async function main(): Promise<void> {
@@ -2239,10 +2379,10 @@ async function main(): Promise<void> {
2239
2379
  await sync(cwd, flags);
2240
2380
  break;
2241
2381
  case 'dev':
2242
- await dev(cwd, flags);
2382
+ await dev(cwd, flags, positionals);
2243
2383
  break;
2244
- case 'run':
2245
- await run(cwd, flags, positionals);
2384
+ case 'run': // hidden back-compat alias → dev
2385
+ await dev(cwd, flags, positionals);
2246
2386
  break;
2247
2387
  case 'build':
2248
2388
  await build(cwd, flags, positionals);
@@ -2250,29 +2390,34 @@ async function main(): Promise<void> {
2250
2390
  case 'deploy':
2251
2391
  await deploy(cwd, flags, positionals);
2252
2392
  break;
2253
- case 'release':
2393
+ case 'publish':
2394
+ await publish(cwd, flags, positionals);
2395
+ break;
2396
+ case 'release': // alias: publish <ios|android> (beta)
2254
2397
  await release(cwd, flags, positionals, 'beta');
2255
2398
  break;
2256
- case 'submit':
2399
+ case 'submit': // alias: publish <ios> prod
2257
2400
  await release(cwd, flags, positionals, 'release');
2258
2401
  break;
2259
2402
  case 'logs':
2260
2403
  await logs(cwd, flags, positionals);
2261
2404
  break;
2262
- case 'debug':
2263
- await debug(cwd, flags, positionals);
2405
+ case 'debug': // hidden back-compat alias → dev --debug
2406
+ await dev(cwd, { ...flags, debug: '' }, positionals);
2264
2407
  break;
2265
2408
  default:
2266
- console.log('Usage: appwrap <init|sync|dev|run|build|deploy|release|submit|logs|debug> [--config <path>] [--out native]\n' +
2409
+ console.log('Usage: appwrap <init|sync|dev|build|deploy|publish|logs> [--config <path>] [--out native]\n' +
2267
2410
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
2268
- ' run <ios|android> [--device <id|name>] (compile + boot in a simulator/emulator, live reload)\n' +
2269
- ' deploy <ios|android> [--device <id|name>] [--no-launch] [--no-web-build] [-f] (build → install to device → launch; builds web if bundled)\n' +
2270
- ' build <ios|android> [--release] [--aab] (store artifact)\n' +
2271
- ' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
2272
- ' submit ios [--build-number <n>] [--submit-for-review] (promote the binary to the App Store; metadata stays in ASC)\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' +
2416
+ ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
2417
+ ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
2418
+ ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
2273
2419
  ' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
2274
- ' debug <ios|android> [--device <id|name>] (open the WebView inspector + stream console)\n' +
2275
- ' dev [--url <url> | --port <p>]');
2420
+ ' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
2276
2421
  process.exit(command ? 1 : 0);
2277
2422
  }
2278
2423
  }
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 +