@livx.cc/appwrap 0.39.12 → 0.39.14

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +209 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.39.12",
3
+ "version": "0.39.14",
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",
package/src/cli.ts CHANGED
@@ -1714,14 +1714,16 @@ function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: strin
1714
1714
  return h.toString(36);
1715
1715
  }
1716
1716
 
1717
- const BUILD_CACHE_FILE = '.appwrap-build-cache.json';
1718
- interface BuildCache { fingerprint: string; ipaPath: string; builtAt: string }
1717
+ // Per-platform build cache (iOS .ipa / Android .apk) — separate files so the two don't clobber each
1718
+ // other's fingerprint (that's what enables the build-skip on BOTH platforms).
1719
+ const buildCacheFile = (platform: string) => `.appwrap-build-cache-${platform}.json`;
1720
+ interface BuildCache { fingerprint: string; artifactPath: string; builtAt: string }
1719
1721
 
1720
- function readBuildCache(outDir: string): BuildCache | null {
1721
- try { return JSON.parse(readFileSync(join(outDir, BUILD_CACHE_FILE), 'utf8')); } catch { return null; }
1722
+ function readBuildCache(outDir: string, platform: string): BuildCache | null {
1723
+ try { return JSON.parse(readFileSync(join(outDir, buildCacheFile(platform)), 'utf8')); } catch { return null; }
1722
1724
  }
1723
- function writeBuildCache(outDir: string, cache: BuildCache): void {
1724
- try { writeFileSync(join(outDir, BUILD_CACHE_FILE), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
1725
+ function writeBuildCache(outDir: string, platform: string, cache: BuildCache): void {
1726
+ try { writeFileSync(join(outDir, buildCacheFile(platform)), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
1725
1727
  }
1726
1728
 
1727
1729
  /** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
@@ -1776,11 +1778,145 @@ function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1776
1778
  * distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
1777
1779
  * iOS has a bespoke Debug-IPA path (below); Android delegates to `run` (NativeScript builds + installs
1778
1780
  * + launches), so the `deploy <platform>` surface is symmetric across both. */
1781
+ /** The project's web-build command: explicit `webBuild` in config, else `bun run build` if the
1782
+ * project's package.json has a "build" script. null when there's nothing to run. */
1783
+ function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
1784
+ const explicit = (cfg as { webBuild?: string }).webBuild;
1785
+ if (explicit) return explicit.trim().split(/\s+/);
1786
+ try {
1787
+ const pj = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
1788
+ if (pj.scripts?.build) return ['bun', 'run', 'build'];
1789
+ } catch { /* no package.json */ }
1790
+ return null;
1791
+ }
1792
+
1793
+ /** Build the web bundle before a device deploy, but ONLY for a bundled loader — a `loader:'server'`
1794
+ * app loads the live serverUrl, so its dist is unused (building it would be wasteful + misleading).
1795
+ * Prints exactly what it's doing either way. `--no-web-build` skips it (ship the current dist as-is,
1796
+ * e.g. to hit the native build-skip fast-path when only the wrapper changed). */
1797
+ function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
1798
+ if ((cfg.loader ?? 'app') === 'server') {
1799
+ console.log('ℹ loader:server — NOT building the web (the app loads serverUrl live; the bundle is unused).');
1800
+ return;
1801
+ }
1802
+ if ('no-web-build' in flags) {
1803
+ console.log('ℹ --no-web-build — shipping the CURRENT dist/ as-is (web NOT rebuilt).');
1804
+ return;
1805
+ }
1806
+ const cmd = detectWebBuildCmd(cwd, cfg);
1807
+ if (!cmd) {
1808
+ console.warn('⚠ Bundled loader but no web-build command (no package.json "build" script / `webBuild` config). Shipping the CURRENT dist/ — it may be STALE.');
1809
+ return;
1810
+ }
1811
+ console.log(`▶ ${cmd.join(' ')} (bundled loader → fresh web bundle so dist isn't stale)`);
1812
+ execFileSync(cmd[0], cmd.slice(1), { cwd, stdio: 'inherit' });
1813
+ }
1814
+
1815
+ /** Resolve adb: $ANDROID_HOME/$ANDROID_SDK_ROOT platform-tools, else `adb` on PATH. */
1816
+ function androidAdb(): string {
1817
+ const home = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
1818
+ if (home) {
1819
+ const p = join(home, 'platform-tools', 'adb');
1820
+ if (existsSync(p)) return p;
1821
+ }
1822
+ return 'adb';
1823
+ }
1824
+
1825
+ /** Authorized (`device` state) adb serials. Skips `unauthorized`/`offline`. */
1826
+ function listAndroidDevices(adb: string): string[] {
1827
+ try {
1828
+ return execFileSync(adb, ['devices'], { encoding: 'utf8' })
1829
+ .split('\n').slice(1)
1830
+ .filter((l) => /\tdevice\b/.test(l))
1831
+ .map((l) => l.split('\t')[0].trim())
1832
+ .filter(Boolean);
1833
+ } catch {
1834
+ return [];
1835
+ }
1836
+ }
1837
+
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
+ /** `appwrap deploy android` — ONE-SHOT device deploy, the Android twin of `deploy ios`: sync + debug
1859
+ * config → `ns build android` (debug APK) → `adb install -r` to the device → launch (unless --no-launch)
1860
+ * → exit. Unlike `run android` (ns watch-mode), it doesn't stay attached. NOTE: like `deploy ios`, it
1861
+ * 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);
1864
+ const outDir = resolve(cwd, flags.out ?? 'native');
1865
+ if (!existsSync(outDir)) {
1866
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1867
+ process.exit(1);
1868
+ }
1869
+ const adb = androidAdb();
1870
+ const device = pickAndroidDevice(adb, flags.device || undefined); // fail fast before the build
1871
+
1872
+ buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle; server → skip (both printed)
1873
+ await sync(cwd, flags); // re-stamp config + copy latest PWA dist
1874
+ stampShellConfig(outDir, { ...cfg, debug: true }); // debug: keep-awake + WebView inspector (parity with deploy ios)
1875
+
1876
+ const apk = join(outDir, 'platforms/android/app/build/outputs/apk/debug/app-debug.apk');
1877
+ // Fingerprint build-skip (parity with deploy ios): skip the gradle build when dist + config are
1878
+ // unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
1879
+ // bumps the fingerprint; pair with --no-web-build to actually hit this fast-path.)
1880
+ const force = 'force' in flags || 'f' in flags;
1881
+ const fp = buildFingerprint(cwd, cfg, outDir);
1882
+ const cache = readBuildCache(outDir, 'android');
1883
+ if (!force && existsSync(apk) && cache?.fingerprint === fp && cache?.artifactPath === apk) {
1884
+ console.log(`⚡ Skipping build — inputs unchanged since last build (${apk.split('/').pop()})`);
1885
+ } else {
1886
+ console.log(`▶ ns build android (debug)${force ? ' [--force]' : ''}`);
1887
+ runNs(outDir, ['build', 'android']); // bun-installs deps if needed, then gradle build
1888
+ if (!existsSync(apk)) { console.error(`✖ No APK produced at ${apk}`); process.exit(1); }
1889
+ writeBuildCache(outDir, 'android', { fingerprint: fp, artifactPath: apk, builtAt: new Date().toISOString() });
1890
+ }
1891
+
1892
+ console.log(`▶ installing → ${device}`);
1893
+ try {
1894
+ execFileSync(adb, ['-s', device, 'install', '-r', apk], { stdio: 'inherit' });
1895
+ } catch (e: unknown) {
1896
+ const log = execErrText(e);
1897
+ if (/INSTALL_FAILED_USER_RESTRICTED|user is restricted/i.test(log)) {
1898
+ console.error('\n✖ Install blocked by the device. On MIUI/Xiaomi: Developer options → enable "Install via USB" (may need mobile data + a SIM, no VPN).');
1899
+ }
1900
+ process.exit(1);
1901
+ }
1902
+
1903
+ if (!('no-launch' in flags)) {
1904
+ console.log(`▶ launching ${cfg.id}`);
1905
+ try {
1906
+ execFileSync(adb, ['-s', device, 'shell', 'monkey', '-p', cfg.id, '-c', 'android.intent.category.LAUNCHER', '1'], { stdio: 'ignore' });
1907
+ } catch {
1908
+ console.error('⚠ Launch failed — the app is installed; tap its icon.');
1909
+ }
1910
+ }
1911
+ console.log(`✓ Deployed to ${device}.`);
1912
+ }
1913
+
1779
1914
  async function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1780
1915
  const platform = positionals[0];
1781
1916
  if (platform === 'android') {
1782
- // Aligned surface: `deploy android` == `run android` (Android's run already does build+install+launch).
1783
- return run(cwd, flags, positionals);
1917
+ // Parity with `deploy ios`: a clean ONE-SHOT build → install-to-device → launch → exit (NOT `ns run`,
1918
+ // which is watch-mode + hangs on some devices). Mirrors the iOS path with the adb toolchain.
1919
+ return deployAndroid(cwd, flags);
1784
1920
  }
1785
1921
  if (platform !== 'ios') {
1786
1922
  console.error('Usage: appwrap deploy <ios|android> [--device <id|name>] [--no-launch]');
@@ -1795,6 +1931,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1795
1931
  // Pick the device up front so we fail fast before a long build if nothing's connected.
1796
1932
  const device = pickDevice(listIosDevices(), flags.device || undefined);
1797
1933
 
1934
+ buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle (no stale dist); server → skip (printed)
1798
1935
  await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1799
1936
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1800
1937
  stampShellConfig(outDir, { ...cfg, debug: true });
@@ -1808,11 +1945,11 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1808
1945
  const resume = 'resume' in flags || 'r' in flags;
1809
1946
  const force = 'force' in flags || 'f' in flags;
1810
1947
  const fp = buildFingerprint(cwd, cfg, outDir);
1811
- const cache = readBuildCache(outDir);
1948
+ const cache = readBuildCache(outDir, 'ios');
1812
1949
  const existingIpa = existsSync(ipaDir)
1813
1950
  ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
1814
1951
  : undefined;
1815
- const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.ipaPath === join(ipaDir, existingIpa);
1952
+ const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.artifactPath === join(ipaDir, existingIpa);
1816
1953
  // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
1817
1954
  // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
1818
1955
  const noCache = existingIpa && !cache;
@@ -1844,7 +1981,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1844
1981
  const ipa = builtIpa;
1845
1982
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
1846
1983
  const ipaPath = join(ipaDir, ipa);
1847
- if (!canSkipBuild) writeBuildCache(outDir, { fingerprint: fp, ipaPath, builtAt: new Date().toISOString() });
1984
+ if (!canSkipBuild) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
1848
1985
 
1849
1986
  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
1850
1987
  let installedViaUsbmux = false;
@@ -1973,12 +2110,28 @@ function libimobiledeviceUdid(): { udid: string; network: boolean } | null {
1973
2110
  * Headless-friendly: redirect to a file and read it. */
1974
2111
  async function logs(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1975
2112
  const platform = positionals[0] ?? 'ios';
1976
- if (platform !== 'ios') {
1977
- console.error('Usage: appwrap logs ios [--once] [--native] [--device <id|name>]');
2113
+ if (platform !== 'ios' && platform !== 'android') {
2114
+ console.error('Usage: appwrap logs <ios|android> [--once] [--native] [--device <id|name>]');
1978
2115
  process.exit(1);
1979
2116
  }
1980
2117
  const cfg = await loadConfig(cwd, flags);
1981
2118
 
2119
+ // ── Android: adb logcat — WebView console (chromium tag) by default; --native = full app logcat ──
2120
+ if (platform === 'android') {
2121
+ const adb = androidAdb();
2122
+ const device = pickAndroidDevice(adb, flags.device || undefined);
2123
+ const once = 'once' in flags;
2124
+ if ('native' in flags) {
2125
+ const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
2126
+ console.log(`▶ logcat for ${cfg.id}${pid ? ` (pid ${pid})` : ' (not running — full stream)'}${once ? ' [once]' : ''} — Ctrl-C to stop.`);
2127
+ try { execFileSync(adb, ['-s', device, 'logcat', ...(once ? ['-d'] : []), ...(pid ? ['--pid', pid] : [])], { stdio: 'inherit' }); } catch { process.exit(1); }
2128
+ return;
2129
+ }
2130
+ console.log(`▶ WebView console (chromium) for ${cfg.id}${once ? ' [once]' : ''} — Ctrl-C to stop. (--native for full app logcat)`);
2131
+ try { execFileSync(adb, ['-s', device, 'logcat', ...(once ? ['-d'] : []), '-s', 'chromium:I'], { stdio: 'inherit' }); } catch { process.exit(1); }
2132
+ return;
2133
+ }
2134
+
1982
2135
  if ('native' in flags) {
1983
2136
  const li = libimobiledeviceUdid();
1984
2137
  if (!li) {
@@ -2028,6 +2181,40 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2028
2181
 
2029
2182
  /** CLI dispatch. Guarded by `import.meta.main` so importing this module (e.g. for the `AppwrapConfig`
2030
2183
  * type via the package entry) doesn't run a command. */
2184
+ /** `appwrap debug <ios|android>` — point at the WebView inspector for the (debug-built) app, then
2185
+ * stream its console. Debug builds enable the inspector (deploy installs one); this just opens the
2186
+ * door + tails logs — no rebuild. Android: adb-forwards the WebView devtools socket → chrome://inspect.
2187
+ * iOS: prints the Safari Web Inspector path. Both then fall through to `logs` for the live console. */
2188
+ async function debug(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2189
+ const platform = positionals[0];
2190
+ if (platform !== 'ios' && platform !== 'android') {
2191
+ console.error('Usage: appwrap debug <ios|android> [--device <id|name>]');
2192
+ process.exit(1);
2193
+ }
2194
+ const cfg = await loadConfig(cwd, flags);
2195
+ if (platform === 'android') {
2196
+ const adb = androidAdb();
2197
+ const device = pickAndroidDevice(adb, flags.device || undefined);
2198
+ const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
2199
+ if (pid) {
2200
+ try {
2201
+ execFileSync(adb, ['-s', device, 'forward', 'tcp:9222', `localabstract:webview_devtools_remote_${pid}`], { stdio: 'pipe' });
2202
+ console.log('✓ WebView devtools forwarded → open chrome://inspect (or http://localhost:9222) in desktop Chrome to inspect the page.');
2203
+ } catch {
2204
+ console.log('⚠ Could not forward the devtools socket — open chrome://inspect and look for the device there.');
2205
+ }
2206
+ } else {
2207
+ console.log(`⚠ ${cfg.id} not running — launch it (or \`appwrap deploy android\`), then open chrome://inspect.`);
2208
+ }
2209
+ console.log(' (Needs a DEBUG build — `appwrap deploy android` installs one with the inspector enabled.)\n');
2210
+ } else {
2211
+ console.log('▶ iOS WebView inspector: Safari → Develop → [your iPhone] → [the app]. Enable it first in iOS Settings → Safari → Advanced → Web Inspector.');
2212
+ console.log(' (Needs a DEBUG build — `appwrap deploy ios` installs one.)\n');
2213
+ }
2214
+ // Tail the live console (reuses logs' per-platform streaming).
2215
+ await logs(cwd, flags, positionals);
2216
+ }
2217
+
2031
2218
  async function main(): Promise<void> {
2032
2219
  const { command, flags, positionals } = parseArgs(process.argv.slice(2));
2033
2220
  const cwd = process.cwd();
@@ -2060,14 +2247,20 @@ async function main(): Promise<void> {
2060
2247
  case 'logs':
2061
2248
  await logs(cwd, flags, positionals);
2062
2249
  break;
2250
+ case 'debug':
2251
+ await debug(cwd, flags, positionals);
2252
+ break;
2063
2253
  default:
2064
- console.log('Usage: appwrap <init|sync|dev|run|build|deploy|release|submit|logs> [--config <path>] [--out native]\n' +
2254
+ console.log('Usage: appwrap <init|sync|dev|run|build|deploy|release|submit|logs|debug> [--config <path>] [--out native]\n' +
2065
2255
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
2066
2256
  ' run <ios|android> [--device <id|name>] (compile + boot in a simulator/emulator, live reload)\n' +
2067
- ' build <ios|android> [--release] [--aab] deploy <ios|android> [--device <id|name>] [--no-launch]\n' +
2257
+ ' deploy <ios|android> [--device <id|name>] [--no-launch] [--no-web-build] [-f] (build → install to device → launch; builds web if bundled)\n' +
2258
+ ' build <ios|android> [--release] [--aab] (store artifact)\n' +
2068
2259
  ' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
2069
2260
  ' submit ios [--build-number <n>] [--submit-for-review] (promote the binary to the App Store; metadata stays in ASC)\n' +
2070
- ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
2261
+ ' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
2262
+ ' debug <ios|android> [--device <id|name>] (open the WebView inspector + stream console)\n' +
2263
+ ' dev [--url <url> | --port <p>]');
2071
2264
  process.exit(command ? 1 : 0);
2072
2265
  }
2073
2266
  }