655 lines
40 KiB
TypeScript
655 lines
40 KiB
TypeScript
// Project detection for `storebound scan`: reads a mobile app or game project (read-only, offline) and works out the
|
|
// stack, the dependencies (with versions and the file:line they come from), the Info.plist keys (from Info.plist files,
|
|
// Xcode INFOPLIST_KEY_* build settings, Expo app.json, Unity ProjectSettings and Godot export presets), entitlements,
|
|
// privacy manifests, the Android manifest (permissions, services, target SDK) and the app's own source files.
|
|
// Tolerant on purpose: an unreadable or odd file is skipped, never an exception.
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { basename, dirname, extname, join, relative, sep } from 'node:path';
|
|
import { resolvePackage } from '../node-packages';
|
|
import { isBuildCopyPath, type Manifest, parseManifest } from '../privacy-manifest';
|
|
import { type DepRef, type Eco, matchesSdk, SDKS, type SdkDef } from './sdks';
|
|
import { exportedIpas, ipaInfo } from '../xcode';
|
|
|
|
export interface Loc { file: string; line: number }
|
|
export interface PlistEntry { key: string; value: unknown; at: Loc }
|
|
export interface Hit extends Loc { text: string }
|
|
|
|
export type Stack = 'ios-native' | 'android-native' | 'react-native' | 'expo' | 'capacitor' | 'cordova' | 'flutter' | 'unity' | 'godot' | 'web';
|
|
|
|
export interface DetectedSdk { sdk: SdkDef; deps: DepRef[] }
|
|
|
|
export interface AndroidComponent {
|
|
kind: 'activity' | 'service' | 'receiver';
|
|
name: string;
|
|
/** android:permission (e.g. BIND_ACCESSIBILITY_SERVICE, BIND_VPN_SERVICE, BIND_DEVICE_ADMIN). */
|
|
permission: string | null;
|
|
fgsType: string | null;
|
|
actions: string[];
|
|
categories: string[];
|
|
meta: string[];
|
|
at: Loc;
|
|
}
|
|
|
|
export interface ProjectFacts {
|
|
root: string;
|
|
stacks: Stack[];
|
|
/** The project has iOS / Android build files (or a cross-platform engine that targets them). */
|
|
ios: boolean;
|
|
android: boolean;
|
|
deps: DepRef[];
|
|
sdks: DetectedSdk[];
|
|
/** Info.plist keys merged from every source; first source wins. */
|
|
plist: Map<string, PlistEntry>;
|
|
/** Where Info.plist keys came from (empty = the project has no readable iOS settings: plist rules are skipped). */
|
|
plistSources: string[];
|
|
entitlements: Map<string, PlistEntry>;
|
|
privacyManifests: Manifest[];
|
|
androidInfo: {
|
|
manifests: string[];
|
|
permissions: { name: string; at: Loc }[];
|
|
services: { name: string; fgsType: string | null; at: Loc }[];
|
|
targetSdk: { value: number; at: Loc } | null;
|
|
/** targetSdk is set by a tool we cannot read (Flutter SDK, Expo SDK, Unity "automatic"). */
|
|
targetSdkFrom: string | null;
|
|
formFactor: 'phone' | 'wear' | 'tv' | 'automotive';
|
|
/** <uses-permission tools:node="remove">: permissions the app strips from the merged manifest (e.g. AD_ID). */
|
|
removedPermissions: { name: string; at: Loc }[];
|
|
/** Activities, services and receivers with their android:permission, intent-filter actions and meta-data names. */
|
|
components: AndroidComponent[];
|
|
/** <application android:extractNativeLibs="..."> */
|
|
extractNativeLibs: { value: boolean; at: Loc } | null;
|
|
/** A merged manifest from a local build (build/intermediates), read for the permissions libraries add. */
|
|
mergedManifest: string | null;
|
|
/** res/xml accessibility-service configs (android:isAccessibilityTool). */
|
|
accessibilityConfigs: { at: Loc; isTool: boolean }[];
|
|
/** res/values*\/strings.xml (for disclosure texts). */
|
|
strings: { path: string; text: string }[];
|
|
};
|
|
/** Native code and the toolchain versions that decide 16 KB page size support. */
|
|
native: {
|
|
/** Prebuilt .so files in the project (jniLibs, Unity Plugins/Android, ...). */
|
|
libs: string[];
|
|
ndkVersion: { value: string; at: Loc } | null;
|
|
/** The NDK version is set by a tool (flutter.ndkVersion). */
|
|
ndkFrom: string | null;
|
|
/** Android Gradle Plugin version. */
|
|
agp: { value: string; at: Loc } | null;
|
|
/** The project builds C / C++ itself (externalNativeBuild, CMakeLists.txt, Android.mk). */
|
|
nativeBuild: Loc | null;
|
|
/** ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON or -Wl,-z,max-page-size=16384 in the native build. */
|
|
flexiblePageSizes: Loc | null;
|
|
/** jniLibs.useLegacyPackaging = true (compressed native libraries). */
|
|
legacyPackaging: Loc | null;
|
|
/** ProjectSettings/ProjectVersion.txt m_EditorVersion. */
|
|
unityVersion: { value: string; at: Loc } | null;
|
|
};
|
|
/** Capacitor server.url / Cordova <content src> pointing at a remote site. */
|
|
remoteStart: { url: string; at: Loc } | null;
|
|
/** App source files (Swift, Objective-C, Kotlin, Java, Dart, JS / TS, C#, GDScript, HTML). */
|
|
sources: { path: string; text: string }[];
|
|
/** Folders of built web output inside the project (Capacitor webDir): low-trust evidence, see isBuilt. */
|
|
builtDirs: string[];
|
|
/** Xcode / SDK of the newest IPA in build/ios/export (Info.plist DTXcode, DTSDKName, DTPlatformVersion). */
|
|
iosBuild?: { file: string; xcode?: string; sdkName?: string; platformVersion?: string } | null;
|
|
/** Files looked at, relative to root (for the report). */
|
|
scanned: number;
|
|
}
|
|
|
|
const SKIP_DIRS = new Set(['.git', 'node_modules', 'Pods', 'build', 'Build', 'DerivedData', '.gradle', '.dart_tool', '.build', 'Library', 'Temp', 'Logs',
|
|
'obj', 'UserSettings', '.storebound', 'renders', '.idea', '.vscode', '.expo', 'vendor', 'xcuserdata', 'Carthage', '.next', 'coverage', '.godot',
|
|
'intermediates', 'generated', 'outputs', 'tmp', '.cxx', 'fastlane', 'dist', 'www', 'public', 'clips', 'frames', 'fonts', '.fvm',
|
|
// AI tool folders: .claude/worktrees holds whole copies of the repo (seen live: evidence pointed into them)
|
|
'.claude', '.cursor', '.windsurf', '.codex', '.gemini']);
|
|
const SRC_EXT = new Set(['.swift', '.m', '.mm', '.h', '.kt', '.java', '.dart', '.js', '.jsx', '.ts', '.tsx', '.cs', '.gd', '.html', '.vue', '.svelte']);
|
|
const MAX_FILES = 25_000;
|
|
const MAX_TEXT = 1_500_000;
|
|
|
|
/** A linked git worktree: its .git is a file pointing into <repo>/.git/worktrees/ (a submodule's points into modules/). */
|
|
function isWorktree(dir: string): boolean {
|
|
try { return /^gitdir:.*[\\/]worktrees[\\/]/m.test(readFileSync(join(dir, '.git'), 'utf8')); } catch { return false; }
|
|
}
|
|
|
|
/** Every file under root (relative paths), skipping build output, dependency folders and VCS data. Native libraries
|
|
* (.so) of any size also go into `bins` when given (they are not read, only listed). */
|
|
export function listFiles(root: string, bins?: string[]): string[] {
|
|
const out: string[] = [];
|
|
const walk = (dir: string, depth: number) => {
|
|
if (depth > 14 || out.length >= MAX_FILES) return;
|
|
let names: string[];
|
|
try { names = readdirSync(dir); } catch { return; }
|
|
for (const n of names.sort()) {
|
|
if (SKIP_DIRS.has(n) || n === '.DS_Store') continue;
|
|
const p = join(dir, n);
|
|
let st;
|
|
try { st = statSync(p); } catch { continue; }
|
|
if (st.isDirectory()) {
|
|
// compiled frameworks are SDK binaries, not app code
|
|
if (/\.(xc)?framework$|\.xcassets$|\.bundle$|\.app$|\.dSYM$/.test(n)) continue;
|
|
// a git worktree inside the project (another checkout of the same repo; submodules stay in)
|
|
if (isWorktree(p)) continue;
|
|
walk(p, depth + 1);
|
|
} else {
|
|
if (bins && n.endsWith('.so')) bins.push(relative(root, p).split(sep).join('/'));
|
|
if (st.size <= MAX_TEXT) out.push(relative(root, p).split(sep).join('/'));
|
|
}
|
|
if (out.length >= MAX_FILES) return;
|
|
}
|
|
};
|
|
walk(root, 0);
|
|
return out;
|
|
}
|
|
|
|
export const lineAt = (text: string, index: number): number => (index <= 0 ? 1 : text.slice(0, index).split('\n').length);
|
|
/** Line of the first occurrence of `needle` (1 when not found). */
|
|
export const lineOf = (text: string, needle: string | RegExp): number => {
|
|
const i = typeof needle === 'string' ? text.indexOf(needle) : text.search(needle);
|
|
return i < 0 ? 1 : lineAt(text, i);
|
|
};
|
|
|
|
// ---- plist ----
|
|
|
|
type PV = string | number | boolean | PV[] | { [k: string]: PV };
|
|
|
|
/** Minimal XML plist reader: the value tree, plus the line of every top-level key. Never throws (bad input -> {}). */
|
|
export function parsePlist(xml: string): { value: Record<string, PV>; lines: Map<string, number> } {
|
|
const lines = new Map<string, number>();
|
|
const re = /<(\/?)(dict|array|key|string|integer|real|true|false|date|data)(\s*\/)?>|<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<\/?plist[^>]*>/g;
|
|
const toks: { tag: string; close: boolean; self: boolean; at: number; end: number }[] = [];
|
|
for (const m of xml.matchAll(re)) if (m[2]) toks.push({ tag: m[2], close: m[1] === '/', self: !!m[3] || m[2] === 'true' || m[2] === 'false', at: m.index!, end: m.index! + m[0].length });
|
|
let i = 0;
|
|
const unesc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\'').replace(/&/g, '&');
|
|
const readText = (tag: string): string => {
|
|
const open = toks[i]!;
|
|
if (open.self) { i++; return ''; }
|
|
const close = toks[i + 1];
|
|
i += 2;
|
|
return close && close.tag === tag && close.close ? unesc(xml.slice(open.end, close.at)) : '';
|
|
};
|
|
const value = (depth: number): PV => {
|
|
const t = toks[i];
|
|
if (!t || depth > 30) { i++; return ''; }
|
|
switch (t.tag) {
|
|
case 'true': i++; if (toks[i]?.tag === 'true' && toks[i]!.close) i++; return true;
|
|
case 'false': i++; if (toks[i]?.tag === 'false' && toks[i]!.close) i++; return false;
|
|
case 'integer': case 'real': return Number(readText(t.tag));
|
|
case 'string': case 'date': case 'data': return readText(t.tag);
|
|
case 'array': {
|
|
i++;
|
|
const a: PV[] = [];
|
|
if (t.self) return a;
|
|
while (i < toks.length && !(toks[i]!.tag === 'array' && toks[i]!.close)) a.push(value(depth + 1));
|
|
i++;
|
|
return a;
|
|
}
|
|
case 'dict': {
|
|
i++;
|
|
const o: Record<string, PV> = {};
|
|
if (t.self) return o;
|
|
while (i < toks.length && !(toks[i]!.tag === 'dict' && toks[i]!.close)) {
|
|
if (toks[i]!.tag !== 'key') { i++; continue; }
|
|
const at = toks[i]!.at;
|
|
const k = readText('key');
|
|
if (depth === 0) lines.set(k, lineAt(xml, at));
|
|
o[k] = value(depth + 1);
|
|
}
|
|
i++;
|
|
return o;
|
|
}
|
|
default: i++; return '';
|
|
}
|
|
};
|
|
while (i < toks.length && toks[i]!.tag !== 'dict') i++;
|
|
const v = i < toks.length ? value(0) : {};
|
|
return { value: (v && typeof v === 'object' && !Array.isArray(v) ? v : {}) as Record<string, PV>, lines };
|
|
}
|
|
|
|
// ---- dependency files ----
|
|
|
|
function podsLock(file: string, text: string): DepRef[] {
|
|
const out: DepRef[] = [];
|
|
const pods = /^PODS:\n([\s\S]*?)(?=\n\S|$(?![\s\S]))/m.exec(text)?.[1] ?? '';
|
|
const off = text.indexOf(pods);
|
|
for (const m of pods.matchAll(/^ {2}- "?([A-Za-z0-9_.+\-/]+) \(([^)]+)\)"?:?$/gm))
|
|
out.push({ eco: 'cocoapods', name: m[1]!, version: m[2]!, file, line: lineAt(text, off + m.index!) });
|
|
return out;
|
|
}
|
|
function podfile(file: string, text: string): DepRef[] {
|
|
return [...text.matchAll(/^\s*pod\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/gm)].map(m => ({ eco: 'cocoapods' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) }));
|
|
}
|
|
function packageResolved(file: string, text: string): DepRef[] {
|
|
try {
|
|
const j = JSON.parse(text) as { pins?: { identity?: string; location?: string; state?: { version?: string } }[]; object?: { pins?: { package?: string; repositoryURL?: string; state?: { version?: string } }[] } };
|
|
const pins = j.pins ?? j.object?.pins?.map(p => ({ identity: p.package, location: p.repositoryURL, state: p.state })) ?? [];
|
|
return pins.map(p => ({ eco: 'spm' as const, name: p.location ?? p.identity ?? '', version: p.state?.version ?? null, file, line: lineOf(text, p.location ?? p.identity ?? '') }));
|
|
} catch { return []; }
|
|
}
|
|
function pbxproj(file: string, text: string): DepRef[] {
|
|
const out: DepRef[] = [];
|
|
for (const m of text.matchAll(/repositoryURL = "([^"]+)";/g)) out.push({ eco: 'spm', name: m[1]!, version: null, file, line: lineAt(text, m.index!) });
|
|
for (const m of text.matchAll(/isa = XCSwiftPackageProductDependency;[\s\S]{0,300}?productName = "?([A-Za-z0-9_-]+)"?;/g)) out.push({ eco: 'spm', name: m[1]!, version: null, file, line: lineAt(text, m.index!) });
|
|
return out;
|
|
}
|
|
function cartfile(file: string, text: string): DepRef[] {
|
|
return [...text.matchAll(/^\s*(?:github|git|binary)\s+"([^"]+)"(?:\s+"?([^"\n]+)"?)?/gm)].map(m => ({ eco: 'carthage' as const, name: m[1]!, version: m[2]?.trim() ?? null, file, line: lineAt(text, m.index!) }));
|
|
}
|
|
function gradle(file: string, text: string): DepRef[] {
|
|
const out: DepRef[] = [];
|
|
for (const m of text.matchAll(/(?:implementation|api|compileOnly|runtimeOnly|kapt|ksp|annotationProcessor|classpath)\s*\(?\s*(?:platform\(\s*)?["']([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+)(?::([^"'@]+))?/g))
|
|
out.push({ eco: 'gradle', name: `${m[1]}:${m[2]}`, version: m[3] ?? null, file, line: lineAt(text, m.index!) });
|
|
return out;
|
|
}
|
|
function versionCatalog(file: string, text: string): DepRef[] {
|
|
const versions = new Map<string, string>();
|
|
const vsec = /^\[versions\]\n([\s\S]*?)(?=^\[|$(?![\s\S]))/m.exec(text)?.[1] ?? '';
|
|
for (const m of vsec.matchAll(/^\s*([A-Za-z0-9_.\-]+)\s*=\s*"([^"]+)"/gm)) versions.set(m[1]!, m[2]!);
|
|
const out: DepRef[] = [];
|
|
for (const m of text.matchAll(/^\s*[A-Za-z0-9_.\-]+\s*=\s*\{([^}]*)\}/gm)) {
|
|
const body = m[1]!;
|
|
const mod = /module\s*=\s*"([^":]+):([^"]+)"/.exec(body);
|
|
const grp = /group\s*=\s*"([^"]+)"/.exec(body), nm = /name\s*=\s*"([^"]+)"/.exec(body);
|
|
const plugin = /\bid\s*=\s*"(com\.android\.(?:application|library))"/.exec(body);
|
|
const name = mod ? `${mod[1]}:${mod[2]}` : grp && nm ? `${grp[1]}:${nm[1]}` : plugin ? 'com.android.application' : null;
|
|
if (!name) continue;
|
|
const ver = /version\.ref\s*=\s*"([^"]+)"/.exec(body)?.[1];
|
|
out.push({ eco: 'gradle', name, version: ver ? versions.get(ver) ?? null : /version\s*=\s*"([^"]+)"/.exec(body)?.[1] ?? null, file, line: lineAt(text, m.index!) });
|
|
}
|
|
for (const m of text.matchAll(/^\s*[A-Za-z0-9_.\-]+\s*=\s*"([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+):([^"]+)"/gm))
|
|
out.push({ eco: 'gradle', name: `${m[1]}:${m[2]}`, version: m[3]!, file, line: lineAt(text, m.index!) });
|
|
return out;
|
|
}
|
|
function packageJson(file: string, text: string): DepRef[] {
|
|
try {
|
|
const j = JSON.parse(text) as { dependencies?: Record<string, string> };
|
|
return Object.entries(j.dependencies ?? {}).map(([n, v]) => ({ eco: 'npm' as const, name: n, version: v, file, line: lineOf(text, `"${n}"`) }));
|
|
} catch { return []; }
|
|
}
|
|
/** `s.dependency 'IronSourceSDK', '~> 9.6'` lines of a podspec (a native plugin's own CocoaPods dependencies). */
|
|
function podspec(file: string, text: string): DepRef[] {
|
|
return [...text.matchAll(/^\s*\w+\.dependency\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/gm)].map(m => ({ eco: 'cocoapods' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) }));
|
|
}
|
|
/** `.package(url: "...", from: "1.0")` lines of a Package.swift. */
|
|
function packageSwift(file: string, text: string): DepRef[] {
|
|
return [...text.matchAll(/\.package\(\s*(?:name:\s*"[^"]*"\s*,\s*)?url:\s*"([^"]+)"\s*,?\s*(?:(?:from|exact):\s*)?(?:"([^"]+)")?/g)]
|
|
.map(m => ({ eco: 'spm' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) }));
|
|
}
|
|
/** The native dependencies an npm plugin package brings (its podspec, Package.swift and android/build.gradle), with
|
|
* paths relative to the scanned root. Capacitor / Cordova / React Native plugins wrap the real SDK this way. */
|
|
function pluginNativeDeps(root: string, dir: string): DepRef[] {
|
|
const out: DepRef[] = [];
|
|
const rd = (p: string): string => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
const rel = (p: string) => relative(root, p).split(sep).join('/');
|
|
let names: string[] = [];
|
|
try { names = readdirSync(dir); } catch { return out; }
|
|
for (const n of names) {
|
|
if (n.endsWith('.podspec')) out.push(...podspec(rel(join(dir, n)), rd(join(dir, n))));
|
|
else if (n === 'Package.swift') out.push(...packageSwift(rel(join(dir, n)), rd(join(dir, n))));
|
|
}
|
|
for (const g of ['android/build.gradle', 'android/build.gradle.kts']) { const t = rd(join(dir, g)); if (t) out.push(...gradle(rel(join(dir, g)), t)); }
|
|
return out;
|
|
}
|
|
|
|
/** Built output (bundles, dist / build folders, minified files, the Capacitor webDir): low-trust evidence for what the
|
|
* app collects; the own sources are preferred and these are only a fallback. */
|
|
export function isBuilt(path: string, builtDirs: readonly string[] = []): boolean {
|
|
return /(^|\/)(bundles?|dist|build|out|www|\.output|\.vite)\//.test(path) || /\.min\.(js|css)$|\.map$|\.(bundle|chunk)\.js$/.test(path)
|
|
|| builtDirs.some(d => path.startsWith(`${d}/`));
|
|
}
|
|
|
|
/** grep the own sources first; built output (isBuilt) only when they have no match. `built` marks that fallback. */
|
|
export function grepOwn(p: Pick<ProjectFacts, 'sources' | 'builtDirs'>, re: RegExp, max = 5): { hits: Hit[]; built: boolean } {
|
|
const own = grep(p.sources, re, max, f => !isBuilt(f, p.builtDirs));
|
|
if (own.length) return { hits: own, built: false };
|
|
const b = grep(p.sources, re, max, f => isBuilt(f, p.builtDirs));
|
|
return { hits: b, built: b.length > 0 };
|
|
}
|
|
|
|
function pubspec(file: string, text: string): DepRef[] {
|
|
const sec = /^dependencies:\n([\s\S]*?)(?=^\S|$(?![\s\S]))/m.exec(text);
|
|
if (!sec) return [];
|
|
const off = sec.index + 'dependencies:\n'.length;
|
|
return [...sec[1]!.matchAll(/^ {2}([a-z0-9_]+):\s*([^\n#]*)/gm)].map(m => ({ eco: 'pub' as const, name: m[1]!, version: m[2]!.trim() || null, file, line: lineAt(text, off + m.index!) }));
|
|
}
|
|
function pubspecLock(text: string): Map<string, string> {
|
|
const out = new Map<string, string>();
|
|
for (const m of text.matchAll(/^ {2}([a-z0-9_]+):\n(?: {4}.*\n)*? {4}version: "([^"]+)"/gm)) out.set(m[1]!, m[2]!);
|
|
return out;
|
|
}
|
|
function unityManifest(file: string, text: string): DepRef[] {
|
|
try {
|
|
const j = JSON.parse(text) as { dependencies?: Record<string, string> };
|
|
return Object.entries(j.dependencies ?? {}).map(([n, v]) => ({ eco: 'unity' as const, name: n, version: v, file, line: lineOf(text, `"${n}"`) }));
|
|
} catch { return []; }
|
|
}
|
|
/** External Dependency Manager (Assets/.../Editor/*Dependencies.xml): the pods and Gradle packages a Unity plugin adds. */
|
|
function edmXml(file: string, text: string): DepRef[] {
|
|
const out: DepRef[] = [];
|
|
for (const m of text.matchAll(/<iosPod\s+name="([^"]+)"(?:\s+version="([^"]+)")?/g)) out.push({ eco: 'cocoapods', name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) });
|
|
for (const m of text.matchAll(/<androidPackage\s+spec="([^":]+):([^":]+)(?::([^"]+))?"/g)) out.push({ eco: 'gradle', name: `${m[1]}:${m[2]}`, version: m[3] ?? null, file, line: lineAt(text, m.index!) });
|
|
return out;
|
|
}
|
|
function cordovaXml(file: string, text: string): DepRef[] {
|
|
return [...text.matchAll(/<plugin\s+name="([^"]+)"(?:\s+spec="([^"]+)")?/g)].map(m => ({ eco: 'npm' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) }));
|
|
}
|
|
|
|
// ---- Info.plist key sources other than Info.plist ----
|
|
|
|
const UNITY_KEYS: Record<string, string> = { cameraUsageDescription: 'NSCameraUsageDescription', locationUsageDescription: 'NSLocationWhenInUseUsageDescription', microphoneUsageDescription: 'NSMicrophoneUsageDescription' };
|
|
const GODOT_KEYS: Record<string, string> = {
|
|
'privacy/camera_usage_description': 'NSCameraUsageDescription', 'privacy/microphone_usage_description': 'NSMicrophoneUsageDescription',
|
|
'privacy/photolibrary_usage_description': 'NSPhotoLibraryUsageDescription',
|
|
};
|
|
|
|
export function detectProject(root: string): ProjectFacts {
|
|
const bins: string[] = [];
|
|
const files = listFiles(root, bins);
|
|
const cache = new Map<string, string>();
|
|
const read = (f: string): string => {
|
|
if (!cache.has(f)) { try { cache.set(f, readFileSync(join(root, f), 'utf8')); } catch { cache.set(f, ''); } }
|
|
return cache.get(f)!;
|
|
};
|
|
const has = (re: RegExp) => files.some(f => re.test(f));
|
|
const deps: DepRef[] = [];
|
|
const plist = new Map<string, PlistEntry>();
|
|
const plistSources: string[] = [];
|
|
const entitlements = new Map<string, PlistEntry>();
|
|
const privacyManifests: Manifest[] = [];
|
|
const setKey = (m: Map<string, PlistEntry>, key: string, value: unknown, at: Loc) => { if (!m.has(key)) m.set(key, { key, value, at }); };
|
|
const android: ProjectFacts['androidInfo'] = { manifests: [], permissions: [], services: [], targetSdk: null, targetSdkFrom: null, formFactor: 'phone',
|
|
removedPermissions: [], components: [], extractNativeLibs: null, mergedManifest: null, accessibilityConfigs: [], strings: [] };
|
|
const native: ProjectFacts['native'] = { libs: bins, ndkVersion: null, ndkFrom: null, agp: null, nativeBuild: null, flexiblePageSizes: null, legacyPackaging: null, unityVersion: null };
|
|
const sdkHits: { value: number; at: Loc; prio: number }[] = [];
|
|
let remoteStart: ProjectFacts['remoteStart'] = null;
|
|
const sources: { path: string; text: string }[] = [];
|
|
const stacks = new Set<Stack>();
|
|
const builtDirs: string[] = [];
|
|
let pubLock = new Map<string, string>();
|
|
|
|
for (const f of files) {
|
|
const n = basename(f);
|
|
const testy = /(^|\/)(Tests?|UITests|[A-Za-z]+Tests|test|androidTest|__tests__|Example|Samples?)\//.test(f);
|
|
if (n === 'Info.plist' && !testy && !/\.(app|appex)\//.test(f)) {
|
|
const { value, lines } = parsePlist(read(f));
|
|
if (Object.keys(value).length) plistSources.push(f);
|
|
for (const [k, v] of Object.entries(value)) setKey(plist, k, v, { file: f, line: lines.get(k) ?? 1 });
|
|
} else if (n.endsWith('.entitlements')) {
|
|
const { value, lines } = parsePlist(read(f));
|
|
for (const [k, v] of Object.entries(value)) setKey(entitlements, k, v, { file: f, line: lines.get(k) ?? 1 });
|
|
} else if (n === 'PrivacyInfo.xcprivacy') { if (!isBuildCopyPath(f)) privacyManifests.push(parseManifest(f, read(f))); }
|
|
else if (n === 'project.pbxproj') {
|
|
const t = read(f);
|
|
deps.push(...pbxproj(f, t));
|
|
let found = false;
|
|
for (const m of t.matchAll(/INFOPLIST_KEY_([A-Za-z]+) = ("(?:[^"\\]|\\.)*"|[^;\n]*);/g)) {
|
|
found = true;
|
|
setKey(plist, m[1]!, m[2]!.replace(/^"|"$/g, '').replace(/\\"/g, '"'), { file: f, line: lineAt(t, m.index!) });
|
|
}
|
|
if (found) plistSources.push(f);
|
|
} else if (n === 'Podfile.lock') deps.push(...podsLock(f, read(f)));
|
|
else if (n === 'Podfile') deps.push(...podfile(f, read(f)));
|
|
else if (n === 'Package.resolved') deps.push(...packageResolved(f, read(f)));
|
|
else if (n === 'Cartfile' || n === 'Cartfile.resolved') deps.push(...cartfile(f, read(f)));
|
|
else if (/^(build\.gradle(\.kts)?|variables\.gradle|settings\.gradle(\.kts)?)$/.test(n)) {
|
|
const t = read(f);
|
|
deps.push(...gradle(f, t));
|
|
for (const m of t.matchAll(/\btargetSdk(?:Version)?\s*(?:=|\s)\s*\(?\s*(\d{2})\b/g))
|
|
sdkHits.push({ value: Number(m[1]), at: { file: f, line: lineAt(t, m.index!) }, prio: /(^|\/)app\/|variables\.gradle/.test(f) ? 0 : 1 });
|
|
if (/targetSdk(?:Version)?\s*(?:=|\s)\s*flutter\.targetSdkVersion/.test(t)) android.targetSdkFrom = 'the Flutter SDK (flutter.targetSdkVersion)';
|
|
gradleNative(f, t, native, deps);
|
|
} else if (n === 'libs.versions.toml') {
|
|
const t = read(f);
|
|
deps.push(...versionCatalog(f, t));
|
|
for (const m of t.matchAll(/^\s*(?:target-?sdk|targetSdk|android-?target-?sdk)\w*\s*=\s*"(\d{2})"/gim)) sdkHits.push({ value: Number(m[1]), at: { file: f, line: lineAt(t, m.index!) }, prio: 0 });
|
|
const nd = /^\s*ndk(?:-?version)?\s*=\s*"(\d+\.[\d.]+)"/im.exec(t);
|
|
if (nd && !native.ndkVersion) native.ndkVersion = { value: nd[1]!, at: { file: f, line: lineAt(t, nd.index) } };
|
|
} else if (n === 'AndroidManifest.xml' && /(^|\/)src\/main\/AndroidManifest\.xml$|^AndroidManifest\.xml$|Plugins\/Android\/AndroidManifest\.xml$/.test(f) && !testy) {
|
|
android.manifests.push(f);
|
|
readManifest(f, read(f), android);
|
|
} else if (/\/res\/xml\/[^/]+\.xml$/.test(f) && read(f).includes('<accessibility-service')) {
|
|
const t = read(f);
|
|
const m = /<accessibility-service\b[^>]*/.exec(t)!;
|
|
android.accessibilityConfigs.push({ at: { file: f, line: lineAt(t, m.index) }, isTool: /android:isAccessibilityTool="true"/.test(m[0]) });
|
|
} else if (n === 'strings.xml' && /(^|\/)res\/values[^/]*\/strings\.xml$/.test(f) && !testy) {
|
|
android.strings.push({ path: f, text: read(f) });
|
|
} else if (n === 'CMakeLists.txt' || n === 'Android.mk' || n === 'Application.mk') {
|
|
const t = read(f);
|
|
if (!native.nativeBuild) native.nativeBuild = { file: f, line: 1 };
|
|
const fl = FLEXIBLE_PAGES.exec(t);
|
|
if (fl && !native.flexiblePageSizes) native.flexiblePageSizes = { file: f, line: lineAt(t, fl.index) };
|
|
} else if (n === 'ProjectVersion.txt' && /(^|\/)ProjectSettings\//.test(f)) {
|
|
const t = read(f);
|
|
const m = /^m_EditorVersion:\s*(\S+)/m.exec(t);
|
|
if (m) native.unityVersion = { value: m[1]!, at: { file: f, line: lineAt(t, m.index) } };
|
|
} else if (n === 'package.json' && !f.includes('/node_modules/')) {
|
|
const t = read(f);
|
|
const d = packageJson(f, t);
|
|
deps.push(...d);
|
|
// the native SDKs behind each plugin, resolved like Node does (node_modules next to this package.json, then up
|
|
// to the root, then workspace packages): apps/native/node_modules in a monorepo too
|
|
for (const x of d) {
|
|
const dir = resolvePackage(x.name, join(root, dirname(f)), root);
|
|
if (dir) deps.push(...pluginNativeDeps(root, dir));
|
|
}
|
|
const names = new Set(d.map(x => x.name));
|
|
if (names.has('react-native')) stacks.add('react-native');
|
|
if (names.has('expo')) stacks.add('expo');
|
|
if ([...names].some(x => x.startsWith('@capacitor/'))) stacks.add('capacitor');
|
|
if (names.has('cordova-android') || names.has('cordova-ios')) stacks.add('cordova');
|
|
} else if ((n === 'app.json' || n === 'app.config.json') && !f.includes('/')) {
|
|
const t = read(f);
|
|
try {
|
|
const j = JSON.parse(t) as { expo?: { ios?: { infoPlist?: Record<string, unknown>; usesAppleSignIn?: boolean }; android?: { permissions?: string[] }; plugins?: unknown[] } };
|
|
if (j.expo) {
|
|
stacks.add('expo');
|
|
plistSources.push(f);
|
|
for (const [k, v] of Object.entries(j.expo.ios?.infoPlist ?? {})) setKey(plist, k, v, { file: f, line: lineOf(t, `"${k}"`) });
|
|
if (j.expo.ios?.usesAppleSignIn) setKey(entitlements, 'com.apple.developer.applesignin', ['Default'], { file: f, line: lineOf(t, '"usesAppleSignIn"') });
|
|
for (const p of j.expo.android?.permissions ?? []) android.permissions.push({ name: p.includes('.') ? p : `android.permission.${p}`, at: { file: f, line: lineOf(t, `"${p}"`) } });
|
|
for (const p of j.expo.plugins ?? []) {
|
|
if (Array.isArray(p) && p[0] === 'expo-build-properties') {
|
|
const ts = (p[1] as { android?: { targetSdkVersion?: number } } | undefined)?.android?.targetSdkVersion;
|
|
if (ts) sdkHits.push({ value: ts, at: { file: f, line: lineOf(t, '"targetSdkVersion"') }, prio: 0 });
|
|
}
|
|
}
|
|
if (!android.targetSdkFrom) android.targetSdkFrom = 'the Expo SDK version (expo-build-properties android.targetSdkVersion overrides it)';
|
|
}
|
|
} catch { /* not JSON */ }
|
|
} else if (/^capacitor\.config\.(json|ts|js)$/.test(n)) {
|
|
stacks.add('capacitor');
|
|
const t = read(f);
|
|
const wd = /webDir\s*["']?\s*:\s*["']([^"']+)["']/.exec(t)?.[1];
|
|
if (wd) builtDirs.push(join(dirname(f), wd).split(sep).join('/').replace(/^\.\//, '').replace(/\/$/, ''));
|
|
const m = /server\s*["']?\s*:\s*\{[\s\S]*?\burl["']?\s*:\s*["'](https?:\/\/[^"']+)["']/.exec(t);
|
|
if (m && !/^https?:\/\/(localhost|127\.0\.0\.1|10\.0\.2\.2|192\.168\.)/.test(m[1]!)) remoteStart = { url: m[1]!, at: { file: f, line: lineAt(t, m.index! + m[0].indexOf(m[1]!)) } };
|
|
} else if (n === 'config.xml' && /<widget\b/.test(read(f))) {
|
|
stacks.add('cordova');
|
|
const t = read(f);
|
|
deps.push(...cordovaXml(f, t));
|
|
const m = /<content\s+src="(https?:\/\/[^"]+)"/.exec(t);
|
|
if (m && !/localhost|127\.0\.0\.1/.test(m[1]!)) remoteStart = { url: m[1]!, at: { file: f, line: lineAt(t, m.index!) } };
|
|
} else if (n === 'pubspec.yaml') { stacks.add('flutter'); deps.push(...pubspec(f, read(f))); }
|
|
else if (n === 'pubspec.lock') pubLock = pubspecLock(read(f));
|
|
else if (f === 'Packages/manifest.json' || f.endsWith('/Packages/manifest.json')) { stacks.add('unity'); deps.push(...unityManifest(f, read(f))); }
|
|
else if (/Dependencies\.xml$/.test(n) && /\/Editor\//.test(f)) deps.push(...edmXml(f, read(f)));
|
|
else if (n === 'ProjectSettings.asset') {
|
|
stacks.add('unity');
|
|
const t = read(f);
|
|
let any = false;
|
|
for (const [uk, pk] of Object.entries(UNITY_KEYS)) {
|
|
const m = new RegExp(`^\\s*${uk}:[ \\t]*(.*)$`, 'm').exec(t);
|
|
if (m) { any = true; setKey(plist, pk, m[1]!.trim().replace(/^'|'$/g, ''), { file: f, line: lineAt(t, m.index) }); }
|
|
}
|
|
if (any) plistSources.push(f);
|
|
const ts = /^\s*AndroidTargetSdkVersion:\s*(\d+)/m.exec(t);
|
|
if (ts && Number(ts[1]) > 0) sdkHits.push({ value: Number(ts[1]), at: { file: f, line: lineAt(t, ts.index) }, prio: 0 });
|
|
else if (ts) android.targetSdkFrom = 'Unity "Automatic (highest installed)": the Android SDK the build machine has';
|
|
} else if (n === 'export_presets.cfg') {
|
|
stacks.add('godot');
|
|
const t = read(f);
|
|
let any = false;
|
|
for (const [gk, pk] of Object.entries(GODOT_KEYS)) {
|
|
const m = new RegExp(`^${gk.replace('/', '\\/')}="(.*)"`, 'm').exec(t);
|
|
if (m) { any = true; setKey(plist, pk, m[1]!, { file: f, line: lineAt(t, m.index) }); }
|
|
}
|
|
if (any) plistSources.push(f);
|
|
const ts = /^gradle_build\/target_sdk="(\d+)"/m.exec(t);
|
|
if (ts) sdkHits.push({ value: Number(ts[1]), at: { file: f, line: lineAt(t, ts.index) }, prio: 0 });
|
|
for (const m of t.matchAll(/^permissions\/([a-z_]+)=true/gm)) android.permissions.push({ name: `android.permission.${m[1]!.toUpperCase()}`, at: { file: f, line: lineAt(t, m.index!) } });
|
|
}
|
|
// app sources
|
|
if (SRC_EXT.has(extname(n)) && !testy && !/\.(test|spec)\.[cm]?[jt]sx?$/.test(n) && !/\.min\.js$|\.d\.ts$|(^|\/)(Plugins|ThirdParty|third_party|Packages|addons)\//.test(f) && !/\.config\.(ts|js)$/.test(n)) {
|
|
const t = read(f);
|
|
if (t && !t.includes('\u0000')) sources.push({ path: f, text: t });
|
|
}
|
|
}
|
|
// Unity / Godot plugin folders count as dependencies (a folder under Assets/ or addons/)
|
|
for (const d of new Set(files.map(f => /^Assets\/([^/]+)\//.exec(f)?.[1]).filter((x): x is string => !!x)))
|
|
deps.push({ eco: 'unity', name: d, version: null, file: `Assets/${d}/`, line: 1 });
|
|
for (const d of new Set(files.map(f => /^addons\/([^/]+)\//.exec(f)?.[1]).filter((x): x is string => !!x)))
|
|
deps.push({ eco: 'godot', name: d, version: null, file: `addons/${d}/`, line: 1 });
|
|
for (const d of deps) if (d.eco === 'pub' && pubLock.has(d.name)) d.version = pubLock.get(d.name)!;
|
|
|
|
if (has(/\.xcodeproj\/project\.pbxproj$/) && !stacks.has('flutter') && !stacks.has('react-native') && !stacks.has('capacitor') && !stacks.has('cordova') && !stacks.has('unity')) stacks.add('ios-native');
|
|
if (android.manifests.length && !stacks.has('flutter') && !stacks.has('react-native') && !stacks.has('capacitor') && !stacks.has('cordova') && !stacks.has('unity') && !stacks.has('godot')) stacks.add('android-native');
|
|
if (!stacks.size && has(/(^|\/)index\.html$/)) stacks.add('web');
|
|
const ios = has(/\.xcodeproj\/project\.pbxproj$/) || plistSources.length > 0 || stacks.has('expo') || stacks.has('unity') || (stacks.has('godot') && /platform="iOS"/.test(read(files.find(f => basename(f) === 'export_presets.cfg') ?? '')));
|
|
const isAndroid = android.manifests.length > 0 || has(/(^|\/)build\.gradle(\.kts)?$/) || stacks.has('expo') || stacks.has('unity') || (stacks.has('godot') && /platform="Android"/.test(read(files.find(f => basename(f) === 'export_presets.cfg') ?? '')));
|
|
if (sdkHits.length) {
|
|
sdkHits.sort((a, b) => a.prio - b.prio || a.value - b.value);
|
|
android.targetSdk = { value: sdkHits[0]!.value, at: sdkHits[0]!.at };
|
|
}
|
|
|
|
const sdks: DetectedSdk[] = [];
|
|
for (const s of SDKS) {
|
|
const hits = deps.filter(d => matchesSdk(d, s));
|
|
if (hits.length) sdks.push({ sdk: s, deps: hits });
|
|
}
|
|
// the Android Gradle Plugin: buildscript classpath, plugins { id(...) version }, or the version catalog
|
|
const agp = deps.find(d => (d.name === 'com.android.tools.build:gradle' || d.name === 'com.android.application') && d.version && /^\d/.test(d.version));
|
|
if (agp) native.agp = { value: agp.version!, at: { file: agp.file, line: agp.line } };
|
|
// the merged manifest of a local build, when there is one (libraries add permissions there: AD_ID, location, ...)
|
|
if (isAndroid) {
|
|
const merged = findMergedManifest(root, android.manifests, stacks.has('flutter'));
|
|
let t = '';
|
|
if (merged) { try { t = readFileSync(join(root, merged), 'utf8'); } catch { /* unreadable */ } }
|
|
if (merged && t) {
|
|
android.mergedManifest = merged;
|
|
const extra: ProjectFacts['androidInfo'] = { ...android, permissions: [], services: [], components: [], removedPermissions: [], extractNativeLibs: null };
|
|
readManifest(merged, t, extra);
|
|
const removed = new Set(android.removedPermissions.map(x => x.name));
|
|
const have = new Set(android.permissions.map(x => x.name));
|
|
for (const x of extra.permissions) if (!have.has(x.name) && !removed.has(x.name)) { android.permissions.push(x); have.add(x.name); }
|
|
const key = (k: string, n: string) => `${k}:${n.split('.').pop()}`;
|
|
const comps = new Set(android.components.map(x => key(x.kind, x.name)));
|
|
for (const x of extra.components) if (!comps.has(key(x.kind, x.name))) android.components.push(x);
|
|
const svc = new Set(android.services.map(x => key('service', x.name)));
|
|
for (const x of extra.services) if (!svc.has(key('service', x.name))) android.services.push(x);
|
|
}
|
|
}
|
|
return { root, stacks: [...stacks], ios, android: isAndroid, deps, sdks, plist, plistSources, entitlements, privacyManifests, androidInfo: android, native, remoteStart, sources, builtDirs, iosBuild: newestIpaBuild(root), scanned: files.length };
|
|
}
|
|
|
|
/** NDK r27 flag and the linker flag of older NDKs that make native libraries 16 KB aligned. */
|
|
const FLEXIBLE_PAGES = /ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES\s*=?\s*ON|max-page-size=16384/;
|
|
|
|
/** Permissions, components and <application> settings of one AndroidManifest.xml, added to `a`. */
|
|
function readManifest(f: string, t: string, a: ProjectFacts['androidInfo']): void {
|
|
for (const m of t.matchAll(/<uses-permission(?:-sdk-23)?\b([^>]*?)\/?>/g)) {
|
|
const name = /android:name="([^"]+)"/.exec(m[1]!)?.[1];
|
|
if (!name) continue;
|
|
const at = { file: f, line: lineAt(t, m.index!) };
|
|
if (/tools:node="remove"/.test(m[1]!)) a.removedPermissions.push({ name, at });
|
|
else a.permissions.push({ name, at });
|
|
}
|
|
for (const kind of ['activity', 'activity-alias', 'service', 'receiver'] as const) {
|
|
for (const m of t.matchAll(new RegExp(`<${kind}\\b([^>]*?)(/>|>([\\s\\S]*?)</${kind}>)`, 'g'))) {
|
|
const attrs = m[1]!, body = m[3] ?? '';
|
|
if (/tools:node="remove"/.test(attrs)) continue;
|
|
const c: AndroidComponent = {
|
|
kind: kind === 'activity-alias' ? 'activity' : kind,
|
|
name: /android:name="([^"]+)"/.exec(attrs)?.[1] ?? '?',
|
|
permission: /android:permission="([^"]+)"/.exec(attrs)?.[1] ?? null,
|
|
fgsType: /android:foregroundServiceType="([^"]+)"/.exec(attrs)?.[1] ?? null,
|
|
actions: [...body.matchAll(/<action\s+android:name="([^"]+)"/g)].map(x => x[1]!),
|
|
categories: [...body.matchAll(/<category\s+android:name="([^"]+)"/g)].map(x => x[1]!),
|
|
meta: [...body.matchAll(/<meta-data\s+android:name="([^"]+)"/g)].map(x => x[1]!),
|
|
at: { file: f, line: lineAt(t, m.index!) },
|
|
};
|
|
a.components.push(c);
|
|
if (c.kind === 'service') a.services.push({ name: c.name, fgsType: c.fgsType, at: c.at });
|
|
}
|
|
}
|
|
const app = /<application\b[^>]*/.exec(t);
|
|
const ex = app ? /android:extractNativeLibs="(true|false)"/.exec(app[0]) : null;
|
|
if (app && ex && !a.extractNativeLibs) a.extractNativeLibs = { value: ex[1] === 'true', at: { file: f, line: lineAt(t, app.index + ex.index) } };
|
|
if (/android\.hardware\.type\.watch/.test(t)) a.formFactor = 'wear';
|
|
else if (/android\.software\.leanback"[^>]*android:required="true"|android:required="true"[^>]*android\.software\.leanback/.test(t)) a.formFactor = 'tv';
|
|
else if (/android\.hardware\.type\.automotive/.test(t)) a.formFactor = 'automotive';
|
|
}
|
|
|
|
/** NDK version, AGP plugin version, native build and packaging settings in one Gradle file. */
|
|
function gradleNative(f: string, t: string, n: ProjectFacts['native'], deps: DepRef[]): void {
|
|
const nd = /\bndkVersion\s*=?\s*\(?\s*["'](\d+\.[\d.]+)["']/.exec(t);
|
|
if (nd && !n.ndkVersion) n.ndkVersion = { value: nd[1]!, at: { file: f, line: lineAt(t, nd.index) } };
|
|
if (/\bndkVersion\s*=?\s*flutter\.ndkVersion/.test(t)) n.ndkFrom = 'the Flutter SDK (flutter.ndkVersion)';
|
|
const ext = /\bexternalNativeBuild\s*\{/.exec(t);
|
|
if (ext && !n.nativeBuild) n.nativeBuild = { file: f, line: lineAt(t, ext.index) };
|
|
const fl = FLEXIBLE_PAGES.exec(t);
|
|
if (fl && !n.flexiblePageSizes) n.flexiblePageSizes = { file: f, line: lineAt(t, fl.index) };
|
|
const lp = /\buseLegacyPackaging\s*(?:=\s*)?true\b/.exec(t);
|
|
if (lp && !n.legacyPackaging) n.legacyPackaging = { file: f, line: lineAt(t, lp.index) };
|
|
for (const m of t.matchAll(/\bid\s*\(?\s*["']com\.android\.(?:application|library)["']\s*\)?\s*version\s*["']([^"']+)["']/g))
|
|
deps.push({ eco: 'gradle', name: 'com.android.application', version: m[1]!, file: f, line: lineAt(t, m.index!) });
|
|
}
|
|
|
|
/** A merged AndroidManifest.xml of a local build under <module>/build/intermediates (release first), relative to
|
|
* root; Flutter builds into build/app/ at the project root. null when the project was not built here. */
|
|
function findMergedManifest(root: string, manifests: string[], flutter: boolean): string | null {
|
|
const modules = new Set(manifests.filter(m => m.endsWith('src/main/AndroidManifest.xml')).map(m => `${m.slice(0, -'src/main/AndroidManifest.xml'.length)}build/`));
|
|
if (flutter) modules.add('build/app/');
|
|
const found: string[] = [];
|
|
const walk = (dir: string, depth: number) => {
|
|
if (depth > 4) return;
|
|
let names: string[];
|
|
try { names = readdirSync(join(root, dir)); } catch { return; }
|
|
for (const n of names.sort()) {
|
|
if (n === 'AndroidManifest.xml') found.push(`${dir}/${n}`);
|
|
else if (!n.includes('.')) walk(`${dir}/${n}`, depth + 1);
|
|
}
|
|
};
|
|
for (const m of modules) for (const sub of ['merged_manifest', 'merged_manifests', 'packaged_manifests']) walk(`${m}intermediates/${sub}`, 0);
|
|
return found.find(f => /release/i.test(f)) ?? found[0] ?? null;
|
|
}
|
|
|
|
const COMMENT = /^\s*(\/\/|#|\*|\/\*|<!--)/;
|
|
|
|
/** Source lines matching `re` (comments skipped), at most `max`. */
|
|
export function grep(sources: { path: string; text: string }[], re: RegExp, max = 5, filter?: (path: string) => boolean): Hit[] {
|
|
const out: Hit[] = [];
|
|
for (const s of sources) {
|
|
if (filter && !filter(s.path)) continue;
|
|
if (!re.test(s.text)) continue;
|
|
const lines = s.text.split('\n');
|
|
for (let i = 0; i < lines.length && out.length < max; i++) {
|
|
const l = lines[i]!;
|
|
if (re.test(l) && !COMMENT.test(l)) out.push({ file: s.path, line: i + 1, text: l.trim().slice(0, 160) });
|
|
}
|
|
if (out.length >= max) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** "file:line" of a location. */
|
|
export const where = (l: Loc): string => `${l.file}:${l.line}`;
|
|
|
|
/** Ecosystems in a readable order for the report. */
|
|
export const ECO_LABEL: Record<Eco, string> = { cocoapods: 'CocoaPods', spm: 'SwiftPM', carthage: 'Carthage', gradle: 'Gradle', npm: 'npm', pub: 'pub', unity: 'Unity', godot: 'Godot' };
|
|
|
|
/** The build tools of the newest IPA under build/ios/export, or null (no IPA, or unreadable). */
|
|
function newestIpaBuild(root: string): ProjectFacts['iosBuild'] {
|
|
try {
|
|
const ipa = exportedIpas(join(root, 'build/ios/export'))[0];
|
|
if (!ipa) return null;
|
|
const i = ipaInfo(ipa);
|
|
return { file: relative(root, ipa).split(sep).join('/'), xcode: i.xcode, sdkName: i.sdkName, platformVersion: i.platformVersion };
|
|
} catch { return null; }
|
|
}
|