refactor(domain): split Interface Builder keyed-archive parse from graph builder (#859)
* refactor(domain): split Interface Builder keyed-archive parse from graph builder * fix(domain): drop unused Interface Builder type re-exports Knip failed Static checks because the split re-exported object and connection types that no caller imported. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
676959ecb5
commit
da0970672d
|
|
@ -1,39 +1,18 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
firstString,
|
||||
parseInterfaceBuilderRecords,
|
||||
record,
|
||||
} from "./interfaceBuilderKeyedArchive.js";
|
||||
import {
|
||||
nativeInvestigationEdgeSchema,
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationNodeSchema,
|
||||
nativeInvestigationTraceLimitsSchema,
|
||||
} from "./nativeInvestigationGraph.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const objectNode = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"view_controller",
|
||||
"view",
|
||||
"control",
|
||||
"constraint",
|
||||
"layout_guide",
|
||||
"resource",
|
||||
"placeholder",
|
||||
"external_object",
|
||||
"other",
|
||||
]),
|
||||
class_name: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
const connection = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum(["outlet", "action", "segue", "other"]),
|
||||
source_id: z.string().min(1),
|
||||
destination_id: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
export { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js";
|
||||
|
||||
/** One compiled Interface Builder document projected from ibtool output. */
|
||||
export const interfaceBuilderDocumentSchema = z.strictObject({
|
||||
|
|
@ -55,9 +34,6 @@ export const interfaceBuilderAnalysisSchema = z.strictObject({
|
|||
|
||||
type InterfaceBuilderAnalysis = z.infer<typeof interfaceBuilderAnalysisSchema>;
|
||||
|
||||
export type InterfaceBuilderObject = z.infer<typeof objectNode>;
|
||||
export type InterfaceBuilderConnection = z.infer<typeof connection>;
|
||||
|
||||
export interface InterfaceBuilderDocumentInput {
|
||||
readonly relativePath: string;
|
||||
readonly archiveSha256: string;
|
||||
|
|
@ -65,245 +41,8 @@ export interface InterfaceBuilderDocumentInput {
|
|||
readonly raw: unknown;
|
||||
}
|
||||
|
||||
/** Parse ibtool dictionaries or compiled keyed archives into typed records. */
|
||||
export const parseInterfaceBuilderRecords = (
|
||||
value: unknown,
|
||||
): {
|
||||
readonly objects: readonly InterfaceBuilderObject[];
|
||||
readonly connections: readonly InterfaceBuilderConnection[];
|
||||
readonly hierarchy: readonly unknown[];
|
||||
readonly classes: Readonly<Record<string, unknown>>;
|
||||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
const keyed = parseKeyedArchive(root);
|
||||
if (keyed !== null) return keyed;
|
||||
const objectsRaw = record(root["com.apple.ibtool.document.objects"]);
|
||||
const connectionsRaw = record(root["com.apple.ibtool.document.connections"]);
|
||||
const hierarchyRaw = root["com.apple.ibtool.document.hierarchy"];
|
||||
const classesRaw = record(root["com.apple.ibtool.document.classes"]);
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let omittedConnections = 0;
|
||||
const objectEntries = Object.entries(objectsRaw);
|
||||
for (const [id, raw] of objectEntries.slice(0, 20_000)) {
|
||||
const attributes = record(raw);
|
||||
const className = firstString(
|
||||
attributes["customClass"],
|
||||
attributes["class"],
|
||||
attributes["isa"],
|
||||
);
|
||||
const label = firstString(
|
||||
attributes["label"],
|
||||
attributes["title"],
|
||||
attributes["name"],
|
||||
className,
|
||||
);
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id,
|
||||
kind: classifyObject(className),
|
||||
class_name: className,
|
||||
name: label ?? id,
|
||||
attributes: jsonSafeRecord(attributes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [sourceId, rawConnections] of Object.entries(connectionsRaw)) {
|
||||
if (!Array.isArray(rawConnections)) continue;
|
||||
for (const [index, raw] of rawConnections.entries()) {
|
||||
if (connections.length >= 40_000) {
|
||||
omittedConnections += rawConnections.length - index;
|
||||
break;
|
||||
}
|
||||
const item = record(raw);
|
||||
const type = firstString(item.type, item.connectionType) ?? "unknown";
|
||||
const destination = firstString(
|
||||
item["destination-id"],
|
||||
item.destinationId,
|
||||
);
|
||||
const label = firstString(item.label, item.selector, item.identifier);
|
||||
connections.push(
|
||||
connection.parse({
|
||||
id: `${sourceId}:connection:${index}`,
|
||||
kind: classifyConnection(type),
|
||||
source_id: sourceId,
|
||||
destination_id: destination,
|
||||
label,
|
||||
attributes: jsonSafeRecord(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw.slice(0, 20_000) : [],
|
||||
classes: classesRaw,
|
||||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Project class and connection objects from an NSKeyedArchiver object table. */
|
||||
const parseKeyedArchive = (
|
||||
root: Record<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | null => {
|
||||
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
|
||||
return null;
|
||||
const objectTable = root.$objects;
|
||||
const resolve = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 16) return null;
|
||||
const uid = record(value).UID;
|
||||
if (typeof uid === "number") {
|
||||
const target = objectTable[uid];
|
||||
const resolved = target === undefined ? null : resolve(target, depth + 1);
|
||||
return typeof resolved === "object" &&
|
||||
resolved !== null &&
|
||||
!Array.isArray(resolved)
|
||||
? { ...resolved, archiveUID: uid }
|
||||
: resolved;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => resolve(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") {
|
||||
const classObject = record(
|
||||
typeof record(child).UID === "number"
|
||||
? objectTable[record(child).UID as number]
|
||||
: child,
|
||||
);
|
||||
output.className = firstString(classObject.$classname);
|
||||
} else output[key] = resolve(child, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const toHierarchy = (value: unknown, depth = 0): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => toHierarchy(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) =>
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.flatMap(([, child]) => toHierarchy(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
let omittedConnections = 0;
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = firstString(resolved.className);
|
||||
if (className === null) continue;
|
||||
objectCount += 1;
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "className"),
|
||||
);
|
||||
const authoredId = firstString(fields.objectID, fields["object-id"]);
|
||||
const objectId =
|
||||
authoredId ??
|
||||
(typeof fields.archiveUID === "number"
|
||||
? String(fields.archiveUID)
|
||||
: String(index));
|
||||
const kind = classifyObject(className);
|
||||
if (objects.length < 20_000)
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id: objectId,
|
||||
kind,
|
||||
class_name: className,
|
||||
name:
|
||||
firstString(
|
||||
fields.label,
|
||||
fields.title,
|
||||
fields.identifier,
|
||||
fields.accessibilityLabel,
|
||||
className,
|
||||
) ?? `object ${String(index)}`,
|
||||
attributes: jsonSafeRecord(fields),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
|
||||
className,
|
||||
)
|
||||
) {
|
||||
const controlAction = /ControlConnector/iu.test(className);
|
||||
const source = firstObjectReference(
|
||||
controlAction ? fields.destination : fields.source,
|
||||
controlAction ? fields.to : fields.from,
|
||||
controlAction ? fields.target : fields.owner,
|
||||
);
|
||||
if (source === null) continue;
|
||||
const kind = controlAction ? "action" : classifyConnection(className);
|
||||
const parsedConnection = connection.parse({
|
||||
id: `archive:${objectId}`,
|
||||
kind,
|
||||
source_id: source,
|
||||
destination_id: firstObjectReference(
|
||||
controlAction ? fields.source : fields.destination,
|
||||
controlAction ? fields.from : fields.to,
|
||||
controlAction ? fields.owner : fields.target,
|
||||
),
|
||||
label: firstString(
|
||||
fields.label,
|
||||
fields.selector,
|
||||
fields.identifier,
|
||||
fields.action,
|
||||
),
|
||||
attributes: jsonSafeRecord(fields),
|
||||
});
|
||||
if (connections.length < 40_000) connections.push(parsedConnection);
|
||||
else omittedConnections += 1;
|
||||
}
|
||||
}
|
||||
const top = record(root.$top);
|
||||
const topObject = resolve(top.root ?? top.UITopLevelObjectsKey);
|
||||
const hierarchy = toHierarchy(topObject);
|
||||
const classes = Object.fromEntries(
|
||||
objectTable.flatMap((raw) => {
|
||||
const item = record(raw);
|
||||
const name = firstString(item.$classname);
|
||||
return name === null
|
||||
? []
|
||||
: [
|
||||
[
|
||||
name,
|
||||
{
|
||||
superclasses: Array.isArray(item.$classes) ? item.$classes : [],
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy,
|
||||
classes,
|
||||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
type InvestigationNode = z.infer<typeof nativeInvestigationNodeSchema>;
|
||||
type InvestigationEdge = z.infer<typeof nativeInvestigationEdgeSchema>;
|
||||
|
||||
/** Build the graph returned by the static Interface Builder archive decoder. */
|
||||
export const buildInterfaceBuilderAnalysis = (input: {
|
||||
|
|
@ -312,15 +51,8 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||
readonly documents: readonly InterfaceBuilderDocumentInput[];
|
||||
readonly limits: z.output<typeof interfaceBuilderLimitsSchema>;
|
||||
}) => {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationNodeSchema
|
||||
>
|
||||
>();
|
||||
const edges: z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationEdgeSchema
|
||||
>[] = [];
|
||||
const nodes = new Map<string, InvestigationNode>();
|
||||
const edges: InvestigationEdge[] = [];
|
||||
const coverage = [] as Array<{
|
||||
facet: string;
|
||||
status: "complete" | "partial" | "unsupported" | "not_requested";
|
||||
|
|
@ -347,11 +79,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||
artifact_sha256: document.archiveSha256,
|
||||
},
|
||||
];
|
||||
const addNode = (
|
||||
node: z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationNodeSchema
|
||||
>,
|
||||
): boolean => {
|
||||
const addNode = (node: InvestigationNode): boolean => {
|
||||
if (nodes.has(node.id)) return true;
|
||||
if (nodes.size >= input.limits.max_objects) {
|
||||
truncated = true;
|
||||
|
|
@ -783,92 +511,6 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||
});
|
||||
};
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
recordSchema.safeParse(value).data ?? {};
|
||||
|
||||
const firstString = (...values: unknown[]): string | null => {
|
||||
for (const value of values)
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstObjectReference = (...values: unknown[]): string | null => {
|
||||
for (const value of values) {
|
||||
const direct = firstString(value);
|
||||
if (direct !== null) return direct;
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
item.identifier,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
if (objectId !== null) return objectId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const classifyObject = (
|
||||
className: string | null,
|
||||
): InterfaceBuilderObject["kind"] => {
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
if (value.includes("constraint")) return "constraint";
|
||||
if (value.includes("layoutguide")) return "layout_guide";
|
||||
if (value.includes("placeholder") || value.includes("firstresponder"))
|
||||
return "placeholder";
|
||||
if (
|
||||
value.includes("image") ||
|
||||
value.includes("color") ||
|
||||
value.includes("font")
|
||||
)
|
||||
return "resource";
|
||||
if (value.includes("external")) return "external_object";
|
||||
if (value.includes("viewcontroller") || value.includes("windowcontroller"))
|
||||
return "view_controller";
|
||||
if (
|
||||
value.includes("button") ||
|
||||
value.includes("control") ||
|
||||
value.includes("textfield") ||
|
||||
value.includes("slider") ||
|
||||
value.includes("menuitem")
|
||||
)
|
||||
return "control";
|
||||
if (
|
||||
value.includes("view") ||
|
||||
value.includes("window") ||
|
||||
value.includes("cell")
|
||||
)
|
||||
return "view";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const classifyConnection = (
|
||||
type: string,
|
||||
): InterfaceBuilderConnection["kind"] => {
|
||||
const value = type.toLowerCase();
|
||||
if (value.includes("outlet")) return "outlet";
|
||||
if (value.includes("action")) return "action";
|
||||
if (value.includes("segue")) return "segue";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const jsonSafeRecord = (value: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
if (serialized === undefined) return [];
|
||||
const parsed = jsonValueSchema.safeParse(JSON.parse(serialized));
|
||||
return parsed.success ? [[key, parsed.data]] : [];
|
||||
} catch (cause: unknown) {
|
||||
// Unserializable entries are omitted from the record.
|
||||
void cause;
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/** Hard limits shared by Interface Builder archive aggregation and tracing. */
|
||||
export const interfaceBuilderLimitsSchema = z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const objectNode = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"view_controller",
|
||||
"view",
|
||||
"control",
|
||||
"constraint",
|
||||
"layout_guide",
|
||||
"resource",
|
||||
"placeholder",
|
||||
"external_object",
|
||||
"other",
|
||||
]),
|
||||
class_name: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
const connection = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum(["outlet", "action", "segue", "other"]),
|
||||
source_id: z.string().min(1),
|
||||
destination_id: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
type InterfaceBuilderObject = z.infer<typeof objectNode>;
|
||||
type InterfaceBuilderConnection = z.infer<typeof connection>;
|
||||
|
||||
/** Parse ibtool dictionaries or compiled keyed archives into typed records. */
|
||||
export const parseInterfaceBuilderRecords = (
|
||||
value: unknown,
|
||||
): {
|
||||
readonly objects: readonly InterfaceBuilderObject[];
|
||||
readonly connections: readonly InterfaceBuilderConnection[];
|
||||
readonly hierarchy: readonly unknown[];
|
||||
readonly classes: Readonly<Record<string, unknown>>;
|
||||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
const keyed = parseKeyedArchive(root);
|
||||
if (keyed !== null) return keyed;
|
||||
const objectsRaw = record(root["com.apple.ibtool.document.objects"]);
|
||||
const connectionsRaw = record(root["com.apple.ibtool.document.connections"]);
|
||||
const hierarchyRaw = root["com.apple.ibtool.document.hierarchy"];
|
||||
const classesRaw = record(root["com.apple.ibtool.document.classes"]);
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let omittedConnections = 0;
|
||||
const objectEntries = Object.entries(objectsRaw);
|
||||
for (const [id, raw] of objectEntries.slice(0, 20_000)) {
|
||||
const attributes = record(raw);
|
||||
const className = firstString(
|
||||
attributes["customClass"],
|
||||
attributes["class"],
|
||||
attributes["isa"],
|
||||
);
|
||||
const label = firstString(
|
||||
attributes["label"],
|
||||
attributes["title"],
|
||||
attributes["name"],
|
||||
className,
|
||||
);
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id,
|
||||
kind: classifyObject(className),
|
||||
class_name: className,
|
||||
name: label ?? id,
|
||||
attributes: jsonSafeRecord(attributes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [sourceId, rawConnections] of Object.entries(connectionsRaw)) {
|
||||
if (!Array.isArray(rawConnections)) continue;
|
||||
for (const [index, raw] of rawConnections.entries()) {
|
||||
if (connections.length >= 40_000) {
|
||||
omittedConnections += rawConnections.length - index;
|
||||
break;
|
||||
}
|
||||
const item = record(raw);
|
||||
const type = firstString(item.type, item.connectionType) ?? "unknown";
|
||||
const destination = firstString(
|
||||
item["destination-id"],
|
||||
item.destinationId,
|
||||
);
|
||||
const label = firstString(item.label, item.selector, item.identifier);
|
||||
connections.push(
|
||||
connection.parse({
|
||||
id: `${sourceId}:connection:${index}`,
|
||||
kind: classifyConnection(type),
|
||||
source_id: sourceId,
|
||||
destination_id: destination,
|
||||
label,
|
||||
attributes: jsonSafeRecord(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw.slice(0, 20_000) : [],
|
||||
classes: classesRaw,
|
||||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Project class and connection objects from an NSKeyedArchiver object table. */
|
||||
const parseKeyedArchive = (
|
||||
root: Record<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | null => {
|
||||
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
|
||||
return null;
|
||||
const objectTable = root.$objects;
|
||||
const resolve = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 16) return null;
|
||||
const uid = record(value).UID;
|
||||
if (typeof uid === "number") {
|
||||
const target = objectTable[uid];
|
||||
const resolved = target === undefined ? null : resolve(target, depth + 1);
|
||||
return typeof resolved === "object" &&
|
||||
resolved !== null &&
|
||||
!Array.isArray(resolved)
|
||||
? { ...resolved, archiveUID: uid }
|
||||
: resolved;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => resolve(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") {
|
||||
const classObject = record(
|
||||
typeof record(child).UID === "number"
|
||||
? objectTable[record(child).UID as number]
|
||||
: child,
|
||||
);
|
||||
output.className = firstString(classObject.$classname);
|
||||
} else output[key] = resolve(child, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const toHierarchy = (value: unknown, depth = 0): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => toHierarchy(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) =>
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.flatMap(([, child]) => toHierarchy(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
let omittedConnections = 0;
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = firstString(resolved.className);
|
||||
if (className === null) continue;
|
||||
objectCount += 1;
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "className"),
|
||||
);
|
||||
const authoredId = firstString(fields.objectID, fields["object-id"]);
|
||||
const objectId =
|
||||
authoredId ??
|
||||
(typeof fields.archiveUID === "number"
|
||||
? String(fields.archiveUID)
|
||||
: String(index));
|
||||
const kind = classifyObject(className);
|
||||
if (objects.length < 20_000)
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id: objectId,
|
||||
kind,
|
||||
class_name: className,
|
||||
name:
|
||||
firstString(
|
||||
fields.label,
|
||||
fields.title,
|
||||
fields.identifier,
|
||||
fields.accessibilityLabel,
|
||||
className,
|
||||
) ?? `object ${String(index)}`,
|
||||
attributes: jsonSafeRecord(fields),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
|
||||
className,
|
||||
)
|
||||
) {
|
||||
const controlAction = /ControlConnector/iu.test(className);
|
||||
const source = firstObjectReference(
|
||||
controlAction ? fields.destination : fields.source,
|
||||
controlAction ? fields.to : fields.from,
|
||||
controlAction ? fields.target : fields.owner,
|
||||
);
|
||||
if (source === null) continue;
|
||||
const kind = controlAction ? "action" : classifyConnection(className);
|
||||
const parsedConnection = connection.parse({
|
||||
id: `archive:${objectId}`,
|
||||
kind,
|
||||
source_id: source,
|
||||
destination_id: firstObjectReference(
|
||||
controlAction ? fields.source : fields.destination,
|
||||
controlAction ? fields.from : fields.to,
|
||||
controlAction ? fields.owner : fields.target,
|
||||
),
|
||||
label: firstString(
|
||||
fields.label,
|
||||
fields.selector,
|
||||
fields.identifier,
|
||||
fields.action,
|
||||
),
|
||||
attributes: jsonSafeRecord(fields),
|
||||
});
|
||||
if (connections.length < 40_000) connections.push(parsedConnection);
|
||||
else omittedConnections += 1;
|
||||
}
|
||||
}
|
||||
const top = record(root.$top);
|
||||
const topObject = resolve(top.root ?? top.UITopLevelObjectsKey);
|
||||
const hierarchy = toHierarchy(topObject);
|
||||
const classes = Object.fromEntries(
|
||||
objectTable.flatMap((raw) => {
|
||||
const item = record(raw);
|
||||
const name = firstString(item.$classname);
|
||||
return name === null
|
||||
? []
|
||||
: [
|
||||
[
|
||||
name,
|
||||
{
|
||||
superclasses: Array.isArray(item.$classes) ? item.$classes : [],
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy,
|
||||
classes,
|
||||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Coerce unknown values to a string-keyed record; non-objects become `{}`. */
|
||||
export const record = (value: unknown): Record<string, unknown> =>
|
||||
recordSchema.safeParse(value).data ?? {};
|
||||
|
||||
/** First non-empty string among the candidates, otherwise null. */
|
||||
export const firstString = (...values: unknown[]): string | null => {
|
||||
for (const value of values)
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstObjectReference = (...values: unknown[]): string | null => {
|
||||
for (const value of values) {
|
||||
const direct = firstString(value);
|
||||
if (direct !== null) return direct;
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
item.identifier,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
if (objectId !== null) return objectId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const classifyObject = (
|
||||
className: string | null,
|
||||
): InterfaceBuilderObject["kind"] => {
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
if (value.includes("constraint")) return "constraint";
|
||||
if (value.includes("layoutguide")) return "layout_guide";
|
||||
if (value.includes("placeholder") || value.includes("firstresponder"))
|
||||
return "placeholder";
|
||||
if (
|
||||
value.includes("image") ||
|
||||
value.includes("color") ||
|
||||
value.includes("font")
|
||||
)
|
||||
return "resource";
|
||||
if (value.includes("external")) return "external_object";
|
||||
if (value.includes("viewcontroller") || value.includes("windowcontroller"))
|
||||
return "view_controller";
|
||||
if (
|
||||
value.includes("button") ||
|
||||
value.includes("control") ||
|
||||
value.includes("textfield") ||
|
||||
value.includes("slider") ||
|
||||
value.includes("menuitem")
|
||||
)
|
||||
return "control";
|
||||
if (
|
||||
value.includes("view") ||
|
||||
value.includes("window") ||
|
||||
value.includes("cell")
|
||||
)
|
||||
return "view";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const classifyConnection = (
|
||||
type: string,
|
||||
): InterfaceBuilderConnection["kind"] => {
|
||||
const value = type.toLowerCase();
|
||||
if (value.includes("outlet")) return "outlet";
|
||||
if (value.includes("action")) return "action";
|
||||
if (value.includes("segue")) return "segue";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const jsonSafeRecord = (value: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
if (serialized === undefined) return [];
|
||||
const parsed = jsonValueSchema.safeParse(JSON.parse(serialized));
|
||||
return parsed.success ? [[key, parsed.data]] : [];
|
||||
} catch (cause: unknown) {
|
||||
// Unserializable entries are omitted from the record.
|
||||
void cause;
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
Loading…
Reference in New Issue