260 lines
19 KiB
TypeScript
260 lines
19 KiB
TypeScript
// Optional local tools storebound uses (screenshots, previews, icons, simulator and emulator captures), found on this
|
|
// machine with their versions. The desktop app can manage its own copies of ffmpeg and Chrome for Testing in its data
|
|
// folder and points storebound at them:
|
|
// 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, readFileSync } from 'node:fs';
|
|
import { delimiter, join } from 'node:path';
|
|
import * as rt from './rt';
|
|
|
|
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git' | 'java' | 'sdkmanager' | 'cocoapods' | 'node' | 'bun' | 'bundletool';
|
|
|
|
export interface ToolStatus {
|
|
id: ToolId;
|
|
name: string;
|
|
found: boolean;
|
|
path: string | null;
|
|
version: string | null;
|
|
/** 'app' = the desktop app's own copy (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME), 'system' = installed by the user. */
|
|
source: 'app' | 'system' | null;
|
|
/** What needs it. */
|
|
usedFor: string;
|
|
/** How to get it: the desktop app downloads ffmpeg and Chrome for Testing; Xcode and the Android SDK are link-out. */
|
|
install: { kind: 'download' | 'link'; url: string; note: string };
|
|
/** The version the project needs (JDK from the Android Gradle plugin / jvmToolchain), when one is known. */
|
|
required?: string | null;
|
|
/** ok, missing, or found but older than `required`. */
|
|
status?: 'ok' | 'missing' | 'outdated';
|
|
}
|
|
|
|
/** Folders where tools usually live but that are often missing from PATH (apps started from the Dock or Finder get
|
|
* a minimal PATH): the Android SDK that Android Studio installs, Homebrew, and the usual local bin folders. */
|
|
export function wellKnownToolDirs(env: Record<string, string | undefined> = process.env, platform: string = process.platform): string[] {
|
|
const home = env.HOME ?? env.USERPROFILE ?? '';
|
|
if (!home) return []; // a bare environment (tests, sandboxes) keeps exactly its own PATH
|
|
const sdks = [env.ANDROID_HOME, env.ANDROID_SDK_ROOT,
|
|
platform === 'darwin' ? join(home, 'Library/Android/sdk') : platform === 'win32' ? join(env.LOCALAPPDATA ?? join(home, 'AppData/Local'), 'Android/Sdk') : join(home, 'Android/Sdk')];
|
|
const dirs = sdks.filter((d): d is string => !!d).flatMap(d => [join(d, 'platform-tools'), join(d, 'emulator')]);
|
|
if (platform === 'darwin') dirs.push('/opt/homebrew/bin', '/usr/local/bin');
|
|
else if (platform !== 'win32') dirs.push('/usr/local/bin', join(home, '.local/bin'));
|
|
return [...new Set(dirs)].filter(d => existsSync(d));
|
|
}
|
|
|
|
/** PATH with the app-managed tool folders first and the well-known tool folders after the user's own PATH. */
|
|
export function toolPath(env: Record<string, string | undefined> = process.env, platform: string = process.platform): string {
|
|
const extra = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean);
|
|
const own = (env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
const known = wellKnownToolDirs(env, platform).filter(d => !own.includes(d));
|
|
return [...extra, ...own, ...known].join(delimiter);
|
|
}
|
|
|
|
/** First line of `<cmd> <args>` (version banners), or null. */
|
|
function firstLine(cmd: string[], env: Record<string, string>): string | null {
|
|
try {
|
|
const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 });
|
|
if (r.exitCode !== 0) return null;
|
|
return (r.stdout.toString() || r.stderr.toString()).trim().split('\n')[0]?.trim() || null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** "ffmpeg version 7.1.1 Copyright ..." -> "7.1.1"; "Google Chrome for Testing 131.0.6778.85" -> "131.0.6778.85". */
|
|
export function parseVersion(line: string | null): string | null {
|
|
if (!line) return null;
|
|
const java = /version "(\d[^"]*)"/.exec(line)?.[1];
|
|
if (java) return java;
|
|
return line.match(/version\s+([^\s,]+)/i)?.[1] ?? line.match(/(\d+(?:\.\d+)+)/)?.[1] ?? null;
|
|
}
|
|
|
|
export function systemChrome(platform = process.platform, env: Record<string, string | undefined> = process.env): string | null {
|
|
const c = platform === 'darwin' ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
|
|
: platform === 'win32' ? [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter((d): d is string => !!d).map(d => join(d, 'Google/Chrome/Application/chrome.exe'))
|
|
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
return c.find(p => existsSync(p)) ?? null;
|
|
}
|
|
|
|
const FFMPEG_NOTE = 'Static build into the app data folder (GPL; never bundled with the app). Checksum-verified.';
|
|
|
|
export function detectTools(env: Record<string, string | undefined> = process.env, project?: string): ToolStatus[] {
|
|
const PATH = toolPath(env);
|
|
const spawnEnv: Record<string, string> = { ...Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)), PATH };
|
|
const appDirs = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean);
|
|
const which = (n: string): string | null => rt.which(n, { PATH });
|
|
const src = (p: string | null): ToolStatus['source'] => (!p ? null : appDirs.some(d => p.startsWith(d)) ? 'app' : 'system');
|
|
const bin = (id: ToolId, name: string, usedFor: string, install: ToolStatus['install'], versionArgs: string[]): ToolStatus => {
|
|
const path = which(id);
|
|
const version = path ? parseVersion(firstLine([path, ...versionArgs], spawnEnv)) : null;
|
|
return { id, name, found: !!path, path, version, source: src(path), usedFor, install };
|
|
};
|
|
const out: ToolStatus[] = [
|
|
bin('ffmpeg', 'ffmpeg', 'icons, screenshot renders, app preview videos, frame crops', { kind: 'download', url: 'https://ffmpeg.org/download.html', note: FFMPEG_NOTE }, ['-version']),
|
|
bin('ffprobe', 'ffprobe', 'checks of app preview videos and clips', { kind: 'download', url: 'https://ffmpeg.org/download.html', note: 'Comes with the ffmpeg download.' }, ['-version']),
|
|
];
|
|
const appChrome = env.STOREBOUND_CHROME && existsSync(env.STOREBOUND_CHROME) ? env.STOREBOUND_CHROME : null;
|
|
const chrome = appChrome ?? systemChrome(process.platform, env);
|
|
out.push({
|
|
id: 'chrome', name: appChrome ? 'Chrome for Testing' : 'Google Chrome', found: !!chrome, path: chrome,
|
|
version: chrome ? parseVersion(firstLine([chrome, '--version'], spawnEnv)) : null, source: appChrome ? 'app' : chrome ? 'system' : null,
|
|
usedFor: 'screenshot renders, captions, web captures and recordings',
|
|
install: { kind: 'download', url: 'https://googlechromelabs.github.io/chrome-for-testing/', note: 'Chrome for Testing from Google\'s official endpoints into the app data folder.' },
|
|
});
|
|
if (process.platform === 'darwin') {
|
|
const dev = firstLine(['xcode-select', '-p'], spawnEnv);
|
|
out.push({ id: 'xcode', name: 'Xcode command line tools', found: !!dev, path: dev, version: parseVersion(firstLine(['xcodebuild', '-version'], spawnEnv)), source: dev ? 'system' : null,
|
|
usedFor: 'iOS builds, the iOS Simulator (simctl)', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: 'Install Xcode from the App Store or developer.apple.com.' } });
|
|
const simctl = firstLine(['xcrun', '--find', 'simctl'], spawnEnv);
|
|
out.push({ id: 'simctl', name: 'simctl (iOS Simulator)', found: !!simctl, path: simctl, version: null, source: simctl ? 'system' : null,
|
|
usedFor: 'iOS Simulator screenshots and recordings', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: 'Part of Xcode.' } });
|
|
}
|
|
out.push(bin('adb', 'adb (Android platform tools)', 'Android emulator / device screenshots and recordings', { kind: 'link', url: 'https://developer.android.com/tools/releases/platform-tools', note: 'Install Android Studio or the SDK platform tools.' }, ['version']));
|
|
out.push(bin('git', 'git', 'release notes from history, analytics compare', { kind: 'link', url: 'https://git-scm.com/downloads', note: 'Install git from your OS package manager.' }, ['--version']));
|
|
// build toolchain: what play build / apple build need
|
|
const javaBin = env.JAVA_HOME && existsSync(join(env.JAVA_HOME, 'bin')) ? join(env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : which('java');
|
|
out.push({ id: 'java', name: 'JDK (Java)', found: !!javaBin, path: javaBin, version: javaBin ? parseVersion(firstLine([javaBin, '-version'], spawnEnv)) : null, source: javaBin ? 'system' : null,
|
|
usedFor: 'play build (Gradle), keystore checks (keytool)', required: project ? requiredJdk(project) : null,
|
|
install: { kind: 'link', url: 'https://adoptium.net/temurin/releases/', note: 'Temurin JDK; the version the Android Gradle plugin asks for.' } });
|
|
const sdk = androidSdk(project ? [join(project, 'android'), project] : [], env);
|
|
const sdkm = sdk.path ? ['cmdline-tools/latest/bin', 'tools/bin'].map(d => join(sdk.path!, d, process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager')).find(f => existsSync(f)) ?? null : null;
|
|
out.push({ id: 'sdkmanager', name: 'Android SDK command-line tools', found: !!sdkm, path: sdkm ?? sdk.path, version: null, source: sdkm ? 'system' : null,
|
|
usedFor: 'play build: SDK platforms and build tools', install: { kind: 'link', url: 'https://developer.android.com/studio#command-tools', note: 'Android Studio, or the command-line tools alone.' } });
|
|
if (process.platform === 'darwin') out.push(bin('pod' as ToolId, 'CocoaPods', 'iOS builds of Capacitor / React Native / Flutter apps with a Podfile', { kind: 'link', url: 'https://cocoapods.org', note: 'brew install cocoapods' }, ['--version']));
|
|
out.push(bin('node', 'Node.js', 'web builds (npm scripts), npx storebound', { kind: 'link', url: 'https://nodejs.org', note: 'The LTS release.' }, ['--version']));
|
|
out.push(bin('bun', 'Bun', 'storebound from a git clone, web builds', { kind: 'link', url: 'https://bun.sh', note: 'bun.sh installer or a package manager.' }, ['--version']));
|
|
out.push(bin('bundletool', 'bundletool', 'checks of the AAB (sizes, APKs per device)', { kind: 'link', url: 'https://github.com/google/bundletool/releases', note: 'Optional.' }, ['version']));
|
|
for (const t of out) {
|
|
if ((t.id as string) === 'pod') t.id = 'cocoapods';
|
|
t.status = !t.found ? 'missing' : t.required && t.version && majorOf(t.version) < majorOf(t.required) ? 'outdated' : 'ok';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** "17.0.9" -> 17; "1.8.0_392" -> 8. */
|
|
export const majorOf = (v: string): number => { const n = v.replace(/^1\.(?=\d+\.)/, '').match(/\d+/); return n ? Number(n[0]) : 0; };
|
|
|
|
/**
|
|
* The JDK major version an Android project needs: jvmToolchain(N) / JavaVersion.VERSION_N in its Gradle files, at
|
|
* least the Android Gradle plugin's minimum (AGP 8.x: 17, 7.x: 11). Null when the project has no Gradle files.
|
|
*/
|
|
export function requiredJdk(project: string): string | null {
|
|
const files = ['android/build.gradle', 'android/build.gradle.kts', 'android/app/build.gradle', 'android/app/build.gradle.kts', 'android/gradle/libs.versions.toml',
|
|
'build.gradle', 'build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts', 'gradle/libs.versions.toml'].map(f => join(project, f)).filter(f => existsSync(f));
|
|
if (!files.length) return null;
|
|
const text = files.map(f => { try { return readFileSync(f, 'utf8'); } catch { return ''; } }).join('\n');
|
|
const toolchain = /jvmToolchain\s*\(\s*(\d+)/.exec(text)?.[1] ?? /JavaVersion\.VERSION_(\d+)/.exec(text)?.[1] ?? /JavaLanguageVersion\.of\(\s*(\d+)/.exec(text)?.[1];
|
|
const agp = /com\.android\.tools\.build:gradle:(\d+)/.exec(text)?.[1] ?? /^\s*(?:agp|androidGradlePlugin|android-gradle-plugin)\s*=\s*"(\d+)/m.exec(text)?.[1];
|
|
const fromAgp = agp ? (Number(agp) >= 8 ? 17 : Number(agp) >= 7 ? 11 : 8) : 17;
|
|
return String(Math.max(toolchain ? Number(toolchain) : 0, fromAgp));
|
|
}
|
|
|
|
/**
|
|
* Commands that install a tool on this OS (shown by `storebound doctor` and the desktop Dependencies screen, never
|
|
* run by storebound). Package managers only; the desktop app can also download ffmpeg and Chrome for Testing itself.
|
|
*/
|
|
export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Platform = process.platform): string[] {
|
|
const mac = platform === 'darwin', win = platform === 'win32';
|
|
switch (id) {
|
|
case 'ffmpeg': case 'ffprobe': return mac ? ['brew install ffmpeg'] : win ? ['winget install --id Gyan.FFmpeg'] : ['sudo apt install ffmpeg', 'sudo dnf install ffmpeg'];
|
|
case 'chrome': return ['bunx playwright install chromium', ...(mac ? ['brew install --cask google-chrome'] : win ? ['winget install --id Google.Chrome'] : [])];
|
|
case 'playwright': return ['bun add -d playwright'];
|
|
case 'xcode': return mac ? ['xcode-select --install'] : [];
|
|
case 'simctl': return mac ? ['Install Xcode from the App Store, then: sudo xcode-select -s /Applications/Xcode.app'] : [];
|
|
case 'adb': return mac ? ['brew install --cask android-platform-tools'] : win ? ['winget install --id Google.PlatformTools'] : ['sudo apt install adb'];
|
|
case 'git': return mac ? ['xcode-select --install', 'brew install git'] : win ? ['winget install --id Git.Git'] : ['sudo apt install git'];
|
|
case 'java': return mac ? ['brew install --cask temurin@17'] : win ? ['winget install --id EclipseAdoptium.Temurin.17.JDK'] : ['sudo apt install openjdk-17-jdk'];
|
|
case 'sdkmanager': return mac ? ['brew install --cask android-commandlinetools'] : win ? ['winget install --id Google.AndroidStudio'] : ['sudo snap install android-studio --classic'];
|
|
case 'cocoapods': return mac ? ['brew install cocoapods'] : [];
|
|
case 'node': return mac ? ['brew install node'] : win ? ['winget install --id OpenJS.NodeJS.LTS'] : ['sudo apt install nodejs npm'];
|
|
case 'bun': return mac ? ['brew install oven-sh/bun/bun'] : win ? ['winget install --id Oven-sh.Bun'] : ['curl -fsSL https://bun.sh/install | bash'];
|
|
case 'bundletool': return mac ? ['brew install bundletool'] : [];
|
|
}
|
|
}
|
|
|
|
// ---- 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) {
|
|
try { return rt.resolveFrom('playwright', base); } catch { /* next */ }
|
|
}
|
|
return null;
|
|
}
|