Add a protected development-signing workflow, isolated debug app identity, Linux USB device tooling, documentation, and focused validation coverage.
857 lines
32 KiB
JavaScript
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();
|
|
}
|