diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 3d2631394..247a2f02c 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -277,16 +277,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 01e14f262..a68503b3a 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(); @@ -183,7 +262,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 }) => 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>