@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 +1 -1
- package/runtime/App_Resources/iOS/Info.plist +4 -6
- package/runtime/app/shell/devmenu.ts +17 -1
- package/src/cli.ts +310 -165
- 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>
|
|
@@ -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
|
-
`
|
|
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
|
-
//
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
1225
|
-
*
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1763
|
-
|
|
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
|
|
1780
|
-
*
|
|
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
|
|
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 =
|
|
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);
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
2195
|
-
*
|
|
2196
|
-
|
|
2197
|
-
*
|
|
2198
|
-
*
|
|
2199
|
-
*
|
|
2200
|
-
async function
|
|
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
|
|
2331
|
+
console.error('Usage: appwrap publish <ios|android> [prod] (default: beta — TestFlight / Play internal)');
|
|
2204
2332
|
process.exit(1);
|
|
2205
2333
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
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
|
-
|
|
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
|
|
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 '
|
|
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
|
|
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|
|
|
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
|
-
'
|
|
2269
|
-
'
|
|
2270
|
-
'
|
|
2271
|
-
'
|
|
2272
|
-
'
|
|
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
|
-
'
|
|
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)
|
|
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 +
|