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:
parent
366362d62b
commit
fc524cd2d4
15 changed files with 460 additions and 34 deletions
47
cli/lib/progress.ts
Normal file
47
cli/lib/progress.ts
Normal 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(); }
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue