NSNibControlConnector archives the sending control as NSSource and the action target as NSDestination; a nil target is the first responder. The NIBArchive projection swapped them for control connectors, so every compiled AppKit action ran from its target to the control. The action trace, which follows control -> action, could not find them, and the selector node was keyed by the control instead of the target class. When the target was nil, the swapped source was null and the action was dropped: Chess lost 29 menu actions, KeepingYouAwake 7. Connectors now keep NSSource -> NSDestination. The NIBArchive test encoder moves to a fixture module so a bundle test can build compiled NIBs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { parseBinary } from "plist";
|
|
|
|
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
|
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
|
import {
|
|
decodeNibArchive,
|
|
type NibArchiveDocument,
|
|
} from "../artifacts/NibArchive.js";
|
|
import type { ArtifactEntry } from "../artifacts/ArtifactReader.js";
|
|
import {
|
|
buildInterfaceBuilderAnalysis,
|
|
interfaceBuilderLimitsSchema,
|
|
type InterfaceBuilderDocumentInput,
|
|
} from "../domain/interfaceBuilderGraph.js";
|
|
import {
|
|
mergeNibHierarchies,
|
|
projectNibViewHierarchy,
|
|
type NibHierarchyNode,
|
|
} from "./NibViewHierarchy.js";
|
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
|
|
|
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
|
|
|
/** Decode compiled Interface Builder archives from a local app bundle. */
|
|
export const analyzeInterfaceBuilderBundle = async (input: {
|
|
readonly bundlePath: string;
|
|
readonly targetSha256: string;
|
|
readonly limits?: unknown;
|
|
readonly signal?: AbortSignal;
|
|
}) => {
|
|
const limits = interfaceBuilderLimitsSchema.parse(input.limits ?? {});
|
|
const reader = new DirectoryArtifactReader(input.bundlePath);
|
|
const documents: InterfaceBuilderDocumentInput[] = [];
|
|
const invalid: string[] = [];
|
|
const incompleteHierarchies = new Map<string, number>();
|
|
let omitted = 0;
|
|
let attempted = 0;
|
|
try {
|
|
for await (const entry of reader.entries(input.signal)) {
|
|
if (entry.kind !== "file" || !isInterfaceBuilderArchive(entry.path))
|
|
continue;
|
|
if (attempted >= limits.max_documents) {
|
|
omitted += 1;
|
|
continue;
|
|
}
|
|
attempted += 1;
|
|
try {
|
|
const bytes = await readEntry(reader, entry, input.signal);
|
|
const nib =
|
|
bytes.subarray(0, 10).toString("ascii") === "NIBArchive"
|
|
? projectNibArchive(decodeNibArchive(bytes))
|
|
: null;
|
|
const raw = nib === null ? decodePlist(bytes) : nib.raw;
|
|
if (nib !== null && nib.omitted > 0)
|
|
incompleteHierarchies.set(entry.path, nib.omitted);
|
|
const documentHash = createHash("sha256").update(bytes).digest("hex");
|
|
documents.push({
|
|
relativePath: entry.path,
|
|
archiveSha256: documentHash,
|
|
documentKind: entry.path.includes(".storyboardc/")
|
|
? "storyboard_scene"
|
|
: "nib",
|
|
raw,
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (input.signal?.aborted === true) throw cause;
|
|
invalid.push(
|
|
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
const result = buildInterfaceBuilderAnalysis({
|
|
targetSha256: input.targetSha256,
|
|
toolVersion: "rea-interface-builder/1",
|
|
documents,
|
|
limits,
|
|
});
|
|
return finalizeHierarchyCoverage(result, {
|
|
incompleteHierarchies,
|
|
invalid,
|
|
attempted,
|
|
omitted,
|
|
});
|
|
};
|
|
|
|
const finalizeHierarchyCoverage = (
|
|
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
|
|
counts: {
|
|
incompleteHierarchies: ReadonlyMap<string, number>;
|
|
invalid: readonly string[];
|
|
attempted: number;
|
|
omitted: number;
|
|
},
|
|
) => {
|
|
const { incompleteHierarchies, invalid } = counts;
|
|
return {
|
|
...result,
|
|
documents: result.documents.map((document) => ({
|
|
...document,
|
|
hierarchy_complete:
|
|
document.hierarchy_complete &&
|
|
!incompleteHierarchies.has(document.relative_path),
|
|
})),
|
|
graph: {
|
|
...result.graph,
|
|
coverage: [
|
|
...result.graph.coverage.map((facet) => {
|
|
const omitted = incompleteHierarchies.get(
|
|
facet.facet.replace(/^hierarchy:/u, ""),
|
|
);
|
|
return facet.facet.startsWith("hierarchy:") && omitted !== undefined
|
|
? {
|
|
...facet,
|
|
status: "partial" as const,
|
|
reason: "serialized_view_hierarchy_incomplete",
|
|
omitted: facet.omitted + omitted,
|
|
}
|
|
: facet;
|
|
}),
|
|
{
|
|
facet: "archive_decode",
|
|
status:
|
|
invalid.length > 0 ? ("partial" as const) : ("complete" as const),
|
|
reason: invalid.length > 0 ? "one_or_more_archives_invalid" : null,
|
|
examined: counts.attempted,
|
|
omitted: counts.omitted,
|
|
},
|
|
],
|
|
truncated:
|
|
result.graph.truncated ||
|
|
counts.omitted > 0 ||
|
|
invalid.length > 0 ||
|
|
incompleteHierarchies.size > 0,
|
|
},
|
|
limitations: [
|
|
...result.limitations,
|
|
"Compiled Interface Builder archives are private serialized object graphs. The decoder reports only recognized keyed-archive objects and connections; unrecognized object classes and fields remain unknown.",
|
|
...(incompleteHierarchies.size === 0
|
|
? []
|
|
: [
|
|
"Some serialized hierarchy links could not be projected within the bounded hierarchy; their parentage remains unknown.",
|
|
]),
|
|
...(invalid.length === 0
|
|
? []
|
|
: [
|
|
`Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`,
|
|
]),
|
|
],
|
|
};
|
|
};
|
|
|
|
/** Project decoded NIB records into the same bounded graph input as ibtool. */
|
|
const projectNibArchive = (archive: NibArchiveDocument) => {
|
|
const byId = new Map(archive.objects.map((object) => [object.id, object]));
|
|
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
return value;
|
|
const ref = value.$nib_object_ref;
|
|
if (typeof ref !== "number") return value;
|
|
const target = byId.get(ref);
|
|
if (target === undefined) return undefined;
|
|
if (target.class_name.replace(/\0+$/u, "") === "NSString") {
|
|
const data = target.values["NS.bytes"];
|
|
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
|
const encoded = data.$nib_data_base64;
|
|
if (typeof encoded === "string")
|
|
return Buffer.from(encoded, "base64").toString("utf8");
|
|
}
|
|
}
|
|
return { objectID: String(ref) };
|
|
};
|
|
const referencedString = (value: JsonValue | undefined): string | null => {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
return null;
|
|
const ref = value.$nib_object_ref;
|
|
if (typeof ref !== "number") return null;
|
|
const target = byId.get(ref);
|
|
const data = target?.values["NS.bytes"];
|
|
if (target?.class_name.replace(/\0+$/u, "") !== "NSString") return null;
|
|
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
return null;
|
|
const encoded = data.$nib_data_base64;
|
|
return typeof encoded === "string"
|
|
? Buffer.from(encoded, "base64").toString("utf8")
|
|
: null;
|
|
};
|
|
const runtimeClass = (objectId: number): string | null => {
|
|
const object = byId.get(objectId);
|
|
if (object === undefined) return null;
|
|
const storedClass = object.class_name.replace(/\0+$/u, "");
|
|
return storedClass === "NSClassSwapper"
|
|
? (referencedString(object.values.NSClassName) ?? storedClass)
|
|
: storedClass;
|
|
};
|
|
const objects = Object.fromEntries(
|
|
archive.objects
|
|
.filter((object) => {
|
|
const name = runtimeClass(object.id) ?? "";
|
|
return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test(
|
|
name,
|
|
);
|
|
})
|
|
.map((object) => {
|
|
const className =
|
|
runtimeClass(object.id) ?? object.class_name.replace(/\0+$/u, "");
|
|
const strings = Object.entries(object.values).flatMap(
|
|
([key, value]) => {
|
|
const resolved = dereference(value);
|
|
return typeof resolved === "string" ? [[key, resolved]] : [];
|
|
},
|
|
);
|
|
const name =
|
|
strings.find(([key]) =>
|
|
/(?:title|label|identifier|accessibility|name|contents)/iu.test(
|
|
key ?? "",
|
|
),
|
|
)?.[1] ?? className;
|
|
return [
|
|
String(object.id),
|
|
{
|
|
customClass: className,
|
|
label: name,
|
|
objectID: String(object.id),
|
|
nibValues: Object.fromEntries(
|
|
Object.entries(object.values).map(([key, value]) => [
|
|
key,
|
|
dereference(value) ?? null,
|
|
]),
|
|
),
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
const connections: Record<string, JsonValue[]> = {};
|
|
const hierarchy: NibHierarchyNode[] = [];
|
|
const reference = (value: JsonValue | undefined): number | null => {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
return null;
|
|
return typeof value.$nib_object_ref === "number"
|
|
? value.$nib_object_ref
|
|
: null;
|
|
};
|
|
for (const object of archive.objects) {
|
|
const className = object.class_name.replace(/\0+$/u, "");
|
|
const source = reference(object.values.NSSource);
|
|
const destination = reference(object.values.NSDestination);
|
|
if (className.includes("Connector") && source !== null) {
|
|
const label = referencedString(object.values.NSLabel);
|
|
const type = className.includes("Outlet")
|
|
? "outlet"
|
|
: className.includes("Control")
|
|
? "action"
|
|
: className;
|
|
// NSSource is the outlet owner or the sending control; NSDestination is
|
|
// the outlet value or the action target, and a nil target is the first
|
|
// responder.
|
|
(connections[String(source)] ??= []).push({
|
|
type,
|
|
"destination-id": destination === null ? null : String(destination),
|
|
label,
|
|
source_id: String(source),
|
|
archive_object_id: String(object.id),
|
|
});
|
|
}
|
|
}
|
|
let hierarchyOmitted = 0;
|
|
const hierarchyFor = (
|
|
objectId: number,
|
|
seen: Set<number>,
|
|
depth: number,
|
|
): NibHierarchyNode | null => {
|
|
if (depth > 32 || seen.has(objectId)) {
|
|
hierarchyOmitted += 1;
|
|
return null;
|
|
}
|
|
const object = byId.get(objectId);
|
|
if (object === undefined) {
|
|
hierarchyOmitted += 1;
|
|
return null;
|
|
}
|
|
const nextSeen = new Set(seen).add(objectId);
|
|
const children: NibHierarchyNode[] = [];
|
|
for (const [key, value] of Object.entries(object.values)) {
|
|
if (!/(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key))
|
|
continue;
|
|
const direct = reference(value);
|
|
if (direct === null) continue;
|
|
const target = byId.get(direct);
|
|
if (target === undefined) {
|
|
hierarchyOmitted += 1;
|
|
continue;
|
|
}
|
|
if (/array|set/iu.test(target.class_name)) {
|
|
for (const candidate of Object.values(target.values)) {
|
|
const child = reference(candidate);
|
|
const nested =
|
|
child === null ? null : hierarchyFor(child, nextSeen, depth + 1);
|
|
if (nested !== null) children.push(nested);
|
|
}
|
|
} else {
|
|
const nested = hierarchyFor(direct, nextSeen, depth + 1);
|
|
if (nested !== null) children.push(nested);
|
|
}
|
|
}
|
|
return { objectID: String(objectId), children };
|
|
};
|
|
const ibData = archive.objects.find(
|
|
({ class_name }) => class_name.replace(/\0+$/u, "") === "NSIBObjectData",
|
|
);
|
|
const rootId = reference(ibData?.values.NSRoot);
|
|
const hierarchyRoots = rootId === null ? [] : [rootId];
|
|
for (const hierarchyRoot of hierarchyRoots) {
|
|
const root = hierarchyFor(hierarchyRoot, new Set(), 0);
|
|
if (root !== null) hierarchy.push(root);
|
|
}
|
|
const viewHierarchy = projectNibViewHierarchy(
|
|
archive.objects,
|
|
new Set(Object.keys(objects).map(Number)),
|
|
);
|
|
const merged = mergeNibHierarchies(hierarchy, viewHierarchy.hierarchy ?? []);
|
|
return {
|
|
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
|
|
raw: jsonValueSchema.parse({
|
|
"com.apple.ibtool.document.objects": objects,
|
|
"com.apple.ibtool.document.connections": connections,
|
|
"com.apple.ibtool.document.hierarchy": merged.hierarchy,
|
|
"com.apple.ibtool.document.classes": Object.fromEntries(
|
|
archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]),
|
|
),
|
|
}),
|
|
};
|
|
};
|
|
|
|
const isInterfaceBuilderArchive = (path: string): boolean => {
|
|
const lower = path.toLowerCase();
|
|
return lower.endsWith(".nib") || lower.endsWith("/objects.nib");
|
|
};
|
|
|
|
const readEntry = async (
|
|
reader: DirectoryArtifactReader,
|
|
entry: ArtifactEntry,
|
|
signal?: AbortSignal,
|
|
): Promise<Buffer> => {
|
|
if (entry.declaredSize !== null && entry.declaredSize > MAX_DOCUMENT_BYTES)
|
|
throw new RangeError(
|
|
"archive exceeds the 64 MiB per-document decode limit",
|
|
);
|
|
const stream = await reader.open(entry, signal);
|
|
const chunks: Buffer[] = [];
|
|
let length = 0;
|
|
for await (const chunk of stream) {
|
|
const bytes = Buffer.isBuffer(chunk)
|
|
? chunk
|
|
: Buffer.from(chunk as Uint8Array);
|
|
length += bytes.length;
|
|
if (length > MAX_DOCUMENT_BYTES) {
|
|
stream.destroy();
|
|
throw new RangeError(
|
|
"archive exceeded the 64 MiB per-document decode limit",
|
|
);
|
|
}
|
|
chunks.push(bytes);
|
|
}
|
|
return Buffer.concat(chunks, length);
|
|
};
|
|
|
|
const decodePlist = (bytes: Buffer): JsonValue => {
|
|
const parsed =
|
|
bytes.subarray(0, 8).toString("ascii") === "bplist00"
|
|
? parseBinary(bytes)
|
|
: parseXmlPropertyList(bytes.toString("utf8")).value;
|
|
return jsonValueSchema.parse(parsed);
|
|
};
|