Merge pull request #913 from morluto/fix/uikit-runtime-connections

fix(artifacts): project UIKit runtime outlet and event connections
This commit is contained in:
Kaoru0822
2026-10-07 04:00:25 -07:00
committed by GitHub
3 changed files with 127 additions and 24 deletions
+33 -23
View File
@@ -215,15 +215,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,
);
})
@@ -269,26 +271,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 = (
+5 -1
View File
@@ -394,7 +394,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") ||
@@ -340,3 +340,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",
);
});
});