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:
parent
53bfa7e3f7
commit
8b243877c2
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue