From 8b243877c22a5550d005e7b8abf93791ce8566b9 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:42:44 +0000 Subject: [PATCH] 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 --- src/application/InterfaceBuilderAnalysis.ts | 56 +++++++----- src/domain/interfaceBuilderKeyedArchive.ts | 6 +- .../filesystem/interfaceBuilderBundle.test.ts | 89 +++++++++++++++++++ 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 3bf869165..07f288f70 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -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 = ( diff --git a/src/domain/interfaceBuilderKeyedArchive.ts b/src/domain/interfaceBuilderKeyedArchive.ts index 24378a75c..6d43ca57e 100644 --- a/src/domain/interfaceBuilderKeyedArchive.ts +++ b/src/domain/interfaceBuilderKeyedArchive.ts @@ -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") || diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1010b87ca..ef0cda84b 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -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", + ); + }); +});