From b08228d19f8a0f6dbc940e8d322ef2bb42d2ce1a Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:39:27 +0000 Subject: [PATCH 1/5] fix(artifacts): project compiled AppKit actions from the control to its target NSNibControlConnector archives the sending control as NSSource and the action target as NSDestination; a nil target is the first responder. The NIBArchive projection swapped them for control connectors, so every compiled AppKit action ran from its target to the control. The action trace, which follows control -> action, could not find them, and the selector node was keyed by the control instead of the target class. When the target was nil, the swapped source was null and the action was dropped: Chess lost 29 menu actions, KeepingYouAwake 7. Connectors now keep NSSource -> NSDestination. The NIBArchive test encoder moves to a fixture module so a bundle test can build compiled NIBs. Co-Authored-By: Claude Opus 5.5 --- src/application/InterfaceBuilderAnalysis.ts | 14 ++- src/artifacts/NibArchive.fixture.ts | 94 ++++++++++++++++++ src/artifacts/NibArchive.test.ts | 95 +------------------ .../filesystem/interfaceBuilderBundle.test.ts | 79 +++++++++++++++ 4 files changed, 180 insertions(+), 102 deletions(-) create mode 100644 src/artifacts/NibArchive.fixture.ts diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 697a1fd8f..3bf869165 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -255,16 +255,14 @@ const projectNibArchive = (archive: NibArchiveDocument) => { : className.includes("Control") ? "action" : className; - const controlAction = className.includes("Control"); - const connectionSource = controlAction ? destination : source; - const connectionDestination = controlAction ? source : destination; - if (connectionSource === null) continue; - (connections[String(connectionSource)] ??= []).push({ + // 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": - connectionDestination === null ? null : String(connectionDestination), + "destination-id": destination === null ? null : String(destination), label, - source_id: String(connectionSource), + source_id: String(source), archive_object_id: String(object.id), }); } diff --git a/src/artifacts/NibArchive.fixture.ts b/src/artifacts/NibArchive.fixture.ts new file mode 100644 index 000000000..09f2d6e2c --- /dev/null +++ b/src/artifacts/NibArchive.fixture.ts @@ -0,0 +1,94 @@ +/** Encode a minimal NIBArchive object table for decoder and projection tests. */ +export const encodeNibArchiveFixture = (input: { + readonly classes: readonly string[]; + readonly objects: readonly { + readonly classIndex: number; + readonly values: Readonly< + Record + >; + }[]; +}): Buffer => { + const keys = [ + ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), + ]; + const keyIndex = new Map(keys.map((key, index) => [key, index])); + const valueRecords = input.objects.flatMap(({ values }) => + Object.entries(values).map(([key, value]) => + Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), + ), + ); + let valueStart = 0; + const records = input.objects.map(({ classIndex, values }) => { + const count = Object.keys(values).length; + const result = Buffer.concat([ + varint(classIndex), + varint(valueStart), + varint(count), + ]); + valueStart += count; + return result; + }); + const objects = Buffer.concat(records); + const encodedKeys = Buffer.concat( + keys.map((key) => + Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]), + ), + ); + const values = Buffer.concat(valueRecords); + const classes = Buffer.concat( + input.classes.map((name) => { + const bytes = Buffer.from(name); + return Buffer.concat([varint(bytes.length), varint(0), bytes]); + }), + ); + const objectsOffset = 50; + const keysOffset = objectsOffset + objects.length; + const valuesOffset = keysOffset + encodedKeys.length; + const classesOffset = valuesOffset + values.length; + const header = Buffer.alloc(50); + header.write("NIBArchive", 0, "ascii"); + header.writeUInt32LE(1, 10); + header.writeUInt32LE(10, 14); + header.writeUInt32LE(input.objects.length, 18); + header.writeUInt32LE(objectsOffset, 22); + header.writeUInt32LE(keys.length, 26); + header.writeUInt32LE(keysOffset, 30); + header.writeUInt32LE(valueStart, 34); + header.writeUInt32LE(valuesOffset, 38); + header.writeUInt32LE(input.classes.length, 42); + header.writeUInt32LE(classesOffset, 46); + return Buffer.concat([header, objects, encodedKeys, values, classes]); +}; + +const varint = (value: number): Buffer => { + const bytes: number[] = []; + let remaining = value; + do { + let byte = remaining & 0x7f; + remaining >>>= 7; + if (remaining === 0) byte |= 0x80; + bytes.push(byte); + } while (remaining > 0); + return Buffer.from(bytes); +}; + +const encodeValue = ( + value: number | string | boolean | null | { ref: number }, +): Buffer => { + if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]); + if (value === null) return Buffer.from([9]); + if (typeof value === "number") { + const bytes = Buffer.alloc(5); + bytes[0] = 2; + bytes.writeInt32LE(value, 1); + return bytes; + } + if (typeof value === "object") { + const bytes = Buffer.alloc(5); + bytes[0] = 10; + bytes.writeUInt32LE(value.ref, 1); + return bytes; + } + const data = Buffer.from(value); + return Buffer.concat([Buffer.from([8]), varint(data.length), data]); +}; diff --git a/src/artifacts/NibArchive.test.ts b/src/artifacts/NibArchive.test.ts index f9b4fac92..a77121785 100644 --- a/src/artifacts/NibArchive.test.ts +++ b/src/artifacts/NibArchive.test.ts @@ -1,100 +1,7 @@ import { describe, expect, it } from "vitest"; import { decodeNibArchive } from "./NibArchive.js"; - -const encodeArchive = (input: { - readonly classes: readonly string[]; - readonly objects: readonly { - readonly classIndex: number; - readonly values: Readonly< - Record - >; - }[]; -}): Buffer => { - const keys = [ - ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), - ]; - const keyIndex = new Map(keys.map((key, index) => [key, index])); - const valueRecords = input.objects.flatMap(({ values }) => - Object.entries(values).map(([key, value]) => - Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), - ), - ); - let valueStart = 0; - const records = input.objects.map(({ classIndex, values }) => { - const count = Object.keys(values).length; - const result = Buffer.concat([ - varint(classIndex), - varint(valueStart), - varint(count), - ]); - valueStart += count; - return result; - }); - const objects = Buffer.concat(records); - const encodedKeys = Buffer.concat( - keys.map((key) => - Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]), - ), - ); - const values = Buffer.concat(valueRecords); - const classes = Buffer.concat( - input.classes.map((name) => { - const bytes = Buffer.from(name); - return Buffer.concat([varint(bytes.length), varint(0), bytes]); - }), - ); - const objectsOffset = 50; - const keysOffset = objectsOffset + objects.length; - const valuesOffset = keysOffset + encodedKeys.length; - const classesOffset = valuesOffset + values.length; - const header = Buffer.alloc(50); - header.write("NIBArchive", 0, "ascii"); - header.writeUInt32LE(1, 10); - header.writeUInt32LE(10, 14); - header.writeUInt32LE(input.objects.length, 18); - header.writeUInt32LE(objectsOffset, 22); - header.writeUInt32LE(keys.length, 26); - header.writeUInt32LE(keysOffset, 30); - header.writeUInt32LE(valueStart, 34); - header.writeUInt32LE(valuesOffset, 38); - header.writeUInt32LE(input.classes.length, 42); - header.writeUInt32LE(classesOffset, 46); - return Buffer.concat([header, objects, encodedKeys, values, classes]); -}; - -const varint = (value: number): Buffer => { - const bytes: number[] = []; - let remaining = value; - do { - let byte = remaining & 0x7f; - remaining >>>= 7; - if (remaining === 0) byte |= 0x80; - bytes.push(byte); - } while (remaining > 0); - return Buffer.from(bytes); -}; - -const encodeValue = ( - value: number | string | boolean | null | { ref: number }, -): Buffer => { - if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]); - if (value === null) return Buffer.from([9]); - if (typeof value === "number") { - const bytes = Buffer.alloc(5); - bytes[0] = 2; - bytes.writeInt32LE(value, 1); - return bytes; - } - if (typeof value === "object") { - const bytes = Buffer.alloc(5); - bytes[0] = 10; - bytes.writeUInt32LE(value.ref, 1); - return bytes; - } - const data = Buffer.from(value); - return Buffer.concat([Buffer.from([8]), varint(data.length), data]); -}; +import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js"; describe("NIBArchive decoder", () => { it("decodes bounded object, key, class, and reference tables", () => { diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 77c8ef7aa..fecd242a0 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -8,6 +8,7 @@ import { buildBinary } from "plist"; import { describe, expect, it } from "vitest"; import { analyzeInterfaceBuilderBundle } from "../../../src/application/InterfaceBuilderAnalysis.js"; +import { encodeNibArchiveFixture } from "../../../src/artifacts/NibArchive.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const compile = promisify(execFile); @@ -71,6 +72,84 @@ describe("compiled Interface Builder bundle reader", () => { ); }); + it("projects compiled AppKit actions from the control to their target", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // NSNibControlConnector archives the sending control as NSSource and its + // target as NSDestination; a nil target is the first responder. + await writeFile( + join(resources, "Panel.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSNibExternalObjectPlaceholder", + "NSButton", + "NSNibControlConnector", + "NSString", + "NSMenuItem", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 1 }, + NSDestination: { ref: 0 }, + NSLabel: { ref: 3 }, + }, + }, + { classIndex: 3, values: { "NS.bytes": "doOK:" } }, + { classIndex: 4, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 4 }, + NSDestination: null, + NSLabel: { ref: 6 }, + }, + }, + { classIndex: 3, values: { "NS.bytes": "arrangeInFront:" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node])); + const routes = analysis.graph.nodes + .filter(({ kind }) => kind === "action") + .map((action) => ({ + action: action.name, + from: analysis.graph.edges + .filter( + ({ relation, to }) => + relation === "target_action" && to === action.id, + ) + .map(({ from }) => byId.get(from)?.name), + to: analysis.graph.edges + .filter( + ({ relation, from, to }) => + relation === "target_action" && + from === action.id && + (to === null || byId.get(to)?.kind !== "objc_selector"), + ) + .map(({ to }) => (to === null ? null : byId.get(to)?.name)), + })) + .sort((left, right) => left.action.localeCompare(right.action)); + expect(routes).toEqual([ + { action: "arrangeInFront:", from: ["NSMenuItem"], to: [null] }, + { + action: "doOK:", + from: ["NSButton"], + to: ["NSNibExternalObjectPlaceholder"], + }, + ]); + }); + it("honors cancellation during directory traversal", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const controller = new AbortController(); From c60b737f7db8e2c9a1982cdb4357fcda1f47ff08 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:46:57 +0000 Subject: [PATCH 2/5] test(artifacts): report observed action routes when a compiled route is missing Co-Authored-By: Claude Opus 5.5 --- .../filesystem/interfaceBuilderBundle.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index fecd242a0..1010b87ca 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -234,7 +234,18 @@ describe("bounded Interface Builder archive decoding", () => { analysis.graph.nodes.find(({ id }) => id === from)?.kind === "control", ); - expect(actionSource).toBeDefined(); + const describeNode = (id: string | null) => { + const node = analysis.graph.nodes.find((item) => item.id === id); + return node === undefined ? String(id) : `${node.kind}:${node.name}`; + }; + const actionRoutes = analysis.graph.edges + .filter( + ({ relation, from, to }) => + relation === "target_action" && + (from === action?.id || to === action?.id), + ) + .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); + expect(actionSource, actionRoutes.join("; ")).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => From d421d3f286ce515b5126859a0f9cac60667a168e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:00:41 +0000 Subject: [PATCH 3/5] test(artifacts): report compiled connector records when a storyboard route is missing Co-Authored-By: Claude Opus 5.5 --- .../filesystem/interfaceBuilderBundle.test.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1010b87ca..1ffd7e1b6 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -245,7 +245,55 @@ describe("bounded Interface Builder archive decoding", () => { (from === action?.id || to === action?.id), ) .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); - expect(actionSource, actionRoutes.join("; ")).toBeDefined(); + // Temporary diagnostic: the compiled connector records. + const { decodeNibArchive } = + await import("../../../src/artifacts/NibArchive.js"); + const { readdir, readFile } = await import("node:fs/promises"); + const compiled = join(resources, "MacFixture.storyboardc"); + const records: string[] = []; + for (const entry of await readdir(compiled, { recursive: true })) { + if (!String(entry).endsWith(".nib")) continue; + let bytes: Buffer; + try { + bytes = await readFile(join(compiled, String(entry))); + } catch { + continue; + } + if (bytes.subarray(0, 10).toString("ascii") !== "NIBArchive") { + records.push( + `${String(entry)}: ${bytes.subarray(0, 8).toString("ascii")}`, + ); + continue; + } + const archive = decodeNibArchive(bytes); + const byId = new Map(archive.objects.map((item) => [item.id, item])); + const named = (value: unknown) => { + const ref = + typeof value === "object" && + value !== null && + "$nib_object_ref" in value + ? Number(value.$nib_object_ref) + : undefined; + return ref === undefined + ? JSON.stringify(value) + : `${byId.get(ref)?.class_name.replace(/\0+$/u, "") ?? "?"}#${String(ref)}`; + }; + for (const item of archive.objects) { + const name = item.class_name.replace(/\0+$/u, ""); + if (!/Connector|Placeholder/u.test(name)) continue; + records.push( + `${String(entry)}#${String(item.id)} ${name} {${Object.entries( + item.values, + ) + .map(([key, value]) => `${key}=${named(value)}`) + .join(", ")}}`, + ); + } + } + expect( + actionSource, + `${actionRoutes.join("; ")} | ${records.join(" | ")}`, + ).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => From 53bfa7e3f792cdb9f2e95fc954f3616426414bcc Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:06:36 +0000 Subject: [PATCH 4/5] test(artifacts): write the storyboard fixture's action in Cocoa syntax MacFixture.storyboard declared its button action with the iOS attribute, . Cocoa documents name an action's receiver with target=. ibtool compiled the iOS spelling into an NSNibControlConnector whose NSSource is the view controller placeholder and whose NSDestination is the button. That is the reverse of what Xcode-authored AppKit NIBs contain (Music, Chess, KeepingYouAwake, and Transmission all archive NSSource as the control), and it is what the old inversion had matched. With target= the compiled fixture archives the button as NSSource. The temporary connector dump is removed; the observed routes remain in the assertion message. Co-Authored-By: Claude Opus 5.5 --- .../filesystem/interfaceBuilderBundle.test.ts | 50 +------------------ .../interface-builder/MacFixture.storyboard | 2 +- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1ffd7e1b6..1010b87ca 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -245,55 +245,7 @@ describe("bounded Interface Builder archive decoding", () => { (from === action?.id || to === action?.id), ) .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); - // Temporary diagnostic: the compiled connector records. - const { decodeNibArchive } = - await import("../../../src/artifacts/NibArchive.js"); - const { readdir, readFile } = await import("node:fs/promises"); - const compiled = join(resources, "MacFixture.storyboardc"); - const records: string[] = []; - for (const entry of await readdir(compiled, { recursive: true })) { - if (!String(entry).endsWith(".nib")) continue; - let bytes: Buffer; - try { - bytes = await readFile(join(compiled, String(entry))); - } catch { - continue; - } - if (bytes.subarray(0, 10).toString("ascii") !== "NIBArchive") { - records.push( - `${String(entry)}: ${bytes.subarray(0, 8).toString("ascii")}`, - ); - continue; - } - const archive = decodeNibArchive(bytes); - const byId = new Map(archive.objects.map((item) => [item.id, item])); - const named = (value: unknown) => { - const ref = - typeof value === "object" && - value !== null && - "$nib_object_ref" in value - ? Number(value.$nib_object_ref) - : undefined; - return ref === undefined - ? JSON.stringify(value) - : `${byId.get(ref)?.class_name.replace(/\0+$/u, "") ?? "?"}#${String(ref)}`; - }; - for (const item of archive.objects) { - const name = item.class_name.replace(/\0+$/u, ""); - if (!/Connector|Placeholder/u.test(name)) continue; - records.push( - `${String(entry)}#${String(item.id)} ${name} {${Object.entries( - item.values, - ) - .map(([key, value]) => `${key}=${named(value)}`) - .join(", ")}}`, - ); - } - } - expect( - actionSource, - `${actionRoutes.join("; ")} | ${records.join(" | ")}`, - ).toBeDefined(); + expect(actionSource, actionRoutes.join("; ")).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => diff --git a/tests/fixtures/interface-builder/MacFixture.storyboard b/tests/fixtures/interface-builder/MacFixture.storyboard index 995dc1d12..f9fe157f9 100644 --- a/tests/fixtures/interface-builder/MacFixture.storyboard +++ b/tests/fixtures/interface-builder/MacFixture.storyboard @@ -11,7 +11,7 @@ <connections> - <action selector="buildTapped:" destination="vc1" id="action1"/> + <action selector="buildTapped:" target="vc1" id="action1"/> </connections> </button> </subviews> 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 5/5] 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> --- 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", + ); + }); +});