mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
Consume Toybox’s single NAME separator without stripping source whitespace. Keep MCP subprocess fixtures independent of ambient PATH and validate calls through the existing named-schema helper. Validation: focused ADB provider/parser and MCP tests passed; no real Android device was used.
635 lines
21 KiB
TypeScript
635 lines
21 KiB
TypeScript
import { collectNumericIssues } from "./AdbNumericIssues.js";
|
|
import type {
|
|
AdbDisplayNumericIssues,
|
|
AdbPackageNumericIssues,
|
|
AdbWindowFocusContext,
|
|
AdbWindowFocusObservation,
|
|
} from "../domain/adb/adbDeviceAnalysis.js";
|
|
import {
|
|
collectUnparsedLines,
|
|
type AdbUnparsedLines,
|
|
} from "./AdbUnparsedLines.js";
|
|
|
|
/**
|
|
* Parsers for fixed observation commands REA runs through `adb shell`.
|
|
*
|
|
* Formats follow toybox output on Android 8+ (verified against an Android 14
|
|
* emulator). Unparseable lines are sampled and counted rather than dropped so
|
|
* callers can see the format drift instead of silently losing rows.
|
|
*/
|
|
|
|
export interface AdbProcessEntry {
|
|
readonly user: string;
|
|
readonly pid: number;
|
|
readonly ppid: number | null;
|
|
readonly rss: number | null;
|
|
readonly state: string | null;
|
|
readonly name: string;
|
|
}
|
|
|
|
// Toybox emits one separator before the unpadded final NAME column.
|
|
// Remaining whitespace belongs to argv[0], not the table layout.
|
|
const PROCESS_LINE =
|
|
/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+) (.+)$/u;
|
|
|
|
const safeInteger = (text: string): number | null => {
|
|
const value = Number(text);
|
|
return Number.isSafeInteger(value) ? value : null;
|
|
};
|
|
|
|
/** Parse `ps -A` output; the header line is preserved verbatim as `columns`. */
|
|
export const parseProcessListOutput = (
|
|
stdout: string,
|
|
): {
|
|
columns: string | null;
|
|
processes: readonly AdbProcessEntry[];
|
|
unparsedLines: AdbUnparsedLines;
|
|
} => {
|
|
const processes: AdbProcessEntry[] = [];
|
|
const unparsed = collectUnparsedLines();
|
|
let columns: string | null = null;
|
|
for (const line of stdout.split(/\r?\n/u)) {
|
|
if (line.trim() === "") continue;
|
|
if (/^USER\s+PID\s+PPID\b/u.test(line)) {
|
|
columns = line.trim();
|
|
continue;
|
|
}
|
|
const match = PROCESS_LINE.exec(line);
|
|
if (match === null) {
|
|
unparsed.add(line);
|
|
continue;
|
|
}
|
|
const pid = safeInteger(match[2]!);
|
|
const ppid = safeInteger(match[3]!);
|
|
const rss = safeInteger(match[5]!);
|
|
if (pid === null || ppid === null || rss === null) unparsed.add(line);
|
|
if (pid === null) continue;
|
|
processes.push({
|
|
user: match[1]!,
|
|
pid,
|
|
ppid,
|
|
rss,
|
|
state: match[8]!,
|
|
name: match[9]!,
|
|
});
|
|
}
|
|
return { columns, processes, unparsedLines: unparsed.facts() };
|
|
};
|
|
|
|
export interface AdbFeatureEntry {
|
|
readonly name: string;
|
|
readonly version: number | null;
|
|
}
|
|
|
|
const FEATURE_LINE = /^feature:([^=\s]+)(?:=(0x[0-9a-fA-F]+|\d+))?$/u;
|
|
|
|
/** Parse `pm list features` output, including hex GL versions. */
|
|
export const parseFeatureListOutput = (
|
|
stdout: string,
|
|
): {
|
|
features: readonly AdbFeatureEntry[];
|
|
unparsedLines: AdbUnparsedLines;
|
|
} => {
|
|
const features: AdbFeatureEntry[] = [];
|
|
const unparsed = collectUnparsedLines();
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (line === "") continue;
|
|
const match = FEATURE_LINE.exec(line);
|
|
if (match === null) {
|
|
unparsed.add(rawLine);
|
|
continue;
|
|
}
|
|
const version = match[2] === undefined ? null : safeInteger(match[2]);
|
|
if (match[2] !== undefined && version === null) unparsed.add(rawLine);
|
|
features.push({
|
|
name: match[1]!,
|
|
version,
|
|
});
|
|
}
|
|
return { features, unparsedLines: unparsed.facts() };
|
|
};
|
|
|
|
export interface AdbServiceEntry {
|
|
readonly name: string;
|
|
readonly interface: string | null;
|
|
}
|
|
|
|
const SERVICE_LINE = /^\s*(?:\d+\s+)?([A-Za-z0-9._$/-]+):\s*\[([^\]]*)\]\s*$/u;
|
|
|
|
/** Parse `service list` output, tolerating numbered and plain formats. */
|
|
export const parseServiceListOutput = (
|
|
stdout: string,
|
|
): {
|
|
services: readonly AdbServiceEntry[];
|
|
unparsedLines: AdbUnparsedLines;
|
|
} => {
|
|
const services: AdbServiceEntry[] = [];
|
|
const unparsed = collectUnparsedLines();
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const line = rawLine.trimEnd();
|
|
if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim()))
|
|
continue;
|
|
const match = SERVICE_LINE.exec(line);
|
|
if (match === null) {
|
|
if (line.trim() !== "") unparsed.add(line.trim());
|
|
continue;
|
|
}
|
|
services.push({
|
|
name: match[1]!,
|
|
interface: match[2] === "" ? null : match[2]!,
|
|
});
|
|
}
|
|
return { services, unparsedLines: unparsed.facts() };
|
|
};
|
|
|
|
const SIZE_DECLARATION = /^(Physical|Override) size:(.*)$/u;
|
|
const SIZE_VALUE = /^\s+(\d+)x(\d+)$/u;
|
|
const DENSITY_DECLARATION = /^(Physical|Override) density:(.*)$/u;
|
|
const DENSITY_VALUE = /^\s+(\d+)$/u;
|
|
const DENSITY_BARE = /^(\d+)$/u;
|
|
|
|
type DisplayNumericIssue = AdbDisplayNumericIssues["sample"][number];
|
|
|
|
/** Parse declarations before admission so a malformed last declaration stays owned. */
|
|
export const parseWmSizeOutput = (
|
|
stdout: string,
|
|
): {
|
|
physical: { readonly width: number; readonly height: number } | null;
|
|
override: { readonly width: number; readonly height: number } | null;
|
|
physicalDeclared: boolean;
|
|
numericIssues: AdbDisplayNumericIssues;
|
|
} => {
|
|
let physical = null;
|
|
let override = null;
|
|
let physicalDeclared = false;
|
|
const issues = collectNumericIssues<DisplayNumericIssue>();
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const declaration = SIZE_DECLARATION.exec(rawLine.trim());
|
|
if (declaration === null) continue;
|
|
const field =
|
|
declaration[1] === "Physical" ? "physical_size" : "override_size";
|
|
if (field === "physical_size") physicalDeclared = true;
|
|
const match = SIZE_VALUE.exec(declaration[2]!);
|
|
const width = match === null ? null : safeInteger(match[1]!);
|
|
const height = match === null ? null : safeInteger(match[2]!);
|
|
const reason =
|
|
match === null
|
|
? "invalid_format"
|
|
: width === null || height === null
|
|
? "unsafe_integer"
|
|
: width <= 0 || height <= 0
|
|
? "nonpositive"
|
|
: null;
|
|
const value =
|
|
reason === null && width !== null && height !== null
|
|
? { width, height }
|
|
: null;
|
|
if (reason !== null)
|
|
issues.add({ command: "wm size", field, reason, raw_line: rawLine });
|
|
if (field === "physical_size") physical = value;
|
|
else override = value;
|
|
}
|
|
return {
|
|
physical,
|
|
override,
|
|
physicalDeclared,
|
|
numericIssues: issues.facts(),
|
|
};
|
|
};
|
|
|
|
/** Labeled physical density dominates the first bare declaration, even when malformed. */
|
|
export const parseWmDensityOutput = (
|
|
stdout: string,
|
|
): {
|
|
physical: number | null;
|
|
override: number | null;
|
|
numericIssues: AdbDisplayNumericIssues;
|
|
} => {
|
|
let physical = null;
|
|
let override = null;
|
|
let bare = null;
|
|
let physicalDeclared = false;
|
|
let bareDeclared = false;
|
|
const issues = collectNumericIssues<DisplayNumericIssue>();
|
|
for (const rawLine of stdout.split(/\r?\n/u)) {
|
|
const trimmed = rawLine.trim();
|
|
const declaration = DENSITY_DECLARATION.exec(trimmed);
|
|
const bareMatch = declaration === null ? DENSITY_BARE.exec(trimmed) : null;
|
|
if (declaration === null && (bareMatch === null || bareDeclared)) continue;
|
|
const field =
|
|
declaration?.[1] === "Override" ? "override_density" : "physical_density";
|
|
const match =
|
|
declaration === null ? bareMatch : DENSITY_VALUE.exec(declaration[2]!);
|
|
const value = match === null ? null : safeInteger(match[1]!);
|
|
const reason =
|
|
match === null
|
|
? "invalid_format"
|
|
: value === null
|
|
? "unsafe_integer"
|
|
: value <= 0
|
|
? "nonpositive"
|
|
: null;
|
|
const admitted = reason === null ? value : null;
|
|
if (reason !== null)
|
|
issues.add({ command: "wm density", field, reason, raw_line: rawLine });
|
|
if (declaration === null) {
|
|
bareDeclared = true;
|
|
bare = admitted;
|
|
} else if (field === "physical_density") {
|
|
physicalDeclared = true;
|
|
physical = admitted;
|
|
} else override = admitted;
|
|
}
|
|
return {
|
|
physical: physicalDeclared ? physical : bare,
|
|
override,
|
|
numericIssues: issues.facts(),
|
|
};
|
|
};
|
|
|
|
/** Own declaration identity and omission before deriving a singular summary. */
|
|
export const parseWindowFocusOutput = (
|
|
stdout: string,
|
|
): AdbWindowFocusObservation => {
|
|
const contexts: AdbWindowFocusContext[] = [];
|
|
const observedLines: string[] = [];
|
|
let current: AdbWindowFocusContext | undefined;
|
|
for (const line of stdout.split(/\r?\n/u)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("Display:")) {
|
|
current = {
|
|
scope: "display",
|
|
display_id:
|
|
/^Display:\s+mDisplayId=(-?[0-9]+)(?:\s|$)/u.exec(trimmed)?.[1] ??
|
|
null,
|
|
header_line: line,
|
|
declarations: [],
|
|
};
|
|
contexts.push(current);
|
|
observedLines.push(line);
|
|
continue;
|
|
}
|
|
const field = trimmed.startsWith("mCurrentFocus=")
|
|
? "mCurrentFocus"
|
|
: trimmed.startsWith("mFocusedApp=")
|
|
? "mFocusedApp"
|
|
: undefined;
|
|
if (field === undefined) continue;
|
|
if (current === undefined) {
|
|
current = {
|
|
scope: "unscoped",
|
|
display_id: null,
|
|
header_line: null,
|
|
declarations: [],
|
|
};
|
|
contexts.push(current);
|
|
}
|
|
const value = trimmed.slice(field.length + 1).trim();
|
|
current.declarations.push({
|
|
field,
|
|
value: value === "null" ? null : value,
|
|
raw_line: line,
|
|
});
|
|
observedLines.push(line);
|
|
}
|
|
const windows =
|
|
contexts.length === 1
|
|
? contexts[0]!.declarations.filter(
|
|
({ field }) => field === "mCurrentFocus",
|
|
)
|
|
: [];
|
|
const apps =
|
|
contexts.length === 1
|
|
? contexts[0]!.declarations.filter(({ field }) => field === "mFocusedApp")
|
|
: [];
|
|
const declared = contexts.some(({ declarations }) => declarations.length > 0);
|
|
const summary = !declared
|
|
? "unavailable"
|
|
: contexts.length === 1 && windows.length <= 1 && apps.length <= 1
|
|
? "single_context"
|
|
: "ambiguous";
|
|
return {
|
|
focused_window:
|
|
summary === "single_context" ? (windows[0]?.value ?? null) : null,
|
|
focused_app: summary === "single_context" ? (apps[0]?.value ?? null) : null,
|
|
focus_summary: summary,
|
|
focus_contexts: contexts,
|
|
observed_lines: observedLines,
|
|
};
|
|
};
|
|
|
|
export interface AdbPackageDetails {
|
|
readonly versionName: string | null;
|
|
/** Signed 64-bit long version code exactly as Package Manager prints it. */
|
|
readonly versionCode: string | null;
|
|
readonly firstInstallTime: string | null;
|
|
readonly lastUpdateTime: string | null;
|
|
readonly installerPackageName: string | null;
|
|
readonly userId: number | null;
|
|
readonly pkgFlags: string | null;
|
|
readonly requestedPermissions: number | null;
|
|
readonly numericIssues: AdbPackageNumericIssues;
|
|
}
|
|
|
|
const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u;
|
|
|
|
/**
|
|
* Package Manager prints its `String` fields by concatenation, so an absent
|
|
* `installerPackageName` appears as the literal text `null`
|
|
* (morluto/rea#3943). No installer can be named "null", so an empty value or
|
|
* that sentinel is the absence of the fact and becomes the nullable result's
|
|
* null. `versionName` keeps the printed text: a manifest may declare the
|
|
* string "null" on purpose, and the parser cannot tell the two apart.
|
|
*/
|
|
const absentInstallerOrName = (value: string): string | null =>
|
|
value === "" || value === "null" ? null : value;
|
|
|
|
/**
|
|
* Project `dumpsys package PACKAGE` output. Only the listed fields are
|
|
* extracted; the input is version-dependent and everything else is ignored
|
|
* by design rather than guessed.
|
|
*/
|
|
export const parseDumpsysPackageOutput = (
|
|
stdout: string,
|
|
): AdbPackageDetails => {
|
|
const details: {
|
|
versionName: string | null;
|
|
versionCode: string | null;
|
|
firstInstallTime: string | null;
|
|
lastUpdateTime: string | null;
|
|
installerPackageName: string | null;
|
|
userId: number | null;
|
|
pkgFlags: string | null;
|
|
} = {
|
|
versionName: null,
|
|
versionCode: null,
|
|
firstInstallTime: null,
|
|
lastUpdateTime: null,
|
|
installerPackageName: null,
|
|
userId: null,
|
|
pkgFlags: null,
|
|
};
|
|
const issues =
|
|
collectNumericIssues<AdbPackageNumericIssues["sample"][number]>();
|
|
let permissionsIndent: number | null = null;
|
|
let hasPermissionsSection = false;
|
|
let permissions = 0;
|
|
let parsedPackageAvailable: boolean | null = null;
|
|
for (const line of stdout.split(/\r?\n/u)) {
|
|
// Hidden system packages describe a separate section, not the requested package.
|
|
if (/^\s*Hidden system packages:\s*$/u.test(line)) break;
|
|
const permissionsHeader = /^(\s*)requested permissions:\s*$/u.exec(line);
|
|
if (permissionsHeader !== null) {
|
|
hasPermissionsSection = true;
|
|
permissionsIndent = permissionsHeader[1]!.length;
|
|
continue;
|
|
}
|
|
if (permissionsIndent !== null) {
|
|
const indentation = /^\s*/u.exec(line)![0].length;
|
|
if (line.trim() !== "" && indentation > permissionsIndent) {
|
|
// Android 10 and 11 may append ": restricted=true" to a row. The
|
|
// section is indentation-delimited; permission strings are arbitrary.
|
|
permissions += 1;
|
|
continue;
|
|
}
|
|
if (line.trim() === "" && indentation > permissionsIndent) continue;
|
|
permissionsIndent = null;
|
|
}
|
|
const match = DETAIL_LINE.exec(line);
|
|
if (match === null) continue;
|
|
const key = match[1]!;
|
|
// versionName is an arbitrary manifest string, printed verbatim by AOSP.
|
|
// Only the fields with their own structured grammar are normalized below.
|
|
const rawValue = match[2]!;
|
|
const value = rawValue.trim();
|
|
if (key === "pkg" && parsedPackageAvailable === null) {
|
|
if (value === "null") parsedPackageAvailable = false;
|
|
else if (value.startsWith("Package{") && value.endsWith("}"))
|
|
parsedPackageAvailable = true;
|
|
} else if (key === "versionName" && details.versionName === null)
|
|
details.versionName = rawValue;
|
|
else if (key === "versionCode" && details.versionCode === null) {
|
|
const parsed = /^(-?\d+)/u.exec(value);
|
|
if (parsed !== null) details.versionCode = parsed[1]!;
|
|
} else if (key === "firstInstallTime" && details.firstInstallTime === null)
|
|
details.firstInstallTime = value === "" ? null : value;
|
|
else if (key === "lastUpdateTime" && details.lastUpdateTime === null)
|
|
details.lastUpdateTime = value === "" ? null : value;
|
|
else if (
|
|
key === "installerPackageName" &&
|
|
details.installerPackageName === null
|
|
)
|
|
details.installerPackageName = absentInstallerOrName(value);
|
|
else if (key === "userId" || key === "appId") {
|
|
const parsed = /^(\d+)/u.exec(value);
|
|
const admitted = parsed === null ? null : safeInteger(parsed[1]!);
|
|
if (admitted === null)
|
|
issues.add({
|
|
command: "dumpsys package",
|
|
field: "user_id",
|
|
reason: parsed === null ? "invalid_format" : "unsafe_integer",
|
|
raw_line: line,
|
|
});
|
|
else if (details.userId === null) details.userId = admitted;
|
|
} else if (key === "pkgFlags" && details.pkgFlags === null)
|
|
details.pkgFlags = value === "" ? null : value;
|
|
}
|
|
// `dumpsys` omits this section both for known-empty inventories and when
|
|
// Package Manager has no parsed package; only the former is a known zero.
|
|
const requestedPermissions =
|
|
parsedPackageAvailable === false
|
|
? null
|
|
: hasPermissionsSection
|
|
? permissions
|
|
: parsedPackageAvailable === true
|
|
? 0
|
|
: null;
|
|
return { ...details, requestedPermissions, numericIssues: issues.facts() };
|
|
};
|
|
|
|
/**
|
|
* Parse `settings get` output. Android prints the literal string `null` for
|
|
* absent keys; that is reported as a device-side null observation.
|
|
*/
|
|
export const parseSettingsGetOutput = (
|
|
stdout: string,
|
|
): {
|
|
value: string | null;
|
|
deviceReportedNull: boolean;
|
|
} => {
|
|
const trimmed = stdout.replace(/\r?\n$/u, "");
|
|
if (trimmed === "null") return { value: null, deviceReportedNull: true };
|
|
return { value: trimmed, deviceReportedNull: false };
|
|
};
|
|
|
|
/** Parse the host-side completion line from `adb bugreport`. */
|
|
export const parseBugreportCopiedPath = (stdout: string): string | null => {
|
|
const match = /^Bug report copied to (.+)\r?$/mu.exec(stdout.trim());
|
|
return match === null ? null : match[1]!;
|
|
};
|
|
|
|
/**
|
|
* Read the digest from one `sha256sum FILE` line, or null when the output
|
|
* names none. GNU sha256sum starts the line with a backslash when it escaped
|
|
* the echoed name; the digest itself is never escaped.
|
|
*/
|
|
export const parseSha256sumOutput = (stdout: string): string | null =>
|
|
/^\\?([a-f0-9]{64}) [ *]/u.exec(stdout)?.[1] ?? null;
|
|
|
|
/** Read the device's `Failure [REASON]` verdict from package-change output. */
|
|
export const packageChangeFailureReason = (output: string): string | null =>
|
|
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;
|
|
|
|
/**
|
|
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
|
|
* with a zero exit code, so the text decides, not the exit status.
|
|
*/
|
|
export const parsePackageChangeOutput = (
|
|
stdout: string,
|
|
):
|
|
| { readonly status: "success" | "unknown"; readonly reason: null }
|
|
| { readonly status: "failure"; readonly reason: string } => {
|
|
const text = stdout.trim();
|
|
const failure = packageChangeFailureReason(text);
|
|
if (failure !== null) return { status: "failure", reason: failure };
|
|
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
|
|
return { status: "unknown", reason: null };
|
|
};
|
|
|
|
/**
|
|
* Parse `cmd package resolve-activity --brief` output: the component is the
|
|
* last non-empty line.
|
|
*/
|
|
export const parseResolveActivityOutput = (stdout: string): string | null => {
|
|
const lines = stdout
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line !== "" && !/^No activity found$/iu.test(line));
|
|
return lines.length === 0 ? null : lines[lines.length - 1]!;
|
|
};
|
|
|
|
/** Parsed facts and verdict from the full `am start -W` report. */
|
|
export interface AmStartOutput {
|
|
readonly status: "started" | "refused";
|
|
readonly startedActivity: string | null;
|
|
readonly activity: string | null;
|
|
readonly reportedStatus: string | null;
|
|
readonly reason: string | null;
|
|
}
|
|
|
|
/**
|
|
* Parse the complete `am start -W` report. `Starting:` records an attempt;
|
|
* hard errors and any non-ok Activity Manager status determine the verdict.
|
|
*/
|
|
export const parseAmStartOutput = (
|
|
stdout: string,
|
|
stderr = "",
|
|
): AmStartOutput => {
|
|
const normalizedStdout = stdout.replaceAll("\r\n", "\n");
|
|
const starting = /^Starting:[ \t]+Intent[ \t]*\{(.*)\}[ \t]*$/mu.exec(
|
|
normalizedStdout,
|
|
);
|
|
const startedActivity = starting?.[1]?.trim() ?? null;
|
|
const activityMatch = /^Activity:[ \t]*(.*)$/mu.exec(normalizedStdout);
|
|
const activity = activityMatch?.[1]?.trim() || null;
|
|
const statuses = [...normalizedStdout.matchAll(/^Status:[ \t]*(.*)$/gmu)].map(
|
|
(match) => match[1]!.trim(),
|
|
);
|
|
const nonOkStatus = statuses.find((status) => status.toLowerCase() !== "ok");
|
|
const reportedStatus = nonOkStatus ?? statuses[0] ?? null;
|
|
const errorLines = [normalizedStdout, stderr.replaceAll("\r\n", "\n")]
|
|
.flatMap((stream) => stream.split("\n"))
|
|
.map((line) => line.trim())
|
|
.map((line) => ({
|
|
line,
|
|
prefixedMatch: /^(?:Error|Exception)(?::|\b)(.*)$/iu.exec(line),
|
|
throwable:
|
|
/^(?:(?:[A-Za-z_$][A-Za-z0-9_$]*\.)+(?:[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error)|Exception|Error)|[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error))(?::\s*.*)?$/u.test(
|
|
line,
|
|
),
|
|
genericHeader:
|
|
/^(?:Error\s+type\s+\d+|Exception\s+occurred\s+while\s+executing\b.*)$/iu.test(
|
|
line,
|
|
),
|
|
}));
|
|
|
|
// Prefer the underlying Java throwable over the generic Activity Manager
|
|
// wrapper, regardless of which line or stream printed it first.
|
|
const error =
|
|
errorLines.find(({ throwable }) => throwable) ??
|
|
errorLines.find(
|
|
({ prefixedMatch, genericHeader }) =>
|
|
prefixedMatch !== null && !genericHeader,
|
|
) ??
|
|
errorLines.find(({ prefixedMatch }) => prefixedMatch !== null);
|
|
|
|
const errorReason =
|
|
error === undefined
|
|
? null
|
|
: error.throwable
|
|
? error.line
|
|
: error.prefixedMatch?.[1]?.trim() || error.line;
|
|
const statusReason =
|
|
nonOkStatus === ""
|
|
? "am start reported an empty status"
|
|
: nonOkStatus === undefined
|
|
? null
|
|
: `am start reported status: ${nonOkStatus}`;
|
|
const reason =
|
|
errorReason ??
|
|
statusReason ??
|
|
(/^Warning:/mu.test(normalizedStdout.trim())
|
|
? normalizedStdout.trim().slice(0, 512)
|
|
: null);
|
|
if (errorReason !== null || nonOkStatus !== undefined)
|
|
return {
|
|
status: "refused",
|
|
startedActivity,
|
|
activity,
|
|
reportedStatus,
|
|
reason: reason ?? "am start did not report a successful launch",
|
|
};
|
|
if (startedActivity !== null)
|
|
return {
|
|
status: "started",
|
|
startedActivity,
|
|
activity,
|
|
reportedStatus,
|
|
reason: null,
|
|
};
|
|
const text = `${normalizedStdout}\n${stderr.replaceAll("\r\n", "\n")}`.trim();
|
|
return {
|
|
status: "refused",
|
|
startedActivity,
|
|
activity,
|
|
reportedStatus,
|
|
reason:
|
|
reason ??
|
|
(text === "" ? "am start returned no activity" : text.slice(0, 512)),
|
|
};
|
|
};
|
|
|
|
const PNG_SIGNATURE = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
]);
|
|
|
|
/** Read image dimensions from a PNG header, or null for other payloads. */
|
|
export const parsePngDimensions = (
|
|
bytes: Uint8Array,
|
|
): {
|
|
width: number | null;
|
|
height: number | null;
|
|
} => {
|
|
if (
|
|
bytes.length < 24 ||
|
|
!Buffer.from(bytes.subarray(0, 8)).equals(PNG_SIGNATURE)
|
|
)
|
|
return { width: null, height: null };
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
return {
|
|
width: view.getUint32(16),
|
|
height: view.getUint32(20),
|
|
};
|
|
};
|