Files
Jeppe B 3323f392e3 Add signed iOS device debug workflow (#179)
Add a protected development-signing workflow, isolated debug app identity, Linux USB device tooling, documentation, and focused validation coverage.
2026-07-20 12:07:10 +02:00

857 lines
32 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { constants as fsConstants, createWriteStream, fchmodSync, openSync } from "node:fs";
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { finished } from "node:stream/promises";
import { fileURLToPath } from "node:url";
export const DEBUG_BUNDLE_ID = "io.truckwash.app.debug";
export const DEBUG_DISPLAY_NAME = "Truck Wash Debug";
export const DEBUG_EXECUTABLE_NAME = "TruckWashDebug";
export const DEBUG_API_URL = "https://api-v2.truckwash.io/master/api";
const BASE_TOOLS = ["idevice_id", "idevicepair", "ideviceinfo"];
const IPA_TOOLS = ["unzip", "openssl", "python3"];
const DOCTOR_TOOLS = [...BASE_TOOLS, ...IPA_TOOLS, "ideviceinstaller", "idevicesyslog", "idevicecrashreport"];
const PLIST_TO_JSON = String.raw`
import datetime
import json
import plistlib
import sys
def encode(value):
if isinstance(value, (datetime.datetime, datetime.date)):
encoded = value.isoformat()
return (encoded + "Z") if value.tzinfo is None else encoded.replace("+00:00", "Z")
if isinstance(value, bytes):
return {"type": "data", "length": len(value)}
raise TypeError(f"Unsupported plist value: {type(value).__name__}")
with open(sys.argv[1], "rb") as source:
print(json.dumps(plistlib.load(source), default=encode))
`;
export class CliError extends Error {
constructor(message, { exitCode = 1, cause } = {}) {
super(message, { cause });
this.name = "CliError";
this.exitCode = exitCode;
}
}
function usage() {
return `Usage:
node scripts/mobile/ios-device.mjs doctor [--udid ID]
node scripts/mobile/ios-device.mjs install IPA [--manifest FILE] [--udid ID]
node scripts/mobile/ios-device.mjs logs [--output FILE] [--udid ID]
node scripts/mobile/ios-device.mjs crashes DIRECTORY [--udid ID]
node scripts/mobile/ios-device.mjs uninstall --confirm ${DEBUG_BUNDLE_ID} [--udid ID]`;
}
export function parseArgs(argv) {
const args = [...argv];
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
return { help: true };
}
const command = args.shift();
const supported = new Set(["doctor", "install", "logs", "crashes", "uninstall"]);
if (!supported.has(command)) {
throw new CliError(`Unknown command: ${command}\n\n${usage()}`);
}
const options = { command, positionals: [] };
const valueOptions = new Set(["--udid", "--manifest", "--output", "--confirm"]);
while (args.length > 0) {
const arg = args.shift();
if (!arg.startsWith("--")) {
options.positionals.push(arg);
continue;
}
if (!valueOptions.has(arg)) {
throw new CliError(`Unknown option for ${command}: ${arg}`);
}
const key = arg.slice(2);
if (Object.hasOwn(options, key)) {
throw new CliError(`Option may only be supplied once: ${arg}`);
}
const value = args.shift();
if (!value || value.startsWith("--")) {
throw new CliError(`Option requires a value: ${arg}`);
}
options[key] = value;
}
const allowedOptions = {
doctor: new Set(["udid"]),
install: new Set(["udid", "manifest"]),
logs: new Set(["udid", "output"]),
crashes: new Set(["udid"]),
uninstall: new Set(["udid", "confirm"]),
};
for (const key of ["udid", "manifest", "output", "confirm"]) {
if (Object.hasOwn(options, key) && !allowedOptions[command].has(key)) {
throw new CliError(`--${key} is not valid for ${command}`);
}
}
const expectedPositionals = command === "install" || command === "crashes" ? 1 : 0;
if (options.positionals.length !== expectedPositionals) {
const expectation = expectedPositionals === 0 ? "no positional arguments" : "exactly one path";
throw new CliError(`${command} requires ${expectation}.\n\n${usage()}`);
}
if (command === "uninstall" && options.confirm !== DEBUG_BUNDLE_ID) {
throw new CliError(`Refusing to uninstall. Supply --confirm ${DEBUG_BUNDLE_ID} exactly.`);
}
return options;
}
export function redactUdids(value, udids = []) {
let redacted = String(value ?? "");
for (const udid of udids) {
if (udid) redacted = redacted.split(udid).join("<redacted-udid>");
}
return redacted
.replace(/\b[0-9a-f]{40}\b/giu, "<redacted-udid>")
.replace(/\b[0-9a-f]{8}-[0-9a-f]{16}\b/giu, "<redacted-udid>");
}
export function selectUsbDevice(rawOutput, requestedUdid) {
const devices = [
...new Set(
String(rawOutput)
.split(/\r?\n/u)
.map((item) => item.trim())
.filter(Boolean)
),
];
if (devices.length === 0) {
throw new CliError("No cable-connected iPhone was found. Connect and unlock the phone, then retry.");
}
if (requestedUdid) {
if (!devices.includes(requestedUdid)) {
throw new CliError("The requested device is not connected over USB.");
}
return { udid: requestedUdid, allUdids: devices };
}
if (devices.length !== 1) {
throw new CliError(`Found ${devices.length} USB devices. Select one explicitly with --udid ID.`);
}
return { udid: devices[0], allUdids: devices };
}
export function createRedactedLineWriter(output, udids) {
const decoder = new StringDecoder("utf8");
let pending = "";
let ended = false;
const flushCompleteLines = () => {
const newline = Math.max(pending.lastIndexOf("\n"), pending.lastIndexOf("\r"));
if (newline < 0) return;
output.write(redactUdids(pending.slice(0, newline + 1), udids));
pending = pending.slice(newline + 1);
};
return {
write(chunk) {
pending += decoder.write(chunk);
flushCompleteLines();
},
end() {
if (ended) return;
ended = true;
pending += decoder.end();
if (pending) output.write(redactUdids(pending, udids));
},
};
}
export function spawnCommand(command, args, { inherit = false, streamRedactedUdids, redactedStdoutFile } = {}) {
return new Promise((resolvePromise, rejectPromise) => {
const streamRedacted = Array.isArray(streamRedactedUdids);
if (redactedStdoutFile && !streamRedacted) {
rejectPromise(new CliError("redactedStdoutFile requires streamed UDID redaction."));
return;
}
const stdoutFileDescriptor = redactedStdoutFile ? openSync(redactedStdoutFile, "w", 0o600) : null;
if (stdoutFileDescriptor !== null) fchmodSync(stdoutFileDescriptor, 0o600);
const stdoutTarget = redactedStdoutFile
? createWriteStream(redactedStdoutFile, { fd: stdoutFileDescriptor, autoClose: true })
: process.stdout;
const stdoutFinished = redactedStdoutFile
? finished(stdoutTarget).then(
() => null,
(error) => error
)
: null;
const child = spawn(command, args, {
stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
});
if (redactedStdoutFile) {
stdoutTarget.once("error", (error) => {
child.kill("SIGTERM");
rejectPromise(error);
});
}
const stdout = [];
const stderr = [];
let finishStreamedStdout = () => {};
if (!inherit) {
if (streamRedacted) {
const stdoutWriter = createRedactedLineWriter(stdoutTarget, streamRedactedUdids);
const stderrWriter = createRedactedLineWriter(process.stderr, streamRedactedUdids);
child.stdout.on("data", (chunk) => stdoutWriter.write(chunk));
child.stderr.on("data", (chunk) => stderrWriter.write(chunk));
finishStreamedStdout = () => {
stdoutWriter.end();
if (redactedStdoutFile && !stdoutTarget.writableEnded) stdoutTarget.end();
};
child.stdout.on("end", finishStreamedStdout);
child.stderr.on("end", () => stderrWriter.end());
} else {
child.stdout.on("data", (chunk) => stdout.push(chunk));
child.stderr.on("data", (chunk) => stderr.push(chunk));
}
}
child.once("error", (error) => {
finishStreamedStdout();
rejectPromise(error);
});
child.once("close", async (code, signal) => {
finishStreamedStdout();
const outputError = stdoutFinished ? await stdoutFinished : null;
if (outputError) {
rejectPromise(outputError);
return;
}
resolvePromise({
code: code ?? (signal ? 1 : 0),
signal,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
});
});
});
}
async function defaultCommandExists(command) {
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
for (const entry of pathEntries) {
try {
await access(join(entry, command), fsConstants.X_OK);
return true;
} catch {
// Continue through PATH.
}
}
return false;
}
function commandFailure(command, args, result, udids) {
const stderr = redactUdids(
Buffer.from(result.stderr ?? "")
.toString("utf8")
.trim(),
udids
);
const detail = stderr ? `: ${stderr}` : "";
return new CliError(`${command} ${args.join(" ")} failed${detail}`);
}
async function runChecked(context, command, args, options = {}) {
let result;
try {
result = await context.run(command, args, options);
} catch (error) {
if (error?.code === "ENOENT") {
throw new CliError(`Required command is not installed: ${command}`, { cause: error });
}
throw error;
}
if (result.code !== 0) {
throw commandFailure(command, args, result, context.knownUdids);
}
return result;
}
async function requireTools(context, tools) {
const missing = [];
for (const tool of tools) {
if (!(await context.commandExists(tool))) missing.push(tool);
}
if (missing.length > 0) {
throw new CliError(`Missing required command${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
}
}
async function discoverDevice(context, requestedUdid) {
const result = await runChecked(context, "idevice_id", ["-l"]);
const selection = selectUsbDevice(result.stdout, requestedUdid);
context.knownUdids = selection.allUdids;
return selection.udid;
}
function lockedOrTrustHint(error) {
const message = String(error?.message ?? error).toLowerCase();
if (message.includes("password protected") || message.includes("passcode") || message.includes("locked")) {
return "The iPhone is locked. Unlock it, keep the screen awake, and retry.";
}
if (message.includes("pair") || message.includes("trust") || message.includes("invalid host")) {
return "Pairing is not valid. Unlock the phone, accept Trust This Computer, then run doctor again.";
}
return null;
}
async function validatePairing(context, udid) {
try {
await runChecked(context, "idevicepair", ["-u", udid, "validate"]);
} catch (error) {
throw new CliError(lockedOrTrustHint(error) ?? `Pairing validation failed: ${error.message}`, {
cause: error,
});
}
}
async function queryDeviceValue(context, udid, key) {
const result = await runChecked(context, "ideviceinfo", ["-u", udid, "-k", key]);
return Buffer.from(result.stdout).toString("utf8").trim();
}
async function assertInstallationProxy(context, udid) {
try {
await runChecked(context, "ideviceinstaller", [
"-u",
udid,
"list",
"--user",
"-b",
DEBUG_BUNDLE_ID,
"-a",
"CFBundleIdentifier",
]);
} catch (error) {
throw new CliError(
lockedOrTrustHint(error) ??
"The installation service is unavailable. Unlock the phone, reconnect the cable, and retry.",
{ cause: error }
);
}
}
function developerModeEnabled(output) {
return /\benabled\b/iu.test(String(output)) && !/\bdisabled\b/iu.test(String(output));
}
async function assertDeveloperMode(context, udid) {
const result = await runChecked(context, "idevicedevmodectl", ["-u", udid, "list"]);
if (!developerModeEnabled(Buffer.from(result.stdout).toString("utf8"))) {
throw new CliError(
"Developer Mode is disabled. Enable Settings > Privacy & Security > Developer Mode, restart the iPhone, and confirm Enable."
);
}
}
async function runDoctor(context, options) {
await requireTools(context, DOCTOR_TOOLS);
const udid = await discoverDevice(context, options.udid);
await validatePairing(context, udid);
const activationState = await queryDeviceValue(context, udid, "ActivationState");
if (activationState !== "Activated") {
throw new CliError(`The iPhone is not activated (state: ${activationState || "unknown"}).`);
}
await assertInstallationProxy(context, udid);
const [model, iosVersion] = await Promise.all([
queryDeviceValue(context, udid, "ProductType"),
queryDeviceValue(context, udid, "ProductVersion"),
]);
if (requiresDeveloperMode(iosVersion)) {
await requireTools(context, ["idevicedevmodectl"]);
await assertDeveloperMode(context, udid);
}
context.out(`Ready: ${model || "iPhone"}, iOS ${iosVersion || "unknown"}`);
context.out(
`Pairing: valid; activation: active; installation service: available; Developer Mode: ${
requiresDeveloperMode(iosVersion) ? "enabled" : "not required before iOS 16"
}.`
);
}
function bufferText(value) {
return Buffer.from(value ?? "").toString("utf8");
}
async function parsePlistFile(context, plistPath) {
const result = await runChecked(context, "python3", ["-c", PLIST_TO_JSON, plistPath]);
try {
return JSON.parse(bufferText(result.stdout));
} catch (error) {
throw new CliError(`Could not parse plist ${basename(plistPath)}.`, { cause: error });
}
}
export function selectIpaPayload(entries) {
const normalized = entries.map((entry) => String(entry).trim()).filter(Boolean);
const infoPlists = normalized.filter((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(entry));
if (infoPlists.length !== 1) {
throw new CliError(`IPA must contain exactly one app payload; found ${infoPlists.length}.`);
}
const appRoot = dirname(infoPlists[0]);
const profilePath = `${appRoot}/embedded.mobileprovision`;
if (!normalized.includes(profilePath)) {
throw new CliError("IPA does not contain an embedded provisioning profile.");
}
return { appRoot, infoPlistPath: infoPlists[0], profilePath };
}
async function extractZipEntry(context, ipaPath, entry, destination) {
const result = await runChecked(context, "unzip", ["-p", ipaPath, entry]);
await writeFile(destination, Buffer.from(result.stdout));
}
async function fileExists(path) {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
export async function verifySha256File(targetPath, checksumPath) {
const content = await readFile(checksumPath, "utf8");
const targetName = basename(targetPath);
const matches = content
.split(/\r?\n/u)
.map((line) => line.match(/^([0-9a-f]{64})\s+\*?(.+)$/iu))
.filter(Boolean)
.filter((match) => basename(match[2].trim()) === targetName);
if (matches.length !== 1) {
throw new CliError(`${basename(checksumPath)} must contain exactly one checksum for ${targetName}.`);
}
const hash = createHash("sha256");
hash.update(await readFile(targetPath));
const actual = hash.digest("hex");
if (actual.toLowerCase() !== matches[0][1].toLowerCase()) {
throw new CliError(`Checksum verification failed for ${targetName}.`);
}
}
export function validateManifest(manifest, artifact, ipaPath) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new CliError("manifest.json must contain a JSON object.");
}
if (manifest.schema_version !== 1) {
throw new CliError("manifest.json must use schema_version 1.");
}
if (manifest.signing_method !== "development") {
throw new CliError("manifest.json must identify the signing method as development.");
}
if (manifest.repository !== "copenhagentruckwash/pleno-vue") {
throw new CliError("manifest.json repository must be copenhagentruckwash/pleno-vue.");
}
if (typeof manifest.source_ref !== "string" || !manifest.source_ref.trim()) {
throw new CliError("manifest.json must contain a non-empty source_ref.");
}
if (typeof manifest.source_sha !== "string" || !/^[0-9a-f]{40}$/iu.test(manifest.source_sha)) {
throw new CliError("manifest.json must contain a full 40-character source_sha.");
}
if (manifest.api_url !== DEBUG_API_URL || manifest.release_manager_control_api_url !== DEBUG_API_URL) {
throw new CliError(`manifest.json API URLs must both be ${DEBUG_API_URL}.`);
}
const expectations = [
{
name: "bundle identifier",
value: manifest.bundle_id,
actual: artifact.info.CFBundleIdentifier,
},
{
name: "display name",
value: manifest.display_name,
actual: artifact.info.CFBundleDisplayName ?? artifact.info.CFBundleName,
},
{
name: "executable name",
value: manifest.executable_name,
actual: artifact.info.CFBundleExecutable,
},
{
name: "version",
value: manifest.version,
actual: artifact.info.CFBundleShortVersionString,
},
{
name: "build",
value: manifest.build,
actual: artifact.info.CFBundleVersion,
},
{
name: "IPA filename",
value: manifest.ipa_filename,
actual: basename(ipaPath),
},
{
name: "minimum iOS version",
value: manifest.minimum_ios,
actual: artifact.info.MinimumOSVersion,
},
];
for (const expectation of expectations) {
if (expectation.value === undefined || expectation.value === null || expectation.value === "") {
throw new CliError(`manifest.json is missing ${expectation.name}.`);
}
if (String(expectation.value) !== String(expectation.actual ?? "")) {
throw new CliError(
`Manifest ${expectation.name} does not match the IPA (${expectation.value} != ${
expectation.actual ?? "missing"
}).`
);
}
}
const manifestExpiration = new Date(manifest.profile_expiration_utc).getTime();
const profileExpiration = new Date(artifact.profile.ExpirationDate).getTime();
if (
!Number.isFinite(manifestExpiration) ||
!Number.isFinite(profileExpiration) ||
manifestExpiration !== profileExpiration
) {
throw new CliError("Manifest profile expiration does not match the embedded profile.");
}
}
function profileApplicationIdentifier(profile) {
return profile?.Entitlements?.["application-identifier"];
}
function parseNumericVersion(value, label) {
const normalized = String(value ?? "").trim();
const match = normalized.match(/^(\d+(?:\.\d+)*)(?:[^.\d].*)?$/u);
if (!match) {
throw new CliError(`${label} is malformed: ${normalized || "missing"}.`);
}
const components = match[1].split(".").map((component) => Number(component));
if (components.some((component) => !Number.isSafeInteger(component))) {
throw new CliError(`${label} contains an unsupported numeric component: ${normalized}.`);
}
return components;
}
export function assertMinimumIosCompatible(minimumIos, deviceIos) {
const minimum = parseNumericVersion(minimumIos, "IPA minimum iOS version");
const device = parseNumericVersion(deviceIos, "Connected iPhone iOS version");
const componentCount = Math.max(minimum.length, device.length);
for (let index = 0; index < componentCount; index += 1) {
const minimumComponent = minimum[index] ?? 0;
const deviceComponent = device[index] ?? 0;
if (minimumComponent < deviceComponent) return;
if (minimumComponent > deviceComponent) {
throw new CliError(`IPA requires iOS ${minimumIos}, but the connected iPhone runs iOS ${deviceIos}.`);
}
}
}
export function requiresDeveloperMode(deviceIos) {
return parseNumericVersion(deviceIos, "Connected iPhone iOS version")[0] >= 16;
}
export function validateDevelopmentArtifact(artifact, connectedUdid, now = new Date()) {
const { info, profile } = artifact;
if (info.CFBundleIdentifier !== DEBUG_BUNDLE_ID) {
throw new CliError(
`Refusing IPA with bundle identifier ${info.CFBundleIdentifier ?? "missing"}; expected ${DEBUG_BUNDLE_ID}.`
);
}
const displayName = info.CFBundleDisplayName ?? info.CFBundleName;
if (displayName !== DEBUG_DISPLAY_NAME) {
throw new CliError(`Refusing IPA with display name ${displayName ?? "missing"}; expected ${DEBUG_DISPLAY_NAME}.`);
}
if (!info.CFBundleExecutable || !info.CFBundleShortVersionString || !info.CFBundleVersion || !info.MinimumOSVersion) {
throw new CliError("IPA Info.plist is missing executable, version, build, or minimum iOS metadata.");
}
if (info.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) {
throw new CliError(`Refusing IPA with executable ${info.CFBundleExecutable}; expected ${DEBUG_EXECUTABLE_NAME}.`);
}
const entitlements = profile?.Entitlements ?? {};
if (entitlements["get-task-allow"] !== true) {
throw new CliError("IPA is not development-signed: get-task-allow is not true.");
}
const applicationIdentifier = profileApplicationIdentifier(profile);
if (typeof applicationIdentifier !== "string" || !applicationIdentifier.endsWith(`.${DEBUG_BUNDLE_ID}`)) {
throw new CliError("Provisioning profile application identifier does not match the debug bundle.");
}
const teamIdentifier = entitlements["com.apple.developer.team-identifier"];
if (typeof teamIdentifier !== "string" || applicationIdentifier !== `${teamIdentifier}.${DEBUG_BUNDLE_ID}`) {
throw new CliError("Provisioning profile team identifier is inconsistent with its application identifier.");
}
if (!Array.isArray(profile.TeamIdentifier) || !profile.TeamIdentifier.includes(teamIdentifier)) {
throw new CliError("Provisioning profile does not include its entitlement team identifier.");
}
if (profile.ProvisionsAllDevices === true) {
throw new CliError("Enterprise provisioning profiles are not accepted for cable debug installation.");
}
if (!Array.isArray(profile.ProvisionedDevices) || profile.ProvisionedDevices.length === 0) {
throw new CliError("Provisioning profile contains no registered development devices.");
}
if (!profile.ProvisionedDevices.includes(connectedUdid)) {
throw new CliError("The connected iPhone is not included in the provisioning profile.");
}
const expiresAt = new Date(profile.ExpirationDate);
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {
throw new CliError("The provisioning profile is expired or has an invalid expiration date.");
}
return {
bundleId: info.CFBundleIdentifier,
displayName,
executable: info.CFBundleExecutable,
version: String(info.CFBundleShortVersionString),
build: String(info.CFBundleVersion),
minimumIos: String(info.MinimumOSVersion),
expiresAt: expiresAt.toISOString(),
};
}
export async function inspectIpa(context, ipaInput, { manifestPath, connectedUdid }) {
const ipaPath = resolve(ipaInput);
if (extname(ipaPath).toLowerCase() !== ".ipa" || !(await fileExists(ipaPath))) {
throw new CliError(`IPA file not found: ${ipaInput}`);
}
await requireTools(context, IPA_TOOLS);
const candidateManifest = manifestPath ? resolve(manifestPath) : join(dirname(ipaPath), "manifest.json");
if (!(await fileExists(candidateManifest))) {
throw new CliError(
`Manifest file not found: ${manifestPath ?? candidateManifest}. Keep manifest.json with the workflow IPA.`
);
}
const siblingChecksum = join(dirname(ipaPath), "SHA256SUMS");
if (!(await fileExists(siblingChecksum))) {
throw new CliError(`Checksum file not found: ${siblingChecksum}. Keep SHA256SUMS with the workflow IPA.`);
}
await verifySha256File(ipaPath, siblingChecksum);
await verifySha256File(candidateManifest, siblingChecksum);
const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-device-"));
try {
const listing = await runChecked(context, "unzip", ["-Z1", ipaPath]);
const payload = selectIpaPayload(bufferText(listing.stdout).split(/\r?\n/u));
const infoPath = join(temporaryDirectory, "Info.plist");
const profileCmsPath = join(temporaryDirectory, "embedded.mobileprovision");
const profilePlistPath = join(temporaryDirectory, "profile.plist");
await extractZipEntry(context, ipaPath, payload.infoPlistPath, infoPath);
await extractZipEntry(context, ipaPath, payload.profilePath, profileCmsPath);
const decodedProfile = await runChecked(context, "openssl", [
"smime",
"-inform",
"der",
"-verify",
"-noverify",
"-in",
profileCmsPath,
]);
await writeFile(profilePlistPath, Buffer.from(decodedProfile.stdout));
const artifact = {
info: await parsePlistFile(context, infoPath),
profile: await parsePlistFile(context, profilePlistPath),
};
artifact.validated = validateDevelopmentArtifact(artifact, connectedUdid, context.now());
let manifest;
try {
manifest = JSON.parse(await readFile(candidateManifest, "utf8"));
} catch (error) {
throw new CliError(`Could not parse ${basename(candidateManifest)} as JSON.`, { cause: error });
}
validateManifest(manifest, artifact, ipaPath);
return { ...artifact, ipaPath };
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function parsePlistBuffer(context, buffer, label) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-plist-"));
const plistPath = join(temporaryDirectory, "value.plist");
try {
await writeFile(plistPath, Buffer.from(buffer));
return await parsePlistFile(context, plistPath);
} catch (error) {
throw new CliError(`Could not parse ${label}.`, { cause: error });
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
function findInstalledApp(plist) {
if (!plist || typeof plist !== "object") return null;
if (plist[DEBUG_BUNDLE_ID] && typeof plist[DEBUG_BUNDLE_ID] === "object") {
return plist[DEBUG_BUNDLE_ID];
}
const candidates = Array.isArray(plist) ? plist : Object.values(plist);
return candidates.find((item) => item?.CFBundleIdentifier === DEBUG_BUNDLE_ID) ?? null;
}
async function getInstalledDebugApp(context, udid) {
const result = await runChecked(context, "ideviceinstaller", [
"-u",
udid,
"list",
"--user",
"--xml",
"-b",
DEBUG_BUNDLE_ID,
"-a",
"CFBundleIdentifier",
"-a",
"CFBundleExecutable",
"-a",
"CFBundleShortVersionString",
"-a",
"CFBundleVersion",
]);
return findInstalledApp(await parsePlistBuffer(context, result.stdout, "installed-app list"));
}
async function prepareDevice(context, requestedUdid, tools) {
await requireTools(context, [...BASE_TOOLS, ...tools]);
const udid = await discoverDevice(context, requestedUdid);
await validatePairing(context, udid);
return udid;
}
async function runInstall(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", ...IPA_TOOLS]);
const deviceIos = await queryDeviceValue(context, udid, "ProductVersion");
if (requiresDeveloperMode(deviceIos)) {
await requireTools(context, ["idevicedevmodectl"]);
await assertDeveloperMode(context, udid);
}
await assertInstallationProxy(context, udid);
const artifact = await context.inspectIpa(context, options.positionals[0], {
manifestPath: options.manifest,
connectedUdid: udid,
});
assertMinimumIosCompatible(artifact.validated.minimumIos, deviceIos);
const installed = await context.getInstalledDebugApp(context, udid);
const action = installed ? "upgrade" : "install";
context.out(`${action === "upgrade" ? "Upgrading" : "Installing"} ${DEBUG_DISPLAY_NAME}...`);
await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", action, artifact.ipaPath], {
streamRedactedUdids: context.knownUdids,
});
const verified = await context.getInstalledDebugApp(context, udid);
if (!verified) {
throw new CliError("Installation command completed, but the debug app is not present on the iPhone.");
}
const actualVersion = String(verified.CFBundleShortVersionString ?? "");
const actualBuild = String(verified.CFBundleVersion ?? "");
if (actualVersion !== artifact.validated.version || actualBuild !== artifact.validated.build) {
throw new CliError(
`Installed version verification failed (expected ${artifact.validated.version} (${
artifact.validated.build
}), found ${actualVersion || "missing"} (${actualBuild || "missing"})).`
);
}
context.out(`Installed ${DEBUG_DISPLAY_NAME} ${actualVersion} (${actualBuild}).`);
}
async function requireInstalledDebugApp(context, udid) {
const app = await context.getInstalledDebugApp(context, udid);
if (!app) {
throw new CliError(`${DEBUG_DISPLAY_NAME} is not installed.`);
}
if (app.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) {
throw new CliError(
`Installed debug app executable is ${
app.CFBundleExecutable ?? "missing"
}; expected ${DEBUG_EXECUTABLE_NAME}. Reinstall the current device-debug IPA.`
);
}
return app;
}
async function runLogs(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicesyslog", "python3"]);
const app = await requireInstalledDebugApp(context, udid);
const args = ["-u", udid, "--no-colors", "-p", String(app.CFBundleExecutable)];
context.out(`Streaming logs for ${app.CFBundleExecutable}; press Ctrl-C to stop.`);
await runChecked(context, "idevicesyslog", args, {
streamRedactedUdids: context.knownUdids,
...(options.output ? { redactedStdoutFile: resolve(options.output) } : {}),
});
}
async function runCrashes(context, options) {
const destination = resolve(options.positionals[0]);
await mkdir(destination, { recursive: true });
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicecrashreport", "python3"]);
const app = await requireInstalledDebugApp(context, udid);
await runChecked(
context,
"idevicecrashreport",
["-u", udid, "--keep", "--extract", "--filter", String(app.CFBundleExecutable), destination],
{ streamRedactedUdids: context.knownUdids }
);
context.out(`Copied crash reports to ${destination}; reports were kept on the iPhone.`);
}
async function runUninstall(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "python3"]);
const installed = await context.getInstalledDebugApp(context, udid);
if (!installed) {
context.out(`${DEBUG_DISPLAY_NAME} is not installed; nothing to remove.`);
return;
}
await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", "uninstall", DEBUG_BUNDLE_ID], {
streamRedactedUdids: context.knownUdids,
});
const remaining = await context.getInstalledDebugApp(context, udid);
if (remaining) throw new CliError(`Uninstall completed, but ${DEBUG_DISPLAY_NAME} is still present.`);
context.out(`Removed ${DEBUG_DISPLAY_NAME}. The production app was not touched.`);
}
function createContext(overrides = {}) {
return {
run: overrides.run ?? spawnCommand,
commandExists: overrides.commandExists ?? defaultCommandExists,
inspectIpa: overrides.inspectIpa ?? inspectIpa,
getInstalledDebugApp: overrides.getInstalledDebugApp ?? getInstalledDebugApp,
now: overrides.now ?? (() => new Date()),
out: overrides.out ?? ((message) => process.stdout.write(`${message}\n`)),
err: overrides.err ?? ((message) => process.stderr.write(`${message}\n`)),
knownUdids: [],
};
}
export async function main(argv = process.argv.slice(2), overrides = {}) {
const context = createContext(overrides);
try {
const options = parseArgs(argv);
if (options.help) {
context.out(usage());
return 0;
}
if (options.command === "doctor") await runDoctor(context, options);
if (options.command === "install") await runInstall(context, options);
if (options.command === "logs") await runLogs(context, options);
if (options.command === "crashes") await runCrashes(context, options);
if (options.command === "uninstall") await runUninstall(context, options);
return 0;
} catch (error) {
const message = redactUdids(error?.message ?? error, context.knownUdids);
context.err(`Error: ${message}`);
return error?.exitCode ?? 1;
}
}
const isDirectInvocation = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
process.exitCode = await main();
}