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

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) {