fix(artifacts): project UIKit runtime outlet and event connections

Compiled UIKit NIBs and storyboards archive outlets and control actions
as UIRuntimeOutletConnection and UIRuntimeEventConnection with
UISource, UIDestination, and UILabel. The NIBArchive projection
recognized only AppKit *Connector classes with NS-prefixed keys, so an
iOS app produced no outlet or action edges: Stash's 64 decoded documents
yielded none. These connections are now projected with the same
source -> destination roles, an event connection carries its
UIEventMask as ui_event_mask, UIClassSwapper reports its UIClassName,
UIProxyObject is a placeholder, and connection records are no longer
emitted as unknown objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-07 08:42:44 +00:00
parent 53bfa7e3f7
commit 8b243877c2
3 changed files with 127 additions and 24 deletions

View File

@ -192,15 +192,17 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
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)
if (storedClass === "NSClassSwapper")
return referencedString(object.values.NSClassName) ?? storedClass;
return storedClass === "UIClassSwapper"
? (referencedString(object.values.UIClassName) ?? 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(
return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector|UIRuntime\w*Connection)$/u.test(
name,
);
})
@ -246,26 +248,34 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
};
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),
});
}
// AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and
// event connections use UI-prefixed keys with the same roles.
const uiKit = /^UIRuntime\w*Connection$/u.test(className);
if (!uiKit && !className.includes("Connector")) continue;
const values = object.values;
const source = reference(uiKit ? values.UISource : values.NSSource);
if (source === null) continue;
const destination = reference(
uiKit ? values.UIDestination : values.NSDestination,
);
const label = referencedString(uiKit ? values.UILabel : values.NSLabel);
const type = className.includes("Outlet")
? "outlet"
: className.includes("Control") || className.includes("Event")
? "action"
: className;
const eventMask = values.UIEventMask;
// The source is the outlet owner or the sending control; the destination
// 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),
...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}),
});
}
let hierarchyOmitted = 0;
const hierarchyFor = (

View File

@ -308,7 +308,11 @@ const classifyObject = (
const value = className?.toLowerCase() ?? "";
if (value.includes("constraint")) return "constraint";
if (value.includes("layoutguide")) return "layout_guide";
if (value.includes("placeholder") || value.includes("firstresponder"))
if (
value.includes("placeholder") ||
value.includes("firstresponder") ||
value === "uiproxyobject"
)
return "placeholder";
if (
value.includes("image") ||

View File

@ -268,3 +268,92 @@ describe("bounded Interface Builder archive decoding", () => {
},
);
});
describe("compiled UIKit NIB connections", () => {
it("projects UIKit runtime outlet and event connections", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
await writeFile(
join(bundle, "Cell.nib"),
encodeNibArchiveFixture({
classes: [
"UIProxyObject",
"UIClassSwapper",
"UIButton",
"UIRuntimeEventConnection",
"UIRuntimeOutletConnection",
"NSString",
],
objects: [
{ classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } },
{ classIndex: 1, values: { UIClassName: { ref: 6 } } },
{ classIndex: 2, values: {} },
{
classIndex: 3,
values: {
UISource: { ref: 2 },
UIDestination: { ref: 1 },
UILabel: { ref: 7 },
UIEventMask: 64,
},
},
{
classIndex: 4,
values: {
UISource: { ref: 1 },
UIDestination: { ref: 2 },
UILabel: { ref: 8 },
},
},
{ classIndex: 5, values: { "NS.bytes": "IBFilesOwner" } },
{ classIndex: 5, values: { "NS.bytes": "BuildCell" } },
{ classIndex: 5, values: { "NS.bytes": "buildTapped:" } },
{ classIndex: 5, values: { "NS.bytes": "buildButton" } },
],
}),
);
const analysis = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
});
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
const named = (id: string | null) =>
id === null ? null : (byId.get(id)?.name ?? null);
const action = analysis.graph.nodes.find(({ kind }) => kind === "action");
expect(action).toMatchObject({
name: "buildTapped:",
attributes: { ui_event_mask: 64 },
});
const actionEdges = analysis.graph.edges.filter(
({ relation }) => relation === "target_action",
);
expect(
actionEdges
.filter(({ to }) => to === action?.id)
.map(({ from }) => named(from)),
).toEqual(["UIButton"]);
expect(
actionEdges
.filter(({ from }) => from === action?.id)
.map(({ to }) => named(to)),
).toEqual(expect.arrayContaining(["BuildCell"]));
const outlet = analysis.graph.nodes.find(({ kind }) => kind === "outlet");
expect(outlet?.name).toBe("buildButton");
expect(
analysis.graph.edges
.filter(
({ relation, from }) =>
relation === "outlet_to" && from === outlet?.id,
)
.map(({ to }) => named(to)),
).toEqual(["UIButton"]);
expect(
analysis.graph.nodes.find(({ name }) => name === "IBFilesOwner")?.kind,
).toBe("placeholder");
expect(analysis.graph.nodes.map(({ name }) => name)).not.toContain(
"UIRuntimeEventConnection",
);
});
});