play build: find the Android SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir, default folder), pass it in the env, stop before Gradle when missing; doctor shows it. apple upload: size, per-part lines, 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, --timeout exits 7 with the resume hint. Pitfall 25

This commit is contained in:
CRTkafa 2026-10-12 08:48:02 +08:00
parent 366362d62b
commit fc524cd2d4
15 changed files with 460 additions and 34 deletions

47
cli/lib/progress.ts Normal file
View file

@ -0,0 +1,47 @@
// Progress for long blocking steps (uploads, store processing waits, external tools): a heartbeat line every few
// seconds so a run is never silent for minutes, and the size / elapsed formats those lines use. The timer is
// injectable (Ticker), so tests drive beats by hand instead of sleeping.
/** Calls fn every ms until the returned stop function is called. */
export type Ticker = (fn: () => void, ms: number) => () => void;
export const realTicker: Ticker = (fn, ms) => {
const t = setInterval(fn, ms);
(t as { unref?: () => void }).unref?.();
return () => clearInterval(t);
};
/** Default heartbeat interval of uploads and waits. */
export const HEARTBEAT_MS = 20_000;
/** 4.2 MB, 312 MB (whole MB from 10 MB up). */
export const mb = (bytes: number): string => `${(bytes / 1048576).toFixed(bytes < 10 * 1048576 ? 1 : 0)} MB`;
/** 45s, 4m30s, 1h02m. */
export function elapsed(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
}
/** "312/890 MB (35%)". */
export const share = (done: number, total: number): string =>
`${(done / 1048576).toFixed(total < 10 * 1048576 ? 1 : 0)}/${mb(total)} (${total ? Math.floor((done / total) * 100) : 100}%)`;
export interface HeartbeatOptions { everyMs?: number; ticker?: Ticker; now?: () => number }
/** Runs `say(elapsedMs)` every everyMs while a step runs; stop() ends it. */
export function heartbeat(say: (elapsedMs: number) => void, o: HeartbeatOptions = {}): { stop: () => void } {
const now = o.now ?? Date.now;
const started = now();
const stop = (o.ticker ?? realTicker)(() => say(now() - started), o.everyMs ?? HEARTBEAT_MS);
return { stop };
}
/** Runs fn with a heartbeat; the heartbeat stops however fn ends. */
export async function withHeartbeat<T>(say: (elapsedMs: number) => void, fn: () => Promise<T>, o: HeartbeatOptions = {}): Promise<T> {
const h = heartbeat(say, o);
try { return await fn(); } finally { h.stop(); }
}

View file

@ -4,7 +4,7 @@
// STOREBOUND_TOOL_PATH folders put in front of PATH for every command (app-managed ffmpeg / ffprobe)
// STOREBOUND_CHROME Chrome / Chrome for Testing executable for renders and web captures
// Nothing is installed system-wide. Sources and licenses: docs/desktop.md.
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { delimiter, join } from 'node:path';
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git';
@ -121,6 +121,88 @@ export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Plat
}
}
// ---- Android SDK ----
/** Where the Android SDK path came from, in the order Android Studio and the Android Gradle plugin look. */
export type AndroidSdkSource = 'ANDROID_HOME' | 'ANDROID_SDK_ROOT' | 'local.properties' | 'default';
export interface AndroidSdkLookup {
/** The SDK folder (has platforms/ or platform-tools/), or null. */
path: string | null;
source: AndroidSdkSource | null;
/** Every place looked at, in order, with what was there ("not set", "does not exist", ...). */
checked: string[];
}
export interface AndroidSdkInputs {
env: Record<string, string | undefined>;
platform: string;
/** The user's home folder ('' when unknown). */
home: string;
/** Gradle project folders whose local.properties may name sdk.dir (the first one that names it counts). */
gradleDirs?: string[];
exists: (path: string) => boolean;
/** Text of a file, or null when unreadable. */
readText: (path: string) => string | null;
}
/** sdk.dir of a local.properties (Java properties: `\:` and `\\` escapes, `=` or `:` separator), or null. */
export function sdkDirOf(properties: string): string | null {
for (const raw of properties.split(/\r?\n/)) {
const m = /^\s*sdk\.dir\s*[=:]\s*(.*?)\s*$/.exec(raw);
if (m?.[1]) return m[1].replace(/\\(.)/g, '$1');
}
return null;
}
/**
* Finds the Android SDK the way Android Studio does: ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir in the Gradle project's
* local.properties, then the folder Android Studio installs to (macOS ~/Library/Android/sdk, Linux ~/Android/Sdk,
* Windows %LOCALAPPDATA%\Android\Sdk). A candidate counts only when it has platforms/ or platform-tools/. Pure: the
* file system comes in through `exists` / `readText`. Nothing is written (never local.properties).
*/
export function resolveAndroidSdk(i: AndroidSdkInputs): AndroidSdkLookup {
const checked: string[] = [];
const sep = i.platform === 'win32' ? '\\' : '/';
const at = (...p: string[]): string => p.join(sep).replace(/[\\/]+/g, sep);
const isSdk = (d: string): boolean => i.exists(at(d, 'platforms')) || i.exists(at(d, 'platform-tools'));
const tryDir = (source: AndroidSdkSource, label: string, dir: string | undefined | null): AndroidSdkLookup | null => {
if (!dir) { checked.push(`${label}: not set`); return null; }
if (isSdk(dir)) { checked.push(`${label}: ${dir}`); return { path: dir, source, checked }; }
checked.push(`${label}: ${dir} (${i.exists(dir) ? 'no platforms/ or platform-tools/ in it' : 'does not exist'})`);
return null;
};
const fromEnv = tryDir('ANDROID_HOME', 'ANDROID_HOME', i.env.ANDROID_HOME) ?? tryDir('ANDROID_SDK_ROOT', 'ANDROID_SDK_ROOT', i.env.ANDROID_SDK_ROOT);
if (fromEnv) return fromEnv;
const files = (i.gradleDirs ?? []).map(d => at(d, 'local.properties'));
let dir: string | null = null, file: string | null = null;
for (const f of files) {
const t = i.exists(f) ? i.readText(f) : null;
const d = t ? sdkDirOf(t) : null;
if (d) { dir = d; file = f; break; }
}
const fromProps = tryDir('local.properties', `sdk.dir in ${file ?? (files.length ? files.join(' / ') : 'local.properties (no Gradle project)')}`, dir);
if (fromProps) return fromProps;
const local = i.env.LOCALAPPDATA ?? (i.home ? at(i.home, 'AppData/Local') : null);
const def = i.platform === 'darwin' ? (i.home ? at(i.home, 'Library/Android/sdk') : null)
: i.platform === 'win32' ? (local ? at(local, 'Android/Sdk') : null)
: (i.home ? at(i.home, 'Android/Sdk') : null);
return tryDir('default', 'Android Studio default folder', def) ?? { path: null, source: null, checked };
}
/** resolveAndroidSdk on this machine. */
export function androidSdk(gradleDirs: string[] = [], env: Record<string, string | undefined> = process.env, platform: string = process.platform): AndroidSdkLookup {
return resolveAndroidSdk({ env, platform, home: env.HOME ?? env.USERPROFILE ?? '', gradleDirs, exists: existsSync,
readText: p => { try { return readFileSync(p, 'utf8'); } catch { return null; } } });
}
/** The environment that hands a found SDK to Gradle (both names: older plugins read ANDROID_SDK_ROOT). */
export const androidSdkEnv = (l: AndroidSdkLookup): Record<string, string> => (l.path ? { ANDROID_HOME: l.path, ANDROID_SDK_ROOT: l.path } : {});
/** The error when no Android SDK is found, with every place checked and how to fix it. */
export function androidSdkMissing(l: AndroidSdkLookup): string {
return ['no Android SDK found (Gradle would stop with "SDK location not found"). Checked:', ...l.checked.map(c => ` - ${c}`),
'Install it with Android Studio (Settings > Android SDK), or set ANDROID_HOME to the SDK folder (the one with platforms/ and platform-tools/).'].join('\n');
}
/** Where playwright resolves from (the project, then the storebound folder), or null. */
export function findPlaywright(bases: string[]): string | null {
for (const base of bases) {